diff --git a/aftman.toml b/aftman.toml index 2838662e208..f275133971a 100644 --- a/aftman.toml +++ b/aftman.toml @@ -7,7 +7,7 @@ luau-lsp = "Quenty/luau-lsp@1.58.0-quenty.1" lune = "lune-org/lune@0.10.5" mantle = "blake-mealey/mantle@0.10.7" moonwave-extractor = "UpliftGames/moonwave@1.3.0" -rojo = "quenty/rojo@7.7.0-rc.2-quenty" +rojo = "quenty/rojo@7.7.0-rc.3-quenty" run-in-roblox = "rojo-rbx/run-in-roblox@0.3.0" selene = "Kampfkarren/selene@0.29.0" stylua = "johnnymorganz/stylua@2.3.1" diff --git a/foreman.toml b/foreman.toml index bfcf4e0f32a..4e158178a6d 100644 --- a/foreman.toml +++ b/foreman.toml @@ -1,4 +1,4 @@ [tools] -rojo = { github = "quenty/rojo", version = "=7.7.0-rc.2-quenty" } +rojo = { github = "quenty/rojo", version = "=7.7.0-rc.3-quenty" } luau-lsp = { github = "quenty/luau-lsp", version = "=1.58.0-quenty.1" } stylua = { github = "johnnymorganz/stylua", version = "=2.3.1" } \ No newline at end of file diff --git a/games/integration/aftman.toml b/games/integration/aftman.toml index 848ec39b949..03d37f9c7d0 100644 --- a/games/integration/aftman.toml +++ b/games/integration/aftman.toml @@ -3,6 +3,6 @@ [tools] luau-lsp = "Quenty/luau-lsp@1.58.0-quenty.1" moonwave-extractor = "UpliftGames/moonwave@1.3.0" -rojo = "quenty/rojo@7.7.0-rc.2-quenty" +rojo = "quenty/rojo@7.7.0-rc.3-quenty" selene = "Kampfkarren/selene@0.29.0" stylua = "johnnymorganz/stylua@2.3.1" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d456d4d59f4..b9083162a5f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1736,6 +1736,15 @@ importers: '@quenty/loader': specifier: workspace:* version: link:../loader + '@quenty/nevermore-test-runner': + specifier: workspace:* + version: link:../nevermore-test-runner + '@quenty/time': + specifier: workspace:* + version: link:../time + '@quentystudios/jest-lua': + specifier: 3.10.0-quenty.2 + version: 3.10.0-quenty.2 src/counter: dependencies: @@ -1841,12 +1850,18 @@ importers: '@quenty/binder': specifier: workspace:* version: link:../binder + '@quenty/brio': + specifier: workspace:* + version: link:../brio '@quenty/characterutils': specifier: workspace:* version: link:../characterutils '@quenty/humanoidkillerutils': specifier: workspace:* version: link:../humanoidkillerutils + '@quenty/jestutils': + specifier: workspace:* + version: link:../jest-utils '@quenty/loader': specifier: workspace:* version: link:../loader @@ -1856,9 +1871,15 @@ importers: '@quenty/nevermore-test-runner': specifier: workspace:* version: link:../nevermore-test-runner + '@quenty/playerbinder': + specifier: workspace:* + version: link:../playerbinder '@quenty/playerhumanoidbinder': specifier: workspace:* version: link:../playerhumanoidbinder + '@quenty/playermock': + specifier: workspace:* + version: link:../player-mock '@quenty/remoting': specifier: workspace:* version: link:../remoting @@ -1877,6 +1898,12 @@ importers: '@quenty/table': specifier: workspace:* version: link:../table + '@quenty/tie': + specifier: workspace:* + version: link:../tie + '@quenty/valuebaseutils': + specifier: workspace:* + version: link:../valuebaseutils '@quentystudios/jest-lua': specifier: 3.10.0-quenty.2 version: 3.10.0-quenty.2 @@ -5109,6 +5136,9 @@ importers: '@quenty/tie': specifier: workspace:* version: link:../tie + '@quenty/time': + specifier: workspace:* + version: link:../time '@quenty/valueobject': specifier: workspace:* version: link:../valueobject @@ -6307,7 +6337,20 @@ importers: specifier: workspace:* version: link:../signal - src/time: {} + src/time: + dependencies: + '@quenty/clienttranslator': + specifier: workspace:* + version: link:../clienttranslator + '@quenty/loader': + specifier: workspace:* + version: link:../loader + '@quenty/nevermore-test-runner': + specifier: workspace:* + version: link:../nevermore-test-runner + '@quentystudios/jest-lua': + specifier: 3.10.0-quenty.2 + version: 3.10.0-quenty.2 src/timedtween: dependencies: @@ -6908,6 +6951,9 @@ importers: typescript: specifier: ^5.9.3 version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/node@18.19.130)(yaml@2.8.3) tools/nevermore-vscode: devDependencies: diff --git a/src/binder/src/Shared/Binder.lua b/src/binder/src/Shared/Binder.lua index e86749f72fe..3c42887cd41 100644 --- a/src/binder/src/Shared/Binder.lua +++ b/src/binder/src/Shared/Binder.lua @@ -698,6 +698,12 @@ function Binder._add(self: Binder, inst: Instance) tostring(type(constructor) == "table" and constructor.ClassName or constructor) ) ) + if MaidTaskUtils.isValidTask(class) then + task.spawn(function() + MaidTaskUtils.doTask(class) + end) + end + return end diff --git a/src/clienttranslator/src/Shared/Numbers/NumberLocalizationOrdinalUtils.lua b/src/clienttranslator/src/Shared/Numbers/NumberLocalizationOrdinalUtils.lua new file mode 100644 index 00000000000..d6ff7ba8c32 --- /dev/null +++ b/src/clienttranslator/src/Shared/Numbers/NumberLocalizationOrdinalUtils.lua @@ -0,0 +1,158 @@ +--!strict +--[=[ + Localizes ordinal numbers, for example `22nd` in English, `22e` in French, `22.` in German or + `第22` in Chinese. Rules are keyed by language subtag and resolved through [ResolveLocaleUtils], + so regional variants such as `en-gb` or `es-mx` land on their language's rule. Locales without + a rule fall back to English with a warning. + + To add a language, add one entry to `ORDINAL_RULES`. Every field is required. + + @class NumberLocalizationOrdinalUtils +]=] + +local require = require(script.Parent.loader).load(script) + +local ResolveLocaleUtils = require("ResolveLocaleUtils") + +local NumberLocalizationOrdinalUtils = {} + +local DEFAULT_LOCALE = "en-us" + +--[=[ + How a language writes an ordinal: `prefix .. number .. suffix(number)`. + + @interface OrdinalRule + .prefix string + .suffix (number) -> string + @within NumberLocalizationOrdinalUtils +]=] +export type OrdinalRule = { + prefix: string, + suffix: (number) -> string, +} + +function NumberLocalizationOrdinalUtils._constantSuffix(suffix: string): (number) -> string + return function(_number: number): string + return suffix + end +end + +function NumberLocalizationOrdinalUtils._englishSuffix(number: number): string + local hundredRemainder = number % 100 + if hundredRemainder >= 11 and hundredRemainder <= 13 then + return "th" + end + + local tenRemainder = number % 10 + if tenRemainder == 1 then + return "st" + elseif tenRemainder == 2 then + return "nd" + elseif tenRemainder == 3 then + return "rd" + else + return "th" + end +end + +function NumberLocalizationOrdinalUtils._frenchSuffix(number: number): string + return if number == 1 then "er" else "e" +end + +function NumberLocalizationOrdinalUtils._swedishSuffix(number: number): string + local tenRemainder = number % 10 + local hundredRemainder = number % 100 + local isTeen = hundredRemainder >= 11 and hundredRemainder <= 12 + return if (tenRemainder == 1 or tenRemainder == 2) and not isTeen then ":a" else ":e" +end + +local ORDINAL_INDICATOR = NumberLocalizationOrdinalUtils._constantSuffix("º") +local TRAILING_PERIOD = NumberLocalizationOrdinalUtils._constantSuffix(".") +local NO_SUFFIX = NumberLocalizationOrdinalUtils._constantSuffix("") + +local ORDINAL_RULES: { [string]: OrdinalRule } = { + en = { prefix = "", suffix = NumberLocalizationOrdinalUtils._englishSuffix }, + fr = { prefix = "", suffix = NumberLocalizationOrdinalUtils._frenchSuffix }, + sv = { prefix = "", suffix = NumberLocalizationOrdinalUtils._swedishSuffix }, + nl = { prefix = "", suffix = NumberLocalizationOrdinalUtils._constantSuffix("e") }, + + -- Masculine ordinal indicator (1º, 2º) + es = { prefix = "", suffix = ORDINAL_INDICATOR }, + pt = { prefix = "", suffix = ORDINAL_INDICATOR }, + it = { prefix = "", suffix = ORDINAL_INDICATOR }, + + -- Trailing period (1., 2.) + de = { prefix = "", suffix = TRAILING_PERIOD }, + pl = { prefix = "", suffix = TRAILING_PERIOD }, + tr = { prefix = "", suffix = TRAILING_PERIOD }, + fi = { prefix = "", suffix = TRAILING_PERIOD }, + da = { prefix = "", suffix = TRAILING_PERIOD }, + nb = { prefix = "", suffix = TRAILING_PERIOD }, + no = { prefix = "", suffix = TRAILING_PERIOD }, + cs = { prefix = "", suffix = TRAILING_PERIOD }, + hu = { prefix = "", suffix = TRAILING_PERIOD }, + + -- Masculine digit ordinal (1-й, 22-й) + ru = { prefix = "", suffix = NumberLocalizationOrdinalUtils._constantSuffix("-й") }, + + -- Ordinal prefix (第22, 제22, ke-22, thứ 22, ที่ 22) + zh = { prefix = "第", suffix = NO_SUFFIX }, + ja = { prefix = "第", suffix = NO_SUFFIX }, + ko = { prefix = "제", suffix = NO_SUFFIX }, + id = { prefix = "ke-", suffix = NO_SUFFIX }, + vi = { prefix = "thứ ", suffix = NO_SUFFIX }, + th = { prefix = "ที่ ", suffix = NO_SUFFIX }, + + -- Arabic writes digit ordinals as the bare number + ar = { prefix = "", suffix = NO_SUFFIX }, +} + +for _, rule in pairs(ORDINAL_RULES) do + table.freeze(rule) +end + +function NumberLocalizationOrdinalUtils._resolveRuleOrDefault(locale: string?): OrdinalRule + local key = ResolveLocaleUtils.resolveClosestKey(locale, ORDINAL_RULES) + if key then + return ORDINAL_RULES[key] + end + + warn( + string.format( + "[NumberLocalizationOrdinalUtils] - No ordinal rule for locale '%s', reverting to '%s' instead.", + tostring(locale), + DEFAULT_LOCALE + ) + ) + return ORDINAL_RULES[ResolveLocaleUtils.resolveClosestKey(DEFAULT_LOCALE, ORDINAL_RULES) :: string] +end + +--[=[ + Returns the localized ordinal form of a whole number. + + ```lua + print(NumberLocalizationOrdinalUtils.localize(22, "en-us")) --> 22nd + print(NumberLocalizationOrdinalUtils.localize(1, "fr-fr")) --> 1er + print(NumberLocalizationOrdinalUtils.localize(22, "zh-cn")) --> 第22 + ``` +]=] +function NumberLocalizationOrdinalUtils.localize(number: number, locale: string): string + assert(type(number) == "number", "Bad number") + + local rule = NumberLocalizationOrdinalUtils._resolveRuleOrDefault(locale) + return rule.prefix .. tostring(number) .. rule.suffix(number) +end + +--[=[ + Returns only the part of the localized ordinal that follows the number, for example `nd` for + 22 in English or `.` in German. Languages that mark ordinals with a prefix (Chinese, Japanese, + Korean, Indonesian, Vietnamese, Thai) have an empty suffix; use + [NumberLocalizationOrdinalUtils.localize] for the whole form. +]=] +function NumberLocalizationOrdinalUtils.getSuffix(number: number, locale: string): string + assert(type(number) == "number", "Bad number") + + return NumberLocalizationOrdinalUtils._resolveRuleOrDefault(locale).suffix(number) +end + +return NumberLocalizationOrdinalUtils diff --git a/src/clienttranslator/src/Shared/Numbers/NumberLocalizationOrdinalUtils.spec.lua b/src/clienttranslator/src/Shared/Numbers/NumberLocalizationOrdinalUtils.spec.lua new file mode 100644 index 00000000000..3b3b662a4f7 --- /dev/null +++ b/src/clienttranslator/src/Shared/Numbers/NumberLocalizationOrdinalUtils.spec.lua @@ -0,0 +1,91 @@ +--!strict +local require = require(script.Parent.loader).load(script) + +local Jest = require("Jest") +local NumberLocalizationOrdinalUtils = require("NumberLocalizationOrdinalUtils") + +local describe = Jest.Globals.describe +local expect = Jest.Globals.expect +local it = Jest.Globals.it + +local function checkOrdinal(locale: string, responseMapping: { [number]: string }) + for input, output in responseMapping do + expect(NumberLocalizationOrdinalUtils.localize(input, locale)).toBe(output) + end +end + +describe("NumberLocalizationOrdinalUtils.localize", function() + it("should use st, nd, rd and th in English, including the teens", function() + checkOrdinal("en-us", { + [0] = "0th", + [1] = "1st", + [2] = "2nd", + [3] = "3rd", + [4] = "4th", + [11] = "11th", + [12] = "12th", + [13] = "13th", + [21] = "21st", + [22] = "22nd", + [23] = "23rd", + [101] = "101st", + [111] = "111th", + [112] = "112th", + }) + end) + + it("should use er for 1 and e otherwise in French", function() + checkOrdinal("fr-fr", { [1] = "1er", [2] = "2e", [21] = "21e", [22] = "22e" }) + end) + + it("should use the ordinal indicator in Spanish, Portuguese and Italian", function() + checkOrdinal("es-es", { [1] = "1º", [22] = "22º" }) + checkOrdinal("pt-br", { [1] = "1º", [22] = "22º" }) + checkOrdinal("it-it", { [1] = "1º", [22] = "22º" }) + end) + + it("should use a trailing period in German, Polish and Turkish", function() + checkOrdinal("de-de", { [1] = "1.", [22] = "22." }) + checkOrdinal("pl-pl", { [1] = "1.", [22] = "22." }) + checkOrdinal("tr-tr", { [1] = "1.", [22] = "22." }) + end) + + it("should use e in Dutch and :a / :e in Swedish", function() + checkOrdinal("nl-nl", { [1] = "1e", [22] = "22e" }) + checkOrdinal("sv-se", { [1] = "1:a", [2] = "2:a", [3] = "3:e", [11] = "11:e", [12] = "12:e", [21] = "21:a" }) + end) + + it("should prefix in Chinese, Japanese and Korean", function() + checkOrdinal("zh-cn", { [1] = "第1", [22] = "第22" }) + checkOrdinal("zh-tw", { [22] = "第22" }) + checkOrdinal("ja-jp", { [22] = "第22" }) + checkOrdinal("ko-kr", { [22] = "제22" }) + end) + + it("should cover the remaining NumberLocalizationUtils languages", function() + checkOrdinal("ru-ru", { [1] = "1-й", [22] = "22-й" }) + checkOrdinal("id-id", { [22] = "ke-22" }) + checkOrdinal("vi-vn", { [22] = "thứ 22" }) + checkOrdinal("th-th", { [22] = "ที่ 22" }) + checkOrdinal("ar", { [22] = "22" }) + end) + + it("should resolve regional variants to their language", function() + checkOrdinal("en-gb", { [22] = "22nd" }) + checkOrdinal("es-mx", { [22] = "22º" }) + checkOrdinal("fr-ca", { [1] = "1er" }) + end) + + it("should fall back to English for an unknown locale", function() + checkOrdinal("xx-yy", { [22] = "22nd" }) + end) +end) + +describe("NumberLocalizationOrdinalUtils.getSuffix", function() + it("should return only the suffix", function() + expect(NumberLocalizationOrdinalUtils.getSuffix(22, "en-us")).toBe("nd") + expect(NumberLocalizationOrdinalUtils.getSuffix(1, "fr-fr")).toBe("er") + expect(NumberLocalizationOrdinalUtils.getSuffix(22, "de-de")).toBe(".") + expect(NumberLocalizationOrdinalUtils.getSuffix(22, "zh-cn")).toBe("") + end) +end) diff --git a/src/countdowntext/deploy.nevermore.json b/src/countdowntext/deploy.nevermore.json new file mode 100644 index 00000000000..30cc23ade16 --- /dev/null +++ b/src/countdowntext/deploy.nevermore.json @@ -0,0 +1,10 @@ +{ + "targets": { + "test": { + "universeId": 9716264427, + "placeId": 100977825723862, + "project": "test/default.project.json", + "scriptTemplate": "test/scripts/Server/ServerMain.server.lua" + } + } +} diff --git a/src/countdowntext/package.json b/src/countdowntext/package.json index 0a9d4bc82a0..5a1b45fd908 100644 --- a/src/countdowntext/package.json +++ b/src/countdowntext/package.json @@ -28,7 +28,10 @@ "Quenty" ], "dependencies": { - "@quenty/loader": "workspace:*" + "@quenty/loader": "workspace:*", + "@quenty/nevermore-test-runner": "workspace:*", + "@quenty/time": "workspace:*", + "@quentystudios/jest-lua": "3.10.0-quenty.2" }, "publishConfig": { "access": "public" diff --git a/src/countdowntext/src/Shared/CountdownTextUtils.lua b/src/countdowntext/src/Shared/CountdownTextUtils.lua index 5244dc11c44..9df6066e24d 100644 --- a/src/countdowntext/src/Shared/CountdownTextUtils.lua +++ b/src/countdowntext/src/Shared/CountdownTextUtils.lua @@ -1,55 +1,50 @@ --!strict --[=[ - Utility functions to format countdowns in-game + Utility functions to format countdowns in-game, built on [TimeDurationUtils]. @class CountdownTextUtils ]=] +local require = require(script.Parent.loader).load(script) + +local TimeDurationUtils = require("TimeDurationUtils") + local CountdownTextUtils = {} --[=[ - Formats countdown text - - @param seconds number - @param whenAtZeroText string? - @return string + Formats a number of seconds as countdown text, showing only as many units as the remaining + time needs: `45`, then `3:05`, then `1:02:03`, and `2 days 1:02:03` for longer waits. Each unit + counts up to a round number before the next is used, so a one minute countdown starts at `60`, + a one hour countdown at `60:00`, and a single day rolls into the hours as `47:15:00`. + The `days` word is localized through [TimeDurationUtils.format]; `locale` defaults to English. + Fractional seconds are truncated. + + ```lua + print(CountdownTextUtils.formatCountdown(0, "Now!")) --> Now! + print(CountdownTextUtils.formatCountdown(185)) --> 3:05 + print(CountdownTextUtils.formatCountdown(3 * 86400 + 5, nil, "es-es")) --> 3 días 0:00:05 + ``` ]=] -function CountdownTextUtils.formatCountdown(seconds: number, whenAtZeroText: string?): string +function CountdownTextUtils.formatCountdown(seconds: number, whenAtZeroText: string?, locale: string?): string assert(type(seconds) == "number", "Bad seconds") assert(type(whenAtZeroText) == "string" or whenAtZeroText == nil, "Bad whenAtZeroText") + assert(type(locale) == "string" or locale == nil, "Bad locale") if seconds <= 0 then return whenAtZeroText or "0" end - -- less than 1 minute - if seconds <= 60 then - return string.format("%d", seconds) - end - - -- less than 1 hour - if seconds <= 60 * 60 then - local hours = math.floor(seconds / 60) - return string.format("%0d:%02d", hours, seconds % 60) - end - - local days = math.floor(seconds / 60 / 60 / 24) - local hours = math.floor(seconds / 60 / 60) % 24 - local minutes = math.floor(seconds / 60) % 60 - - if days == 0 then - return string.format("%d:%02d:%02d", hours, minutes, seconds % 60) - elseif days == 1 then - -- People would be confused about "1 day 2:15:00" - -- So show 47:15:00 - hours = math.floor(seconds / 60 / 60) % 48 - - return string.format("%d:%02d:%02d", hours, minutes, seconds % 60) - else - -- TODO: Localize this "days" part? - - return string.format("%d days %d:%02d:%02d", days, hours, minutes, seconds % 60) - end + return TimeDurationUtils.format(seconds, "d __ h:mm:ss", { + locale = locale, + trunc = true, + -- A one minute countdown starts at 60, a one hour countdown at 60:00, and a single day + -- reads as 24 to 47 hours since "1 day 23:15:00" is easy to misread + limits = { + seconds = 60, + minutes = 60, + hours = 47, + }, + }) end return CountdownTextUtils diff --git a/src/countdowntext/src/Shared/CountdownTextUtils.spec.lua b/src/countdowntext/src/Shared/CountdownTextUtils.spec.lua new file mode 100644 index 00000000000..251965a27ce --- /dev/null +++ b/src/countdowntext/src/Shared/CountdownTextUtils.spec.lua @@ -0,0 +1,79 @@ +--!strict +local require = require(script.Parent.loader).load(script) + +local CountdownTextUtils = require("CountdownTextUtils") +local Jest = require("Jest") + +local describe = Jest.Globals.describe +local expect = Jest.Globals.expect +local it = Jest.Globals.it + +local MINUTE = 60 +local HOUR = 60 * MINUTE +local DAY = 24 * HOUR + +describe("CountdownTextUtils.formatCountdown", function() + it("should show the zero text at or below zero", function() + expect(CountdownTextUtils.formatCountdown(0)).toBe("0") + expect(CountdownTextUtils.formatCountdown(-5)).toBe("0") + expect(CountdownTextUtils.formatCountdown(0, "Now!")).toBe("Now!") + expect(CountdownTextUtils.formatCountdown(-5, "Now!")).toBe("Now!") + end) + + it("should show bare seconds under a minute", function() + expect(CountdownTextUtils.formatCountdown(1)).toBe("1") + expect(CountdownTextUtils.formatCountdown(45)).toBe("45") + expect(CountdownTextUtils.formatCountdown(59)).toBe("59") + expect(CountdownTextUtils.formatCountdown(60)).toBe("60") + end) + + it("should show minutes and seconds under an hour", function() + expect(CountdownTextUtils.formatCountdown(61)).toBe("1:01") + expect(CountdownTextUtils.formatCountdown(3 * MINUTE + 5)).toBe("3:05") + expect(CountdownTextUtils.formatCountdown(59 * MINUTE + 59)).toBe("59:59") + expect(CountdownTextUtils.formatCountdown(HOUR)).toBe("60:00") + expect(CountdownTextUtils.formatCountdown(HOUR + 59)).toBe("60:59") + expect(CountdownTextUtils.formatCountdown(HOUR + MINUTE)).toBe("1:01:00") + end) + + it("should show hours, minutes and seconds under two days", function() + expect(CountdownTextUtils.formatCountdown(HOUR + 2 * MINUTE + 3)).toBe("1:02:03") + expect(CountdownTextUtils.formatCountdown(23 * HOUR + 59 * MINUTE + 59)).toBe("23:59:59") + expect(CountdownTextUtils.formatCountdown(DAY)).toBe("24:00:00") + expect(CountdownTextUtils.formatCountdown(DAY + 23 * HOUR + 15 * MINUTE)).toBe("47:15:00") + expect(CountdownTextUtils.formatCountdown(2 * DAY - 1)).toBe("47:59:59") + end) + + it("should show days from two days on", function() + expect(CountdownTextUtils.formatCountdown(2 * DAY)).toBe("2 days 0:00:00") + expect(CountdownTextUtils.formatCountdown(2 * DAY + HOUR + 2 * MINUTE + 3)).toBe("2 days 1:02:03") + expect(CountdownTextUtils.formatCountdown(45 * DAY + 5)).toBe("45 days 0:00:05") + expect(CountdownTextUtils.formatCountdown(400 * DAY)).toBe("400 days 0:00:00") + end) + + it("should localize the days word", function() + expect(CountdownTextUtils.formatCountdown(3 * DAY + 5, nil, "es-es")).toBe("3 días 0:00:05") + expect(CountdownTextUtils.formatCountdown(3 * DAY + 5, nil, "de-de")).toBe("3 Tage 0:00:05") + expect(CountdownTextUtils.formatCountdown(5 * DAY, nil, "ru-ru")).toBe("5 дней 0:00:00") + expect(CountdownTextUtils.formatCountdown(3 * DAY, nil, "en-gb")).toBe("3 days 0:00:00") + end) + + it("should truncate fractional seconds", function() + expect(CountdownTextUtils.formatCountdown(0.5)).toBe("0") + expect(CountdownTextUtils.formatCountdown(59.9)).toBe("59") + expect(CountdownTextUtils.formatCountdown(MINUTE + 0.9)).toBe("60") + expect(CountdownTextUtils.formatCountdown(2 * DAY - 0.1)).toBe("47:59:59") + end) + + it("should reject bad arguments", function() + expect(function() + CountdownTextUtils.formatCountdown("5" :: any) + end).toThrow("Bad seconds") + expect(function() + CountdownTextUtils.formatCountdown(5, 5 :: any) + end).toThrow("Bad whenAtZeroText") + expect(function() + CountdownTextUtils.formatCountdown(5, nil, 5 :: any) + end).toThrow("Bad locale") + end) +end) diff --git a/src/countdowntext/src/jest.config.lua b/src/countdowntext/src/jest.config.lua new file mode 100644 index 00000000000..4294d00462f --- /dev/null +++ b/src/countdowntext/src/jest.config.lua @@ -0,0 +1,3 @@ +return { + testMatch = { "**/*.spec" }, +} diff --git a/src/countdowntext/test/default.project.json b/src/countdowntext/test/default.project.json index f3f2681bc68..c6ea695e7e2 100644 --- a/src/countdowntext/test/default.project.json +++ b/src/countdowntext/test/default.project.json @@ -8,6 +8,9 @@ }, "countdowntext": { "$path": ".." + }, + "Script": { + "$path": "scripts/Server" } } } diff --git a/src/countdowntext/test/scripts/Server/ServerMain.server.lua b/src/countdowntext/test/scripts/Server/ServerMain.server.lua new file mode 100644 index 00000000000..662b6d35608 --- /dev/null +++ b/src/countdowntext/test/scripts/Server/ServerMain.server.lua @@ -0,0 +1,12 @@ +--!nonstrict +local ServerScriptService = game:GetService("ServerScriptService") + +local root = ServerScriptService.countdowntext +local loader = root:FindFirstChild("LoaderUtils", true).Parent +local require = require(loader).bootstrapGame(root) + +local NevermoreTestRunnerUtils = require("NevermoreTestRunnerUtils") + +if NevermoreTestRunnerUtils.runTestsIfNeededAsync(root) then + return +end diff --git a/src/deathreport/package.json b/src/deathreport/package.json index 3601cc11b2d..c74c6c21f26 100644 --- a/src/deathreport/package.json +++ b/src/deathreport/package.json @@ -34,18 +34,24 @@ "dependencies": { "@quenty/baseobject": "workspace:*", "@quenty/binder": "workspace:*", + "@quenty/brio": "workspace:*", "@quenty/characterutils": "workspace:*", "@quenty/humanoidkillerutils": "workspace:*", + "@quenty/jestutils": "workspace:*", "@quenty/loader": "workspace:*", "@quenty/maid": "workspace:*", "@quenty/nevermore-test-runner": "workspace:*", + "@quenty/playerbinder": "workspace:*", "@quenty/playerhumanoidbinder": "workspace:*", + "@quenty/playermock": "workspace:*", "@quenty/remoting": "workspace:*", "@quenty/rx": "workspace:*", "@quenty/rxbinderutils": "workspace:*", "@quenty/servicebag": "workspace:*", "@quenty/signal": "workspace:*", "@quenty/table": "workspace:*", + "@quenty/tie": "workspace:*", + "@quenty/valuebaseutils": "workspace:*", "@quentystudios/jest-lua": "3.10.0-quenty.2" } } diff --git a/src/deathreport/src/Client/DeathReportBindersClient.lua b/src/deathreport/src/Client/DeathReportBindersClient.lua index 89add0d595a..868ff4cf373 100644 --- a/src/deathreport/src/Client/DeathReportBindersClient.lua +++ b/src/deathreport/src/Client/DeathReportBindersClient.lua @@ -1,20 +1,39 @@ --!strict --[=[ + Holds the client binders for the death report system. Kept for callers that still retrieve binders + through a provider; every binder here is the same singleton a [ServiceBag] hands out directly. + + :::tip + Binders can be retrieved directly through a [ServiceBag] now, for example + `serviceBag:GetService(require("TeamKillTrackerClient"))`. + ::: + + @client + @deprecated 10.60.0 -- Retrieve the binders directly from the ServiceBag @class DeathReportBindersClient ]=] local require = require(script.Parent.loader).load(script) -local Binder = require("Binder") local BinderProvider = require("BinderProvider") -local PlayerDeathTrackerClient = require("PlayerDeathTrackerClient") -local PlayerKillTrackerClient = require("PlayerKillTrackerClient") local ServiceBag = require("ServiceBag") -local TeamKillTrackerClient = require("TeamKillTrackerClient") -return BinderProvider.new(script.Name, function(self, serviceBag: ServiceBag.ServiceBag) - -- Stats - self:Add(Binder.new("TeamKillTracker", TeamKillTrackerClient :: any, serviceBag)) - self:Add(Binder.new("PlayerKillTracker", PlayerKillTrackerClient :: any, serviceBag)) - self:Add(Binder.new("PlayerDeathTracker", PlayerDeathTrackerClient :: any, serviceBag)) +return BinderProvider.new(script.Name, function(self: BinderProvider.BinderProvider, serviceBag: ServiceBag.ServiceBag) + --[=[ + @prop TeamKillTracker Binder + @within DeathReportBindersClient +]=] + self:Add(serviceBag:GetService(require("TeamKillTrackerClient"))) + + --[=[ + @prop PlayerKillTracker Binder + @within DeathReportBindersClient +]=] + self:Add(serviceBag:GetService(require("PlayerKillTrackerClient"))) + + --[=[ + @prop PlayerDeathTracker Binder + @within DeathReportBindersClient +]=] + self:Add(serviceBag:GetService(require("PlayerDeathTrackerClient"))) end) diff --git a/src/deathreport/src/Client/DeathReportBindersClient.spec.lua b/src/deathreport/src/Client/DeathReportBindersClient.spec.lua new file mode 100644 index 00000000000..b69803b8f62 --- /dev/null +++ b/src/deathreport/src/Client/DeathReportBindersClient.spec.lua @@ -0,0 +1,69 @@ +--!strict +--[[ + @class DeathReportBindersClient.spec.lua +]] + +local require = require(script.Parent.loader).load(script) + +local DeathReportBindersClient = require("DeathReportBindersClient") +local DeathReportServiceClient = require("DeathReportServiceClient") +local Jest = require("Jest") +local JestUtils = require("JestUtils") +local Maid = require("Maid") +local PlayerDeathTrackerClient = require("PlayerDeathTrackerClient") +local PlayerKillTrackerClient = require("PlayerKillTrackerClient") +local ServiceBag = require("ServiceBag") +local TeamKillTrackerClient = require("TeamKillTrackerClient") + +local describe = Jest.Globals.describe +local expect = Jest.Globals.expect +local it = Jest.Globals.it + +local function setup(): any + local maid = Maid.new() + + local serviceBag = ServiceBag.new() + serviceBag:GetService(DeathReportServiceClient) + local provider = serviceBag:GetService(DeathReportBindersClient) + serviceBag:Init() + serviceBag:Start() + + maid:GiveTask(function() + serviceBag:Destroy() + end) + + local controller = { + serviceBag = serviceBag, + provider = provider, + Destroy = function(_self) + maid:DoCleaning() + end, + } + + maid:GiveTask(JestUtils.afterThis(controller)) + + return controller +end + +describe("DeathReportBindersClient", function() + it("exposes the same binders the service bag hands out", function() + local controller = setup() + + expect(controller.provider.TeamKillTracker).toBe(controller.serviceBag:GetService(TeamKillTrackerClient)) + expect(controller.provider.PlayerKillTracker).toBe(controller.serviceBag:GetService(PlayerKillTrackerClient)) + expect(controller.provider.PlayerDeathTracker).toBe(controller.serviceBag:GetService(PlayerDeathTrackerClient)) + + controller:Destroy() + end) + + it("resolves binders by tag", function() + local controller = setup() + + local ok, binder = controller.provider:PromiseBinder("PlayerKillTracker"):Yield() + expect(ok).toBe(true) + expect(binder).toBe(controller.serviceBag:GetService(PlayerKillTrackerClient)) + expect(controller.provider:Get("Unknown")).toBeNil() + + controller:Destroy() + end) +end) diff --git a/src/deathreport/src/Client/DeathReportServiceClient.lua b/src/deathreport/src/Client/DeathReportServiceClient.lua index ab8b0b94d30..31740cbceec 100644 --- a/src/deathreport/src/Client/DeathReportServiceClient.lua +++ b/src/deathreport/src/Client/DeathReportServiceClient.lua @@ -1,7 +1,8 @@ --!strict --[=[ Centralized death reporting service which can be used to track - deaths. + deaths. Receives the reports [DeathReportService] replicates and feeds them to + [DeathReportDataService], which this service aliases. @client @class DeathReportServiceClient @@ -9,46 +10,67 @@ local require = require(script.Parent.loader).load(script) -local DeathReportProcessor = require("DeathReportProcessor") +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local DeathReportDataService = require("DeathReportDataService") local DeathReportServiceConstants = require("DeathReportServiceConstants") local DeathReportUtils = require("DeathReportUtils") local Maid = require("Maid") -local PromiseGetRemoteEvent = require("PromiseGetRemoteEvent") +local Observable = require("Observable") +local PlayerDeathTrackerClient = require("PlayerDeathTrackerClient") +local PlayerKillTrackerClient = require("PlayerKillTrackerClient") +local Remoting = require("Remoting") local ServiceBag = require("ServiceBag") local Signal = require("Signal") - --- Note: don't make this too big without upgrading the way we handle the queue -local MAX_DEATH_REPORTS = 5 +local TeamKillTrackerClient = require("TeamKillTrackerClient") local DeathReportServiceClient = {} DeathReportServiceClient.ServiceName = "DeathReportServiceClient" +export type DeathReportServiceClient = typeof(setmetatable( + {} :: { + _serviceBag: ServiceBag.ServiceBag, + _maid: Maid.Maid, + _dataService: DeathReportDataService.DeathReportDataService, + NewDeathReport: Signal.Signal, + _remoting: Remoting.Remoting, + }, + {} :: typeof({ __index = DeathReportServiceClient }) +)) + --[=[ Initializes the death report service for the given service bag. Should be done via [ServiceBag]. @param serviceBag ServiceBag ]=] -function DeathReportServiceClient:Init(serviceBag: ServiceBag.ServiceBag) - assert(not self._serviceBag, "Already initialized") +function DeathReportServiceClient.Init(self: DeathReportServiceClient, serviceBag: ServiceBag.ServiceBag) + assert(not (self :: any)._serviceBag, "Already initialized") self._serviceBag = assert(serviceBag, "No serviceBag") self._maid = Maid.new() -- Internal - self._serviceBag:GetService(require("DeathReportBindersClient")) - - -- State - self.NewDeathReport = self._maid:Add(Signal.new()) - - self._reportProcessor = self._maid:Add(DeathReportProcessor.new()) - self._lastDeathReports = {} - - -- Setup remote Event - self:_promiseRemoteEvent():Then(function(remoteEvent) - self._maid:GiveTask(remoteEvent.OnClientEvent:Connect(function(...) - self:_handleClientEvent(...) - end)) - end) + self._dataService = self._serviceBag:GetService(DeathReportDataService) :: any + + -- Binders + self._serviceBag:GetService(TeamKillTrackerClient) + self._serviceBag:GetService(PlayerKillTrackerClient) + self._serviceBag:GetService(PlayerDeathTrackerClient) + + --[=[ + Fires with every [DeathReport] the server replicates. Same signal as + [DeathReportDataService.NewDeathReport]. + @prop NewDeathReport Signal + @within DeathReportServiceClient +]=] + self.NewDeathReport = self._dataService.NewDeathReport + + self._remoting = self._maid:Add(Remoting.Client.new(ReplicatedStorage, DeathReportServiceConstants.REMOTING_NAME)) + self._maid:GiveTask( + self._remoting:Connect(DeathReportServiceConstants.DEATH_REPORTED_EVENT_NAME, function(deathReport) + self:_handleClientEvent(deathReport) + end) + ) end --[=[ @@ -57,10 +79,11 @@ end @param player Player @return Observable ]=] -function DeathReportServiceClient:ObservePlayerKillerReports(player: Player) - assert(typeof(player) == "Instance" and player:IsA("Player"), "Bad player") - - return self._reportProcessor:ObservePlayerKillerReports(player) +function DeathReportServiceClient.ObservePlayerKillerReports( + self: DeathReportServiceClient, + player: Player +): Observable.Observable + return self._dataService:ObservePlayerKillerReports(player) end --[=[ @@ -69,10 +92,11 @@ end @param player Player @return Observable ]=] -function DeathReportServiceClient:ObservePlayerDeathReports(player: Player) - assert(typeof(player) == "Instance" and player:IsA("Player"), "Bad player") - - return self._reportProcessor:ObservePlayerDeathReports(player) +function DeathReportServiceClient.ObservePlayerDeathReports( + self: DeathReportServiceClient, + player: Player +): Observable.Observable + return self._dataService:ObservePlayerDeathReports(player) end --[=[ @@ -81,10 +105,11 @@ end @param humanoid Humanoid @return Observable ]=] -function DeathReportServiceClient:ObserveHumanoidKillerReports(humanoid: Humanoid) - assert(typeof(humanoid) == "Instance" and humanoid:IsA("Humanoid"), "Bad humanoid") - - return self._reportProcessor:ObserveHumanoidKillerReports(humanoid) +function DeathReportServiceClient.ObserveHumanoidKillerReports( + self: DeathReportServiceClient, + humanoid: Humanoid +): Observable.Observable + return self._dataService:ObserveHumanoidKillerReports(humanoid) end --[=[ @@ -93,10 +118,11 @@ end @param humanoid Humanoid @return Observable ]=] -function DeathReportServiceClient:ObserveHumanoidDeathReports(humanoid: Humanoid) - assert(typeof(humanoid) == "Instance" and humanoid:IsA("Humanoid"), "Bad humanoid") - - return self._reportProcessor:ObserveHumanoidDeathReports(humanoid) +function DeathReportServiceClient.ObserveHumanoidDeathReports( + self: DeathReportServiceClient, + humanoid: Humanoid +): Observable.Observable + return self._dataService:ObserveHumanoidDeathReports(humanoid) end --[=[ @@ -105,33 +131,38 @@ end @param character Model @return Observable ]=] -function DeathReportServiceClient:ObserveCharacterKillerReports(character: Model) - assert(typeof(character) == "Instance" and character:IsA("Model"), "Bad character") - - return self._reportProcessor:ObserveCharacterKillerReports(character) +function DeathReportServiceClient.ObserveCharacterKillerReports( + self: DeathReportServiceClient, + character: Model +): Observable.Observable + return self._dataService:ObserveCharacterKillerReports(character) end --[=[ - Observes killer reports for the given character + Observes death reports for the given character @param character Model @return Observable ]=] -function DeathReportServiceClient:ObserveCharacterDeathReports(character: Model) - assert(typeof(character) == "Instance" and character:IsA("Model"), "Bad character") - - return self._reportProcessor:ObserveCharacterDeathReports(character) +function DeathReportServiceClient.ObserveCharacterDeathReports( + self: DeathReportServiceClient, + character: Model +): Observable.Observable + return self._dataService:ObserveCharacterDeathReports(character) end --[=[ - Gets the last recorded death reports + Gets the last recorded death reports, oldest first @return { DeathReport } ]=] -function DeathReportServiceClient:GetLastDeathReports() - return self._lastDeathReports +function DeathReportServiceClient.GetLastDeathReports(self: DeathReportServiceClient): { DeathReportUtils.DeathReport } + return self._dataService:GetLastDeathReports() end -function DeathReportServiceClient:_handleClientEvent(deathReport: DeathReportUtils.DeathReport) +function DeathReportServiceClient._handleClientEvent( + self: DeathReportServiceClient, + deathReport: DeathReportUtils.DeathReport +) assert(DeathReportUtils.isDeathReport(deathReport), "Bad deathreport") if typeof(deathReport.adornee) ~= "Instance" then @@ -139,22 +170,10 @@ function DeathReportServiceClient:_handleClientEvent(deathReport: DeathReportUti return end - -- Hack O(2*n) operation for death reports, but since n is really low, it's all good. - table.insert(self._lastDeathReports, deathReport) - while #self._lastDeathReports > MAX_DEATH_REPORTS do - table.remove(self._lastDeathReports, 1) - end - - -- Fire off events - self.NewDeathReport:Fire(deathReport) - self._reportProcessor:HandleDeathReport(deathReport) -end - -function DeathReportServiceClient:_promiseRemoteEvent() - return self._maid:GivePromise(PromiseGetRemoteEvent(DeathReportServiceConstants.REMOTE_EVENT_NAME)) + self._dataService:HandleDeathReport(deathReport) end -function DeathReportServiceClient:Destroy() +function DeathReportServiceClient.Destroy(self: DeathReportServiceClient) self._maid:DoCleaning() end diff --git a/src/deathreport/src/Client/DeathReportServiceClient.spec.lua b/src/deathreport/src/Client/DeathReportServiceClient.spec.lua new file mode 100644 index 00000000000..227a78a3075 --- /dev/null +++ b/src/deathreport/src/Client/DeathReportServiceClient.spec.lua @@ -0,0 +1,172 @@ +--!strict +--[[ + Dual-realm coverage for DeathReportServiceClient. A server bag and a client bag boot in the same + DataModel; deaths reported on the server cross dummy-mode remoting to the client the same way they + replicate in production. + + @class DeathReportServiceClient.spec.lua +]] + +local require = require(script.Parent.loader).load(script) + +local DeathReportDataService = require("DeathReportDataService") +local DeathReportTestUtils = require("DeathReportTestUtils") +local DeathReportUtils = require("DeathReportUtils") +local Jest = require("Jest") + +local describe = Jest.Globals.describe +local expect = Jest.Globals.expect +local it = Jest.Globals.it + +local function setup(): any + local controller = DeathReportTestUtils.setup({ withClient = true }) + + local localPlayer = controller.newMock() + controller.setLocalPlayer(localPlayer) + controller.localPlayer = localPlayer + + return controller +end + +local function collectClientReports(controller: any): { DeathReportUtils.DeathReport } + local reports: { DeathReportUtils.DeathReport } = {} + controller.maid:GiveTask(controller.deathReportServiceClient.NewDeathReport:Connect(function(report) + table.insert(reports, report) + end)) + return reports +end + +local function collect(controller: any, observable: any): { any } + local emissions = {} + controller.maid:GiveTask(observable:Subscribe(function(value) + table.insert(emissions, value) + end)) + return emissions +end + +describe("DeathReportServiceClient", function() + it("boots against the server service", function() + local controller = setup() + + expect(controller.deathReportServiceClient).toBeDefined() + expect(controller.deathReportServiceClient:GetLastDeathReports()).toEqual({}) + + controller:Destroy() + end) + + it("aliases the client bag's DeathReportDataService, separate from the server's", function() + local controller = setup() + local client = controller.deathReportServiceClient + local clientDataService = controller.clientBag:GetService(DeathReportDataService) + local serverDataService = controller.serverBag:GetService(DeathReportDataService) + + expect(client.NewDeathReport).toBe(clientDataService.NewDeathReport) + expect(clientDataService).never.toBe(serverDataService) + + controller:Destroy() + end) + + it("receives a death reported on the server", function() + local controller = setup() + local client = controller.deathReportServiceClient + local victim = controller.newMock() + local victimCharacter, victimHumanoid = controller.newCharacter(victim) + local killer = controller.newMock() + local _killerCharacter, killerHumanoid = controller.newCharacter(killer) + + local reports = collectClientReports(controller) + + controller.kill(victimHumanoid, killer) + + expect(DeathReportTestUtils.waitFor(function() + return #reports == 1 + end)).toBe(true) + + expect(reports[1].adornee).toBe(victimCharacter) + expect(reports[1].humanoid).toBe(victimHumanoid) + expect(reports[1].player).toBe(victim) + expect(reports[1].killerHumanoid).toBe(killerHumanoid) + expect(reports[1].killerPlayer).toBe(killer) + expect(client:GetLastDeathReports()).toEqual({ reports[1] }) + + controller:Destroy() + end) + + it("routes replicated reports to the client observers", function() + local controller = setup() + local client = controller.deathReportServiceClient + local victim = controller.newMock() + local victimCharacter, victimHumanoid = controller.newCharacter(victim) + local killer = controller.newMock() + local killerCharacter, killerHumanoid = controller.newCharacter(killer) + + local victimDeaths = collect(controller, client:ObservePlayerDeathReports(victim)) + local killerKills = collect(controller, client:ObservePlayerKillerReports(killer)) + local humanoidDeaths = collect(controller, client:ObserveHumanoidDeathReports(victimHumanoid)) + local characterDeaths = collect(controller, client:ObserveCharacterDeathReports(victimCharacter)) + local humanoidKills = collect(controller, client:ObserveHumanoidKillerReports(killerHumanoid)) + local characterKills = collect(controller, client:ObserveCharacterKillerReports(killerCharacter)) + local victimKills = collect(controller, client:ObservePlayerKillerReports(victim)) + + controller.kill(victimHumanoid, killer) + + expect(DeathReportTestUtils.waitFor(function() + return #victimDeaths == 1 + and #killerKills == 1 + and #humanoidDeaths == 1 + and #characterDeaths == 1 + and #humanoidKills == 1 + and #characterKills == 1 + end)).toBe(true) + expect(victimKills).toEqual({}) + + controller:Destroy() + end) + + it("keeps only the most recent reports", function() + local controller = setup() + local client = controller.deathReportServiceClient + + local reports = collectClientReports(controller) + + local npcs = {} + for index = 1, 7 do + local npc = controller.newNpc(string.format("Npc_%d", index)) + table.insert(npcs, npc) + controller.deathReportService:ReportDeathReport(DeathReportUtils.create(npc)) + end + + expect(DeathReportTestUtils.waitFor(function() + return #reports == 7 + end)).toBe(true) + + local lastReports = client:GetLastDeathReports() + expect(#lastReports).toEqual(5) + expect(lastReports[1].adornee).toBe(npcs[3]) + expect(lastReports[5].adornee).toBe(npcs[7]) + + controller:Destroy() + end) + + it("rejects the wrong instance class for observers", function() + local controller = setup() + local client = controller.deathReportServiceClient + local folder = Instance.new("Folder") + + expect(function() + client:ObservePlayerDeathReports(folder :: any) + end).toThrow() + expect(function() + client:ObservePlayerKillerReports(folder :: any) + end).toThrow() + expect(function() + client:ObserveHumanoidDeathReports(folder :: any) + end).toThrow() + expect(function() + client:ObserveCharacterDeathReports(folder :: any) + end).toThrow() + + folder:Destroy() + controller:Destroy() + end) +end) diff --git a/src/deathreport/src/Client/Stats/PlayerDeathTrackerClient.lua b/src/deathreport/src/Client/Stats/PlayerDeathTrackerClient.lua index b5ed00ac9bd..8870f90a131 100644 --- a/src/deathreport/src/Client/Stats/PlayerDeathTrackerClient.lua +++ b/src/deathreport/src/Client/Stats/PlayerDeathTrackerClient.lua @@ -1,12 +1,25 @@ --!strict --[=[ + Client view of a [PlayerDeathTracker]: reads the death count the server replicates under the + player. Binds to every tagged [Player]. + + Retrieve the binder from a [ServiceBag] with `serviceBag:GetService(require("PlayerDeathTrackerClient"))`. + + @client @class PlayerDeathTrackerClient ]=] local require = require(script.Parent.loader).load(script) local BaseObject = require("BaseObject") +local Binder = require("Binder") +local DeathReportServiceConstants = require("DeathReportServiceConstants") +local Observable = require("Observable") +local PlayerDeathTrackerInterface = require("PlayerDeathTrackerInterface") +local Rx = require("Rx") +local RxValueBaseUtils = require("RxValueBaseUtils") local ServiceBag = require("ServiceBag") +local Signal = require("Signal") local PlayerDeathTrackerClient = setmetatable({}, BaseObject) PlayerDeathTrackerClient.ClassName = "PlayerDeathTrackerClient" @@ -15,33 +28,96 @@ PlayerDeathTrackerClient.__index = PlayerDeathTrackerClient export type PlayerDeathTrackerClient = typeof(setmetatable( {} :: { + _obj: Player, _serviceBag: ServiceBag.ServiceBag, - DeathsChanged: RBXScriptSignal, + DeathsChanged: Signal.Signal, }, {} :: typeof({ __index = PlayerDeathTrackerClient }) )) & BaseObject.BaseObject -function PlayerDeathTrackerClient.new(tracker: IntValue, serviceBag: ServiceBag.ServiceBag): PlayerDeathTrackerClient - local self: PlayerDeathTrackerClient = setmetatable(BaseObject.new(tracker) :: any, PlayerDeathTrackerClient) +--[=[ + Constructs a new PlayerDeathTrackerClient. Should be done via the binder. + + @param player Player + @param serviceBag ServiceBag + @return PlayerDeathTrackerClient +]=] +function PlayerDeathTrackerClient.new(player: Player, serviceBag: ServiceBag.ServiceBag): PlayerDeathTrackerClient + local self: PlayerDeathTrackerClient = setmetatable(BaseObject.new(player) :: any, PlayerDeathTrackerClient) self._serviceBag = assert(serviceBag, "No serviceBag") - self.DeathsChanged = (self._obj :: IntValue).Changed + --[=[ + Fires with the new death count whenever it changes + @prop DeathsChanged Signal + @within PlayerDeathTrackerClient +]=] + self.DeathsChanged = self._maid:Add(Signal.new()) :: any + + self._maid:GiveTask(self:ObserveDeaths():Pipe({ Rx.skip(1) :: any }):Subscribe(function(deaths) + self.DeathsChanged:Fire(deaths) + end)) + + self._maid:GiveTask(PlayerDeathTrackerInterface.Client:Implement(self._obj, self)) return self end -function PlayerDeathTrackerClient.GetDeathValue(self: PlayerDeathTrackerClient): IntValue - return self._obj :: IntValue +--[=[ + Returns the replicated value holding the death count, once it has streamed in + @return IntValue? +]=] +function PlayerDeathTrackerClient.GetDeathValue(self: PlayerDeathTrackerClient): IntValue? + local value = self._obj:FindFirstChild(DeathReportServiceConstants.PLAYER_DEATH_VALUE_NAME) + if value and value:IsA("IntValue") then + return value + end + + return nil +end + +--[=[ + Returns the player whose deaths are tracked + @return Player +]=] +function PlayerDeathTrackerClient.GetPlayer(self: PlayerDeathTrackerClient): Player + return self._obj end -function PlayerDeathTrackerClient.GetPlayer(self: PlayerDeathTrackerClient): Instance? - return (self._obj :: IntValue).Parent +--[=[ + Returns the number of deaths of the player, 0 until the value has replicated + @return number +]=] +function PlayerDeathTrackerClient.GetDeaths(self: PlayerDeathTrackerClient): number + local value = self:GetDeathValue() + return if value then value.Value else 0 end +--[=[ + Returns the number of deaths of the player + + @deprecated 10.60.0 -- Use [PlayerDeathTrackerClient.GetDeaths] + @return number +]=] function PlayerDeathTrackerClient.GetKills(self: PlayerDeathTrackerClient): number - return (self._obj :: IntValue).Value + return self:GetDeaths() +end + +--[=[ + Observes the number of deaths of the player. Emits 0 until the value has replicated. +]=] +function PlayerDeathTrackerClient.ObserveDeaths(self: PlayerDeathTrackerClient): Observable.Observable + return RxValueBaseUtils.observe( + self._obj, + "IntValue", + DeathReportServiceConstants.PLAYER_DEATH_VALUE_NAME, + 0 + ) + :Pipe({ + Rx.defaultsTo(0) :: any, + Rx.distinct() :: any, + }) :: any end -return PlayerDeathTrackerClient +return Binder.new("PlayerDeathTracker", PlayerDeathTrackerClient :: any) :: Binder.Binder diff --git a/src/deathreport/src/Client/Stats/PlayerDeathTrackerClient.spec.lua b/src/deathreport/src/Client/Stats/PlayerDeathTrackerClient.spec.lua new file mode 100644 index 00000000000..8dd0f89caf9 --- /dev/null +++ b/src/deathreport/src/Client/Stats/PlayerDeathTrackerClient.spec.lua @@ -0,0 +1,88 @@ +--!strict +--[[ + Client binder coverage with both realms booted: the server binder tags every player and keeps the + replicated value, and the client binder binds the same tagged player. + + @class PlayerDeathTrackerClient.spec.lua +]] + +local require = require(script.Parent.loader).load(script) + +local DeathReportTestUtils = require("DeathReportTestUtils") +local Jest = require("Jest") +local PlayerDeathTrackerInterface = require("PlayerDeathTrackerInterface") + +local describe = Jest.Globals.describe +local expect = Jest.Globals.expect +local it = Jest.Globals.it + +local function setup(): any + local controller = DeathReportTestUtils.setup({ withClient = true }) + + local mock = controller.newMock() + controller.setLocalPlayer(mock) + + controller.mock = mock + controller.score = DeathReportTestUtils.awaitBound(controller.playerDeathTrackerBinder, mock):GetDeathValue() + controller.tracker = DeathReportTestUtils.awaitBound(controller.playerDeathTrackerClientBinder, mock) + + return controller +end + +describe("PlayerDeathTrackerClient", function() + it("binds the tagged player and reads its replicated deaths", function() + local controller = setup() + + expect(controller.tracker:GetPlayer()).toBe(controller.mock) + expect(controller.tracker:GetDeathValue()).toBe(controller.score) + expect(controller.tracker:GetDeaths()).toEqual(0) + expect(controller.tracker:GetKills()).toEqual(0) + + controller:Destroy() + end) + + it("fires DeathsChanged when the value changes", function() + local controller = setup() + + local values = {} + controller.maid:GiveTask(controller.tracker.DeathsChanged:Connect(function(value: number) + table.insert(values, value) + end)) + + controller.score.Value = 4 + + expect(DeathReportTestUtils.waitFor(function() + return #values == 1 + end)).toBe(true) + expect(values[1]).toEqual(4) + expect(controller.tracker:GetDeaths()).toEqual(4) + + controller:Destroy() + end) + + it("sees deaths the server counts for the player", function() + local controller = setup() + local _character, humanoid = controller.newCharacter(controller.mock) + + controller.kill(humanoid) + + expect(DeathReportTestUtils.waitFor(function() + return controller.tracker:GetDeaths() == 1 + end)).toBe(true) + + controller:Destroy() + end) + + it("implements PlayerDeathTrackerInterface for the client realm", function() + local controller = setup() + + local implementation = PlayerDeathTrackerInterface.Client:Find(controller.mock) + assert(implementation, "No implementation") + + expect(implementation:GetPlayer()).toBe(controller.mock) + expect(implementation:GetDeathValue()).toBe(controller.score) + expect(implementation:GetDeaths()).toEqual(0) + + controller:Destroy() + end) +end) diff --git a/src/deathreport/src/Client/Stats/PlayerKillTrackerClient.lua b/src/deathreport/src/Client/Stats/PlayerKillTrackerClient.lua index c3fcb85aacd..a97791f7deb 100644 --- a/src/deathreport/src/Client/Stats/PlayerKillTrackerClient.lua +++ b/src/deathreport/src/Client/Stats/PlayerKillTrackerClient.lua @@ -1,12 +1,25 @@ --!strict --[=[ + Client view of a [PlayerKillTracker]: reads the kill count the server replicates under the + player. Binds to every tagged [Player]. + + Retrieve the binder from a [ServiceBag] with `serviceBag:GetService(require("PlayerKillTrackerClient"))`. + + @client @class PlayerKillTrackerClient ]=] local require = require(script.Parent.loader).load(script) local BaseObject = require("BaseObject") +local Binder = require("Binder") +local DeathReportServiceConstants = require("DeathReportServiceConstants") +local Observable = require("Observable") +local PlayerKillTrackerInterface = require("PlayerKillTrackerInterface") +local Rx = require("Rx") +local RxValueBaseUtils = require("RxValueBaseUtils") local ServiceBag = require("ServiceBag") +local Signal = require("Signal") local PlayerKillTrackerClient = setmetatable({}, BaseObject) PlayerKillTrackerClient.ClassName = "PlayerKillTrackerClient" @@ -15,34 +28,86 @@ PlayerKillTrackerClient.__index = PlayerKillTrackerClient export type PlayerKillTrackerClient = typeof(setmetatable( {} :: { - _obj: IntValue, + _obj: Player, _serviceBag: ServiceBag.ServiceBag, - KillsChanged: RBXScriptSignal, + KillsChanged: Signal.Signal, }, {} :: typeof({ __index = PlayerKillTrackerClient }) )) & BaseObject.BaseObject -function PlayerKillTrackerClient.new(tracker: IntValue, serviceBag: ServiceBag.ServiceBag): PlayerKillTrackerClient - local self: PlayerKillTrackerClient = setmetatable(BaseObject.new(tracker) :: any, PlayerKillTrackerClient) +--[=[ + Constructs a new PlayerKillTrackerClient. Should be done via the binder. + + @param player Player + @param serviceBag ServiceBag + @return PlayerKillTrackerClient +]=] +function PlayerKillTrackerClient.new(player: Player, serviceBag: ServiceBag.ServiceBag): PlayerKillTrackerClient + local self: PlayerKillTrackerClient = setmetatable(BaseObject.new(player) :: any, PlayerKillTrackerClient) self._serviceBag = assert(serviceBag, "No serviceBag") - self.KillsChanged = self._obj.Changed + --[=[ + Fires with the new kill count whenever it changes + @prop KillsChanged Signal + @within PlayerKillTrackerClient +]=] + self.KillsChanged = self._maid:Add(Signal.new()) :: any + + self._maid:GiveTask(self:ObserveKills():Pipe({ Rx.skip(1) :: any }):Subscribe(function(kills) + self.KillsChanged:Fire(kills) + end)) + + self._maid:GiveTask(PlayerKillTrackerInterface.Client:Implement(self._obj, self)) return self end -function PlayerKillTrackerClient.GetKillValue(self: PlayerKillTrackerClient): IntValue - return self._obj +--[=[ + Returns the replicated value holding the kill count, once it has streamed in + @return IntValue? +]=] +function PlayerKillTrackerClient.GetKillValue(self: PlayerKillTrackerClient): IntValue? + local value = self._obj:FindFirstChild(DeathReportServiceConstants.PLAYER_KILL_VALUE_NAME) + if value and value:IsA("IntValue") then + return value + end + + return nil end -function PlayerKillTrackerClient.GetPlayer(self: PlayerKillTrackerClient): Instance? - return self._obj.Parent +--[=[ + Returns the player whose kills are tracked + @return Player +]=] +function PlayerKillTrackerClient.GetPlayer(self: PlayerKillTrackerClient): Player + return self._obj end +--[=[ + Returns the number of kills scored by the player, 0 until the value has replicated + @return number +]=] function PlayerKillTrackerClient.GetKills(self: PlayerKillTrackerClient): number - return self._obj.Value + local value = self:GetKillValue() + return if value then value.Value else 0 +end + +--[=[ + Observes the number of kills scored by the player. Emits 0 until the value has replicated. +]=] +function PlayerKillTrackerClient.ObserveKills(self: PlayerKillTrackerClient): Observable.Observable + return RxValueBaseUtils.observe( + self._obj, + "IntValue", + DeathReportServiceConstants.PLAYER_KILL_VALUE_NAME, + 0 + ) + :Pipe({ + Rx.defaultsTo(0) :: any, + Rx.distinct() :: any, + }) :: any end -return PlayerKillTrackerClient +return Binder.new("PlayerKillTracker", PlayerKillTrackerClient :: any) :: Binder.Binder diff --git a/src/deathreport/src/Client/Stats/PlayerKillTrackerClient.spec.lua b/src/deathreport/src/Client/Stats/PlayerKillTrackerClient.spec.lua new file mode 100644 index 00000000000..4f7d1bd1917 --- /dev/null +++ b/src/deathreport/src/Client/Stats/PlayerKillTrackerClient.spec.lua @@ -0,0 +1,90 @@ +--!strict +--[[ + Client binder coverage with both realms booted: the server binder tags every player and keeps the + replicated value, and the client binder binds the same tagged player. + + @class PlayerKillTrackerClient.spec.lua +]] + +local require = require(script.Parent.loader).load(script) + +local DeathReportTestUtils = require("DeathReportTestUtils") +local Jest = require("Jest") +local PlayerKillTrackerInterface = require("PlayerKillTrackerInterface") + +local describe = Jest.Globals.describe +local expect = Jest.Globals.expect +local it = Jest.Globals.it + +local function setup(): any + local controller = DeathReportTestUtils.setup({ withClient = true }) + + local mock = controller.newMock() + controller.setLocalPlayer(mock) + + controller.mock = mock + controller.score = DeathReportTestUtils.awaitBound(controller.playerKillTrackerBinder, mock):GetKillValue() + controller.tracker = DeathReportTestUtils.awaitBound(controller.playerKillTrackerClientBinder, mock) + + return controller +end + +describe("PlayerKillTrackerClient", function() + it("binds the tagged player and reads its replicated kills", function() + local controller = setup() + + expect(controller.tracker:GetPlayer()).toBe(controller.mock) + expect(controller.tracker:GetKillValue()).toBe(controller.score) + expect(controller.tracker:GetKills()).toEqual(0) + + controller:Destroy() + end) + + it("fires KillsChanged when the value changes", function() + local controller = setup() + + local values = {} + controller.maid:GiveTask(controller.tracker.KillsChanged:Connect(function(value: number) + table.insert(values, value) + end)) + + controller.score.Value = 2 + + expect(DeathReportTestUtils.waitFor(function() + return #values == 1 + end)).toBe(true) + expect(values[1]).toEqual(2) + expect(controller.tracker:GetKills()).toEqual(2) + + controller:Destroy() + end) + + it("sees kills the server counts for the player", function() + local controller = setup() + controller.newCharacter(controller.mock) + + local victim = controller.newMock() + local _victimCharacter, victimHumanoid = controller.newCharacter(victim) + + controller.kill(victimHumanoid, controller.mock) + + expect(DeathReportTestUtils.waitFor(function() + return controller.tracker:GetKills() == 1 + end)).toBe(true) + + controller:Destroy() + end) + + it("implements PlayerKillTrackerInterface for the client realm", function() + local controller = setup() + + local implementation = PlayerKillTrackerInterface.Client:Find(controller.mock) + assert(implementation, "No implementation") + + expect(implementation:GetPlayer()).toBe(controller.mock) + expect(implementation:GetKillValue()).toBe(controller.score) + expect(implementation:GetKills()).toEqual(0) + + controller:Destroy() + end) +end) diff --git a/src/deathreport/src/Client/Stats/TeamKillTrackerClient.lua b/src/deathreport/src/Client/Stats/TeamKillTrackerClient.lua index d51d68bcd23..1da55adfbc0 100644 --- a/src/deathreport/src/Client/Stats/TeamKillTrackerClient.lua +++ b/src/deathreport/src/Client/Stats/TeamKillTrackerClient.lua @@ -1,11 +1,20 @@ --!strict --[=[ + Client view of a [TeamKillTracker]: reads the replicated kill count of a team. + + Retrieve the binder from a [ServiceBag] with `serviceBag:GetService(require("TeamKillTrackerClient"))`. + + @client @class TeamKillTrackerClient ]=] local require = require(script.Parent.loader).load(script) local BaseObject = require("BaseObject") +local Binder = require("Binder") +local Observable = require("Observable") +local RxValueBaseUtils = require("RxValueBaseUtils") +local TeamKillTrackerInterface = require("TeamKillTrackerInterface") local TeamKillTrackerClient = setmetatable({}, BaseObject) TeamKillTrackerClient.ClassName = "TeamKillTrackerClient" @@ -14,30 +23,63 @@ TeamKillTrackerClient.__index = TeamKillTrackerClient export type TeamKillTrackerClient = typeof(setmetatable( {} :: { + _obj: IntValue, KillsChanged: RBXScriptSignal, }, {} :: typeof({ __index = TeamKillTrackerClient }) )) & BaseObject.BaseObject +--[=[ + Constructs a new TeamKillTrackerClient. Should be done via the binder. + + @param tracker IntValue + @return TeamKillTrackerClient +]=] function TeamKillTrackerClient.new(tracker: IntValue): TeamKillTrackerClient local self: TeamKillTrackerClient = setmetatable(BaseObject.new(tracker) :: any, TeamKillTrackerClient) - self.KillsChanged = self:GetKillValue().Changed + --[=[ + Fires when the kill count changes + @prop KillsChanged RBXScriptSignal + @within TeamKillTrackerClient +]=] + self.KillsChanged = self._obj.Changed + + self._maid:GiveTask(TeamKillTrackerInterface.Client:Implement(self._obj, self)) return self end +--[=[ + Returns the value holding the kill count + @return IntValue +]=] function TeamKillTrackerClient.GetKillValue(self: TeamKillTrackerClient): IntValue - return self._obj :: IntValue + return self._obj end +--[=[ + Returns the team whose kills are tracked + @return Instance? +]=] function TeamKillTrackerClient.GetTeam(self: TeamKillTrackerClient): Instance? - return self:GetKillValue().Parent + return self._obj.Parent end +--[=[ + Returns the number of kills scored by the team + @return number +]=] function TeamKillTrackerClient.GetKills(self: TeamKillTrackerClient): number - return self:GetKillValue().Value + return self._obj.Value +end + +--[=[ + Observes the number of kills scored by the team +]=] +function TeamKillTrackerClient.ObserveKills(self: TeamKillTrackerClient): Observable.Observable + return RxValueBaseUtils.observeValue(self._obj) end -return TeamKillTrackerClient +return Binder.new("TeamKillTracker", TeamKillTrackerClient :: any) :: Binder.Binder diff --git a/src/deathreport/src/Client/Stats/TeamKillTrackerClient.spec.lua b/src/deathreport/src/Client/Stats/TeamKillTrackerClient.spec.lua new file mode 100644 index 00000000000..eee5dc4cddc --- /dev/null +++ b/src/deathreport/src/Client/Stats/TeamKillTrackerClient.spec.lua @@ -0,0 +1,95 @@ +--!strict +--[[ + Client binder coverage with both realms booted: the server binds the tracker it created, and the + client binder binds the same replicated IntValue. + + @class TeamKillTrackerClient.spec.lua +]] + +local require = require(script.Parent.loader).load(script) + +local DeathReportTestUtils = require("DeathReportTestUtils") +local Jest = require("Jest") +local TeamKillTrackerInterface = require("TeamKillTrackerInterface") +local TeamKillTrackerUtils = require("TeamKillTrackerUtils") + +local describe = Jest.Globals.describe +local expect = Jest.Globals.expect +local it = Jest.Globals.it + +local function setup(): any + local controller = DeathReportTestUtils.setup({ withClient = true }) + + local team = controller.newTeam() + local score = TeamKillTrackerUtils.create(controller.teamKillTrackerBinder) + score.Parent = team + + controller.team = team + controller.score = score + controller.tracker = DeathReportTestUtils.awaitBound(controller.teamKillTrackerClientBinder, score) + + return controller +end + +describe("TeamKillTrackerClient", function() + it("binds the replicated tracker and reads its team and kills", function() + local controller = setup() + + expect(controller.tracker:GetTeam()).toBe(controller.team) + expect(controller.tracker:GetKillValue()).toBe(controller.score) + expect(controller.tracker:GetKills()).toEqual(0) + + controller:Destroy() + end) + + it("fires KillsChanged when the value changes", function() + local controller = setup() + + local values = {} + controller.maid:GiveTask(controller.tracker.KillsChanged:Connect(function(value: number) + table.insert(values, value) + end)) + + controller.score.Value = 3 + + expect(DeathReportTestUtils.waitFor(function() + return #values == 1 + end)).toBe(true) + expect(values[1]).toEqual(3) + expect(controller.tracker:GetKills()).toEqual(3) + + controller:Destroy() + end) + + it("sees kills the server counts for the team", function() + local controller = setup() + + local killer = controller.newMock() + controller.setTeam(killer, controller.team) + controller.newCharacter(killer) + + local victim = controller.newMock() + local _victimCharacter, victimHumanoid = controller.newCharacter(victim) + + controller.kill(victimHumanoid, killer) + + expect(DeathReportTestUtils.waitFor(function() + return controller.tracker:GetKills() == 1 + end)).toBe(true) + + controller:Destroy() + end) + + it("implements TeamKillTrackerInterface for the client realm", function() + local controller = setup() + + local implementation = TeamKillTrackerInterface.Client:Find(controller.score) + assert(implementation, "No implementation") + + expect(implementation:GetTeam()).toBe(controller.team) + expect(implementation:GetKillValue()).toBe(controller.score) + expect(implementation:GetKills()).toEqual(0) + + controller:Destroy() + end) +end) diff --git a/src/deathreport/src/Server/DeathReportBindersServer.lua b/src/deathreport/src/Server/DeathReportBindersServer.lua index ab7e37342f5..5f1d2d41f5a 100644 --- a/src/deathreport/src/Server/DeathReportBindersServer.lua +++ b/src/deathreport/src/Server/DeathReportBindersServer.lua @@ -1,20 +1,39 @@ --!strict --[=[ + Holds the server binders for the death report system. Kept for callers that still retrieve binders + through a provider; every binder here is the same singleton a [ServiceBag] hands out directly. + + :::tip + Binders can be retrieved directly through a [ServiceBag] now, for example + `serviceBag:GetService(require("TeamKillTracker"))`. + ::: + + @server + @deprecated 10.60.0 -- Retrieve the binders directly from the ServiceBag @class DeathReportBindersServer ]=] local require = require(script.Parent.loader).load(script) -local Binder = require("Binder") local BinderProvider = require("BinderProvider") -local PlayerDeathTracker = require("PlayerDeathTracker") -local PlayerKillTracker = require("PlayerKillTracker") local ServiceBag = require("ServiceBag") -local TeamKillTracker = require("TeamKillTracker") -return BinderProvider.new(script.Name, function(self, serviceBag: ServiceBag.ServiceBag) - -- Stats - self:Add(Binder.new("TeamKillTracker", TeamKillTracker :: any, serviceBag)) - self:Add(Binder.new("PlayerKillTracker", PlayerKillTracker :: any, serviceBag)) - self:Add(Binder.new("PlayerDeathTracker", PlayerDeathTracker :: any, serviceBag)) +return BinderProvider.new(script.Name, function(self: BinderProvider.BinderProvider, serviceBag: ServiceBag.ServiceBag) + --[=[ + @prop TeamKillTracker Binder + @within DeathReportBindersServer +]=] + self:Add(serviceBag:GetService(require("TeamKillTracker"))) + + --[=[ + @prop PlayerKillTracker Binder + @within DeathReportBindersServer +]=] + self:Add(serviceBag:GetService(require("PlayerKillTracker"))) + + --[=[ + @prop PlayerDeathTracker Binder + @within DeathReportBindersServer +]=] + self:Add(serviceBag:GetService(require("PlayerDeathTracker"))) end) diff --git a/src/deathreport/src/Server/DeathReportBindersServer.spec.lua b/src/deathreport/src/Server/DeathReportBindersServer.spec.lua new file mode 100644 index 00000000000..d02ea19ce26 --- /dev/null +++ b/src/deathreport/src/Server/DeathReportBindersServer.spec.lua @@ -0,0 +1,69 @@ +--!strict +--[[ + @class DeathReportBindersServer.spec.lua +]] + +local require = require(script.Parent.loader).load(script) + +local DeathReportBindersServer = require("DeathReportBindersServer") +local DeathReportService = require("DeathReportService") +local Jest = require("Jest") +local JestUtils = require("JestUtils") +local Maid = require("Maid") +local PlayerDeathTracker = require("PlayerDeathTracker") +local PlayerKillTracker = require("PlayerKillTracker") +local ServiceBag = require("ServiceBag") +local TeamKillTracker = require("TeamKillTracker") + +local describe = Jest.Globals.describe +local expect = Jest.Globals.expect +local it = Jest.Globals.it + +local function setup(): any + local maid = Maid.new() + + local serviceBag = ServiceBag.new() + serviceBag:GetService(DeathReportService) + local provider = serviceBag:GetService(DeathReportBindersServer) + serviceBag:Init() + serviceBag:Start() + + maid:GiveTask(function() + serviceBag:Destroy() + end) + + local controller = { + serviceBag = serviceBag, + provider = provider, + Destroy = function(_self) + maid:DoCleaning() + end, + } + + maid:GiveTask(JestUtils.afterThis(controller)) + + return controller +end + +describe("DeathReportBindersServer", function() + it("exposes the same binders the service bag hands out", function() + local controller = setup() + + expect(controller.provider.TeamKillTracker).toBe(controller.serviceBag:GetService(TeamKillTracker)) + expect(controller.provider.PlayerKillTracker).toBe(controller.serviceBag:GetService(PlayerKillTracker)) + expect(controller.provider.PlayerDeathTracker).toBe(controller.serviceBag:GetService(PlayerDeathTracker)) + + controller:Destroy() + end) + + it("resolves binders by tag", function() + local controller = setup() + + local ok, binder = controller.provider:PromiseBinder("TeamKillTracker"):Yield() + expect(ok).toBe(true) + expect(binder).toBe(controller.serviceBag:GetService(TeamKillTracker)) + expect(controller.provider:Get("Unknown")).toBeNil() + + controller:Destroy() + end) +end) diff --git a/src/deathreport/src/Server/DeathReportService.lua b/src/deathreport/src/Server/DeathReportService.lua index 51cab20fb15..cc6f44640dd 100644 --- a/src/deathreport/src/Server/DeathReportService.lua +++ b/src/deathreport/src/Server/DeathReportService.lua @@ -1,7 +1,9 @@ --!strict --[=[ Centralized death reporting service which can be used to track - deaths. + deaths. Builds reports from dying humanoids, records them in + [DeathReportDataService] (which this service aliases) and replicates them to + every client. @server @class DeathReportService @@ -9,12 +11,14 @@ local require = require(script.Parent.loader).load(script) -local DeathReportProcessor = require("DeathReportProcessor") +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local DeathReportDataService = require("DeathReportDataService") local DeathReportServiceConstants = require("DeathReportServiceConstants") local DeathReportUtils = require("DeathReportUtils") -local GetRemoteEvent = require("GetRemoteEvent") local Maid = require("Maid") local Observable = require("Observable") +local Remoting = require("Remoting") local ServiceBag = require("ServiceBag") local Signal = require("Signal") @@ -25,9 +29,9 @@ export type DeathReportService = typeof(setmetatable( {} :: { _serviceBag: ServiceBag.ServiceBag, _maid: Maid.Maid, + _dataService: DeathReportDataService.DeathReportDataService, NewDeathReport: Signal.Signal, - _remoteEvent: RemoteEvent, - _reportProcessor: DeathReportProcessor.DeathReportProcessor, + _remoting: Remoting.Remoting, _weaponDataRetrievers: { GetWeaponData }, }, {} :: typeof({ __index = DeathReportService }) @@ -46,22 +50,39 @@ function DeathReportService.Init(self: DeathReportService, serviceBag: ServiceBa self._maid = Maid.new() -- Internal - self._serviceBag:GetService((require :: any)("DeathReportBindersServer")) + self._dataService = self._serviceBag:GetService(DeathReportDataService) :: any - -- Binders + -- Binders. Required lazily: each one requires this service at load time. self._serviceBag:GetService((require :: any)("DeathTrackedHumanoid")) - - -- Export - self.NewDeathReport = self._maid:Add(Signal.new()) :: any + self._serviceBag:GetService((require :: any)("TeamKillTracker")) + self._serviceBag:GetService((require :: any)("PlayerKillTracker")) + self._serviceBag:GetService((require :: any)("PlayerDeathTracker")) + + --[=[ + Fires with every [DeathReport] the server records. Same signal as + [DeathReportDataService.NewDeathReport]. + @prop NewDeathReport Signal + @within DeathReportService +]=] + self.NewDeathReport = self._dataService.NewDeathReport -- State - self._remoteEvent = GetRemoteEvent(DeathReportServiceConstants.REMOTE_EVENT_NAME) - self._reportProcessor = self._maid:Add(DeathReportProcessor.new()) + self._remoting = self._maid:Add(Remoting.Server.new(ReplicatedStorage, DeathReportServiceConstants.REMOTING_NAME)) + self._remoting:DeclareEvent(DeathReportServiceConstants.DEATH_REPORTED_EVENT_NAME) self._weaponDataRetrievers = {} end -function DeathReportService.AddWeaponDataRetriever(self: DeathReportService, getWeaponData: GetWeaponData) +--[=[ + Registers a callback that resolves the weapon a humanoid died to. Retrievers are asked in + registration order; the first non-nil answer wins. + + @param getWeaponData (humanoid: Humanoid) -> WeaponData? + @return () -> () -- Removes the retriever +]=] +function DeathReportService.AddWeaponDataRetriever(self: DeathReportService, getWeaponData: GetWeaponData): () -> () + assert(type(getWeaponData) == "function", "Bad getWeaponData") + table.insert(self._weaponDataRetrievers, getWeaponData) return function() @@ -72,6 +93,12 @@ function DeathReportService.AddWeaponDataRetriever(self: DeathReportService, get end end +--[=[ + Asks the registered retrievers for the weapon the humanoid died to + + @param humanoid Humanoid + @return WeaponData? +]=] function DeathReportService.FindWeaponData(self: DeathReportService, humanoid: Humanoid): DeathReportUtils.WeaponData? assert(typeof(humanoid) == "Instance", "Bad humanoid") @@ -97,9 +124,7 @@ function DeathReportService.ObservePlayerKillerReports( self: DeathReportService, player: Player ): Observable.Observable - assert(typeof(player) == "Instance" and player:IsA("Player"), "Bad player") - - return self._reportProcessor:ObservePlayerKillerReports(player) + return self._dataService:ObservePlayerKillerReports(player) end --[=[ @@ -112,9 +137,7 @@ function DeathReportService.ObservePlayerDeathReports( self: DeathReportService, player: Player ): Observable.Observable - assert(typeof(player) == "Instance" and player:IsA("Player"), "Bad player") - - return self._reportProcessor:ObservePlayerDeathReports(player) + return self._dataService:ObservePlayerDeathReports(player) end --[=[ @@ -127,9 +150,7 @@ function DeathReportService.ObserveHumanoidKillerReports( self: DeathReportService, humanoid: Humanoid ): Observable.Observable - assert(typeof(humanoid) == "Instance" and humanoid:IsA("Humanoid"), "Bad humanoid") - - return self._reportProcessor:ObserveHumanoidKillerReports(humanoid) + return self._dataService:ObserveHumanoidKillerReports(humanoid) end --[=[ @@ -142,9 +163,7 @@ function DeathReportService.ObserveHumanoidDeathReports( self: DeathReportService, humanoid: Humanoid ): Observable.Observable - assert(typeof(humanoid) == "Instance" and humanoid:IsA("Humanoid"), "Bad humanoid") - - return self._reportProcessor:ObserveHumanoidDeathReports(humanoid) + return self._dataService:ObserveHumanoidDeathReports(humanoid) end --[=[ @@ -157,13 +176,11 @@ function DeathReportService.ObserveCharacterKillerReports( self: DeathReportService, character: Model ): Observable.Observable - assert(typeof(character) == "Instance" and character:IsA("Model"), "Bad character") - - return self._reportProcessor:ObserveCharacterKillerReports(character) + return self._dataService:ObserveCharacterKillerReports(character) end --[=[ - Observes killer reports for the given character + Observes death reports for the given character @param character Model @return Observable @@ -172,9 +189,15 @@ function DeathReportService.ObserveCharacterDeathReports( self: DeathReportService, character: Model ): Observable.Observable - assert(typeof(character) == "Instance" and character:IsA("Model"), "Bad character") + return self._dataService:ObserveCharacterDeathReports(character) +end - return self._reportProcessor:ObserveCharacterDeathReports(character) +--[=[ + Gets the last recorded death reports, oldest first + @return { DeathReport } +]=] +function DeathReportService.GetLastDeathReports(self: DeathReportService): { DeathReportUtils.DeathReport } + return self._dataService:GetLastDeathReports() end --[=[ @@ -196,16 +219,19 @@ function DeathReportService.ReportHumanoidDeath( self:ReportDeathReport(report) end +--[=[ + Records a death report: fires [DeathReportService.NewDeathReport], routes it to the observers, + and replicates it to every client. + + @param deathReport DeathReport +]=] function DeathReportService.ReportDeathReport(self: DeathReportService, deathReport: DeathReportUtils.DeathReport) assert(DeathReportUtils.isDeathReport(deathReport), "Bad deathReport") - -- Notify services - self.NewDeathReport:Fire(deathReport) - - self._reportProcessor:HandleDeathReport(deathReport) + self._dataService:HandleDeathReport(deathReport) -- Send to all clients - self._remoteEvent:FireAllClients(deathReport) + self._remoting:FireAllClients(DeathReportServiceConstants.DEATH_REPORTED_EVENT_NAME, deathReport) end function DeathReportService.Destroy(self: DeathReportService) diff --git a/src/deathreport/src/Server/DeathReportService.spec.lua b/src/deathreport/src/Server/DeathReportService.spec.lua new file mode 100644 index 00000000000..efd2bbfbd76 --- /dev/null +++ b/src/deathreport/src/Server/DeathReportService.spec.lua @@ -0,0 +1,270 @@ +--!strict +--[[ + Coverage for DeathReportService booted headless against PlayerMocks. Deaths are driven the way + production drives them: a mock character is spawned, DeathTrackedHumanoid binds its humanoid, the + killer is tagged with the legacy creator tag, and the humanoid's health is set to zero. + + @class DeathReportService.spec.lua +]] + +local require = require(script.Parent.loader).load(script) + +local DeathReportDataService = require("DeathReportDataService") +local DeathReportTestUtils = require("DeathReportTestUtils") +local DeathReportUtils = require("DeathReportUtils") +local Jest = require("Jest") + +local describe = Jest.Globals.describe +local expect = Jest.Globals.expect +local it = Jest.Globals.it + +local function collectReports(controller: any): { DeathReportUtils.DeathReport } + local reports: { DeathReportUtils.DeathReport } = {} + controller.maid:GiveTask(controller.deathReportService.NewDeathReport:Connect(function(report) + table.insert(reports, report) + end)) + return reports +end + +local function collect(controller: any, observable: any): { any } + local emissions = {} + controller.maid:GiveTask(observable:Subscribe(function(value) + table.insert(emissions, value) + end)) + return emissions +end + +describe("DeathReportService.NewDeathReport", function() + it("reports a tracked humanoid death with its tagged killer", function() + local controller = DeathReportTestUtils.setup() + local victim = controller.newMock() + local victimCharacter, victimHumanoid = controller.newCharacter(victim) + local killer = controller.newMock() + local _killerCharacter, killerHumanoid = controller.newCharacter(killer) + + local reports = collectReports(controller) + + controller.kill(victimHumanoid, killer) + + expect(#reports).toEqual(1) + expect(reports[1].adornee).toBe(victimCharacter) + expect(reports[1].humanoid).toBe(victimHumanoid) + expect(reports[1].player).toBe(victim) + expect(reports[1].killerHumanoid).toBe(killerHumanoid) + expect(reports[1].killerPlayer).toBe(killer) + + controller:Destroy() + end) + + it("reports an unattributed death without a killer", function() + local controller = DeathReportTestUtils.setup() + local victim = controller.newMock() + local _victimCharacter, victimHumanoid = controller.newCharacter(victim) + + local reports = collectReports(controller) + + controller.kill(victimHumanoid) + + expect(#reports).toEqual(1) + expect(reports[1].player).toBe(victim) + expect(reports[1].killerPlayer).toBeNil() + expect(reports[1].killerHumanoid).toBeNil() + + controller:Destroy() + end) +end) + +describe("DeathReportService observers", function() + it("route a report to the killer and victim player observers", function() + local controller = DeathReportTestUtils.setup() + local service = controller.deathReportService + local victim = controller.newMock() + local _victimCharacter, victimHumanoid = controller.newCharacter(victim) + local killer = controller.newMock() + controller.newCharacter(killer) + + local victimDeaths = collect(controller, service:ObservePlayerDeathReports(victim)) + local victimKills = collect(controller, service:ObservePlayerKillerReports(victim)) + local killerDeaths = collect(controller, service:ObservePlayerDeathReports(killer)) + local killerKills = collect(controller, service:ObservePlayerKillerReports(killer)) + + controller.kill(victimHumanoid, killer) + + expect(#victimDeaths).toEqual(1) + expect(#victimKills).toEqual(0) + expect(#killerDeaths).toEqual(0) + expect(#killerKills).toEqual(1) + expect(victimDeaths[1].killerPlayer).toBe(killer) + + controller:Destroy() + end) + + it("route a report to the humanoid and character observers", function() + local controller = DeathReportTestUtils.setup() + local service = controller.deathReportService + local victim = controller.newMock() + local victimCharacter, victimHumanoid = controller.newCharacter(victim) + local killer = controller.newMock() + local killerCharacter, killerHumanoid = controller.newCharacter(killer) + + local humanoidDeaths = collect(controller, service:ObserveHumanoidDeathReports(victimHumanoid)) + local characterDeaths = collect(controller, service:ObserveCharacterDeathReports(victimCharacter)) + local humanoidKills = collect(controller, service:ObserveHumanoidKillerReports(killerHumanoid)) + local characterKills = collect(controller, service:ObserveCharacterKillerReports(killerCharacter)) + + controller.kill(victimHumanoid, killer) + + expect(#humanoidDeaths).toEqual(1) + expect(#characterDeaths).toEqual(1) + expect(#humanoidKills).toEqual(1) + expect(#characterKills).toEqual(1) + + controller:Destroy() + end) + + it("reject the wrong instance class", function() + local controller = DeathReportTestUtils.setup() + local service = controller.deathReportService + local folder = Instance.new("Folder") + + expect(function() + service:ObservePlayerDeathReports(folder :: any) + end).toThrow() + expect(function() + service:ObservePlayerKillerReports(folder :: any) + end).toThrow() + expect(function() + service:ObserveHumanoidDeathReports(folder :: any) + end).toThrow() + expect(function() + service:ObserveHumanoidKillerReports(folder :: any) + end).toThrow() + expect(function() + service:ObserveCharacterDeathReports(folder :: any) + end).toThrow() + expect(function() + service:ObserveCharacterKillerReports(folder :: any) + end).toThrow() + + folder:Destroy() + controller:Destroy() + end) +end) + +describe("DeathReportService weapon data", function() + it("attaches the answer of the first retriever that resolves a weapon", function() + local controller = DeathReportTestUtils.setup() + local service = controller.deathReportService + local _npc, npcHumanoid = controller.newNpc() + local weapon = Instance.new("Tool") + controller.maid:GiveTask(weapon) + + local reports = collectReports(controller) + + service:AddWeaponDataRetriever(function() + return nil + end) + local removeRetriever = service:AddWeaponDataRetriever(function(humanoid: Humanoid) + expect(humanoid).toBe(npcHumanoid) + return DeathReportUtils.createWeaponData(weapon) + end) + + service:ReportHumanoidDeath(npcHumanoid) + expect(reports[1].weaponData.weaponInstance).toBe(weapon) + + removeRetriever() + + service:ReportHumanoidDeath(npcHumanoid) + expect(reports[2].weaponData.weaponInstance).toBeNil() + + controller:Destroy() + end) + + it("prefers explicitly passed weapon data over the retrievers", function() + local controller = DeathReportTestUtils.setup() + local service = controller.deathReportService + local _npc, npcHumanoid = controller.newNpc() + local weapon = Instance.new("Tool") + controller.maid:GiveTask(weapon) + + local reports = collectReports(controller) + + service:AddWeaponDataRetriever(function() + error("Should not be asked") + end) + + service:ReportHumanoidDeath(npcHumanoid, DeathReportUtils.createWeaponData(weapon)) + expect(reports[1].weaponData.weaponInstance).toBe(weapon) + + controller:Destroy() + end) + + it("rejects a retriever that returns invalid weapon data", function() + local controller = DeathReportTestUtils.setup() + local service = controller.deathReportService + local _npc, npcHumanoid = controller.newNpc() + + service:AddWeaponDataRetriever(function() + return { weaponInstance = 5 } :: any + end) + + expect(function() + service:FindWeaponData(npcHumanoid) + end).toThrow() + + controller:Destroy() + end) + + it("rejects a non-function retriever", function() + local controller = DeathReportTestUtils.setup() + + expect(function() + controller.deathReportService:AddWeaponDataRetriever(nil :: any) + end).toThrow() + + controller:Destroy() + end) +end) + +describe("DeathReportService.ReportDeathReport(deathReport)", function() + it("fires NewDeathReport with the report as given", function() + local controller = DeathReportTestUtils.setup() + local npc = controller.newNpc() + + local reports = collectReports(controller) + + local report = DeathReportUtils.create(npc) + controller.deathReportService:ReportDeathReport(report) + + expect(reports).toEqual({ report }) + expect(controller.deathReportService:GetLastDeathReports()).toEqual({ report }) + + controller:Destroy() + end) + + it("records the report in the bag's DeathReportDataService", function() + local controller = DeathReportTestUtils.setup() + local service = controller.deathReportService + local dataService = controller.serverBag:GetService(DeathReportDataService) + local npc = controller.newNpc() + + expect(service.NewDeathReport).toBe(dataService.NewDeathReport) + + local report = DeathReportUtils.create(npc) + service:ReportDeathReport(report) + + expect(dataService:GetLastDeathReports()).toEqual({ report }) + + controller:Destroy() + end) + + it("rejects a value that is not a report", function() + local controller = DeathReportTestUtils.setup() + + expect(function() + controller.deathReportService:ReportDeathReport({} :: any) + end).toThrow() + + controller:Destroy() + end) +end) diff --git a/src/deathreport/src/Server/DeathTrackedHumanoid.spec.lua b/src/deathreport/src/Server/DeathTrackedHumanoid.spec.lua new file mode 100644 index 00000000000..fb5759c028e --- /dev/null +++ b/src/deathreport/src/Server/DeathTrackedHumanoid.spec.lua @@ -0,0 +1,81 @@ +--!strict +--[[ + @class DeathTrackedHumanoid.spec.lua +]] + +local require = require(script.Parent.loader).load(script) + +local DeathReportTestUtils = require("DeathReportTestUtils") +local DeathTrackedHumanoid = require("DeathTrackedHumanoid") +local Jest = require("Jest") +local PlayerMock = require("PlayerMock") + +local describe = Jest.Globals.describe +local expect = Jest.Globals.expect +local it = Jest.Globals.it + +describe("DeathTrackedHumanoid", function() + it("binds the humanoid of a mock character", function() + local controller = DeathReportTestUtils.setup() + local binder = controller.serverBag:GetService(DeathTrackedHumanoid) + local mock = controller.newMock() + local _character, humanoid = controller.newCharacter(mock) + + local class = binder:Get(humanoid) + expect(class).toBeDefined() + expect((class :: any).ClassName).toEqual("DeathTrackedHumanoid") + + controller:Destroy() + end) + + it("unbinds when the character is removed", function() + local controller = DeathReportTestUtils.setup() + local binder = controller.serverBag:GetService(DeathTrackedHumanoid) + local mock = controller.newMock() + local _character, humanoid = controller.newCharacter(mock) + + PlayerMock.removeCharacter(mock) + DeathReportTestUtils.awaitUnbound(binder, humanoid) + + expect(binder:Get(humanoid)).toBeNil() + + controller:Destroy() + end) + + it("reports the death to the service exactly once", function() + local controller = DeathReportTestUtils.setup() + local mock = controller.newMock() + local _character, humanoid = controller.newCharacter(mock) + + local reportCount = 0 + controller.maid:GiveTask(controller.deathReportService.NewDeathReport:Connect(function() + reportCount += 1 + end)) + + humanoid.Health = 0 + expect(reportCount).toEqual(1) + + humanoid.Health = 100 + humanoid.Health = 0 + expect(reportCount).toEqual(1) + + controller:Destroy() + end) + + it("does not report while the humanoid is alive", function() + local controller = DeathReportTestUtils.setup() + local mock = controller.newMock() + local _character, humanoid = controller.newCharacter(mock) + + local reportCount = 0 + controller.maid:GiveTask(controller.deathReportService.NewDeathReport:Connect(function() + reportCount += 1 + end)) + + humanoid.Health = 50 + humanoid.Health = 1 + expect(reportCount).toEqual(0) + + controller:Destroy() + end) +end) diff --git a/src/deathreport/src/Server/Stats/PlayerDeathTracker.lua b/src/deathreport/src/Server/Stats/PlayerDeathTracker.lua index 05f0d8e18ed..0d3152edea3 100644 --- a/src/deathreport/src/Server/Stats/PlayerDeathTracker.lua +++ b/src/deathreport/src/Server/Stats/PlayerDeathTracker.lua @@ -1,5 +1,12 @@ --!strict --[=[ + Counts the deaths of one player. Bound to every [Player] automatically through a + [PlayerBinder]; every death report for that player increments a replicated [IntValue] kept + under the player. + + Retrieve the binder from a [ServiceBag] with `serviceBag:GetService(require("PlayerDeathTracker"))`. + + @server @class PlayerDeathTracker ]=] @@ -7,6 +14,12 @@ local require = require(script.Parent.loader).load(script) local BaseObject = require("BaseObject") local DeathReportService = require("DeathReportService") +local DeathReportServiceConstants = require("DeathReportServiceConstants") +local Observable = require("Observable") +local PlayerBinder = require("PlayerBinder") +local PlayerDeathTrackerInterface = require("PlayerDeathTrackerInterface") +local PlayerMock = require("PlayerMock") +local RxValueBaseUtils = require("RxValueBaseUtils") local ServiceBag = require("ServiceBag") local PlayerDeathTracker = setmetatable({}, BaseObject) @@ -16,31 +29,77 @@ PlayerDeathTracker.__index = PlayerDeathTracker export type PlayerDeathTracker = typeof(setmetatable( {} :: { - _obj: IntValue, + _obj: Player, _serviceBag: ServiceBag.ServiceBag, _deathReportService: DeathReportService.DeathReportService, - _player: Player, + _deathValue: IntValue, }, {} :: typeof({ __index = PlayerDeathTracker }) )) & BaseObject.BaseObject -function PlayerDeathTracker.new(scoreObject: IntValue, serviceBag: ServiceBag.ServiceBag): PlayerDeathTracker - local self: PlayerDeathTracker = setmetatable(BaseObject.new(scoreObject) :: any, PlayerDeathTracker) +--[=[ + Constructs a new PlayerDeathTracker. Should be done via the binder. + + @param player Player + @param serviceBag ServiceBag + @return PlayerDeathTracker +]=] +function PlayerDeathTracker.new(player: Player, serviceBag: ServiceBag.ServiceBag): PlayerDeathTracker + assert(typeof(player) == "Instance" and (player:IsA("Player") or PlayerMock.isMock(player)), "Bad player") + + local self: PlayerDeathTracker = setmetatable(BaseObject.new(player) :: any, PlayerDeathTracker) self._serviceBag = assert(serviceBag, "No serviceBag") self._deathReportService = self._serviceBag:GetService(DeathReportService) :: any - local player = self._obj.Parent - assert(player and player:IsA("Player"), "Bad player") - self._player = player + self._deathValue = self._maid:Add(Instance.new("IntValue")) + self._deathValue.Name = DeathReportServiceConstants.PLAYER_DEATH_VALUE_NAME + self._deathValue.Value = 0 + self._deathValue.Parent = self._obj - self._maid:GiveTask(self._deathReportService:ObservePlayerDeathReports(self._player):Subscribe(function(deathReport) - assert(deathReport.player == self._player, "Bad player") - self._obj.Value = self._obj.Value + 1 + self._maid:GiveTask(self._deathReportService:ObservePlayerDeathReports(self._obj):Subscribe(function(deathReport) + assert(deathReport.player == self._obj, "Bad player") + self._deathValue.Value = self._deathValue.Value + 1 end)) + self._maid:GiveTask(PlayerDeathTrackerInterface.Server:Implement(self._obj, self)) + return self end -return PlayerDeathTracker +--[=[ + Returns the replicated value holding the death count + @return IntValue +]=] +function PlayerDeathTracker.GetDeathValue(self: PlayerDeathTracker): IntValue + return self._deathValue +end + +--[=[ + Returns the player whose deaths are tracked + @return Player +]=] +function PlayerDeathTracker.GetPlayer(self: PlayerDeathTracker): Player + return self._obj +end + +--[=[ + Returns the number of deaths of the player + @return number +]=] +function PlayerDeathTracker.GetDeaths(self: PlayerDeathTracker): number + return self._deathValue.Value +end + +--[=[ + Observes the number of deaths of the player +]=] +function PlayerDeathTracker.ObserveDeaths(self: PlayerDeathTracker): Observable.Observable + return RxValueBaseUtils.observeValue(self._deathValue) +end + +return PlayerBinder.new( + "PlayerDeathTracker", + PlayerDeathTracker :: any + ) :: PlayerBinder.PlayerBinder diff --git a/src/deathreport/src/Server/Stats/PlayerDeathTracker.spec.lua b/src/deathreport/src/Server/Stats/PlayerDeathTracker.spec.lua new file mode 100644 index 00000000000..f04d544093c --- /dev/null +++ b/src/deathreport/src/Server/Stats/PlayerDeathTracker.spec.lua @@ -0,0 +1,112 @@ +--!strict +--[[ + @class PlayerDeathTracker.spec.lua +]] + +local require = require(script.Parent.loader).load(script) + +local DeathReportServiceConstants = require("DeathReportServiceConstants") +local DeathReportTestUtils = require("DeathReportTestUtils") +local Jest = require("Jest") +local PlayerDeathTracker = require("PlayerDeathTracker") +local PlayerDeathTrackerInterface = require("PlayerDeathTrackerInterface") + +local describe = Jest.Globals.describe +local expect = Jest.Globals.expect +local it = Jest.Globals.it + +local function awaitTracker(controller: any, mock: Player): PlayerDeathTracker.PlayerDeathTracker + return DeathReportTestUtils.awaitBound(controller.playerDeathTrackerBinder, mock) +end + +describe("PlayerDeathTracker", function() + it("binds to every player and starts at zero", function() + local controller = DeathReportTestUtils.setup() + local mock = controller.newMock() + local tracker = awaitTracker(controller, mock) + + expect(tracker:GetPlayer()).toBe(mock) + expect(tracker:GetDeaths()).toEqual(0) + + local deathValue = tracker:GetDeathValue() + expect(deathValue.Parent).toBe(mock) + expect(deathValue.Name).toEqual(DeathReportServiceConstants.PLAYER_DEATH_VALUE_NAME) + expect(deathValue.Value).toEqual(0) + + controller:Destroy() + end) + + it("counts the deaths of the player", function() + local controller = DeathReportTestUtils.setup() + local victim = controller.newMock() + local _victimCharacter, victimHumanoid = controller.newCharacter(victim) + local tracker = awaitTracker(controller, victim) + + controller.kill(victimHumanoid) + + expect(tracker:GetDeaths()).toEqual(1) + + controller:Destroy() + end) + + it("counts attributed deaths of the player", function() + local controller = DeathReportTestUtils.setup() + local killer = controller.newMock() + controller.newCharacter(killer) + + local victim = controller.newMock() + local _victimCharacter, victimHumanoid = controller.newCharacter(victim) + local tracker = awaitTracker(controller, victim) + + controller.kill(victimHumanoid, killer) + + expect(tracker:GetDeaths()).toEqual(1) + + controller:Destroy() + end) + + it("does not count the kills the player scores", function() + local controller = DeathReportTestUtils.setup() + local killer = controller.newMock() + controller.newCharacter(killer) + local killerTracker = awaitTracker(controller, killer) + + local victim = controller.newMock() + local _victimCharacter, victimHumanoid = controller.newCharacter(victim) + + controller.kill(victimHumanoid, killer) + + expect(killerTracker:GetDeaths()).toEqual(0) + + controller:Destroy() + end) + + it("removes its value once unbound", function() + local controller = DeathReportTestUtils.setup() + local victim = controller.newMock() + local deathValue = awaitTracker(controller, victim):GetDeathValue() + + controller.playerDeathTrackerBinder:Unbind(victim) + DeathReportTestUtils.awaitUnbound(controller.playerDeathTrackerBinder, victim) + + expect(deathValue.Parent).toBeNil() + expect(victim:FindFirstChild(DeathReportServiceConstants.PLAYER_DEATH_VALUE_NAME)).toBeNil() + + controller:Destroy() + end) + + it("implements PlayerDeathTrackerInterface on the player", function() + local controller = DeathReportTestUtils.setup() + local mock = controller.newMock() + local tracker = awaitTracker(controller, mock) + + local implementation = PlayerDeathTrackerInterface.Server:Find(mock) + assert(implementation, "No implementation") + + expect(implementation:GetPlayer()).toBe(mock) + expect(implementation:GetDeathValue()).toBe(tracker:GetDeathValue()) + expect(implementation:GetDeaths()).toEqual(0) + + controller:Destroy() + end) +end) diff --git a/src/deathreport/src/Server/Stats/PlayerKillTracker.lua b/src/deathreport/src/Server/Stats/PlayerKillTracker.lua index 329636ae711..750994aa7f6 100644 --- a/src/deathreport/src/Server/Stats/PlayerKillTracker.lua +++ b/src/deathreport/src/Server/Stats/PlayerKillTracker.lua @@ -1,5 +1,12 @@ --!strict --[=[ + Counts the kills scored by one player. Bound to every [Player] automatically through a + [PlayerBinder]; every death report whose killer is that player increments a replicated + [IntValue] kept under the player. + + Retrieve the binder from a [ServiceBag] with `serviceBag:GetService(require("PlayerKillTracker"))`. + + @server @class PlayerKillTracker ]=] @@ -7,6 +14,12 @@ local require = require(script.Parent.loader).load(script) local BaseObject = require("BaseObject") local DeathReportService = require("DeathReportService") +local DeathReportServiceConstants = require("DeathReportServiceConstants") +local Observable = require("Observable") +local PlayerBinder = require("PlayerBinder") +local PlayerKillTrackerInterface = require("PlayerKillTrackerInterface") +local PlayerMock = require("PlayerMock") +local RxValueBaseUtils = require("RxValueBaseUtils") local ServiceBag = require("ServiceBag") local PlayerKillTracker = setmetatable({}, BaseObject) @@ -16,45 +29,74 @@ PlayerKillTracker.__index = PlayerKillTracker export type PlayerKillTracker = typeof(setmetatable( {} :: { - _obj: IntValue, + _obj: Player, _serviceBag: ServiceBag.ServiceBag, _deathReportService: DeathReportService.DeathReportService, - _player: Player, + _killValue: IntValue, }, {} :: typeof({ __index = PlayerKillTracker }) )) & BaseObject.BaseObject -function PlayerKillTracker.new(scoreObject: IntValue, serviceBag: ServiceBag.ServiceBag): PlayerKillTracker - local self: PlayerKillTracker = setmetatable(BaseObject.new(scoreObject) :: any, PlayerKillTracker) +--[=[ + Constructs a new PlayerKillTracker. Should be done via the binder. + + @param player Player + @param serviceBag ServiceBag + @return PlayerKillTracker +]=] +function PlayerKillTracker.new(player: Player, serviceBag: ServiceBag.ServiceBag): PlayerKillTracker + assert(typeof(player) == "Instance" and (player:IsA("Player") or PlayerMock.isMock(player)), "Bad player") + + local self: PlayerKillTracker = setmetatable(BaseObject.new(player) :: any, PlayerKillTracker) self._serviceBag = assert(serviceBag, "No serviceBag") self._deathReportService = self._serviceBag:GetService(DeathReportService) :: any - local player = self._obj.Parent - assert(player and player:IsA("Player"), "Bad player") - self._player = player + self._killValue = self._maid:Add(Instance.new("IntValue")) + self._killValue.Name = DeathReportServiceConstants.PLAYER_KILL_VALUE_NAME + self._killValue.Value = 0 + self._killValue.Parent = self._obj + + self._maid:GiveTask(self._deathReportService:ObservePlayerKillerReports(self._obj):Subscribe(function(deathReport) + assert(deathReport.killerPlayer == self._obj, "Bad player") + self._killValue.Value = self._killValue.Value + 1 + end)) - self._maid:GiveTask( - self._deathReportService:ObservePlayerKillerReports(self._player):Subscribe(function(deathReport) - assert(deathReport.killerPlayer == self._player, "Bad player") - self._obj.Value = self._obj.Value + 1 - end) - ) + self._maid:GiveTask(PlayerKillTrackerInterface.Server:Implement(self._obj, self)) return self end +--[=[ + Returns the replicated value holding the kill count + @return IntValue +]=] function PlayerKillTracker.GetKillValue(self: PlayerKillTracker): IntValue - return self._obj + return self._killValue end +--[=[ + Returns the player whose kills are tracked + @return Player +]=] function PlayerKillTracker.GetPlayer(self: PlayerKillTracker): Player - return self._player + return self._obj end +--[=[ + Returns the number of kills scored by the player + @return number +]=] function PlayerKillTracker.GetKills(self: PlayerKillTracker): number - return self._obj.Value + return self._killValue.Value +end + +--[=[ + Observes the number of kills scored by the player +]=] +function PlayerKillTracker.ObserveKills(self: PlayerKillTracker): Observable.Observable + return RxValueBaseUtils.observeValue(self._killValue) end -return PlayerKillTracker +return PlayerBinder.new("PlayerKillTracker", PlayerKillTracker :: any) :: PlayerBinder.PlayerBinder diff --git a/src/deathreport/src/Server/Stats/PlayerKillTracker.spec.lua b/src/deathreport/src/Server/Stats/PlayerKillTracker.spec.lua new file mode 100644 index 00000000000..72f96013b2e --- /dev/null +++ b/src/deathreport/src/Server/Stats/PlayerKillTracker.spec.lua @@ -0,0 +1,118 @@ +--!strict +--[[ + @class PlayerKillTracker.spec.lua +]] + +local require = require(script.Parent.loader).load(script) + +local DeathReportServiceConstants = require("DeathReportServiceConstants") +local DeathReportTestUtils = require("DeathReportTestUtils") +local Jest = require("Jest") +local PlayerKillTracker = require("PlayerKillTracker") +local PlayerKillTrackerInterface = require("PlayerKillTrackerInterface") + +local describe = Jest.Globals.describe +local expect = Jest.Globals.expect +local it = Jest.Globals.it + +local function awaitTracker(controller: any, mock: Player): PlayerKillTracker.PlayerKillTracker + return DeathReportTestUtils.awaitBound(controller.playerKillTrackerBinder, mock) +end + +describe("PlayerKillTracker", function() + it("binds to every player and starts at zero", function() + local controller = DeathReportTestUtils.setup() + local mock = controller.newMock() + local tracker = awaitTracker(controller, mock) + + expect(tracker:GetPlayer()).toBe(mock) + expect(tracker:GetKills()).toEqual(0) + + local killValue = tracker:GetKillValue() + expect(killValue.Parent).toBe(mock) + expect(killValue.Name).toEqual(DeathReportServiceConstants.PLAYER_KILL_VALUE_NAME) + expect(killValue.Value).toEqual(0) + + controller:Destroy() + end) + + it("counts the kills the player scores", function() + local controller = DeathReportTestUtils.setup() + local killer = controller.newMock() + controller.newCharacter(killer) + local tracker = awaitTracker(controller, killer) + + local victim = controller.newMock() + local _victimCharacter, victimHumanoid = controller.newCharacter(victim) + + controller.kill(victimHumanoid, killer) + + expect(tracker:GetKills()).toEqual(1) + + controller:Destroy() + end) + + it("does not count the deaths of the player", function() + local controller = DeathReportTestUtils.setup() + local killer = controller.newMock() + controller.newCharacter(killer) + + local victim = controller.newMock() + local _victimCharacter, victimHumanoid = controller.newCharacter(victim) + local victimTracker = awaitTracker(controller, victim) + + controller.kill(victimHumanoid, killer) + + expect(victimTracker:GetKills()).toEqual(0) + + controller:Destroy() + end) + + it("does not count kills scored by other players", function() + local controller = DeathReportTestUtils.setup() + local killer = controller.newMock() + controller.newCharacter(killer) + + local bystander = controller.newMock() + local bystanderTracker = awaitTracker(controller, bystander) + + local victim = controller.newMock() + local _victimCharacter, victimHumanoid = controller.newCharacter(victim) + + controller.kill(victimHumanoid, killer) + + expect(bystanderTracker:GetKills()).toEqual(0) + + controller:Destroy() + end) + + it("removes its value once unbound", function() + local controller = DeathReportTestUtils.setup() + local killer = controller.newMock() + controller.newCharacter(killer) + local killValue = awaitTracker(controller, killer):GetKillValue() + + controller.playerKillTrackerBinder:Unbind(killer) + DeathReportTestUtils.awaitUnbound(controller.playerKillTrackerBinder, killer) + + expect(killValue.Parent).toBeNil() + expect(killer:FindFirstChild(DeathReportServiceConstants.PLAYER_KILL_VALUE_NAME)).toBeNil() + + controller:Destroy() + end) + + it("implements PlayerKillTrackerInterface on the player", function() + local controller = DeathReportTestUtils.setup() + local mock = controller.newMock() + local tracker = awaitTracker(controller, mock) + + local implementation = PlayerKillTrackerInterface.Server:Find(mock) + assert(implementation, "No implementation") + + expect(implementation:GetPlayer()).toBe(mock) + expect(implementation:GetKillValue()).toBe(tracker:GetKillValue()) + expect(implementation:GetKills()).toEqual(0) + + controller:Destroy() + end) +end) diff --git a/src/deathreport/src/Server/Stats/PlayerKillTrackerAssigner.lua b/src/deathreport/src/Server/Stats/PlayerKillTrackerAssigner.lua deleted file mode 100644 index 78ea7b96afc..00000000000 --- a/src/deathreport/src/Server/Stats/PlayerKillTrackerAssigner.lua +++ /dev/null @@ -1,93 +0,0 @@ ---!strict ---[=[ - @class PlayerKillTrackerAssigner -]=] - -local require = require(script.Parent.loader).load(script) - -local Players = game:GetService("Players") - -local BaseObject = require("BaseObject") -local DeathReportBindersServer = require("DeathReportBindersServer") -local Maid = require("Maid") -local PlayerKillTrackerUtils = require("PlayerKillTrackerUtils") -local ServiceBag = require("ServiceBag") - -local PlayerKillTrackerAssigner = setmetatable({}, BaseObject) -PlayerKillTrackerAssigner.ClassName = "PlayerKillTrackerAssigner" -PlayerKillTrackerAssigner.__index = PlayerKillTrackerAssigner - -export type PlayerKillTrackerAssigner = - typeof(setmetatable( - {} :: { - _serviceBag: ServiceBag.ServiceBag, - _deathReportBindersServer: any, - _killTrackers: { [Player]: Instance }, - }, - {} :: typeof({ __index = PlayerKillTrackerAssigner }) - )) - & BaseObject.BaseObject - -function PlayerKillTrackerAssigner.new(serviceBag: ServiceBag.ServiceBag): PlayerKillTrackerAssigner - local self: PlayerKillTrackerAssigner = setmetatable(BaseObject.new() :: any, PlayerKillTrackerAssigner) - - self._serviceBag = assert(serviceBag, "No serviceBag") - self._deathReportBindersServer = self._serviceBag:GetService(DeathReportBindersServer) - - self._killTrackers = {} - - self._maid:GiveTask(Players.PlayerAdded:Connect(function(player) - self:_handlePlayerAdded(player) - end)) - self._maid:GiveTask(Players.PlayerRemoving:Connect(function(player) - self:_handlePlayerRemoving(player) - end)) - - for _, player in Players:GetPlayers() do - self:_handlePlayerAdded(player) - end - - return self -end - -function PlayerKillTrackerAssigner.GetPlayerKills(self: PlayerKillTrackerAssigner, player: Player): number? - local tracker = self:GetPlayerKillTracker(player) - if tracker then - return tracker:GetKills() - else - return nil - end -end - -function PlayerKillTrackerAssigner.GetPlayerKillTracker(self: PlayerKillTrackerAssigner, player: Player) - local trackerInstance = self._killTrackers[player] - if trackerInstance then - return self._deathReportBindersServer.PlayerKillTracker:Get(trackerInstance) - else - return nil - end -end - -function PlayerKillTrackerAssigner._handlePlayerRemoving(self: PlayerKillTrackerAssigner, player: Player) - self._maid[player] = nil -end - -function PlayerKillTrackerAssigner._handlePlayerAdded(self: PlayerKillTrackerAssigner, player: Player) - local maid = Maid.new() - - local killTracker = PlayerKillTrackerUtils.create(self._deathReportBindersServer.PlayerKillTracker, player) - maid:GiveTask(killTracker) - - self._killTrackers[player] = killTracker - - maid:GiveTask(function() - self._killTrackers[player] = nil - end) - - local deathTracker = PlayerKillTrackerUtils.create(self._deathReportBindersServer.PlayerDeathTracker, player) - maid:GiveTask(deathTracker) - - self._maid[player] = maid -end - -return PlayerKillTrackerAssigner diff --git a/src/deathreport/src/Server/Stats/TeamKillTracker.lua b/src/deathreport/src/Server/Stats/TeamKillTracker.lua index 96ad43c85b2..cdf5f011eb7 100644 --- a/src/deathreport/src/Server/Stats/TeamKillTracker.lua +++ b/src/deathreport/src/Server/Stats/TeamKillTracker.lua @@ -1,14 +1,26 @@ --!strict --[=[ + Counts the kills scored by one team. Bound to an [IntValue] parented under a [Team]; every death + report whose killer is on that team increments the value. + + Retrieve the binder from a [ServiceBag] with `serviceBag:GetService(require("TeamKillTracker"))` + and create tracked values with [TeamKillTrackerUtils.create]. + + @server @class TeamKillTracker ]=] local require = require(script.Parent.loader).load(script) local BaseObject = require("BaseObject") +local Binder = require("Binder") local DeathReportService = require("DeathReportService") local DeathReportUtils = require("DeathReportUtils") +local Observable = require("Observable") +local PlayerMock = require("PlayerMock") +local RxValueBaseUtils = require("RxValueBaseUtils") local ServiceBag = require("ServiceBag") +local TeamKillTrackerInterface = require("TeamKillTrackerInterface") local TeamKillTracker = setmetatable({}, BaseObject) TeamKillTracker.ClassName = "TeamKillTracker" @@ -17,6 +29,7 @@ TeamKillTracker.__index = TeamKillTracker export type TeamKillTracker = typeof(setmetatable( {} :: { + _obj: IntValue, _serviceBag: ServiceBag.ServiceBag, _deathReportService: DeathReportService.DeathReportService, _team: Team, @@ -25,40 +38,75 @@ export type TeamKillTracker = )) & BaseObject.BaseObject +--[=[ + Constructs a new TeamKillTracker. Should be done via the binder. + + @param scoreObject IntValue -- Parented under the [Team] to track + @param serviceBag ServiceBag + @return TeamKillTracker +]=] function TeamKillTracker.new(scoreObject: IntValue, serviceBag: ServiceBag.ServiceBag): TeamKillTracker local self: TeamKillTracker = setmetatable(BaseObject.new(scoreObject) :: any, TeamKillTracker) self._serviceBag = assert(serviceBag, "No serviceBag") self._deathReportService = self._serviceBag:GetService(DeathReportService) :: any - -- Hm.... this is suppose to be generic, but this is not.... + local team = self._obj.Parent + assert(team and team:IsA("Team"), "Bad team") + self._team = team + self._maid:GiveTask(self._deathReportService.NewDeathReport:Connect(function(deathReport) self:_handleDeathReport(deathReport) end)) - local team = (self._obj :: IntValue).Parent - assert(team and team:IsA("Team"), "Bad team") - self._team = team + self._maid:GiveTask(TeamKillTrackerInterface.Server:Implement(self._obj, self)) return self end +--[=[ + Returns the team whose kills are tracked + @return Instance? +]=] function TeamKillTracker.GetTeam(self: TeamKillTracker): Instance? - return (self._obj :: IntValue).Parent + return self._obj.Parent end +--[=[ + Returns the value holding the kill count + @return IntValue +]=] +function TeamKillTracker.GetKillValue(self: TeamKillTracker): IntValue + return self._obj +end + +--[=[ + Returns the number of kills scored by the team + @return number +]=] function TeamKillTracker.GetKills(self: TeamKillTracker): number - return (self._obj :: IntValue).Value + return self._obj.Value +end + +--[=[ + Observes the number of kills scored by the team +]=] +function TeamKillTracker.ObserveKills(self: TeamKillTracker): Observable.Observable + return RxValueBaseUtils.observeValue(self._obj) end function TeamKillTracker._handleDeathReport(self: TeamKillTracker, deathReport: DeathReportUtils.DeathReport) - if deathReport.killerPlayer then - if deathReport.killerPlayer.Team == self._team then - -- increment kills - local obj = self._obj :: IntValue - obj.Value = obj.Value + 1 - end + local killerPlayer = deathReport.killerPlayer + if not killerPlayer then + return + end + + local killerTeam = if PlayerMock.isMock(killerPlayer) + then PlayerMock.read(killerPlayer, "Team") + else killerPlayer.Team + if killerTeam == self._team then + self._obj.Value = self._obj.Value + 1 end end -return TeamKillTracker +return Binder.new("TeamKillTracker", TeamKillTracker :: any) :: Binder.Binder diff --git a/src/deathreport/src/Server/Stats/TeamKillTracker.spec.lua b/src/deathreport/src/Server/Stats/TeamKillTracker.spec.lua new file mode 100644 index 00000000000..fa776dd945e --- /dev/null +++ b/src/deathreport/src/Server/Stats/TeamKillTracker.spec.lua @@ -0,0 +1,140 @@ +--!strict +--[[ + @class TeamKillTracker.spec.lua +]] + +local require = require(script.Parent.loader).load(script) + +local DeathReportTestUtils = require("DeathReportTestUtils") +local Jest = require("Jest") +local TeamKillTracker = require("TeamKillTracker") +local TeamKillTrackerInterface = require("TeamKillTrackerInterface") +local TeamKillTrackerUtils = require("TeamKillTrackerUtils") + +local describe = Jest.Globals.describe +local expect = Jest.Globals.expect +local it = Jest.Globals.it + +local function newTrackedTeam(controller: any): (Team, TeamKillTracker.TeamKillTracker, IntValue) + local team = controller.newTeam() + local score = TeamKillTrackerUtils.create(controller.teamKillTrackerBinder) + score.Parent = team + + return team, DeathReportTestUtils.awaitBound(controller.teamKillTrackerBinder, score), score +end + +describe("TeamKillTracker", function() + it("binds under a team and starts at zero", function() + local controller = DeathReportTestUtils.setup() + local team, tracker = newTrackedTeam(controller) + + expect(tracker:GetTeam()).toBe(team) + expect(tracker:GetKills()).toEqual(0) + + controller:Destroy() + end) + + it("counts kills scored by players on the team", function() + local controller = DeathReportTestUtils.setup() + local team, tracker = newTrackedTeam(controller) + + local killer = controller.newMock() + controller.setTeam(killer, team) + controller.newCharacter(killer) + + local victim = controller.newMock() + local _victimCharacter, victimHumanoid = controller.newCharacter(victim) + + controller.kill(victimHumanoid, killer) + + expect(tracker:GetKills()).toEqual(1) + + controller:Destroy() + end) + + it("ignores kills scored by other teams", function() + local controller = DeathReportTestUtils.setup() + local _team, tracker = newTrackedTeam(controller) + local otherTeam = controller.newTeam() + + local killer = controller.newMock() + controller.setTeam(killer, otherTeam) + controller.newCharacter(killer) + + local victim = controller.newMock() + local _victimCharacter, victimHumanoid = controller.newCharacter(victim) + + controller.kill(victimHumanoid, killer) + + expect(tracker:GetKills()).toEqual(0) + + controller:Destroy() + end) + + it("ignores kills scored by players without a team", function() + local controller = DeathReportTestUtils.setup() + local _team, tracker = newTrackedTeam(controller) + + local killer = controller.newMock() + controller.newCharacter(killer) + + local victim = controller.newMock() + local _victimCharacter, victimHumanoid = controller.newCharacter(victim) + + controller.kill(victimHumanoid, killer) + + expect(tracker:GetKills()).toEqual(0) + + controller:Destroy() + end) + + it("ignores unattributed deaths", function() + local controller = DeathReportTestUtils.setup() + local team, tracker = newTrackedTeam(controller) + + local victim = controller.newMock() + controller.setTeam(victim, team) + local _victimCharacter, victimHumanoid = controller.newCharacter(victim) + + controller.kill(victimHumanoid) + + expect(tracker:GetKills()).toEqual(0) + + controller:Destroy() + end) + + it("stops counting once unbound", function() + local controller = DeathReportTestUtils.setup() + local team, _tracker, score = newTrackedTeam(controller) + + local killer = controller.newMock() + controller.setTeam(killer, team) + controller.newCharacter(killer) + + local victim = controller.newMock() + local _victimCharacter, victimHumanoid = controller.newCharacter(victim) + + controller.teamKillTrackerBinder:Unbind(score) + DeathReportTestUtils.awaitUnbound(controller.teamKillTrackerBinder, score) + + controller.kill(victimHumanoid, killer) + + expect(score.Value).toEqual(0) + + controller:Destroy() + end) + + it("implements TeamKillTrackerInterface on the tracked value", function() + local controller = DeathReportTestUtils.setup() + local team, _tracker, score = newTrackedTeam(controller) + + local implementation = TeamKillTrackerInterface.Server:Find(score) + assert(implementation, "No implementation") + + expect(implementation:GetTeam()).toBe(team) + expect(implementation:GetKillValue()).toBe(score) + expect(implementation:GetKills()).toEqual(0) + + controller:Destroy() + end) +end) diff --git a/src/deathreport/src/Shared/DeathReportDataService.lua b/src/deathreport/src/Shared/DeathReportDataService.lua new file mode 100644 index 00000000000..92f573167df --- /dev/null +++ b/src/deathreport/src/Shared/DeathReportDataService.lua @@ -0,0 +1,322 @@ +--!strict +--[=[ + Realm-agnostic core of death reporting. Owns the per-subject report observers, the + [DeathReportDataService.NewDeathReport] signal and the recent-report queue that + [DeathReportService] and [DeathReportServiceClient] both expose. Each realm's service + feeds it reports -- the server from [DeathTrackedHumanoid], the client from remoting -- + and aliases its observers. + + @class DeathReportDataService +]=] + +local require = require(script.Parent.loader).load(script) + +local Players = game:GetService("Players") + +local Brio = require("Brio") +local DeathReportUtils = require("DeathReportUtils") +local Maid = require("Maid") +local Observable = require("Observable") +local ObservableSubscriptionTable = require("ObservableSubscriptionTable") +local PlayerDeathTrackerInterface = require("PlayerDeathTrackerInterface") +local PlayerKillTrackerInterface = require("PlayerKillTrackerInterface") +local PlayerMock = require("PlayerMock") +local Rx = require("Rx") +local RxBrioUtils = require("RxBrioUtils") +local ServiceBag = require("ServiceBag") +local Signal = require("Signal") +local TeamKillTrackerInterface = require("TeamKillTrackerInterface") +local TieInterface = require("TieInterface") +local TieRealmService = require("TieRealmService") + +-- Note: don't make this too big without upgrading the way we handle the queue +local MAX_DEATH_REPORTS = 5 + +local DeathReportDataService = {} +DeathReportDataService.ServiceName = "DeathReportDataService" + +export type DeathReportDataService = typeof(setmetatable( + {} :: { + _serviceBag: ServiceBag.ServiceBag, + _tieRealmService: TieRealmService.TieRealmService, + _maid: Maid.Maid, + NewDeathReport: Signal.Signal, + _lastDeathReports: { DeathReportUtils.DeathReport }, + _playerKillerSubTable: ObservableSubscriptionTable.ObservableSubscriptionTable, + _playerDeathSubTable: ObservableSubscriptionTable.ObservableSubscriptionTable, + _humanoidKillerSubTable: ObservableSubscriptionTable.ObservableSubscriptionTable, + _humanoidDeathSubTable: ObservableSubscriptionTable.ObservableSubscriptionTable, + _characterKillerSubTable: ObservableSubscriptionTable.ObservableSubscriptionTable, + _characterDeathSubTable: ObservableSubscriptionTable.ObservableSubscriptionTable, + }, + {} :: typeof({ __index = DeathReportDataService }) +)) + +--[=[ + Initializes the service. Should be done via [ServiceBag]. +]=] +function DeathReportDataService.Init(self: DeathReportDataService, serviceBag: ServiceBag.ServiceBag) + assert(not (self :: any)._serviceBag, "Already initialized") + self._serviceBag = assert(serviceBag, "No serviceBag") + self._maid = Maid.new() + + -- External + self._tieRealmService = self._serviceBag:GetService(TieRealmService) :: any + + --[=[ + Fires with every [DeathReport] handled in this realm + @prop NewDeathReport Signal + @within DeathReportDataService +]=] + self.NewDeathReport = self._maid:Add(Signal.new()) :: any + + self._lastDeathReports = {} + + self._playerKillerSubTable = self._maid:Add(ObservableSubscriptionTable.new() :: any) + self._playerDeathSubTable = self._maid:Add(ObservableSubscriptionTable.new() :: any) + self._humanoidKillerSubTable = self._maid:Add(ObservableSubscriptionTable.new() :: any) + self._humanoidDeathSubTable = self._maid:Add(ObservableSubscriptionTable.new() :: any) + self._characterKillerSubTable = self._maid:Add(ObservableSubscriptionTable.new() :: any) + self._characterDeathSubTable = self._maid:Add(ObservableSubscriptionTable.new() :: any) + + self._maid:GiveTask(Players.PlayerRemoving:Connect(function(player) + self:_handlePlayerRemoving(player) + end)) + + -- Mocks are invisible to the Players service, so their removal is the counterpart of + -- PlayerRemoving. + self._maid:GiveTask(PlayerMock.getMockRemovingSignal():Connect(function(player) + self:_handlePlayerRemoving(player) + end)) +end + +function DeathReportDataService._handlePlayerRemoving(self: DeathReportDataService, player: Player) + self._playerKillerSubTable:Complete(player) + self._playerDeathSubTable:Complete(player) +end + +--[=[ + Observes killer reports for the given player +]=] +function DeathReportDataService.ObservePlayerKillerReports( + self: DeathReportDataService, + player: Player +): Observable.Observable + assert(typeof(player) == "Instance" and (player:IsA("Player") or PlayerMock.isMock(player)), "Bad player") + + return self._playerKillerSubTable:Observe(player) +end + +--[=[ + Observes death reports for the given player +]=] +function DeathReportDataService.ObservePlayerDeathReports( + self: DeathReportDataService, + player: Player +): Observable.Observable + assert(typeof(player) == "Instance" and (player:IsA("Player") or PlayerMock.isMock(player)), "Bad player") + + return self._playerDeathSubTable:Observe(player) +end + +--[=[ + Observes killer reports for the given humanoid +]=] +function DeathReportDataService.ObserveHumanoidKillerReports( + self: DeathReportDataService, + humanoid: Humanoid +): Observable.Observable + assert(typeof(humanoid) == "Instance" and humanoid:IsA("Humanoid"), "Bad humanoid") + + return self._humanoidKillerSubTable:Observe(humanoid) +end + +--[=[ + Observes death reports for the given humanoid +]=] +function DeathReportDataService.ObserveHumanoidDeathReports( + self: DeathReportDataService, + humanoid: Humanoid +): Observable.Observable + assert(typeof(humanoid) == "Instance" and humanoid:IsA("Humanoid"), "Bad humanoid") + + return self._humanoidDeathSubTable:Observe(humanoid) +end + +--[=[ + Observes killer reports for the given character +]=] +function DeathReportDataService.ObserveCharacterKillerReports( + self: DeathReportDataService, + character: Model +): Observable.Observable + assert(typeof(character) == "Instance" and character:IsA("Model"), "Bad character") + + return self._characterKillerSubTable:Observe(character) +end + +--[=[ + Observes death reports for the given character +]=] +function DeathReportDataService.ObserveCharacterDeathReports( + self: DeathReportDataService, + character: Model +): Observable.Observable + assert(typeof(character) == "Instance" and character:IsA("Model"), "Bad character") + + return self._characterDeathSubTable:Observe(character) +end + +--[=[ + Observes the [PlayerKillTracker] bound to the player, through [PlayerKillTrackerInterface] in + this realm. +]=] +function DeathReportDataService.ObservePlayerKillTrackerBrio( + self: DeathReportDataService, + player: Player +): Observable.Observable< + Brio.Brio> +> + assert(typeof(player) == "Instance" and (player:IsA("Player") or PlayerMock.isMock(player)), "Bad player") + + return PlayerKillTrackerInterface:ObserveBrio(player, self._tieRealmService:GetTieRealm()) :: any +end + +--[=[ + Observes the [PlayerDeathTracker] bound to the player, through [PlayerDeathTrackerInterface] in + this realm. +]=] +function DeathReportDataService.ObservePlayerDeathTrackerBrio( + self: DeathReportDataService, + player: Player +): Observable.Observable< + Brio.Brio> +> + assert(typeof(player) == "Instance" and (player:IsA("Player") or PlayerMock.isMock(player)), "Bad player") + + return PlayerDeathTrackerInterface:ObserveBrio(player, self._tieRealmService:GetTieRealm()) :: any +end + +--[=[ + Observes the [TeamKillTracker] bound under the team, through [TeamKillTrackerInterface] in this + realm. +]=] +function DeathReportDataService.ObserveTeamKillTrackerBrio( + self: DeathReportDataService, + team: Team +): Observable.Observable< + Brio.Brio> +> + assert(typeof(team) == "Instance" and team:IsA("Team"), "Bad team") + + return TeamKillTrackerInterface:ObserveChildrenBrio(team, self._tieRealmService:GetTieRealm()) :: any +end + +--[=[ + Observes the number of kills scored by the player. Emits nil -- rather than staying silent -- + while no [PlayerKillTracker] is bound to the player, so a subscriber always has a current answer. +]=] +function DeathReportDataService.ObservePlayerKillCount( + self: DeathReportDataService, + player: Player +): Observable.Observable + return self:ObservePlayerKillTrackerBrio(player):Pipe({ + RxBrioUtils.switchMapBrio(function(tracker) + return tracker:ObserveKills() + end) :: any, + RxBrioUtils.emitOnDeath(nil) :: any, + Rx.defaultsToNil :: any, + }) :: any +end + +--[=[ + Observes the number of deaths of the player. Emits nil -- rather than staying silent -- while no + [PlayerDeathTracker] is bound to the player. +]=] +function DeathReportDataService.ObservePlayerDeathCount( + self: DeathReportDataService, + player: Player +): Observable.Observable + return self:ObservePlayerDeathTrackerBrio(player):Pipe({ + RxBrioUtils.switchMapBrio(function(tracker) + return tracker:ObserveDeaths() + end) :: any, + RxBrioUtils.emitOnDeath(nil) :: any, + Rx.defaultsToNil :: any, + }) :: any +end + +--[=[ + Observes the number of kills scored by the team. Emits nil -- rather than staying silent -- while + no [TeamKillTracker] is bound under the team. +]=] +function DeathReportDataService.ObserveTeamKillCount( + self: DeathReportDataService, + team: Team +): Observable.Observable + return self:ObserveTeamKillTrackerBrio(team):Pipe({ + RxBrioUtils.switchMapBrio(function(tracker) + return tracker:ObserveKills() + end) :: any, + RxBrioUtils.emitOnDeath(nil) :: any, + Rx.defaultsToNil :: any, + }) :: any +end + +--[=[ + Gets the last recorded death reports, oldest first +]=] +function DeathReportDataService.GetLastDeathReports(self: DeathReportDataService): { DeathReportUtils.DeathReport } + return self._lastDeathReports +end + +--[=[ + Records a death report: remembers it, fires [DeathReportDataService.NewDeathReport] and routes + it to the observers. Replication is the realm service's job. +]=] +function DeathReportDataService.HandleDeathReport( + self: DeathReportDataService, + deathReport: DeathReportUtils.DeathReport +) + assert(DeathReportUtils.isDeathReport(deathReport), "Bad deathReport") + + -- Hack O(2*n) operation for death reports, but since n is really low, it's all good. + table.insert(self._lastDeathReports, deathReport) + while #self._lastDeathReports > MAX_DEATH_REPORTS do + table.remove(self._lastDeathReports, 1) + end + + self.NewDeathReport:Fire(deathReport) + + if deathReport.killerPlayer then + self._playerKillerSubTable:Fire(deathReport.killerPlayer, deathReport) + end + + if deathReport.killerHumanoid then + self._humanoidKillerSubTable:Fire(deathReport.killerHumanoid, deathReport) + + local character = deathReport.killerHumanoid.Parent + if character then + self._characterKillerSubTable:Fire(character, deathReport) + end + end + + if deathReport.player then + self._playerDeathSubTable:Fire(deathReport.player, deathReport) + end + + if deathReport.humanoid then + self._humanoidDeathSubTable:Fire(deathReport.humanoid, deathReport) + + local character = deathReport.humanoid.Parent + if character then + self._characterDeathSubTable:Fire(character, deathReport) + end + end +end + +function DeathReportDataService.Destroy(self: DeathReportDataService) + self._maid:DoCleaning() +end + +return DeathReportDataService diff --git a/src/deathreport/src/Shared/DeathReportDataService.spec.lua b/src/deathreport/src/Shared/DeathReportDataService.spec.lua new file mode 100644 index 00000000000..5704680626d --- /dev/null +++ b/src/deathreport/src/Shared/DeathReportDataService.spec.lua @@ -0,0 +1,390 @@ +--!strict +--[[ + @class DeathReportDataService.spec.lua +]] + +local require = require(script.Parent.loader).load(script) + +local Players = game:GetService("Players") + +local DeathReportDataService = require("DeathReportDataService") +local DeathReportTestUtils = require("DeathReportTestUtils") +local DeathReportUtils = require("DeathReportUtils") +local Jest = require("Jest") +local JestUtils = require("JestUtils") +local Maid = require("Maid") +local PlayerMock = require("PlayerMock") +local ServiceBag = require("ServiceBag") +local TeamKillTrackerUtils = require("TeamKillTrackerUtils") + +local describe = Jest.Globals.describe +local expect = Jest.Globals.expect +local it = Jest.Globals.it + +local USER_ID_BASE = 55500000 + +local specCounter = 0 + +local function setup(): any + specCounter += 1 + local suffix = specCounter + + local maid = Maid.new() + + local serviceBag = maid:Add(ServiceBag.new()) + local dataService = serviceBag:GetService(DeathReportDataService) + serviceBag:Init() + serviceBag:Start() + + local mockCounter = 0 + local function newMock(): Player + mockCounter += 1 + + local mock = PlayerMock.new({ UserId = USER_ID_BASE + suffix * 100 + mockCounter }) + mock.Parent = Players + maid:GiveTask(mock) + + return mock + end + + local function newCharacter(mock: Player): (Model, Humanoid) + local character = PlayerMock.loadMinimalCharacterAsync(mock) + return character, assert(character:FindFirstChildWhichIsA("Humanoid"), "No humanoid") + end + + local function newNpc(name: string?): (Model, Humanoid) + local character = Instance.new("Model") + character.Name = name or "Npc" + maid:GiveTask(character) + + local humanoid = Instance.new("Humanoid") + humanoid.Parent = character + + return character, humanoid + end + + local function collect(observable: any): { any } + local emissions = {} + maid:GiveTask(observable:Subscribe(function(value) + table.insert(emissions, value) + end)) + return emissions + end + + local controller = { + maid = maid, + serviceBag = serviceBag, + dataService = dataService, + newMock = newMock, + newCharacter = newCharacter, + newNpc = newNpc, + collect = collect, + Destroy = function(_self) + maid:DoCleaning() + end, + } + + maid:GiveTask(JestUtils.afterThis(controller)) + + return controller +end + +describe("DeathReportDataService.HandleDeathReport(deathReport)", function() + it("fires NewDeathReport and routes the report to every observer of the subjects involved", function() + local controller = setup() + local dataService = controller.dataService + local victim = controller.newMock() + local victimCharacter, victimHumanoid = controller.newCharacter(victim) + local killer = controller.newMock() + local killerCharacter, killerHumanoid = controller.newCharacter(killer) + + local reports = {} + controller.maid:GiveTask(dataService.NewDeathReport:Connect(function(report) + table.insert(reports, report) + end)) + local victimDeaths = controller.collect(dataService:ObservePlayerDeathReports(victim)) + local victimKills = controller.collect(dataService:ObservePlayerKillerReports(victim)) + local killerKills = controller.collect(dataService:ObservePlayerKillerReports(killer)) + local humanoidDeaths = controller.collect(dataService:ObserveHumanoidDeathReports(victimHumanoid)) + local characterDeaths = controller.collect(dataService:ObserveCharacterDeathReports(victimCharacter)) + local humanoidKills = controller.collect(dataService:ObserveHumanoidKillerReports(killerHumanoid)) + local characterKills = controller.collect(dataService:ObserveCharacterKillerReports(killerCharacter)) + + local report = DeathReportUtils.create(victimCharacter, killerHumanoid) + dataService:HandleDeathReport(report) + + expect(reports).toEqual({ report }) + expect(victimDeaths).toEqual({ report }) + expect(victimKills).toEqual({}) + expect(killerKills).toEqual({ report }) + expect(humanoidDeaths).toEqual({ report }) + expect(characterDeaths).toEqual({ report }) + expect(humanoidKills).toEqual({ report }) + expect(characterKills).toEqual({ report }) + + controller:Destroy() + end) + + it("does not notify observers of uninvolved subjects", function() + local controller = setup() + local dataService = controller.dataService + local victim = controller.newMock() + local victimCharacter = controller.newCharacter(victim) + local killer = controller.newMock() + local _killerCharacter, killerHumanoid = controller.newCharacter(killer) + local bystander = controller.newMock() + local bystanderCharacter, bystanderHumanoid = controller.newCharacter(bystander) + + local victimKills = controller.collect(dataService:ObservePlayerKillerReports(victim)) + local killerDeaths = controller.collect(dataService:ObservePlayerDeathReports(killer)) + local bystanderDeaths = controller.collect(dataService:ObservePlayerDeathReports(bystander)) + local bystanderKills = controller.collect(dataService:ObservePlayerKillerReports(bystander)) + local bystanderHumanoidDeaths = controller.collect(dataService:ObserveHumanoidDeathReports(bystanderHumanoid)) + local bystanderCharacterKills = + controller.collect(dataService:ObserveCharacterKillerReports(bystanderCharacter)) + + dataService:HandleDeathReport(DeathReportUtils.create(victimCharacter, killerHumanoid)) + + expect(victimKills).toEqual({}) + expect(killerDeaths).toEqual({}) + expect(bystanderDeaths).toEqual({}) + expect(bystanderKills).toEqual({}) + expect(bystanderHumanoidDeaths).toEqual({}) + expect(bystanderCharacterKills).toEqual({}) + + controller:Destroy() + end) + + it("routes an unattributed death only to the victim observers", function() + local controller = setup() + local dataService = controller.dataService + local victim = controller.newMock() + local victimCharacter = controller.newCharacter(victim) + + local playerDeaths = controller.collect(dataService:ObservePlayerDeathReports(victim)) + + local report = DeathReportUtils.create(victimCharacter) + dataService:HandleDeathReport(report) + + expect(playerDeaths).toEqual({ report }) + + controller:Destroy() + end) + + it("rejects a value that is not a report", function() + local controller = setup() + + expect(function() + controller.dataService:HandleDeathReport({} :: any) + end).toThrow() + + controller:Destroy() + end) +end) + +describe("DeathReportDataService.GetLastDeathReports()", function() + it("starts empty", function() + local controller = setup() + + expect(controller.dataService:GetLastDeathReports()).toEqual({}) + + controller:Destroy() + end) + + it("keeps only the most recent reports, oldest first", function() + local controller = setup() + local dataService = controller.dataService + + local npcs = {} + for index = 1, 7 do + local npc = controller.newNpc(string.format("Npc_%d", index)) + table.insert(npcs, npc) + dataService:HandleDeathReport(DeathReportUtils.create(npc)) + end + + local lastReports = dataService:GetLastDeathReports() + expect(#lastReports).toEqual(5) + expect(lastReports[1].adornee).toBe(npcs[3]) + expect(lastReports[5].adornee).toBe(npcs[7]) + + controller:Destroy() + end) +end) + +describe("DeathReportDataService observers", function() + it("complete the player observers when the mock player is removed", function() + local controller = setup() + local dataService = controller.dataService + local mock = controller.newMock() + + local completed = { deaths = false, kills = false } + controller.maid:GiveTask(dataService:ObservePlayerDeathReports(mock):Subscribe(nil, nil, function() + completed.deaths = true + end)) + controller.maid:GiveTask(dataService:ObservePlayerKillerReports(mock):Subscribe(nil, nil, function() + completed.kills = true + end)) + + mock:Destroy() + + expect(DeathReportTestUtils.waitFor(function() + return completed.deaths and completed.kills + end)).toBe(true) + + controller:Destroy() + end) + + it("reject the wrong instance class", function() + local controller = setup() + local dataService = controller.dataService + local folder = Instance.new("Folder") + + expect(function() + dataService:ObservePlayerDeathReports(folder :: any) + end).toThrow() + expect(function() + dataService:ObservePlayerKillerReports(folder :: any) + end).toThrow() + expect(function() + dataService:ObserveHumanoidDeathReports(folder :: any) + end).toThrow() + expect(function() + dataService:ObserveHumanoidKillerReports(folder :: any) + end).toThrow() + expect(function() + dataService:ObserveCharacterDeathReports(folder :: any) + end).toThrow() + expect(function() + dataService:ObserveCharacterKillerReports(folder :: any) + end).toThrow() + + folder:Destroy() + controller:Destroy() + end) +end) + +-- Emissions may be nil, so each one is boxed +local function collectBoxed(controller: any, observable: any): { { value: any } } + local emissions = {} + controller.maid:GiveTask(observable:Subscribe(function(value) + table.insert(emissions, { value = value }) + end)) + return emissions +end + +describe("DeathReportDataService tracker observers", function() + it("emit nil without a tracker and the count once one is bound", function() + local controller = DeathReportTestUtils.setup() + local dataService = controller.serverBag:GetService(DeathReportDataService) + local binder = controller.playerKillTrackerBinder + local mock = controller.newMock() + + DeathReportTestUtils.awaitBound(binder, mock) + binder:Unbind(mock) + DeathReportTestUtils.awaitUnbound(binder, mock) + + local kills = collectBoxed(controller, dataService:ObservePlayerKillCount(mock)) + expect(#kills).toEqual(1) + expect(kills[1].value).toBeNil() + + binder:Bind(mock) + DeathReportTestUtils.awaitBound(binder, mock) + + expect(kills[#kills].value).toEqual(0) + + binder:Unbind(mock) + DeathReportTestUtils.awaitUnbound(binder, mock) + + expect(kills[#kills].value).toBeNil() + + controller:Destroy() + end) + + it("observe player kills and deaths in the server realm", function() + local controller = DeathReportTestUtils.setup() + local dataService = controller.serverBag:GetService(DeathReportDataService) + local killer = controller.newMock() + controller.newCharacter(killer) + local victim = controller.newMock() + local _victimCharacter, victimHumanoid = controller.newCharacter(victim) + + DeathReportTestUtils.awaitBound(controller.playerKillTrackerBinder, killer) + DeathReportTestUtils.awaitBound(controller.playerDeathTrackerBinder, victim) + + local kills = collectBoxed(controller, dataService:ObservePlayerKillCount(killer)) + local deaths = collectBoxed(controller, dataService:ObservePlayerDeathCount(victim)) + expect(kills[#kills].value).toEqual(0) + expect(deaths[#deaths].value).toEqual(0) + + controller.kill(victimHumanoid, killer) + + expect(kills[#kills].value).toEqual(1) + expect(deaths[#deaths].value).toEqual(1) + + controller:Destroy() + end) + + it("observe team kills in the server realm", function() + local controller = DeathReportTestUtils.setup() + local dataService = controller.serverBag:GetService(DeathReportDataService) + local team = controller.newTeam() + local score = TeamKillTrackerUtils.create(controller.teamKillTrackerBinder) + score.Parent = team + DeathReportTestUtils.awaitBound(controller.teamKillTrackerBinder, score) + + local killer = controller.newMock() + controller.setTeam(killer, team) + controller.newCharacter(killer) + local victim = controller.newMock() + local _victimCharacter, victimHumanoid = controller.newCharacter(victim) + + local kills = collectBoxed(controller, dataService:ObserveTeamKillCount(team)) + expect(kills[#kills].value).toEqual(0) + + controller.kill(victimHumanoid, killer) + + expect(kills[#kills].value).toEqual(1) + + controller:Destroy() + end) + + it("observe the replicated trackers in the client realm", function() + local controller = DeathReportTestUtils.setup({ withClient = true }) + local dataService = controller.clientBag:GetService(DeathReportDataService) + local killer = controller.newMock() + controller.setLocalPlayer(killer) + controller.newCharacter(killer) + local victim = controller.newMock() + local _victimCharacter, victimHumanoid = controller.newCharacter(victim) + + DeathReportTestUtils.awaitBound(controller.playerKillTrackerClientBinder, killer) + + local kills = collectBoxed(controller, dataService:ObservePlayerKillCount(killer)) + + controller.kill(victimHumanoid, killer) + + expect(DeathReportTestUtils.waitFor(function() + return kills[#kills].value == 1 + end)).toBe(true) + + controller:Destroy() + end) + + it("reject the wrong instance class", function() + local controller = setup() + local folder = Instance.new("Folder") + + expect(function() + controller.dataService:ObservePlayerKillCount(folder :: any) + end).toThrow() + expect(function() + controller.dataService:ObservePlayerDeathCount(folder :: any) + end).toThrow() + expect(function() + controller.dataService:ObserveTeamKillCount(folder :: any) + end).toThrow() + + folder:Destroy() + controller:Destroy() + end) +end) diff --git a/src/deathreport/src/Shared/DeathReportProcessor.lua b/src/deathreport/src/Shared/DeathReportProcessor.lua deleted file mode 100644 index 4fa93fa5d2a..00000000000 --- a/src/deathreport/src/Shared/DeathReportProcessor.lua +++ /dev/null @@ -1,188 +0,0 @@ ---!strict ---[=[ - Handles shared observable subscription tables for the client and server - - @class DeathReportProcessor -]=] - -local require = require(script.Parent.loader).load(script) - -local Players = game:GetService("Players") - -local BaseObject = require("BaseObject") -local DeathReportUtils = require("DeathReportUtils") -local Observable = require("Observable") -local ObservableSubscriptionTable = require("ObservableSubscriptionTable") - -local DeathReportProcessor = setmetatable({}, BaseObject) -DeathReportProcessor.ClassName = "DeathReportProcessor" -DeathReportProcessor.__index = DeathReportProcessor - -export type DeathReportProcessor = - typeof(setmetatable( - {} :: { - _playerKillerSubTable: ObservableSubscriptionTable.ObservableSubscriptionTable< - DeathReportUtils.DeathReport - >, - _playerDeathSubTable: ObservableSubscriptionTable.ObservableSubscriptionTable, - _humanoidKillerSubTable: ObservableSubscriptionTable.ObservableSubscriptionTable< - DeathReportUtils.DeathReport - >, - _humanoidDeathSubTable: ObservableSubscriptionTable.ObservableSubscriptionTable< - DeathReportUtils.DeathReport - >, - _characterKillerSubTable: ObservableSubscriptionTable.ObservableSubscriptionTable< - DeathReportUtils.DeathReport - >, - _characterDeathSubTable: ObservableSubscriptionTable.ObservableSubscriptionTable< - DeathReportUtils.DeathReport - >, - }, - {} :: typeof({ __index = DeathReportProcessor }) - )) - & BaseObject.BaseObject - -function DeathReportProcessor.new(): DeathReportProcessor - local self = setmetatable(BaseObject.new() :: any, DeathReportProcessor) - - self._playerKillerSubTable = self._maid:Add(ObservableSubscriptionTable.new()) - self._playerDeathSubTable = self._maid:Add(ObservableSubscriptionTable.new()) - self._humanoidKillerSubTable = self._maid:Add(ObservableSubscriptionTable.new()) - self._humanoidDeathSubTable = self._maid:Add(ObservableSubscriptionTable.new()) - self._characterKillerSubTable = self._maid:Add(ObservableSubscriptionTable.new()) - self._characterDeathSubTable = self._maid:Add(ObservableSubscriptionTable.new()) - - self._maid:GiveTask(Players.PlayerRemoving:Connect(function(player) - self._playerKillerSubTable:Complete(player) - self._playerDeathSubTable:Complete(player) - end)) - - return self -end - ---[=[ - Observes killer reports for the given player - - @param player Player - @return Observable -]=] -function DeathReportProcessor.ObservePlayerKillerReports( - self: DeathReportProcessor, - player: Player -): Observable.Observable - assert(typeof(player) == "Instance" and player:IsA("Player"), "Bad player") - - return self._playerKillerSubTable:Observe(player) -end - ---[=[ - Observes death reports for the given player - - @param player Player - @return Observable -]=] -function DeathReportProcessor.ObservePlayerDeathReports( - self: DeathReportProcessor, - player: Player -): Observable.Observable - assert(typeof(player) == "Instance" and player:IsA("Player"), "Bad player") - - return self._playerDeathSubTable:Observe(player) -end - ---[=[ - Observes death reports for the given humanoid - - @param humanoid Humanoid - @return Observable -]=] -function DeathReportProcessor.ObserveHumanoidDeathReports( - self: DeathReportProcessor, - humanoid: Humanoid -): Observable.Observable - assert(typeof(humanoid) == "Instance" and humanoid:IsA("Humanoid"), "Bad humanoid") - - return self._humanoidDeathSubTable:Observe(humanoid) -end - ---[=[ - Observes killer reports for the given humanoid - - @param humanoid Humanoid - @return Observable -]=] -function DeathReportProcessor.ObserveHumanoidKillerReports( - self: DeathReportProcessor, - humanoid: Humanoid -): Observable.Observable - assert(typeof(humanoid) == "Instance" and humanoid:IsA("Humanoid"), "Bad humanoid") - - return self._humanoidKillerSubTable:Observe(humanoid) -end - ---[=[ - Observes killer reports for the given character - - @param character Model - @return Observable -]=] -function DeathReportProcessor.ObserveCharacterKillerReports( - self: DeathReportProcessor, - character: Model -): Observable.Observable - assert(typeof(character) == "Instance" and character:IsA("Model"), "Bad character") - - return self._characterKillerSubTable:Observe(character) -end - ---[=[ - Observes killer reports for the given character - - @param character Model - @return Observable -]=] -function DeathReportProcessor.ObserveCharacterDeathReports( - self: DeathReportProcessor, - character: Model -): Observable.Observable - assert(typeof(character) == "Instance" and character:IsA("Model"), "Bad character") - - return self._characterDeathSubTable:Observe(character) -end - ---[=[ - Handles the death report - - @param deathReport DeathReport -]=] -function DeathReportProcessor.HandleDeathReport(self: DeathReportProcessor, deathReport: DeathReportUtils.DeathReport) - assert(DeathReportUtils.isDeathReport(deathReport), "Bad deathreport") - - if deathReport.killerPlayer then - self._playerKillerSubTable:Fire(deathReport.killerPlayer, deathReport) - end - - if deathReport.killerHumanoid then - self._humanoidKillerSubTable:Fire(deathReport.killerHumanoid, deathReport) - - local character = deathReport.killerHumanoid.Parent - if character then - self._characterKillerSubTable:Fire(character, deathReport) - end - end - - if deathReport.player then - self._playerDeathSubTable:Fire(deathReport.player, deathReport) - end - - if deathReport.humanoid then - self._humanoidDeathSubTable:Fire(deathReport.humanoid, deathReport) - - local character = deathReport.humanoid.Parent - if character then - self._characterDeathSubTable:Fire(character, deathReport) - end - end -end - -return DeathReportProcessor diff --git a/src/deathreport/src/Shared/DeathReportServiceConstants.lua b/src/deathreport/src/Shared/DeathReportServiceConstants.lua index c7bb2565bd5..14309c06597 100644 --- a/src/deathreport/src/Shared/DeathReportServiceConstants.lua +++ b/src/deathreport/src/Shared/DeathReportServiceConstants.lua @@ -8,5 +8,10 @@ local require = require(script.Parent.loader).load(script) local Table = require("Table") return Table.readonly({ - REMOTE_EVENT_NAME = "DeathReportServiceRemoteEvent", + REMOTING_NAME = "DeathReportService", + DEATH_REPORTED_EVENT_NAME = "DeathReported", + + -- Replicated IntValues a [PlayerKillTracker] and [PlayerDeathTracker] keep under the player + PLAYER_KILL_VALUE_NAME = "PlayerKillTracker", + PLAYER_DEATH_VALUE_NAME = "PlayerDeathTracker", }) diff --git a/src/deathreport/src/Shared/DeathReportTestUtils.lua b/src/deathreport/src/Shared/DeathReportTestUtils.lua new file mode 100644 index 00000000000..1e0eec07f90 --- /dev/null +++ b/src/deathreport/src/Shared/DeathReportTestUtils.lua @@ -0,0 +1,277 @@ +--!nonstrict +--[=[ + Shared harness for the deathreport specs. [DeathReportTestUtils.setup] boots [DeathReportService] + the way production does -- through a [ServiceBag] -- and optionally a second, client-realm bag + running [DeathReportServiceClient] against it over dummy-mode remoting. Real players never join a + headless place, so players are [PlayerMock]s created through the server bag's [PlayerMockService], + and a character is spawned with [PlayerMock.loadMinimalCharacterAsync] so [DeathTrackedHumanoid] + discovers it like a real join. + + Binders are exposed as resolved by the bags (`teamKillTrackerBinder`, ...): a ServiceBag + instantiates its own isolated copy of every service it runs, so the module value a spec requires + is never the binder that was started, and binding through it fails. + + The test place is shared across a batch run, so every controller derives distinct user ids from a + module-level counter and tears down everything it created via `Destroy()` -- client bag first, then + the server bag that owns the mocks. + + @class DeathReportTestUtils +]=] + +local require = require(script.Parent.loader).load(script) + +local Teams = game:GetService("Teams") +local Workspace = game:GetService("Workspace") + +local DeathReportService = require("DeathReportService") +local DeathReportServiceClient = require("DeathReportServiceClient") +local DeathTrackedHumanoid = require("DeathTrackedHumanoid") +local HumanoidKillerUtils = require("HumanoidKillerUtils") +local JestUtils = require("JestUtils") +local Maid = require("Maid") +local PlayerDeathTracker = require("PlayerDeathTracker") +local PlayerDeathTrackerClient = require("PlayerDeathTrackerClient") +local PlayerKillTracker = require("PlayerKillTracker") +local PlayerKillTrackerClient = require("PlayerKillTrackerClient") +local PlayerMock = require("PlayerMock") +local PlayerMockService = require("PlayerMockService") +local PlayerMockServiceClient = require("PlayerMockServiceClient") +local ServiceBag = require("ServiceBag") +local TeamKillTracker = require("TeamKillTracker") +local TeamKillTrackerClient = require("TeamKillTrackerClient") +local TieRealmService = require("TieRealmService") +local TieRealms = require("TieRealms") + +local DeathReportTestUtils = {} + +local USER_ID_BASE = 55210000 + +local specCounter = 0 + +--[=[ + Polls until the condition passes or the timeout elapses. Returns the final condition result so + specs can `expect(...).toBe(true)` on it. + + @param condition () -> boolean + @param timeout number? + @return boolean +]=] +function DeathReportTestUtils.waitFor(condition, timeout) + local deadline = os.clock() + (timeout or 5) + while os.clock() < deadline do + if condition() then + return true + end + task.wait() + end + return condition() +end + +--[=[ + Yields until the binder has bound the instance and returns the bound class. + + @param binder Binder + @param inst Instance + @return T +]=] +function DeathReportTestUtils.awaitBound(binder, inst) + local ok, class = binder:Promise(inst):Yield() + assert(ok, string.format("%q never bound", binder:GetTag())) + return class +end + +--[=[ + Returns once `inst` is no longer bound. Removal is usually already done by the time we check; the + guarded wait also covers a deferred case. + + @param binder Binder + @param inst Instance +]=] +function DeathReportTestUtils.awaitUnbound(binder, inst) + if binder:Get(inst) ~= nil then + binder:GetClassRemovedSignal():Wait() + end +end + +--[=[ + Builds the controller the specs share. + + Fields: `maid`, `serverBag`, `deathReportService`, `playerMockService`, `container`, the server + binders `teamKillTrackerBinder`, `playerKillTrackerBinder`, `playerDeathTrackerBinder`, and with + `withClient`: `clientBag`, `deathReportServiceClient`, `playerMockServiceClient` and the client + binders `teamKillTrackerClientBinder`, `playerKillTrackerClientBinder`, `playerDeathTrackerClientBinder`. + Builders: `newMock(overrides?)` -> Player, `newCharacter(mock)` -> (Model, Humanoid) bound to + [DeathTrackedHumanoid], `newNpc(name?)` -> (Model, Humanoid) untracked, `newTeam(name?, brickColor?)` + -> Team. + Actions: `setTeam(mock, team?)`, `setLocalPlayer(mock)`, `kill(humanoid, killer?)`. + Lifecycle: `destroyClientBag()`, `destroyServerBag()`, `Destroy()`. + + @param options { withClient: boolean? }? + @return { ... } +]=] +function DeathReportTestUtils.setup(options) + options = options or {} + + specCounter += 1 + local suffix = specCounter + + local maid = Maid.new() + + local container = Instance.new("Folder") + container.Name = string.format("DeathReportSpecContainer_%d", suffix) + container.Parent = Workspace + + local instances = {} + + local serverBag = ServiceBag.new() + local deathReportService = serverBag:GetService(DeathReportService) + local playerMockService = serverBag:GetService(PlayerMockService) + serverBag:GetService(TieRealmService):SetTieRealm(TieRealms.SERVER) + serverBag:Init() + serverBag:Start() + + local deathTrackedHumanoidBinder = serverBag:GetService(DeathTrackedHumanoid) + + local clientBag, deathReportServiceClient, playerMockServiceClient + if options.withClient then + clientBag = ServiceBag.new() + deathReportServiceClient = clientBag:GetService(DeathReportServiceClient) + playerMockServiceClient = clientBag:GetService(PlayerMockServiceClient) + clientBag:GetService(TieRealmService):SetTieRealm(TieRealms.CLIENT) + clientBag:Init() + clientBag:Start() + end + + local clientBagDestroyed = false + local function destroyClientBag() + if clientBag and not clientBagDestroyed then + clientBagDestroyed = true + clientBag:Destroy() + end + end + + local serverBagDestroyed = false + local function destroyServerBag() + if not serverBagDestroyed then + serverBagDestroyed = true + serverBag:Destroy() + end + end + + local mockCounter = 0 + local function newMock(overrides) + mockCounter += 1 + + local seed = { UserId = USER_ID_BASE + suffix * 100 + mockCounter } + if overrides then + for key, value in overrides do + seed[key] = value + end + end + + return playerMockService:CreatePlayer(seed) + end + + local function newCharacter(mock) + local character = PlayerMock.loadMinimalCharacterAsync(mock) + local humanoid = assert(character:FindFirstChildWhichIsA("Humanoid"), "No humanoid") + + DeathReportTestUtils.awaitBound(deathTrackedHumanoidBinder, humanoid) + + return character, humanoid + end + + local function newNpc(name) + local character = Instance.new("Model") + character.Name = name or "Npc" + + local humanoid = Instance.new("Humanoid") + humanoid.Parent = character + + character.Parent = container + table.insert(instances, character) + + return character, humanoid + end + + local teamCounter = 0 + local function newTeam(name, brickColor) + teamCounter += 1 + + local team = Instance.new("Team") + team.Name = name or string.format("DeathReportSpecTeam_%d_%d", suffix, teamCounter) + team.TeamColor = brickColor or BrickColor.new("Bright red") + team.AutoAssignable = false + team.Parent = Teams + table.insert(instances, team) + + return team + end + + local function setTeam(mock, team) + PlayerMock.write(mock, "Team", team) + PlayerMock.write(mock, "Neutral", team == nil) + end + + local function setLocalPlayer(mock) + assert(playerMockServiceClient, "No client bag -- pass withClient") + playerMockServiceClient:SetLocalPlayer(mock) + end + + local function kill(humanoid, killer) + if killer then + HumanoidKillerUtils.tagKiller(humanoid, killer) + end + + humanoid.Health = 0 + end + + maid:GiveTask(function() + -- Client bags first: the server bag owns the mocks, and destroying them out from under a + -- live client is not something production ever does. + destroyClientBag() + destroyServerBag() + + for _, inst in instances do + pcall(function() + inst:Destroy() + end) + end + container:Destroy() + end) + + local controller = { + maid = maid, + container = container, + serverBag = serverBag, + deathReportService = deathReportService, + playerMockService = playerMockService, + teamKillTrackerBinder = serverBag:GetService(TeamKillTracker), + playerKillTrackerBinder = serverBag:GetService(PlayerKillTracker), + playerDeathTrackerBinder = serverBag:GetService(PlayerDeathTracker), + clientBag = clientBag, + deathReportServiceClient = deathReportServiceClient, + playerMockServiceClient = playerMockServiceClient, + teamKillTrackerClientBinder = clientBag and clientBag:GetService(TeamKillTrackerClient), + playerKillTrackerClientBinder = clientBag and clientBag:GetService(PlayerKillTrackerClient), + playerDeathTrackerClientBinder = clientBag and clientBag:GetService(PlayerDeathTrackerClient), + newMock = newMock, + newCharacter = newCharacter, + newNpc = newNpc, + newTeam = newTeam, + setTeam = setTeam, + setLocalPlayer = setLocalPlayer, + kill = kill, + destroyClientBag = destroyClientBag, + destroyServerBag = destroyServerBag, + Destroy = function(_self) + maid:DoCleaning() + end, + } + + maid:GiveTask(JestUtils.afterThis(controller)) + + return controller +end + +return DeathReportTestUtils diff --git a/src/deathreport/src/Shared/DeathReportUtils.lua b/src/deathreport/src/Shared/DeathReportUtils.lua index e1ae5aad13f..99315dd4ad8 100644 --- a/src/deathreport/src/Shared/DeathReportUtils.lua +++ b/src/deathreport/src/Shared/DeathReportUtils.lua @@ -7,6 +7,7 @@ local require = require(script.Parent.loader).load(script) local CharacterUtils = require("CharacterUtils") local HumanoidKillerUtils = require("HumanoidKillerUtils") +local PlayerMock = require("PlayerMock") local DEFAULT_COLOR = Color3.new(0.9, 0.9, 0.9) @@ -41,6 +42,14 @@ export type DeathReport = { weaponData: WeaponData, } +--[=[ + Constructs a death report for the adornee that died + + @param adornee Instance -- The humanoid or character that died + @param killerAdornee Instance? -- The humanoid or character that killed it + @param weaponData WeaponData? + @return DeathReport +]=] function DeathReportUtils.create(adornee: Instance, killerAdornee: Instance?, weaponData: WeaponData?): DeathReport assert(typeof(adornee) == "Instance", "Bad adornee") @@ -105,8 +114,9 @@ end @return string ]=] function DeathReportUtils.getDeadDisplayName(deathReport: DeathReport): string? - if deathReport.player then - return deathReport.player.DisplayName + local player = deathReport.player + if player then + return if PlayerMock.isMock(player) then PlayerMock.read(player, "DisplayName") else player.DisplayName elseif deathReport.humanoid then local character = deathReport.humanoid.Parent if character then @@ -130,7 +140,7 @@ end @return string ]=] function DeathReportUtils.involvesPlayer(deathReport: DeathReport, player: Player): boolean - assert(typeof(player) == "Instance" and player:IsA("Player"), "Bad player") + assert(typeof(player) == "Instance" and (player:IsA("Player") or PlayerMock.isMock(player)), "Bad player") return (deathReport.player == player) or (deathReport.killerPlayer == player) end @@ -142,9 +152,12 @@ end @return string? ]=] function DeathReportUtils.getKillerDisplayName(deathReport: DeathReport): string? - if deathReport.killerPlayer then - assert(deathReport.killerPlayer:IsA("Player"), "Bad player") - return deathReport.killerPlayer.DisplayName + local killerPlayer = deathReport.killerPlayer + if killerPlayer then + assert(killerPlayer:IsA("Player") or PlayerMock.isMock(killerPlayer), "Bad player") + return if PlayerMock.isMock(killerPlayer) + then PlayerMock.read(killerPlayer, "DisplayName") + else killerPlayer.DisplayName elseif deathReport.killerHumanoid then local character = deathReport.killerHumanoid.Parent if character then @@ -165,8 +178,9 @@ end @return Color3? ]=] function DeathReportUtils.getDeadColor(deathReport: DeathReport): Color3? - if deathReport.player then - local team = deathReport.player.Team + local player = deathReport.player + if player then + local team = if PlayerMock.isMock(player) then PlayerMock.read(player, "Team") else player.Team if team then return team.TeamColor.Color end @@ -182,8 +196,11 @@ end @return Color3? ]=] function DeathReportUtils.getKillerColor(deathReport: DeathReport): Color3? - if deathReport.killerPlayer then - local team = deathReport.killerPlayer.Team + local killerPlayer = deathReport.killerPlayer + if killerPlayer then + local team = if PlayerMock.isMock(killerPlayer) + then PlayerMock.read(killerPlayer, "Team") + else killerPlayer.Team if team then return team.TeamColor.Color end diff --git a/src/deathreport/src/Shared/DeathReportUtils.spec.lua b/src/deathreport/src/Shared/DeathReportUtils.spec.lua index 634a20a7f74..895ee0cccc2 100644 --- a/src/deathreport/src/Shared/DeathReportUtils.spec.lua +++ b/src/deathreport/src/Shared/DeathReportUtils.spec.lua @@ -5,13 +5,103 @@ local require = require(script.Parent.loader).load(script) +local Players = game:GetService("Players") +local Teams = game:GetService("Teams") +local Workspace = game:GetService("Workspace") + local DeathReportUtils = require("DeathReportUtils") +local HumanoidKillerUtils = require("HumanoidKillerUtils") local Jest = require("Jest") +local JestUtils = require("JestUtils") +local Maid = require("Maid") +local PlayerMock = require("PlayerMock") local describe = Jest.Globals.describe local expect = Jest.Globals.expect local it = Jest.Globals.it +local USER_ID_BASE = 55300000 + +local specCounter = 0 + +local function setup(): any + specCounter += 1 + local suffix = specCounter + + local maid = Maid.new() + + local container = Instance.new("Folder") + container.Name = string.format("DeathReportUtilsSpecContainer_%d", suffix) + container.Parent = Workspace + maid:GiveTask(container) + + local mockCounter = 0 + local function newMock(overrides: { [string]: any }?): Player + mockCounter += 1 + + local seed: { [string]: any } = { UserId = USER_ID_BASE + suffix * 100 + mockCounter } + if overrides then + for key, value in overrides do + seed[key] = value + end + end + + local mock = PlayerMock.new(seed) + mock.Parent = Players + maid:GiveTask(mock) + + return mock + end + + local function newCharacter(mock: Player): (Model, Humanoid) + local character = PlayerMock.loadMinimalCharacterAsync(mock) + return character, assert(character:FindFirstChildWhichIsA("Humanoid"), "No humanoid") + end + + local function newNpc(name: string): (Model, Humanoid) + local character = Instance.new("Model") + character.Name = name + + local humanoid = Instance.new("Humanoid") + humanoid.Parent = character + + character.Parent = container + + return character, humanoid + end + + local function newTeam(brickColor: BrickColor): Team + local team = Instance.new("Team") + team.Name = string.format("DeathReportUtilsSpecTeam_%d", suffix) + team.TeamColor = brickColor + team.AutoAssignable = false + team.Parent = Teams + maid:GiveTask(team) + + return team + end + + local function setTeam(mock: Player, team: Team?) + PlayerMock.write(mock, "Team", team) + PlayerMock.write(mock, "Neutral", team == nil) + end + + local controller = { + newMock = newMock, + newCharacter = newCharacter, + newNpc = newNpc, + newTeam = newTeam, + setTeam = setTeam, + Destroy = function(_self) + maid:DoCleaning() + end, + } + + maid:GiveTask(JestUtils.afterThis(controller)) + + return controller +end + describe("DeathReportUtils.isDeathReport(deathReport)", function() it("should return true for a valid death report table", function() expect(DeathReportUtils.isDeathReport({ type = "deathReport" })).toEqual(true) @@ -26,6 +116,321 @@ describe("DeathReportUtils.isDeathReport(deathReport)", function() end) end) +describe("DeathReportUtils.isWeaponData(weaponData)", function() + it("accepts an empty table", function() + expect(DeathReportUtils.isWeaponData({})).toEqual(true) + end) + + it("accepts an instance weapon", function() + local weapon = Instance.new("Tool") + expect(DeathReportUtils.isWeaponData({ weaponInstance = weapon })).toEqual(true) + weapon:Destroy() + end) + + it("rejects a non-instance weapon", function() + expect(DeathReportUtils.isWeaponData({ weaponInstance = 5 })).toEqual(false) + end) + + it("rejects nil", function() + expect(DeathReportUtils.isWeaponData(nil)).toEqual(false) + end) +end) + +describe("DeathReportUtils.createWeaponData(weaponInstance)", function() + it("wraps the weapon instance", function() + local weapon = Instance.new("Tool") + expect(DeathReportUtils.createWeaponData(weapon)).toEqual({ weaponInstance = weapon }) + weapon:Destroy() + end) + + it("allows no weapon", function() + expect(DeathReportUtils.createWeaponData(nil)).toEqual({}) + end) + + it("rejects a non-instance", function() + expect(function() + DeathReportUtils.createWeaponData(5 :: any) + end).toThrow() + end) +end) + +describe("DeathReportUtils.create(adornee, killerAdornee, weaponData)", function() + it("resolves the humanoid and player of a mock character", function() + local controller = setup() + local mock = controller.newMock() + local character, humanoid = controller.newCharacter(mock) + + local report = DeathReportUtils.create(character) + + expect(report.type).toEqual("deathReport") + expect(report.adornee).toBe(character) + expect(report.humanoid).toBe(humanoid) + expect(report.player).toBe(mock) + expect(report.killerAdornee).toBeNil() + expect(report.killerHumanoid).toBeNil() + expect(report.killerPlayer).toBeNil() + expect(report.weaponData).toEqual({}) + + controller:Destroy() + end) + + it("accepts a humanoid as the adornee", function() + local controller = setup() + local _character, humanoid = controller.newNpc("Zombie") + + local report = DeathReportUtils.create(humanoid) + + expect(report.adornee).toBe(humanoid) + expect(report.humanoid).toBe(humanoid) + expect(report.player).toBeNil() + + controller:Destroy() + end) + + it("resolves the killer humanoid and player", function() + local controller = setup() + local victim = controller.newMock() + local victimCharacter = controller.newCharacter(victim) + local killer = controller.newMock() + local _killerCharacter, killerHumanoid = controller.newCharacter(killer) + local weapon = Instance.new("Tool") + + local report = + DeathReportUtils.create(victimCharacter, killerHumanoid, DeathReportUtils.createWeaponData(weapon)) + + expect(report.player).toBe(victim) + expect(report.killerAdornee).toBe(killerHumanoid) + expect(report.killerHumanoid).toBe(killerHumanoid) + expect(report.killerPlayer).toBe(killer) + expect(report.weaponData.weaponInstance).toBe(weapon) + + weapon:Destroy() + controller:Destroy() + end) + + it("resolves only the killer player from a killer character", function() + local controller = setup() + local victimCharacter = controller.newNpc("Victim") + local killer = controller.newMock() + local killerCharacter = controller.newCharacter(killer) + + local report = DeathReportUtils.create(victimCharacter, killerCharacter) + + expect(report.killerAdornee).toBe(killerCharacter) + expect(report.killerHumanoid).toBeNil() + expect(report.killerPlayer).toBe(killer) + + controller:Destroy() + end) + + it("rejects a non-instance adornee", function() + expect(function() + DeathReportUtils.create({} :: any) + end).toThrow() + end) +end) + +describe("DeathReportUtils.fromDeceasedHumanoid(humanoid, weaponData)", function() + it("reads the killer from the creator tag", function() + local controller = setup() + local victim = controller.newMock() + local victimCharacter, victimHumanoid = controller.newCharacter(victim) + local killer = controller.newMock() + local _killerCharacter, killerHumanoid = controller.newCharacter(killer) + + HumanoidKillerUtils.tagKiller(victimHumanoid, killer) + + local report = DeathReportUtils.fromDeceasedHumanoid(victimHumanoid) + + expect(report.adornee).toBe(victimCharacter) + expect(report.player).toBe(victim) + expect(report.killerHumanoid).toBe(killerHumanoid) + expect(report.killerPlayer).toBe(killer) + + controller:Destroy() + end) + + it("reports no killer without a creator tag", function() + local controller = setup() + local _character, humanoid = controller.newNpc("Zombie") + + local report = DeathReportUtils.fromDeceasedHumanoid(humanoid) + + expect(report.humanoid).toBe(humanoid) + expect(report.killerPlayer).toBeNil() + expect(report.killerHumanoid).toBeNil() + + controller:Destroy() + end) + + it("rejects a humanoid without a character", function() + local humanoid = Instance.new("Humanoid") + + expect(function() + DeathReportUtils.fromDeceasedHumanoid(humanoid) + end).toThrow() + + humanoid:Destroy() + end) + + it("rejects invalid weapon data", function() + local controller = setup() + local _character, humanoid = controller.newNpc("Zombie") + + expect(function() + DeathReportUtils.fromDeceasedHumanoid(humanoid, { weaponInstance = 5 } :: any) + end).toThrow() + + controller:Destroy() + end) +end) + +describe("DeathReportUtils.getDeadDisplayName(deathReport)", function() + it("uses the display name of a mock player", function() + local controller = setup() + local mock = controller.newMock({ DisplayName = "Victim" }) + local character = controller.newCharacter(mock) + + expect(DeathReportUtils.getDeadDisplayName(DeathReportUtils.create(character))).toEqual("Victim") + + controller:Destroy() + end) + + it("uses the character name of a non-player humanoid", function() + local controller = setup() + local character = controller.newNpc("Zombie") + + expect(DeathReportUtils.getDeadDisplayName(DeathReportUtils.create(character))).toEqual("Zombie") + + controller:Destroy() + end) + + it("returns nil for an adornee with no humanoid", function() + local part = Instance.new("Part") + + expect(DeathReportUtils.getDeadDisplayName(DeathReportUtils.create(part))).toBeNil() + + part:Destroy() + end) +end) + +describe("DeathReportUtils.getKillerDisplayName(deathReport)", function() + it("uses the display name of a mock killer", function() + local controller = setup() + local victimCharacter = controller.newNpc("Victim") + local killer = controller.newMock({ DisplayName = "Killer" }) + local _killerCharacter, killerHumanoid = controller.newCharacter(killer) + + local report = DeathReportUtils.create(victimCharacter, killerHumanoid) + + expect(DeathReportUtils.getKillerDisplayName(report)).toEqual("Killer") + + controller:Destroy() + end) + + it("uses the character name of a non-player killer", function() + local controller = setup() + local victimCharacter = controller.newNpc("Victim") + local _killerCharacter, killerHumanoid = controller.newNpc("Zombie") + + local report = DeathReportUtils.create(victimCharacter, killerHumanoid) + + expect(DeathReportUtils.getKillerDisplayName(report)).toEqual("Zombie") + + controller:Destroy() + end) + + it("returns nil without a killer", function() + local controller = setup() + local victimCharacter = controller.newNpc("Victim") + + expect(DeathReportUtils.getKillerDisplayName(DeathReportUtils.create(victimCharacter))).toBeNil() + + controller:Destroy() + end) +end) + +describe("DeathReportUtils.involvesPlayer(deathReport, player)", function() + it("is true for the victim and the killer and false for a bystander", function() + local controller = setup() + local victim = controller.newMock() + local victimCharacter = controller.newCharacter(victim) + local killer = controller.newMock() + local _killerCharacter, killerHumanoid = controller.newCharacter(killer) + local bystander = controller.newMock() + + local report = DeathReportUtils.create(victimCharacter, killerHumanoid) + + expect(DeathReportUtils.involvesPlayer(report, victim)).toEqual(true) + expect(DeathReportUtils.involvesPlayer(report, killer)).toEqual(true) + expect(DeathReportUtils.involvesPlayer(report, bystander)).toEqual(false) + + controller:Destroy() + end) + + it("rejects a non-player", function() + local controller = setup() + local victimCharacter = controller.newNpc("Victim") + local report = DeathReportUtils.create(victimCharacter) + + expect(function() + DeathReportUtils.involvesPlayer(report, victimCharacter :: any) + end).toThrow() + + controller:Destroy() + end) +end) + +describe("DeathReportUtils.getDeadColor(deathReport) and getKillerColor(deathReport)", function() + it("use the team colors of the players involved", function() + local controller = setup() + local redTeam = controller.newTeam(BrickColor.new("Bright red")) + local blueTeam = controller.newTeam(BrickColor.new("Bright blue")) + + local victim = controller.newMock() + controller.setTeam(victim, blueTeam) + local victimCharacter = controller.newCharacter(victim) + + local killer = controller.newMock() + controller.setTeam(killer, redTeam) + local _killerCharacter, killerHumanoid = controller.newCharacter(killer) + + local report = DeathReportUtils.create(victimCharacter, killerHumanoid) + + expect(DeathReportUtils.getDeadColor(report)).toEqual(BrickColor.new("Bright blue").Color) + expect(DeathReportUtils.getKillerColor(report)).toEqual(BrickColor.new("Bright red").Color) + + controller:Destroy() + end) + + it("return nil for players without a team", function() + local controller = setup() + local victim = controller.newMock() + local victimCharacter = controller.newCharacter(victim) + local killer = controller.newMock() + local _killerCharacter, killerHumanoid = controller.newCharacter(killer) + + local report = DeathReportUtils.create(victimCharacter, killerHumanoid) + + expect(DeathReportUtils.getDeadColor(report)).toBeNil() + expect(DeathReportUtils.getKillerColor(report)).toBeNil() + + controller:Destroy() + end) + + it("return nil without players", function() + local controller = setup() + local victimCharacter = controller.newNpc("Victim") + + local report = DeathReportUtils.create(victimCharacter) + + expect(DeathReportUtils.getDeadColor(report)).toBeNil() + expect(DeathReportUtils.getKillerColor(report)).toBeNil() + + controller:Destroy() + end) +end) + describe("DeathReportUtils.getDefaultColor()", function() it("should return a Color3", function() local color = DeathReportUtils.getDefaultColor() diff --git a/src/deathreport/src/Shared/Stats/PlayerDeathTrackerInterface.lua b/src/deathreport/src/Shared/Stats/PlayerDeathTrackerInterface.lua new file mode 100644 index 00000000000..df9c7cdf672 --- /dev/null +++ b/src/deathreport/src/Shared/Stats/PlayerDeathTrackerInterface.lua @@ -0,0 +1,18 @@ +--!strict +--[=[ + Tie interface implemented by [PlayerDeathTracker] and [PlayerDeathTrackerClient] on the tracked + [IntValue], so consumers can read a player's deaths without depending on either binder. + + @class PlayerDeathTrackerInterface +]=] + +local require = require(script.Parent.loader).load(script) + +local TieDefinition = require("TieDefinition") + +return TieDefinition.new("PlayerDeathTracker", { + GetPlayer = TieDefinition.Types.METHOD, + GetDeathValue = TieDefinition.Types.METHOD, + GetDeaths = TieDefinition.Types.METHOD, + ObserveDeaths = TieDefinition.Types.METHOD, +}) diff --git a/src/deathreport/src/Shared/Stats/PlayerKillTrackerInterface.lua b/src/deathreport/src/Shared/Stats/PlayerKillTrackerInterface.lua new file mode 100644 index 00000000000..41d1498f9da --- /dev/null +++ b/src/deathreport/src/Shared/Stats/PlayerKillTrackerInterface.lua @@ -0,0 +1,18 @@ +--!strict +--[=[ + Tie interface implemented by [PlayerKillTracker] and [PlayerKillTrackerClient] on the tracked + [IntValue], so consumers can read a player's kills without depending on either binder. + + @class PlayerKillTrackerInterface +]=] + +local require = require(script.Parent.loader).load(script) + +local TieDefinition = require("TieDefinition") + +return TieDefinition.new("PlayerKillTracker", { + GetPlayer = TieDefinition.Types.METHOD, + GetKillValue = TieDefinition.Types.METHOD, + GetKills = TieDefinition.Types.METHOD, + ObserveKills = TieDefinition.Types.METHOD, +}) diff --git a/src/deathreport/src/Shared/Stats/PlayerKillTrackerUtils.lua b/src/deathreport/src/Shared/Stats/PlayerKillTrackerUtils.lua deleted file mode 100644 index fa53626c9da..00000000000 --- a/src/deathreport/src/Shared/Stats/PlayerKillTrackerUtils.lua +++ /dev/null @@ -1,38 +0,0 @@ ---!strict ---[=[ - @class PlayerKillTrackerUtils -]=] - -local require = require(script.Parent.loader).load(script) - -local BinderUtils = require("BinderUtils") -local RxBinderUtils = require("RxBinderUtils") - -local PlayerKillTrackerUtils = {} - -function PlayerKillTrackerUtils.create(binder: any, player: Player): IntValue - assert(typeof(player) == "Instance", "Bad player") - - local score = Instance.new("IntValue") - score.Name = "PlayerKillTracker" - score.Value = 0 - - binder:Bind(score) - - score.Parent = player - - return score -end - -function PlayerKillTrackerUtils.observeBrio(binder: any, player: Player): any - assert(typeof(player) == "Instance" and player:IsA("Player"), "Bad player") - - -- This ain't performant, but it's ok - return RxBinderUtils.observeBoundChildClassBrio(binder, player) -end - -function PlayerKillTrackerUtils.getPlayerKillTracker(binder: any, team: Instance): any - return BinderUtils.findFirstChild(binder, team) -end - -return PlayerKillTrackerUtils diff --git a/src/deathreport/src/Shared/Stats/TeamKillTrackerInterface.lua b/src/deathreport/src/Shared/Stats/TeamKillTrackerInterface.lua new file mode 100644 index 00000000000..9d13a92067b --- /dev/null +++ b/src/deathreport/src/Shared/Stats/TeamKillTrackerInterface.lua @@ -0,0 +1,18 @@ +--!strict +--[=[ + Tie interface implemented by [TeamKillTracker] and [TeamKillTrackerClient] on the tracked + [IntValue], so consumers can read a team's kills without depending on either binder. + + @class TeamKillTrackerInterface +]=] + +local require = require(script.Parent.loader).load(script) + +local TieDefinition = require("TieDefinition") + +return TieDefinition.new("TeamKillTracker", { + GetTeam = TieDefinition.Types.METHOD, + GetKillValue = TieDefinition.Types.METHOD, + GetKills = TieDefinition.Types.METHOD, + ObserveKills = TieDefinition.Types.METHOD, +}) diff --git a/src/deathreport/src/Shared/Stats/TeamKillTrackerUtils.lua b/src/deathreport/src/Shared/Stats/TeamKillTrackerUtils.lua index e281c32dbe9..ed9bfae7f79 100644 --- a/src/deathreport/src/Shared/Stats/TeamKillTrackerUtils.lua +++ b/src/deathreport/src/Shared/Stats/TeamKillTrackerUtils.lua @@ -1,16 +1,28 @@ --!strict --[=[ + Helpers for creating and finding the [IntValue] a [TeamKillTracker] binds to. + @class TeamKillTrackerUtils ]=] local require = require(script.Parent.loader).load(script) +local Binder = require("Binder") local BinderUtils = require("BinderUtils") +local Brio = require("Brio") +local Observable = require("Observable") local RxBinderUtils = require("RxBinderUtils") local TeamKillTrackerUtils = {} -function TeamKillTrackerUtils.create(binder: any): IntValue +--[=[ + Creates a zeroed tracked value bound to the given binder. The caller parents it under the [Team] + to track. + + @param binder Binder + @return IntValue +]=] +function TeamKillTrackerUtils.create(binder: Binder.Binder): IntValue local score = Instance.new("IntValue") score.Name = "TeamKillTracker" score.Value = 0 @@ -20,14 +32,28 @@ function TeamKillTrackerUtils.create(binder: any): IntValue return score end -function TeamKillTrackerUtils.observeBrio(binder: any, player: Instance): any - assert(typeof(player) == "Instance", "Bad player") +--[=[ + Observes the bound tracker classes under the team + + @param binder Binder + @param team Instance + @return Observable> +]=] +function TeamKillTrackerUtils.observeBrio(binder: Binder.Binder, team: Instance): Observable.Observable> + assert(typeof(team) == "Instance", "Bad team") -- This ain't performant, but it's ok - return RxBinderUtils.observeBoundChildClassBrio(binder, player) + return RxBinderUtils.observeBoundChildClassBrio(binder, team) end -function TeamKillTrackerUtils.getTeamKillTracker(binder: any, team: Instance): any +--[=[ + Finds the first bound tracker class under the team + + @param binder Binder + @param team Instance + @return T? +]=] +function TeamKillTrackerUtils.getTeamKillTracker(binder: Binder.Binder, team: Instance): T? return BinderUtils.findFirstChild(binder, team) end diff --git a/src/deathreport/src/Shared/Stats/TeamKillTrackerUtils.spec.lua b/src/deathreport/src/Shared/Stats/TeamKillTrackerUtils.spec.lua new file mode 100644 index 00000000000..d2708fabd15 --- /dev/null +++ b/src/deathreport/src/Shared/Stats/TeamKillTrackerUtils.spec.lua @@ -0,0 +1,101 @@ +--!strict +--[[ + @class TeamKillTrackerUtils.spec.lua +]] + +local require = require(script.Parent.loader).load(script) + +local Brio = require("Brio") +local DeathReportTestUtils = require("DeathReportTestUtils") +local Jest = require("Jest") +local TeamKillTrackerUtils = require("TeamKillTrackerUtils") + +local describe = Jest.Globals.describe +local expect = Jest.Globals.expect +local it = Jest.Globals.it + +describe("TeamKillTrackerUtils.create(binder)", function() + it("returns an unparented zeroed value tagged for the binder", function() + local controller = DeathReportTestUtils.setup() + local binder = controller.teamKillTrackerBinder + + local score = TeamKillTrackerUtils.create(binder) + controller.maid:GiveTask(score) + + expect(score.Name).toEqual("TeamKillTracker") + expect(score.Value).toEqual(0) + expect(score.Parent).toBeNil() + expect(binder:HasTag(score)).toBe(true) + + controller:Destroy() + end) + + it("binds once parented under a team", function() + local controller = DeathReportTestUtils.setup() + local binder = controller.teamKillTrackerBinder + local team = controller.newTeam() + + local score = TeamKillTrackerUtils.create(binder) + score.Parent = team + + local tracker = DeathReportTestUtils.awaitBound(binder, score) + expect(tracker:GetTeam()).toBe(team) + + controller:Destroy() + end) +end) + +describe("TeamKillTrackerUtils.getTeamKillTracker(binder, team)", function() + it("finds the bound tracker under the team", function() + local controller = DeathReportTestUtils.setup() + local binder = controller.teamKillTrackerBinder + local team = controller.newTeam() + + expect(TeamKillTrackerUtils.getTeamKillTracker(binder, team)).toBeNil() + + local score = TeamKillTrackerUtils.create(binder) + score.Parent = team + local tracker = DeathReportTestUtils.awaitBound(binder, score) + + expect(TeamKillTrackerUtils.getTeamKillTracker(binder, team)).toBe(tracker) + + controller:Destroy() + end) +end) + +describe("TeamKillTrackerUtils.observeBrio(binder, team)", function() + it("emits the bound tracker and kills the brio when it is removed", function() + local controller = DeathReportTestUtils.setup() + local binder = controller.teamKillTrackerBinder + local team = controller.newTeam() + + local brios: { Brio.Brio } = {} + controller.maid:GiveTask(TeamKillTrackerUtils.observeBrio(binder, team):Subscribe(function(brio) + table.insert(brios, brio) + end)) + + local score = TeamKillTrackerUtils.create(binder) + score.Parent = team + local tracker = DeathReportTestUtils.awaitBound(binder, score) + + expect(DeathReportTestUtils.waitFor(function() + return #brios == 1 + end)).toBe(true) + expect(brios[1]:GetValue()).toBe(tracker) + expect(brios[1]:IsDead()).toBe(false) + + score:Destroy() + + expect(DeathReportTestUtils.waitFor(function() + return brios[1]:IsDead() + end)).toBe(true) + + controller:Destroy() + end) + + it("rejects a non-instance", function() + expect(function() + TeamKillTrackerUtils.observeBrio(nil :: any, {} :: any) + end).toThrow() + end) +end) diff --git a/src/httppromise/src/Server/HttpPromise.lua b/src/httppromise/src/Server/HttpPromise.lua index 3d0d26db970..bf726c190fc 100644 --- a/src/httppromise/src/Server/HttpPromise.lua +++ b/src/httppromise/src/Server/HttpPromise.lua @@ -35,7 +35,7 @@ local HttpPromise = {} export type HTTPRequest = { Url: string, - Method: "POST" | "GET" | "PUT" | "DELETE", + Method: ("POST" | "GET" | "PUT" | "DELETE")?, Headers: { [string]: string | Secret }?, Body: string?, Compress: Enum.HttpCompression?, @@ -64,9 +64,9 @@ export type HTTPResponse = { ``` @param request HTTPRequest - @return Promise + @return Promise ]=] -function HttpPromise.request(request: HTTPRequest): Promise.Promise<()> +function HttpPromise.request(request: HTTPRequest): Promise.Promise if DEBUG_REQUEST then print("Sending request", HttpService:JSONEncode(request)) end @@ -78,7 +78,10 @@ function HttpPromise.request(request: HTTPRequest): Promise.Promise<()> end) if DEBUG_RESPONSE then - print(string.format("Response: %d %s %s", response.StatusCode, request.Method, request.Url), response.Body) + print( + string.format("Response: %d %s %s", response.StatusCode, request.Method or "GET", request.Url), + response.Body + ) end if not ok then diff --git a/src/observablecollection/src/Shared/ObservableList.lua b/src/observablecollection/src/Shared/ObservableList.lua index a5e932fecd3..671c00d4200 100644 --- a/src/observablecollection/src/Shared/ObservableList.lua +++ b/src/observablecollection/src/Shared/ObservableList.lua @@ -29,6 +29,7 @@ export type ObservableList = typeof(setmetatable( _contents: { [Symbol.Symbol]: T }, _indexes: { [Symbol.Symbol]: number }, _indexObservers: any, -- ObservableSubscriptionTable.ObservableSubscriptionTable, + _negativeIndexObservers: any, -- ObservableSubscriptionTable.ObservableSubscriptionTable, _keyIndexObservables: any, -- ObservableSubscriptionTable.ObservableSubscriptionTable, _countValue: ValueObject.ValueObject, @@ -72,6 +73,7 @@ function ObservableList.new(): ObservableList self._indexes = {} -- { [Symbol]: number } self._indexObservers = self._maid:Add(ObservableSubscriptionTable.new()) + self._negativeIndexObservers = self._maid:Add(ObservableSubscriptionTable.new()) self._keyIndexObservables = self._maid:Add(ObservableSubscriptionTable.new()) self._countValue = self._maid:Add(ValueObject.new(0, "number")) @@ -198,7 +200,9 @@ end function ObservableList.ObserveAtIndex(self: ObservableList, indexToObserve: number): Observable.Observable assert(type(indexToObserve) == "number", "Bad indexToObserve") - return self._indexObservers:Observe(indexToObserve, function(sub) + local observers = if indexToObserve < 0 then self._negativeIndexObservers else self._indexObservers + + return observers:Observe(indexToObserve, function(sub) sub:Fire(self:Get(indexToObserve)) end) end @@ -355,7 +359,6 @@ function ObservableList.InsertAt(self: ObservableList, item: T, index: num end self._keyList[index] = key - local listLength = #self._keyList -- Fire off count self._countValue.Value = self._countValue.Value + 1 @@ -366,16 +369,18 @@ function ObservableList.InsertAt(self: ObservableList, item: T, index: num -- Fire off the index change on the value self._keyIndexObservables:Fire(key, index) self._indexObservers:Fire(index, item) - self._indexObservers:Fire(ListIndexUtils.toNegativeIndex(listLength, index), item) for _, data in changed do if self._indexes[data.key] == data.newIndex then self._indexObservers:Fire(data.newIndex, self._contents[data.key]) - self._indexObservers:Fire(ListIndexUtils.toNegativeIndex(listLength, index), self._contents[data.key]) self._keyIndexObservables:Fire(data.key, data.newIndex) end end + if self.Destroy then + self:_fireNegativeIndexObservers(index, n) + end + return function() if self.Destroy then self:RemoveByKey(key) @@ -435,7 +440,6 @@ function ObservableList.RemoveByKey(self: ObservableList, key): T? }) end self._keyList[n] = nil - local listLength = #self._keyList -- Fire off that count changed self._countValue.Value = self._countValue.Value - 1 @@ -446,21 +450,20 @@ function ObservableList.RemoveByKey(self: ObservableList, key): T? -- Fire off the index change on the value self._keyIndexObservables:Complete(key) - self._indexObservers:Fire(listLength, nil) - - if listLength == 0 then - self._indexObservers:Fire(-1, nil) - end + self._indexObservers:Fire(n, nil) -- Fire off index change on each key list (if the data isn't stale) for _, data in changed do if self._indexes[data.key] == data.newIndex then self._indexObservers:Fire(data.newIndex, self._contents[data.key]) - self._indexObservers:Fire(ListIndexUtils.toNegativeIndex(listLength, index), self._contents[data.key]) self._keyIndexObservables:Fire(data.key, data.newIndex) end end + if self.Destroy then + self:_fireNegativeIndexObservers(index - 1, n) + end + return item end @@ -476,6 +479,39 @@ function ObservableList.GetList(self: ObservableList): { T } return list end +function ObservableList._fireNegativeIndexObservers( + self: ObservableList, + lastChangedIndex: number, + previousLength: number +) + local observers = self._negativeIndexObservers + if not observers:HasAnySubscriptions() then + return + end + + local listLength = #self._keyList + + -- Subscribers run synchronously and may (un)subscribe, so snapshot the keys first + local negativeIndexes: { number } = {} + for negativeIndex in observers:GetRawSubscriptionMap() do + table.insert(negativeIndexes, negativeIndex) + end + + for _, negativeIndex in negativeIndexes do + local index = listLength + negativeIndex + 1 + local previousIndex = previousLength + negativeIndex + 1 + + local changed = (index >= 1 and index <= lastChangedIndex) or (index < 1 and previousIndex >= 1) + if not changed then + continue + end + + -- Emit the live value: an earlier subscriber may have mutated the list reentrantly + local key = self._keyList[#self._keyList + negativeIndex + 1] + observers:Fire(negativeIndex, if key ~= nil then self._contents[key] else nil) + end +end + --[=[ Cleans up the ObservableList and sets the metatable to nil. ]=] diff --git a/src/observablecollection/src/Shared/ObservableList.spec.lua b/src/observablecollection/src/Shared/ObservableList.spec.lua index 30e350abfc9..355ef163cc3 100644 --- a/src/observablecollection/src/Shared/ObservableList.spec.lua +++ b/src/observablecollection/src/Shared/ObservableList.spec.lua @@ -7,11 +7,33 @@ local require = require(script.Parent.loader).load(script) local Jest = require("Jest") local ObservableList = require("ObservableList") +local ObservableSubscriptionTable = require("ObservableSubscriptionTable") local describe = Jest.Globals.describe local expect = Jest.Globals.expect local it = Jest.Globals.it +local function observe(observableList, index: number): ({ { any } }, any) + local seen: { { any } } = {} + local sub = observableList:ObserveAtIndex(index):Subscribe(function(value) + table.insert(seen, { value }) + end) + + return seen, sub +end + +local function last(seen: { { any } }): any + return seen[#seen][1] +end + +local function values(seen: { { any } }): { any } + local result = {} + for i, entry in seen do + result[i] = entry[1] + end + return result +end + describe("ObservableList.new()", function() local observableList = ObservableList.new() @@ -133,3 +155,360 @@ describe("ObservableList.new()", function() observableList:Destroy() end) end) + +describe("ObservableList negative index observation", function() + it("moves the last item back when a later item is added", function() + local observableList = ObservableList.new() + observableList:Add("a") + + local seenLast, subLast = observe(observableList, -1) + local seenSecondLast, subSecondLast = observe(observableList, -2) + + observableList:Add("b") + + expect(last(seenLast)).toEqual("b") + expect(last(seenSecondLast)).toEqual("a") + + subLast:Destroy() + subSecondLast:Destroy() + observableList:Destroy() + end) + + it("reveals the previous item when the last item is removed", function() + local observableList = ObservableList.new() + observableList:Add("a") + local removeB = observableList:Add("b") + + local seen, sub = observe(observableList, -1) + expect(last(seen)).toEqual("b") + + removeB() + + expect(last(seen)).toEqual("a") + expect(#seen).toEqual(2) + + sub:Destroy() + observableList:Destroy() + end) + + it("emits nil for the most negative index once it falls off the end", function() + local observableList = ObservableList.new() + local removeA = observableList:Add("a") + observableList:Add("b") + + local seen, sub = observe(observableList, -2) + expect(last(seen)).toEqual("a") + + removeA() + + expect(last(seen)).toEqual(nil) + expect(#seen).toEqual(2) + + sub:Destroy() + observableList:Destroy() + end) + + it("leaves the last item alone when an earlier item is removed", function() + local observableList = ObservableList.new() + local removeA = observableList:Add("a") + observableList:Add("b") + + local seen, sub = observe(observableList, -1) + removeA() + + expect(last(seen)).toEqual("b") + expect(#seen).toEqual(1) + + sub:Destroy() + observableList:Destroy() + end) + + it("emits nil for the vacated positive index when the last item is removed", function() + local observableList = ObservableList.new() + observableList:Add("a") + local removeB = observableList:Add("b") + + local seenFirst, subFirst = observe(observableList, 1) + local seenSecond, subSecond = observe(observableList, 2) + + removeB() + + expect(#seenFirst).toEqual(1) + expect(last(seenFirst)).toEqual("a") + expect(last(seenSecond)).toEqual(nil) + + subFirst:Destroy() + subSecond:Destroy() + observableList:Destroy() + end) +end) + +describe("ObservableList negative index observation around the middle", function() + it("shifts earlier items when an item is inserted in the middle", function() + local observableList = ObservableList.new() + observableList:Add("a") + observableList:Add("b") + observableList:Add("c") + + local seen4, sub4 = observe(observableList, -4) + local seen3, sub3 = observe(observableList, -3) + local seen2, sub2 = observe(observableList, -2) + local seen1, sub1 = observe(observableList, -1) + + observableList:InsertAt("x", 2) + + expect(#seen4).toEqual(2) + expect(seen4[1][1]).toEqual(nil) + expect(seen4[2][1]).toEqual("a") + expect(values(seen3)).toEqual({ "a", "x" }) + expect(values(seen2)).toEqual({ "b" }) + expect(values(seen1)).toEqual({ "c" }) + + sub4:Destroy() + sub3:Destroy() + sub2:Destroy() + sub1:Destroy() + observableList:Destroy() + end) + + it("shifts earlier items when a middle item is removed", function() + local observableList = ObservableList.new() + observableList:Add("a") + local removeB = observableList:Add("b") + observableList:Add("c") + + local seen3, sub3 = observe(observableList, -3) + local seen2, sub2 = observe(observableList, -2) + local seen1, sub1 = observe(observableList, -1) + + removeB() + + expect(#seen3).toEqual(2) + expect(seen3[1][1]).toEqual("a") + expect(seen3[2][1]).toEqual(nil) + expect(values(seen2)).toEqual({ "b", "a" }) + expect(values(seen1)).toEqual({ "c" }) + + sub3:Destroy() + sub2:Destroy() + sub1:Destroy() + observableList:Destroy() + end) + + it("emits only the shifted value for a positive slot when an earlier item is removed", function() + local observableList = ObservableList.new() + local removeA = observableList:Add("a") + observableList:Add("b") + observableList:Add("c") + + local seen, sub = observe(observableList, 2) + + removeA() + + expect(values(seen)).toEqual({ "b", "c" }) + + sub:Destroy() + observableList:Destroy() + end) + + it("emits the final value when a subscriber mutates the list reentrantly", function() + local observableList = ObservableList.new() + observableList:Add("a") + + local seenLast, subLast = observe(observableList, -1) + + local added = false + local subSecondLast = observableList:ObserveAtIndex(-2):Subscribe(function(value) + if value == "a" and not added then + added = true + observableList:Add("c") + end + end) + + observableList:Add("b") + + expect(observableList:GetList()).toEqual({ "a", "b", "c" }) + expect(last(seenLast)).toEqual("c") + + subLast:Destroy() + subSecondLast:Destroy() + observableList:Destroy() + end) +end) + +describe("ObservableList destroyed from its own handlers", function() + it("survives Destroy from an ItemAdded handler", function() + local observableList = ObservableList.new() + observableList.ItemAdded:Connect(function() + observableList:Destroy() + end) + + expect(function() + observableList:Add("a") + end).never.toThrow() + end) + + it("survives Destroy from an ItemRemoved handler", function() + local observableList = ObservableList.new() + local removeA = observableList:Add("a") + observableList.ItemRemoved:Connect(function() + observableList:Destroy() + end) + + expect(function() + removeA() + end).never.toThrow() + end) +end) + +describe("ObservableList emission cost", function() + local function countFires(callback: () -> ()): number + local originalFire = ObservableSubscriptionTable.Fire + local count = 0 + ObservableSubscriptionTable.Fire = function(...) + count += 1 + return originalFire(...) + end + + local ok, err = pcall(callback :: () -> any) + ObservableSubscriptionTable.Fire = originalFire + + if not ok then + error(err) + end + + return count + end + + local ITEMS = 100 + + it("fires a bounded number of observers per Add with a last-item observer", function() + local observableList = ObservableList.new() + local _, sub = observe(observableList, -1) + + local fires = countFires(function() + for i = 1, ITEMS do + observableList:Add(i) + end + end) + + expect(fires).toBeLessThanOrEqual(4 * ITEMS) + + sub:Destroy() + observableList:Destroy() + end) + + it("fires a bounded number of observers per pop from the back with a last-item observer", function() + local observableList = ObservableList.new() + local removers = {} + for i = 1, ITEMS do + removers[i] = observableList:Add(i) + end + local _, sub = observe(observableList, -1) + + local fires = countFires(function() + for i = ITEMS, 1, -1 do + removers[i]() + end + end) + + expect(fires).toBeLessThanOrEqual(4 * ITEMS) + + sub:Destroy() + observableList:Destroy() + end) +end) + +describe("ObservableList positive index observation (mirrors ObservableSortedList)", function() + it("does not fire existing indices when adding at the end", function() + local observableList = ObservableList.new() + observableList:Add("a") + observableList:Add("b") + observableList:Add("c") + + local seen1, sub1 = observe(observableList, 1) + local seen2, sub2 = observe(observableList, 2) + local seen3, sub3 = observe(observableList, 3) + + observableList:Add("d") + + expect(observableList:GetList()).toEqual({ "a", "b", "c", "d" }) + expect(values(seen1)).toEqual({ "a" }) + expect(values(seen2)).toEqual({ "b" }) + expect(values(seen3)).toEqual({ "c" }) + + sub1:Destroy() + sub2:Destroy() + sub3:Destroy() + observableList:Destroy() + end) + + it("does not fire indices before a removed middle element", function() + local observableList = ObservableList.new() + observableList:Add("a") + observableList:Add("b") + local removeC = observableList:Add("c") + observableList:Add("d") + observableList:Add("e") + + local seen1, sub1 = observe(observableList, 1) + local seen2, sub2 = observe(observableList, 2) + local seen4, sub4 = observe(observableList, 4) + + removeC() + + expect(observableList:GetList()).toEqual({ "a", "b", "d", "e" }) + expect(values(seen1)).toEqual({ "a" }) + expect(values(seen2)).toEqual({ "b" }) + expect(values(seen4)).toEqual({ "d", "e" }) + + sub1:Destroy() + sub2:Destroy() + sub4:Destroy() + observableList:Destroy() + end) + + it("fires every index when inserting at the beginning", function() + local observableList = ObservableList.new() + observableList:Add("b") + observableList:Add("c") + observableList:Add("d") + + local seen1, sub1 = observe(observableList, 1) + local seen2, sub2 = observe(observableList, 2) + local seen3, sub3 = observe(observableList, 3) + local seen4, sub4 = observe(observableList, 4) + + observableList:InsertAt("a", 1) + + expect(observableList:GetList()).toEqual({ "a", "b", "c", "d" }) + expect(values(seen1)).toEqual({ "b", "a" }) + expect(values(seen2)).toEqual({ "c", "b" }) + expect(values(seen3)).toEqual({ "d", "c" }) + expect(#seen4).toEqual(2) + expect(seen4[2][1]).toEqual("d") + + sub1:Destroy() + sub2:Destroy() + sub3:Destroy() + sub4:Destroy() + observableList:Destroy() + end) + + it("fires nil when a positive index goes out of bounds", function() + local observableList = ObservableList.new() + local removeA = observableList:Add("a") + observableList:Add("b") + + local seen, sub = observe(observableList, 2) + expect(values(seen)).toEqual({ "b" }) + + removeA() + + expect(#seen).toEqual(2) + expect(seen[2][1]).toEqual(nil) + + sub:Destroy() + observableList:Destroy() + end) +end) diff --git a/src/observablecollection/src/Shared/SortedList/ObservableSortedList.lua b/src/observablecollection/src/Shared/SortedList/ObservableSortedList.lua index adc0ecfdee8..847f27add2f 100644 --- a/src/observablecollection/src/Shared/SortedList/ObservableSortedList.lua +++ b/src/observablecollection/src/Shared/SortedList/ObservableSortedList.lua @@ -624,24 +624,25 @@ function ObservableSortedList._fireEvents(self: ObservableSortedList) return end - local effectiveSpans = self._unifiedTracker:ComputeEffectiveSpans(previousCount, descendantCount) + local effectiveSpans, originalIndexes = self._unifiedTracker:ComputeEffectiveSpans(previousCount, descendantCount) -- We assume there's not that many index observers at once (since you're usually looking for the ordinal first/last) for rawIndex, _ in self._indexObservers:GetRawSubscriptionMap() do local index = ListIndexUtils.toPositiveIndex(descendantCount, rawIndex) - local shouldFire = UnifiedChangedSpanTracker.isIndexInSpan(effectiveSpans, index) - -- For negative indices, also fire if the position mapping changed due to count change - if not shouldFire and rawIndex < 0 and previousCount ~= descendantCount then - local oldIndex = ListIndexUtils.toPositiveIndex(previousCount, rawIndex) - shouldFire = oldIndex ~= index + local shouldFire + if rawIndex > 0 then + shouldFire = UnifiedChangedSpanTracker.isIndexInSpan(effectiveSpans, index) + else + shouldFire = UnifiedChangedSpanTracker.isNegativeIndexChanged(originalIndexes, previousCount, rawIndex) end if not shouldFire then continue end - local node = self:_findNodeAtIndex(index) -- O(log n) + -- A negative slot that fell off the front maps to index <= 0, which the tree cannot look up + local node = if index >= 1 then self:_findNodeAtIndex(index) else nil -- O(log n) if node then self._indexObservers:Fire(rawIndex, node.data, node) else diff --git a/src/observablecollection/src/Shared/SortedList/ObservableSortedList.spec.lua b/src/observablecollection/src/Shared/SortedList/ObservableSortedList.spec.lua index 840f09f83b6..d8283e9c0a9 100644 --- a/src/observablecollection/src/Shared/SortedList/ObservableSortedList.spec.lua +++ b/src/observablecollection/src/Shared/SortedList/ObservableSortedList.spec.lua @@ -9,6 +9,7 @@ local Brio = require("Brio") local Jest = require("Jest") local Maid = require("Maid") local ObservableSortedList = require("ObservableSortedList") +local ObservableSubscriptionTable = require("ObservableSubscriptionTable") local Rx = require("Rx") local Symbol = require("Symbol") local ValueObject = require("ValueObject") @@ -2331,3 +2332,259 @@ describe("ObservableSortedList", function() end) end) end) + +local function observe(list, index: number) + return ObservableSortedListTestUtils.collectValues(list:ObserveAtIndex(index)) +end + +describe("ObservableSortedList negative index observation (mirrors ObservableList)", function() + it("moves the last item back when a later item is added", function() + local maid = Maid.new() + local list = maid:Add(ObservableSortedList.new()) + list:Add("a", 1) + list:_testForceFireEvents() + + local seenLast, subLast = observe(list, -1) + local seenSecondLast, subSecondLast = observe(list, -2) + + list:Add("b", 2) + list:_testForceFireEvents() + + expect(seenLast).toEqual({ "a", "b" }) + expect(seenSecondLast).toEqual({ NIL_VALUE, "a" }) + + subLast:Destroy() + subSecondLast:Destroy() + maid:Destroy() + end) + + it("reveals the previous item when the last item is removed", function() + local maid = Maid.new() + local list = maid:Add(ObservableSortedList.new()) + list:Add("a", 1) + local removeB = list:Add("b", 2) + list:_testForceFireEvents() + + local seen, sub = observe(list, -1) + + removeB() + list:_testForceFireEvents() + + expect(seen).toEqual({ "b", "a" }) + + sub:Destroy() + maid:Destroy() + end) + + it("emits nil for the most negative index once it falls off the end", function() + local maid = Maid.new() + local list = maid:Add(ObservableSortedList.new()) + local removeA = list:Add("a", 1) + list:Add("b", 2) + list:_testForceFireEvents() + + local seen, sub = observe(list, -2) + + removeA() + list:_testForceFireEvents() + + expect(seen).toEqual({ "a", NIL_VALUE }) + + sub:Destroy() + maid:Destroy() + end) + + it("leaves the last item alone when an earlier item is removed", function() + local maid = Maid.new() + local list = maid:Add(ObservableSortedList.new()) + local removeA = list:Add("a", 1) + list:Add("b", 2) + list:_testForceFireEvents() + + local seen, sub = observe(list, -1) + + removeA() + list:_testForceFireEvents() + + expect(seen).toEqual({ "b" }) + + sub:Destroy() + maid:Destroy() + end) + + it("emits nil for the vacated positive index when the last item is removed", function() + local maid = Maid.new() + local list = maid:Add(ObservableSortedList.new()) + list:Add("a", 1) + local removeB = list:Add("b", 2) + list:_testForceFireEvents() + + local seenFirst, subFirst = observe(list, 1) + local seenSecond, subSecond = observe(list, 2) + + removeB() + list:_testForceFireEvents() + + expect(seenFirst).toEqual({ "a" }) + expect(seenSecond).toEqual({ "b", NIL_VALUE }) + + subFirst:Destroy() + subSecond:Destroy() + maid:Destroy() + end) + + it("shifts earlier items when an item is inserted in the middle", function() + local maid = Maid.new() + local list = maid:Add(ObservableSortedList.new()) + list:Add("a", 1) + list:Add("b", 2) + list:Add("c", 3) + list:_testForceFireEvents() + + local seen4, sub4 = observe(list, -4) + local seen3, sub3 = observe(list, -3) + local seen2, sub2 = observe(list, -2) + local seen1, sub1 = observe(list, -1) + + list:Add("x", 1.5) + list:_testForceFireEvents() + + expect(list:GetList()).toEqual({ "a", "x", "b", "c" }) + expect(seen4).toEqual({ NIL_VALUE, "a" }) + expect(seen3).toEqual({ "a", "x" }) + expect(seen2).toEqual({ "b" }) + expect(seen1).toEqual({ "c" }) + + sub4:Destroy() + sub3:Destroy() + sub2:Destroy() + sub1:Destroy() + maid:Destroy() + end) + + it("shifts earlier items when a middle item is removed", function() + local maid = Maid.new() + local list = maid:Add(ObservableSortedList.new()) + list:Add("a", 1) + local removeB = list:Add("b", 2) + list:Add("c", 3) + list:_testForceFireEvents() + + local seen3, sub3 = observe(list, -3) + local seen2, sub2 = observe(list, -2) + local seen1, sub1 = observe(list, -1) + + removeB() + list:_testForceFireEvents() + + expect(list:GetList()).toEqual({ "a", "c" }) + expect(seen3).toEqual({ "a", NIL_VALUE }) + expect(seen2).toEqual({ "b", "a" }) + expect(seen1).toEqual({ "c" }) + + sub3:Destroy() + sub2:Destroy() + sub1:Destroy() + maid:Destroy() + end) + + it("emits only the shifted value for a positive slot when an earlier item is removed", function() + local maid = Maid.new() + local list = maid:Add(ObservableSortedList.new()) + local removeA = list:Add("a", 1) + list:Add("b", 2) + list:Add("c", 3) + list:_testForceFireEvents() + + local seen, sub = observe(list, 2) + + removeA() + list:_testForceFireEvents() + + expect(seen).toEqual({ "b", "c" }) + + sub:Destroy() + maid:Destroy() + end) + + it("survives Destroy from an ItemRemoved handler", function() + local maid = Maid.new() + local list = maid:Add(ObservableSortedList.new()) + local removeA = list:Add("a", 1) + list:_testForceFireEvents() + + maid:Add(list.ItemRemoved:Connect(function() + maid:Destroy() + end)) + + expect(function() + removeA() + list:_testForceFireEvents() + end).never.toThrow() + + maid:Destroy() + end) +end) + +describe("ObservableSortedList emission cost (mirrors ObservableList)", function() + local function countFires(callback: () -> ()): number + local originalFire = ObservableSubscriptionTable.Fire + local count = 0 + ObservableSubscriptionTable.Fire = function(...) + count += 1 + return originalFire(...) + end + + local ok, err = pcall(callback :: () -> any) + ObservableSubscriptionTable.Fire = originalFire + + if not ok then + error(err) + end + + return count + end + + local ITEMS = 100 + + it("fires a bounded number of observers per Add with a last-item observer", function() + local maid = Maid.new() + local list = maid:Add(ObservableSortedList.new()) + local _, sub = observe(list, -1) + + local fires = countFires(function() + for i = 1, ITEMS do + list:Add(i, i) + list:_testForceFireEvents() + end + end) + + expect(fires).toBeLessThanOrEqual(4 * ITEMS) + + sub:Destroy() + maid:Destroy() + end) + + it("fires a bounded number of observers per pop from the back with a last-item observer", function() + local maid = Maid.new() + local list = maid:Add(ObservableSortedList.new()) + local removers = {} + for i = 1, ITEMS do + removers[i] = list:Add(i, i) + end + list:_testForceFireEvents() + local _, sub = observe(list, -1) + + local fires = countFires(function() + for i = ITEMS, 1, -1 do + removers[i]() + list:_testForceFireEvents() + end + end) + + expect(fires).toBeLessThanOrEqual(4 * ITEMS) + + sub:Destroy() + maid:Destroy() + end) +end) diff --git a/src/observablecollection/src/Shared/SortedList/UnifiedChangedSpanTracker.lua b/src/observablecollection/src/Shared/SortedList/UnifiedChangedSpanTracker.lua index e9dc4c41675..66bbd492596 100644 --- a/src/observablecollection/src/Shared/SortedList/UnifiedChangedSpanTracker.lua +++ b/src/observablecollection/src/Shared/SortedList/UnifiedChangedSpanTracker.lua @@ -83,20 +83,25 @@ end --[=[ Computes the effective changed spans and clears internal state. + Also returns, for each final position, the index that item had before the mutations + (0 for a newly added item). Nil when nothing was logged. Negative-index observers use + this: slot -k changed iff the item now at that slot is not the item that was there before. + @param previousCount number -- List count before mutations @param currentCount number -- List count after mutations @return { ChangedSpanTracker.ChangedSpan } + @return { number }? -- originalIndexes ]=] function UnifiedChangedSpanTracker.ComputeEffectiveSpans( self: UnifiedChangedSpanTracker, previousCount: number, currentCount: number -): { ChangedSpan } +): ({ ChangedSpan }, { number }?) local ops = self._sortedSpans self._sortedSpans = {} if #ops == 0 then - return {} + return {}, nil end -- Simulate operations on a virtual list to determine exactly which indices changed. @@ -170,7 +175,39 @@ function UnifiedChangedSpanTracker.ComputeEffectiveSpans( ) end - return result + return result, items +end + +--[=[ + Returns whether the value observed at a negative index changed, given the originalIndexes + from [ComputeEffectiveSpans]. + + @param originalIndexes { number }? + @param previousCount number + @param negativeIndex number + @return boolean +]=] +function UnifiedChangedSpanTracker.isNegativeIndexChanged( + originalIndexes: { number }?, + previousCount: number, + negativeIndex: number +): boolean + assert(negativeIndex < 0, "Bad negativeIndex") + + if not originalIndexes then + return false + end + + local index = #originalIndexes + negativeIndex + 1 + local previousIndex = previousCount + negativeIndex + 1 + + if index >= 1 then + local originalIndex = originalIndexes[index] + return originalIndex == 0 or originalIndex ~= previousIndex + else + -- Slot fell off the front of the list + return previousIndex >= 1 + end end function UnifiedChangedSpanTracker.isIndexInSpan(sortedSpans: { ChangedSpan }, index: number): boolean diff --git a/src/observablecollection/src/Shared/SortedList/UnifiedChangedSpanTracker.spec.lua b/src/observablecollection/src/Shared/SortedList/UnifiedChangedSpanTracker.spec.lua index ddc87698fef..bc1436ae11c 100644 --- a/src/observablecollection/src/Shared/SortedList/UnifiedChangedSpanTracker.spec.lua +++ b/src/observablecollection/src/Shared/SortedList/UnifiedChangedSpanTracker.spec.lua @@ -416,3 +416,86 @@ describe("UnifiedChangedSpanTracker", function() end) end) end) + +describe("UnifiedChangedSpanTracker.isNegativeIndexChanged", function() + local function changed(originalIndexes: { number }?, previousCount: number, negativeIndex: number): boolean + return UnifiedChangedSpanTracker.isNegativeIndexChanged(originalIndexes, previousCount, negativeIndex) + end + + it("returns original indexes alongside the spans", function() + local tracker = UnifiedChangedSpanTracker.new() + tracker:LogAdd(2) + + local _, originalIndexes = tracker:ComputeEffectiveSpans(3, 4) + + expect(originalIndexes).toEqual({ 1, 0, 2, 3 }) + end) + + it("returns nil original indexes when nothing happened", function() + local tracker = UnifiedChangedSpanTracker.new() + + local _, originalIndexes = tracker:ComputeEffectiveSpans(3, 3) + + expect(originalIndexes).toEqual(nil) + expect(changed(originalIndexes, 3, -1)).toEqual(false) + end) + + it("shifts every negative slot when adding at the end", function() + local tracker = UnifiedChangedSpanTracker.new() + tracker:LogAdd(3) + + local _, originalIndexes = tracker:ComputeEffectiveSpans(2, 3) + + expect(changed(originalIndexes, 2, -1)).toEqual(true) + expect(changed(originalIndexes, 2, -2)).toEqual(true) + expect(changed(originalIndexes, 2, -3)).toEqual(true) + expect(changed(originalIndexes, 2, -4)).toEqual(false) + end) + + it("only changes the slot that fell off when removing from the front", function() + local tracker = UnifiedChangedSpanTracker.new() + tracker:LogRemove(1) + + local _, originalIndexes = tracker:ComputeEffectiveSpans(3, 2) + + expect(changed(originalIndexes, 3, -1)).toEqual(false) + expect(changed(originalIndexes, 3, -2)).toEqual(false) + expect(changed(originalIndexes, 3, -3)).toEqual(true) + expect(changed(originalIndexes, 3, -4)).toEqual(false) + end) + + it("shifts every negative slot when removing from the end", function() + local tracker = UnifiedChangedSpanTracker.new() + tracker:LogRemove(3) + + local _, originalIndexes = tracker:ComputeEffectiveSpans(3, 2) + + expect(changed(originalIndexes, 3, -1)).toEqual(true) + expect(changed(originalIndexes, 3, -2)).toEqual(true) + expect(changed(originalIndexes, 3, -3)).toEqual(true) + end) + + it("only changes earlier slots when inserting in the middle", function() + local tracker = UnifiedChangedSpanTracker.new() + tracker:LogAdd(2) + + local _, originalIndexes = tracker:ComputeEffectiveSpans(3, 4) + + expect(changed(originalIndexes, 3, -1)).toEqual(false) + expect(changed(originalIndexes, 3, -2)).toEqual(false) + expect(changed(originalIndexes, 3, -3)).toEqual(true) + expect(changed(originalIndexes, 3, -4)).toEqual(true) + end) + + it("treats a move across the whole list as changing every slot", function() + local tracker = UnifiedChangedSpanTracker.new() + tracker:LogMove(1, 3) + + local _, originalIndexes = tracker:ComputeEffectiveSpans(3, 3) + + expect(originalIndexes).toEqual({ 2, 3, 1 }) + expect(changed(originalIndexes, 3, -1)).toEqual(true) + expect(changed(originalIndexes, 3, -2)).toEqual(true) + expect(changed(originalIndexes, 3, -3)).toEqual(true) + end) +end) diff --git a/src/saveslot/package.json b/src/saveslot/package.json index a09241e33ac..23831aeae40 100644 --- a/src/saveslot/package.json +++ b/src/saveslot/package.json @@ -52,6 +52,7 @@ "@quenty/table": "workspace:*", "@quenty/teleportserviceutils": "workspace:*", "@quenty/tie": "workspace:*", + "@quenty/time": "workspace:*", "@quenty/valueobject": "workspace:*", "@quentystudios/jest-lua": "3.10.0-quenty.2" }, diff --git a/src/saveslot/src/Client/Binders/HasSaveSlotsClient.lua b/src/saveslot/src/Client/Binders/HasSaveSlotsClient.lua index a4b5155eb70..7d384e48252 100644 --- a/src/saveslot/src/Client/Binders/HasSaveSlotsClient.lua +++ b/src/saveslot/src/Client/Binders/HasSaveSlotsClient.lua @@ -120,6 +120,13 @@ function HasSaveSlotsClient.PromiseLastActiveSlotId(self: HasSaveSlotsClient): P return self._remoting.PromiseLastActiveSlotId:PromiseInvokeServer() end +--[=[ + Clears the active slot selection, returning the player to a no-slot state +]=] +function HasSaveSlotsClient.PromiseDeselectSlot(self: HasSaveSlotsClient): Promise.Promise<()> + return self._remoting.PromiseDeselectSlot:PromiseInvokeServer() +end + --[=[ Returns the slot ID from the given index ]=] diff --git a/src/saveslot/src/Server/Binders/HasSaveSlots.lua b/src/saveslot/src/Server/Binders/HasSaveSlots.lua index 5b4843958c7..4d00b8c2d09 100644 --- a/src/saveslot/src/Server/Binders/HasSaveSlots.lua +++ b/src/saveslot/src/Server/Binders/HasSaveSlots.lua @@ -725,6 +725,14 @@ function HasSaveSlots._setupRemotes(self: HasSaveSlots): () return (Promise :: any).rejected("Bad player") end end)) + + self._maid:GiveTask(self._remoting.PromiseDeselectSlot:Bind(function(remotePlayer: Player) + if remotePlayer == self._obj then + return self:PromiseDeselectSlot() + else + return (Promise :: any).rejected("Bad player") + end + end)) end return PlayerBinder.new("HasSaveSlots", HasSaveSlots :: any) :: Binder.Binder diff --git a/src/saveslot/src/Server/Binders/HasSaveSlots.spec.lua b/src/saveslot/src/Server/Binders/HasSaveSlots.spec.lua index 6b59f422692..b37ef6b7b28 100644 --- a/src/saveslot/src/Server/Binders/HasSaveSlots.spec.lua +++ b/src/saveslot/src/Server/Binders/HasSaveSlots.spec.lua @@ -1237,6 +1237,28 @@ describe("HasSaveSlots playtime tracking", function() context:Destroy() end) + it("zeroes LastSessionLength when a session begins, keeping the total minus it as the pre-session time", function() + local context = setup() + + local slotId = createAndSelect(context, 1) + local tracker: any = context.hasSaveSlots:GetSlotsDataStore() + tracker._playSessionStart = os.time() - 120 + tracker._playSessionLastFlush = os.time() - 120 + tracker:_flushPlaytime() + + -- Switching away ends the session with its length recorded; coming back must not carry it over + createAndSelect(context, 2) + local reselectPromise = context.hasSaveSlots:PromiseSelectSlot(slotId) + expect(PromiseTestUtils.awaitSettled(reselectPromise, 10)).toEqual(true) + reselectPromise:Yield() + + local metadata = getMetadata(context, slotId) + expect(metadata.TimePlayed ~= nil and metadata.TimePlayed >= 120).toEqual(true) + expect(metadata.LastSessionLength).toEqual(0) + + context:Destroy() + end) + it("does not accrue time before any slot is selected", function() local context = setup() diff --git a/src/saveslot/src/Server/HasSaveSlotsDataStore.lua b/src/saveslot/src/Server/HasSaveSlotsDataStore.lua index 668b0012835..4e6b2412fa3 100644 --- a/src/saveslot/src/Server/HasSaveSlotsDataStore.lua +++ b/src/saveslot/src/Server/HasSaveSlotsDataStore.lua @@ -41,6 +41,7 @@ local SaveSlotCodeUtils = require("SaveSlotCodeUtils") local SaveSlotConstants = require("SaveSlotConstants") local SaveSlotData = require("SaveSlotData") local SaveSlotExportUtils = require("SaveSlotExportUtils") +local SaveSlotUtils = require("SaveSlotUtils") local ValueObject = require("ValueObject") -- The caller-supplied fields for a new slot. SlotId and SlotIndex are assigned by PromiseCreateSlot @@ -485,7 +486,7 @@ function HasSaveSlotsDataStore.PromiseCreateSlot( local data = { SlotId = slotId, SlotIndex = slotIndex, - SlotName = (metadata and metadata.SlotName) or `Slot {slotIndex}`, + SlotName = (metadata and metadata.SlotName) or SaveSlotUtils.getDefaultSlotName(slotIndex), CreatedTime = os.time(), Summary = metadata and metadata.Summary, TimePlayed = metadata and metadata.TimePlayed, @@ -1573,7 +1574,8 @@ end selecting a slot begins one (bumping PlayCount), and deselecting, switching, or unbinding ends it. Elapsed wall time is folded into the slot's TimePlayed from a datastore saving callback, so it persists on exactly the cadence the data is written -- always fresh at save time, with no - separate timer -- and again at each session boundary. + separate timer -- and again at each session boundary. Between flushes the active slot's total is + stale on purpose; [SaveSlotUtils.getTimePlayed] re-derives it from the clock for display. Skipped entirely when the owner opts out (`TrackPlaytime = false`), which is what offline admin tooling wants: editing a slot from a console is not someone playing it, and no session is ever @@ -1619,6 +1621,9 @@ function HasSaveSlotsDataStore._beginPlaySession(self: HasSaveSlotsDataStore, sl local slot = self._slotMap[slotId] if slot then SaveSlotData.PlayCount:Set(slot, (SaveSlotData.PlayCount:Get(slot) or 0) + 1) + -- Zeroed at the boundary so TimePlayed - LastSessionLength is the pre-session total for the + -- whole session, whatever the flush cadence; see SaveSlotUtils.getTimePlayed. + SaveSlotData.LastSessionLength:Set(slot, 0) end end diff --git a/src/saveslot/src/Shared/Cmdr/SaveSlotCmdrUtils.lua b/src/saveslot/src/Shared/Cmdr/SaveSlotCmdrUtils.lua index 62970f5e9bb..624ed780b2e 100644 --- a/src/saveslot/src/Shared/Cmdr/SaveSlotCmdrUtils.lua +++ b/src/saveslot/src/Shared/Cmdr/SaveSlotCmdrUtils.lua @@ -5,8 +5,12 @@ local require = require(script.Parent.loader).load(script) +local RelativeTimeUtils = require("RelativeTimeUtils") local SaveSlotConstants = require("SaveSlotConstants") local SaveSlotData = require("SaveSlotData") +local SaveSlotUtils = require("SaveSlotUtils") +local Time = require("Time") +local TimeDurationUtils = require("TimeDurationUtils") local SaveSlotCmdrUtils = {} @@ -14,6 +18,13 @@ local SaveSlotCmdrUtils = {} -- JSON-encoded attribute, so they are shallow in practice; this only guards a pathological one. local MAX_VALUE_DEPTH = 3 +-- Unit suffixes for the compact "1h 23m" playtime a listing shows +local COMPACT_DURATION_STRINGS: TimeDurationUtils.DurationStringOverrides = { + hours = { one = "%dh", other = "%dh" }, + minutes = { one = "%dm", other = "%dm" }, + seconds = { one = "%ds", other = "%ds" }, +} + function SaveSlotCmdrUtils.registerSlotIndexType(cmdr, saveSlotDataService) local slotIndex = { Transform = function(text: string, player: Player) @@ -153,52 +164,29 @@ function SaveSlotCmdrUtils.formatValue(value: any, depth: number?): string end --[=[ - Renders a duration in seconds as "1h 23m", "23m", or "45s". - - @param seconds number - @return string + Renders a duration in seconds as its two largest units, e.g. "1h 23m", "23m 4s", or "45s". ]=] function SaveSlotCmdrUtils.formatDuration(seconds: number): string - local total = math.max(0, math.floor(seconds)) - local hours = math.floor(total / 3600) - local minutes = math.floor((total % 3600) / 60) - - if hours > 0 then - return `{hours}h {minutes}m` - elseif minutes > 0 then - return `{minutes}m` - end - - return `{total}s` + return TimeDurationUtils.format(math.max(0, seconds), "h __ m __ s __", { + largest = 2, + trunc = true, + strings = COMPACT_DURATION_STRINGS, + }) end --[=[ - Renders a unix timestamp as UTC plus how long ago it was, e.g. "2026-08-12 14:03 UTC (3h ago)". - The relative part is what an admin actually reads; the absolute one is what they quote back. - - @param unixTime number - @param now number? -- defaults to os.time() - @return string + Renders a unix timestamp as UTC plus how long ago it was, e.g. "2026-08-12 14:03 UTC (3 hours ago)". + The relative part is what an admin actually reads; the absolute one is what they quote back. A + timestamp ahead of `now` (clock skew) prints the absolute part alone. `now` defaults to os.time(). ]=] function SaveSlotCmdrUtils.formatTimestamp(unixTime: number, now: number?): string - local absolute = os.date("!%Y-%m-%d %H:%M UTC", math.floor(unixTime)) - local elapsed = (now or os.time()) - unixTime - if elapsed < 0 then + local absolute = Time.format("YYYY-MM-DD HH:mm [UTC]", unixTime) + local resolvedNow = now or os.time() + if unixTime > resolvedNow then return absolute end - local relative - if elapsed < 60 then - relative = "just now" - elseif elapsed < 3600 then - relative = `{math.floor(elapsed / 60)}m ago` - elseif elapsed < 86400 then - relative = `{math.floor(elapsed / 3600)}h ago` - else - relative = `{math.floor(elapsed / 86400)}d ago` - end - - return `{absolute} ({relative})` + return `{absolute} ({RelativeTimeUtils.from(unixTime, resolvedNow)})` end --[=[ @@ -251,7 +239,7 @@ function SaveSlotCmdrUtils.formatSlotBlock( status: string?, now: number? ): string - local name = metadata.SlotName or `Slot {metadata.SlotIndex}` + local name = metadata.SlotName or SaveSlotUtils.getDefaultSlotName(metadata.SlotIndex) local lines = { `"{name}" ({metadata.SlotIndex}){if status then ` — {status}` else ""}` } local played = {} diff --git a/src/saveslot/src/Shared/Cmdr/SaveSlotCmdrUtils.spec.lua b/src/saveslot/src/Shared/Cmdr/SaveSlotCmdrUtils.spec.lua index 8556d2e569b..731d72a6578 100644 --- a/src/saveslot/src/Shared/Cmdr/SaveSlotCmdrUtils.spec.lua +++ b/src/saveslot/src/Shared/Cmdr/SaveSlotCmdrUtils.spec.lua @@ -244,8 +244,8 @@ describe("SaveSlotCmdrUtils.formatSlotBlock", function() expect(block).toBe(table.concat({ '"Alpha" (1) — Active', - " played 1h 23m, 4 session(s), last played 2025-07-31 20:13 UTC (2h ago)", - " created 2025-07-28 22:13 UTC (3d ago)", + " played 1h 23m, 4 session(s), last played 2025-07-31 20:13 UTC (2 hours ago)", + " created 2025-07-28 22:13 UTC (3 days ago)", " progress: chapter = 3", }, "\n")) end) diff --git a/src/saveslot/src/Shared/Data/SaveSlotData.lua b/src/saveslot/src/Shared/Data/SaveSlotData.lua index ce73d597a7a..cb09a3547c5 100644 --- a/src/saveslot/src/Shared/Data/SaveSlotData.lua +++ b/src/saveslot/src/Shared/Data/SaveSlotData.lua @@ -27,7 +27,7 @@ export type SaveSlotMetadata = { -- Accrued automatically by HasSaveSlots while the slot is the active slot; see _setupPlaytimeTracking. TimePlayed: number?, -- total seconds the slot has been actively played, across every session PlayCount: number?, -- number of sessions (incremented each time the slot is selected) - LastSessionLength: number?, -- seconds of the current/most-recent session + LastSessionLength: number?, -- seconds of the current/most-recent session, zeroed when a session begins -- A session-only slot that is never persisted and is filtered out of the save-slot list. Set at -- creation and never mutated; see HasSaveSlots.PromiseSelectEphemeralSlot. IsEphemeral: boolean?, diff --git a/src/saveslot/src/Shared/HasSaveSlotsInterface.lua b/src/saveslot/src/Shared/HasSaveSlotsInterface.lua index 694ad07c82a..e7f3fb90353 100644 --- a/src/saveslot/src/Shared/HasSaveSlotsInterface.lua +++ b/src/saveslot/src/Shared/HasSaveSlotsInterface.lua @@ -20,6 +20,7 @@ return TieDefinition.new("HasSaveSlots", { PromiseGetSlotMetadata = TieDefinition.Types.METHOD, PromiseSlotIdFromIndex = TieDefinition.Types.METHOD, PromiseLastActiveSlotId = TieDefinition.Types.METHOD, + PromiseDeselectSlot = TieDefinition.Types.METHOD, -- Answered from the teleport data the player arrived with (see HasSaveSlotsBase); both realms -- resolve the incoming slot id from their own [TeleportDataService]. @@ -32,7 +33,6 @@ return TieDefinition.new("HasSaveSlots", { ObserveActiveSlotStoreBrio = TieDefinition.Types.METHOD, PromiseActiveSlotStore = TieDefinition.Types.METHOD, PromiseSlotsLoaded = TieDefinition.Types.METHOD, - PromiseDeselectSlot = TieDefinition.Types.METHOD, PromiseSelectLastSaveSlot = TieDefinition.Types.METHOD, PromiseSelectNewSaveSlot = TieDefinition.Types.METHOD, PromiseSelectEphemeralSlot = TieDefinition.Types.METHOD, diff --git a/src/saveslot/src/Shared/SaveSlotUtils.lua b/src/saveslot/src/Shared/SaveSlotUtils.lua new file mode 100644 index 00000000000..71a066081c6 --- /dev/null +++ b/src/saveslot/src/Shared/SaveSlotUtils.lua @@ -0,0 +1,42 @@ +--!strict +--[=[ + @class SaveSlotUtils +]=] + +local require = require(script.Parent.loader).load(script) + +local SaveSlotData = require("SaveSlotData") + +local SaveSlotUtils = {} + +--[=[ + The name a slot gets when it is created without one, or when its name is cleared. +]=] +function SaveSlotUtils.getDefaultSlotName(slotIndex: number): string + assert(type(slotIndex) == "number", "Bad slotIndex") + + return `Slot {slotIndex}` +end + +--[=[ + Seconds the slot has been played, current as of `now` (defaults to `os.time()`). `TimePlayed` + only lands on the slot when its data saves or its session ends, so for the active slot the + running session is re-derived: the total minus the part of it already credited to this session, + plus the wall time since the slot was selected. It never reads below the credited total, so a + client clock behind the server's cannot make it go backwards. +]=] +function SaveSlotUtils.getTimePlayed(metadata: SaveSlotData.SaveSlotMetadata, isActive: boolean, now: number?): number + assert(type(metadata) == "table", "Bad metadata") + assert(type(isActive) == "boolean", "Bad isActive") + + local timePlayed = metadata.TimePlayed or 0 + if not isActive or metadata.LastPlayedTime == nil then + return timePlayed + end + + local beforeSession = timePlayed - (metadata.LastSessionLength or 0) + local sessionLength = (now or os.time()) - metadata.LastPlayedTime + return math.max(timePlayed, beforeSession + sessionLength) +end + +return SaveSlotUtils diff --git a/src/saveslot/src/Shared/SaveSlotUtils.spec.lua b/src/saveslot/src/Shared/SaveSlotUtils.spec.lua new file mode 100644 index 00000000000..18ebb8b0035 --- /dev/null +++ b/src/saveslot/src/Shared/SaveSlotUtils.spec.lua @@ -0,0 +1,57 @@ +--!strict +local require = require(script.Parent.loader).load(script) + +local Jest = require("Jest") +local SaveSlotUtils = require("SaveSlotUtils") + +local describe = Jest.Globals.describe +local expect = Jest.Globals.expect +local it = Jest.Globals.it + +local NOW = 1_754_000_000 + +local function metadata(fields: { [string]: any }): any + local result: { [string]: any } = { SlotId = "slot-1", SlotIndex = 1 } + for key, value in fields do + result[key] = value + end + return result +end + +describe("SaveSlotUtils.getTimePlayed", function() + it("returns the credited total for an inactive slot", function() + local slot = metadata({ TimePlayed = 500, LastSessionLength = 120, LastPlayedTime = NOW - 3600 }) + + expect(SaveSlotUtils.getTimePlayed(slot, false, NOW)).toBe(500) + end) + + it("returns zero for a slot that has never been played", function() + expect(SaveSlotUtils.getTimePlayed(metadata({}), false, NOW)).toBe(0) + expect(SaveSlotUtils.getTimePlayed(metadata({}), true, NOW)).toBe(0) + end) + + it("counts the running session from the moment the active slot was selected", function() + local slot = metadata({ TimePlayed = 500, LastSessionLength = 0, LastPlayedTime = NOW - 90 }) + + expect(SaveSlotUtils.getTimePlayed(slot, true, NOW)).toBe(590) + end) + + it("does not double count the part of the session a flush already credited", function() + -- 60s of a 90s session has been flushed into the total + local slot = metadata({ TimePlayed = 560, LastSessionLength = 60, LastPlayedTime = NOW - 90 }) + + expect(SaveSlotUtils.getTimePlayed(slot, true, NOW)).toBe(590) + end) + + it("never reads below the credited total when the clock is behind the server", function() + local slot = metadata({ TimePlayed = 560, LastSessionLength = 60, LastPlayedTime = NOW - 90 }) + + expect(SaveSlotUtils.getTimePlayed(slot, true, NOW - 45)).toBe(560) + end) + + it("starts a first session from zero", function() + local slot = metadata({ LastPlayedTime = NOW - 30 }) + + expect(SaveSlotUtils.getTimePlayed(slot, true, NOW)).toBe(30) + end) +end) diff --git a/src/time/README.md b/src/time/README.md index 238bbb48442..9a93fd72a3d 100644 --- a/src/time/README.md +++ b/src/time/README.md @@ -11,78 +11,11 @@ -Library handles time based parsing / operations. Untested. Based off of PHP's time system. Note: This library is out of date, and does not necessarily work. I recommend using os.time() +Time based parsing and formatting ## Installation ``` npm install @quenty/time --save -``` - -## Usage -Usage is designed to be simple. - -### `Time.getDaysMonthTable(year)` -Returns a Days in months table for the given year - -### `Time.getSecond(currentTime)` - -### `Time.getMinute(currentTime)` - -### `Time.getHour(currentTime)` - -### `Time.getDay(currentTime)` - -### `Time.getYear(currentTime)` - -### `Time.getYearShort(currentTime)` - -### `Time.getYearShortFormatted(currentTime)` - -### `Time.getMonth(currentTime)` - -### `Time.getFormattedMonth(currentTime)` - -### `Time.getDayOfTheMonth(currentTime)` - -### `Time.getFormattedDayOfTheMonth(currentTime)` - -### `Time.getMonthName(currentTime)` - -### `Time.getMonthNameShort(currentTime)` - -### `Time.getJulianDate(currentTime)` - -### `Time.getDayOfTheWeek(currentTime)` - -### `Time.getDayOfTheWeekName(currentTime)` - -### `Time.getDayOfTheWeekNameShort(currentTime)` - -### `Time.getOrdinalOfNumber(number)` - -### `Time.getDayOfTheMonthOrdinal(currentTime)` - -### `Time.getFormattedSecond(currentTime)` - -### `Time.getFormattedMinute(currentTime)` - -### `Time.getRegularHour(currentTime)` - -### `Time.getHourFormatted(currentTime)` - -### `Time.getRegularHourFormatted(currentTime)` - -### `Time.getamOrpm(currentTime)` - -### `Time.getAMorPM(currentTime)` - -### `Time.getMilitaryHour(currentTime)` - -### `Time.isLeapYear(currentTime)` - -### `Time.getDaysInMonth(currentTime)` - -### `Time.getFormattedTime(format, currentTime)` - +``` \ No newline at end of file diff --git a/src/time/deploy.nevermore.json b/src/time/deploy.nevermore.json new file mode 100644 index 00000000000..286c75b9588 --- /dev/null +++ b/src/time/deploy.nevermore.json @@ -0,0 +1,10 @@ +{ + "targets": { + "test": { + "universeId": 9716264427, + "placeId": 122259403373028, + "project": "test/default.project.json", + "scriptTemplate": "test/scripts/Server/ServerMain.server.lua" + } + } +} diff --git a/src/time/package.json b/src/time/package.json index 85c67e2492b..4c133a0b289 100644 --- a/src/time/package.json +++ b/src/time/package.json @@ -1,7 +1,7 @@ { "name": "@quenty/time", "version": "2.5.0", - "description": "Library handles time based parsing / operations. Untested. Based off of PHP's time system. Note: This library is out of date, and does not necessarily work. I recommend using os.time()", + "description": "Time based parsing and formatting built on Roblox's DateTime, with PHP-style format codes. All calculations are in UTC.", "keywords": [ "Roblox", "Nevermore", @@ -27,6 +27,15 @@ "contributors": [ "Quenty" ], + "dependencies": { + "@quenty/clienttranslator": "workspace:*", + "@quenty/loader": "workspace:*", + "@quenty/nevermore-test-runner": "workspace:*", + "@quentystudios/jest-lua": "3.10.0-quenty.2" + }, + "devDependencies": { + "@quenty/loader": "workspace:*" + }, "publishConfig": { "access": "public" } diff --git a/src/time/src/Shared/RelativeTimeUtils.lua b/src/time/src/Shared/RelativeTimeUtils.lua new file mode 100644 index 00000000000..5d6684bc4b6 --- /dev/null +++ b/src/time/src/Shared/RelativeTimeUtils.lua @@ -0,0 +1,220 @@ +--!strict +--[=[ + Describes the distance between two times in words: `in 3 hours`, `a day ago`, `2 months`. + + @class RelativeTimeUtils +]=] + +local require = require(script.Parent.loader).load(script) + +local Time = require("Time") +local TimeLocalizationUtils = require("TimeLocalizationUtils") + +local RelativeTimeUtils = {} + +-- Fractional months in (dateTime - other), calendar aware. Anchors on whole months, then adds the fraction of the month the remainder covers. +function RelativeTimeUtils._monthDiff(dateTime: DateTime, other: DateTime): number + local a = Time._toUniversalTime(dateTime) + local b = Time._toUniversalTime(other) + if a.Day < b.Day then + return -RelativeTimeUtils._monthDiff(other, dateTime) + end + + local wholeMonthDiff = (b.Year - a.Year) * 12 + (b.Month - a.Month) + local anchor = Time.add(dateTime, wholeMonthDiff, "month") + local remainder = other.UnixTimestampMillis - anchor.UnixTimestampMillis + local isBeforeAnchor = remainder < 0 + local anchor2 = Time.add(dateTime, wholeMonthDiff + (if isBeforeAnchor then -1 else 1), "month") + local monthLength = math.abs(anchor2.UnixTimestampMillis - anchor.UnixTimestampMillis) + local fraction = if monthLength == 0 then 0 else remainder / monthLength + + return -(wholeMonthDiff + fraction) +end + +-- (dateTime - other) in the given unit, fractional +function RelativeTimeUtils._diff(dateTime: DateTime, other: DateTime, unit: Time.TimeUnit): number + local normalized = Time._normalizeUnit(unit) + local millis = dateTime.UnixTimestampMillis - other.UnixTimestampMillis + + if normalized == "second" then + return millis / 1000 + elseif normalized == "minute" then + return millis / 60000 + elseif normalized == "hour" then + return millis / 3600000 + elseif normalized == "day" then + return millis / 86400000 + elseif normalized == "week" then + return millis / 604800000 + elseif normalized == "month" then + return RelativeTimeUtils._monthDiff(dateTime, other) + elseif normalized == "year" then + return RelativeTimeUtils._monthDiff(dateTime, other) / 12 + else + error(string.format("Bad relative time unit %q", normalized)) + end +end + +--[=[ + One step of the relative time scale. `key` picks the string, `limit` is the + largest rounded amount this step accepts (omit for the last step), and `unit` is one of + [Time.TimeUnit] other than milliseconds or quarters. A step without a unit reuses the + previous step's diff, which is how `45..89 seconds` becomes `a minute`. + + @interface RelativeTimeThreshold + .key string + .limit number? + .unit TimeUnit? + @within RelativeTimeUtils +]=] +export type RelativeTimeThreshold = { key: string, limit: number?, unit: Time.TimeUnit? } + +--[=[ + See [TimeLocalizationUtils.RelativeTimeStringOverrides]. + + @type RelativeTimeStringOverrides TimeLocalizationUtils.RelativeTimeStringOverrides + @within RelativeTimeUtils +]=] +export type RelativeTimeStringOverrides = TimeLocalizationUtils.RelativeTimeStringOverrides + +--[=[ + Options for [RelativeTimeUtils.from], [RelativeTimeUtils.to], [RelativeTimeUtils.fromNow] and [RelativeTimeUtils.toNow]. + + @interface RelativeTimeOptions + .withoutSuffix boolean? -- Drop the `in` / `ago` wrapper + .thresholds { RelativeTimeThreshold }? -- Replaces the default scale + .rounding ((number) -> number)? -- Applied to the absolute amount, defaults to round half up + .locale string? -- Picks the strings through [TimeLocalizationUtils], defaults to English + .strings RelativeTimeStringOverrides? -- Overrides or extends the locale's strings keyed by threshold key, plus `future` and `past` + @within RelativeTimeUtils +]=] +export type RelativeTimeOptions = { + withoutSuffix: boolean?, + locale: string?, + thresholds: { RelativeTimeThreshold }?, + rounding: ((number) -> number)?, + strings: RelativeTimeStringOverrides?, +} + +-- Default scale: seconds, then minutes, hours, days, months and years +local RELATIVE_TIME_THRESHOLDS: { RelativeTimeThreshold } = { + { key = "s", limit = 44, unit = "second" }, + { key = "m", limit = 89 }, + { key = "mm", limit = 44, unit = "minute" }, + { key = "h", limit = 89 }, + { key = "hh", limit = 21, unit = "hour" }, + { key = "d", limit = 35 }, + { key = "dd", limit = 25, unit = "day" }, + { key = "M", limit = 45 }, + { key = "MM", limit = 10, unit = "month" }, + { key = "y", limit = 17 }, + { key = "yy", unit = "year" }, +} + +function RelativeTimeUtils._roundHalfUp(value: number): number + return math.floor(value + 0.5) +end + +function RelativeTimeUtils._getRelativeTimeString( + options: RelativeTimeOptions?, + key: string +): TimeLocalizationUtils.RelativeTimeString + local overrides = if options then options.strings else nil + local localeStrings: any = + TimeLocalizationUtils.getRelativeTimeStringsForLocale(if options then options.locale else nil) + local value = (if overrides then overrides[key] else nil) or localeStrings[key] + assert(value, string.format("No relative time string for %q", key)) + return value +end + +-- Describes (dateTime - other): positive is the future. +function RelativeTimeUtils._relativeTime(dateTime: DateTime, other: DateTime, options: RelativeTimeOptions?): string + local thresholds = (if options then options.thresholds else nil) or RELATIVE_TIME_THRESHOLDS + local rounding = (if options then options.rounding else nil) or RelativeTimeUtils._roundHalfUp + + local withoutSuffix = if options then options.withoutSuffix == true else false + + local result = 0 + local isFuture = false + local out = "" + + for index, threshold in thresholds do + if threshold.unit then + result = RelativeTimeUtils._diff(dateTime, other, threshold.unit) + end + + local amount = rounding(math.abs(result)) + isFuture = result > 0 + + if threshold.limit == nil or amount <= threshold.limit then + -- "1 minutes" reads as "a minute", "0 seconds" as "a few seconds" + local resolved = if amount <= 1 and index > 1 then thresholds[index - 1] else threshold + local format = RelativeTimeUtils._getRelativeTimeString(options, resolved.key) + if type(format) == "function" then + out = format(amount, withoutSuffix, resolved.key, isFuture) + else + out = string.gsub(format, "%%d", tostring(amount)) + end + break + end + end + + if withoutSuffix then + return out + end + + local template = RelativeTimeUtils._getRelativeTimeString(options, if isFuture then "future" else "past") + assert(type(template) == "string", "future and past must be strings") + local formatted = string.gsub(template, "%%s", function() + return out + end) + + return formatted +end + +--[=[ + Describes how far `currentTime` is from `compareTo`: a later time reads + `in a day`, an earlier one `a day ago`. See [RelativeTimeOptions] to drop the suffix or + change the scale. + + ```lua + print(RelativeTimeUtils.from("2025-09-23T00:00:00Z", "2025-09-22T00:00:00Z")) --> in a day + print(RelativeTimeUtils.from("2025-09-22T00:00:00Z", "2025-09-23T00:00:00Z", { withoutSuffix = true })) --> a day + ``` +]=] +function RelativeTimeUtils.from( + currentTime: Time.DateTimeLike, + compareTo: Time.DateTimeLike, + options: RelativeTimeOptions? +): string + return RelativeTimeUtils._relativeTime(Time._toDateTime(currentTime), Time._toDateTime(compareTo), options) +end + +--[=[ + Describes how far `compareTo` is from `currentTime`. The mirror of + [RelativeTimeUtils.from]: `RelativeTimeUtils.to(a, b)` equals `RelativeTimeUtils.from(b, a)`. +]=] +function RelativeTimeUtils.to( + currentTime: Time.DateTimeLike, + compareTo: Time.DateTimeLike, + options: RelativeTimeOptions? +): string + return RelativeTimeUtils._relativeTime(Time._toDateTime(compareTo), Time._toDateTime(currentTime), options) +end + +--[=[ + Describes how far `currentTime` is from now: `3 hours ago`. +]=] +function RelativeTimeUtils.fromNow(currentTime: Time.DateTimeLike, options: RelativeTimeOptions?): string + return RelativeTimeUtils.from(currentTime, nil, options) +end + +--[=[ + Describes how far now is from `currentTime`: `in 3 hours` for a time + three hours in the past. +]=] +function RelativeTimeUtils.toNow(currentTime: Time.DateTimeLike, options: RelativeTimeOptions?): string + return RelativeTimeUtils.to(currentTime, nil, options) +end + +return RelativeTimeUtils diff --git a/src/time/src/Shared/RelativeTimeUtils.spec.lua b/src/time/src/Shared/RelativeTimeUtils.spec.lua new file mode 100644 index 00000000000..c555404f695 --- /dev/null +++ b/src/time/src/Shared/RelativeTimeUtils.spec.lua @@ -0,0 +1,308 @@ +--!strict +--[[ + @class RelativeTimeUtils.spec.lua +]] + +local require = require(script.Parent.loader).load(script) + +local Jest = require("Jest") +local RelativeTimeUtils = require("RelativeTimeUtils") + +local describe = Jest.Globals.describe +local expect = Jest.Globals.expect +local it = Jest.Globals.it + +local BASE = 1758499200 -- 2025-09-22 Monday 00:00:00 UTC +local WITHOUT_SUFFIX = { withoutSuffix = true } + +describe("RelativeTimeUtils.from", function() + local function fromOffset(seconds: number): string + return RelativeTimeUtils.from(BASE + seconds, BASE, WITHOUT_SUFFIX) + end + + it("should pick the threshold for seconds, minutes and hours", function() + expect(fromOffset(0)).toBe("a few seconds") + expect(fromOffset(44)).toBe("a few seconds") + expect(fromOffset(45)).toBe("a minute") + expect(fromOffset(89)).toBe("a minute") + expect(fromOffset(90)).toBe("2 minutes") + expect(fromOffset(44 * 60)).toBe("44 minutes") + expect(fromOffset(45 * 60)).toBe("an hour") + expect(fromOffset(89 * 60)).toBe("an hour") + expect(fromOffset(90 * 60)).toBe("2 hours") + expect(fromOffset(21 * 3600)).toBe("21 hours") + end) + + it("should pick the threshold for days and months", function() + expect(fromOffset(22 * 3600)).toBe("a day") + expect(fromOffset(35 * 3600)).toBe("a day") + expect(fromOffset(36 * 3600)).toBe("2 days") + expect(fromOffset(25 * 86400)).toBe("25 days") + expect(fromOffset(26 * 86400)).toBe("a month") + expect(fromOffset(45 * 86400)).toBe("a month") + expect(fromOffset(46 * 86400)).toBe("2 months") + end) + + it("should count months and years on the calendar", function() + expect(RelativeTimeUtils.from("2026-07-22T00:00:00Z", BASE, WITHOUT_SUFFIX)).toBe("10 months") + expect(RelativeTimeUtils.from("2026-08-22T00:00:00Z", BASE, WITHOUT_SUFFIX)).toBe("a year") + expect(RelativeTimeUtils.from("2027-02-22T00:00:00Z", BASE, WITHOUT_SUFFIX)).toBe("a year") + expect(RelativeTimeUtils.from("2027-03-22T00:00:00Z", BASE, WITHOUT_SUFFIX)).toBe("2 years") + expect(RelativeTimeUtils.from("2035-09-22T00:00:00Z", BASE, WITHOUT_SUFFIX)).toBe("10 years") + expect(RelativeTimeUtils.from("2025-02-28T00:00:00Z", "2025-01-31T00:00:00Z", WITHOUT_SUFFIX)).toBe("a month") + end) + + it("should say in for the future and ago for the past", function() + expect(RelativeTimeUtils.from(BASE + 86400, BASE)).toBe("in a day") + expect(RelativeTimeUtils.from(BASE - 86400, BASE)).toBe("a day ago") + expect(RelativeTimeUtils.from(BASE, BASE)).toBe("a few seconds ago") + expect(RelativeTimeUtils.from(BASE - 3 * 3600, BASE)).toBe("3 hours ago") + end) + + it("should accept any DateTimeLike on both sides", function() + expect(RelativeTimeUtils.from(DateTime.fromUnixTimestamp(BASE + 3600), "2025-09-22T00:00:00Z")).toBe( + "in an hour" + ) + end) +end) + +describe("RelativeTimeUtils.to", function() + it("should mirror from", function() + expect(RelativeTimeUtils.to(BASE, BASE + 86400)).toBe("in a day") + expect(RelativeTimeUtils.to(BASE + 86400, BASE)).toBe("a day ago") + expect(RelativeTimeUtils.to(BASE, BASE + 90, WITHOUT_SUFFIX)).toBe("2 minutes") + end) +end) + +describe("RelativeTimeUtils.fromNow / toNow", function() + it("should compare against the current time", function() + local hourAgo = DateTime.now().UnixTimestamp - 3600 + + expect(RelativeTimeUtils.fromNow(hourAgo)).toBe("an hour ago") + expect(RelativeTimeUtils.fromNow(hourAgo, WITHOUT_SUFFIX)).toBe("an hour") + expect(RelativeTimeUtils.toNow(hourAgo)).toBe("in an hour") + expect(RelativeTimeUtils.fromNow(nil)).toBe("a few seconds ago") + end) +end) + +describe("RelativeTimeUtils.from with options", function() + it("should use custom thresholds and strings", function() + local thresholds: { RelativeTimeUtils.RelativeTimeThreshold } = { + { key = "s", limit = 59, unit = "second" }, + { key = "mm", limit = 59, unit = "minute" }, + { key = "hh", limit = 23, unit = "hour" }, + { key = "ww", limit = 3, unit = "week" }, + { key = "dd", unit = "day" }, + } + local strings: RelativeTimeUtils.RelativeTimeStringOverrides = { + s = "%d seconds", + ww = "%d weeks", + } + local options: RelativeTimeUtils.RelativeTimeOptions = { + withoutSuffix = true, + thresholds = thresholds, + strings = strings, + } + + expect(RelativeTimeUtils.from(BASE + 30, BASE, options)).toBe("30 seconds") + expect(RelativeTimeUtils.from(BASE + 90, BASE, options)).toBe("2 minutes") + expect(RelativeTimeUtils.from(BASE + 5 * 3600, BASE, options)).toBe("5 hours") + expect(RelativeTimeUtils.from(BASE + 14 * 86400, BASE, options)).toBe("2 weeks") + expect(RelativeTimeUtils.from(BASE + 40 * 86400, BASE, options)).toBe("40 days") + end) + + it("should override the suffix strings", function() + local strings: RelativeTimeUtils.RelativeTimeStringOverrides = { future = "%s from now", past = "%s earlier" } + local options = { strings = strings } + + expect(RelativeTimeUtils.from(BASE + 3600, BASE, options)).toBe("an hour from now") + expect(RelativeTimeUtils.from(BASE - 3600, BASE, options)).toBe("an hour earlier") + end) + + it("should use custom rounding", function() + expect(RelativeTimeUtils.from(BASE + 90, BASE, { withoutSuffix = true, rounding = math.floor })).toBe( + "a minute" + ) + expect(RelativeTimeUtils.from(BASE + 90, BASE, { withoutSuffix = true, rounding = math.ceil })).toBe( + "2 minutes" + ) + end) + + it("should error on a threshold key without a string", function() + local thresholds: { RelativeTimeUtils.RelativeTimeThreshold } = { { key = "nope", unit = "second" } } + + expect(function() + RelativeTimeUtils.from(BASE + 30, BASE, { thresholds = thresholds }) + end).toThrow("No relative time string") + end) +end) + +describe("RelativeTimeUtils.from with a locale", function() + local function from(seconds: number, locale: string, withoutSuffix: boolean?): string + return RelativeTimeUtils.from(BASE + seconds, BASE, { locale = locale, withoutSuffix = withoutSuffix }) + end + + it("should use the locale's strings and suffixes", function() + expect(from(-60, "fr-fr")).toBe("il y a une minute") + expect(from(3 * 3600, "es-es")).toBe("en 3 horas") + expect(from(-2 * 86400, "pt-br")).toBe("há 2 dias") + expect(from(60, "ja-jp")).toBe("1分後") + expect(from(3 * 86400, "zh-cn")).toBe("3 天内") + expect(from(-3 * 86400, "zh-tw")).toBe("3 天前") + expect(from(3600, "ko-kr")).toBe("한 시간 후") + expect(from(5 * 60, "tr-tr")).toBe("5 dakika sonra") + end) + + it("should inflect German after in and vor", function() + expect(from(60, "de-de")).toBe("in einer Minute") + expect(from(-60, "de-de")).toBe("vor einer Minute") + expect(from(60, "de-de", true)).toBe("eine Minute") + expect(from(-3 * 86400, "de-de")).toBe("vor 3 Tagen") + expect(from(3 * 86400, "de-de", true)).toBe("3 Tage") + end) + + it("should pick Russian and Polish plural forms", function() + expect(from(2 * 60, "ru-ru")).toBe("через 2 минуты") + expect(from(5 * 60, "ru-ru")).toBe("через 5 минут") + expect(from(21 * 60, "ru-ru")).toBe("через 21 минуту") + expect(from(21 * 60, "ru-ru", true)).toBe("21 минута") + expect(from(-60, "ru-ru")).toBe("минуту назад") + expect(from(2 * 3600, "pl-pl")).toBe("za 2 godziny") + expect(from(5 * 3600, "pl-pl")).toBe("za 5 godzin") + expect(from(60, "pl-pl")).toBe("za minutę") + expect(from(60, "pl-pl", true)).toBe("minuta") + end) + + it("should resolve regional variants and let overrides win", function() + local strings: RelativeTimeUtils.RelativeTimeStringOverrides = { h = "une petite heure" } + + expect(from(-60, "fr-ca")).toBe("il y a une minute") + expect(RelativeTimeUtils.from(BASE + 3600, BASE, { locale = "fr-fr", strings = strings })).toBe( + "dans une petite heure" + ) + end) +end) + +describe("RelativeTimeUtils localization edge cases", function() + local function from(seconds: number, locale: string, withoutSuffix: boolean?): string + return RelativeTimeUtils.from(BASE + seconds, BASE, { locale = locale, withoutSuffix = withoutSuffix }) + end + + it("should call function valued strings with the amount, suffix flag, key and direction", function() + local calls: { { any } } = {} + local strings: RelativeTimeUtils.RelativeTimeStringOverrides = { + mm = function(amount: number, withoutSuffix: boolean, key: string, isFuture: boolean): string + table.insert(calls, { amount :: any, withoutSuffix, key, isFuture }) + return "custom" + end, + } + + expect(RelativeTimeUtils.from(BASE + 5 * 60, BASE, { strings = strings })).toBe("in custom") + expect(RelativeTimeUtils.from(BASE - 5 * 60, BASE, { strings = strings, withoutSuffix = true })).toBe("custom") + expect(calls).toEqual({ + { 5 :: any, false, "mm", true }, + { 5 :: any, true, "mm", false }, + }) + end) + + it("should let a string override replace a locale's function", function() + local strings: RelativeTimeUtils.RelativeTimeStringOverrides = { m = "1 Min." } + + expect(RelativeTimeUtils.from(BASE + 60, BASE, { locale = "de-de", strings = strings })).toBe("in 1 Min.") + expect(RelativeTimeUtils.from(BASE + 3600, BASE, { locale = "de-de", strings = strings })).toBe( + "in einer Stunde" + ) + end) + + it("should reject function valued future and past", function() + local strings: RelativeTimeUtils.RelativeTimeStringOverrides = { + future = function(): string + return "soon" + end, + } + + expect(function() + RelativeTimeUtils.from(BASE + 60, BASE, { strings = strings }) + end).toThrow("future and past must be strings") + end) + + it("should use the singular step for a rounded amount of 1 in every locale", function() + expect(from(60, "de-de")).toBe("in einer Minute") + expect(from(60 * 60, "ru-ru")).toBe("через час") + expect(from(-24 * 3600, "pl-pl")).toBe("1 dzień temu") + expect(from(24 * 3600, "ja-jp")).toBe("1日後") + end) + + it("should treat 11 to 14 as many in Russian even past 100", function() + expect(from(11 * 60, "ru-ru")).toBe("через 11 минут") + expect(from(12 * 3600, "ru-ru")).toBe("через 12 часов") + expect(from(14 * 3600, "ru-ru")).toBe("через 14 часов") + expect(RelativeTimeUtils.from("2136-09-22T00:00:00Z", BASE, { locale = "ru-ru", withoutSuffix = true })).toBe( + "111 лет" + ) + expect(RelativeTimeUtils.from("2126-09-22T00:00:00Z", BASE, { locale = "ru-ru" })).toBe("через 101 год") + end) + + it("should keep Polish teens and 22 apart", function() + expect(from(12 * 3600, "pl-pl")).toBe("za 12 godzin") + expect(from(22 * 60, "pl-pl")).toBe("za 22 minuty") + expect(RelativeTimeUtils.from("2137-09-22T00:00:00Z", BASE, { locale = "pl-pl", withoutSuffix = true })).toBe( + "112 lat" + ) + expect(RelativeTimeUtils.from("2047-09-22T00:00:00Z", BASE, { locale = "pl-pl" })).toBe("za 22 lata") + expect(RelativeTimeUtils.from("2037-09-22T00:00:00Z", BASE, { locale = "pl-pl" })).toBe("za 12 lat") + end) + + it("should inflect German months and years after vor", function() + expect(RelativeTimeUtils.from("2025-07-01T00:00:00Z", BASE, { locale = "de-de" })).toBe("vor 3 Monaten") + expect(RelativeTimeUtils.from("2025-07-01T00:00:00Z", BASE, { locale = "de-de", withoutSuffix = true })).toBe( + "3 Monate" + ) + expect(RelativeTimeUtils.from("2020-09-22T00:00:00Z", BASE, { locale = "de-de" })).toBe("vor 5 Jahren") + expect(RelativeTimeUtils.from("2024-09-22T00:00:00Z", BASE, { locale = "de-de" })).toBe("vor einem Jahr") + end) + + it("should place the suffix where the language puts it", function() + expect(from(3600, "it-it")).toBe("tra un'ora") + expect(from(-3600, "it-it")).toBe("un'ora fa") + expect(from(-3600, "id-id")).toBe("sejam yang lalu") + expect(from(2 * 86400, "vi-vn")).toBe("2 ngày tới") + expect(from(-2 * 86400, "th-th")).toBe("2 วันที่แล้ว") + expect(from(2 * 86400, "ar")).toBe("بعد 2 أيام") + end) + + it("should combine a locale with custom thresholds", function() + local thresholds: { RelativeTimeUtils.RelativeTimeThreshold } = { + { key = "s", limit = 59, unit = "second" }, + { key = "mm", limit = 59, unit = "minute" }, + { key = "hh", limit = 23, unit = "hour" }, + { key = "ww", unit = "week" }, + } + local strings: RelativeTimeUtils.RelativeTimeStringOverrides = { ww = "%d semaines" } + + expect( + RelativeTimeUtils.from( + BASE + 14 * 86400, + BASE, + { locale = "fr-fr", thresholds = thresholds, strings = strings } + ) + ).toBe("dans 2 semaines") + expect( + RelativeTimeUtils.from( + BASE + 5 * 3600, + BASE, + { locale = "fr-fr", thresholds = thresholds, strings = strings } + ) + ).toBe("dans 5 heures") + expect(function() + RelativeTimeUtils.from(BASE + 14 * 86400, BASE, { locale = "fr-fr", thresholds = thresholds }) + end).toThrow("No relative time string") + end) + + it("should resolve the locale loosely", function() + expect(from(-60, "FR-FR")).toBe("il y a une minute") + expect(from(-60, "fr_CA")).toBe("il y a une minute") + expect(from(-60, "zh-Hant")).toBe("1 分鐘前") + expect(from(-60, "xx-yy")).toBe("a minute ago") + end) +end) diff --git a/src/time/src/Shared/Time.lua b/src/time/src/Shared/Time.lua index aba94d84bd0..36b59ea7fc4 100644 --- a/src/time/src/Shared/Time.lua +++ b/src/time/src/Shared/Time.lua @@ -1,41 +1,74 @@ --!strict --[=[ - Library handles time based parsing / operations. Untested. Based off of PHP's time system. - - :::note - This library is out of date, and does not necessarily work. I recommend using os.time() - ::: - @class Time ]=] +local require = require(script.Parent.loader).load(script) + +local NumberLocalizationOrdinalUtils = require("NumberLocalizationOrdinalUtils") + local Time = {} --- luacheck: push ignore 631 -local MONTH_NAMES = { - "January", - "February", - "March", - "April", - "May", - "June", - "July", - "August", - "September", - "October", - "November", - "December", +--[=[ + A unix timestamp in seconds (fractions are floored), a Roblox `DateTime`, an ISO 8601 string + such as `"2025-09-22T13:00:00Z"`, or `nil` for the current time. + + @type DateTimeLike number | DateTime | string | nil + @within Time +]=] +export type DateTimeLike = (number | DateTime | string)? + +type UniversalTime = { + Year: number, + Month: number, + Day: number, + Hour: number, + Minute: number, + Second: number, + Millisecond: number, } -local MONTH_NAMES_SHORT = { "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" } -local DAYS_IN_MONTH = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 } -local DAYS_OF_WEEK = { "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday" } -local DAYS_OF_WEEK_SHORT = { "Sun", "Mon", "Tues", "Weds", "Thurs", "Fri", "Sat" } --- luacheck: pop + +-- Roblox has no default locale: FormatUniversalTime errors without one and falls back to English +-- (with a warning) for one it does not know. +local DEFAULT_LOCALE = "en-us" + +local DAYS_IN_MONTH = table.freeze({ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }) +local SECONDS_PER_DAY = 86400 +local UNIX_EPOCH_JULIAN_DAY = 2440588 + +function Time._toDateTime(currentTime: DateTimeLike): DateTime + if currentTime == nil then + return DateTime.now() + elseif typeof(currentTime) == "DateTime" then + return currentTime + elseif type(currentTime) == "number" then + return DateTime.fromUnixTimestamp(math.floor(currentTime)) + elseif type(currentTime) == "string" then + local dateTime = DateTime.fromIsoDate(currentTime) + assert(dateTime, "Bad ISO date") + return dateTime + else + error(string.format("Bad currentTime %q", typeof(currentTime))) + end +end + +function Time._toUniversalTime(currentTime: DateTimeLike): UniversalTime + -- The engine definitions type ToUniversalTime() as { any } + return Time._toDateTime(currentTime):ToUniversalTime() :: any +end + +function Time._formatUniversal(currentTime: DateTimeLike, format: string, locale: string?): string + return Time._toDateTime(currentTime):FormatUniversalTime(format, locale or DEFAULT_LOCALE) +end + +function Time._formatUniversalNumber(currentTime: DateTimeLike, format: string): number + local value = tonumber(Time._formatUniversal(currentTime, format)) + assert(value, "Failed to parse formatted time") + return value +end --[=[ Returns a Days in months table for the given year - @param year number - @return { [number]: number } ]=] function Time.getDaysMonthTable(year: number): { [number]: number } local copy = table.clone(DAYS_IN_MONTH) @@ -46,483 +79,762 @@ function Time.getDaysMonthTable(year: number): { [number]: number } copy[2] = 28 end - return copy + return table.freeze(copy) end --[=[ Returns the second of the given time. - @param currentTime number - @return number ]=] -function Time.getSecond(currentTime: number): number - return math.floor(currentTime % 60) +function Time.getSecond(currentTime: DateTimeLike): number + return Time._toUniversalTime(currentTime).Second end --[=[ Returns the minute of the given time. - @param currentTime number - @return number ]=] -function Time.getMinute(currentTime: number): number - return math.floor(currentTime / 60 % 60) +function Time.getMinute(currentTime: DateTimeLike): number + return Time._toUniversalTime(currentTime).Minute end --[=[ Returns the hour of the given time in 24-hour format. - @param currentTime number - @return number ]=] -function Time.getHour(currentTime: number): number - return math.floor(currentTime / 3600 % 24) +function Time.getHour(currentTime: DateTimeLike): number + return Time._toUniversalTime(currentTime).Hour end --[=[ - Returns the day of the year for the given time. - @param currentTime number - @return number + Returns the day of the year (1-366) for the given time. ]=] -function Time.getDay(currentTime: number): number - return math.ceil(currentTime / 60 / 60 / 24 % 365.25) +function Time.getDay(currentTime: DateTimeLike): number + return Time._formatUniversalNumber(currentTime, "DDD") end --[=[ Returns the year for the given time. - @param currentTime number - @return number ]=] -function Time.getYear(currentTime: number): number - return math.floor(currentTime / 60 / 60 / 24 / 365.25 + 1970) +function Time.getYear(currentTime: DateTimeLike): number + return Time._toUniversalTime(currentTime).Year end --[=[ Returns the last two digits of the year for the given time. - @param currentTime number - @return number ]=] -function Time.getYearShort(currentTime: number): number +function Time.getYearShort(currentTime: DateTimeLike): number return Time.getYear(currentTime) % 100 end --[=[ Returns the last two digits of the year formatted as a string. - @param currentTime number - @return string ]=] -function Time.getYearShortFormatted(currentTime: number): string - local shortYear = Time.getYearShort(currentTime) - if shortYear < 10 then - return "0" .. shortYear - end - return tostring(shortYear) +function Time.getYearShortFormatted(currentTime: DateTimeLike, locale: string?): string + return Time._formatUniversal(currentTime, "YY", locale) end --[=[ - Returns the month of the given time. - @param currentTime number - @return number? + Returns the month (1-12) of the given time. ]=] -function Time.getMonth(currentTime: number): number? - local year = Time.getYear(currentTime) - local day = Time.getDay(currentTime) - - local daysInMonth = Time.getDaysMonthTable(year) - - for i = 1, #daysInMonth do - if day > daysInMonth[i] then - day = day - daysInMonth[i] - else - return i - end - end - - return nil +function Time.getMonth(currentTime: DateTimeLike): number + return Time._toUniversalTime(currentTime).Month end --[=[ Returns the month formatted as a two-digit string. - @param currentTime number - @return string ]=] -function Time.getFormattedMonth(currentTime: number): string - local month = assert(Time.getMonth(currentTime), "Failed to get month") - if month < 10 then - return "0" .. month - end - - return tostring(month) +function Time.getFormattedMonth(currentTime: DateTimeLike, locale: string?): string + return Time._formatUniversal(currentTime, "MM", locale) end --[=[ - Returns the day of the month for the given time. - @param currentTime number - @return number? + Returns the day of the month (1-31) for the given time. ]=] -function Time.getDayOfTheMonth(currentTime: number): number? - local year = Time.getYear(currentTime) - local day = Time.getDay(currentTime) - - local daysInMonth = Time.getDaysMonthTable(year) - - for i = 1, #daysInMonth do - if day > daysInMonth[i] then - day = day - daysInMonth[i] - else - return day - end - end - - return nil +function Time.getDayOfTheMonth(currentTime: DateTimeLike): number + return Time._toUniversalTime(currentTime).Day end --[=[ Returns the day of the month formatted as a two-digit string. - @param currentTime number - @return string ]=] -function Time.getFormattedDayOfTheMonth(currentTime: number): string - local dayOfTheMonth = assert(Time.getDayOfTheMonth(currentTime), "Failed to get day of the month") - - if dayOfTheMonth < 10 then - return "0" .. dayOfTheMonth - end - - return tostring(dayOfTheMonth) +function Time.getFormattedDayOfTheMonth(currentTime: DateTimeLike, locale: string?): string + return Time._formatUniversal(currentTime, "DD", locale) end --[=[ Returns the full name of the month for the given time. - @param currentTime number - @return string ]=] -function Time.getMonthName(currentTime: number): string - local month = Time.getMonth(currentTime) - if month == nil then - return "Unknown" - end - - return MONTH_NAMES[month] +function Time.getMonthName(currentTime: DateTimeLike, locale: string?): string + return Time._formatUniversal(currentTime, "MMMM", locale) end --[=[ Returns the abbreviated name of the month for the given time. - @param currentTime number - @return string ]=] -function Time.getMonthNameShort(currentTime: number): string - local month = Time.getMonth(currentTime) - if month == nil then - return "???" - end - - return MONTH_NAMES_SHORT[month] +function Time.getMonthNameShort(currentTime: DateTimeLike, locale: string?): string + return Time._formatUniversal(currentTime, "MMM", locale) end --[=[ - Calculates the Julian date for the given time. - @param currentTime number - @return number + Returns the Julian day number for the given time. ]=] -function Time.getJulianDate(currentTime: number): number - local month = assert(Time.getMonth(currentTime), "Failed to get month") - local year = Time.getYear(currentTime) - local day = Time.getDay(currentTime) - - local a = (14 - month) / 12 - local y = year + 4800 - a - local m = month + 12 * a - 3 - - local julianDay = day + ((153 * m + 2) / 5) + 365 * y + (y / 4) - (y / 100) + (y / 400) - 32045 - - --[[local julianDay = (day - + ((153 * (month + 12 * ((14 - month) / 12 ) - 3) + 2) / 5) - + (365 * (year + 4800 - ((14 - month) / 12))) - + ((year + 4800 - ((14 - month) / 12)) / 4) - + ((year + 4800 - ((14 - month) / 12)) / 100) - + ((year + 4800 - ((14 - month) / 12)) / 400) - - 32045)--]] - - return julianDay +function Time.getJulianDate(currentTime: DateTimeLike): number + return Time._toDateTime(currentTime).UnixTimestamp // SECONDS_PER_DAY + UNIX_EPOCH_JULIAN_DAY end --[=[ - Returns the day of the week as a number (0-6) for the given time. - @param currentTime number - @return number + Returns the day of the week as a number (0-6, starting on Sunday) for the given time. ]=] -function Time.getDayOfTheWeek(currentTime: number): number - return math.floor(Time.getJulianDate(currentTime)) % 7 +function Time.getDayOfTheWeek(currentTime: DateTimeLike): number + return Time._formatUniversalNumber(currentTime, "d") end --[=[ Returns the full name of the day of the week for the given time. - @param currentTime number - @return string ]=] -function Time.getDayOfTheWeekName(currentTime: number): string - return DAYS_OF_WEEK[Time.getDayOfTheWeek(currentTime)] +function Time.getDayOfTheWeekName(currentTime: DateTimeLike, locale: string?): string + return Time._formatUniversal(currentTime, "dddd", locale) end --[=[ Returns the abbreviated name of the day of the week for the given time. - @param currentTime number - @return string ]=] -function Time.getDayOfTheWeekNameShort(currentTime: number): string - return DAYS_OF_WEEK_SHORT[Time.getDayOfTheWeek(currentTime)] +function Time.getDayOfTheWeekNameShort(currentTime: DateTimeLike, locale: string?): string + return Time._formatUniversal(currentTime, "ddd", locale) end --[=[ - Returns the ordinal suffix (e.g., "st", "nd", "rd", "th") for a given number. - @param number number - @return string + Returns the localized ordinal suffix for a number, for example `nd` for 22 in English. + See [NumberLocalizationOrdinalUtils.getSuffix]. ]=] -function Time.getOrdinalOfNumber(number: number): string - local tenRemainder = number % 10 - local hundredRemainder = number % 100 - - if hundredRemainder >= 10 and hundredRemainder <= 20 then - return "th" - end - - if tenRemainder == 1 then - return "st" - elseif tenRemainder == 2 then - return "nd" - elseif tenRemainder == 3 then - return "rd" - else - return "th" - end +function Time.getOrdinalOfNumber(number: number, locale: string?): string + return NumberLocalizationOrdinalUtils.getSuffix(number, locale or DEFAULT_LOCALE) end --[=[ - Returns the ordinal suffix for the day of the month for the given time. - @param currentTime number - @return string? + Returns the localized ordinal suffix for the day of the month for the given time. ]=] -function Time.getDayOfTheMonthOrdinal(currentTime: number): string? - local dayOfMonth = Time.getDayOfTheMonth(currentTime) - if dayOfMonth == nil then - return nil - end - - return Time.getOrdinalOfNumber(dayOfMonth) +function Time.getDayOfTheMonthOrdinal(currentTime: DateTimeLike, locale: string?): string + return Time.getOrdinalOfNumber(Time.getDayOfTheMonth(currentTime), locale) end --[=[ Returns the second formatted as a two-digit string. - @param currentTime number - @return string ]=] -function Time.getFormattedSecond(currentTime: number): string - local currentSecond = Time.getSecond(currentTime) - if currentSecond < 10 then - return "0" .. currentSecond - end - return tostring(currentSecond) +function Time.getFormattedSecond(currentTime: DateTimeLike, locale: string?): string + return Time._formatUniversal(currentTime, "ss", locale) end --[=[ Returns the minute formatted as a two-digit string. - @param currentTime number - @return string ]=] -function Time.getFormattedMinute(currentTime: number): string - local currentMinute = Time.getMinute(currentTime) - if currentMinute < 10 then - return "0" .. currentMinute - end - return tostring(currentMinute) +function Time.getFormattedMinute(currentTime: DateTimeLike, locale: string?): string + return Time._formatUniversal(currentTime, "mm", locale) end --[=[ - Returns the hour in 12-hour format. - @param currentTime number - @return number + Returns the hour in 12-hour format (1-12). ]=] -function Time.getRegularHour(currentTime: number): number - local hour = Time.getHour(currentTime) - if hour > 12 then - hour = hour - 12 - end - return hour +function Time.getRegularHour(currentTime: DateTimeLike): number + return Time._formatUniversalNumber(currentTime, "h") end --[=[ Returns the hour formatted as a two-digit string in 24-hour format. - @param currentTime number - @return string ]=] -function Time.getHourFormatted(currentTime: number): string - local hour = Time.getHour(currentTime) - if hour < 10 then - return "0" .. hour - end - return tostring(hour) +function Time.getHourFormatted(currentTime: DateTimeLike, locale: string?): string + return Time._formatUniversal(currentTime, "HH", locale) end --[=[ Returns the hour formatted as a two-digit string in 12-hour format. - @param currentTime number - @return string ]=] -function Time.getRegularHourFormatted(currentTime: number): string - local hour = Time.getRegularHour(currentTime) - if hour < 10 then - return "0" .. hour - end - return tostring(hour) +function Time.getRegularHourFormatted(currentTime: DateTimeLike, locale: string?): string + return Time._formatUniversal(currentTime, "hh", locale) end --[=[ - Returns "am" or "pm" based on the given time. - @param currentTime number - @return "am" | "pm" + Returns "am" or "pm" (or the locale's equivalent) based on the given time. ]=] -function Time.getamOrpm(currentTime: number): "am" | "pm" - local hour = Time.getHour(currentTime) - - if hour > 12 then - return "pm" - else - return "am" - end +function Time.getamOrpm(currentTime: DateTimeLike, locale: string?): string + return Time._formatUniversal(currentTime, "a", locale) end --[=[ - Returns "AM" or "PM" based on the given time. - @param currentTime number - @return "AM" | "PM" + Returns "AM" or "PM" (or the locale's equivalent) based on the given time. ]=] -function Time.getAMorPM(currentTime: number): "AM" | "PM" - local hour = Time.getHour(currentTime) - - if hour > 12 then - return "PM" - else - return "AM" - end +function Time.getAMorPM(currentTime: DateTimeLike, locale: string?): string + return Time._formatUniversal(currentTime, "A", locale) end --[=[ Reports the time in 24-hour format as a two-digit string. - @param currentTime number - @return string ]=] -function Time.getMilitaryHour(currentTime: number): string - local hour = Time.getHour(currentTime) - if hour < 10 then - return "0" .. hour - end - return tostring(hour) +function Time.getMilitaryHour(currentTime: DateTimeLike, locale: string?): string + return Time.getHourFormatted(currentTime, locale) end --[=[ Determines if the year of the given time is a leap year. - @param currentTime number - @return boolean ]=] -function Time.isLeapYear(currentTime: number): boolean +function Time.isLeapYear(currentTime: DateTimeLike): boolean local year = Time.getYear(currentTime) - if year % 4 == 0 and (year % 100 ~= 0 or year % 400 == 0) then - return true - else - return false - end + return year % 4 == 0 and (year % 100 ~= 0 or year % 400 == 0) end --[=[ Returns the number of days in the month for the given time. - @param currentTime number - @return number ]=] -function Time.getDaysInMonth(currentTime: number): number - local month = assert(Time.getMonth(currentTime), "Failed to get month") - local year = Time.getYear(currentTime) - return Time.getDaysMonthTable(year)[month] -end - -local ISO_FORMAT_STRINGS: { [string]: (number) -> any } = { - d = Time.getFormattedDayOfTheMonth, - D = Time.getDayOfTheWeekNameShort, - j = Time.getDayOfTheMonth, - l = Time.getDayOfTheWeekName, - N = Time.getDayOfTheWeek, - S = Time.getDayOfTheMonthOrdinal, - W = Time.getDayOfTheWeek, - Z = Time.getDay, - - -- W - - F = Time.getMonthName, - m = Time.getFormattedMonth, - M = Time.getMonthNameShort, - n = Time.getMonth, - t = Time.getDaysInMonth, - - L = Time.isLeapYear, - o = Time.getYear, - Y = Time.getYear, -- Screw ISO-8610, it confuses me. - y = Time.getYearShortFormatted, - - a = Time.getamOrpm, - A = Time.getAMorPM, - --B -- No one uses it - g = Time.getRegularHour, - G = Time.getHour, - h = Time.getRegularHourFormatted, - H = Time.getHourFormatted, - i = Time.getFormattedMinute, - s = Time.getFormattedSecond, - - X = Time.getJulianDate, -- For testing purposes. - - -- e -- No way to get Time Zones - -- I -- Daylight saving time should be added later. - -- O -- No way to get Time Zones - -- P -- No way to get Time Zones - -- T -- No way to get Time Zones - -- Z -- No way to get Time Zones - - -- c -- ISO 8601 - -- r -- No need for formatted dates - U = time, +function Time.getDaysInMonth(currentTime: DateTimeLike): number + local universalTime = Time._toUniversalTime(currentTime) + return Time.getDaysMonthTable(universalTime.Year)[universalTime.Month] +end + +--[=[ + Returns the unix timestamp in seconds for the given time. +]=] +function Time.getUnixTimestamp(currentTime: DateTimeLike): number + return Time._toDateTime(currentTime).UnixTimestamp +end + +--[=[ + A unit of time for [Time.add], [Time.subtract], [Time.startOf] and [Time.endOf], singular or + plural, or one of the shorthands `ms`, `s`, `m`, `h`, `d`, `D`, `w`, `M`, `Q` and `y`. + `date` is another name for `day`. + + @type TimeUnit "millisecond" | "second" | "minute" | "hour" | "day" | "week" | "month" | "quarter" | "year" | plurals | shorthands + @within Time +]=] +export type TimeUnit = + "millisecond" + | "milliseconds" + | "ms" + | "second" + | "seconds" + | "s" + | "minute" + | "minutes" + | "m" + | "hour" + | "hours" + | "h" + | "day" + | "days" + | "d" + | "date" + | "D" + | "week" + | "weeks" + | "w" + | "month" + | "months" + | "M" + | "quarter" + | "quarters" + | "Q" + | "year" + | "years" + | "y" + +type CanonicalTimeUnit = "millisecond" | "second" | "minute" | "hour" | "day" | "week" | "month" | "quarter" | "year" + +local TIME_UNIT_ALIASES: { [string]: CanonicalTimeUnit } = { + millisecond = "millisecond", + milliseconds = "millisecond", + ms = "millisecond", + second = "second", + seconds = "second", + s = "second", + minute = "minute", + minutes = "minute", + m = "minute", + hour = "hour", + hours = "hour", + h = "hour", + day = "day", + days = "day", + d = "day", + date = "day", + D = "day", + week = "week", + weeks = "week", + w = "week", + month = "month", + months = "month", + M = "month", + quarter = "quarter", + quarters = "quarter", + Q = "quarter", + year = "year", + years = "year", + y = "year", } -local matchString: string = "[" -do - for i, _ in ISO_FORMAT_STRINGS do - matchString ..= i +-- Units that are a fixed number of milliseconds. Month, quarter and year move on the calendar. +local TIME_UNIT_MILLISECONDS: { [string]: number } = { + millisecond = 1, + second = 1000, + minute = 60000, + hour = 3600000, + day = 86400000, + week = 604800000, +} + +function Time._normalizeUnit(unit: TimeUnit): CanonicalTimeUnit + local normalized = TIME_UNIT_ALIASES[unit] + assert(normalized, string.format("Bad unit %q", tostring(unit))) + return normalized :: CanonicalTimeUnit +end + +-- Moves whole months on the calendar, clamping the day to the target month +function Time._addMonths(dateTime: DateTime, months: number): DateTime + local universalTime = Time._toUniversalTime(dateTime) + local monthIndex = universalTime.Month - 1 + months + local year = universalTime.Year + monthIndex // 12 + local month = monthIndex % 12 + 1 + local day = math.min(universalTime.Day, Time.getDaysMonthTable(year)[month]) + + return DateTime.fromUniversalTime( + year, + month, + day, + universalTime.Hour, + universalTime.Minute, + universalTime.Second, + universalTime.Millisecond + ) +end + +--[=[ + Returns a `DateTime` moved forward by `value` units. Months, quarters and + years move on the calendar and clamp the day, so January 31st plus a month is February 28th + (or 29th); every other unit is a fixed offset. A negative value moves backwards. + + ```lua + Time.add("2025-01-31T00:00:00Z", 1, "month") --> 2025-02-28T00:00:00Z + Time.add(os.time(), 7, "days") + ``` +]=] +function Time.add(currentTime: DateTimeLike, value: number, unit: TimeUnit): DateTime + assert(type(value) == "number", "Bad value") + + local dateTime = Time._toDateTime(currentTime) + local normalized = Time._normalizeUnit(unit) + + local monthsPerUnit = if normalized == "month" + then 1 + elseif normalized == "quarter" then 3 + elseif normalized == "year" then 12 + else nil + if monthsPerUnit then + local wholeMonths = math.modf(value * monthsPerUnit) + return Time._addMonths(dateTime, wholeMonths) end - matchString ..= "]" + + return DateTime.fromUnixTimestampMillis(dateTime.UnixTimestampMillis + value * TIME_UNIT_MILLISECONDS[normalized]) end --[=[ - Formats the given time based on the provided format string. - @param format string - @param currentTime number? - @return string + Returns a `DateTime` moved back by `value` units. See [Time.add]. ]=] -function Time.getFormattedTime(format: string, currentTime: number?): string - local resolvedTime = currentTime or tick() +function Time.subtract(currentTime: DateTimeLike, value: number, unit: TimeUnit): DateTime + assert(type(value) == "number", "Bad value") + + return Time.add(currentTime, -value, unit) +end - local returnString = format - local formatsRequired: { string } = {} +--[=[ + Returns a `DateTime` at the first instant of the unit containing `currentTime`: + `startOf(t, "day")` is midnight, `startOf(t, "month")` the 1st at midnight. Weeks + start on Sunday. + + ```lua + Time.startOf("2025-09-22T13:45:30Z", "week") --> 2025-09-21T00:00:00Z + ``` +]=] +function Time.startOf(currentTime: DateTimeLike, unit: TimeUnit): DateTime + local dateTime = Time._toDateTime(currentTime) + local normalized = Time._normalizeUnit(unit) + local t = Time._toUniversalTime(dateTime) - for newFormat in string.gmatch(format, matchString) do - formatsRequired[#formatsRequired + 1] = newFormat + if normalized == "millisecond" then + return dateTime + elseif normalized == "second" then + return DateTime.fromUniversalTime(t.Year, t.Month, t.Day, t.Hour, t.Minute, t.Second) + elseif normalized == "minute" then + return DateTime.fromUniversalTime(t.Year, t.Month, t.Day, t.Hour, t.Minute) + elseif normalized == "hour" then + return DateTime.fromUniversalTime(t.Year, t.Month, t.Day, t.Hour) + elseif normalized == "day" then + return DateTime.fromUniversalTime(t.Year, t.Month, t.Day) + elseif normalized == "week" then + return Time.subtract(DateTime.fromUniversalTime(t.Year, t.Month, t.Day), Time.getDayOfTheWeek(dateTime), "day") + elseif normalized == "month" then + return DateTime.fromUniversalTime(t.Year, t.Month) + elseif normalized == "quarter" then + return DateTime.fromUniversalTime(t.Year, (t.Month - 1) // 3 * 3 + 1) + elseif normalized == "year" then + return DateTime.fromUniversalTime(t.Year) + else + error(string.format("Bad unit %q", normalized)) end +end - for _, formatType in formatsRequired do - returnString = string.gsub(returnString, formatType, string.rep(formatType, 3)) +--[=[ + Returns a `DateTime` at the last millisecond of the unit containing `currentTime`: + `endOf(t, "day")` is 23:59:59.999. See [Time.startOf]. +]=] +function Time.endOf(currentTime: DateTimeLike, unit: TimeUnit): DateTime + local nextStart = Time.add(Time.startOf(currentTime, unit), 1, unit) + return DateTime.fromUnixTimestampMillis(nextStart.UnixTimestampMillis - 1) +end + +--[=[ + A field of a time for [Time.get] and [Time.set], singular or plural, or one of the + shorthands `y`, `M`, `D`, `d`, `h`, `m`, `s` and `ms`. `date` is the day of the month and + `day` is the day of the week (0 is Sunday). `month` runs 1-12. + + @type TimeField "year" | "month" | "date" | "day" | "hour" | "minute" | "second" | "millisecond" | plurals | shorthands + @within Time +]=] +export type TimeField = + "year" + | "years" + | "y" + | "month" + | "months" + | "M" + | "date" + | "dates" + | "D" + | "day" + | "days" + | "d" + | "hour" + | "hours" + | "h" + | "minute" + | "minutes" + | "m" + | "second" + | "seconds" + | "s" + | "millisecond" + | "milliseconds" + | "ms" + +type CanonicalTimeField = "year" | "month" | "date" | "day" | "hour" | "minute" | "second" | "millisecond" + +local TIME_FIELD_ALIASES: { [string]: CanonicalTimeField } = { + year = "year", + years = "year", + y = "year", + month = "month", + months = "month", + M = "month", + date = "date", + dates = "date", + D = "date", + day = "day", + days = "day", + d = "day", + hour = "hour", + hours = "hour", + h = "hour", + minute = "minute", + minutes = "minute", + m = "minute", + second = "second", + seconds = "second", + s = "second", + millisecond = "millisecond", + milliseconds = "millisecond", + ms = "millisecond", +} + +function Time._normalizeField(field: TimeField): CanonicalTimeField + local normalized = TIME_FIELD_ALIASES[field] + assert(normalized, string.format("Bad field %q", tostring(field))) + return normalized :: CanonicalTimeField +end + +-- Like JavaScript's Date setters: a month or day past the end of its range rolls into the next +-- period, and 0 or a negative day rolls back into the previous one. +function Time._fromUniversalTimeOverflowing( + year: number, + month: number, + day: number, + hour: number, + minute: number, + second: number, + millisecond: number +): DateTime + local monthIndex = month - 1 + local firstOfMonth = + DateTime.fromUniversalTime(year + monthIndex // 12, monthIndex % 12 + 1, 1, hour, minute, second, millisecond) + + return Time.add(firstOfMonth, day - 1, "day") +end + +--[=[ + Reads one field of the given time. + + ```lua + Time.get("2025-09-22T13:45:30Z", "date") --> 22 + Time.get("2025-09-22T13:45:30Z", "day") --> 1 (Monday) + ``` +]=] +function Time.get(currentTime: DateTimeLike, field: TimeField): number + local normalized = Time._normalizeField(field) + + if normalized == "day" then + return Time.getDayOfTheWeek(currentTime) end - for _, formatType in formatsRequired do - local replacement = ISO_FORMAT_STRINGS[formatType](resolvedTime) - returnString = string.gsub(returnString, string.rep(formatType, 3), tostring(replacement)) + local t = Time._toUniversalTime(currentTime) + + if normalized == "year" then + return t.Year + elseif normalized == "month" then + return t.Month + elseif normalized == "date" then + return t.Day + elseif normalized == "hour" then + return t.Hour + elseif normalized == "minute" then + return t.Minute + elseif normalized == "second" then + return t.Second + elseif normalized == "millisecond" then + return t.Millisecond + else + error(string.format("Bad field %q", normalized)) + end +end + +--[=[ + Returns a `DateTime` with one field replaced. Values outside the field's + range roll over the way JavaScript dates do: month 13 is January of the next year, date 0 is + the last day of the previous month, hour 25 is 1am the next day. Setting `day` moves within + the current week, so 0 is the preceding Sunday. + + ```lua + Time.set("2025-09-22T13:45:30Z", "date", 1) --> 2025-09-01T13:45:30Z + Time.set("2025-09-22T13:45:30Z", "day", 0) --> 2025-09-21T13:45:30Z + ``` +]=] +function Time.set(currentTime: DateTimeLike, field: TimeField, value: number): DateTime + assert(type(value) == "number", "Bad value") + + local dateTime = Time._toDateTime(currentTime) + local normalized = Time._normalizeField(field) + local t = Time._toUniversalTime(dateTime) + local whole = math.floor(value) + + if normalized == "year" then + return Time._fromUniversalTimeOverflowing(whole, t.Month, t.Day, t.Hour, t.Minute, t.Second, t.Millisecond) + elseif normalized == "month" then + return Time._fromUniversalTimeOverflowing(t.Year, whole, t.Day, t.Hour, t.Minute, t.Second, t.Millisecond) + elseif normalized == "date" then + return Time._fromUniversalTimeOverflowing(t.Year, t.Month, whole, t.Hour, t.Minute, t.Second, t.Millisecond) + elseif normalized == "day" then + return Time.add(dateTime, whole - Time.getDayOfTheWeek(dateTime), "day") + elseif normalized == "hour" then + return DateTime.fromUniversalTime(t.Year, t.Month, t.Day, whole, t.Minute, t.Second, t.Millisecond) + elseif normalized == "minute" then + return DateTime.fromUniversalTime(t.Year, t.Month, t.Day, t.Hour, whole, t.Second, t.Millisecond) + elseif normalized == "second" then + return DateTime.fromUniversalTime(t.Year, t.Month, t.Day, t.Hour, t.Minute, whole, t.Millisecond) + elseif normalized == "millisecond" then + return DateTime.fromUniversalTime(t.Year, t.Month, t.Day, t.Hour, t.Minute, t.Second, whole) + else + error(string.format("Bad field %q", normalized)) end +end + +-- Tokens FormatUniversalTime does not offer. Documented on Time.format. +local EXTENDED_FORMAT_TOKENS: { [string]: (DateTime, string) -> string } = { + Do = function(dateTime: DateTime, locale: string): string + return NumberLocalizationOrdinalUtils.localize(Time.getDayOfTheMonth(dateTime), locale) + end, + DDDo = function(dateTime: DateTime, locale: string): string + return NumberLocalizationOrdinalUtils.localize(Time.getDay(dateTime), locale) + end, + t = function(dateTime: DateTime): string + return tostring(Time.getDaysInMonth(dateTime)) + end, + LY = function(dateTime: DateTime): string + return tostring(Time.isLeapYear(dateTime)) + end, + X = function(dateTime: DateTime): string + return tostring(Time.getUnixTimestamp(dateTime)) + end, + J = function(dateTime: DateTime): string + return tostring(Time.getJulianDate(dateTime)) + end, +} + +-- Longest first so "DDDo" wins over "Do" +local EXTENDED_FORMAT_TOKENS_BY_LENGTH: { string } = {} +do + for token, _ in EXTENDED_FORMAT_TOKENS do + table.insert(EXTENDED_FORMAT_TOKENS_BY_LENGTH, token) + end + table.sort(EXTENDED_FORMAT_TOKENS_BY_LENGTH, function(a, b) + return #a > #b + end) +end + +function Time._matchExtendedToken(format: string, index: number): string? + for _, token in EXTENDED_FORMAT_TOKENS_BY_LENGTH do + if string.sub(format, index, index + #token - 1) == token then + return token + end + end + + return nil +end + +--[=[ + Formats the given time. The format string is passed to + [DateTime:FormatUniversalTime](https://create.roblox.com/docs/reference/engine/datatypes/DateTime#FormatUniversalTime) + with the given locale, so every Roblox token works, for example `"YYYY-MM-DD HH:mm:ss"` + or `"dddd, MMMM D"`. On top of those, these tokens are available, for example + `"MMMM Do, YYYY"` gives `September 22nd, 2025`. Their spellings avoid Roblox's own letters, + so `S` (fractional seconds) and `L` (locale composites) keep their Roblox meaning. + + | Token | Output | + | ----- | ------ | + | `Do` | Day of the month as a localized ordinal, e.g. `22nd` or `22e` | + | `DDDo` | Day of the year as a localized ordinal, e.g. `265th` | + | `t` | Days in the month, e.g. `30` | + | `LY` | Leap year, `true` or `false` | + | `X` | Unix timestamp in seconds | + | `J` | Julian day number | + + Wrap literal text in square brackets to keep it from being read as tokens: + `"[Today is] dddd"` gives `Today is Monday`. + + Defaults to the current time and the `en-us` locale. +]=] +function Time.format(format: string, currentTime: DateTimeLike, locale: string?): string + local dateTime = Time._toDateTime(currentTime) + local resolvedLocale = locale or DEFAULT_LOCALE + + local parts: { string } = {} + local pending = "" + + local index = 1 + while index <= #format do + local char = string.sub(format, index, index) + local token = Time._matchExtendedToken(format, index) + + if char == "[" then + -- Bracketed text is literal for FormatUniversalTime too, so hand it over whole + local closeIndex = string.find(format, "]", index, true) or #format + pending ..= string.sub(format, index, closeIndex) + index = closeIndex + 1 + elseif token then + if pending ~= "" then + table.insert(parts, dateTime:FormatUniversalTime(pending, resolvedLocale)) + pending = "" + end + table.insert(parts, EXTENDED_FORMAT_TOKENS[token](dateTime, resolvedLocale)) + index += #token + else + pending ..= char + index += 1 + end + end + + if pending ~= "" then + table.insert(parts, dateTime:FormatUniversalTime(pending, resolvedLocale)) + end + + return table.concat(parts) +end + +--[=[ + Returns a `DateTime` with the second replaced. See [Time.set]. +]=] +function Time.setSecond(currentTime: DateTimeLike, second: number): DateTime + return Time.set(currentTime, "second", second) +end + +--[=[ + Returns a `DateTime` with the minute replaced. See [Time.set]. +]=] +function Time.setMinute(currentTime: DateTimeLike, minute: number): DateTime + return Time.set(currentTime, "minute", minute) +end + +--[=[ + Returns a `DateTime` with the 24-hour hour replaced. See [Time.set]. +]=] +function Time.setHour(currentTime: DateTimeLike, hour: number): DateTime + return Time.set(currentTime, "hour", hour) +end + +--[=[ + Returns a `DateTime` with the 12-hour hour replaced, staying in the same half of the day, so + setting 3 on a 13:45 time gives 15:45. 12 is midnight in the morning and noon in the afternoon. +]=] +function Time.setRegularHour(currentTime: DateTimeLike, hour: number): DateTime + assert(type(hour) == "number", "Bad hour") + + local isAfternoon = Time.getHour(currentTime) >= 12 + return Time.set(currentTime, "hour", hour % 12 + (if isAfternoon then 12 else 0)) +end + +--[=[ + Returns a `DateTime` with the day of the year (1-366) replaced, keeping the clock. A day past + the end of the year rolls into the next one. +]=] +function Time.setDay(currentTime: DateTimeLike, day: number): DateTime + assert(type(day) == "number", "Bad day") + + local t = Time._toUniversalTime(currentTime) + return Time._fromUniversalTimeOverflowing(t.Year, 1, math.floor(day), t.Hour, t.Minute, t.Second, t.Millisecond) +end + +--[=[ + Returns a `DateTime` with the year replaced. See [Time.set]. +]=] +function Time.setYear(currentTime: DateTimeLike, year: number): DateTime + return Time.set(currentTime, "year", year) +end + +--[=[ + Returns a `DateTime` with the month (1-12) replaced. See [Time.set]. +]=] +function Time.setMonth(currentTime: DateTimeLike, month: number): DateTime + return Time.set(currentTime, "month", month) +end + +--[=[ + Returns a `DateTime` with the day of the month replaced. See [Time.set]. +]=] +function Time.setDayOfTheMonth(currentTime: DateTimeLike, dayOfTheMonth: number): DateTime + return Time.set(currentTime, "date", dayOfTheMonth) +end + +--[=[ + Returns a `DateTime` with the day of the week (0-6, starting on Sunday) replaced, moving + within the current week. See [Time.set]. +]=] +function Time.setDayOfTheWeek(currentTime: DateTimeLike, dayOfTheWeek: number): DateTime + return Time.set(currentTime, "day", dayOfTheWeek) +end + +--[=[ + Returns a `DateTime` on the given Julian day number, keeping the clock. +]=] +function Time.setJulianDate(currentTime: DateTimeLike, julianDate: number): DateTime + assert(type(julianDate) == "number", "Bad julianDate") + + local dateTime = Time._toDateTime(currentTime) + local millisecondsIntoDay = dateTime.UnixTimestampMillis % (SECONDS_PER_DAY * 1000) + local unixDay = math.floor(julianDate) - UNIX_EPOCH_JULIAN_DAY - return returnString + return DateTime.fromUnixTimestampMillis(unixDay * SECONDS_PER_DAY * 1000 + millisecondsIntoDay) end return Time diff --git a/src/time/src/Shared/Time.spec.lua b/src/time/src/Shared/Time.spec.lua new file mode 100644 index 00000000000..a85b2f6f9bb --- /dev/null +++ b/src/time/src/Shared/Time.spec.lua @@ -0,0 +1,714 @@ +--!strict +--[[ + @class Time.spec.lua +]] + +local require = require(script.Parent.loader).load(script) + +local Jest = require("Jest") +local Time = require("Time") + +local describe = Jest.Globals.describe +local expect = Jest.Globals.expect +local it = Jest.Globals.it + +-- All timestamps are UTC unix seconds. +local EPOCH = 0 -- 1970-01-01 Thursday 00:00:00 +local Y2000_FEB_29 = 951782400 -- 2000-02-29 Tuesday 00:00:00, day 60 +local Y2000_MAR_1 = 951868800 -- 2000-03-01 Wednesday 00:00:00, day 61 +local Y2000_LAST_SECOND = 978307199 -- 2000-12-31 Sunday 23:59:59, day 366 +local Y2005_JAN_1 = 1104537600 -- 2005-01-01 Saturday 00:00:00 +local Y2024_JAN_1 = 1704067200 -- 2024-01-01 Monday 00:00:00 +local Y2024_FEB_29 = 1709164800 -- 2024-02-29 Thursday 00:00:00, day 60 +local Y2024_LAST_SECOND = 1735689599 -- 2024-12-31 Tuesday 23:59:59, day 366 +local Y2025_SEP_7 = 1757203200 -- 2025-09-07 Sunday 00:00:00 +local Y2025_SEP_8 = 1757289600 -- 2025-09-08 Monday 00:00:00 +local Y2025_SEP_22 = 1758499200 -- 2025-09-22 Monday 00:00:00, day 265 +local Y2025_SEP_22_11_59_59 = 1758542399 -- 2025-09-22 Monday 11:59:59 +local Y2025_SEP_22_NOON = 1758542400 -- 2025-09-22 Monday 12:00:00 +local Y2025_SEP_22_13_00 = 1758546000 -- 2025-09-22 Monday 13:00:00 +local Y2025_SEP_22_LAST_SECOND = 1758585599 -- 2025-09-22 Monday 23:59:59 + +describe("Time.getDaysMonthTable", function() + it("should give February 29 days in a leap year", function() + expect(Time.getDaysMonthTable(2024)).toEqual({ 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }) + end) + + it("should give February 28 days in a common year", function() + expect(Time.getDaysMonthTable(2023)).toEqual({ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }) + end) + + it("should treat centuries as common years unless divisible by 400", function() + expect(Time.getDaysMonthTable(1900)[2]).toBe(28) + expect(Time.getDaysMonthTable(2000)[2]).toBe(29) + expect(Time.getDaysMonthTable(2100)[2]).toBe(28) + end) + + it("should return a read-only table", function() + expect(table.isfrozen(Time.getDaysMonthTable(2024))).toBe(true) + end) +end) + +describe("Time.getSecond / getMinute / getHour", function() + it("should be zero at the epoch", function() + expect(Time.getSecond(EPOCH)).toBe(0) + expect(Time.getMinute(EPOCH)).toBe(0) + expect(Time.getHour(EPOCH)).toBe(0) + end) + + it("should extract the clock components of a time", function() + expect(Time.getSecond(Y2025_SEP_22_LAST_SECOND)).toBe(59) + expect(Time.getMinute(Y2025_SEP_22_LAST_SECOND)).toBe(59) + expect(Time.getHour(Y2025_SEP_22_LAST_SECOND)).toBe(23) + end) + + it("should report noon as hour 12", function() + expect(Time.getHour(Y2025_SEP_22_NOON)).toBe(12) + end) + + it("should floor fractional seconds", function() + expect(Time.getSecond(1.9)).toBe(1) + expect(Time.getMinute(119.9)).toBe(1) + end) +end) + +describe("Time.getDay", function() + it("should be day 1 on January 1st", function() + expect(Time.getDay(EPOCH)).toBe(1) + expect(Time.getDay(Y2024_JAN_1)).toBe(1) + end) + + it("should count February 29th as day 60", function() + expect(Time.getDay(Y2024_FEB_29)).toBe(60) + end) + + it("should reach day 366 at the end of a leap year", function() + expect(Time.getDay(Y2024_LAST_SECOND)).toBe(366) + expect(Time.getDay(Y2000_LAST_SECOND)).toBe(366) + end) + + it("should compute the day of the year", function() + expect(Time.getDay(Y2025_SEP_22)).toBe(265) + expect(Time.getDay(Y2025_SEP_22_LAST_SECOND)).toBe(265) + end) +end) + +describe("Time.getYear", function() + it("should be 1970 at the epoch", function() + expect(Time.getYear(EPOCH)).toBe(1970) + end) + + it("should stay in the same year until the last second", function() + expect(Time.getYear(Y2000_LAST_SECOND)).toBe(2000) + expect(Time.getYear(Y2024_LAST_SECOND)).toBe(2024) + end) + + it("should roll over on January 1st", function() + expect(Time.getYear(Y2024_JAN_1)).toBe(2024) + expect(Time.getYear(Y2025_SEP_22)).toBe(2025) + end) +end) + +describe("Time.getYearShort", function() + it("should return the last two digits of the year", function() + expect(Time.getYearShort(Y2005_JAN_1)).toBe(5) + expect(Time.getYearShort(Y2024_JAN_1)).toBe(24) + end) + + it("should zero pad the formatted short year", function() + expect(Time.getYearShortFormatted(Y2005_JAN_1)).toBe("05") + expect(Time.getYearShortFormatted(Y2024_JAN_1)).toBe("24") + end) +end) + +describe("Time.getMonth", function() + it("should be January at the epoch", function() + expect(Time.getMonth(EPOCH)).toBe(1) + end) + + it("should handle the leap day boundary", function() + expect(Time.getMonth(Y2000_FEB_29)).toBe(2) + expect(Time.getMonth(Y2000_MAR_1)).toBe(3) + expect(Time.getMonth(Y2024_FEB_29)).toBe(2) + end) + + it("should be December at the end of the year", function() + expect(Time.getMonth(Y2000_LAST_SECOND)).toBe(12) + expect(Time.getMonth(Y2024_LAST_SECOND)).toBe(12) + end) + + it("should compute the month", function() + expect(Time.getMonth(Y2025_SEP_22)).toBe(9) + end) + + it("should zero pad the formatted month", function() + expect(Time.getFormattedMonth(Y2000_FEB_29)).toBe("02") + expect(Time.getFormattedMonth(Y2000_LAST_SECOND)).toBe("12") + end) +end) + +describe("Time.getDayOfTheMonth", function() + it("should be the 1st at the epoch", function() + expect(Time.getDayOfTheMonth(EPOCH)).toBe(1) + end) + + it("should handle the leap day boundary", function() + expect(Time.getDayOfTheMonth(Y2000_FEB_29)).toBe(29) + expect(Time.getDayOfTheMonth(Y2000_MAR_1)).toBe(1) + end) + + it("should be the 31st at the end of the year", function() + expect(Time.getDayOfTheMonth(Y2000_LAST_SECOND)).toBe(31) + end) + + it("should compute the day of the month", function() + expect(Time.getDayOfTheMonth(Y2025_SEP_22)).toBe(22) + expect(Time.getDayOfTheMonth(Y2025_SEP_22_LAST_SECOND)).toBe(22) + end) + + it("should zero pad the formatted day of the month", function() + expect(Time.getFormattedDayOfTheMonth(EPOCH)).toBe("01") + expect(Time.getFormattedDayOfTheMonth(Y2000_FEB_29)).toBe("29") + end) +end) + +describe("Time.getMonthName", function() + it("should return the full month name", function() + expect(Time.getMonthName(EPOCH)).toBe("January") + expect(Time.getMonthName(Y2000_FEB_29)).toBe("February") + expect(Time.getMonthName(Y2025_SEP_22)).toBe("September") + expect(Time.getMonthName(Y2000_LAST_SECOND)).toBe("December") + end) + + it("should return the short month name", function() + expect(Time.getMonthNameShort(EPOCH)).toBe("Jan") + expect(Time.getMonthNameShort(Y2000_FEB_29)).toBe("Feb") + expect(Time.getMonthNameShort(Y2025_SEP_22)).toBe("Sep") + expect(Time.getMonthNameShort(Y2000_LAST_SECOND)).toBe("Dec") + end) +end) + +describe("Time.getJulianDate", function() + it("should be the Julian day number of the epoch", function() + expect(Time.getJulianDate(EPOCH)).toBe(2440588) + end) + + it("should compute the Julian day number", function() + expect(Time.getJulianDate(Y2024_JAN_1)).toBe(2460311) + expect(Time.getJulianDate(Y2025_SEP_22)).toBe(2460941) + end) + + it("should not change within a day", function() + expect(Time.getJulianDate(Y2025_SEP_22_LAST_SECOND)).toBe(Time.getJulianDate(Y2025_SEP_22)) + end) +end) + +describe("Time.getDayOfTheWeek", function() + it("should number the week from Sunday as 0", function() + expect(Time.getDayOfTheWeek(Y2025_SEP_7)).toBe(0) + expect(Time.getDayOfTheWeek(Y2025_SEP_8)).toBe(1) + expect(Time.getDayOfTheWeek(EPOCH)).toBe(4) + expect(Time.getDayOfTheWeek(Y2005_JAN_1)).toBe(6) + end) + + it("should return the full day name", function() + expect(Time.getDayOfTheWeekName(Y2025_SEP_7)).toBe("Sunday") + expect(Time.getDayOfTheWeekName(Y2025_SEP_8)).toBe("Monday") + expect(Time.getDayOfTheWeekName(EPOCH)).toBe("Thursday") + expect(Time.getDayOfTheWeekName(Y2005_JAN_1)).toBe("Saturday") + end) + + it("should return the short day name", function() + expect(Time.getDayOfTheWeekNameShort(Y2025_SEP_7)).toBe("Sun") + expect(Time.getDayOfTheWeekNameShort(Y2025_SEP_8)).toBe("Mon") + expect(Time.getDayOfTheWeekNameShort(EPOCH)).toBe("Thu") + expect(Time.getDayOfTheWeekNameShort(Y2005_JAN_1)).toBe("Sat") + end) +end) + +describe("Time.getOrdinalOfNumber", function() + it("should use st, nd, rd for 1, 2, 3", function() + expect(Time.getOrdinalOfNumber(1)).toBe("st") + expect(Time.getOrdinalOfNumber(2)).toBe("nd") + expect(Time.getOrdinalOfNumber(3)).toBe("rd") + expect(Time.getOrdinalOfNumber(4)).toBe("th") + end) + + it("should use th for the teens", function() + expect(Time.getOrdinalOfNumber(11)).toBe("th") + expect(Time.getOrdinalOfNumber(12)).toBe("th") + expect(Time.getOrdinalOfNumber(13)).toBe("th") + expect(Time.getOrdinalOfNumber(111)).toBe("th") + expect(Time.getOrdinalOfNumber(112)).toBe("th") + end) + + it("should use st, nd, rd for 21, 22, 23", function() + expect(Time.getOrdinalOfNumber(21)).toBe("st") + expect(Time.getOrdinalOfNumber(22)).toBe("nd") + expect(Time.getOrdinalOfNumber(23)).toBe("rd") + expect(Time.getOrdinalOfNumber(31)).toBe("st") + expect(Time.getOrdinalOfNumber(101)).toBe("st") + end) + + it("should return the ordinal of the day of the month", function() + expect(Time.getDayOfTheMonthOrdinal(EPOCH)).toBe("st") + expect(Time.getDayOfTheMonthOrdinal(Y2025_SEP_22)).toBe("nd") + expect(Time.getDayOfTheMonthOrdinal(Y2000_LAST_SECOND)).toBe("st") + end) +end) + +describe("Time formatted clock components", function() + it("should zero pad seconds and minutes", function() + expect(Time.getFormattedSecond(EPOCH)).toBe("00") + expect(Time.getFormattedSecond(65)).toBe("05") + expect(Time.getFormattedSecond(Y2025_SEP_22_LAST_SECOND)).toBe("59") + expect(Time.getFormattedMinute(EPOCH)).toBe("00") + expect(Time.getFormattedMinute(300)).toBe("05") + expect(Time.getFormattedMinute(Y2025_SEP_22_LAST_SECOND)).toBe("59") + end) + + it("should zero pad the 24-hour hour", function() + expect(Time.getHourFormatted(EPOCH)).toBe("00") + expect(Time.getHourFormatted(Y2025_SEP_22_13_00)).toBe("13") + expect(Time.getMilitaryHour(EPOCH)).toBe("00") + expect(Time.getMilitaryHour(Y2025_SEP_22_LAST_SECOND)).toBe("23") + end) +end) + +describe("Time.getRegularHour", function() + it("should report midnight as 12", function() + expect(Time.getRegularHour(EPOCH)).toBe(12) + end) + + it("should report noon as 12", function() + expect(Time.getRegularHour(Y2025_SEP_22_NOON)).toBe(12) + end) + + it("should wrap the afternoon to 1-11", function() + expect(Time.getRegularHour(Y2025_SEP_22_13_00)).toBe(1) + expect(Time.getRegularHour(Y2025_SEP_22_LAST_SECOND)).toBe(11) + end) + + it("should keep the morning as 1-11", function() + expect(Time.getRegularHour(Y2025_SEP_22_11_59_59)).toBe(11) + end) + + it("should zero pad the 12-hour hour", function() + expect(Time.getRegularHourFormatted(Y2025_SEP_22_13_00)).toBe("01") + expect(Time.getRegularHourFormatted(Y2025_SEP_22_NOON)).toBe("12") + end) +end) + +describe("Time.getamOrpm", function() + it("should be am before noon", function() + expect(Time.getamOrpm(EPOCH)).toBe("am") + expect(Time.getamOrpm(Y2025_SEP_22_11_59_59)).toBe("am") + expect(Time.getAMorPM(EPOCH)).toBe("AM") + expect(Time.getAMorPM(Y2025_SEP_22_11_59_59)).toBe("AM") + end) + + it("should be pm from noon onwards", function() + expect(Time.getamOrpm(Y2025_SEP_22_NOON)).toBe("pm") + expect(Time.getamOrpm(Y2025_SEP_22_LAST_SECOND)).toBe("pm") + expect(Time.getAMorPM(Y2025_SEP_22_NOON)).toBe("PM") + expect(Time.getAMorPM(Y2025_SEP_22_LAST_SECOND)).toBe("PM") + end) +end) + +describe("Time.isLeapYear", function() + it("should detect leap years", function() + expect(Time.isLeapYear(Y2000_FEB_29)).toBe(true) + expect(Time.isLeapYear(Y2024_JAN_1)).toBe(true) + end) + + it("should detect common years", function() + expect(Time.isLeapYear(Y2005_JAN_1)).toBe(false) + expect(Time.isLeapYear(Y2025_SEP_22)).toBe(false) + end) +end) + +describe("Time.getDaysInMonth", function() + it("should return the days in the month of the given time", function() + expect(Time.getDaysInMonth(EPOCH)).toBe(31) + expect(Time.getDaysInMonth(Y2024_FEB_29)).toBe(29) + expect(Time.getDaysInMonth(Y2025_SEP_22)).toBe(30) + expect(Time.getDaysInMonth(Y2000_LAST_SECOND)).toBe(31) + end) +end) + +describe("Time.format", function() + it("should pass Roblox tokens through to FormatUniversalTime", function() + expect(Time.format("YYYY-MM-DD HH:mm:ss", Y2025_SEP_22_LAST_SECOND)).toBe("2025-09-22 23:59:59") + expect(Time.format("YYYY-MM-DD HH:mm:ss", EPOCH)).toBe("1970-01-01 00:00:00") + expect(Time.format("dddd, MMMM D YYYY", Y2025_SEP_22)).toBe("Monday, September 22 2025") + expect(Time.format("ddd MMM D", Y2005_JAN_1)).toBe("Sat Jan 1") + expect(Time.format("LL", Y2025_SEP_22)).toBe("September 22, 2025") + end) + + it("should format a 12-hour clock", function() + expect(Time.format("h:mm a", Y2025_SEP_22_13_00)).toBe("1:00 pm") + expect(Time.format("hh:mm A", Y2025_SEP_22_NOON)).toBe("12:00 PM") + expect(Time.format("h:mm a", EPOCH)).toBe("12:00 am") + end) + + it("should format the short year and day of the year", function() + expect(Time.format("YY", Y2005_JAN_1)).toBe("05") + expect(Time.format("DDD", Y2024_LAST_SECOND)).toBe("366") + end) + + it("should add ordinal suffixes", function() + expect(Time.format("Do", Y2025_SEP_22)).toBe("22nd") + expect(Time.format("Do", EPOCH)).toBe("1st") + expect(Time.format("Do", Y2000_LAST_SECOND)).toBe("31st") + expect(Time.format("DDDo", Y2025_SEP_22)).toBe("265th") + expect(Time.format("MMMM Do, YYYY", Y2025_SEP_22)).toBe("September 22nd, 2025") + end) + + it("should format days in month, leap year, unix timestamp and Julian day", function() + expect(Time.format("t", Y2025_SEP_22)).toBe("30") + expect(Time.format("t", Y2024_FEB_29)).toBe("29") + expect(Time.format("LY", Y2024_FEB_29)).toBe("true") + expect(Time.format("LY", Y2025_SEP_22)).toBe("false") + expect(Time.format("X", Y2025_SEP_22)).toBe(tostring(Y2025_SEP_22)) + expect(Time.format("J", Y2025_SEP_22)).toBe("2460941") + end) + + it("should mix extension tokens with Roblox tokens", function() + expect(Time.format("dddd Do [of] MMMM (t [days])", Y2025_SEP_22)).toBe("Monday 22nd of September (30 days)") + expect(Time.format("D/t LY", Y2024_FEB_29)).toBe("29/29 true") + end) + + it("should leave punctuation alone", function() + expect(Time.format("(YYYY) -- /MM/", Y2024_JAN_1)).toBe("(2024) -- /01/") + expect(Time.format("", Y2024_JAN_1)).toBe("") + end) + + it("should treat bracketed text as literal", function() + expect(Time.format("[YYYY] YYYY", Y2024_JAN_1)).toBe("YYYY 2024") + expect(Time.format("[Do] Do", Y2025_SEP_22)).toBe("Do 22nd") + expect(Time.format("[Today is] dddd", Y2025_SEP_22)).toBe("Today is Monday") + end) + + it("should default to the current time", function() + local expected = os.date("!%Y", os.time()) + + expect(Time.format("YYYY")).toBe(expected) + end) +end) + +describe("Time with a locale", function() + it("should default to en-us", function() + expect(Time.getMonthName(Y2025_SEP_22)).toBe(Time.getMonthName(Y2025_SEP_22, "en-us")) + expect(Time.getDayOfTheMonthOrdinal(Y2025_SEP_22)).toBe(Time.getDayOfTheMonthOrdinal(Y2025_SEP_22, "en-us")) + end) + + it("should localize ordinals", function() + expect(Time.getOrdinalOfNumber(22, "fr-fr")).toBe("e") + expect(Time.getOrdinalOfNumber(1, "fr-fr")).toBe("er") + expect(Time.getDayOfTheMonthOrdinal(Y2025_SEP_22, "de-de")).toBe(".") + expect(Time.format("Do", Y2025_SEP_22, "de-de")).toBe("22.") + expect(Time.format("Do", Y2025_SEP_22, "zh-cn")).toBe("第22") + end) + + it("should localize names", function() + expect(Time.getMonthName(Y2025_SEP_22, "fr-fr")).toBe("septembre") + expect(Time.getDayOfTheWeekName(Y2025_SEP_22, "fr-fr")).toBe("lundi") + expect(Time.getMonthName(Y2025_SEP_22, "de-de")).toBe("September") + expect(Time.getDayOfTheWeekName(Y2025_SEP_22, "de-de")).toBe("Montag") + end) + + it("should localize format output while keeping extension tokens", function() + expect(Time.format("dddd Do MMMM YYYY", Y2025_SEP_22, "fr-fr")).toBe("lundi 22e septembre 2025") + expect(Time.format("LL", Y2025_SEP_22, "en-gb")).toBe("22 September 2025") + end) + + it("should keep padded numbers stable across locales", function() + expect(Time.getFormattedMonth(Y2025_SEP_22, "fr-fr")).toBe("09") + expect(Time.getHourFormatted(Y2025_SEP_22_13_00, "fr-fr")).toBe("13") + expect(Time.getFormattedSecond(Y2025_SEP_22_LAST_SECOND, "de-de")).toBe("59") + end) +end) + +describe("Time with DateTime inputs", function() + it("should accept a DateTime anywhere a timestamp is accepted", function() + local dateTime = DateTime.fromUnixTimestamp(Y2025_SEP_22_13_00) + + expect(Time.getYear(dateTime)).toBe(2025) + expect(Time.getMonth(dateTime)).toBe(9) + expect(Time.getDayOfTheMonth(dateTime)).toBe(22) + expect(Time.getHour(dateTime)).toBe(13) + expect(Time.getDayOfTheWeekName(dateTime)).toBe("Monday") + expect(Time.format("YYYY-MM-DD h:mm a", dateTime)).toBe("2025-09-22 1:00 pm") + end) + + it("should agree between a DateTime and its timestamp", function() + local dateTime = DateTime.fromUnixTimestamp(Y2000_LAST_SECOND) + + expect(Time.format("dddd, MMMM Do YYYY HH:mm:ss", dateTime)).toBe( + Time.format("dddd, MMMM Do YYYY HH:mm:ss", Y2000_LAST_SECOND) + ) + end) + + it("should accept an ISO 8601 string", function() + expect(Time.getUnixTimestamp("2025-09-22T13:00:00Z")).toBe(Y2025_SEP_22_13_00) + expect(Time.format("YYYY-MM-DD HH:mm", "2025-09-22T13:00:00Z")).toBe("2025-09-22 13:00") + expect(Time.getDayOfTheWeekName("2000-12-31T23:59:59Z")).toBe("Sunday") + end) + + it("should reject a string that is not an ISO date", function() + expect(function() + Time.getYear("yesterday") + end).toThrow("Bad ISO date") + end) + + it("should use the current time for nil", function() + local now = DateTime.now() + + expect(Time.getYear(nil)).toBe(Time.getYear(now)) + expect(math.abs(Time.getUnixTimestamp(nil) - now.UnixTimestamp) <= 1).toBe(true) + end) + + it("should return the unix timestamp", function() + expect(Time.getUnixTimestamp(Y2025_SEP_22)).toBe(Y2025_SEP_22) + expect(Time.getUnixTimestamp(DateTime.fromUnixTimestamp(Y2025_SEP_22))).toBe(Y2025_SEP_22) + expect(Time.getUnixTimestamp(Y2025_SEP_22 + 0.75)).toBe(Y2025_SEP_22) + end) +end) + +describe("Time.add", function() + local function iso(dateTime: DateTime): string + return dateTime:ToIsoDate() + end + + it("should add fixed units", function() + expect(iso(Time.add(Y2025_SEP_22, 10, "day"))).toBe("2025-10-02T00:00:00Z") + expect(iso(Time.add(Y2025_SEP_22, 2, "week"))).toBe("2025-10-06T00:00:00Z") + expect(iso(Time.add(Y2025_SEP_22, 25, "hour"))).toBe("2025-09-23T01:00:00Z") + expect(iso(Time.add(Y2025_SEP_22, 90, "minute"))).toBe("2025-09-22T01:30:00Z") + expect(iso(Time.add(Y2025_SEP_22, 1.5, "second"))).toBe("2025-09-22T00:00:01Z") + expect(Time.add(Y2025_SEP_22, 250, "millisecond").UnixTimestampMillis).toBe(Y2025_SEP_22 * 1000 + 250) + end) + + it("should move months on the calendar and clamp the day", function() + expect(iso(Time.add("2025-01-31T12:30:00Z", 1, "month"))).toBe("2025-02-28T12:30:00Z") + expect(iso(Time.add("2024-01-31T00:00:00Z", 1, "month"))).toBe("2024-02-29T00:00:00Z") + expect(iso(Time.add("2025-12-15T00:00:00Z", 1, "month"))).toBe("2026-01-15T00:00:00Z") + expect(iso(Time.add("2025-01-15T00:00:00Z", -1, "month"))).toBe("2024-12-15T00:00:00Z") + expect(iso(Time.add("2025-03-31T00:00:00Z", -1, "month"))).toBe("2025-02-28T00:00:00Z") + expect(iso(Time.add(Y2025_SEP_22, 14, "month"))).toBe("2026-11-22T00:00:00Z") + end) + + it("should move quarters and years on the calendar", function() + expect(iso(Time.add(Y2025_SEP_22, 1, "quarter"))).toBe("2025-12-22T00:00:00Z") + expect(iso(Time.add("2024-02-29T00:00:00Z", 1, "year"))).toBe("2025-02-28T00:00:00Z") + expect(iso(Time.add("2024-02-29T00:00:00Z", 4, "years"))).toBe("2028-02-29T00:00:00Z") + end) + + it("should accept plural and short units", function() + expect(iso(Time.add(Y2025_SEP_22, 1, "days"))).toBe(iso(Time.add(Y2025_SEP_22, 1, "d"))) + expect(iso(Time.add(Y2025_SEP_22, 1, "M"))).toBe(iso(Time.add(Y2025_SEP_22, 1, "month"))) + expect(iso(Time.add(Y2025_SEP_22, 1, "m"))).toBe(iso(Time.add(Y2025_SEP_22, 1, "minute"))) + expect(iso(Time.add(Y2025_SEP_22, 1, "y"))).toBe(iso(Time.add(Y2025_SEP_22, 1, "year"))) + expect(iso(Time.add(Y2025_SEP_22, 1, "Q"))).toBe(iso(Time.add(Y2025_SEP_22, 3, "months"))) + end) + + it("should reject unknown units", function() + expect(function() + Time.add(Y2025_SEP_22, 1, "fortnight" :: any) + end).toThrow("Bad unit") + end) + + it("should return a DateTime usable everywhere else", function() + expect(Time.format("dddd Do MMMM", Time.add(Y2025_SEP_22, 1, "day"))).toBe("Tuesday 23rd September") + expect(Time.getDayOfTheWeekName(Time.add(nil, 0, "day"))).toBe(Time.getDayOfTheWeekName(nil)) + end) +end) + +describe("Time.subtract", function() + it("should be add with a negated value", function() + expect(Time.subtract(Y2025_SEP_22, 1, "day"):ToIsoDate()).toBe("2025-09-21T00:00:00Z") + expect(Time.subtract("2025-03-31T00:00:00Z", 1, "month"):ToIsoDate()).toBe("2025-02-28T00:00:00Z") + expect(Time.subtract(Y2025_SEP_22, -1, "hour"):ToIsoDate()).toBe("2025-09-22T01:00:00Z") + end) +end) + +describe("Time.startOf / endOf", function() + local SAMPLE = DateTime.fromUnixTimestampMillis(1758548730250) -- 2025-09-22T13:45:30.250Z, a Monday + + local function iso(dateTime: DateTime): string + return dateTime:ToIsoDate() + end + + it("should snap to the start of clock units", function() + expect(Time.startOf(SAMPLE, "millisecond").UnixTimestampMillis).toBe(SAMPLE.UnixTimestampMillis) + expect(Time.startOf(SAMPLE, "second").UnixTimestampMillis).toBe(1758548730000) + expect(iso(Time.startOf(SAMPLE, "minute"))).toBe("2025-09-22T13:45:00Z") + expect(iso(Time.startOf(SAMPLE, "hour"))).toBe("2025-09-22T13:00:00Z") + expect(iso(Time.startOf(SAMPLE, "day"))).toBe("2025-09-22T00:00:00Z") + expect(iso(Time.startOf(SAMPLE, "date"))).toBe("2025-09-22T00:00:00Z") + end) + + it("should snap to the start of calendar units", function() + expect(iso(Time.startOf(SAMPLE, "week"))).toBe("2025-09-21T00:00:00Z") + expect(iso(Time.startOf("2025-09-21T12:00:00Z", "week"))).toBe("2025-09-21T00:00:00Z") + expect(iso(Time.startOf("2025-09-27T12:00:00Z", "week"))).toBe("2025-09-21T00:00:00Z") + expect(iso(Time.startOf(SAMPLE, "month"))).toBe("2025-09-01T00:00:00Z") + expect(iso(Time.startOf(SAMPLE, "quarter"))).toBe("2025-07-01T00:00:00Z") + expect(iso(Time.startOf("2025-12-31T23:59:59Z", "quarter"))).toBe("2025-10-01T00:00:00Z") + expect(iso(Time.startOf(SAMPLE, "year"))).toBe("2025-01-01T00:00:00Z") + end) + + it("should end one millisecond before the next unit starts", function() + expect(Time.endOf(SAMPLE, "second").UnixTimestampMillis).toBe(1758548730999) + expect(Time.endOf(SAMPLE, "day").UnixTimestampMillis).toBe( + Time.startOf("2025-09-23T00:00:00Z", "day").UnixTimestampMillis - 1 + ) + expect(iso(Time.endOf(SAMPLE, "day"))).toBe("2025-09-22T23:59:59Z") + expect(iso(Time.endOf(SAMPLE, "week"))).toBe("2025-09-27T23:59:59Z") + expect(iso(Time.endOf(SAMPLE, "month"))).toBe("2025-09-30T23:59:59Z") + expect(iso(Time.endOf("2024-02-10T00:00:00Z", "month"))).toBe("2024-02-29T23:59:59Z") + expect(iso(Time.endOf(SAMPLE, "quarter"))).toBe("2025-09-30T23:59:59Z") + expect(iso(Time.endOf(SAMPLE, "year"))).toBe("2025-12-31T23:59:59Z") + end) + + it("should accept short units and any DateTimeLike", function() + expect(iso(Time.startOf(Y2025_SEP_22_13_00, "M"))).toBe("2025-09-01T00:00:00Z") + expect(iso(Time.endOf("2025-09-22T13:00:00Z", "y"))).toBe("2025-12-31T23:59:59Z") + expect(Time.getHour(Time.startOf(nil, "day"))).toBe(0) + end) +end) + +describe("Time.get", function() + local SAMPLE = DateTime.fromUnixTimestampMillis(1758548730250) -- 2025-09-22T13:45:30.250Z, a Monday + + it("should read every field", function() + expect(Time.get(SAMPLE, "year")).toBe(2025) + expect(Time.get(SAMPLE, "month")).toBe(9) + expect(Time.get(SAMPLE, "date")).toBe(22) + expect(Time.get(SAMPLE, "day")).toBe(1) + expect(Time.get(SAMPLE, "hour")).toBe(13) + expect(Time.get(SAMPLE, "minute")).toBe(45) + expect(Time.get(SAMPLE, "second")).toBe(30) + expect(Time.get(SAMPLE, "millisecond")).toBe(250) + end) + + it("should accept plural and short fields", function() + expect(Time.get(SAMPLE, "y")).toBe(2025) + expect(Time.get(SAMPLE, "M")).toBe(9) + expect(Time.get(SAMPLE, "D")).toBe(22) + expect(Time.get(SAMPLE, "d")).toBe(1) + expect(Time.get(SAMPLE, "h")).toBe(13) + expect(Time.get(SAMPLE, "m")).toBe(45) + expect(Time.get(SAMPLE, "s")).toBe(30) + expect(Time.get(SAMPLE, "ms")).toBe(250) + expect(Time.get(SAMPLE, "hours")).toBe(13) + end) + + it("should accept any DateTimeLike", function() + expect(Time.get("2000-12-31T23:59:59Z", "day")).toBe(0) + expect(Time.get(Y2025_SEP_22_13_00, "hour")).toBe(13) + end) + + it("should reject unknown fields", function() + expect(function() + Time.get(SAMPLE, "week" :: any) + end).toThrow("Bad field") + end) +end) + +describe("Time.set", function() + local SAMPLE = "2025-09-22T13:45:30Z" -- a Monday + + local function iso(dateTime: DateTime): string + return dateTime:ToIsoDate() + end + + it("should replace calendar fields", function() + expect(iso(Time.set(SAMPLE, "year", 2030))).toBe("2030-09-22T13:45:30Z") + expect(iso(Time.set(SAMPLE, "month", 12))).toBe("2025-12-22T13:45:30Z") + expect(iso(Time.set(SAMPLE, "date", 1))).toBe("2025-09-01T13:45:30Z") + end) + + it("should roll calendar fields over like JavaScript dates", function() + expect(iso(Time.set(SAMPLE, "month", 13))).toBe("2026-01-22T13:45:30Z") + expect(iso(Time.set(SAMPLE, "month", 0))).toBe("2024-12-22T13:45:30Z") + expect(iso(Time.set("2025-01-31T00:00:00Z", "month", 2))).toBe("2025-03-03T00:00:00Z") + expect(iso(Time.set("2024-02-29T00:00:00Z", "year", 2025))).toBe("2025-03-01T00:00:00Z") + expect(iso(Time.set(SAMPLE, "date", 31))).toBe("2025-10-01T13:45:30Z") + expect(iso(Time.set(SAMPLE, "date", 0))).toBe("2025-08-31T13:45:30Z") + end) + + it("should move within the week when setting the day", function() + expect(iso(Time.set(SAMPLE, "day", 0))).toBe("2025-09-21T13:45:30Z") + expect(iso(Time.set(SAMPLE, "day", 1))).toBe("2025-09-22T13:45:30Z") + expect(iso(Time.set(SAMPLE, "day", 6))).toBe("2025-09-27T13:45:30Z") + expect(iso(Time.set(SAMPLE, "day", 8))).toBe("2025-09-29T13:45:30Z") + end) + + it("should replace and roll clock fields", function() + expect(iso(Time.set(SAMPLE, "hour", 0))).toBe("2025-09-22T00:45:30Z") + expect(iso(Time.set(SAMPLE, "hour", 25))).toBe("2025-09-23T01:45:30Z") + expect(iso(Time.set(SAMPLE, "minute", 0))).toBe("2025-09-22T13:00:30Z") + expect(iso(Time.set(SAMPLE, "second", 90))).toBe("2025-09-22T13:46:30Z") + expect(Time.set(SAMPLE, "millisecond", 250).UnixTimestampMillis).toBe(1758548730250) + end) + + it("should round trip through get", function() + for _, field in { "year", "month", "date", "day", "hour", "minute", "second", "millisecond" } do + local value = Time.get(SAMPLE, field :: Time.TimeField) + expect(Time.get(Time.set(SAMPLE, field :: Time.TimeField, value), field :: Time.TimeField)).toBe(value) + end + + expect(Time.get(Time.set(SAMPLE, "h", 7), "hour")).toBe(7) + end) +end) + +describe("Time setters", function() + local SAMPLE = "2025-09-22T13:45:30Z" -- a Monday, day 265 + + local function iso(dateTime: DateTime): string + return dateTime:ToIsoDate() + end + + it("should mirror the clock getters", function() + expect(iso(Time.setSecond(SAMPLE, 5))).toBe("2025-09-22T13:45:05Z") + expect(iso(Time.setMinute(SAMPLE, 5))).toBe("2025-09-22T13:05:30Z") + expect(iso(Time.setHour(SAMPLE, 5))).toBe("2025-09-22T05:45:30Z") + expect(Time.getSecond(Time.setSecond(SAMPLE, 5))).toBe(5) + expect(Time.getMinute(Time.setMinute(SAMPLE, 5))).toBe(5) + expect(Time.getHour(Time.setHour(SAMPLE, 5))).toBe(5) + end) + + it("should keep the half of the day when setting the 12-hour hour", function() + expect(iso(Time.setRegularHour(SAMPLE, 3))).toBe("2025-09-22T15:45:30Z") + expect(iso(Time.setRegularHour(SAMPLE, 12))).toBe("2025-09-22T12:45:30Z") + expect(iso(Time.setRegularHour("2025-09-22T01:00:00Z", 3))).toBe("2025-09-22T03:00:00Z") + expect(iso(Time.setRegularHour("2025-09-22T01:00:00Z", 12))).toBe("2025-09-22T00:00:00Z") + expect(Time.getRegularHour(Time.setRegularHour(SAMPLE, 3))).toBe(3) + end) + + it("should mirror the calendar getters", function() + expect(iso(Time.setYear(SAMPLE, 2030))).toBe("2030-09-22T13:45:30Z") + expect(iso(Time.setMonth(SAMPLE, 2))).toBe("2025-02-22T13:45:30Z") + expect(iso(Time.setDayOfTheMonth(SAMPLE, 30))).toBe("2025-09-30T13:45:30Z") + expect(iso(Time.setDayOfTheWeek(SAMPLE, 5))).toBe("2025-09-26T13:45:30Z") + expect(Time.getYear(Time.setYear(SAMPLE, 2030))).toBe(2030) + expect(Time.getMonth(Time.setMonth(SAMPLE, 2))).toBe(2) + expect(Time.getDayOfTheMonth(Time.setDayOfTheMonth(SAMPLE, 30))).toBe(30) + expect(Time.getDayOfTheWeek(Time.setDayOfTheWeek(SAMPLE, 5))).toBe(5) + end) + + it("should set the day of the year and keep the clock", function() + expect(iso(Time.setDay(SAMPLE, 1))).toBe("2025-01-01T13:45:30Z") + expect(iso(Time.setDay(SAMPLE, 60))).toBe("2025-03-01T13:45:30Z") + expect(iso(Time.setDay("2024-09-22T13:45:30Z", 60))).toBe("2024-02-29T13:45:30Z") + expect(iso(Time.setDay(SAMPLE, 366))).toBe("2026-01-01T13:45:30Z") + expect(Time.getDay(Time.setDay(SAMPLE, 265))).toBe(265) + end) + + it("should set the Julian day and keep the clock", function() + expect(iso(Time.setJulianDate(SAMPLE, 2440588))).toBe("1970-01-01T13:45:30Z") + expect(iso(Time.setJulianDate(SAMPLE, 2460941))).toBe("2025-09-22T13:45:30Z") + expect(Time.getJulianDate(Time.setJulianDate(SAMPLE, 2460311))).toBe(2460311) + end) +end) diff --git a/src/time/src/Shared/TimeCalendarUtils.lua b/src/time/src/Shared/TimeCalendarUtils.lua new file mode 100644 index 00000000000..3836dbdadde --- /dev/null +++ b/src/time/src/Shared/TimeCalendarUtils.lua @@ -0,0 +1,92 @@ +--!strict +--[=[ + Calendar time: describes a time relative to a + reference day with phrases like `Today at 2:30 PM`, `Tomorrow at 9:00 AM`, `Last Friday at + 5:00 PM`, and falls back to a plain date outside of a week either way. + + ```lua + TimeCalendarUtils.calendar("2025-09-23T18:30:00Z", "2025-09-22T13:00:00Z") --> Tomorrow at 6:30 PM + TimeCalendarUtils.calendar(os.time() - 3 * 86400) --> Last Friday at 1:00 PM (for example) + ``` + + @class TimeCalendarUtils +]=] + +local require = require(script.Parent.loader).load(script) + +local Time = require("Time") +local TimeLocalizationUtils = require("TimeLocalizationUtils") + +local TimeCalendarUtils = {} + +--[=[ + See [TimeLocalizationUtils.CalendarFormat]. + + @type CalendarFormat TimeLocalizationUtils.CalendarFormat + @within TimeCalendarUtils +]=] +export type CalendarFormat = TimeLocalizationUtils.CalendarFormat + +--[=[ + Which phrase to use for each distance from the reference day. Missing keys fall back to the + locale's defaults from [TimeLocalizationUtils.getCalendarFormatsForLocale]. + + @type CalendarFormats TimeLocalizationUtils.CalendarFormatOverrides + @within TimeCalendarUtils +]=] +export type CalendarFormats = TimeLocalizationUtils.CalendarFormatOverrides + +type CalendarFormatKey = "sameDay" | "nextDay" | "nextWeek" | "lastDay" | "lastWeek" | "sameElse" + +local MILLISECONDS_A_DAY = 86400000 + +-- The distance in days from the start of the reference day picks the phrase +function TimeCalendarUtils._getFormatKey(daysFromReferenceStart: number): CalendarFormatKey + if daysFromReferenceStart < -6 then + return "sameElse" + elseif daysFromReferenceStart < -1 then + return "lastWeek" + elseif daysFromReferenceStart < 0 then + return "lastDay" + elseif daysFromReferenceStart < 1 then + return "sameDay" + elseif daysFromReferenceStart < 2 then + return "nextDay" + elseif daysFromReferenceStart < 7 then + return "nextWeek" + else + return "sameElse" + end +end + +--[=[ + Describes `currentTime` relative to the day of `referenceTime` (now when nil). Times within the same day, the next day, the previous day, the next six days and + the previous six days each get their own phrase; anything further is a plain date. + + `formats` overrides any of the phrases, see [CalendarFormats]. The `locale` picks the + default phrases through [TimeLocalizationUtils] and applies to every template. +]=] +function TimeCalendarUtils.calendar( + currentTime: Time.DateTimeLike, + referenceTime: Time.DateTimeLike, + formats: CalendarFormats?, + locale: string? +): string + local dateTime = Time._toDateTime(currentTime) + local reference = Time._toDateTime(referenceTime) + local referenceStartOfDay = Time.startOf(reference, "day") + + local daysFromReferenceStart = (dateTime.UnixTimestampMillis - referenceStartOfDay.UnixTimestampMillis) + / MILLISECONDS_A_DAY + local key = TimeCalendarUtils._getFormatKey(daysFromReferenceStart) + + local localeFormats: any = TimeLocalizationUtils.getCalendarFormatsForLocale(locale) + local format: CalendarFormat = (if formats then (formats :: any)[key] else nil) or localeFormats[key] + if type(format) == "function" then + return format(dateTime, reference) + end + + return Time.format(format, dateTime, locale) +end + +return TimeCalendarUtils diff --git a/src/time/src/Shared/TimeCalendarUtils.spec.lua b/src/time/src/Shared/TimeCalendarUtils.spec.lua new file mode 100644 index 00000000000..432afbf68d2 --- /dev/null +++ b/src/time/src/Shared/TimeCalendarUtils.spec.lua @@ -0,0 +1,156 @@ +--!strict +--[[ + @class TimeCalendarUtils.spec.lua +]] + +local require = require(script.Parent.loader).load(script) + +local Jest = require("Jest") +local TimeCalendarUtils = require("TimeCalendarUtils") + +local describe = Jest.Globals.describe +local expect = Jest.Globals.expect +local it = Jest.Globals.it + +local REFERENCE = "2025-09-22T13:00:00Z" -- a Monday + +local function calendar(currentTime: string): string + return TimeCalendarUtils.calendar(currentTime, REFERENCE) +end + +describe("TimeCalendarUtils.calendar", function() + it("should describe the reference day", function() + expect(calendar("2025-09-22T09:05:00Z")).toBe("Today at 9:05 AM") + expect(calendar("2025-09-22T00:00:00Z")).toBe("Today at 12:00 AM") + expect(calendar("2025-09-22T23:59:59Z")).toBe("Today at 11:59 PM") + end) + + it("should describe the surrounding days", function() + expect(calendar("2025-09-23T18:30:00Z")).toBe("Tomorrow at 6:30 PM") + expect(calendar("2025-09-21T00:00:00Z")).toBe("Yesterday at 12:00 AM") + expect(calendar("2025-09-21T23:59:59Z")).toBe("Yesterday at 11:59 PM") + end) + + it("should name the weekday within a week either way", function() + expect(calendar("2025-09-24T10:00:00Z")).toBe("Wednesday at 10:00 AM") + expect(calendar("2025-09-28T23:59:00Z")).toBe("Sunday at 11:59 PM") + expect(calendar("2025-09-20T08:00:00Z")).toBe("Last Saturday at 8:00 AM") + expect(calendar("2025-09-16T00:00:00Z")).toBe("Last Tuesday at 12:00 AM") + end) + + it("should fall back to a date further out", function() + expect(calendar("2025-09-29T00:00:00Z")).toBe("09/29/2025") + expect(calendar("2025-09-15T23:59:59Z")).toBe("09/15/2025") + expect(calendar("2026-01-01T00:00:00Z")).toBe("01/01/2026") + end) + + it("should measure from the start of the reference day", function() + expect(TimeCalendarUtils.calendar("2025-09-23T00:00:00Z", "2025-09-22T23:59:59Z")).toBe("Tomorrow at 12:00 AM") + expect(TimeCalendarUtils.calendar("2025-09-22T00:00:00Z", "2025-09-22T23:59:59Z")).toBe("Today at 12:00 AM") + end) + + it("should default the reference to now", function() + expect(string.sub(TimeCalendarUtils.calendar(nil, nil), 1, 8)).toBe("Today at") + expect(string.sub(TimeCalendarUtils.calendar(DateTime.now().UnixTimestamp + 86400, nil), 1, 11)).toBe( + "Tomorrow at" + ) + end) + + it("should accept template overrides with fallbacks", function() + local formats = { sameDay = "[Today]", sameElse = "YYYY-MM-DD" } + + expect(TimeCalendarUtils.calendar("2025-09-22T09:05:00Z", REFERENCE, formats)).toBe("Today") + expect(TimeCalendarUtils.calendar("2026-01-01T00:00:00Z", REFERENCE, formats)).toBe("2026-01-01") + expect(TimeCalendarUtils.calendar("2025-09-23T18:30:00Z", REFERENCE, formats)).toBe("Tomorrow at 6:30 PM") + end) + + it("should accept function overrides", function() + local formats: TimeCalendarUtils.CalendarFormats = { + nextWeek = function(dateTime: DateTime, referenceTime: DateTime): string + return string.format("%d days out", (dateTime.UnixTimestamp - referenceTime.UnixTimestamp) // 86400) + end, + } + + expect(TimeCalendarUtils.calendar("2025-09-25T13:00:00Z", REFERENCE, formats)).toBe("3 days out") + end) + + it("should use the locale's phrases and templates", function() + local wednesday = TimeCalendarUtils.calendar("2025-09-24T10:00:00Z", REFERENCE, nil, "fr-fr") + local today = TimeCalendarUtils.calendar("2025-09-22T10:00:00Z", REFERENCE, nil, "es-es") + local yesterday = TimeCalendarUtils.calendar("2025-09-21T10:00:00Z", REFERENCE, nil, "de-de") + local tomorrow = TimeCalendarUtils.calendar("2025-09-23T10:00:00Z", REFERENCE, nil, "ja-jp") + + expect(string.find(wednesday, "^mercredi à ") ~= nil).toBe(true) + expect(string.find(today, "^hoy a las ") ~= nil).toBe(true) + expect(string.find(yesterday, "^gestern um .* Uhr$") ~= nil).toBe(true) + expect(string.find(tomorrow, "^明日 ") ~= nil).toBe(true) + end) + + it("should let overrides win over the locale", function() + expect(TimeCalendarUtils.calendar("2025-09-22T10:00:00Z", REFERENCE, { sameDay = "[hoy]" }, "es-es")).toBe( + "hoy" + ) + end) +end) + +describe("TimeCalendarUtils localization edge cases", function() + it("should apply the locale to string overrides", function() + expect(TimeCalendarUtils.calendar("2025-09-22T10:00:00Z", REFERENCE, { sameDay = "dddd" }, "fr-fr")).toBe( + "lundi" + ) + expect(TimeCalendarUtils.calendar("2025-09-22T10:00:00Z", REFERENCE, { sameDay = "dddd" }, "de-de")).toBe( + "Montag" + ) + end) + + it("should fill missing overrides from the locale, not English", function() + local tomorrow = TimeCalendarUtils.calendar("2025-09-23T10:00:00Z", REFERENCE, { sameDay = "[x]" }, "es-es") + + expect(string.find(tomorrow, "^mañana a las ") ~= nil).toBe(true) + end) + + it("should localize the fallback date", function() + local american = TimeCalendarUtils.calendar("2026-01-05T00:00:00Z", REFERENCE, nil, "en-us") + local french = TimeCalendarUtils.calendar("2026-01-05T00:00:00Z", REFERENCE, nil, "fr-fr") + + expect(american).toBe("01/05/2026") + expect(french).never.toBe(american) + expect(string.find(french, "^%d%d[/.]%d%d[/.]%d%d%d%d$") ~= nil).toBe(true) + end) + + it("should keep languages that do not space before the time", function() + local today = TimeCalendarUtils.calendar("2025-09-22T10:00:00Z", REFERENCE, nil, "zh-cn") + + expect(string.find(today, "^今天%d") ~= nil).toBe(true) + end) + + it("should treat the zh-cjv alias like zh-cn", function() + expect(TimeCalendarUtils.calendar("2025-09-22T10:00:00Z", REFERENCE, nil, "zh-cjv")).toBe( + TimeCalendarUtils.calendar("2025-09-22T10:00:00Z", REFERENCE, nil, "zh-cn") + ) + end) + + it("should name the weekday in the locale for next and last week", function() + local next = TimeCalendarUtils.calendar("2025-09-25T10:00:00Z", REFERENCE, nil, "pt-br") + local last = TimeCalendarUtils.calendar("2025-09-18T10:00:00Z", REFERENCE, nil, "pt-br") + + expect(string.find(next, "^Quinta%-feira às ") ~= nil).toBe(true) + expect(string.find(last, "^Quinta%-feira passado às ") ~= nil).toBe(true) + end) + + it("should fall back to English for an unknown locale", function() + expect(TimeCalendarUtils.calendar("2025-09-22T09:05:00Z", REFERENCE, nil, "xx-yy")).toBe("Today at 9:05 AM") + end) + + it("should give function overrides the raw times regardless of locale", function() + local formats: TimeCalendarUtils.CalendarFormats = { + sameDay = function(dateTime: DateTime, referenceTime: DateTime): string + return dateTime:ToIsoDate() .. " vs " .. referenceTime:ToIsoDate() + end, + } + + expect(TimeCalendarUtils.calendar("2025-09-22T10:00:00Z", REFERENCE, formats, "ja-jp")).toBe( + "2025-09-22T10:00:00Z vs 2025-09-22T13:00:00Z" + ) + end) +end) diff --git a/src/time/src/Shared/TimeDurationUtils.lua b/src/time/src/Shared/TimeDurationUtils.lua new file mode 100644 index 00000000000..17c895f6435 --- /dev/null +++ b/src/time/src/Shared/TimeDurationUtils.lua @@ -0,0 +1,890 @@ +--!strict +--[=[ + Durations. A duration is a length of time with no + start point, kept as a number of seconds to match Roblox's `os.time`, `task.wait` and friends. + A year is counted as 365 days and a month as 30 days when converting to and from + seconds. + + ```lua + TimeDurationUtils.format({ hours = 1, minutes = 30 }, "hh:mm") --> 01:30 + TimeDurationUtils.humanize(TimeDurationUtils.toSeconds(2, "days")) --> 2 days + TimeDurationUtils.toIsoString("PT90M") --> PT1H30M + ``` + + @class TimeDurationUtils +]=] + +local require = require(script.Parent.loader).load(script) + +local RelativeTimeUtils = require("RelativeTimeUtils") +local Time = require("Time") +local TimeLocalizationUtils = require("TimeLocalizationUtils") + +local TimeDurationUtils = {} + +--[=[ + Units of a duration. Singular keys are accepted as well. + + @interface DurationTable + .years number? + .months number? + .weeks number? + .days number? + .hours number? + .minutes number? + .seconds number? + .milliseconds number? + @within TimeDurationUtils +]=] +export type DurationTable = { + years: number?, + months: number?, + weeks: number?, + days: number?, + hours: number?, + minutes: number?, + seconds: number?, + milliseconds: number?, +} + +--[=[ + A number of seconds, a [DurationTable], or an ISO 8601 duration such as `"P1DT12H"`. + + @type DurationLike number | DurationTable | string + @within TimeDurationUtils +]=] +export type DurationLike = number | DurationTable | string + +--[=[ + A duration broken into whole units, largest first, as [TimeDurationUtils.toTable] returns it. + + @interface DurationBreakdown + .years number + .months number + .days number + .hours number + .minutes number + .seconds number + .milliseconds number + @within TimeDurationUtils +]=] +export type DurationBreakdown = { + years: number, + months: number, + days: number, + hours: number, + minutes: number, + seconds: number, + milliseconds: number, +} + +--[=[ + See [TimeLocalizationUtils.DurationStringOverrides]. + + @type DurationStringOverrides TimeLocalizationUtils.DurationStringOverrides + @within TimeDurationUtils +]=] +export type DurationStringOverrides = TimeLocalizationUtils.DurationStringOverrides + +--[=[ + Which zero-valued tokens [TimeDurationUtils.format] drops: `large` the leading ones, `small` + the trailing ones, `mid` the interior ones, `both` leading and trailing, `all` every one and + `false` none. The last remaining token is never dropped. + + @type DurationTrim "large" | "small" | "both" | "mid" | "all" | false + @within TimeDurationUtils +]=] +export type DurationTrim = "large" | "small" | "both" | "mid" | "all" | false + +--[=[ + Options for [TimeDurationUtils.format]. + + @interface DurationFormatOptions + .locale string? -- Picks the unit phrases through [TimeLocalizationUtils], defaults to English + .strings DurationStringOverrides? -- Overrides the phrase for a unit, e.g. `{ hours = { one = "%d hr", other = "%d hrs" } }` + .trim DurationTrim? -- Which zero tokens to drop, defaults to `large` + .stopTrim string? -- Tokens never dropped, as a template such as `m`; `*` before a token in the template does the same + .largest number? -- Show only this many of the largest non-zero tokens; implies `trim = "all"` unless `trim` is given + .trunc boolean? -- Truncate the smallest token instead of rounding it + .precision number? -- Decimal places on the smallest token; negative rounds to tens, hundreds and so on + .forceLength boolean? -- Pad the first shown token to its template width even when a larger one was dropped + .minValue number? -- Below this many of the smallest unit, print `< ` and the minimum instead + .maxValue number? -- Above this many of the smallest unit, print `> ` and the maximum instead + .limits { [string]: number }? -- How far a token may count before the next larger token is used: `{ minutes = 60 }` prints one hour as `60:00`, `{ hours = 47 }` prints a day and a half as `36:00:00` + @within TimeDurationUtils +]=] +export type DurationFormatOptions = { + locale: string?, + strings: DurationStringOverrides?, + trim: DurationTrim?, + stopTrim: string?, + largest: number?, + trunc: boolean?, + precision: number?, + forceLength: boolean?, + minValue: number?, + maxValue: number?, + limits: { [string]: number }?, +} + +local SECONDS_A_MINUTE = 60 +local SECONDS_AN_HOUR = 60 * SECONDS_A_MINUTE +local SECONDS_A_DAY = 24 * SECONDS_AN_HOUR +local SECONDS_A_WEEK = 7 * SECONDS_A_DAY +local SECONDS_A_MONTH = 30 * SECONDS_A_DAY +local SECONDS_A_YEAR = 365 * SECONDS_A_DAY + +local UNIT_SECONDS: { [string]: number } = { + millisecond = 0.001, + second = 1, + minute = SECONDS_A_MINUTE, + hour = SECONDS_AN_HOUR, + day = SECONDS_A_DAY, + week = SECONDS_A_WEEK, + month = SECONDS_A_MONTH, + quarter = 3 * SECONDS_A_MONTH, + year = SECONDS_A_YEAR, +} + +-- Breakdown field for each unit that has one +local UNIT_BREAKDOWN_FIELD: { [string]: string } = { + millisecond = "milliseconds", + second = "seconds", + minute = "minutes", + hour = "hours", + day = "days", + week = "weeks", + month = "months", + year = "years", +} + +-- ISO 8601 designators before and after the T +local ISO_DATE_UNITS: { [string]: number } = { + Y = SECONDS_A_YEAR, + M = SECONDS_A_MONTH, + W = SECONDS_A_WEEK, + D = SECONDS_A_DAY, +} +local ISO_TIME_UNITS: { [string]: number } = { + H = SECONDS_AN_HOUR, + M = SECONDS_A_MINUTE, + S = 1, +} + +type FormatToken = { + letter: string, + field: Time.TimeUnit?, + milliseconds: number, +} + +function TimeDurationUtils._formatToken(letter: string, field: Time.TimeUnit?, milliseconds: number): FormatToken + return { letter = letter, field = field, milliseconds = milliseconds } +end + +-- Template letters, largest first, with each unit's size in milliseconds +local FORMAT_TOKENS: { FormatToken } = { + TimeDurationUtils._formatToken("y", "years", SECONDS_A_YEAR * 1000), + TimeDurationUtils._formatToken("M", "months", SECONDS_A_MONTH * 1000), + TimeDurationUtils._formatToken("w", "weeks", SECONDS_A_WEEK * 1000), + TimeDurationUtils._formatToken("d", "days", SECONDS_A_DAY * 1000), + TimeDurationUtils._formatToken("h", "hours", SECONDS_AN_HOUR * 1000), + TimeDurationUtils._formatToken("m", "minutes", SECONDS_A_MINUTE * 1000), + TimeDurationUtils._formatToken("s", "seconds", 1000), + -- Centiseconds have no TimeUnit, so they take no limit and no `__` phrase + TimeDurationUtils._formatToken("C", nil, 10), + TimeDurationUtils._formatToken("S", "milliseconds", 1), +} + +local FORMAT_TOKEN_RANK: { [string]: number } = {} +for rank, token in FORMAT_TOKENS do + FORMAT_TOKEN_RANK[token.letter] = rank +end + +-- One piece of a parsed template: literal text, or a unit token with its padding width +type FormatPart = { + text: string?, + rank: number?, + width: number?, + label: boolean?, + stop: boolean?, + hidden: boolean?, + value: number?, +} + +function TimeDurationUtils._unitSeconds(unit: Time.TimeUnit): number + return UNIT_SECONDS[Time._normalizeUnit(unit)] +end + +-- Truncates toward zero, so a negative duration breaks into negative parts +function TimeDurationUtils._truncate(value: number): number + return if value < 0 then math.ceil(value) else math.floor(value) +end + +function TimeDurationUtils._parseIsoPart(part: string, units: { [string]: number }, source: string): number + local total = 0 + local index = 1 + + while index <= #part do + local numberText, letter, nextIndex = string.match(part, "^([-+]?[%d.,]*)(%a)()", index) + assert(letter and units[letter], string.format("Bad ISO 8601 duration %q", source)) + + local value = tonumber((string.gsub(numberText :: string, ",", "."))) or 0 + total += value * units[letter] + index = nextIndex :: any + end + + return total +end + +function TimeDurationUtils._parseIso(iso: string): number + local sign, body = string.match(iso, "^([-+]?)P(.*)$") + assert(body, string.format("Bad ISO 8601 duration %q", iso)) + + local datePart, timePart = string.match(body :: string, "^([^T]*)T(.*)$") + if not datePart then + datePart = body + timePart = "" + end + + local total = TimeDurationUtils._parseIsoPart(datePart :: string, ISO_DATE_UNITS, iso) + + TimeDurationUtils._parseIsoPart(timePart :: string, ISO_TIME_UNITS, iso) + + return if sign == "-" then -total else total +end + +--[=[ + Normalizes any [DurationLike] to seconds. A bare number is seconds unless `unit` says + otherwise, so `toSeconds(5, "minutes")` is `300`. +]=] +function TimeDurationUtils.toSeconds(duration: DurationLike, unit: Time.TimeUnit?): number + if type(duration) == "number" then + return if unit then duration * TimeDurationUtils._unitSeconds(unit) else duration + elseif type(duration) == "string" then + assert(unit == nil, "A unit cannot be given with an ISO 8601 duration") + return TimeDurationUtils._parseIso(duration) + elseif type(duration) == "table" then + assert(unit == nil, "A unit cannot be given with a duration table") + + local total = 0 + for key, value in pairs(duration :: any) do + assert(type(value) == "number", string.format("Bad value for %q", tostring(key))) + total += value * TimeDurationUtils._unitSeconds(key) + end + + return total + else + error(string.format("Bad duration %q", typeof(duration))) + end +end + +--[=[ + Normalizes any [DurationLike] to milliseconds. See [TimeDurationUtils.toSeconds]. +]=] +function TimeDurationUtils.toMilliseconds(duration: DurationLike, unit: Time.TimeUnit?): number + return TimeDurationUtils.toSeconds(duration, unit) * 1000 +end + +--[=[ + Returns the whole duration in one unit, fractional. `as(90, "minutes")` + is `1.5`. +]=] +function TimeDurationUtils.as(duration: DurationLike, unit: Time.TimeUnit): number + return TimeDurationUtils.toSeconds(duration) / TimeDurationUtils._unitSeconds(unit) +end + +--[=[ + Breaks the duration into whole years, months, days, hours, minutes, seconds and milliseconds, + largest first. +]=] +function TimeDurationUtils.toTable(duration: DurationLike): DurationBreakdown + local seconds = TimeDurationUtils.toSeconds(duration) + -- Work in whole milliseconds so fractional seconds do not leak float noise into the parts + local remaining = TimeDurationUtils._truncate(seconds * 1000 + (if seconds < 0 then -0.5 else 0.5)) + + local years = TimeDurationUtils._truncate(remaining / (SECONDS_A_YEAR * 1000)) + remaining = math.fmod(remaining, SECONDS_A_YEAR * 1000) + local months = TimeDurationUtils._truncate(remaining / (SECONDS_A_MONTH * 1000)) + remaining = math.fmod(remaining, SECONDS_A_MONTH * 1000) + local days = TimeDurationUtils._truncate(remaining / (SECONDS_A_DAY * 1000)) + remaining = math.fmod(remaining, SECONDS_A_DAY * 1000) + local hours = TimeDurationUtils._truncate(remaining / (SECONDS_AN_HOUR * 1000)) + remaining = math.fmod(remaining, SECONDS_AN_HOUR * 1000) + local minutes = TimeDurationUtils._truncate(remaining / (SECONDS_A_MINUTE * 1000)) + remaining = math.fmod(remaining, SECONDS_A_MINUTE * 1000) + local wholeSeconds = TimeDurationUtils._truncate(remaining / 1000) + remaining = math.fmod(remaining, 1000) + + return { + years = years, + months = months, + days = days, + hours = hours, + minutes = minutes, + seconds = wholeSeconds, + milliseconds = remaining, + } +end + +--[=[ + Returns one whole unit of the breakdown: `get({ hours = 25 }, "hours")` is + `1` and `get({ hours = 25 }, "days")` is `1`. Weeks are whole weeks within the days and + quarters whole quarters within the months. +]=] +function TimeDurationUtils.get(duration: DurationLike, unit: Time.TimeUnit): number + local normalized = Time._normalizeUnit(unit) + local breakdown = TimeDurationUtils.toTable(duration) + + if normalized == "week" then + return TimeDurationUtils._truncate(breakdown.days / 7) + elseif normalized == "quarter" then + return TimeDurationUtils._truncate(breakdown.months / 3) + end + + return (breakdown :: any)[UNIT_BREAKDOWN_FIELD[normalized]] +end + +--[=[ + Adds another duration, returning seconds. `unit` applies when `other` is a number. +]=] +function TimeDurationUtils.add(duration: DurationLike, other: DurationLike, unit: Time.TimeUnit?): number + return TimeDurationUtils.toSeconds(duration) + TimeDurationUtils.toSeconds(other, unit) +end + +--[=[ + Subtracts another duration, returning seconds. `unit` applies when `other` is a number. +]=] +function TimeDurationUtils.subtract(duration: DurationLike, other: DurationLike, unit: Time.TimeUnit?): number + return TimeDurationUtils.toSeconds(duration) - TimeDurationUtils.toSeconds(other, unit) +end + +--[=[ + Prints an amount with its unit, pluralized for the locale: `formatUnit("days", 1)` is + `1 day` and `formatUnit("days", 45)` is `45 days`. The amount is printed as given, so + `formatUnit("hours", 1.5)` is `1.5 hours`. Quarters have no phrase. + + ```lua + TimeDurationUtils.formatUnit("hours", 2, { locale = "es-es" }) --> 2 horas + ``` +]=] +function TimeDurationUtils.formatUnit(unit: Time.TimeUnit, amount: number, options: DurationFormatOptions?): string + assert(type(amount) == "number", "Bad amount") + + local field = UNIT_BREAKDOWN_FIELD[Time._normalizeUnit(unit)] + assert(field, string.format("No duration phrase for %q", tostring(unit))) + + local overrides = if options then options.strings else nil + local localeStrings: any = + TimeLocalizationUtils.getDurationStringsForLocale(if options then options.locale else nil) + local phrase = (if overrides then overrides[field] else nil) or localeStrings[field] + assert(phrase, string.format("No duration phrase for %q", field)) + + if type(phrase) == "function" then + return phrase(amount) + end + + local template = if math.abs(amount) == 1 then phrase.one else phrase.other + return (string.gsub(template, "%%d", tostring(amount))) +end + +function TimeDurationUtils._appendText(parts: { FormatPart }, text: string) + local last = parts[#parts] + if last and last.text then + last.text ..= text + else + table.insert(parts, { text = text }) + end +end + +function TimeDurationUtils._tokenizeTemplate(template: string): { FormatPart } + local parts: { FormatPart } = {} + local index = 1 + local stopNext = false + + while index <= #template do + local char = string.sub(template, index, index) + local rank = FORMAT_TOKEN_RANK[char] + + if char == "[" then + local closeIndex = string.find(template, "]", index, true) + if closeIndex then + TimeDurationUtils._appendText(parts, string.sub(template, index + 1, closeIndex - 1)) + index = closeIndex + 1 + else + TimeDurationUtils._appendText(parts, char) + index += 1 + end + elseif char == "*" then + stopNext = true + index += 1 + elseif rank then + local width = 1 + while string.sub(template, index + width, index + width) == char do + width += 1 + end + index += width + + local labelEnd = string.match(template, "^%s*__()", index) + table.insert(parts, { rank = rank, width = width, stop = stopNext, label = labelEnd ~= nil }) + stopNext = false + if labelEnd then + index = labelEnd :: any + end + else + TimeDurationUtils._appendText(parts, char) + index += 1 + end + end + + return parts +end + +-- Shown tokens, largest first +function TimeDurationUtils._getShownTokens(parts: { FormatPart }): { FormatPart } + local shown: { FormatPart } = {} + for _, part in parts do + if part.rank and not part.hidden then + table.insert(shown, part) + end + end + + table.sort(shown, function(a, b) + return (a.rank :: number) < (b.rank :: number) + end) + + return shown +end + +-- The largest shown token absorbs everything above it, the smallest is rounded or truncated +-- to the precision, and the rest hold the remainder in between +function TimeDurationUtils._assignValues( + parts: { FormatPart }, + totalMilliseconds: number, + options: DurationFormatOptions +) + local shown = TimeDurationUtils._getShownTokens(parts) + local ranks: { number } = {} + for _, part in shown do + if ranks[#ranks] ~= part.rank then + table.insert(ranks, part.rank :: number) + end + end + + local precision = options.precision or 0 + local unitsPerStep = 10 ^ precision + local smallestMilliseconds = FORMAT_TOKENS[ranks[#ranks]].milliseconds + + local steps = totalMilliseconds / smallestMilliseconds * unitsPerStep + steps = if options.trunc then math.floor(steps + 1e-9) else math.floor(steps + 0.5) + + local values: { [number]: number } = {} + for i = 1, #ranks - 1 do + local ratio = FORMAT_TOKENS[ranks[i]].milliseconds / smallestMilliseconds * unitsPerStep + local value = math.floor(steps / ratio + 1e-9) + steps -= value * ratio + values[ranks[i]] = value + end + values[ranks[#ranks]] = steps / unitsPerStep + + for _, part in shown do + part.value = values[part.rank :: number] + end +end + +function TimeDurationUtils._getStopRanks(options: DurationFormatOptions): { [number]: boolean } + local stopRanks: { [number]: boolean } = {} + if options.stopTrim then + for _, part in TimeDurationUtils._tokenizeTemplate(options.stopTrim) do + if part.rank then + stopRanks[part.rank] = true + end + end + end + + return stopRanks +end + +function TimeDurationUtils._isPinned(part: FormatPart, stopRanks: { [number]: boolean }): boolean + return part.stop == true or stopRanks[part.rank :: number] == true +end + +function TimeDurationUtils._canHide(part: FormatPart, stopRanks: { [number]: boolean }): boolean + return part.value == 0 and not TimeDurationUtils._isPinned(part, stopRanks) +end + +-- Hides the largest shown token while the next smaller token can still count the whole +-- duration within its limit, so `{ minutes = 60 }` prints one hour as `60:00` +function TimeDurationUtils._applyLimits( + parts: { FormatPart }, + totalMilliseconds: number, + options: DurationFormatOptions +) + local limits: { [string]: number } = {} + for unit, limit in pairs(options.limits or {}) do + limits[Time._normalizeUnit(unit :: any)] = limit + end + + local stopRanks = TimeDurationUtils._getStopRanks(options) + + while true do + local shown = TimeDurationUtils._getShownTokens(parts) + local largest = shown[1] + local largestRank = largest.rank :: number + + local next + for _, part in shown do + if part.rank ~= largestRank then + next = part + break + end + end + if next == nil or TimeDurationUtils._isPinned(largest, stopRanks) then + return + end + + local nextField: Time.TimeUnit? = FORMAT_TOKENS[next.rank :: number].field + local limit = if nextField then limits[Time._normalizeUnit(nextField)] else nil + if limit == nil then + return + end + + for _, part in shown do + if part.rank == largestRank then + part.hidden = true + end + end + TimeDurationUtils._assignValues(parts, totalMilliseconds, options) + + if (next.value :: number) > limit then + for _, part in shown do + if part.rank == largestRank then + part.hidden = false + end + end + TimeDurationUtils._assignValues(parts, totalMilliseconds, options) + return + end + end +end + +function TimeDurationUtils._trimParts(parts: { FormatPart }, options: DurationFormatOptions) + local tokens: { FormatPart } = {} + for _, part in parts do + if part.rank then + table.insert(tokens, part) + end + end + + local stopRanks = TimeDurationUtils._getStopRanks(options) + + local mode: any = options.trim + if mode == nil then + mode = if options.largest then "all" else "large" :: DurationTrim + end + + if options.largest then + local kept = 0 + for _, part in TimeDurationUtils._getShownTokens(parts) do + if part.value ~= 0 and kept < options.largest then + kept += 1 + elseif not TimeDurationUtils._isPinned(part, stopRanks) then + part.hidden = true + end + end + end + + if mode == "large" or mode == "both" or mode == "all" then + for _, part in tokens do + if part.hidden then + continue + elseif TimeDurationUtils._canHide(part, stopRanks) then + part.hidden = true + else + break + end + end + end + + if mode == "small" or mode == "both" or mode == "all" then + for i = #tokens, 1, -1 do + local part = tokens[i] + if part.hidden then + continue + elseif TimeDurationUtils._canHide(part, stopRanks) then + part.hidden = true + else + break + end + end + end + + if mode == "mid" or mode == "all" then + local firstShown, lastShown + for i, part in tokens do + if not part.hidden then + firstShown = firstShown or i + lastShown = i + end + end + + if firstShown and lastShown then + for i = firstShown + 1, lastShown - 1 do + if TimeDurationUtils._canHide(tokens[i], stopRanks) then + tokens[i].hidden = true + end + end + end + end + + local anyShown = false + for _, part in tokens do + anyShown = anyShown or not part.hidden + end + if not anyShown and #tokens > 0 then + tokens[#tokens].hidden = false + end +end + +function TimeDurationUtils._formatTokenValue( + part: FormatPart, + padded: boolean, + isSmallest: boolean, + options: DurationFormatOptions +): string + local value = part.value :: number + local precision = options.precision or 0 + local hasDecimals = isSmallest and precision > 0 + local text = if hasDecimals + then string.format("%." .. precision .. "f", value) + else tostring(math.floor(value + 0.5)) + + if part.label then + local field: Time.TimeUnit? = FORMAT_TOKENS[part.rank :: number].field + assert(field, string.format("No duration phrase for %q", FORMAT_TOKENS[part.rank :: number].letter)) + return TimeDurationUtils.formatUnit(field, tonumber(text) :: number, options) + end + + local width = part.width :: number + local integerLength = #(string.match(text, "^%d+") :: string) + if padded and integerLength < width then + text = string.rep("0", width - integerLength) .. text + end + + return text +end + +-- A gap between tokens is dropped when the token before it was hidden, or when the token after +-- it starts a hidden run that reaches the end of the template +function TimeDurationUtils._isGapDropped(parts: { FormatPart }, index: number): boolean + local previous, next + for j = index - 1, 1, -1 do + if parts[j].rank then + previous = parts[j] + break + end + end + for j = index + 1, #parts do + if parts[j].rank then + next = parts[j] + break + end + end + + if not previous or not next then + return false + elseif previous.hidden then + return true + elseif not next.hidden then + return false + end + + for j = index + 1, #parts do + if parts[j].rank and not parts[j].hidden then + return false + end + end + + return true +end + +function TimeDurationUtils._renderParts( + parts: { FormatPart }, + isNegative: boolean, + options: DurationFormatOptions +): string + local shown = TimeDurationUtils._getShownTokens(parts) + local smallestRank = shown[#shown].rank + + local firstTokenIndex, firstShownIndex + for i, part in parts do + if part.rank then + firstTokenIndex = firstTokenIndex or i + if not part.hidden then + firstShownIndex = firstShownIndex or i + end + end + end + + local pieces: { string } = {} + for i, part in parts do + if part.text then + if not TimeDurationUtils._isGapDropped(parts, i) then + table.insert(pieces, part.text) + end + elseif not part.hidden then + local padded = i == firstTokenIndex or i ~= firstShownIndex or options.forceLength == true + if i == firstShownIndex and isNegative then + table.insert(pieces, "-") + end + table.insert(pieces, TimeDurationUtils._formatTokenValue(part, padded, part.rank == smallestRank, options)) + end + end + + return table.concat(pieces) +end + +-- Picks a template from the duration's size, the way a person would write it +function TimeDurationUtils._defaultTemplate(totalMilliseconds: number): (string, DurationTrim) + local breakdown: any = TimeDurationUtils.toTable(totalMilliseconds / 1000) + local largest, smallest + for _, field in { "years", "months", "days", "hours", "minutes", "seconds", "milliseconds" } do + if breakdown[field] ~= 0 then + largest = largest or field + smallest = field + end + end + + if largest == nil then + return "s __", "large" :: DurationTrim + elseif largest == "milliseconds" then + return "S __", "large" :: DurationTrim + elseif largest == "seconds" or largest == "minutes" then + return "*m:ss", "large" :: DurationTrim + elseif largest == "hours" then + return "h:mm:ss", "large" :: DurationTrim + elseif largest == "days" then + if smallest == "days" then + return (if breakdown.days % 7 == 0 then "w __" else "d __"), "large" :: DurationTrim + end + return "w __, d __, h __", "both" :: DurationTrim + elseif largest == smallest then + return (if largest == "years" then "y __" else "M __"), "large" :: DurationTrim + else + return "y __, M __, d __", "both" :: DurationTrim + end +end + +--[=[ + Formats the duration with a template in the style of moment-duration-format. Tokens are `y` + years, `M` months, `w` weeks, `d` days, `h` hours, `m` minutes, `s` seconds, `C` + centiseconds and `S` milliseconds; repeating a letter zero pads it to that width. The largest token in the + template absorbs everything above it, so `h:mm` on 47 hours is `47:00`, and the smallest + token is rounded unless `trunc` is set. `__` after a token prints it as a localized phrase + such as `2 days`, and text in square brackets is literal. + + Tokens whose value is zero are dropped from the front by default, so `h:mm:ss` on 45 seconds + is `45`; see [DurationTrim]. Dropping a token also drops the text between it and its + neighbour, so put unit words in `__` labels rather than brackets when trimming matters. + Without a template, one is chosen from the duration's size: `250 milliseconds`, `2:03:00`, + `3 days`, `1 week, 3 days, 2 hours` or `1 year, 2 months, 3 days`. + + ```lua + TimeDurationUtils.format(47 * 3600, "h:mm:ss") --> 47:00:00 + TimeDurationUtils.format(65.432, "mm:ss:CC", { trunc = true }) --> 01:05:43 + TimeDurationUtils.format({ days = 45 }, "d __") --> 45 days + TimeDurationUtils.format(123 * 60, "d __ h:mm:ss") --> 2:03:00 + TimeDurationUtils.format({ days = 1, minutes = 5 }, "d __, h __, m __", { largest = 2 }) --> 1 day, 5 minutes + TimeDurationUtils.format({ hours = 2 }, "h __", { locale = "es-es" }) --> 2 horas + ``` +]=] +function TimeDurationUtils.format(duration: DurationLike, template: string?, options: DurationFormatOptions?): string + local seconds = TimeDurationUtils.toSeconds(duration) + local isNegative = seconds < 0 + local totalMilliseconds = math.floor(math.abs(seconds) * 1000 + 0.5) + + local resolved: DurationFormatOptions = if options then table.clone(options) else {} + local str = template + if str == nil then + local defaultTemplate: string, defaultTrim: DurationTrim = TimeDurationUtils._defaultTemplate(totalMilliseconds) + str = defaultTemplate + if resolved.trim == nil then + resolved.trim = defaultTrim + end + end + + local parts = TimeDurationUtils._tokenizeTemplate(str :: string) + local shown = TimeDurationUtils._getShownTokens(parts) + assert(#shown > 0, string.format("No unit tokens in duration template %q", str :: string)) + + if resolved.minValue or resolved.maxValue then + local smallestMilliseconds = FORMAT_TOKENS[shown[#shown].rank :: number].milliseconds + local amount = totalMilliseconds / smallestMilliseconds + local bounded: DurationFormatOptions = table.clone(resolved) + bounded.minValue = nil + bounded.maxValue = nil + + if resolved.minValue and amount < resolved.minValue then + return "< " .. TimeDurationUtils.format(resolved.minValue * smallestMilliseconds / 1000, str, bounded) + elseif resolved.maxValue and amount > resolved.maxValue then + return "> " .. TimeDurationUtils.format(resolved.maxValue * smallestMilliseconds / 1000, str, bounded) + end + end + + TimeDurationUtils._assignValues(parts, totalMilliseconds, resolved) + TimeDurationUtils._applyLimits(parts, totalMilliseconds, resolved) + TimeDurationUtils._trimParts(parts, resolved) + -- Rounding lands on the smallest token still shown + TimeDurationUtils._assignValues(parts, totalMilliseconds, resolved) + + return TimeDurationUtils._renderParts(parts, isNegative, resolved) +end + +--[=[ + Describes the duration in words through [RelativeTimeUtils]: + `an hour`, `2 days`. With `withSuffix`, a positive duration reads `in an hour` and a negative + one `an hour ago`. `options` can set the locale, thresholds or strings; its `withoutSuffix` + is ignored in favour of `withSuffix`. +]=] +function TimeDurationUtils.humanize( + duration: DurationLike, + withSuffix: boolean?, + options: RelativeTimeUtils.RelativeTimeOptions? +): string + local now = DateTime.now() + local later = DateTime.fromUnixTimestampMillis(now.UnixTimestampMillis + TimeDurationUtils.toMilliseconds(duration)) + + local resolved: RelativeTimeUtils.RelativeTimeOptions = if options then table.clone(options) else {} + resolved.withoutSuffix = not withSuffix + + return RelativeTimeUtils.from(later, now, resolved) +end + +--[=[ + Formats the duration as an ISO 8601 string such as `P1DT12H` or `PT1.5S`. A zero duration is `P0D` and a negative one is prefixed with `-`. +]=] +function TimeDurationUtils.toIsoString(duration: DurationLike): string + local breakdown = TimeDurationUtils.toTable(duration) + local isNegative = TimeDurationUtils.toSeconds(duration) < 0 + + local years = math.abs(breakdown.years) + local months = math.abs(breakdown.months) + local days = math.abs(breakdown.days) + local hours = math.abs(breakdown.hours) + local minutes = math.abs(breakdown.minutes) + local seconds = math.abs(breakdown.seconds) + math.abs(breakdown.milliseconds) / 1000 + seconds = math.floor(seconds * 10000 + 0.5) / 10000 + + local date = (if years ~= 0 then years .. "Y" else "") + .. (if months ~= 0 then months .. "M" else "") + .. (if days ~= 0 then days .. "D" else "") + local time = (if hours ~= 0 then hours .. "H" else "") + .. (if minutes ~= 0 then minutes .. "M" else "") + .. (if seconds ~= 0 then seconds .. "S" else "") + + if date == "" and time == "" then + return "P0D" + end + + return (if isNegative then "-" else "") .. "P" .. date .. (if time ~= "" then "T" .. time else "") +end + +return TimeDurationUtils diff --git a/src/time/src/Shared/TimeDurationUtils.spec.lua b/src/time/src/Shared/TimeDurationUtils.spec.lua new file mode 100644 index 00000000000..ff1c2689393 --- /dev/null +++ b/src/time/src/Shared/TimeDurationUtils.spec.lua @@ -0,0 +1,362 @@ +--!strict +--[[ + @class TimeDurationUtils.spec.lua +]] + +local require = require(script.Parent.loader).load(script) + +local Jest = require("Jest") +local RelativeTimeUtils = require("RelativeTimeUtils") +local TimeDurationUtils = require("TimeDurationUtils") + +local describe = Jest.Globals.describe +local expect = Jest.Globals.expect +local it = Jest.Globals.it + +local SECOND = 1 +local MINUTE = 60 * SECOND +local HOUR = 60 * MINUTE +local DAY = 24 * HOUR + +describe("TimeDurationUtils.toSeconds", function() + it("should treat a bare number as seconds", function() + expect(TimeDurationUtils.toSeconds(1.5)).toBe(1.5) + expect(TimeDurationUtils.toMilliseconds(1.5)).toBe(1500) + expect(TimeDurationUtils.toMilliseconds(5, "minutes")).toBe(300000) + end) + + it("should scale a number by a unit", function() + expect(TimeDurationUtils.toSeconds(5, "minutes")).toBe(5 * MINUTE) + expect(TimeDurationUtils.toSeconds(2, "h")).toBe(2 * HOUR) + expect(TimeDurationUtils.toSeconds(1, "week")).toBe(7 * DAY) + expect(TimeDurationUtils.toSeconds(1, "month")).toBe(30 * DAY) + expect(TimeDurationUtils.toSeconds(1, "year")).toBe(365 * DAY) + expect(TimeDurationUtils.toSeconds(1, "quarter")).toBe(90 * DAY) + end) + + it("should sum a duration table", function() + expect(TimeDurationUtils.toSeconds({ hours = 1, minutes = 30 })).toBe(90 * MINUTE) + expect(TimeDurationUtils.toSeconds({ weeks = 1, days = 1 })).toBe(8 * DAY) + expect(TimeDurationUtils.toSeconds({ second = 2, millisecond = 5 })).toBe(2.005) + expect(TimeDurationUtils.toSeconds({})).toBe(0) + end) + + it("should parse ISO 8601 durations", function() + expect(TimeDurationUtils.toSeconds("PT1H30M")).toBe(90 * MINUTE) + expect(TimeDurationUtils.toSeconds("P1DT12H")).toBe(36 * HOUR) + expect(TimeDurationUtils.toSeconds("P1W")).toBe(7 * DAY) + expect(TimeDurationUtils.toSeconds("P1Y2M3DT4H5M6S")).toBe( + 365 * DAY + 60 * DAY + 3 * DAY + 4 * HOUR + 5 * MINUTE + 6 * SECOND + ) + expect(TimeDurationUtils.toSeconds("PT1.5S")).toBe(1.5) + expect(TimeDurationUtils.toSeconds("PT0,5S")).toBe(0.5) + expect(TimeDurationUtils.toSeconds("-PT5S")).toBe(-5 * SECOND) + expect(TimeDurationUtils.toSeconds("P0D")).toBe(0) + end) + + it("should reject malformed input", function() + expect(function() + TimeDurationUtils.toSeconds("1H") + end).toThrow("Bad ISO 8601 duration") + expect(function() + TimeDurationUtils.toSeconds("PT1X") + end).toThrow("Bad ISO 8601 duration") + expect(function() + TimeDurationUtils.toSeconds({ fortnights = 1 } :: any) + end).toThrow("Bad unit") + expect(function() + TimeDurationUtils.toSeconds("PT1H", "minutes") + end).toThrow("cannot be given") + end) +end) + +describe("TimeDurationUtils.as", function() + it("should convert to a fractional amount of a unit", function() + expect(TimeDurationUtils.as(90 * SECOND, "minutes")).toBe(1.5) + expect(TimeDurationUtils.as({ days = 1 }, "hours")).toBe(24) + expect(TimeDurationUtils.as({ days = 14 }, "weeks")).toBe(2) + expect(TimeDurationUtils.as("PT36H", "days")).toBe(1.5) + expect(TimeDurationUtils.as({ years = 1 }, "days")).toBe(365) + end) +end) + +describe("TimeDurationUtils.toTable / get", function() + it("should break a duration into whole units", function() + expect(TimeDurationUtils.toTable({ hours = 1, minutes = 30 })).toEqual({ + years = 0, + months = 0, + days = 0, + hours = 1, + minutes = 30, + seconds = 0, + milliseconds = 0, + }) + expect(TimeDurationUtils.toTable(400 * DAY + 90 * MINUTE + 1.25)).toEqual({ + years = 1, + months = 1, + days = 5, + hours = 1, + minutes = 30, + seconds = 1, + milliseconds = 250, + }) + end) + + it("should read whole units", function() + expect(TimeDurationUtils.get({ hours = 25 }, "hours")).toBe(1) + expect(TimeDurationUtils.get({ hours = 25 }, "days")).toBe(1) + expect(TimeDurationUtils.get(90 * SECOND, "seconds")).toBe(30) + expect(TimeDurationUtils.get(90 * SECOND, "minutes")).toBe(1) + expect(TimeDurationUtils.get({ days = 15 }, "weeks")).toBe(2) + expect(TimeDurationUtils.get({ days = 15 }, "d")).toBe(15) + expect(TimeDurationUtils.get({ months = 7 }, "quarters")).toBe(2) + expect(TimeDurationUtils.get(1.25, "ms")).toBe(250) + end) + + it("should break negative durations into negative parts", function() + expect(TimeDurationUtils.get(-90 * SECOND, "minutes")).toBe(-1) + expect(TimeDurationUtils.get(-90 * SECOND, "seconds")).toBe(-30) + end) +end) + +describe("TimeDurationUtils.add / subtract", function() + it("should combine durations in milliseconds", function() + expect(TimeDurationUtils.add(MINUTE, { seconds = 30 })).toBe(90 * SECOND) + expect(TimeDurationUtils.add(MINUTE, 5, "seconds")).toBe(65 * SECOND) + expect(TimeDurationUtils.subtract({ hours = 1 }, "PT15M")).toBe(45 * MINUTE) + end) +end) + +describe("TimeDurationUtils.format", function() + it("should let the largest token absorb the overflow", function() + expect(TimeDurationUtils.format(47 * HOUR, "h:mm:ss")).toBe("47:00:00") + expect(TimeDurationUtils.format({ days = 45 }, "d __")).toBe("45 days") + expect(TimeDurationUtils.format(HOUR, "m:ss")).toBe("60:00") + expect(TimeDurationUtils.format(MINUTE, "s")).toBe("60") + expect(TimeDurationUtils.format({ minutes = 90 }, "hh:mm:ss")).toBe("01:30:00") + expect(TimeDurationUtils.format({ days = 10 }, "w __ d __")).toBe("1 week 3 days") + end) + + it("should pad repeated letters and leave single ones bare", function() + expect(TimeDurationUtils.format(HOUR + MINUTE + SECOND, "hh:mm:ss")).toBe("01:01:01") + expect(TimeDurationUtils.format(HOUR + MINUTE + SECOND, "h:m:s")).toBe("1:1:1") + expect(TimeDurationUtils.format({ days = 12, months = 3 }, "M/dd")).toBe("3/12") + expect(TimeDurationUtils.format({ years = 12 }, "yy yyyy")).toBe("12 0012") + expect(TimeDurationUtils.format(1.25, "s.SSS")).toBe("1.250") + expect(TimeDurationUtils.format(0.25, "S __")).toBe("250 milliseconds") + expect(TimeDurationUtils.format(0.001, "S__")).toBe("1 millisecond") + end) + + it("should treat bracketed text as literal and leave other characters alone", function() + expect(TimeDurationUtils.format(HOUR + MINUTE + SECOND, "h [hours] m [minutes] s [seconds]")).toBe( + "1 hours 1 minutes 1 seconds" + ) + expect(TimeDurationUtils.format(90 * MINUTE, "mm:ss (hh)")).toBe("30:00 (01)") + expect(TimeDurationUtils.format(5, "[s] s [s]")).toBe("s 5 s") + end) + + it("should drop leading zero tokens by default", function() + expect(TimeDurationUtils.format(123 * MINUTE, "d __ h:mm:ss")).toBe("2:03:00") + expect(TimeDurationUtils.format(45, "h:mm:ss")).toBe("45") + expect(TimeDurationUtils.format(5 * MINUTE, "h:mm:ss")).toBe("5:00") + expect(TimeDurationUtils.format(0, "h:mm:ss")).toBe("0") + expect(TimeDurationUtils.format(45, "h:mm:ss", { trim = false })).toBe("0:00:45") + expect(TimeDurationUtils.format(5 * MINUTE, "h:mm:ss", { forceLength = true })).toBe("05:00") + end) + + it("should drop trailing, interior or all zero tokens when asked", function() + expect(TimeDurationUtils.format({ days = 2 }, "d __ h __ m __", { trim = "both" })).toBe("2 days") + expect(TimeDurationUtils.format({ hours = 2 }, "d __ h __ m __", { trim = "both" })).toBe("2 hours") + expect(TimeDurationUtils.format({ hours = 2 }, "d __ h __ m __", { trim = "small" })).toBe("0 days 2 hours") + expect(TimeDurationUtils.format({ years = 1, days = 3 }, "y __, M __, d __", { trim = "mid" })).toBe( + "1 year, 3 days" + ) + expect(TimeDurationUtils.format({ years = 1, days = 3 }, "y __, M __, d __, h __", { trim = "all" })).toBe( + "1 year, 3 days" + ) + expect(TimeDurationUtils.format(0, "d __ h __ m __", { trim = "all" })).toBe("0 minutes") + end) + + it("should drop the text next to a dropped token", function() + expect(TimeDurationUtils.format(45, "[in] h:mm:ss")).toBe("in 45") + expect(TimeDurationUtils.format({ hours = 2 }, "h:mm [left]", { trim = "both" })).toBe("2 left") + expect(TimeDurationUtils.format({ days = 2 }, "d [days] h:mm:ss", { trim = "both" })).toBe("2") + end) + + it("should keep pinned tokens", function() + expect(TimeDurationUtils.format(45, "h:mm:ss", { stopTrim = "m" })).toBe("0:45") + expect(TimeDurationUtils.format(45, "h:*mm:ss")).toBe("0:45") + expect(TimeDurationUtils.format(45, "*h:mm:ss")).toBe("0:00:45") + end) + + it("should round the smallest token unless truncating", function() + expect(TimeDurationUtils.format(59.6, "m:ss")).toBe("1:00") + expect(TimeDurationUtils.format(59.6, "m:ss", { trunc = true })).toBe("59") + expect(TimeDurationUtils.format(90, "m")).toBe("2") + expect(TimeDurationUtils.format(90, "m", { trunc = true })).toBe("1") + expect(TimeDurationUtils.format(29, "m")).toBe("0") + expect(TimeDurationUtils.format(0.3, "SSS")).toBe("300") + end) + + it("should format centiseconds", function() + expect(TimeDurationUtils.format(65.432, "mm:ss:CC", { trunc = true })).toBe("01:05:43") + expect(TimeDurationUtils.format(65.438, "mm:ss:CC")).toBe("01:05:44") + expect(TimeDurationUtils.format(0.05, "hh:mm:ss:CC", { trim = false })).toBe("00:00:00:05") + expect(TimeDurationUtils.format(1.234, "s.CC.S", { trunc = true })).toBe("1.23.4") + expect(TimeDurationUtils.format(2, "CC")).toBe("200") + expect(function() + TimeDurationUtils.format(1, "C __") + end).toThrow() + end) + + it("should print decimals on the smallest token with precision", function() + expect(TimeDurationUtils.format(90, "m", { precision = 1 })).toBe("1.5") + expect(TimeDurationUtils.format({ hours = 1, minutes = 30 }, "h", { precision = 2 })).toBe("1.50") + expect(TimeDurationUtils.format(1234, "s", { precision = -2 })).toBe("1200") + expect(TimeDurationUtils.format(90, "m __", { precision = 1 })).toBe("1.5 minutes") + expect(TimeDurationUtils.format(3700, "m:ss", { precision = -2 })).toBe("61:40") + end) + + it("should keep only the largest tokens when asked", function() + expect(TimeDurationUtils.format({ days = 1, minutes = 5 }, "d __, h __, m __, s __", { largest = 2 })).toBe( + "1 day, 5 minutes" + ) + expect(TimeDurationUtils.format({ days = 1, hours = 2, minutes = 5 }, "d __, h __, m __", { largest = 2 })).toBe( + "1 day, 2 hours" + ) + expect(TimeDurationUtils.format({ days = 1, hours = 2 }, "d __, h __, m __", { largest = 1 })).toBe("1 day") + end) + + it("should let a token count up to its limit before using the next", function() + local limits: { [string]: number } = { minutes = 60 } + expect(TimeDurationUtils.format(HOUR, "h:mm:ss", { limits = limits })).toBe("60:00") + expect(TimeDurationUtils.format(HOUR + 59, "h:mm:ss", { limits = limits })).toBe("60:59") + expect(TimeDurationUtils.format(HOUR + MINUTE, "h:mm:ss", { limits = limits })).toBe("1:01:00") + expect(TimeDurationUtils.format(HOUR, "*h:mm:ss", { limits = limits })).toBe("1:00:00") + expect(TimeDurationUtils.format(47 * HOUR, "d __ h:mm:ss", { limits = { hours = 47 } })).toBe("47:00:00") + expect(TimeDurationUtils.format(2 * DAY - 1, "d __ h:mm:ss", { limits = { hours = 47 } })).toBe("47:59:59") + expect(TimeDurationUtils.format(2 * DAY, "d __ h:mm:ss", { limits = { hours = 47 } })).toBe("2 days 0:00:00") + expect(TimeDurationUtils.format({ days = 1, hours = 1 }, "d __ h __ m __", { + limits = { hours = 47, minutes = 1500 }, + })).toBe("1500 minutes") + end) + + it("should clamp to minValue and maxValue", function() + expect(TimeDurationUtils.format(30, "m", { minValue = 1 })).toBe("< 1") + expect(TimeDurationUtils.format(30, "m __", { minValue = 1 })).toBe("< 1 minute") + expect(TimeDurationUtils.format(90, "m __", { minValue = 1 })).toBe("2 minutes") + expect(TimeDurationUtils.format({ days = 400 }, "d __", { maxValue = 365 })).toBe("> 365 days") + end) + + it("should put the sign before the first shown token", function() + expect(TimeDurationUtils.format(-(HOUR + MINUTE + SECOND), "h:mm:ss")).toBe("-1:01:01") + expect(TimeDurationUtils.format(-45, "h:mm:ss")).toBe("-45") + expect(TimeDurationUtils.format(-2 * MINUTE, "[T] m __")).toBe("T -2 minutes") + end) + + it("should localize labels and let the locale place the number", function() + expect(TimeDurationUtils.format({ hours = 2 }, "h __", { locale = "es-es" })).toBe("2 horas") + expect(TimeDurationUtils.format({ hours = 1 }, "h __", { locale = "fr-fr" })).toBe("1 heure") + expect(TimeDurationUtils.format({ days = 5 }, "d __", { locale = "ru-ru" })).toBe("5 дней") + expect(TimeDurationUtils.format({ days = 2 }, "d__", { locale = "ko-kr" })).toBe("2일") + expect(TimeDurationUtils.format({ days = 2 }, "d __", { locale = "ko-kr" })).toBe("2일") + expect(TimeDurationUtils.format({ weeks = 2 }, "w __", { locale = "de-de" })).toBe("2 Wochen") + local strings: TimeDurationUtils.DurationStringOverrides = { + hours = { one = "%d hr", other = "%d hrs" }, + } + expect(TimeDurationUtils.format({ hours = 2, minutes = 30 }, "h __ m __", { strings = strings })).toBe( + "2 hrs 30 minutes" + ) + end) + + it("should pick a template from the size of the duration", function() + expect(TimeDurationUtils.format(0)).toBe("0 seconds") + expect(TimeDurationUtils.format(0.25)).toBe("250 milliseconds") + expect(TimeDurationUtils.format(45)).toBe("0:45") + expect(TimeDurationUtils.format(123 * MINUTE)).toBe("2:03:00") + expect(TimeDurationUtils.format({ days = 3 })).toBe("3 days") + expect(TimeDurationUtils.format({ days = 14 })).toBe("2 weeks") + expect(TimeDurationUtils.format({ days = 10, hours = 2 })).toBe("1 week, 3 days, 2 hours") + expect(TimeDurationUtils.format({ days = 1, hours = 2 })).toBe("1 day, 2 hours") + expect(TimeDurationUtils.format({ years = 2 })).toBe("2 years") + expect(TimeDurationUtils.format({ years = 1, months = 2, days = 3 })).toBe("1 year, 2 months, 3 days") + end) + + it("should reject a template without tokens", function() + expect(function() + TimeDurationUtils.format(5, "[none]") + end).toThrow() + end) +end) + +describe("TimeDurationUtils.formatUnit", function() + it("should pluralize the amount as given", function() + expect(TimeDurationUtils.formatUnit("days", 1)).toBe("1 day") + expect(TimeDurationUtils.formatUnit("days", 45)).toBe("45 days") + expect(TimeDurationUtils.formatUnit("hours", 0)).toBe("0 hours") + expect(TimeDurationUtils.formatUnit("h", 36)).toBe("36 hours") + expect(TimeDurationUtils.formatUnit("ms", 250)).toBe("250 milliseconds") + expect(TimeDurationUtils.formatUnit("weeks", 2)).toBe("2 weeks") + end) + + it("should keep fractional and negative amounts", function() + expect(TimeDurationUtils.formatUnit("minutes", 1.5)).toBe("1.5 minutes") + expect(TimeDurationUtils.formatUnit("minutes", -1)).toBe("-1 minute") + end) + + it("should localize and accept overrides", function() + expect(TimeDurationUtils.formatUnit("days", 2, { locale = "es-es" })).toBe("2 días") + expect(TimeDurationUtils.formatUnit("days", 2, { locale = "ru-ru" })).toBe("2 дня") + local strings: TimeDurationUtils.DurationStringOverrides = { + days = { one = "%dd", other = "%dd" }, + } + expect(TimeDurationUtils.formatUnit("days", 2, { strings = strings })).toBe("2d") + end) + + it("should reject units without a phrase", function() + expect(function() + TimeDurationUtils.formatUnit("quarters", 2) + end).toThrow() + end) +end) + +describe("TimeDurationUtils.humanize", function() + it("should describe the duration in words", function() + expect(TimeDurationUtils.humanize(HOUR)).toBe("an hour") + expect(TimeDurationUtils.humanize({ days = 2 })).toBe("2 days") + expect(TimeDurationUtils.humanize(45 * SECOND)).toBe("a minute") + expect(TimeDurationUtils.humanize("PT3H")).toBe("3 hours") + end) + + it("should add a suffix by direction", function() + expect(TimeDurationUtils.humanize(HOUR, true)).toBe("in an hour") + expect(TimeDurationUtils.humanize(-HOUR, true)).toBe("an hour ago") + end) + + it("should pass relative time options through", function() + local hourStrings: RelativeTimeUtils.RelativeTimeStringOverrides = { h = "one hour" } + local futureStrings: RelativeTimeUtils.RelativeTimeStringOverrides = { future = "%s from now" } + + expect(TimeDurationUtils.humanize(HOUR, false, { strings = hourStrings })).toBe("one hour") + expect(TimeDurationUtils.humanize(HOUR, true, { strings = futureStrings })).toBe("an hour from now") + expect(TimeDurationUtils.humanize(HOUR, true, { locale = "fr-fr" })).toBe("dans une heure") + end) +end) + +describe("TimeDurationUtils.toIsoString", function() + it("should format ISO 8601 durations", function() + expect(TimeDurationUtils.toIsoString({ hours = 1, minutes = 30 })).toBe("PT1H30M") + expect(TimeDurationUtils.toIsoString({ days = 1 })).toBe("P1D") + expect(TimeDurationUtils.toIsoString({ weeks = 1 })).toBe("P7D") + expect(TimeDurationUtils.toIsoString({ years = 1, months = 2 })).toBe("P1Y2M") + expect(TimeDurationUtils.toIsoString(1.5)).toBe("PT1.5S") + expect(TimeDurationUtils.toIsoString(0)).toBe("P0D") + expect(TimeDurationUtils.toIsoString(-5 * SECOND)).toBe("-PT5S") + end) + + it("should round trip", function() + for _, iso in { "PT1H30M", "P1DT12H", "P1Y2M3DT4H5M6S", "PT1.5S" } do + expect(TimeDurationUtils.toIsoString(iso)).toBe(iso) + end + end) +end) diff --git a/src/time/src/Shared/TimeLocalizationUtils.lua b/src/time/src/Shared/TimeLocalizationUtils.lua new file mode 100644 index 00000000000..9128dea016a --- /dev/null +++ b/src/time/src/Shared/TimeLocalizationUtils.lua @@ -0,0 +1,964 @@ +--!strict +--[=[ + Locale strings for [TimeCalendarUtils], [RelativeTimeUtils] and [TimeDurationUtils], covering + the same locales as [NumberLocalizationUtils]. Lookups resolve through [ResolveLocaleUtils], + so `en-gb` lands on English and `es-mx` on Spanish, and an unknown locale falls back to English + with a warning. + + To add a locale, add one entry to `LOCALES`. Every field is required, so a missing phrase is a + type error rather than a runtime surprise. + + @class TimeLocalizationUtils +]=] + +local require = require(script.Parent.loader).load(script) + +local ResolveLocaleUtils = require("ResolveLocaleUtils") + +local TimeLocalizationUtils = {} + +local DEFAULT_LOCALE = "en-us" + +--[=[ + A [Time.format] template, or a function given the time and the reference time that returns + the finished text. + + @type CalendarFormat string | (dateTime: DateTime, referenceTime: DateTime) -> string + @within TimeLocalizationUtils +]=] +export type CalendarFormat = string | (DateTime, DateTime) -> string + +--[=[ + The phrase [TimeCalendarUtils.calendar] uses for each distance from the reference day. + + @interface CalendarFormats + .sameDay CalendarFormat + .nextDay CalendarFormat + .nextWeek CalendarFormat + .lastDay CalendarFormat + .lastWeek CalendarFormat + .sameElse CalendarFormat + @within TimeLocalizationUtils +]=] +export type CalendarFormats = { + sameDay: CalendarFormat, + nextDay: CalendarFormat, + nextWeek: CalendarFormat, + lastDay: CalendarFormat, + lastWeek: CalendarFormat, + sameElse: CalendarFormat, +} + +--[=[ + A partial [CalendarFormats] a caller passes to override a locale's phrases. + + @type CalendarFormatOverrides { sameDay: CalendarFormat?, nextDay: CalendarFormat?, nextWeek: CalendarFormat?, lastDay: CalendarFormat?, lastWeek: CalendarFormat?, sameElse: CalendarFormat? } + @within TimeLocalizationUtils +]=] +export type CalendarFormatOverrides = { + sameDay: CalendarFormat?, + nextDay: CalendarFormat?, + nextWeek: CalendarFormat?, + lastDay: CalendarFormat?, + lastWeek: CalendarFormat?, + sameElse: CalendarFormat?, +} + +--[=[ + A relative time string with `%d` for the amount, or a function given the + amount, whether a suffix will be added, the threshold key and whether the time is in the + future, for languages that inflect. + + @type RelativeTimeString string | (amount: number, withoutSuffix: boolean, key: string, isFuture: boolean) -> string + @within TimeLocalizationUtils +]=] +export type RelativeTimeString = string | (number, boolean, string, boolean) -> string + +--[=[ + Relative time strings for every threshold key. `future` and `past` take `%s`. + + @interface RelativeTimeStrings + .future string + .past string + .s RelativeTimeString + .m RelativeTimeString + .mm RelativeTimeString + .h RelativeTimeString + .hh RelativeTimeString + .d RelativeTimeString + .dd RelativeTimeString + .M RelativeTimeString + .MM RelativeTimeString + .y RelativeTimeString + .yy RelativeTimeString + @within TimeLocalizationUtils +]=] +export type RelativeTimeStrings = { + future: string, + past: string, + s: RelativeTimeString, + m: RelativeTimeString, + mm: RelativeTimeString, + h: RelativeTimeString, + hh: RelativeTimeString, + d: RelativeTimeString, + dd: RelativeTimeString, + M: RelativeTimeString, + MM: RelativeTimeString, + y: RelativeTimeString, + yy: RelativeTimeString, +} + +--[=[ + Relative time strings a caller passes to override or extend a locale's, keyed by threshold + key. Custom thresholds may add their own keys here. + + @type RelativeTimeStringOverrides { [string]: RelativeTimeString } + @within TimeLocalizationUtils +]=] +export type RelativeTimeStringOverrides = { [string]: RelativeTimeString } + +--[=[ + A duration phrase with `%d` for the amount: a singular and plural pair, or a function of the + amount for languages with more plural forms. + + @type DurationPhrase { one: string, other: string } | (amount: number) -> string + @within TimeLocalizationUtils +]=] +export type DurationPhrase = { one: string, other: string } | (number) -> string + +--[=[ + Duration phrases for every breakdown field. + + @interface DurationStrings + .years DurationPhrase + .months DurationPhrase + .weeks DurationPhrase + .days DurationPhrase + .hours DurationPhrase + .minutes DurationPhrase + .seconds DurationPhrase + .milliseconds DurationPhrase + @within TimeLocalizationUtils +]=] +export type DurationStrings = { + years: DurationPhrase, + months: DurationPhrase, + weeks: DurationPhrase, + days: DurationPhrase, + hours: DurationPhrase, + minutes: DurationPhrase, + seconds: DurationPhrase, + milliseconds: DurationPhrase, +} + +--[=[ + Duration phrases a caller passes to override a locale's, keyed by breakdown field. + + @type DurationStringOverrides { [string]: DurationPhrase } + @within TimeLocalizationUtils +]=] +export type DurationStringOverrides = { [string]: DurationPhrase } + +--[=[ + Everything one locale needs. + + @interface TimeLocale + .calendar CalendarFormats + .relativeTime RelativeTimeStrings + .duration DurationStrings + @within TimeLocalizationUtils +]=] +export type TimeLocale = { + calendar: CalendarFormats, + relativeTime: RelativeTimeStrings, + duration: DurationStrings, +} + +function TimeLocalizationUtils._withAmount(amount: number, template: string): string + return (string.gsub(template, "%%d", tostring(amount))) +end + +-- Languages without grammatical number use the same phrase for every amount +function TimeLocalizationUtils._invariantPhrase(template: string): DurationPhrase + return { one = template, other = template } +end + +-- Russian style: 1, 21, 31 are singular; 2-4, 22-24 are few; the rest (incl. 11-14) are many +function TimeLocalizationUtils._russianPlural(amount: number, one: string, few: string, many: string): string + local tens = amount % 100 + local ones = amount % 10 + if ones == 1 and tens ~= 11 then + return one + elseif ones >= 2 and ones <= 4 and (tens < 10 or tens >= 20) then + return few + else + return many + end +end + +-- Polish style: only exactly 1 is singular; 2-4, 22-24 are few; the rest are many +function TimeLocalizationUtils._polishPlural(amount: number, one: string, few: string, many: string): string + if amount == 1 then + return one + end + + local tens = amount % 100 + local ones = amount % 10 + if ones >= 2 and ones <= 4 and (tens < 10 or tens >= 20) then + return few + else + return many + end +end + +function TimeLocalizationUtils._russianDuration(one: string, few: string, many: string): DurationPhrase + return function(amount: number): string + return amount .. " " .. TimeLocalizationUtils._russianPlural(math.abs(amount), one, few, many) + end +end + +function TimeLocalizationUtils._polishDuration(one: string, few: string, many: string): DurationPhrase + return function(amount: number): string + return amount .. " " .. TimeLocalizationUtils._polishPlural(math.abs(amount), one, few, many) + end +end + +-- German: nominative on its own, dative after "in" / "vor" +local GERMAN_RELATIVE_FORMS: { [string]: { string } } = { + m = { "eine Minute", "einer Minute" }, + h = { "eine Stunde", "einer Stunde" }, + d = { "ein Tag", "einem Tag" }, + dd = { "%d Tage", "%d Tagen" }, + M = { "ein Monat", "einem Monat" }, + MM = { "%d Monate", "%d Monaten" }, + y = { "ein Jahr", "einem Jahr" }, + yy = { "%d Jahre", "%d Jahren" }, +} + +function TimeLocalizationUtils._germanRelative( + amount: number, + withoutSuffix: boolean, + key: string, + _isFuture: boolean +): string + local forms = GERMAN_RELATIVE_FORMS[key] + return TimeLocalizationUtils._withAmount(amount, if withoutSuffix then forms[1] else forms[2]) +end + +local RUSSIAN_RELATIVE_FORMS: { [string]: { string } } = { + mm = { "минута", "минуты", "минут" }, + hh = { "час", "часа", "часов" }, + dd = { "день", "дня", "дней" }, + MM = { "месяц", "месяца", "месяцев" }, + yy = { "год", "года", "лет" }, +} + +function TimeLocalizationUtils._russianRelative( + amount: number, + withoutSuffix: boolean, + key: string, + _isFuture: boolean +): string + if key == "m" then + return if withoutSuffix then "минута" else "минуту" + end + + local forms = RUSSIAN_RELATIVE_FORMS[key] + -- "минуту" is the accusative used after "через" / before "назад" + local one = if key == "mm" and not withoutSuffix then "минуту" else forms[1] + return amount .. " " .. TimeLocalizationUtils._russianPlural(amount, one, forms[2], forms[3]) +end + +function TimeLocalizationUtils._polishRelative( + amount: number, + withoutSuffix: boolean, + key: string, + _isFuture: boolean +): string + if key == "m" then + return if withoutSuffix then "minuta" else "minutę" + elseif key == "h" then + return if withoutSuffix then "godzina" else "godzinę" + elseif key == "mm" then + return amount .. " " .. TimeLocalizationUtils._polishPlural(amount, "minuta", "minuty", "minut") + elseif key == "hh" then + return amount .. " " .. TimeLocalizationUtils._polishPlural(amount, "godzina", "godziny", "godzin") + elseif key == "MM" then + return amount .. " " .. TimeLocalizationUtils._polishPlural(amount, "miesiąc", "miesiące", "miesięcy") + elseif key == "yy" then + return amount .. " " .. TimeLocalizationUtils._polishPlural(amount, "rok", "lata", "lat") + else + error(string.format("No Polish relative form for %q", key)) + end +end + +local LOCALES: { [string]: TimeLocale } = { + en = { + calendar = { + sameDay = "[Today at] LT", + nextDay = "[Tomorrow at] LT", + nextWeek = "dddd [at] LT", + lastDay = "[Yesterday at] LT", + lastWeek = "[Last] dddd [at] LT", + sameElse = "L", + }, + relativeTime = { + future = "in %s", + past = "%s ago", + s = "a few seconds", + m = "a minute", + mm = "%d minutes", + h = "an hour", + hh = "%d hours", + d = "a day", + dd = "%d days", + M = "a month", + MM = "%d months", + y = "a year", + yy = "%d years", + }, + duration = { + years = { one = "%d year", other = "%d years" }, + months = { one = "%d month", other = "%d months" }, + weeks = { one = "%d week", other = "%d weeks" }, + days = { one = "%d day", other = "%d days" }, + hours = { one = "%d hour", other = "%d hours" }, + minutes = { one = "%d minute", other = "%d minutes" }, + seconds = { one = "%d second", other = "%d seconds" }, + milliseconds = { one = "%d millisecond", other = "%d milliseconds" }, + }, + }, + + es = { + calendar = { + sameDay = "[hoy a las] LT", + nextDay = "[mañana a las] LT", + nextWeek = "dddd [a las] LT", + lastDay = "[ayer a las] LT", + lastWeek = "[el] dddd [pasado a las] LT", + sameElse = "L", + }, + relativeTime = { + future = "en %s", + past = "hace %s", + s = "unos segundos", + m = "un minuto", + mm = "%d minutos", + h = "una hora", + hh = "%d horas", + d = "un día", + dd = "%d días", + M = "un mes", + MM = "%d meses", + y = "un año", + yy = "%d años", + }, + duration = { + years = { one = "%d año", other = "%d años" }, + months = { one = "%d mes", other = "%d meses" }, + weeks = { one = "%d semana", other = "%d semanas" }, + days = { one = "%d día", other = "%d días" }, + hours = { one = "%d hora", other = "%d horas" }, + minutes = { one = "%d minuto", other = "%d minutos" }, + seconds = { one = "%d segundo", other = "%d segundos" }, + milliseconds = { one = "%d milisegundo", other = "%d milisegundos" }, + }, + }, + + fr = { + calendar = { + sameDay = "[Aujourd’hui à] LT", + nextDay = "[Demain à] LT", + nextWeek = "dddd [à] LT", + lastDay = "[Hier à] LT", + lastWeek = "dddd [dernier à] LT", + sameElse = "L", + }, + relativeTime = { + future = "dans %s", + past = "il y a %s", + s = "quelques secondes", + m = "une minute", + mm = "%d minutes", + h = "une heure", + hh = "%d heures", + d = "un jour", + dd = "%d jours", + M = "un mois", + MM = "%d mois", + y = "un an", + yy = "%d ans", + }, + duration = { + years = { one = "%d an", other = "%d ans" }, + months = { one = "%d mois", other = "%d mois" }, + weeks = { one = "%d semaine", other = "%d semaines" }, + days = { one = "%d jour", other = "%d jours" }, + hours = { one = "%d heure", other = "%d heures" }, + minutes = { one = "%d minute", other = "%d minutes" }, + seconds = { one = "%d seconde", other = "%d secondes" }, + milliseconds = { one = "%d milliseconde", other = "%d millisecondes" }, + }, + }, + + de = { + calendar = { + sameDay = "[heute um] LT [Uhr]", + nextDay = "[morgen um] LT [Uhr]", + nextWeek = "dddd [um] LT [Uhr]", + lastDay = "[gestern um] LT [Uhr]", + lastWeek = "[letzten] dddd [um] LT [Uhr]", + sameElse = "L", + }, + relativeTime = { + future = "in %s", + past = "vor %s", + s = "ein paar Sekunden", + m = TimeLocalizationUtils._germanRelative, + mm = "%d Minuten", + h = TimeLocalizationUtils._germanRelative, + hh = "%d Stunden", + d = TimeLocalizationUtils._germanRelative, + dd = TimeLocalizationUtils._germanRelative, + M = TimeLocalizationUtils._germanRelative, + MM = TimeLocalizationUtils._germanRelative, + y = TimeLocalizationUtils._germanRelative, + yy = TimeLocalizationUtils._germanRelative, + }, + duration = { + years = { one = "%d Jahr", other = "%d Jahre" }, + months = { one = "%d Monat", other = "%d Monate" }, + weeks = { one = "%d Woche", other = "%d Wochen" }, + days = { one = "%d Tag", other = "%d Tage" }, + hours = { one = "%d Stunde", other = "%d Stunden" }, + minutes = { one = "%d Minute", other = "%d Minuten" }, + seconds = { one = "%d Sekunde", other = "%d Sekunden" }, + milliseconds = { one = "%d Millisekunde", other = "%d Millisekunden" }, + }, + }, + + pt = { + calendar = { + sameDay = "[Hoje às] LT", + nextDay = "[Amanhã às] LT", + nextWeek = "dddd [às] LT", + lastDay = "[Ontem às] LT", + lastWeek = "dddd [passado às] LT", + sameElse = "L", + }, + relativeTime = { + future = "em %s", + past = "há %s", + s = "poucos segundos", + m = "um minuto", + mm = "%d minutos", + h = "uma hora", + hh = "%d horas", + d = "um dia", + dd = "%d dias", + M = "um mês", + MM = "%d meses", + y = "um ano", + yy = "%d anos", + }, + duration = { + years = { one = "%d ano", other = "%d anos" }, + months = { one = "%d mês", other = "%d meses" }, + weeks = { one = "%d semana", other = "%d semanas" }, + days = { one = "%d dia", other = "%d dias" }, + hours = { one = "%d hora", other = "%d horas" }, + minutes = { one = "%d minuto", other = "%d minutos" }, + seconds = { one = "%d segundo", other = "%d segundos" }, + milliseconds = { one = "%d milissegundo", other = "%d milissegundos" }, + }, + }, + + it = { + calendar = { + sameDay = "[Oggi alle] LT", + nextDay = "[Domani alle] LT", + nextWeek = "dddd [alle] LT", + lastDay = "[Ieri alle] LT", + lastWeek = "[lo scorso] dddd [alle] LT", + sameElse = "L", + }, + relativeTime = { + future = "tra %s", + past = "%s fa", + s = "qualche secondo", + m = "un minuto", + mm = "%d minuti", + h = "un'ora", + hh = "%d ore", + d = "un giorno", + dd = "%d giorni", + M = "un mese", + MM = "%d mesi", + y = "un anno", + yy = "%d anni", + }, + duration = { + years = { one = "%d anno", other = "%d anni" }, + months = { one = "%d mese", other = "%d mesi" }, + weeks = { one = "%d settimana", other = "%d settimane" }, + days = { one = "%d giorno", other = "%d giorni" }, + hours = { one = "%d ora", other = "%d ore" }, + minutes = { one = "%d minuto", other = "%d minuti" }, + seconds = { one = "%d secondo", other = "%d secondi" }, + milliseconds = { one = "%d millisecondo", other = "%d millisecondi" }, + }, + }, + + ru = { + calendar = { + sameDay = "[Сегодня, в] LT", + nextDay = "[Завтра, в] LT", + nextWeek = "[В следующий] dddd, [в] LT", + lastDay = "[Вчера, в] LT", + lastWeek = "[В прошлый] dddd, [в] LT", + sameElse = "L", + }, + relativeTime = { + future = "через %s", + past = "%s назад", + s = "несколько секунд", + m = TimeLocalizationUtils._russianRelative, + mm = TimeLocalizationUtils._russianRelative, + h = "час", + hh = TimeLocalizationUtils._russianRelative, + d = "день", + dd = TimeLocalizationUtils._russianRelative, + M = "месяц", + MM = TimeLocalizationUtils._russianRelative, + y = "год", + yy = TimeLocalizationUtils._russianRelative, + }, + duration = { + years = TimeLocalizationUtils._russianDuration("год", "года", "лет"), + months = TimeLocalizationUtils._russianDuration("месяц", "месяца", "месяцев"), + weeks = TimeLocalizationUtils._russianDuration("неделя", "недели", "недель"), + days = TimeLocalizationUtils._russianDuration("день", "дня", "дней"), + hours = TimeLocalizationUtils._russianDuration("час", "часа", "часов"), + minutes = TimeLocalizationUtils._russianDuration("минута", "минуты", "минут"), + seconds = TimeLocalizationUtils._russianDuration("секунда", "секунды", "секунд"), + milliseconds = TimeLocalizationUtils._russianDuration( + "миллисекунда", + "миллисекунды", + "миллисекунд" + ), + }, + }, + + pl = { + calendar = { + sameDay = "[Dziś o] LT", + nextDay = "[Jutro o] LT", + nextWeek = "[W] dddd [o] LT", + lastDay = "[Wczoraj o] LT", + lastWeek = "[W zeszły] dddd [o] LT", + sameElse = "L", + }, + relativeTime = { + future = "za %s", + past = "%s temu", + s = "kilka sekund", + m = TimeLocalizationUtils._polishRelative, + mm = TimeLocalizationUtils._polishRelative, + h = TimeLocalizationUtils._polishRelative, + hh = TimeLocalizationUtils._polishRelative, + d = "1 dzień", + dd = "%d dni", + M = "miesiąc", + MM = TimeLocalizationUtils._polishRelative, + y = "rok", + yy = TimeLocalizationUtils._polishRelative, + }, + duration = { + years = TimeLocalizationUtils._polishDuration("rok", "lata", "lat"), + months = TimeLocalizationUtils._polishDuration("miesiąc", "miesiące", "miesięcy"), + weeks = TimeLocalizationUtils._polishDuration("tydzień", "tygodnie", "tygodni"), + days = TimeLocalizationUtils._polishDuration("dzień", "dni", "dni"), + hours = TimeLocalizationUtils._polishDuration("godzina", "godziny", "godzin"), + minutes = TimeLocalizationUtils._polishDuration("minuta", "minuty", "minut"), + seconds = TimeLocalizationUtils._polishDuration("sekunda", "sekundy", "sekund"), + milliseconds = TimeLocalizationUtils._polishDuration("milisekunda", "milisekundy", "milisekund"), + }, + }, + + tr = { + calendar = { + sameDay = "[bugün saat] LT", + nextDay = "[yarın saat] LT", + nextWeek = "[gelecek] dddd [saat] LT", + lastDay = "[dün] LT", + lastWeek = "[geçen] dddd [saat] LT", + sameElse = "L", + }, + relativeTime = { + future = "%s sonra", + past = "%s önce", + s = "birkaç saniye", + m = "bir dakika", + mm = "%d dakika", + h = "bir saat", + hh = "%d saat", + d = "bir gün", + dd = "%d gün", + M = "bir ay", + MM = "%d ay", + y = "bir yıl", + yy = "%d yıl", + }, + duration = { + years = TimeLocalizationUtils._invariantPhrase("%d yıl"), + months = TimeLocalizationUtils._invariantPhrase("%d ay"), + weeks = TimeLocalizationUtils._invariantPhrase("%d hafta"), + days = TimeLocalizationUtils._invariantPhrase("%d gün"), + hours = TimeLocalizationUtils._invariantPhrase("%d saat"), + minutes = TimeLocalizationUtils._invariantPhrase("%d dakika"), + seconds = TimeLocalizationUtils._invariantPhrase("%d saniye"), + milliseconds = TimeLocalizationUtils._invariantPhrase("%d milisaniye"), + }, + }, + + id = { + calendar = { + sameDay = "[Hari ini pukul] LT", + nextDay = "[Besok pukul] LT", + nextWeek = "dddd [pukul] LT", + lastDay = "[Kemarin pukul] LT", + lastWeek = "dddd [lalu pukul] LT", + sameElse = "L", + }, + relativeTime = { + future = "dalam %s", + past = "%s yang lalu", + s = "beberapa detik", + m = "semenit", + mm = "%d menit", + h = "sejam", + hh = "%d jam", + d = "sehari", + dd = "%d hari", + M = "sebulan", + MM = "%d bulan", + y = "setahun", + yy = "%d tahun", + }, + duration = { + years = TimeLocalizationUtils._invariantPhrase("%d tahun"), + months = TimeLocalizationUtils._invariantPhrase("%d bulan"), + weeks = TimeLocalizationUtils._invariantPhrase("%d minggu"), + days = TimeLocalizationUtils._invariantPhrase("%d hari"), + hours = TimeLocalizationUtils._invariantPhrase("%d jam"), + minutes = TimeLocalizationUtils._invariantPhrase("%d menit"), + seconds = TimeLocalizationUtils._invariantPhrase("%d detik"), + milliseconds = TimeLocalizationUtils._invariantPhrase("%d milidetik"), + }, + }, + + vi = { + calendar = { + sameDay = "[Hôm nay lúc] LT", + nextDay = "[Ngày mai lúc] LT", + nextWeek = "dddd [tuần tới lúc] LT", + lastDay = "[Hôm qua lúc] LT", + lastWeek = "dddd [tuần trước lúc] LT", + sameElse = "L", + }, + relativeTime = { + future = "%s tới", + past = "%s trước", + s = "vài giây", + m = "một phút", + mm = "%d phút", + h = "một giờ", + hh = "%d giờ", + d = "một ngày", + dd = "%d ngày", + M = "một tháng", + MM = "%d tháng", + y = "một năm", + yy = "%d năm", + }, + duration = { + years = TimeLocalizationUtils._invariantPhrase("%d năm"), + months = TimeLocalizationUtils._invariantPhrase("%d tháng"), + weeks = TimeLocalizationUtils._invariantPhrase("%d tuần"), + days = TimeLocalizationUtils._invariantPhrase("%d ngày"), + hours = TimeLocalizationUtils._invariantPhrase("%d giờ"), + minutes = TimeLocalizationUtils._invariantPhrase("%d phút"), + seconds = TimeLocalizationUtils._invariantPhrase("%d giây"), + milliseconds = TimeLocalizationUtils._invariantPhrase("%d mili giây"), + }, + }, + + th = { + calendar = { + sameDay = "[วันนี้ เวลา] LT", + nextDay = "[พรุ่งนี้ เวลา] LT", + nextWeek = "dddd[หน้า เวลา] LT", + lastDay = "[เมื่อวานนี้ เวลา] LT", + lastWeek = "[วัน]dddd[ที่แล้ว เวลา] LT", + sameElse = "L", + }, + relativeTime = { + future = "อีก %s", + past = "%sที่แล้ว", + s = "ไม่กี่วินาที", + m = "1 นาที", + mm = "%d นาที", + h = "1 ชั่วโมง", + hh = "%d ชั่วโมง", + d = "1 วัน", + dd = "%d วัน", + M = "1 เดือน", + MM = "%d เดือน", + y = "1 ปี", + yy = "%d ปี", + }, + duration = { + years = TimeLocalizationUtils._invariantPhrase("%d ปี"), + months = TimeLocalizationUtils._invariantPhrase("%d เดือน"), + weeks = TimeLocalizationUtils._invariantPhrase("%d สัปดาห์"), + days = TimeLocalizationUtils._invariantPhrase("%d วัน"), + hours = TimeLocalizationUtils._invariantPhrase("%d ชั่วโมง"), + minutes = TimeLocalizationUtils._invariantPhrase("%d นาที"), + seconds = TimeLocalizationUtils._invariantPhrase("%d วินาที"), + milliseconds = TimeLocalizationUtils._invariantPhrase("%d มิลลิวินาที"), + }, + }, + + ar = { + calendar = { + sameDay = "[اليوم عند الساعة] LT", + nextDay = "[غدًا عند الساعة] LT", + nextWeek = "dddd [عند الساعة] LT", + lastDay = "[أمس عند الساعة] LT", + lastWeek = "dddd [عند الساعة] LT", + sameElse = "L", + }, + relativeTime = { + future = "بعد %s", + past = "منذ %s", + s = "ثانية واحدة", + m = "دقيقة واحدة", + mm = "%d دقائق", + h = "ساعة واحدة", + hh = "%d ساعات", + d = "يوم واحد", + dd = "%d أيام", + M = "شهر واحد", + MM = "%d أشهر", + y = "عام واحد", + yy = "%d أعوام", + }, + duration = { + years = { one = "%d سنة", other = "%d سنوات" }, + months = { one = "%d شهر", other = "%d أشهر" }, + weeks = { one = "%d أسبوع", other = "%d أسابيع" }, + days = { one = "%d يوم", other = "%d أيام" }, + hours = { one = "%d ساعة", other = "%d ساعات" }, + minutes = { one = "%d دقيقة", other = "%d دقائق" }, + seconds = { one = "%d ثانية", other = "%d ثوان" }, + milliseconds = { one = "%d مللي ثانية", other = "%d مللي ثانية" }, + }, + }, + + ["zh-cn"] = { + calendar = { + sameDay = "[今天]LT", + nextDay = "[明天]LT", + nextWeek = "[下]dddLT", + lastDay = "[昨天]LT", + lastWeek = "[上]dddLT", + sameElse = "L", + }, + relativeTime = { + future = "%s内", + past = "%s前", + s = "几秒", + m = "1 分钟", + mm = "%d 分钟", + h = "1 小时", + hh = "%d 小时", + d = "1 天", + dd = "%d 天", + M = "1 个月", + MM = "%d 个月", + y = "1 年", + yy = "%d 年", + }, + duration = { + years = TimeLocalizationUtils._invariantPhrase("%d 年"), + months = TimeLocalizationUtils._invariantPhrase("%d 个月"), + weeks = TimeLocalizationUtils._invariantPhrase("%d 周"), + days = TimeLocalizationUtils._invariantPhrase("%d 天"), + hours = TimeLocalizationUtils._invariantPhrase("%d 小时"), + minutes = TimeLocalizationUtils._invariantPhrase("%d 分钟"), + seconds = TimeLocalizationUtils._invariantPhrase("%d 秒"), + milliseconds = TimeLocalizationUtils._invariantPhrase("%d 毫秒"), + }, + }, + + ["zh-tw"] = { + calendar = { + sameDay = "[今天] LT", + nextDay = "[明天] LT", + nextWeek = "[下]dddd LT", + lastDay = "[昨天] LT", + lastWeek = "[上]dddd LT", + sameElse = "L", + }, + relativeTime = { + future = "%s後", + past = "%s前", + s = "幾秒", + m = "1 分鐘", + mm = "%d 分鐘", + h = "1 小時", + hh = "%d 小時", + d = "1 天", + dd = "%d 天", + M = "1 個月", + MM = "%d 個月", + y = "1 年", + yy = "%d 年", + }, + duration = { + years = TimeLocalizationUtils._invariantPhrase("%d 年"), + months = TimeLocalizationUtils._invariantPhrase("%d 個月"), + weeks = TimeLocalizationUtils._invariantPhrase("%d 週"), + days = TimeLocalizationUtils._invariantPhrase("%d 天"), + hours = TimeLocalizationUtils._invariantPhrase("%d 小時"), + minutes = TimeLocalizationUtils._invariantPhrase("%d 分鐘"), + seconds = TimeLocalizationUtils._invariantPhrase("%d 秒"), + milliseconds = TimeLocalizationUtils._invariantPhrase("%d 毫秒"), + }, + }, + + ko = { + calendar = { + sameDay = "[오늘] LT", + nextDay = "[내일] LT", + nextWeek = "dddd LT", + lastDay = "[어제] LT", + lastWeek = "[지난주] dddd LT", + sameElse = "L", + }, + relativeTime = { + future = "%s 후", + past = "%s 전", + s = "몇 초", + m = "1분", + mm = "%d분", + h = "한 시간", + hh = "%d시간", + d = "하루", + dd = "%d일", + M = "한 달", + MM = "%d달", + y = "일 년", + yy = "%d년", + }, + duration = { + years = TimeLocalizationUtils._invariantPhrase("%d년"), + months = TimeLocalizationUtils._invariantPhrase("%d개월"), + weeks = TimeLocalizationUtils._invariantPhrase("%d주"), + days = TimeLocalizationUtils._invariantPhrase("%d일"), + hours = TimeLocalizationUtils._invariantPhrase("%d시간"), + minutes = TimeLocalizationUtils._invariantPhrase("%d분"), + seconds = TimeLocalizationUtils._invariantPhrase("%d초"), + milliseconds = TimeLocalizationUtils._invariantPhrase("%d밀리초"), + }, + }, + + ja = { + calendar = { + sameDay = "[今日] LT", + nextDay = "[明日] LT", + nextWeek = "[来週]dddd LT", + lastDay = "[昨日] LT", + lastWeek = "[先週]dddd LT", + sameElse = "L", + }, + relativeTime = { + future = "%s後", + past = "%s前", + s = "数秒", + m = "1分", + mm = "%d分", + h = "1時間", + hh = "%d時間", + d = "1日", + dd = "%d日", + M = "1ヶ月", + MM = "%dヶ月", + y = "1年", + yy = "%d年", + }, + duration = { + years = TimeLocalizationUtils._invariantPhrase("%d年"), + months = TimeLocalizationUtils._invariantPhrase("%dヶ月"), + weeks = TimeLocalizationUtils._invariantPhrase("%d週間"), + days = TimeLocalizationUtils._invariantPhrase("%d日"), + hours = TimeLocalizationUtils._invariantPhrase("%d時間"), + minutes = TimeLocalizationUtils._invariantPhrase("%d分"), + seconds = TimeLocalizationUtils._invariantPhrase("%d秒"), + milliseconds = TimeLocalizationUtils._invariantPhrase("%dミリ秒"), + }, + }, +} + +for _, locale in pairs(LOCALES) do + table.freeze(locale.calendar) + table.freeze(locale.relativeTime) + table.freeze(locale.duration) + table.freeze(locale) +end + +-- Roblox's Simplified Chinese variant id, same strings as zh-cn +LOCALES["zh-cjv"] = LOCALES["zh-cn"] + +--[=[ + Returns the read-only [TimeLocale] for a locale, defaulting to English. +]=] +function TimeLocalizationUtils.getLocale(locale: string?): TimeLocale + local key = ResolveLocaleUtils.resolveClosestKey(locale or DEFAULT_LOCALE, LOCALES) + if key then + return LOCALES[key] + end + + warn( + string.format( + "[TimeLocalizationUtils] - No strings for locale '%s', reverting to '%s' instead.", + tostring(locale), + DEFAULT_LOCALE + ) + ) + return LOCALES[ResolveLocaleUtils.resolveClosestKey(DEFAULT_LOCALE, LOCALES) :: string] +end + +--[=[ + Returns the read-only [CalendarFormats] for a locale, defaulting to English. +]=] +function TimeLocalizationUtils.getCalendarFormatsForLocale(locale: string?): CalendarFormats + return TimeLocalizationUtils.getLocale(locale).calendar +end + +--[=[ + Returns the read-only [RelativeTimeStrings] for a locale, defaulting to English. +]=] +function TimeLocalizationUtils.getRelativeTimeStringsForLocale(locale: string?): RelativeTimeStrings + return TimeLocalizationUtils.getLocale(locale).relativeTime +end + +--[=[ + Returns the read-only [DurationStrings] for a locale, defaulting to English. +]=] +function TimeLocalizationUtils.getDurationStringsForLocale(locale: string?): DurationStrings + return TimeLocalizationUtils.getLocale(locale).duration +end + +return TimeLocalizationUtils diff --git a/src/time/src/Shared/TimeLocalizationUtils.spec.lua b/src/time/src/Shared/TimeLocalizationUtils.spec.lua new file mode 100644 index 00000000000..26a25aa9970 --- /dev/null +++ b/src/time/src/Shared/TimeLocalizationUtils.spec.lua @@ -0,0 +1,229 @@ +--!strict +--[[ + @class TimeLocalizationUtils.spec.lua +]] + +local require = require(script.Parent.loader).load(script) + +local Jest = require("Jest") +local TimeLocalizationUtils = require("TimeLocalizationUtils") + +local describe = Jest.Globals.describe +local expect = Jest.Globals.expect +local it = Jest.Globals.it + +-- The locales NumberLocalizationUtils covers +local LOCALES = { + "en-us", + "es-es", + "fr-fr", + "de-de", + "pt-br", + "zh-cn", + "zh-cjv", + "zh-tw", + "ko-kr", + "ja-jp", + "it-it", + "ru-ru", + "id-id", + "vi-vn", + "th-th", + "tr-tr", + "pl-pl", + "ar", +} + +local CALENDAR_KEYS = { "sameDay", "nextDay", "nextWeek", "lastDay", "lastWeek", "sameElse" } +local RELATIVE_KEYS = { "future", "past", "s", "m", "mm", "h", "hh", "d", "dd", "M", "MM", "y", "yy" } +local DURATION_KEYS = { "years", "months", "weeks", "days", "hours", "minutes", "seconds", "milliseconds" } + +describe("TimeLocalizationUtils", function() + it("should default to English", function() + expect(TimeLocalizationUtils.getCalendarFormatsForLocale(nil).sameDay).toBe("[Today at] LT") + expect(TimeLocalizationUtils.getRelativeTimeStringsForLocale(nil).future).toBe("in %s") + expect((TimeLocalizationUtils.getDurationStringsForLocale(nil).hours :: any).other).toBe("%d hours") + end) + + it("should resolve regional variants to their language", function() + expect(TimeLocalizationUtils.getCalendarFormatsForLocale("en-gb")).toBe( + TimeLocalizationUtils.getCalendarFormatsForLocale("en-us") + ) + expect(TimeLocalizationUtils.getRelativeTimeStringsForLocale("es-mx")).toBe( + TimeLocalizationUtils.getRelativeTimeStringsForLocale("es-es") + ) + expect(TimeLocalizationUtils.getDurationStringsForLocale("zh-hant")).toBe( + TimeLocalizationUtils.getDurationStringsForLocale("zh-tw") + ) + expect(TimeLocalizationUtils.getDurationStringsForLocale("zh-hans")).toBe( + TimeLocalizationUtils.getDurationStringsForLocale("zh-cn") + ) + end) + + it("should cover every NumberLocalizationUtils locale completely", function() + for _, locale in LOCALES do + local calendarFormats: any = TimeLocalizationUtils.getCalendarFormatsForLocale(locale) + local relativeTimeStrings: any = TimeLocalizationUtils.getRelativeTimeStringsForLocale(locale) + local durationStrings: any = TimeLocalizationUtils.getDurationStringsForLocale(locale) + + for _, key in CALENDAR_KEYS do + expect(calendarFormats[key]).toEqual(expect.any("string")) + end + for _, key in RELATIVE_KEYS do + expect(relativeTimeStrings[key] ~= nil).toBe(true) + end + for _, key in DURATION_KEYS do + expect(durationStrings[key] ~= nil).toBe(true) + end + end + end) + + it("should differ between languages", function() + expect(TimeLocalizationUtils.getRelativeTimeStringsForLocale("fr-fr").past).toBe("il y a %s") + expect(TimeLocalizationUtils.getRelativeTimeStringsForLocale("ja-jp").past).toBe("%s前") + expect(TimeLocalizationUtils.getCalendarFormatsForLocale("de-de").sameDay).toBe("[heute um] LT [Uhr]") + end) + + it("should expose the whole locale", function() + local locale = TimeLocalizationUtils.getLocale("fr-fr") + + expect(locale.calendar).toBe(TimeLocalizationUtils.getCalendarFormatsForLocale("fr-fr")) + expect(locale.relativeTime).toBe(TimeLocalizationUtils.getRelativeTimeStringsForLocale("fr-fr")) + expect(locale.duration).toBe(TimeLocalizationUtils.getDurationStringsForLocale("fr-fr")) + expect(TimeLocalizationUtils.getLocale("zh-cjv")).toBe(TimeLocalizationUtils.getLocale("zh-cn")) + end) + + it("should return read-only tables", function() + expect(table.isfrozen(TimeLocalizationUtils.getLocale("en-us"))).toBe(true) + expect(table.isfrozen(TimeLocalizationUtils.getCalendarFormatsForLocale("en-us"))).toBe(true) + expect(table.isfrozen(TimeLocalizationUtils.getRelativeTimeStringsForLocale("ru-ru"))).toBe(true) + expect(table.isfrozen(TimeLocalizationUtils.getDurationStringsForLocale("pl-pl"))).toBe(true) + end) +end) + +describe("TimeLocalizationUtils locale resolution", function() + it("should ignore case and accept underscores", function() + local english = TimeLocalizationUtils.getLocale("en-us") + + expect(TimeLocalizationUtils.getLocale("EN-US")).toBe(english) + expect(TimeLocalizationUtils.getLocale("en_US")).toBe(english) + expect(TimeLocalizationUtils.getLocale("En")).toBe(english) + expect(TimeLocalizationUtils.getLocale("Fr-CA")).toBe(TimeLocalizationUtils.getLocale("fr-fr")) + end) + + it("should route Chinese by script and region", function() + local simplified = TimeLocalizationUtils.getLocale("zh-cn") + local traditional = TimeLocalizationUtils.getLocale("zh-tw") + + expect(simplified).never.toBe(traditional) + expect(TimeLocalizationUtils.getLocale("zh")).toBe(simplified) + expect(TimeLocalizationUtils.getLocale("zh-hans")).toBe(simplified) + expect(TimeLocalizationUtils.getLocale("zh-sg")).toBe(simplified) + expect(TimeLocalizationUtils.getLocale("zh-hant")).toBe(traditional) + expect(TimeLocalizationUtils.getLocale("zh-hk")).toBe(traditional) + expect(TimeLocalizationUtils.getLocale("zh-Hant-TW")).toBe(traditional) + end) + + it("should fall back to English for nil, empty and unknown locales", function() + local english = TimeLocalizationUtils.getLocale("en-us") + + expect(TimeLocalizationUtils.getLocale(nil)).toBe(english) + expect(TimeLocalizationUtils.getLocale("")).toBe(english) + expect(TimeLocalizationUtils.getLocale("xx-yy")).toBe(english) + expect(TimeLocalizationUtils.getLocale("123")).toBe(english) + end) + + it("should return the same table on every call", function() + expect(TimeLocalizationUtils.getLocale("ja-jp")).toBe(TimeLocalizationUtils.getLocale("ja-jp")) + expect(TimeLocalizationUtils.getCalendarFormatsForLocale("ja-jp")).toBe( + TimeLocalizationUtils.getCalendarFormatsForLocale("ja") + ) + end) + + it("should reject writes", function() + expect(function() + (TimeLocalizationUtils.getLocale("en-us") :: any).calendar = nil + end).toThrow("readonly") + expect(function() + (TimeLocalizationUtils.getRelativeTimeStringsForLocale("en-us") :: any).s = "now" + end).toThrow("readonly") + end) +end) + +describe("TimeLocalizationUtils string invariants", function() + local AMOUNT_KEYS = { "mm", "hh", "dd", "MM", "yy" } + + it("should give every locale a %s slot in future and past", function() + for _, locale in LOCALES do + local strings = TimeLocalizationUtils.getRelativeTimeStringsForLocale(locale) + + expect(string.find(strings.future, "%%s") ~= nil).toBe(true) + expect(string.find(strings.past, "%%s") ~= nil).toBe(true) + end + end) + + it("should put the amount into every plural relative time string", function() + for _, locale in LOCALES do + local strings: any = TimeLocalizationUtils.getRelativeTimeStringsForLocale(locale) + + for _, key in AMOUNT_KEYS do + local value = strings[key] + if type(value) == "function" then + expect(string.find(value(7, false, key, true), "7", 1, true) ~= nil).toBe(true) + expect(string.find(value(7, true, key, false), "7", 1, true) ~= nil).toBe(true) + else + expect(string.find(value, "%%d") ~= nil).toBe(true) + end + end + end + end) + + it("should return a string from every function valued relative time string", function() + for _, locale in LOCALES do + local strings: any = TimeLocalizationUtils.getRelativeTimeStringsForLocale(locale) + + for _, key in RELATIVE_KEYS do + local value = strings[key] + if type(value) == "function" then + expect(value(1, true, key, false)).toEqual(expect.any("string")) + expect(value(1, false, key, true)).toEqual(expect.any("string")) + expect(value(2, false, key, false)).toEqual(expect.any("string")) + end + end + end + end) + + it("should put the amount into every duration phrase", function() + for _, locale in LOCALES do + local strings: any = TimeLocalizationUtils.getDurationStringsForLocale(locale) + + for _, key in DURATION_KEYS do + local phrase = strings[key] + if type(phrase) == "function" then + expect(string.find(phrase(3), "3", 1, true) ~= nil).toBe(true) + expect(string.find(phrase(1), "1", 1, true) ~= nil).toBe(true) + else + expect(string.find(phrase.one, "%%d") ~= nil).toBe(true) + expect(string.find(phrase.other, "%%d") ~= nil).toBe(true) + end + end + end + end) + + it("should give every calendar phrase a time except sameElse", function() + for _, locale in LOCALES do + local formats: any = TimeLocalizationUtils.getCalendarFormatsForLocale(locale) + + for _, key in CALENDAR_KEYS do + if key == "sameElse" then + expect(formats[key]).toBe("L") + else + expect(string.find(formats[key], "LT", 1, true) ~= nil).toBe(true) + end + end + + expect(string.find(formats.nextWeek, "ddd", 1, true) ~= nil).toBe(true) + expect(string.find(formats.lastWeek, "ddd", 1, true) ~= nil).toBe(true) + end + end) +end) diff --git a/src/time/src/jest.config.lua b/src/time/src/jest.config.lua new file mode 100644 index 00000000000..4294d00462f --- /dev/null +++ b/src/time/src/jest.config.lua @@ -0,0 +1,3 @@ +return { + testMatch = { "**/*.spec" }, +} diff --git a/src/time/src/node_modules.project.json b/src/time/src/node_modules.project.json new file mode 100644 index 00000000000..46233dac4ff --- /dev/null +++ b/src/time/src/node_modules.project.json @@ -0,0 +1,7 @@ +{ + "name": "node_modules", + "globIgnorePaths": [ "**/.package-lock.json" ], + "tree": { + "$path": { "optional": "../node_modules" } + } +} \ No newline at end of file diff --git a/src/time/test/default.project.json b/src/time/test/default.project.json new file mode 100644 index 00000000000..1b15227c99b --- /dev/null +++ b/src/time/test/default.project.json @@ -0,0 +1,17 @@ +{ + "name": "TimeTest", + "tree": { + "$className": "DataModel", + "ServerScriptService": { + "$properties": { + "LoadStringEnabled": true + }, + "time": { + "$path": ".." + }, + "Script": { + "$path": "scripts/Server" + } + } + } +} diff --git a/src/time/test/scripts/Server/ServerMain.server.lua b/src/time/test/scripts/Server/ServerMain.server.lua new file mode 100644 index 00000000000..c5e02b2f5be --- /dev/null +++ b/src/time/test/scripts/Server/ServerMain.server.lua @@ -0,0 +1,12 @@ +--!nonstrict +local ServerScriptService = game:GetService("ServerScriptService") + +local root = ServerScriptService.time +local loader = root:FindFirstChild("LoaderUtils", true).Parent +local require = require(loader).bootstrapGame(root) + +local NevermoreTestRunnerUtils = require("NevermoreTestRunnerUtils") + +if NevermoreTestRunnerUtils.runTestsIfNeededAsync(root) then + return +end diff --git a/tools/nevermore-cli/src/commands/init-command/init-game-command.ts b/tools/nevermore-cli/src/commands/init-command/init-game-command.ts index bc07bb0b850..26bd25d6d99 100644 --- a/tools/nevermore-cli/src/commands/init-command/init-game-command.ts +++ b/tools/nevermore-cli/src/commands/init-command/init-game-command.ts @@ -85,7 +85,7 @@ export class InitGameCommand implements CommandModule { public async handler(args: InitGameArgs) { const rawGameName = await InitGameCommand._ensureGameName(args); - const gameName = TemplateHelper.camelize(rawGameName).toLowerCase(); + const gameName = TemplateHelper.toPackageName(rawGameName); const gameNameProper = TemplateHelper.camelize(rawGameName); const srcRoot = process.cwd(); diff --git a/tools/nevermore-cli/src/commands/init-command/init-package-command.ts b/tools/nevermore-cli/src/commands/init-command/init-package-command.ts index f8a3ab5a346..9c7b6e2948f 100644 --- a/tools/nevermore-cli/src/commands/init-command/init-package-command.ts +++ b/tools/nevermore-cli/src/commands/init-command/init-package-command.ts @@ -51,7 +51,7 @@ export class InitPackageCommand public handler = async (args: InitPackageArgs) => { let rawPackageName = await InitPackageCommand._ensurePackageName(args); - const packageName = TemplateHelper.camelize(rawPackageName).toLowerCase(); + const packageName = TemplateHelper.toPackageName(rawPackageName); const packageNameProper = TemplateHelper.camelize(rawPackageName); const description = await InitPackageCommand._ensureDescription(args); @@ -71,6 +71,7 @@ export class InitPackageCommand { packageName: packageName, packageNameProper: packageNameProper, + packageNameIndex: TemplateHelper.toIndexExpression(packageName), description: description, }, args.dryrun diff --git a/tools/nevermore-cli/src/commands/init-command/init-plugin-command.ts b/tools/nevermore-cli/src/commands/init-command/init-plugin-command.ts index 3ff7f3efd06..1148c04ff27 100644 --- a/tools/nevermore-cli/src/commands/init-command/init-plugin-command.ts +++ b/tools/nevermore-cli/src/commands/init-command/init-plugin-command.ts @@ -35,7 +35,7 @@ export class InitPluginCommand implements CommandModule { public handler = async (args: initGameArgs) => { const rawPluginName = await InitPluginCommand._ensurePluginName(args); - const pluginName = TemplateHelper.camelize(rawPluginName).toLowerCase(); + const pluginName = TemplateHelper.toPackageName(rawPluginName); const pluginNameProper = TemplateHelper.camelize(rawPluginName); const srcRoot = process.cwd(); diff --git a/tools/nevermore-cli/templates/game-template/aftman.toml b/tools/nevermore-cli/templates/game-template/aftman.toml index e26e9ecaf45..3aa5ed9abba 100644 --- a/tools/nevermore-cli/templates/game-template/aftman.toml +++ b/tools/nevermore-cli/templates/game-template/aftman.toml @@ -4,6 +4,6 @@ luau-lsp = "Quenty/luau-lsp@1.58.0-quenty.1" lune = "lune-org/lune@0.10.4" moonwave-extractor = "UpliftGames/moonwave@1.3.0" -rojo = "quenty/rojo@7.7.0-rc.2-quenty" +rojo = "quenty/rojo@7.7.0-rc.3-quenty" selene = "Kampfkarren/selene@0.29.0" stylua = "johnnymorganz/stylua@2.1.0" \ No newline at end of file diff --git a/tools/nevermore-cli/templates/nevermore-library-package-template/test/scripts/Server/ServerMain.server.lua b/tools/nevermore-cli/templates/nevermore-library-package-template/test/scripts/Server/ServerMain.server.lua index a658fb4e6e9..fba201bec20 100644 --- a/tools/nevermore-cli/templates/nevermore-library-package-template/test/scripts/Server/ServerMain.server.lua +++ b/tools/nevermore-cli/templates/nevermore-library-package-template/test/scripts/Server/ServerMain.server.lua @@ -3,7 +3,7 @@ ]] local ServerScriptService = game:GetService("ServerScriptService") -local root = ServerScriptService.{{packageName}} +local root = ServerScriptService{{{packageNameIndex}}} local loader = root:FindFirstChild("LoaderUtils", true).Parent local require = require(loader).bootstrapGame(root) diff --git a/tools/nevermore-cli/templates/nevermore-service-package-template/test/scripts/Server/ServerMain.server.lua b/tools/nevermore-cli/templates/nevermore-service-package-template/test/scripts/Server/ServerMain.server.lua index 682905eaf32..b04fcd5c843 100644 --- a/tools/nevermore-cli/templates/nevermore-service-package-template/test/scripts/Server/ServerMain.server.lua +++ b/tools/nevermore-cli/templates/nevermore-service-package-template/test/scripts/Server/ServerMain.server.lua @@ -3,7 +3,7 @@ ]] local ServerScriptService = game:GetService("ServerScriptService") -local root = ServerScriptService.{{packageName}} +local root = ServerScriptService{{{packageNameIndex}}} local loader = root:FindFirstChild("LoaderUtils", true).Parent local require = require(loader).bootstrapGame(root) diff --git a/tools/nevermore-cli/templates/plugin-template/aftman.toml b/tools/nevermore-cli/templates/plugin-template/aftman.toml index 5112f5333e2..bf229495095 100644 --- a/tools/nevermore-cli/templates/plugin-template/aftman.toml +++ b/tools/nevermore-cli/templates/plugin-template/aftman.toml @@ -3,6 +3,6 @@ [tools] luau-lsp = "Quenty/luau-lsp@1.58.0-quenty.1" moonwave-extractor = "UpliftGames/moonwave@1.3.0" -rojo = "quenty/rojo@7.7.0-rc.2-quenty" +rojo = "quenty/rojo@7.7.0-rc.3-quenty" selene = "Kampfkarren/selene@0.29.0" stylua = "johnnymorganz/stylua@2.1.0" \ No newline at end of file diff --git a/tools/nevermore-template-helpers/package.json b/tools/nevermore-template-helpers/package.json index 9d9311a189b..4d8db802c5e 100644 --- a/tools/nevermore-template-helpers/package.json +++ b/tools/nevermore-template-helpers/package.json @@ -31,13 +31,16 @@ "devDependencies": { "@types/node": "^18.11.4", "prettier": "2.7.1", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^3.0.0" }, "scripts": { "build": "tsc --build", "build:watch": "tsc --build --watch", "build:clean": "tsc --build --clean", - "preinstall": "npx only-allow pnpm" + "preinstall": "npx only-allow pnpm", + "test": "vitest run", + "test:watch": "vitest" }, "publishConfig": { "access": "public" diff --git a/tools/nevermore-template-helpers/src/scaffolding/template-helpers.test.ts b/tools/nevermore-template-helpers/src/scaffolding/template-helpers.test.ts new file mode 100644 index 00000000000..9c86402ad8d --- /dev/null +++ b/tools/nevermore-template-helpers/src/scaffolding/template-helpers.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect } from 'vitest'; +import { TemplateHelper } from './template-helpers.js'; + +describe('TemplateHelper.camelize', () => { + it('leaves an already upper camel case name alone', () => { + expect(TemplateHelper.camelize('MyPackage')).toBe('MyPackage'); + expect(TemplateHelper.camelize('AdorneeEditorWelding')).toBe( + 'AdorneeEditorWelding' + ); + }); + + it('upper cases the first letter of a lower case name', () => { + expect(TemplateHelper.camelize('area')).toBe('Area'); + }); + + it('joins hyphenated names into a valid Luau identifier', () => { + expect(TemplateHelper.camelize('adornee-editor-welding')).toBe( + 'AdorneeEditorWelding' + ); + expect(TemplateHelper.camelize('nevermore-test-runner')).toBe( + 'NevermoreTestRunner' + ); + }); + + it('joins underscored and spaced names', () => { + expect(TemplateHelper.camelize('adornee_editor_welding')).toBe( + 'AdorneeEditorWelding' + ); + expect(TemplateHelper.camelize('adornee editor welding')).toBe( + 'AdorneeEditorWelding' + ); + }); + + it('keeps casing inside a part, so an acronym survives', () => { + expect(TemplateHelper.camelize('influxdb-service')).toBe('InfluxdbService'); + expect(TemplateHelper.camelize('InfluxDB-service')).toBe('InfluxDBService'); + }); + + it('ignores repeated, leading and trailing separators', () => { + expect(TemplateHelper.camelize('-adornee--editor_ welding-')).toBe( + 'AdorneeEditorWelding' + ); + }); + + it('handles digits in a name', () => { + expect(TemplateHelper.camelize('egg-hunt-2026')).toBe('EggHunt2026'); + }); + + it('returns an empty string for a name with nothing in it', () => { + expect(TemplateHelper.camelize('')).toBe(''); + expect(TemplateHelper.camelize(' -_ ')).toBe(''); + }); +}); + +describe('TemplateHelper.toPackageName', () => { + it('lower cases the name', () => { + expect(TemplateHelper.toPackageName('MyPackage')).toBe('mypackage'); + }); + + it('keeps hyphens, so the npm and Rojo name stays readable', () => { + expect(TemplateHelper.toPackageName('adornee-editor-welding')).toBe( + 'adornee-editor-welding' + ); + expect(TemplateHelper.toPackageName('Adornee-Editor-Welding')).toBe( + 'adornee-editor-welding' + ); + }); + + it('drops whitespace', () => { + expect(TemplateHelper.toPackageName('my package')).toBe('mypackage'); + expect(TemplateHelper.toPackageName(' spaced out ')).toBe('spacedout'); + }); + + it('leaves an already conventional package name alone', () => { + expect(TemplateHelper.toPackageName('adorneeutils')).toBe('adorneeutils'); + }); +}); + +describe('TemplateHelper.toIndexExpression', () => { + it('uses a dot access for a name that is a valid Luau identifier', () => { + expect(TemplateHelper.toIndexExpression('adorneeutils')).toBe( + '.adorneeutils' + ); + expect(TemplateHelper.toIndexExpression('MyPackage')).toBe('.MyPackage'); + expect(TemplateHelper.toIndexExpression('_private')).toBe('._private'); + expect(TemplateHelper.toIndexExpression('area2')).toBe('.area2'); + }); + + it('brackets a hyphenated name, which cannot be dot accessed', () => { + expect(TemplateHelper.toIndexExpression('adornee-editor-welding')).toBe( + '["adornee-editor-welding"]' + ); + }); + + it('brackets a name that does not start with a letter', () => { + expect(TemplateHelper.toIndexExpression('2026-egg-hunt')).toBe( + '["2026-egg-hunt"]' + ); + }); + + it('brackets a name that is a Luau keyword', () => { + expect(TemplateHelper.toIndexExpression('end')).toBe('["end"]'); + expect(TemplateHelper.toIndexExpression('function')).toBe('["function"]'); + expect(TemplateHelper.toIndexExpression('nil')).toBe('["nil"]'); + }); + + it('does not treat a keyword prefix as a keyword', () => { + expect(TemplateHelper.toIndexExpression('ending')).toBe('.ending'); + }); + + it('brackets an empty name rather than emitting a bare dot', () => { + expect(TemplateHelper.toIndexExpression('')).toBe('[""]'); + }); +}); + +describe('name derivation together', () => { + it('gives a hyphenated package a hyphenated name and a camel identifier', () => { + const raw = 'adornee-editor-welding'; + + expect(TemplateHelper.toPackageName(raw)).toBe('adornee-editor-welding'); + expect(TemplateHelper.camelize(raw)).toBe('AdorneeEditorWelding'); + }); + + it('matches what existing packages are already named', () => { + expect(TemplateHelper.toPackageName('adornee-editor-placement')).toBe( + 'adornee-editor-placement' + ); + expect(TemplateHelper.camelize('adornee-editor-placement')).toBe( + 'AdorneeEditorPlacement' + ); + }); +}); diff --git a/tools/nevermore-template-helpers/src/scaffolding/template-helpers.ts b/tools/nevermore-template-helpers/src/scaffolding/template-helpers.ts index 79271ec6066..348df396a95 100644 --- a/tools/nevermore-template-helpers/src/scaffolding/template-helpers.ts +++ b/tools/nevermore-template-helpers/src/scaffolding/template-helpers.ts @@ -10,20 +10,64 @@ import { OutputHelper } from '@quenty/cli-output-helpers'; const existsAsync = util.promisify(fs.exists); +const LUAU_KEYWORDS = new Set([ + 'and', + 'break', + 'do', + 'else', + 'elseif', + 'end', + 'false', + 'for', + 'function', + 'if', + 'in', + 'local', + 'nil', + 'not', + 'or', + 'repeat', + 'return', + 'then', + 'true', + 'until', + 'while', +]); + /** * Helper class for handling folder templates */ export class TemplateHelper { /** - * Makes the string upper camel case + * Makes the string upper camel case. Separators are dropped, so a hyphenated + * name still yields a valid Luau identifier for module and type names. */ public static camelize(str: string): string { return str - .replace(/-./g, (x) => x[1].toUpperCase()) - .replace(/(?:^\w|[A-Z]|\b\w)/g, function (word: string, index: number) { - return word.toUpperCase(); - }) - .replace(/\s+/g, ''); + .split(/[\s\-_]+/) + .filter((part) => part.length > 0) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(''); + } + + /** + * Makes the name a package is known by on disk, in npm and in the Rojo tree. + * Separators are kept, so `adornee-editor-welding` stays hyphenated. + */ + public static toPackageName(str: string): string { + return str.replace(/\s+/g, '').toLowerCase(); + } + + /** + * Makes the Luau expression that indexes a name out of its parent instance. + * A name that is a valid identifier reads as a dot access, anything else + * needs brackets. + */ + public static toIndexExpression(str: string): string { + const isIdentifier = + /^[A-Za-z_][A-Za-z0-9_]*$/.test(str) && !LUAU_KEYWORDS.has(str); + + return isIdentifier ? `.${str}` : `[${JSON.stringify(str)}]`; } /**