├── CNAME ├── .moonwave ├── static │ ├── CNAME │ ├── fast.png │ ├── favicon.png │ ├── feather.png │ ├── simple.png │ └── hourglass.png └── custom.css ├── CHANGELOG.md ├── .gitignore ├── .vscode └── settings.json ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── Question_Request.md │ ├── Feature_Request.md │ └── Bug_Report.md └── workflows │ └── docs.yaml ├── selene.toml ├── lib └── Leaderboard │ ├── wally_bundle.toml │ ├── wally.toml │ ├── Util │ ├── Compression.luau │ ├── FNV_1a.luau │ └── init.luau │ ├── UserIdCache.luau │ ├── Logger.luau │ ├── Board │ ├── init.luau │ └── MemoryShard.luau │ └── init.luau ├── rokit.toml ├── default.project.json ├── docs ├── classes.md ├── features.md ├── intro.md └── examples.md ├── LICENSE ├── moonwave.toml ├── README.md └── testing └── ServerExamples ├── WinsLeaderboards.server.luau ├── RollingLeaderboards.server.luau ├── RebirthsLeaderboards.server.luau └── MoneyLeaderboards.server.luau /CNAME: -------------------------------------------------------------------------------- 1 | leaderboard.arxk.cloud -------------------------------------------------------------------------------- /.moonwave/static/CNAME: -------------------------------------------------------------------------------- 1 | leaderboard.arxk.cloud -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.0.1-alpha 2 | - Initial release -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | lib/Leaderboard/Packages 3 | lib/Leaderboard/wally.lock -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "stylua.targetReleaseVersion": "latest" 3 | } -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto -------------------------------------------------------------------------------- /.moonwave/static/fast.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arxkdev/Leaderboard/HEAD/.moonwave/static/fast.png -------------------------------------------------------------------------------- /.moonwave/static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arxkdev/Leaderboard/HEAD/.moonwave/static/favicon.png -------------------------------------------------------------------------------- /.moonwave/static/feather.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arxkdev/Leaderboard/HEAD/.moonwave/static/feather.png -------------------------------------------------------------------------------- /.moonwave/static/simple.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arxkdev/Leaderboard/HEAD/.moonwave/static/simple.png -------------------------------------------------------------------------------- /.moonwave/static/hourglass.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arxkdev/Leaderboard/HEAD/.moonwave/static/hourglass.png -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Question_Request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Ask a Question 3 | about: Ask a question about the project. 4 | title: '' 5 | labels: Question 6 | assignees: '' 7 | 8 | --- 9 | 10 | **What is your question?** -------------------------------------------------------------------------------- /selene.toml: -------------------------------------------------------------------------------- 1 | std = "roblox" 2 | 3 | [rules] 4 | parenthese_conditions = "allow" 5 | multiple_statements = "allow" 6 | global_usage = "allow" 7 | shadowing = "allow" 8 | 9 | [config] 10 | unused_variable = { allow_unused_self = true } -------------------------------------------------------------------------------- /lib/Leaderboard/wally_bundle.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "arxkdev/leaderboard-bundle" 3 | version = "0.1.0" 4 | registry = "https://github.com/UpliftGames/wally-index" 5 | realm = "shared" 6 | 7 | [dependencies] 8 | Promise = "arxkdev/typed-promise@4.0.2" 9 | Signal = "sleitnick/signal@1.3.0" -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Feature_Request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Suggest an idea. 4 | title: '' 5 | labels: Feature 6 | assignees: '' 7 | 8 | --- 9 | 10 | **What package would this be relating to?** 11 | 12 | **Describe your problem here:** 13 | 14 | **Describe the solution that you'd like:** -------------------------------------------------------------------------------- /rokit.toml: -------------------------------------------------------------------------------- 1 | # This file lists tools managed by Rokit, a toolchain manager for Roblox projects. 2 | # For more information, see https://github.com/rojo-rbx/rokit 3 | 4 | # New tools can be added by running `rokit add ` in a terminal. 5 | 6 | [tools] 7 | rojo = "rojo-rbx/rojo@7.6.1" 8 | wally = "UpliftGames/wally@0.3.2" 9 | selene = "Kampfkarren/selene@0.29.0" 10 | stylua = "johnnymorganz/stylua@2.3.1" 11 | wally-package-types = "JohnnyMorganz/wally-package-types@1.6.2" 12 | -------------------------------------------------------------------------------- /.moonwave/custom.css: -------------------------------------------------------------------------------- 1 | .navbar__brand:hover { 2 | filter: drop-shadow(0px 0px 10px rgba(4, 97, 155, 0.9)); 3 | } 4 | 5 | /* Color turquoise */ 6 | :root { 7 | --ifm-color-primary: #00b1e9; 8 | --ifm-color-primary-dark: #009fcf; 9 | --ifm-color-primary-darker: #0192be; 10 | --ifm-color-primary-darkest: #007ea5; 11 | --ifm-color-primary-light: #14bbee; 12 | --ifm-color-primary-lighter: #38c0e9; 13 | --ifm-color-primary-lightest: #61daff; 14 | --ifm-code-font-size: 95%; 15 | } -------------------------------------------------------------------------------- /default.project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Leaderboard", 3 | "tree": { 4 | "$className": "DataModel", 5 | 6 | "ReplicatedStorage": { 7 | "$className": "ReplicatedStorage", 8 | "$ignoreUnknownInstances": true, 9 | "$path": "lib" 10 | }, 11 | 12 | "ServerScriptService": { 13 | "$className": "ServerScriptService", 14 | "$ignoreUnknownInstances": true, 15 | "ServerExamples": { 16 | "$path": "testing/ServerExamples" 17 | } 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /lib/Leaderboard/wally.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "arxkdev/leaderboard" 3 | description = "Leaderboard is an intuitive, open-source module designed to effortlessly establish and manage robust non-persistent & persistent leaderboards for your Roblox experiences." 4 | version = "0.0.5" 5 | license = "MIT" 6 | authors = ["Arxk (https://github.com/arxkdev)"] 7 | registry = "https://github.com/UpliftGames/wally-index" 8 | realm = "shared" 9 | 10 | [dependencies] 11 | Promise = "arxkdev/typed-promise@4.0.2" 12 | Signal = "sleitnick/signal@1.3.0" -------------------------------------------------------------------------------- /lib/Leaderboard/Util/Compression.luau: -------------------------------------------------------------------------------- 1 | local Compression = {}; 2 | local LOGARITHMIC_BASE = 1.0000001; 3 | 4 | -- Not super precise, but it's good enough for our purposes. 5 | function Compression.Compress(x: number): number 6 | return (x ~= 0 and math.floor(math.log10(x) / math.log10(LOGARITHMIC_BASE)) or 0); 7 | end 8 | 9 | function Compression.Decompress(x: number): number 10 | return (x ~= 0 and math.floor(math.pow(LOGARITHMIC_BASE, x)) or 0); 11 | end 12 | 13 | return table.freeze(Compression) :: { 14 | Compress: (number) -> number, 15 | Decompress: (number) -> number, 16 | } -------------------------------------------------------------------------------- /lib/Leaderboard/Util/FNV_1a.luau: -------------------------------------------------------------------------------- 1 | local byte = string.byte; 2 | local band = bit32.band; 3 | local bxor = bit32.bxor; 4 | local lshift = bit32.lshift; 5 | 6 | local BASIS = 0x811c9dc5; 7 | 8 | return function (s: string): number 9 | local h = BASIS; 10 | local len = #s; 11 | 12 | for i = 1, len do 13 | h = bxor(h, byte(s, i)); 14 | 15 | -- 32-bit unsigned multiply by 0x01000193 16 | local a = lshift(h, 24); 17 | local b = lshift(h, 8); 18 | local c = h * 0x93; 19 | h = band(a + b + c, 0xFFFFFFFF); 20 | end; 21 | 22 | return h; 23 | end -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Bug_Report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: File a report. 4 | title: '' 5 | labels: Bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the Bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **The Expected Behavior** 21 | A clear and concise description of what was expected to occur. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Environment (please provide the following information):** 27 | - OS: [e.g. Windows, Mac, Linux] 28 | - Package Version(s) [e.g. Repli: 0.0.1-alpha] 29 | 30 | **Additional Context** 31 | Add any additional pieces of context regarding your problem here. -------------------------------------------------------------------------------- /.github/workflows/docs.yaml: -------------------------------------------------------------------------------- 1 | name: Build Moonwave Docs 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build: 10 | name: Build and Deploy Documentation 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-node@v4 15 | with: 16 | node-version: "22" 17 | - run: npm i -g moonwave@latest 18 | - name: Publish 19 | run: | 20 | git remote set-url origin https://git:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git 21 | git config --global user.email "support+actions@github.com" 22 | git config --global user.name "github-actions-bot" 23 | moonwave build --publish 24 | env: 25 | GITHUB_TOKEN: ${{ SECRETS.GITHUB_TOKEN }} 26 | 27 | - name: Deploy to GitHub Pages 28 | uses: JamesIves/github-pages-deploy-action@v4 29 | with: 30 | folder: build 31 | branch: gh-pages -------------------------------------------------------------------------------- /docs/classes.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 4 3 | --- 4 | 5 | # Classes 6 | Heres a list of all the classes in the library and how they are used within the library. 7 | 8 | ### Class: `Leaderboard` 9 | The leaderboard class is used to create a leaderboard object. This object is used to interact with the individual boards which are children of the leaderboard. 10 | 11 | ### Class: `Board` 12 | The board class is used to create a board. This board could etiher be (Hourly, Daily, Weekly, Monthly, Yearly, or All Time). This class interacts with the MemoryShard class to store the data using the `set` and `get` methods. 13 | 14 | ### Class: `MemoryShard` 15 | The memory shard class is used to store individual MemoryMaps for each board. This is a recommended way as per the [MemoryStores](https://create.roblox.com/docs/cloud-services/memory-stores) page under the `Best practices` tab. If you want to understand how this process works, please refer to the [Sharding](/docs/features#sharding) section of the Features page. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Arxk 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docs/features.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 2 3 | --- 4 | 5 | # Features 6 | 7 | ### Intro 8 | The foundation of this library is built upon the best practices recommended by Roblox, as listed here: 9 | 10 | ### Sharding 11 | https://en.wikipedia.org/wiki/Shard_(database_architecture) 12 | 13 | Leaderboard uses a custom sharding solution for MemoryStoreService to reduce the risk of hitting the size limits for a single Memory Map. This is done by splitting the data into multiple Memory Maps, and then using a custom hashing algorithm to determine which Memory Map to use for a given key. 14 | 15 | ### Exponential Backoff 16 | https://en.wikipedia.org/wiki/Exponential_backoff 17 | 18 | Leaderboard uses an exponential backoff algorithm to reduce the risk of hitting rate limits. This is done by waiting a certain amount of time before retrying a request, and then increasing the wait time exponentially for each retry. 19 | 20 | ### Other 21 | 22 | - Abstract API for easy integration into your existing codebase 23 | - Customizable leaderboard settings 24 | - Leaderboard types: Hourly, Daily, Weekly, Monthly, All-Time and Yearly 25 | - A special Leaderboard type for Rolling Leaderboards which automatically reset at a given interval 26 | - Full type support -------------------------------------------------------------------------------- /lib/Leaderboard/UserIdCache.luau: -------------------------------------------------------------------------------- 1 | local Players = game:GetService("Players"); 2 | 3 | local UserIdsCache = {}; 4 | local Processing = {}; 5 | local CachedUsernames = {}; 6 | 7 | local function FetchNameFromAPI(userId: number): string 8 | local Success, Result = pcall(function() 9 | return Players:GetNameFromUserIdAsync(userId); 10 | end); 11 | 12 | return Success, Result; 13 | end; 14 | 15 | function UserIdsCache:ProcessIds() 16 | for UserId in Processing do 17 | local Success, Result = FetchNameFromAPI(UserId); 18 | if (Success) then 19 | CachedUsernames[UserId] = Result; 20 | Processing[UserId] = nil; 21 | end; 22 | end; 23 | end; 24 | 25 | function UserIdsCache:GetNameFromUserId(userId: number) 26 | local player = Players:GetPlayerByUserId(userId); 27 | if (player) then 28 | CachedUsernames[userId] = player.Name; 29 | return player.Name; 30 | end; 31 | 32 | if (CachedUsernames[userId]) then 33 | return CachedUsernames[userId]; 34 | else 35 | local Success, Result = FetchNameFromAPI(userId); 36 | if (Success) then 37 | CachedUsernames[userId] = Result; 38 | return Result; 39 | end; 40 | 41 | Processing[userId] = true; 42 | return "Loading..."; 43 | end; 44 | end 45 | 46 | task.spawn(function() 47 | while (true) do 48 | UserIdsCache:ProcessIds(); 49 | task.wait(10); 50 | end; 51 | end); 52 | 53 | return UserIdsCache; -------------------------------------------------------------------------------- /moonwave.toml: -------------------------------------------------------------------------------- 1 | title = "Leaderboard" # From Git 2 | gitRepoUrl = "https://github.com/arxkdev/Leaderboard" # From Git 3 | 4 | gitSourceBranch = "main" 5 | changelog = true 6 | 7 | [docusaurus] 8 | onBrokenLinks = "throw" 9 | onBrokenMarkdownLinks = "warn" 10 | favicon = "/favicon.png" 11 | 12 | # From git: 13 | organizationName = "arxkdev" 14 | projectName = "Leaderboard" 15 | url = "https://leaderboard.arxk.cloud" 16 | baseUrl = "/" 17 | tagline = "Create and manage timed Global Leaderboards with ease." 18 | 19 | [home] 20 | enabled = true 21 | includeReadme = true # Optional 22 | 23 | [[home.features]] 24 | title = "Simple" 25 | description = "Leaderboard is so simple, you could have an Hourly, Daily, Weekly, Monthly and All Time leaderboard setup in 5 lines of code." 26 | 27 | [[home.features]] 28 | title = "Fast" 29 | description = "Leaderboard is fast, it has been optimized to be as fast as it possibly can be without sacrificing features or functionality." 30 | 31 | [[home.features]] 32 | title = "Lightweight" 33 | description = "Leaderboard is a lightweight performance friendly library that is designed to be as lightweight as possible." 34 | 35 | [[home.features]] 36 | 37 | [[classOrder]] 38 | section = "Leaderboard" 39 | classes = ["Leaderboard", "Board", "MemoryShard", "Logger"] 40 | 41 | [footer] 42 | style = "dark" 43 | copyright = "Copyright © 2024 Arxk. Built with Moonwave and Docusaurus" -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 |

Leaderboard

6 | View docs 7 |
8 | 9 |
10 | 11 |
12 | 13 | [![DocsBuild](https://github.com/arxkdev/Leaderboard/actions/workflows/docs.yaml/badge.svg)](https://github.com/arxkdev/Leaderboard/actions/workflows/docs.yaml) 14 | [![DocsPublish](https://github.com/arxkdev/Leaderboard/actions/workflows/pages/pages-build-deployment/badge.svg)](https://github.com/arxkdev/Leaderboard/actions/workflows/pages/pages-build-deployment) 15 | 16 |
17 | 18 | ## Why you should use Leaderboard 19 | 20 | Roblox developers often face challenges when implementing global leaderboards, particularly when dealing with various time periods. This library provides a streamlined solution, enabling you to create global leaderboards with just a few lines of code. 21 | 22 | - **Time Period Support**: Leaderboard supports different time periods, including All-Time, Monthly, Weekly, and Daily leaderboards. Additionally, it provides flexibility with a custom rolling time, allowing you to choose how long the leaderboard should display. 23 | 24 | - **Simplified Setup**: Streamline the creation of global leaderboards, eliminating the need for complex and time-consuming implementations. The library's design prioritizes ease of use and efficiency. 25 | 26 | - **Efficient Memory Management**: Leaderboard leverages MemoryStore to surpass the limitations of OrderedDataStores. It features flexible rate limits and automatic data expiration, eliminating the need to create new stores for each time period. 27 | 28 | 29 | -------------------------------------------------------------------------------- /docs/intro.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | --- 4 | 5 | # About 6 | Leaderboard is an intuitive, open-source module designed to effortlessly establish and manage robust non-persistent & persistent global leaderboards for your Roblox experiences. 7 | 8 | ### What can I do with this? 9 | - Create leaderboards for your Roblox experiences 10 | - Pick from a variety of leaderboard types such as Hourly, Daily, Weekly, Monthly, All-Time and Yearly 11 | - Not have to worry about rate limits 12 | - Not have to worry about messing with your PlayerData and setup a million hacky workarounds to store individual dated leaderboards 13 | - Customize your leaderboard settings to your liking 14 | - Use automation to automatically update your leaderboards 15 | - Easily integrate into your existing codebase with the abstract API 16 | 17 | ### Why should I use this? 18 | Roblox developers often face challenges when implementing global leaderboards, particularly when dealing with various time periods. This library provides a streamlined solution, enabling you to create global leaderboards with just a few lines of code. Leaderboard supports different time periods, including All-Time, Monthly, Weekly, and Daily leaderboards. Additionally, it provides flexibility with a custom rolling time, allowing you to choose how long the leaderboard should display. 19 | 20 | ### Why not OrderedDataStore? 21 | You should not be using ODS for non persistent data. It should be persistent data. For years there was a workaround to allow people to create Daily/Weekly/Monthly boards with ODS, a very hacky workaround, but now we have MemoryStoreService which is a much better solution for non persistent data. -------------------------------------------------------------------------------- /lib/Leaderboard/Logger.luau: -------------------------------------------------------------------------------- 1 | --[=[ 2 | @within Logger 3 | @interface Object 4 | @field __index Object 5 | @field new (moduleName: string, debugEnabled: boolean) -> Logger 6 | @field Log (self: Logger, logLevel: number, message: string) -> () 7 | @field Destroy (self: Logger) -> () 8 | ]=] 9 | type Object = { 10 | __index: Object, 11 | new: (moduleName: string, debugEnabled: boolean) -> Logger, 12 | Log: (self: Logger, logLevel: number, message: string) -> (), 13 | Destroy: (self: Logger) -> (), 14 | } 15 | 16 | --[=[ 17 | @within Logger 18 | @interface LoggerArguments 19 | @field _moduleName string 20 | @field _debugEnabled boolean 21 | ]=] 22 | export type LoggerArguments = { 23 | _moduleName: string, 24 | _debugEnabled: boolean, 25 | } 26 | 27 | --[=[ 28 | @within Logger 29 | @type Logger () -> Logger 30 | ]=] 31 | export type Logger = typeof(setmetatable({} :: LoggerArguments, {} :: Object)); 32 | 33 | --[=[ 34 | @class Logger 35 | ]=] 36 | local Logger: Object = {} :: Object; 37 | Logger.__index = Logger; 38 | 39 | --[=[ 40 | @param moduleName string 41 | @param debugEnabled boolean 42 | @return Logger 43 | 44 | Constructs a new Logger. 45 | ]=] 46 | function Logger.new(moduleName, debugEnabled) 47 | local self = {}; 48 | self._moduleName = moduleName; 49 | self._debugEnabled = debugEnabled; 50 | return setmetatable(self, Logger); 51 | end 52 | 53 | --[=[ 54 | @param logLevel number 55 | @param message string 56 | @return nil 57 | 58 | Logs a message to the console. 59 | ]=] 60 | function Logger:Log(logLevel, message) 61 | if (not self._debugEnabled) then 62 | return; 63 | end; 64 | if (logLevel == 1) then 65 | print(string.format("[%s] %s", self._moduleName, message)); 66 | elseif (logLevel == 2) then 67 | warn(string.format("[%s] %s", self._moduleName, message)); 68 | elseif (logLevel == 3) then 69 | error(string.format("[%s] %s", self._moduleName, message)); 70 | end; 71 | end 72 | 73 | --[=[ 74 | @return nil 75 | 76 | Destroys the Logger. 77 | ]=] 78 | function Logger:Destroy() 79 | setmetatable(self, nil); 80 | end 81 | 82 | return table.freeze({ 83 | Log = Logger.Log, 84 | new = Logger.new, 85 | Destroy = Logger.Destroy, 86 | }) -------------------------------------------------------------------------------- /testing/ServerExamples/WinsLeaderboards.server.luau: -------------------------------------------------------------------------------- 1 | local ReplicatedStorage = game:GetService("ReplicatedStorage"); 2 | local Players = game:GetService("Players"); 3 | 4 | local LeaderboardTemplate = ReplicatedStorage:WaitForChild("LeaderboardTemplate"); 5 | local Leaderboard = require(ReplicatedStorage:WaitForChild("Leaderboard")); 6 | 7 | type TopData = {Leaderboard.TopData}; 8 | 9 | -- Constants 10 | local Key = `Wins-{11}`; 11 | local Leaderboards = { 12 | ["Hourly"] = `Hourly-{Key}`, 13 | ["Daily"] = `Daily-{Key}`, 14 | ["Weekly"] = `Weekly-{Key}`, 15 | ["Monthly"] = `Monthly-{Key}`, 16 | ["AllTime"] = `AllTime-{Key}`, 17 | }; 18 | local WinsLeaderboard = Leaderboard.new(Leaderboards, { 19 | Automation = true, 20 | Interval = 120, 21 | RecordCount = 100, -- You can also do {Daily = 50, Weekly = 50, Monthly = 50, AllTime = 100} 22 | }); 23 | 24 | local function UpdateBoard(data: TopData, part: BasePart) 25 | -- Remove current items 26 | for _, v in part.UI.List:GetChildren() do 27 | if (not v:IsA("GuiObject")) then continue end; 28 | v:Destroy(); 29 | end; 30 | 31 | -- Add new items 32 | for i, v in data do 33 | local item = LeaderboardTemplate:Clone(); 34 | item.Name = `Item-${i}`; 35 | item.Rank.Text = v.Rank; 36 | item.Username.Text = `{v.Username}`; 37 | item.LayoutOrder = i 38 | item["Value"].Text = v.Value; 39 | item.LayoutOrder = v.Rank; 40 | item.Parent = part.UI.List; 41 | end; 42 | end 43 | 44 | local function IncrementMoneyTest() 45 | for _, player in Players:GetPlayers() do 46 | WinsLeaderboard:IncrementValues(Leaderboards, player.UserId, 111); 47 | end; 48 | 49 | -- Test userIds 50 | local FakeId1, FakeId2 = 100, 101; 51 | WinsLeaderboard:IncrementValues(Leaderboards, FakeId1, 100); 52 | WinsLeaderboard:IncrementValues(Leaderboards, FakeId2, 100); 53 | 54 | -- Larger scale test 55 | for _ = 1, 10 do 56 | WinsLeaderboard:IncrementValues(Leaderboards, math.random(1, 10000000), math.random(1, 1500)); 57 | end; 58 | end 59 | 60 | WinsLeaderboard.Updated:Connect(function(boards) 61 | -- Returns us a table of all the boards that were updated 62 | for _, board in boards do 63 | print(`[Wins] Updating board {board.Type} - with {#board.Data} entries!`); 64 | UpdateBoard(board.Data, workspace.Leaderboards["Wins"][board.Type]); 65 | end; 66 | 67 | -- Increment 68 | IncrementMoneyTest(); 69 | end); 70 | -- WinsLeaderboard.BoardUpdated:Connect(function(board) 71 | -- print(board.Type, board.Data); 72 | -- end); -------------------------------------------------------------------------------- /testing/ServerExamples/RollingLeaderboards.server.luau: -------------------------------------------------------------------------------- 1 | local ReplicatedStorage = game:GetService("ReplicatedStorage"); 2 | local Players = game:GetService("Players"); 3 | 4 | local LeaderboardTemplate = ReplicatedStorage:WaitForChild("LeaderboardTemplate"); 5 | local Leaderboard = require(ReplicatedStorage:WaitForChild("Leaderboard")); 6 | 7 | type TopData = {Leaderboard.TopData}; 8 | 9 | -- Constants 10 | local Key = `Gold-{11}`; 11 | local Leaderboards = { 12 | ["15MinutesRolling"] = {60 * 15, `15MinutesRolling-{Key}`}, -- 15 minutes rolling leaderboard 13 | ["30MinutesRolling"] = {60 * 30, `30MinutesRolling-{Key}`}, -- 30 minutes rolling leaderboard 14 | }; 15 | local GoldLeaderboard = Leaderboard.new(Leaderboards, { 16 | Automation = true, 17 | Interval = 120, 18 | RecordCount = 100, -- You can also do {Daily = 50, Weekly = 50, Monthly = 50, AllTime = 100} 19 | }); 20 | 21 | local function UpdateBoard(data: TopData, part: BasePart) 22 | -- Remove current items 23 | for _, v in part.UI.List:GetChildren() do 24 | if (not v:IsA("GuiObject")) then continue end; 25 | v:Destroy(); 26 | end; 27 | 28 | -- Add new items 29 | for i, v in data do 30 | local item = LeaderboardTemplate:Clone(); 31 | item.Name = `Item-${i}`; 32 | item.Rank.Text = v.Rank; 33 | item.Username.Text = `{v.Username}`; 34 | item.LayoutOrder = i 35 | item["Value"].Text = v.Value; 36 | item.LayoutOrder = v.Rank; 37 | item.Parent = part.UI.List; 38 | end; 39 | end 40 | 41 | local function IncrementGoldTest() 42 | for _, player in Players:GetPlayers() do 43 | GoldLeaderboard:IncrementValues(Leaderboards, player.UserId, 111); 44 | end; 45 | 46 | -- Test userIds 47 | local FakeId1, FakeId2 = 100, 101; 48 | GoldLeaderboard:IncrementValues(Leaderboards, FakeId1, 100); 49 | GoldLeaderboard:IncrementValues(Leaderboards, FakeId2, 100); 50 | 51 | -- Larger scale test 52 | for _ = 1, 10 do 53 | GoldLeaderboard:IncrementValues(Leaderboards, math.random(1, 10000000), math.random(1, 35345)); 54 | end; 55 | end 56 | 57 | GoldLeaderboard.Updated:Connect(function(boards) 58 | -- Returns us a table of all the boards that were updated 59 | for _, board in boards do 60 | print(`[Gold] Updating board {board.Type} - with {#board.Data} entries!`); 61 | UpdateBoard(board.Data, workspace.Leaderboards["Gold"][board.Type]); 62 | end; 63 | 64 | -- Increment 65 | IncrementGoldTest(); 66 | end); 67 | -- GoldLeaderboard.BoardUpdated:Connect(function(board) 68 | -- print(board.Type, board.Data); 69 | -- end); -------------------------------------------------------------------------------- /testing/ServerExamples/RebirthsLeaderboards.server.luau: -------------------------------------------------------------------------------- 1 | local ReplicatedStorage = game:GetService("ReplicatedStorage"); 2 | local Players = game:GetService("Players"); 3 | 4 | local LeaderboardTemplate = ReplicatedStorage:WaitForChild("LeaderboardTemplate"); 5 | local Leaderboard = require(ReplicatedStorage:WaitForChild("Leaderboard")); 6 | 7 | type TopData = {Leaderboard.TopData}; 8 | 9 | -- Constants 10 | local Key = `Rebirths-{11}`; 11 | local Leaderboards = { 12 | ["Hourly"] = `Hourly-{Key}`, 13 | ["Daily"] = `Daily-{Key}`, 14 | ["Weekly"] = `Weekly-{Key}`, 15 | ["Monthly"] = `Monthly-{Key}`, 16 | ["AllTime"] = `AllTime-{Key}`, 17 | }; 18 | local RebirthsLeaderboard = Leaderboard.new(Leaderboards, { 19 | Automation = true, 20 | Interval = 120, 21 | RecordCount = 100, -- You can also do {Daily = 50, Weekly = 50, Monthly = 50, AllTime = 100} 22 | }); 23 | 24 | local function UpdateBoard(data: TopData, part: BasePart) 25 | -- Remove current items 26 | for _, v in part.UI.List:GetChildren() do 27 | if (not v:IsA("GuiObject")) then continue end; 28 | v:Destroy(); 29 | end; 30 | 31 | -- Add new items 32 | for i, v in data do 33 | local item = LeaderboardTemplate:Clone(); 34 | item.Name = `Item-${i}`; 35 | item.Rank.Text = v.Rank; 36 | item.Username.Text = `{v.Username}`; 37 | item.LayoutOrder = i 38 | item["Value"].Text = v.Value; 39 | item.LayoutOrder = v.Rank; 40 | item.Parent = part.UI.List; 41 | end; 42 | end 43 | 44 | local function IncrementMoneyTest() 45 | for _, player in Players:GetPlayers() do 46 | RebirthsLeaderboard:IncrementValues(Leaderboards, player.UserId, 111); 47 | end; 48 | 49 | -- Test userIds 50 | local FakeId1, FakeId2 = 100, 101; 51 | RebirthsLeaderboard:IncrementValues(Leaderboards, FakeId1, 100); 52 | RebirthsLeaderboard:IncrementValues(Leaderboards, FakeId2, 100); 53 | 54 | -- Larger scale test 55 | for _ = 1, 10 do 56 | RebirthsLeaderboard:IncrementValues(Leaderboards, math.random(1, 10000000), math.random(1, 1500)); 57 | end; 58 | end 59 | 60 | RebirthsLeaderboard.Updated:Connect(function(boards) 61 | -- Returns us a table of all the boards that were updated 62 | for _, board in boards do 63 | print(`[Rebirths] Updating board {board.Type} - with {#board.Data} entries!`); 64 | UpdateBoard(board.Data, workspace.Leaderboards["Rebirths"][board.Type]); 65 | end; 66 | 67 | -- Increment 68 | IncrementMoneyTest(); 69 | end); 70 | -- RebirthsLeaderboard.BoardUpdated:Connect(function(board) 71 | -- print(board.Type, board.Data); 72 | -- end); -------------------------------------------------------------------------------- /testing/ServerExamples/MoneyLeaderboards.server.luau: -------------------------------------------------------------------------------- 1 | local ReplicatedStorage = game:GetService("ReplicatedStorage"); 2 | local Players = game:GetService("Players"); 3 | local RunService = game:GetService("RunService"); 4 | 5 | local LeaderboardTemplate = ReplicatedStorage:WaitForChild("LeaderboardTemplate"); 6 | local Leaderboard = require(ReplicatedStorage:WaitForChild("Leaderboard")); 7 | 8 | type TopData = {Leaderboard.TopData}; 9 | 10 | local DEBUG = RunService:IsStudio(); 11 | 12 | -- Constants 13 | local Key = `Money-{12}`; 14 | local Leaderboards = { 15 | ["Hourly"] = `Hourly-{Key}`, 16 | ["Daily"] = `Daily-{Key}`, 17 | ["Weekly"] = `Weekly-{Key}`, 18 | ["Monthly"] = `Monthly-{Key}`, 19 | ["AllTime"] = `AllTime-{Key}`, 20 | }; 21 | local MoneyLeaderboard = Leaderboard.new(Leaderboards, { 22 | Automation = true, 23 | Interval = 120, 24 | RecordCount = 100, -- You can also do {Daily = 50, Weekly = 50, Monthly = 50, AllTime = 100} 25 | }, DEBUG); 26 | 27 | local function UpdateBoard(data: TopData, part: BasePart) 28 | -- Remove current items 29 | for _, v in part.UI.List:GetChildren() do 30 | if (not v:IsA("GuiObject")) then continue end; 31 | v:Destroy(); 32 | end; 33 | 34 | -- Add new items 35 | for i, v in data do 36 | local item = LeaderboardTemplate:Clone(); 37 | item.Name = `Item-${i}`; 38 | item.Rank.Text = v.Rank; 39 | item.Username.Text = `{v.Username}`; 40 | item.LayoutOrder = i 41 | item["Value"].Text = v.Value; 42 | item.LayoutOrder = v.Rank; 43 | item.Parent = part.UI.List; 44 | end; 45 | end 46 | 47 | local function IncrementMoneyTest() 48 | for _, player in Players:GetPlayers() do 49 | MoneyLeaderboard:IncrementValues(Leaderboards, player.UserId, 111); 50 | end; 51 | 52 | -- Test userIds 53 | local FakeId1, FakeId2 = 100, 101; 54 | MoneyLeaderboard:IncrementValues(Leaderboards, FakeId1, 100); 55 | MoneyLeaderboard:IncrementValues(Leaderboards, FakeId2, 100); 56 | 57 | -- Larger scale test 58 | for _ = 1, 100 do 59 | MoneyLeaderboard:IncrementValues(Leaderboards, math.random(1, 10000000), math.random(1, 1500)); 60 | end; 61 | end 62 | 63 | MoneyLeaderboard.Updated:Connect(function(boards) 64 | -- Returns us a table of all the boards that were updated 65 | for _, board in boards do 66 | print(`[Money] Updating board {board.Type} - with {#board.Data} entries!`); 67 | UpdateBoard(board.Data, workspace.Leaderboards["Money"][board.Type]); 68 | end; 69 | 70 | -- Increment 71 | IncrementMoneyTest(); 72 | end); 73 | -- MoneyLeaderboard.BoardUpdated:Connect(function(board) 74 | -- print(board.Type, board.Data); 75 | -- end); -------------------------------------------------------------------------------- /docs/examples.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 3 3 | --- 4 | 5 | # Examples 6 | 7 | ### Automated Example: 8 | ```lua 9 | local Leaderboard = require(game:GetService("ReplicatedStorage").Leaderboard); 10 | 11 | local Key = 1; -- The key for the leaderboard (change to reset) 12 | local LeaderboardTypes = { -- You must provide keys for the individual boards 13 | ["Hourly"] = `Hourly-{Key}`, 14 | ["Daily"] = `Daily-{Key}`, 15 | ["Weekly"] = `Weekly-{Key}`, 16 | ["Monthly"] = `Monthly-{Key}`, 17 | ["AllTime"] = `AllTime-{Key}`, 18 | }; 19 | local MoneyLeaderboard = Leaderboard.new(LeaderboardTypes, { 20 | -- Settings 21 | Automation = true, 22 | Interval = 5, 23 | RecordCount = 100, -- You can also do {Daily = 50, Weekly = 50, Monthly = 50, AllTime = 100} 24 | }) 25 | 26 | local function FunctionToIncrementMoney(userId: number, amount: number) 27 | -- This is where you would give the user money, just add this line to increment the leaderboard aswell 28 | MoneyLeaderboard:IncrementValues("All", userId, amount); 29 | end 30 | 31 | MoneyLeaderboard.Updated:Connect(function(boards) 32 | -- This is where you would update the leaderboard GUI 33 | -- Returns us a table of all the boards that were updated 34 | for _, board in boards do 35 | print(`Updating board {board.Type} - with {#board.Data} items!`); 36 | end; 37 | end) 38 | ``` 39 | 40 | ### Non-Automated Example: 41 | ```lua 42 | local Leaderboard = require(game:GetService("ReplicatedStorage").Leaderboard); 43 | 44 | local INTERVAL = 120; -- 2 minutes 45 | local RECORD_COUNT = 100; -- Amount of records to get per board 46 | 47 | local Key = 1; -- The key for the leaderboard (change to reset) 48 | local LeaderboardTypes = { -- You must provide keys for the individual boards 49 | ["Hourly"] = `Hourly-{Key}`, 50 | ["Daily"] = `Daily-{Key}`, 51 | ["Weekly"] = `Weekly-{Key}`, 52 | ["Monthly"] = `Monthly-{Key}`, 53 | ["AllTime"] = `AllTime-{Key}`, 54 | }; 55 | local MoneyLeaderboard = Leaderboard.new(LeaderboardTypes); 56 | 57 | local function FunctionToIncrementMoney(userId: number, amount: number) 58 | -- This is where you would give the user money, just add this line to increment the leaderboard aswell 59 | MoneyLeaderboard:IncrementValues("All", userId, amount); 60 | end 61 | 62 | local function UpdateLeaderboards() 63 | -- Add the value to the data 64 | for _, Player in Players:GetPlayers() do 65 | FunctionToIncrementMoney(Player.UserId, 100); 66 | end; 67 | 68 | -- Retrieve the data 69 | MoneyLeaderboard:GetRecords("All", RECORD_COUNT):andThen(function(data) 70 | -- This is where you would update the leaderboard GUI 71 | -- Returns us a table of all the boards that were updated 72 | for _, board in data do 73 | print(`Updating board {board.Type} - with {#board.Data} items!`); 74 | end; 75 | end); 76 | end 77 | 78 | task.spawn(function() 79 | while (true) do 80 | UpdateLeaderboards(); 81 | task.wait(INTERVAL); 82 | end; 83 | end) 84 | ``` 85 | 86 | ### Rolling Leaderboard Example: 87 | ```lua 88 | local Leaderboard = require(game:GetService("ReplicatedStorage").Leaderboard); 89 | 90 | local Key = 1; -- The key for the leaderboard (change to reset) 91 | local Leaderboards = { 92 | ["AllTime"] = `AllTime-{Key}`, 93 | ["10MinutesRolling"] = {60 * 10, `10MinutesRolling-{Key}`}, -- 10 minutes rolling leaderboard 94 | ["15MinutesRolling"] = {60 * 15, `15MinutesRolling-{Key}`}, -- 15 minutes rolling leaderboard 95 | ["1MinuteRolling"] = {60, `1MinuteRolling-{Key}`}, -- 1 minute rolling leaderboard 96 | }; 97 | local MoneyLeaderboard = Leaderboard.new(Leaderboards, { 98 | Automation = true, 99 | Interval = 15, 100 | RecordCount = 100, -- You can also do {Daily = 50, Weekly = 50, Monthly = 50, AllTime = 100} 101 | }); 102 | 103 | local function IncrementMoneyTest() 104 | -- Test userIds 105 | local FakeId1, FakeId2 = 100, 101; 106 | MoneyLeaderboard:IncrementValues(Leaderboards, FakeId1, 100); 107 | MoneyLeaderboard:IncrementValues(Leaderboards, FakeId2, 100); 108 | end 109 | IncrementMoneyTest(); 110 | 111 | MoneyLeaderboard.Updated:Connect(function(boards) 112 | -- Returns us a table of all the boards that were updated 113 | for _, board in boards do 114 | print(`Updating board {board.Type} - with {#board.Data} items!`); 115 | end; 116 | end); -------------------------------------------------------------------------------- /lib/Leaderboard/Util/init.luau: -------------------------------------------------------------------------------- 1 | local HttpService = game:GetService("HttpService"); 2 | 3 | -- Requirements 4 | local Compression = require(script.Compression); 5 | local FNV_1A_32 = require(script.FNV_1a); 6 | 7 | local Util = {}; 8 | 9 | -- Constants 10 | local MEMORY_STORE_SERVICE_MAX_EXPIRY = 24 * 60 * 60 * 25; 11 | local MEMORY_STORE_SERVICE_MIN_EXPIRY = 60; 12 | local BOARD_TYPES = {"Hourly", "Daily", "Weekly", "Monthly", "Yearly", "AllTime"}; 13 | local DAYS_IN_MONTH = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; 14 | local MAX_OVERALL_LEADERBOARDS = 4; -- Overall, you can only create 4 leaderboards using Leaderboard.new, this is to prevent rate limiting 15 | local MAX_BOARDS = 7; -- You can only create up to 7 boards per leaderboard 16 | local SHARD_COUNTS = { -- Feel free to change these based on how many MAU your game does have 17 | ["Hourly"] = 1, 18 | ["Daily"] = 2, 19 | ["Weekly"] = 3, 20 | ["Monthly"] = 4, 21 | } 22 | 23 | -- CAUTION: ONLY modify if you know what you are doing 24 | local SHARD_CLEANUP_SETTINGS = { 25 | MAX_ENTRIES_PER_SHARD = 300, -- Maximum entries per shard before cleanup 26 | CLEANUP_INTERVAL = 60 * 4, -- Cleanup interval (normally we would want to do this around every 4 minutes) 27 | CLEANUP_BATCH_SIZE = 200, -- Remove this many excess entries at once (max GetRangeAsync limit) 28 | } 29 | 30 | local function GetDaysInMonth(): number 31 | local CurrentDate = DateTime.now():ToUniversalTime(); 32 | local Month, Year = CurrentDate.Month, CurrentDate.Year; 33 | if (Month == 2) then -- This is for a leap year 34 | if (Year % 4 == 0 and (Year % 100 ~= 0 or Year % 400 == 0)) then 35 | return 29; -- Leap year month (29 days) 36 | end; 37 | end; 38 | return DAYS_IN_MONTH[Month]; 39 | end 40 | 41 | -- Our own very basic implementation of assert because Roblox refuses to fix theirs 42 | local function SmartAssert(condition: boolean, message: string) 43 | if (not condition) then 44 | error(message, 2); 45 | end; 46 | end 47 | 48 | local function Map(tbl: {T}, fn: (T, number) -> (any)): {T} 49 | local t = {}; 50 | 51 | for i, v in tbl do 52 | t[i] = fn(v, i); 53 | end; 54 | 55 | return t; 56 | end 57 | 58 | local function FoundInTable(tbl: {any}, value: any): (boolean | number, any) 59 | local function Search(t: {any}, val: any) 60 | for index, v in t do 61 | if (v == val) then 62 | return index, v; 63 | elseif (type(v) == "table") then 64 | if Search(v, val) then 65 | return index, v; 66 | end; 67 | end; 68 | end; 69 | return false, nil; 70 | end; 71 | 72 | return Search(tbl, value); 73 | end 74 | 75 | local function KeysInDictionary(dictionary: {any}): {any} 76 | local keys = {}; 77 | 78 | for key, _ in dictionary do 79 | table.insert(keys, key); 80 | end; 81 | 82 | return keys; 83 | end 84 | 85 | local function GenerateGUID(): string 86 | return HttpService:GenerateGUID(false); 87 | end 88 | 89 | Util.SmartAssert = SmartAssert; 90 | Util.Map = Map; 91 | Util.FoundInTable = FoundInTable; 92 | Util.GetDaysInMonth = GetDaysInMonth; 93 | Util.KeysInDictionary = KeysInDictionary; 94 | Util.Compression = Compression; 95 | Util.FNV_1A_32 = FNV_1A_32; 96 | Util.GenerateGUID = GenerateGUID; 97 | Util.BOARD_TYPES = BOARD_TYPES; 98 | Util.MEMORY_STORE_SERVICE_MAX_EXPIRY = MEMORY_STORE_SERVICE_MAX_EXPIRY; 99 | Util.MEMORY_STORE_SERVICE_MIN_EXPIRY = MEMORY_STORE_SERVICE_MIN_EXPIRY; 100 | Util.FALLBACK_EXPIRY_TIMES = { 101 | ["Hourly"] = 3600, 102 | ["Daily"] = 24 * 3600, 103 | ["Weekly"] = 7 * 24 * 3600, 104 | ["Monthly"] = (GetDaysInMonth()) * 24 * 3600, -- Used to have it as 30, but that's not accurate, so we use GetDaysInMonth() 105 | } 106 | Util.MAX_OVERALL_LEADERBOARDS = MAX_OVERALL_LEADERBOARDS; 107 | Util.MAX_BOARDS = MAX_BOARDS; 108 | Util.SHARD_COUNTS = SHARD_COUNTS; 109 | Util.SHARD_CLEANUP_SETTINGS = SHARD_CLEANUP_SETTINGS; 110 | 111 | return Util :: { 112 | SmartAssert: (boolean, string) -> (); 113 | Map: ({T}, (T, number) -> (any)) -> {T}; 114 | FoundInTable: ({any}, any) -> (boolean | number, any); 115 | GetDaysInMonth: () -> number; 116 | Compression: typeof(Compression); 117 | KeysInDictionary: ({any}) -> {any}; 118 | FNV_1A_32: typeof(FNV_1A_32); 119 | GenerateGUID: () -> string; 120 | BOARD_TYPES: {string}; 121 | MEMORY_STORE_SERVICE_MAX_EXPIRY: number; 122 | MEMORY_STORE_SERVICE_MIN_EXPIRY: number; 123 | FALLBACK_EXPIRY_TIMES: {[string]: number}; 124 | MAX_OVERALL_LEADERBOARDS: number; 125 | MAX_BOARDS: number; 126 | SHARD_COUNTS: {[string]: number}; 127 | SHARD_CLEANUP_SETTINGS: {[string]: number}; 128 | } -------------------------------------------------------------------------------- /lib/Leaderboard/Board/init.luau: -------------------------------------------------------------------------------- 1 | --[[ 2 | Arxk was here 3 | ]] 4 | 5 | -- DataStoreService to handle longer than 42 days (all time most likely) 6 | local DataStoreService = game:GetService("DataStoreService"); 7 | 8 | -- Requirements 9 | local Packages = script.Parent.Packages; 10 | local MemoryShard = require(script.MemoryShard); 11 | local Promise = require(Packages.Promise); 12 | local Util = require(script.Parent.Util); 13 | local Logger = require(script.Parent.Logger); 14 | local UserIdsCache = require(script.Parent.UserIdCache); 15 | 16 | -- Variables 17 | local Compression = Util.Compression; 18 | local SmartAssert = Util.SmartAssert; 19 | local Cancel = task.cancel; 20 | local Spawn = task.spawn; 21 | 22 | -- Constants 23 | local OFFLINE_ENVIRONMENT = game.GameId == 0; 24 | local SHARD_COUNTS = Util.SHARD_COUNTS; 25 | 26 | -- Supports Daily, Weekly, Monthly and AllTime currently 27 | --[=[ 28 | @within Board 29 | @type LeaderboardType "Hourly" | "Daily" | "Weekly" | "Monthly" | "Yearly" | "AllTime" | "Rolling" | string 30 | ]=] 31 | export type LeaderboardType = "Hourly" | "Daily" | "Weekly" | "Monthly" | "Yearly" | "AllTime" | "Rolling" | string; 32 | 33 | --[=[ 34 | @within Board 35 | @type Board () -> Board 36 | ]=] 37 | export type Board = typeof(setmetatable({} :: BoardArguments, {} :: Object)); 38 | 39 | --[=[ 40 | @within Board 41 | @interface BoardArguments 42 | @field _boardKey string 43 | @field _storeUsing string 44 | @field _store (MemoryStoreSortedMap | OrderedDataStore | MemoryShard)? 45 | @field _threads {thread} 46 | ]=] 47 | export type BoardArguments = { 48 | _type: LeaderboardType, 49 | _boardKey: string, 50 | _storeUsing: string, 51 | _store: (MemoryStoreSortedMap | OrderedDataStore | MemoryShard)?, 52 | _threads: {thread}, 53 | _logger: Logger.Logger?, 54 | } 55 | 56 | --[=[ 57 | @within Board 58 | @interface TopData 59 | @field Rank number 60 | @field UserId number 61 | @field Value number 62 | @field Username string 63 | @field DisplayName string 64 | ]=] 65 | export type TopData = { 66 | Rank: number, 67 | UserId: number, 68 | Value: number, 69 | Username: string, 70 | DisplayName: string, 71 | } 72 | 73 | type MemoryShard = MemoryShard.MemoryShard; 74 | 75 | --[=[ 76 | @within Board 77 | @interface Object 78 | @field __index Object 79 | @field Update (self: Board, userId: number, value: number | (number) -> (number)) -> Promise.TypedPromise 80 | @field Get (self: Board, amount: number, sortDirection: string) -> Promise.TypedPromise<{TopData}> 81 | @field Destroy (self: Board) -> () 82 | @field new (boardKey: string, leaderboardType: LeaderboardType, rollingExpiry: number?, debugMode: boolean?) -> Board 83 | ]=] 84 | type Object = { 85 | __index: Object, 86 | Update: (self: Board, userId: number, value: number | (number) -> (number)) -> Promise.TypedPromise, 87 | Get: (self: Board, amount: number, sortDirection: string) -> Promise.TypedPromise<{TopData}>, 88 | Destroy: (self: Board) -> (), 89 | new: (boardKey: string, leaderboardType: LeaderboardType, rollingExpiry: number?, debugMode: boolean?) -> Board, 90 | } 91 | 92 | --[=[ 93 | @class Board 94 | 95 | This class is used to create a new leaderboard board. 96 | ]=] 97 | local Board: Object = {} :: Object; 98 | Board.__index = Board; 99 | 100 | local function Transform(rank: number, key: number, value: number): TopData 101 | return { 102 | Rank = rank, 103 | Value = Compression.Decompress(value), 104 | UserId = key, 105 | Username = UserIdsCache:GetNameFromUserId(key), 106 | DisplayName = "Not supported yet", 107 | }; 108 | end 109 | 110 | -- local function GetRequestBudget(typeOfBudget: Enum.DataStoreRequestType): number 111 | -- return DataStoreService:GetRequestBudgetForRequestType(typeOfBudget); 112 | -- end 113 | 114 | local function ShardCalculation(rollingExpiry: number): number 115 | return math.round( 116 | math.max( 117 | 1, 118 | math.log10(rollingExpiry) - 3 119 | ) 120 | ); 121 | end 122 | 123 | local function GetCurrentId(leaderboardType: string) 124 | local CurrentHour = DateTime.now():ToUniversalTime().Hour; 125 | local CurrentDay = DateTime.now():ToUniversalTime().Day; 126 | local CurrentWeek = math.floor(os.date("!*t")["yday"] / 7); 127 | local CurrentMonth = DateTime.now():ToUniversalTime().Month; 128 | local CurrentYear = DateTime.now():ToUniversalTime().Year; 129 | 130 | return leaderboardType == "Hourly" and CurrentHour or leaderboardType == "Daily" and CurrentDay or leaderboardType == "Weekly" and CurrentWeek or leaderboardType == "Monthly" and CurrentMonth or leaderboardType == "Yearly" and CurrentYear or "AllTime" and "AllTime"; 131 | end 132 | 133 | local function ConstructStore(boardKey: string, leaderboardType: LeaderboardType, rollingExpiry: number?, debugMode: boolean?): (string, (MemoryStoreSortedMap | OrderedDataStore | MemoryShard)?) 134 | -- print(rollingExpiry); 135 | -- if (leaderboardType == "Hourly" or leaderboardType == "Daily" or leaderboardType == "Weekly" or leaderboardType == "Monthly" or leaderboardType == "Yearly") then 136 | -- return "OrderedDataStore", DataStoreService:GetOrderedDataStore(`{GetCurrentId(leaderboardType)}-{boardKey}`); 137 | -- end; 138 | 139 | -- If we are using a MemoryStore, we can just update the data 140 | -- Not a viable solution anymore, limits are too poor 141 | if (leaderboardType ~= "AllTime" or rollingExpiry) then 142 | local ShardCount = rollingExpiry and ShardCalculation(rollingExpiry) or SHARD_COUNTS[leaderboardType]; 143 | return "MemoryStore", MemoryShard.new(leaderboardType, boardKey, ShardCount, rollingExpiry, debugMode); 144 | end; 145 | 146 | -- If we are in an offline enviornment 147 | if (OFFLINE_ENVIRONMENT) then 148 | return "OrderedDataStore", {} :: any 149 | end; 150 | 151 | local Success, Result = pcall(function() 152 | return DataStoreService:GetOrderedDataStore(boardKey); 153 | end); 154 | if (not Success) then 155 | warn(`Failed to create OrderedDataStore for "{boardKey}": {Result}`); 156 | return "OrderedDataStore", {} :: any 157 | end; 158 | 159 | return "OrderedDataStore", Result; 160 | end 161 | 162 | --[=[ 163 | @param boardKey string 164 | @param leaderboardType LeaderboardType 165 | @param rollingExpiry number? 166 | @param debugMode boolean? 167 | @return Board 168 | 169 | Creates a new board within the Leaderboard. 170 | ]=] 171 | function Board.new(boardKey: string, leaderboardType: LeaderboardType, rollingExpiry: number?, debugMode: boolean?): Board 172 | local self = setmetatable({} :: BoardArguments, Board); 173 | 174 | self._boardKey = boardKey; 175 | self._storeType, self._store = ConstructStore(boardKey, leaderboardType, rollingExpiry, debugMode); 176 | self._threads = {}; 177 | self._logger = Logger.new(`Board-{boardKey}`, debugMode or false); 178 | 179 | -- New implementation: we just check if the current id is different from the previous one 180 | if (leaderboardType == "Hourly" or leaderboardType == "Daily" or leaderboardType == "Weekly" or leaderboardType == "Monthly" or leaderboardType == "Yearly") then 181 | table.insert(self._threads, Spawn(function() 182 | local CurrentId = GetCurrentId(leaderboardType); 183 | while (true) do 184 | local NewId = GetCurrentId(leaderboardType); 185 | if (NewId ~= CurrentId) then 186 | CurrentId = NewId; 187 | self._storeType, self._store = ConstructStore(boardKey, leaderboardType, nil, debugMode); 188 | end; 189 | task.wait(5); 190 | end; 191 | end)); 192 | end; 193 | 194 | return self; 195 | end 196 | 197 | -- Gets the top data for a specific board 198 | --[=[ 199 | @param amount number 200 | @param sortDirection string? 201 | @return Promise.TypedPromise<{TopData}> 202 | @yields 203 | 204 | Gets the top data for a specific board. 205 | ]=] 206 | function Board:Get(amount, sortDirection) 207 | SmartAssert(type(amount) == "number", "Amount must be a number"); 208 | SmartAssert(amount <= 100, "You can only get the top 100."); 209 | SmartAssert(amount > 0, "Amount must be greater than 0"); 210 | SmartAssert(type(sortDirection) == "nil" or typeof(sortDirection) == "string", "SortDirection must be a string"); 211 | sortDirection = sortDirection or "Descending"; 212 | 213 | local function RetrieveTopData() 214 | if (OFFLINE_ENVIRONMENT) then 215 | return {} :: { TopData } 216 | end; 217 | 218 | if (self._storeType == "MemoryStore") then 219 | local Result = self._store:Get(amount, sortDirection):awaitValue(); 220 | local Promises = {}; 221 | for rank, data in pairs(Result) do 222 | table.insert(Promises, Promise.new(function(resolve) 223 | resolve(Transform(rank, data.key, data.value)) 224 | end)); 225 | end; 226 | return Promise.all(Promises):awaitValue() :: { TopData }; 227 | else 228 | -- local RequestBudget = GetRequestBudget(Enum.DataStoreRequestType.GetSortedAsync); 229 | -- if (RequestBudget < 1) then 230 | -- warn(`Skipping getting top data for {self._boardKey} due to insufficient budget`); 231 | -- return {}; 232 | -- end; 233 | local Result = self._store:GetSortedAsync(sortDirection ~= "Descending", amount); 234 | local Data = Result:GetCurrentPage() :: {any}; 235 | local Promises = {}; 236 | for rank, data in pairs(Data) do 237 | table.insert(Promises, Promise.new(function(resolve) 238 | resolve(Transform(rank, data.key, data.value)) 239 | end)); 240 | end; 241 | return Promise.all(Promises):awaitValue() :: { TopData }; 242 | end; 243 | end; 244 | 245 | return Promise.new(function(resolve, reject) 246 | local Success, Result = pcall(function() 247 | return RetrieveTopData(); 248 | end); 249 | if (not Success) then 250 | warn(`Leaderboard had trouble getting top data with error: {Result}`); 251 | return reject(Result); 252 | end; 253 | return resolve(Result); 254 | end) :: Promise.TypedPromise<{ TopData }>; 255 | end 256 | 257 | -- Updates the data for a specific board (either MemoryStore (Shards), or OrderedDataStore) 258 | --[=[ 259 | @param userId number 260 | @param value number | (number) -> (number) 261 | @yields 262 | @return boolean 263 | 264 | Updates the data for a specific board (either MemoryStore (Shards), or OrderedDataStore). 265 | ]=] 266 | function Board:Update(userId, value) 267 | SmartAssert(type(userId) == "number", "UserId must be a number"); 268 | SmartAssert(type(value) == "function" or type(value) == "number", "Transformer must be a function or a number"); 269 | 270 | -- If we are using a MemoryStore, we can just update the data 271 | if (self._storeType == "MemoryStore") then 272 | self._logger:Log(1, `Successfully updated data for ${userId} in "{self._boardKey}"`); 273 | return self._store:Set(userId, value); 274 | end; 275 | 276 | -- local Budget = GetRequestBudget(Enum.DataStoreRequestType.UpdateAsync); 277 | -- if (Budget < 1) then 278 | -- warn(`Skipping updating data for {userId} in {self._boardKey} due to insufficient budget`); 279 | -- return false; 280 | -- end; 281 | 282 | -- Using an actual DataStore, we need to set the data 283 | return Promise.new(function(resolve, reject) 284 | local Success, Result = pcall(function() 285 | if (OFFLINE_ENVIRONMENT) then 286 | return; 287 | end; 288 | 289 | return self._store:UpdateAsync(userId, function(oldValue) 290 | oldValue = oldValue and Compression.Decompress(oldValue) or 0; 291 | local TransformedValue = (type(value) == "function") and value(oldValue) or value; 292 | 293 | -- If their oldValue is greater than the new value, we don't want to update it 294 | if (oldValue > TransformedValue) then 295 | return nil; 296 | end; 297 | 298 | if (type(TransformedValue) == "number") then 299 | return Compression.Compress(TransformedValue); 300 | end; 301 | return nil; 302 | end); 303 | end); 304 | 305 | if (not Success) then 306 | warn(`Leaderboard had trouble updating data with error: {Result}`); 307 | return reject(Result); 308 | end; 309 | 310 | self._logger:Log(1, `Successfully updated data for "{userId}" in "{self._boardKey}"`); 311 | return resolve(Result); 312 | end) :: Promise.TypedPromise; 313 | end 314 | 315 | -- Destroys the board 316 | --[=[ 317 | Destroys the board. 318 | ]=] 319 | function Board:Destroy() 320 | -- Destroy the threads 321 | for _, Thread in self._threads do 322 | if (typeof(Thread) == "thread") then 323 | Cancel(Thread); 324 | end; 325 | end; 326 | 327 | -- Destroy the store 328 | if (self._storeType == "MemoryStore") then 329 | self._store:Destroy(); 330 | end; 331 | 332 | -- Destroy the logger 333 | self._logger:Destroy(); 334 | 335 | -- Destroy the board 336 | setmetatable(self, nil); 337 | end 338 | 339 | -- Make indexing the class with the wrong key throw an error 340 | setmetatable(Board, { 341 | __index = function(_, key) 342 | error(`Attempt to get Board:{tostring(key)} (not a valid member)`, 2); 343 | end, 344 | __newindex = function(_, key, _) 345 | error(`Attempt to set Board:{tostring(key)} (not a valid member)`, 2); 346 | end, 347 | }) 348 | 349 | return table.freeze({ 350 | new = Board.new, 351 | Get = Board.Get, 352 | Update = Board.Update, 353 | Destroy = Board.Destroy, 354 | }) -------------------------------------------------------------------------------- /lib/Leaderboard/init.luau: -------------------------------------------------------------------------------- 1 | --[[ 2 | Arxk @ 2025 3 | ]] 4 | local Players = game:GetService("Players"); 5 | 6 | -- Requirements 7 | local Packages = script.Packages; 8 | local Util = require(script.Util); 9 | local Signal = require(Packages.Signal); 10 | local Promise = require(Packages.Promise); 11 | local Board = require(script.Board); 12 | local Logger = require(script.Logger); 13 | 14 | -- Variables 15 | local SmartAssert = Util.SmartAssert; 16 | local KeysInDictionary = Util.KeysInDictionary; 17 | local GenerateGUID = Util.GenerateGUID; 18 | local Spawn = task.spawn; 19 | local Cancel = task.cancel; 20 | 21 | -- Constants 22 | local MEMORY_STORE_SERVICE_MAX_EXPIRY = Util.MEMORY_STORE_SERVICE_MAX_EXPIRY; 23 | local MEMORY_STORE_SERVICE_MIN_EXPIRY = Util.MEMORY_STORE_SERVICE_MIN_EXPIRY; 24 | local BOARD_TYPES = Util.BOARD_TYPES; 25 | local MAX_OVERALL_LEADERBOARDS = Util.MAX_OVERALL_LEADERBOARDS; 26 | local MAX_BOARDS = Util.MAX_BOARDS; 27 | 28 | --[=[ 29 | @within Leaderboard 30 | @interface TopData 31 | @field Rank number 32 | @field UserId number 33 | @field Value number 34 | @field Username string 35 | @field DisplayName string 36 | ]=] 37 | export type TopData = { 38 | Rank: number, 39 | UserId: number, 40 | Value: number, 41 | Username: string, 42 | DisplayName: string, 43 | } 44 | 45 | --[=[ 46 | @within Leaderboard 47 | @interface AllTopData 48 | @field Type LeaderboardType 49 | @field Data {TopData} 50 | ]=] 51 | type AllTopData = { 52 | Type: LeaderboardType, 53 | Data: {TopData}, 54 | } 55 | 56 | -- Supports Daily, Weekly, Monthly and AllTime currently 57 | --[=[ 58 | @within Leaderboard 59 | @type Leaderboard () -> Leaderboard 60 | ]=] 61 | export type Leaderboard = typeof(setmetatable({} :: LeaderboardArguments, {} :: Object)); 62 | 63 | --[=[ 64 | @within Leaderboard 65 | @type LeaderboardType "Hourly" | "Daily" | "Weekly" | "Monthly" | "Yearly" | "AllTime" | string; 66 | ]=] 67 | export type LeaderboardType = "Hourly" | "Daily" | "Weekly" | "Monthly" | "Yearly" | "AllTime" | string; 68 | 69 | --[=[ 70 | @within Leaderboard 71 | @type LeaderboardTypeArgument {[LeaderboardType]: any} 72 | ]=] 73 | export type LeaderboardTypeArgument = { 74 | [LeaderboardType]: any, 75 | } 76 | 77 | --[=[ 78 | @within Leaderboard 79 | @interface LeaderboardArguments 80 | @field Updated Signal<{AllTopData}> 81 | @field BoardUpdated Signal<{Type: LeaderboardType, Data: {TopData}}> 82 | @field _serviceKey string 83 | @field _types LeaderboardTypeArgument 84 | @field _boards {[LeaderboardType]: Board} 85 | @field _valueQueue ValueQueueType 86 | @field _isSaving boolean 87 | @field _lastSaved number 88 | @field _isFetching boolean 89 | @field _lastFetch number 90 | @field _threads {thread} 91 | @field _connections {RBXScriptConnection} 92 | @field _logger Logger.Logger? 93 | ]=] 94 | export type LeaderboardArguments = { 95 | Updated: Signal.Signal<{AllTopData}>, 96 | BoardUpdated: Signal.Signal<{Type: LeaderboardType, Data: {TopData}}>, 97 | _serviceKey: string, 98 | _types: LeaderboardTypeArgument, 99 | _boards: {[LeaderboardType]: Board}, 100 | _valueQueue: ValueQueueType, 101 | _isSaving: boolean, 102 | _lastSaved: number, 103 | _isFetching: boolean, 104 | _lastFetch: number, 105 | _threads: {thread}, 106 | _connections: {RBXScriptConnection}, 107 | _logger: Logger.Logger?, 108 | } 109 | 110 | --[=[ 111 | @within Leaderboard 112 | @type OperationType "Set" | "Increment" 113 | 114 | The type of operation to perform on the leaderboard value. 115 | - "Set": Sets the value to the specified amount or result of the transform function 116 | - "Increment": Adds the specified amount or result of the transform function to the current value 117 | ]=] 118 | export type OperationType = "Set" | "Increment"; 119 | 120 | --[=[ 121 | @within Leaderboard 122 | @interface Board 123 | @field GetRecords (self: Board, amount: number, sortDirection: string) -> Promise.TypedPromise<{TopData}> 124 | @field Update (self: Board, userId: number, value: number | (number) -> (number)) -> boolean 125 | @field Destroy (self: Board) -> () 126 | ]=] 127 | type ValueQueueType = { 128 | [number]: { 129 | [LeaderboardType]: { 130 | Amount: number | ((number) -> number), 131 | Board: Board, 132 | OperationType: OperationType, 133 | }, 134 | }, 135 | } 136 | 137 | --[=[ 138 | @within Leaderboard 139 | @interface Object 140 | @field IncrementValues (self: Leaderboard, boardTypes: {LeaderboardType} | string, userId: number, amount: number) -> () 141 | @field SetValues (self: Leaderboard, boardTypes: {LeaderboardType} | string, userId: number, value: number | (number) -> (number)) -> () 142 | @field UpdateStoreValues (self: Leaderboard, boardTypes: {LeaderboardType} | string, userId: number, value: number | (number) -> (number)) -> () 143 | @field GetRecords (self: Leaderboard, boardTypes: {LeaderboardType} | string, optionalRange: {[LeaderboardType | string]: number} | number, sortDirection: string) -> Promise.TypedPromise<{AllTopData}> 144 | @field SaveValues (self: Leaderboard) -> Promise.TypedPromise 145 | @field Destroy (self: Leaderboard) -> () 146 | @field new (leaderboardTypes: {LeaderboardType}, automationSettings: AutomationSettings?, debugMode: boolean?) -> Leaderboard 147 | ]=] 148 | type Object = { 149 | __index: Object, 150 | IncrementValues: (self: Leaderboard, boardTypes: {LeaderboardType} | string, userId: number, amount: number) -> (), 151 | SetValues: (self: Leaderboard, boardTypes: {LeaderboardType} | string, userId: number, value: number | (number) -> (number)) -> (), 152 | UpdateStoreValues: (self: Leaderboard, boardTypes: {LeaderboardType} | string, userId: number, value: number | (number) -> (number)) -> (), 153 | GetRecords: (self: Leaderboard, boardTypes: {LeaderboardType} | string, optionalRange: {[LeaderboardType | string]: number} | number, sortDirection: string) -> Promise.TypedPromise<{AllTopData}>, 154 | SaveValues: (self: Leaderboard) -> Promise.TypedPromise, 155 | Destroy: (self: Leaderboard) -> (), 156 | new: (leaderboardTypes: LeaderboardTypeArgument, automationSettings: AutomationSettings?, debugMode: boolean?) -> Leaderboard, 157 | } 158 | 159 | type Board = Board.Board; 160 | 161 | --[=[ 162 | @within Leaderboard 163 | @interface AutomationSettings 164 | @field Automation boolean? 165 | @field Interval number? 166 | @field RecordCount number | {[string]: number}? 167 | ]=] 168 | type AutomationSettings = { 169 | Automation: boolean, 170 | Interval: number, 171 | RecordCount: number | {[string]: number}, 172 | } 173 | 174 | --[=[ 175 | @within Leaderboard 176 | @readonly 177 | @prop Updated Signal<{AllTopData}> 178 | 179 | Fired when the leaderboard is updated. 180 | ]=] 181 | --[=[ 182 | @within Leaderboard 183 | @readonly 184 | @prop BoardUpdated Signal<{Type: LeaderboardType, Data: {TopData}}> 185 | 186 | Fired when a specific board is updated. 187 | ]=] 188 | 189 | local Leaderboards = {} :: {[string]: Leaderboard}; 190 | 191 | --[=[ 192 | @class Leaderboard 193 | 194 | Leaderboard allows you to create a leaderboard that can be used to store and retrieve data for a specific service key. 195 | 196 | For example: 197 | ```lua 198 | local Leaderboard = require(game:GetService("ReplicatedStorage").Leaderboard); 199 | local MoneyLeaderboard = Leaderboard.new({ 200 | Daily = "DailyMoneyKey1", 201 | Weekly = "WeeklyMoneyKey1" 202 | }); 203 | ``` 204 | ]=] 205 | local Leaderboard: Object = {} :: Object; 206 | Leaderboard.__index = Leaderboard; 207 | 208 | local function IsValidLeaderboardType(leaderboardType: LeaderboardType, boardKey: string | {number & string}): boolean 209 | if (type(boardKey) == "table") then 210 | local RollingExpiry = type(boardKey) == "table" and boardKey[1] or nil; 211 | return RollingExpiry >= MEMORY_STORE_SERVICE_MIN_EXPIRY and RollingExpiry <= MEMORY_STORE_SERVICE_MAX_EXPIRY; 212 | end; 213 | return table.find(BOARD_TYPES, leaderboardType) ~= nil; 214 | end 215 | 216 | --[=[ 217 | @param leaderboardTypes LeaderboardTypeArgument 218 | @param automationSettings AutomationSettings? 219 | @param debugMode boolean? 220 | @return Leaderboard 221 | 222 | Constructs a new leaderboard. 223 | ]=] 224 | function Leaderboard.new(leaderboardTypes: LeaderboardTypeArgument, automationSettings: AutomationSettings?, debugMode: boolean?) 225 | -- Check if the leaderboard types are valid 226 | SmartAssert(#KeysInDictionary(leaderboardTypes) > 0, "Leaderboard types must be greater than 0"); 227 | SmartAssert(#KeysInDictionary(leaderboardTypes) <= MAX_BOARDS, `You can only create up to {MAX_BOARDS} types of leaderboards`); 228 | for boardType, boardKey in leaderboardTypes do 229 | SmartAssert(IsValidLeaderboardType(boardType, boardKey), `Leaderboard type {boardType} is not valid`); 230 | SmartAssert(boardKey ~= "", "Leaderboard key must not be empty"); 231 | end; 232 | 233 | -- Check if they've exceeded the max leaderboards 234 | SmartAssert(#KeysInDictionary(Leaderboards) < MAX_OVERALL_LEADERBOARDS, `You can only create up to {MAX_OVERALL_LEADERBOARDS} leaderboards`); 235 | 236 | -- Asserts for settings 237 | if (automationSettings) then 238 | SmartAssert(type(automationSettings) == "table", "Settings must be a table"); 239 | SmartAssert(type(automationSettings.Automation) == "nil" or type(automationSettings.Automation) == "boolean", "Automation must be a boolean"); 240 | SmartAssert(type(automationSettings.Interval) == "nil" or type(automationSettings.Interval) == "number", "Interval must be a number"); 241 | SmartAssert(type(automationSettings.RecordCount) == "nil" or type(automationSettings.RecordCount) == "number" or type(automationSettings.RecordCount) == "table", "RecordCount must be a number or a table"); 242 | end; 243 | 244 | local self = setmetatable({} :: LeaderboardArguments, Leaderboard); 245 | 246 | -- Public properties 247 | self.Updated = Signal.new(); 248 | self.BoardUpdated = Signal.new(); 249 | 250 | -- Private properties 251 | self._serviceKey = GenerateGUID(); 252 | self._types = leaderboardTypes; 253 | self._boards = {}; 254 | self._valueQueue = {}; 255 | self._lastSaved = 0; 256 | self._isSaving = false; 257 | self._lastFetch = 0; 258 | self._isFetching = false; 259 | self._threads = {}; 260 | self._connections = {}; 261 | self._logger = Logger.new("Leaderboard", debugMode or false); 262 | 263 | -- Add to leaderboards 264 | Leaderboards[self._serviceKey] = self; 265 | 266 | -- Initialize boards 267 | for boardType, boardKey in leaderboardTypes do 268 | self._boards[boardType] = Board.new(typeof(boardKey) == "table" and boardKey[2] or boardKey, boardType, typeof(boardKey) == "table" and boardKey[1] or nil, debugMode); 269 | end; 270 | 271 | -- Start automation 272 | if (automationSettings and automationSettings.Automation) then 273 | table.insert(self._threads, Spawn(function() 274 | local CalledTimes = 0; 275 | 276 | while (true) do 277 | -- Update the value from the queue 278 | self:SaveValues(); 279 | 280 | -- We work in alternating turns, so one loop we'll update the value queue, the next we'll update the actual store value(s) 281 | if (CalledTimes % 2 == 0 or CalledTimes == 0) then 282 | -- Get the top and update the signal 283 | local TopData = self:GetRecords("All", automationSettings.RecordCount):awaitValue() :: {AllTopData}; 284 | if (TopData) then 285 | self.Updated:Fire(TopData); 286 | for _, board in TopData do 287 | self.BoardUpdated:Fire(board); 288 | end; 289 | end; 290 | end; 291 | 292 | CalledTimes += 1; 293 | task.wait(automationSettings.Interval); 294 | end; 295 | end)); 296 | end; 297 | 298 | -- Player removing 299 | table.insert(self._connections, Players.PlayerRemoving:Connect(function(player) 300 | -- Remove the player from the value queue 301 | if (self._valueQueue[player.UserId]) then 302 | self._valueQueue[player.UserId] = nil; 303 | end; 304 | end)); 305 | 306 | return self; 307 | end 308 | 309 | -- Flushes the queue 310 | -- Should only be used every 90-120 seconds 311 | --[=[ 312 | @yields 313 | Updates the actual store value(s) (should only be used every 90-120 seconds) 314 | ]=] 315 | function Leaderboard:SaveValues() 316 | local Promises = {}; 317 | self._isSaving = true; 318 | 319 | for UserId, BoardType in self._valueQueue do 320 | for _, Data in BoardType do 321 | local PromiseUpdate = Data.Board:Update(UserId, function(oldValue) 322 | if (Data.OperationType == "Set") then 323 | -- Set operation: use the value/function directly 324 | if (type(Data.Amount) == "function") then 325 | return Data.Amount(oldValue); 326 | else 327 | return Data.Amount; 328 | end; 329 | elseif (Data.OperationType == "Increment") then 330 | -- Increment operation: add the amount 331 | if (type(Data.Amount) == "function") then 332 | return oldValue + Data.Amount(oldValue); 333 | else 334 | return oldValue + Data.Amount; 335 | end; 336 | else 337 | self._logger:Log(3, `Invalid operation type: {Data.OperationType} for user {UserId}`); 338 | return oldValue; 339 | end; 340 | end):andThen(function() 341 | self._valueQueue[UserId] = nil; 342 | end):catch(function(err) 343 | self._logger:Log(3, `Error updating value for user {UserId}: {err}`); 344 | end); 345 | table.insert(Promises, PromiseUpdate); 346 | end; 347 | end; 348 | 349 | return Promise.all(Promises):finally(function() 350 | self._isSaving = false; 351 | self._lastSaved = tick(); 352 | end); 353 | end 354 | 355 | -- Gets the data for all the top boards (should only be used every 90-120 seconds) 356 | --[=[ 357 | @param boardTypes {LeaderboardType} | "All" 358 | @param optionalRange {[string]: number} | number 359 | @param sortDirection string 360 | @yields 361 | 362 | @return Promise<{AllTopData}> 363 | 364 | Gets the data for all the top boards (should only be used every 90-120 seconds) 365 | ]=] 366 | function Leaderboard:GetRecords(boardTypes, optionalRange, sortDirection) 367 | SmartAssert(type(sortDirection) == "nil" or typeof(sortDirection) == "string", "SortDirection must be a string"); 368 | sortDirection = sortDirection or "Descending"; 369 | 370 | -- If boardTypes is all, get all the boards 371 | if (boardTypes == "All") then 372 | boardTypes = self._types; 373 | end; 374 | 375 | -- Check if the optional range is valid 376 | if (optionalRange) then 377 | SmartAssert(type(optionalRange) == "number" or type(optionalRange) == "table", "Optional range must be a number or a table"); 378 | if (type(optionalRange) == "number") then 379 | SmartAssert(optionalRange <= 100, "You can only get the top 100."); 380 | SmartAssert(optionalRange > 0, "Optional range must be greater than 0"); 381 | else 382 | for k, v in optionalRange do 383 | SmartAssert(IsValidLeaderboardType(k, v), `Leaderboard type "{k}" is not valid`); 384 | SmartAssert(type(v) == "number", "Optional range values must be a number"); 385 | SmartAssert(v <= 100, "You can only get the top 100."); 386 | SmartAssert(v > 0, "Optional range values must be greater than 0"); 387 | end; 388 | end; 389 | end; 390 | 391 | self._isFetching = true; 392 | 393 | -- Get all the data 394 | local Promises = {}; 395 | for boardType in boardTypes do 396 | local BoardClass = self._boards[boardType] :: Board; 397 | SmartAssert(BoardClass ~= nil, `Board type {boardType} does not exist`) 398 | 399 | local Amount = optionalRange and (type(optionalRange) == "number" and optionalRange or optionalRange[boardType]) or 100; 400 | table.insert(Promises, BoardClass:Get(Amount, sortDirection):andThen(function(data) 401 | return { 402 | Type = boardType, -- We want to tell the difference between rolling and non-rolling 403 | Data = data, 404 | } 405 | end)); 406 | end; 407 | 408 | return Promise.all(Promises):finally(function() 409 | self._isFetching = false; 410 | self._lastFetch = tick(); 411 | end) :: Promise.TypedPromise<{AllTopData}>; 412 | end 413 | 414 | -- Increments the queued value(s) 415 | -- either Leaderboard:IncrementValues(nil, userId, amount) or Leaderboard:IncrementValues({"Daily", "Weekly"}, userId, amount 416 | --[=[ 417 | Increments the queued value(s) 418 | @param boardTypes {LeaderboardType} | "All" 419 | @param userId number 420 | @param amount number 421 | 422 | Increments the queued value(s) 423 | ]=] 424 | function Leaderboard:IncrementValues(boardTypes, userId, amount) 425 | SmartAssert(type(userId) == "number", "UserId must be a number"); 426 | SmartAssert(type(amount) == "number", "Amount must be a number"); 427 | SmartAssert(type(boardTypes) == "table" or type(boardTypes) == "string", "BoardTypes must be a table or a string"); 428 | 429 | -- If boardTypes is all, increment all the boards 430 | if (boardTypes == "All") then 431 | boardTypes = self._types; 432 | end; 433 | 434 | -- Reconcile the value queue 435 | if (not self._valueQueue[userId]) then 436 | self._valueQueue[userId] = {}; 437 | end; 438 | 439 | local Queue = self._valueQueue[userId]; 440 | for boardType in boardTypes do 441 | -- Update the value queue 442 | if (not Queue[boardType]) then 443 | Queue[boardType] = { 444 | Amount = amount, 445 | Board = self._boards[boardType], 446 | OperationType = "Increment", 447 | }; 448 | else 449 | Queue[boardType].Amount += amount; 450 | end; 451 | end; 452 | end 453 | 454 | -- Updates the queued value(s) 455 | -- either Leaderboard:SetValues("All", userId, value) or Leaderboard:SetValues({"Daily", "Weekly"}, userId, value 456 | --[=[ 457 | Updates the queued value(s) 458 | @param boardTypes {LeaderboardType} | "All" 459 | @param userId number 460 | @param value number | (number) -> (number) 461 | 462 | Updates the queued value(s) 463 | ]=] 464 | function Leaderboard:SetValues(boardTypes, userId, value) 465 | SmartAssert(type(userId) == "number", "UserId must be a number"); 466 | SmartAssert(type(value) == "function" or type(value) == "number", "Value must be a function or a number"); 467 | SmartAssert(type(boardTypes) == "table" or type(boardTypes) == "string", "BoardTypes must be a table or a string"); 468 | 469 | -- If boardTypes is all, update all the boards 470 | if (boardTypes == "All") then 471 | boardTypes = self._types; 472 | end; 473 | 474 | -- Reconcile the value queue 475 | if (not self._valueQueue[userId]) then 476 | self._valueQueue[userId] = {}; 477 | end; 478 | 479 | local Queue = self._valueQueue[userId]; 480 | for boardType in boardTypes do 481 | -- Update the value queue 482 | -- For set operations, always replace the value (don't accumulate) 483 | Queue[boardType] = { 484 | Amount = value, 485 | Board = self._boards[boardType], 486 | OperationType = "Set", 487 | }; 488 | end; 489 | end 490 | 491 | -- Updates the actual store value(s) (should only be used every 90-120 seconds) 492 | -- either Leaderboard:UpdateStoreValues(nil, userId, value) or Leaderboard:UpdateStoreValues({"Daily", "Weekly"}, userId, value 493 | --[=[ 494 | Updates the actual store value(s) (should only be used every 90-120 seconds) 495 | @param boardTypes {LeaderboardType} | "All" 496 | @param userId number 497 | @param value number | (number) -> (number) 498 | @yields 499 | 500 | Updates the actual store value(s) (should only be used every 90-120 seconds) 501 | ]=] 502 | function Leaderboard:UpdateStoreValues(boardTypes, userId, value) 503 | SmartAssert(type(userId) == "number", "UserId must be a number"); 504 | SmartAssert(type(value) == "function" or type(value) == "number", "Value must be a function or a number"); 505 | SmartAssert(type(boardTypes) == "table" or type(boardTypes) == "string", "BoardTypes must be a table or a string"); 506 | 507 | -- If boardTypes is all, update all the boards 508 | if (boardTypes == "All") then 509 | boardTypes = self._types; 510 | end; 511 | 512 | for boardType in boardTypes do 513 | local BoardClass = self._boards[boardType] :: Board; 514 | if (not BoardClass) then 515 | self._logger:Log(3, `Board Type "{boardType}" does not exist for user {userId}`); 516 | continue; 517 | end; 518 | 519 | BoardClass:Update(userId, value); 520 | end; 521 | end 522 | 523 | -- Destroys the leaderboard 524 | --[=[ 525 | Destroys the leaderboard 526 | ]=] 527 | function Leaderboard:Destroy() 528 | if (not self._isSaving and not self._isFetching) then 529 | local TimeSinceLastSaved = tick() - self._lastSaved; 530 | local TimeSinceLastFetch = tick() - self._lastFetch; 531 | 532 | --[[ 533 | If the lastSave was more than 2m ago and the lastFetch was more than 2m ago, OR the lastFetch was 0 and the lastSave was 0, then we should save the values 534 | This is to prevent us saving the values if we just recently ran an operation 535 | Unfortunately this is all we can do, and yes there could potentially be some people who don't get saved in the very last cycle 536 | --]] 537 | if ((TimeSinceLastSaved >= 60 * 2 and TimeSinceLastFetch >= 60 * 2) or (self._lastFetch == 0 and self._lastSaved == 0)) then 538 | self._logger:Log(1, "Leaderboard:Destroy() Saving values"); 539 | self:SaveValues(); 540 | end; 541 | end; 542 | 543 | -- Destroy all the boards 544 | for _, GlobalBoard in self._boards do 545 | GlobalBoard:Destroy(); 546 | end; 547 | 548 | -- Cancel all the threads 549 | for _, Thread in self._threads do 550 | if (typeof(Thread) == "thread") then 551 | Cancel(Thread); 552 | end; 553 | end; 554 | 555 | -- Disconnect all the connections 556 | for _, Connection in self._connections do 557 | Connection:Disconnect(); 558 | end; 559 | 560 | -- Logger destroy 561 | self._logger:Destroy(); 562 | 563 | -- Remove from leaderboards 564 | if (Leaderboards[self._serviceKey]) then 565 | Leaderboards[self._serviceKey] = nil; 566 | end; 567 | 568 | -- Destroy the metatable 569 | setmetatable(self, nil); 570 | end 571 | 572 | -- Bind to close, destroy all the leaderboards 573 | game:BindToClose(function() 574 | for _, GlobalBoard in Leaderboards do 575 | Spawn(function() 576 | GlobalBoard:Destroy(); 577 | end) 578 | end; 579 | end); 580 | 581 | -- Make indexing the class with the wrong key throw an error 582 | setmetatable(Leaderboard, { 583 | __index = function(_, key) 584 | error(`Attempt to get Leaderboard:{tostring(key)} (not a valid member)`, 2); 585 | end, 586 | __newindex = function(_, key, _) 587 | error(`Attempt to set Leaderboard:{tostring(key)} (not a valid member)`, 2); 588 | end, 589 | }) 590 | 591 | return table.freeze({ 592 | new = Leaderboard.new, 593 | IncrementValues = Leaderboard.IncrementValues, 594 | SetValues = Leaderboard.SetValues, 595 | UpdateStoreValues = Leaderboard.UpdateStoreValues, 596 | GetRecords = Leaderboard.GetRecords, 597 | Destroy = Leaderboard.Destroy, 598 | }) 599 | -------------------------------------------------------------------------------- /lib/Leaderboard/Board/MemoryShard.luau: -------------------------------------------------------------------------------- 1 | -- Instead of one monolithic leaderboard, we can use multiple shards to store the data 2 | local MemoryStoreService = game:GetService("MemoryStoreService"); 3 | 4 | local Leaderboard = script.Parent.Parent; 5 | local Packages = Leaderboard.Packages; 6 | local Promise = require(Packages.Promise); 7 | local Util = require(Leaderboard.Util); 8 | local Logger = require(Leaderboard.Logger); 9 | 10 | -- Variables 11 | local Compression = Util.Compression; 12 | local SmartAssert = Util.SmartAssert; 13 | local FoundInTable = Util.FoundInTable; 14 | local GetDaysInMonth = Util.GetDaysInMonth; 15 | local FNV_1A_32 = Util.FNV_1A_32; 16 | local FALLBACK_EXPIRY_TIMES = Util.FALLBACK_EXPIRY_TIMES; 17 | local SHARD_CLEANUP_SETTINGS = Util.SHARD_CLEANUP_SETTINGS; 18 | local OFFLINE_ENVIRONMENT = game.GameId == 0; 19 | 20 | --[=[ 21 | @within MemoryShard 22 | @type LeaderboardType "Hourly" | "Daily" | "Weekly" | "Monthly" | "Yearly" | "AllTime" | string 23 | ]=] 24 | export type LeaderboardType = "Hourly" | "Daily" | "Weekly" | "Monthly" | "AllTime" | string; 25 | 26 | --[=[ 27 | @within MemoryShard 28 | @type MemoryShard () -> MemoryShard 29 | ]=] 30 | export type MemoryShard = typeof(setmetatable({} :: MemoryShardArguments, {} :: Object)); 31 | 32 | --[=[ 33 | @within MemoryShard 34 | @interface MemoryShardArguments 35 | @field _type LeaderboardType 36 | @field _boardKey string 37 | @field _fallbackExpiry number 38 | @field _isRollingExpiry boolean 39 | @field _shards {MemoryStoreSortedMap} 40 | @field _shardCount number 41 | @field _logger Logger.Logger? 42 | ]=] 43 | export type MemoryShardArguments = { 44 | _type: LeaderboardType, 45 | _boardKey: string, 46 | _fallbackExpiry: number, 47 | _isRollingExpiry: boolean, 48 | _shards: {MemoryStoreSortedMap}, 49 | _shardCount: number, 50 | _logger: Logger.Logger?, 51 | _cleanupThread: thread?, 52 | _maxEntriesPerShard: number, 53 | } 54 | 55 | --[=[ 56 | @within MemoryShard 57 | @interface TopData 58 | @field Rank number 59 | @field UserId number 60 | @field Value number 61 | @field Username string 62 | @field DisplayName string 63 | ]=] 64 | export type TopData = { 65 | Rank: number, 66 | UserId: number, 67 | Value: number, 68 | Username: string, 69 | DisplayName: string, 70 | } 71 | 72 | --[=[ 73 | @within MemoryShard 74 | @interface Object 75 | @field __index Object 76 | @field _getShardKey (self: MemoryShard, userId: number) -> (number) 77 | @field _getAsync (self: MemoryShard, key: string) -> any 78 | @field _setAsync (self: MemoryShard, key: string, value: any, expiry: number, sortKey: number) -> boolean 79 | @field _updateAsync (self: MemoryShard, key: string, transformer: (any) -> (any), expiry: number) -> boolean 80 | @field _cleanupShards (self: MemoryShard, maxEntriesPerShard: number?) -> Promise.TypedPromise 81 | @field _removeKeyFromShard (self: MemoryShard, shardIndex: number, key: string) -> Promise.TypedPromise 82 | @field _getShardSize (self: MemoryShard, shardIndex: number) -> Promise.TypedPromise 83 | @field CleanupNow (self: MemoryShard, maxEntriesPerShard: number?) -> Promise.TypedPromise 84 | @field Set (self: MemoryShard, userId: number, value: number | (number) -> (number)) -> Promise.TypedPromise 85 | @field Get (self: MemoryShard, amount: number, sortDirection: string) -> Promise.TypedPromise<{TopData}> 86 | @field Destroy (self: MemoryShard) -> () 87 | @field new (leaderboardType: LeaderboardType, boardKey: string, shardCount: number, rollingExpiry: number?, debugMode: boolean?) -> MemoryShard 88 | ]=] 89 | type Object = { 90 | __index: Object, 91 | _getShardKey: (self: MemoryShard, userId: number) -> (number), 92 | _getExpiry: (leaderboardType: LeaderboardType, leaderboardKey: number | string | {number & string}) -> (number | nil), 93 | _getAsync: (self: MemoryShard, userId: number) -> Promise.TypedPromise, 94 | _setAsync: (self: MemoryShard, userId: number, value: any, expiry: number, sortKey: number) -> Promise.TypedPromise, 95 | _updateAsync: (self: MemoryShard, userId: number, transformer: (any) -> (any), expiry: number) -> Promise.TypedPromise, 96 | _cleanupShards: (self: MemoryShard, maxEntriesPerShard: number?) -> Promise.TypedPromise, 97 | _removeKeyFromShard: (self: MemoryShard, shardIndex: number, key: string) -> Promise.TypedPromise, 98 | _getShardSize: (self: MemoryShard, shardIndex: number) -> Promise.TypedPromise, 99 | CleanupNow: (self: MemoryShard, maxEntriesPerShard: number?) -> Promise.TypedPromise, 100 | Set: (self: MemoryShard, userId: number, value: number | (number) -> (number)) -> Promise.TypedPromise, 101 | Get: (self: MemoryShard, amount: number, sortDirection: string) -> Promise.TypedPromise<{TopData}>, 102 | Destroy: (self: MemoryShard) -> (), 103 | new: (leaderboardType: LeaderboardType, boardKey: string, shardCount: number, rollingExpiry: number?, debugMode: boolean?) -> MemoryShard, 104 | } 105 | 106 | --[=[ 107 | @class MemoryShard 108 | 109 | A memory shard is a way to split up the leaderboard into multiple shards, each shard is a MemoryStoreSortedMap 110 | ]=] 111 | local MemoryShard: Object = {} :: Object; 112 | MemoryShard.__index = MemoryShard; 113 | 114 | local function GetResultCount(shardCount: number): number 115 | -- Depending on how many shards there are the result count should be based on 116 | -- For example, 1 shard is 100 records, 2 shards is 50 records, 4 shards is 25 records, 10 shards is 10 records 117 | local BASE = 100; 118 | local RECORD_COUNT = math.ceil(BASE / shardCount); 119 | return math.max(RECORD_COUNT, 1); 120 | end 121 | 122 | --[=[ 123 | @param leaderboardType LeaderboardType 124 | @param boardKey string 125 | @param shardCount number 126 | @param rollingExpiry number? 127 | @param debugMode boolean? 128 | 129 | Creates a new MemoryShard. This is not a viable solution anymore, as the limits to MemoryStoreService are too poor. 130 | See: https://devforum.roblox.com/t/introducing-memorystore-sortedmap-sortkey-beta/2673559/23 131 | ]=] 132 | function MemoryShard.new(leaderboardType: LeaderboardType, boardKey: string, shardCount: number, rollingExpiry: number?, debugMode: boolean?) 133 | local self = setmetatable({} :: MemoryShardArguments, MemoryShard); 134 | self._shards = {}; 135 | self._shardCount = shardCount; 136 | self._type = leaderboardType; 137 | self._isRollingExpiry = rollingExpiry ~= nil; 138 | self._fallbackExpiry = (rollingExpiry ~= nil) and rollingExpiry or (leaderboardType == "Monthly") and GetDaysInMonth() * 86400 or FALLBACK_EXPIRY_TIMES[self._type]; 139 | self._boardKey = boardKey; 140 | self._logger = Logger.new("MemoryShard", debugMode or false); 141 | self._maxEntriesPerShard = SHARD_CLEANUP_SETTINGS.MAX_ENTRIES_PER_SHARD; 142 | self._cleanupThread = nil; 143 | 144 | -- Each shard is a MemoryStoreSortedMap with a unique name based on the service name and shard index 145 | for Index = 1, shardCount do 146 | if (OFFLINE_ENVIRONMENT) then 147 | continue; 148 | end; 149 | 150 | local Success, Response = pcall(function() 151 | return MemoryStoreService:GetSortedMap(`{boardKey}_Shard{Index}`); 152 | end); 153 | 154 | if (Success) then 155 | self._logger:Log(1, `Created MemoryStoreSortedMap for "{boardKey}_Shard{Index}"`); 156 | else 157 | self._logger:Log(1, `Failed to create MemoryStoreSortedMap for "{boardKey}_Shard{Index}": {Response}`); 158 | end; 159 | 160 | self._shards[Index] = Success and Response or nil; 161 | end; 162 | 163 | -- Start periodic cleanup thread 164 | if (not OFFLINE_ENVIRONMENT) then 165 | self._cleanupThread = task.spawn(function() 166 | while (true) do 167 | task.wait(SHARD_CLEANUP_SETTINGS.CLEANUP_INTERVAL); 168 | 169 | self:_cleanupShards(self._maxEntriesPerShard):catch(function(err) 170 | self._logger:Log(3, `Cleanup cycle failed: {err}`); 171 | end); 172 | end; 173 | end); 174 | 175 | self._logger:Log(1, `Started cleanup thread for {boardKey} with max {self._maxEntriesPerShard} entries per shard`); 176 | end; 177 | 178 | return self; 179 | end 180 | 181 | --[=[ 182 | @private 183 | @return number | nil 184 | 185 | Gets the expiry for a specific leaderboard type and key 186 | ]=] 187 | function MemoryShard:_getExpiry() 188 | if (self._isRollingExpiry) then 189 | -- rolling expiry is dynamic so we can't return a fixed value 190 | return nil; 191 | end; 192 | 193 | local LeaderboardType = self._type; 194 | local DateTimeNow = DateTime.now(); 195 | local DateTable = DateTimeNow:ToUniversalTime(); 196 | local CurrentDayOfWeek = (math.floor(DateTimeNow.UnixTimestamp / 86400) + 4) % 7 + 1; 197 | local DaysInCurrentMonth = GetDaysInMonth(); 198 | 199 | -- Define 200 | local TotalSecondsInAnHour = FALLBACK_EXPIRY_TIMES["Hourly"]; 201 | local TotalSecondsInADay = FALLBACK_EXPIRY_TIMES["Daily"]; 202 | local TotalSecondsInAWeek = FALLBACK_EXPIRY_TIMES["Weekly"]; 203 | local TotalSecondsInMonth = DaysInCurrentMonth * 86400; 204 | 205 | -- Seconds passed for Hourly, Daily, Weekly, Monthly 206 | local SecondsPassedThisHour = DateTable.Minute * 60 + DateTable.Second; 207 | local SecondsPassedToday = (DateTable.Hour * 3600) + (DateTable.Minute * 60) + DateTable.Second; 208 | local SecondsPassedThisWeek = (CurrentDayOfWeek - 1) * 86400 + SecondsPassedToday; 209 | local SecondsPassedThisMonth = (DateTable.Day - 1) * 86400 + SecondsPassedToday; 210 | 211 | if (LeaderboardType == "Hourly") then 212 | local SecondsLeft = (TotalSecondsInAnHour - SecondsPassedThisHour); 213 | return SecondsLeft; 214 | end; 215 | 216 | if (LeaderboardType == "Daily") then 217 | local SecondsLeft = (TotalSecondsInADay - SecondsPassedToday); 218 | return SecondsLeft; 219 | end; 220 | 221 | if (LeaderboardType == "Weekly") then 222 | local SecondsLeft = (TotalSecondsInAWeek - SecondsPassedThisWeek); 223 | return SecondsLeft; 224 | end; 225 | 226 | if (LeaderboardType == "Monthly") then 227 | local SecondsLeft = (TotalSecondsInMonth - SecondsPassedThisMonth); 228 | return SecondsLeft; 229 | end; 230 | 231 | return nil; 232 | end 233 | 234 | 235 | -- Gets the Shard Key using prefixing and modulo 236 | --[=[ 237 | @param userId number 238 | @private 239 | @return number 240 | 241 | Gets the Shard Key using prefixing and modulo 242 | ]=] 243 | function MemoryShard:_getShardKey(userId) 244 | SmartAssert(userId, "userId must be provided"); 245 | SmartAssert(typeof(userId) == "number", "userId must be a number"); 246 | 247 | -- Use the modulo operation to get the shard index 248 | local ShardIndex = (FNV_1A_32(tostring(userId)) % self._shardCount) + 1; 249 | return ShardIndex; 250 | end 251 | 252 | --[=[ 253 | @param userId string 254 | @private 255 | @return Promise.TypedPromise<{TopData}> 256 | 257 | Gets the value for a specific key from a specific shard 258 | ]=] 259 | function MemoryShard:_getAsync(userId) 260 | local ShardKey = self:_getShardKey(userId); 261 | local Shard = self._shards[ShardKey] or self._shards[1]; 262 | 263 | return Promise.new(function(resolve, reject) 264 | local Success, Result = pcall(function() 265 | if (not Shard) then 266 | return; 267 | end; 268 | 269 | return Shard:GetAsync(userId); 270 | end); 271 | if (not Success) then 272 | return reject(Result); 273 | end; 274 | return resolve(Result); 275 | end):catch(warn); 276 | end 277 | 278 | --[=[ 279 | @param userId string 280 | @param value any 281 | @param expiry number 282 | @param sortKey number 283 | @private 284 | @return () 285 | 286 | Destroys all the shards for this MemoryShard 287 | ]=] 288 | function MemoryShard:_setAsync(userId, value, expiry, sortKey) 289 | local ShardKey = self:_getShardKey(userId); 290 | local Shard = self._shards[ShardKey] or self._shards[1]; 291 | 292 | return Promise.new(function(resolve, reject) 293 | local Success, Result = pcall(function() 294 | if (not Shard) then 295 | return; 296 | end; 297 | 298 | return Shard:SetAsync(userId, value, expiry, sortKey); 299 | end); 300 | if (not Success) then 301 | return reject(Result); 302 | end; 303 | return resolve(Result); 304 | end):catch(warn); 305 | end 306 | 307 | --[=[ 308 | @param userId string 309 | @param transformer (any) -> (any) 310 | @param expiry number 311 | @private 312 | @return () 313 | 314 | Updates the value for a specific key from a specific shard 315 | ]=] 316 | function MemoryShard:_updateAsync(userId, transformer, expiry) 317 | local ShardKey = self:_getShardKey(userId); 318 | local Shard = self._shards[ShardKey] or self._shards[1]; 319 | 320 | return Promise.new(function(resolve, reject) 321 | local Success, Result = pcall(function() 322 | if (not Shard) then 323 | return; 324 | end; 325 | 326 | return Shard:UpdateAsync(userId, transformer, expiry); 327 | end); 328 | if (not Success) then 329 | return reject(Result); 330 | end; 331 | return resolve(Result); 332 | end):catch(warn); 333 | end 334 | 335 | -- Gets the top data from all the shards for this MemoryShard 336 | --[=[ 337 | @param topAmount number 338 | @param sortDirection string 339 | @return {TopData} 340 | @yields 341 | 342 | Gets the top data from all the shards for this MemoryShard 343 | ]=] 344 | function MemoryShard:Get(topAmount, sortDirection) 345 | SmartAssert(topAmount, "topAmount must be provided"); 346 | SmartAssert(sortDirection, "sortDirection must be provided"); 347 | SmartAssert(typeof(topAmount) == "number", "topAmount must be a number"); 348 | SmartAssert(typeof(sortDirection) == "string", "sortDirection must be a string"); 349 | 350 | local CombinedResults = {}; 351 | local ResultsToFetch = GetResultCount(self._shardCount); 352 | 353 | self._logger:Log(1, `[{self._boardKey}]: Fetching {ResultsToFetch} records per shard (max {topAmount} total) in {self._type}`); 354 | 355 | local function ProcessRecord(record: {key: string, value: number}) 356 | local IndexFound, Found = FoundInTable(CombinedResults, tonumber(record.key)); 357 | if (Found) then 358 | local FoundValueHigherThanCurrent = (Found.value > record.value); 359 | if (not FoundValueHigherThanCurrent) then 360 | -- The found value already there, is LOWER than this new one, update it 361 | CombinedResults[IndexFound].value = record.value; 362 | end; 363 | else 364 | -- There is no found value, add it to the combined results 365 | table.insert(CombinedResults, { 366 | key = tonumber(record.key), 367 | value = record.value 368 | }); 369 | end; 370 | end 371 | 372 | local function ProcessShard(shard: MemoryStoreSortedMap) 373 | return function() 374 | local ShardData = shard:GetRangeAsync( 375 | Enum.SortDirection[sortDirection], 376 | ResultsToFetch 377 | ); 378 | 379 | if (self._isRollingExpiry) then 380 | for _, record in ShardData do 381 | -- get rid of expiry and creation date padding, convert back to number 382 | record.value = tonumber(record.value.value); 383 | end; 384 | end; 385 | 386 | return ShardData; 387 | end; 388 | end 389 | 390 | -- Go through the shards, extract the data, and combine it 391 | local Promises = {}; 392 | for _, Shard in self._shards do 393 | local ShardPromise = Promise.new(function(resolve, reject) 394 | local Success, Result = pcall(ProcessShard(Shard)); 395 | if (not Success) then 396 | return reject(Result); 397 | end; 398 | return resolve(Result); 399 | end):andThen(function(data) 400 | for _, record in data do 401 | ProcessRecord(record); 402 | end; 403 | end):catch(warn); 404 | table.insert(Promises, ShardPromise); 405 | end; 406 | 407 | -- Sort it 408 | return Promise.all(Promises):andThen(function() 409 | -- Sort the combined results after all promises have resolved 410 | table.sort(CombinedResults, function(a, b) 411 | return a.value > b.value; 412 | end); 413 | 414 | -- Trim the results if necessary 415 | if (#CombinedResults > topAmount) then 416 | self._logger:Log(1, `[{self._boardKey}]: Trimming results from {#CombinedResults} to {topAmount}`); 417 | for i = #CombinedResults, topAmount + 1, -1 do 418 | table.remove(CombinedResults, i); 419 | end; 420 | end; 421 | 422 | self._logger:Log(1, `[{self._boardKey}]: Returning {#CombinedResults} records`); 423 | return CombinedResults; 424 | end):catch(function(err: string) 425 | warn(`An error occurred while processing shards: {err}`); 426 | end); 427 | end 428 | 429 | -- Updates the value for a specific user in a specific shard 430 | --[=[ 431 | @param userId number 432 | @param value number | (number) -> (number) 433 | @return boolean 434 | @yields 435 | 436 | Updates the value for a specific user in a specific shard 437 | ]=] 438 | function MemoryShard:Set(userId, value) 439 | SmartAssert(userId, "userId must be provided"); 440 | SmartAssert(value, "transformer must be provided"); 441 | SmartAssert(typeof(userId) == "number", "userId must be a number"); 442 | SmartAssert(typeof(value) == "function" or typeof(value) == "number", "transformer must be a function or a number"); 443 | 444 | -- Rolling support 445 | if (self._isRollingExpiry) then 446 | return self:_getAsync(userId):andThen(function(oldValue) 447 | local FirstTime = oldValue == nil; 448 | local Created = if not FirstTime then oldValue._created else nil 449 | oldValue = if FirstTime then 0 else Compression.Decompress(oldValue.value); 450 | local TransformedValue = (typeof(value) == "function" and value(oldValue) or value); 451 | 452 | local CompressedValue = Compression.Compress(TransformedValue); 453 | local NewSortKey = TransformedValue; 454 | 455 | local NewValue = { 456 | value = CompressedValue, -- this will be the value we save, we need to save as table to carry over creation data 457 | _created = if FirstTime then os.time() else Created, 458 | _expiry = self._fallbackExpiry 459 | }; 460 | 461 | -- update the value with appropriate expiry time left 462 | self:_setAsync(userId, NewValue, NewValue._expiry - (os.time() - NewValue._created), NewSortKey):catch(warn); 463 | return true; 464 | end); 465 | end; 466 | 467 | -- Update the value 468 | return self:_updateAsync(userId, function(oldValue) 469 | oldValue = oldValue and Compression.Decompress(oldValue) or 0; 470 | local TransformedValue = (typeof(value) == "function") and value(oldValue) or value; 471 | local CompressedValue = Compression.Compress(TransformedValue); 472 | local NewSortKey = TransformedValue; 473 | 474 | if (CompressedValue and NewSortKey) then 475 | return CompressedValue, NewSortKey; 476 | end; 477 | end, self:_getExpiry() or self._fallbackExpiry):catch(warn); 478 | end 479 | 480 | --[=[ 481 | @param maxEntriesPerShard number? 482 | @return Promise.TypedPromise 483 | @yields 484 | 485 | Manually triggers cleanup of excess entries from all shards. 486 | Useful for immediate cleanup when needed. 487 | ]=] 488 | function MemoryShard:CleanupNow(maxEntriesPerShard: number?) 489 | return self:_cleanupShards(maxEntriesPerShard or self._maxEntriesPerShard); 490 | end 491 | 492 | --[=[ 493 | @param shardIndex number 494 | @return number 495 | @yields 496 | 497 | Gets the size of a specific shard. 498 | ]=] 499 | function MemoryShard:_getShardSize(shardIndex: number) 500 | local Shard = self._shards[shardIndex]; 501 | if (not Shard) then 502 | return 0; 503 | end; 504 | 505 | local Success, Result = pcall(function() 506 | return Shard:GetSizeAsync(); 507 | end); 508 | 509 | if (not Success) then 510 | return 0; 511 | end; 512 | 513 | return Result; 514 | end 515 | 516 | --[=[ 517 | @param shardIndex number 518 | @param key string 519 | @return Promise.TypedPromise 520 | @yields 521 | 522 | Removes the key from a specific shard. 523 | ]=] 524 | function MemoryShard:_removeKeyFromShard(shardIndex: number, key: string) 525 | return Promise.new(function(resolve, reject) 526 | local Success, Result = pcall(function() 527 | local Shard = self._shards[shardIndex]; 528 | if (not Shard) then 529 | return reject("Shard not found"); 530 | end; 531 | 532 | return Shard:RemoveAsync(key); 533 | end); 534 | 535 | if (not Success) then 536 | return reject(Result); 537 | end; 538 | 539 | return resolve(Result); 540 | end):catch(warn); 541 | end 542 | 543 | --[=[ 544 | @param maxEntriesPerShard number? 545 | @private 546 | @return Promise.TypedPromise 547 | 548 | Cleans up excess entries from all shards to prevent storage overflow. 549 | Removes the lowest-scoring entries when a shard exceeds the specified limit. 550 | ]=] 551 | function MemoryShard:_cleanupShards(maxEntriesPerShard: number?) 552 | maxEntriesPerShard = maxEntriesPerShard or 1000; -- Default limit per shard 553 | 554 | local CleanupPromises = {}; 555 | 556 | for shardIndex, shard in self._shards do 557 | if (not shard) then continue; end; 558 | 559 | local CleanupPromise = Promise.new(function(resolve, reject) 560 | local Success, Result = pcall(function() 561 | local TotalEntries = self:_getShardSize(shardIndex); 562 | 563 | -- Only cleanup if we significantly exceed the limit 564 | local BaseCleanupThreshold = 50; 565 | if (TotalEntries > maxEntriesPerShard and TotalEntries > BaseCleanupThreshold) then 566 | local ExcessCount = TotalEntries - maxEntriesPerShard; 567 | self._logger:Log(1, `[{self._boardKey}]: Shard {shardIndex} has {TotalEntries} entries, removing {ExcessCount} lowest entries`); 568 | 569 | -- Remove entries in batches (GetRangeAsync max is 200) 570 | local RemovedCount = 0; 571 | local BatchSize = math.min(200, ExcessCount); 572 | 573 | while (RemovedCount < ExcessCount) do 574 | local CurrentBatchSize = math.min(BatchSize, ExcessCount - RemovedCount); 575 | 576 | -- Get the lowest-scoring entries (ascending order) 577 | local Success, LowEntries = pcall(function() 578 | return shard:GetRangeAsync(Enum.SortDirection.Ascending, CurrentBatchSize); 579 | end); 580 | if (not Success) then 581 | return reject(LowEntries); 582 | end; 583 | 584 | for _, entry in LowEntries do 585 | self:_removeKeyFromShard(shardIndex, entry.key); 586 | RemovedCount += 1; 587 | end; 588 | 589 | if (#LowEntries == 0) then 590 | break; 591 | end; 592 | end; 593 | 594 | self._logger:Log(1, `[{self._boardKey}]: Cleaned up shard {shardIndex}, removed {RemovedCount} entries`); 595 | else 596 | self._logger:Log(1, `[{self._boardKey}]: Shard {shardIndex} has {TotalEntries} entries (within limit of {maxEntriesPerShard})`); 597 | end; 598 | 599 | return true; 600 | end); 601 | 602 | if (not Success) then 603 | self._logger:Log(3, `[{self._boardKey}]: Failed to cleanup shard {shardIndex}: {Result}`); 604 | return reject(Result); 605 | end; 606 | 607 | return resolve(Result); 608 | end); 609 | 610 | table.insert(CleanupPromises, CleanupPromise); 611 | end; 612 | 613 | return Promise.all(CleanupPromises):andThen(function() 614 | self._logger:Log(1, `[{self._boardKey}]: Completed cleanup for all shards`); 615 | return true; 616 | end):catch(function(err) 617 | self._logger:Log(3, `[{self._boardKey}]: Cleanup failed: {err}`); 618 | return false; 619 | end); 620 | end 621 | 622 | --[=[ 623 | @return () 624 | @yields 625 | 626 | Destroys the MemoryShard 627 | ]=] 628 | function MemoryShard:Destroy() 629 | -- Cancel cleanup thread 630 | if (self._cleanupThread and typeof(self._cleanupThread) == "thread") then 631 | task.cancel(self._cleanupThread); 632 | self._logger:Log(1, `Cancelled cleanup thread for {self._boardKey}`); 633 | end; 634 | 635 | self._logger:Destroy(); 636 | setmetatable(self, nil); 637 | end 638 | 639 | -- Make indexing the wrong key throw an error 640 | setmetatable(MemoryShard, { 641 | __index = function(_, key) 642 | error(`Attempt to get MemoryShard:{tostring(key)} (not a valid member)`, 2); 643 | end, 644 | __newindex = function(_, key, _) 645 | error(`Attempt to set MemoryShard:{tostring(key)} (not a valid member)`, 2); 646 | end, 647 | }) 648 | 649 | return table.freeze({ 650 | new = MemoryShard.new, 651 | }); 652 | --------------------------------------------------------------------------------