Spaces:
Running
Running
File size: 17,821 Bytes
5e00f68 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 | {
"version": "1.0.0",
"tests": 66,
"kit_bytes": 75081,
"total_lines": 2228,
"modules": [
{
"name": "SaveData",
"side": "Server",
"summary": "Session-locked player saves on DataStoreService.",
"usage": " local SaveData = require(path.to.Studwright.SaveData)\n local store = SaveData.new({\n name = \"PlayerData_v1\",\n template = { Coins = 0, Level = 1, Inventory = {} },\n })\n Players.PlayerAdded:Connect(function(player)\n local profile = store:Load(player) -- nil if the player left or the save is busy\n if not profile then return end\n profile.Data.Coins += 10 -- edit Data freely; it autosaves\n end)\n\nWhat it handles for you:\n * Session lock: one server owns a save at a time, so a player who hops servers fast\n can't duplicate items. A lock older than lockTimeout (default 30 min) is treated as stale\n (crashed server) and taken over.\n * Only UpdateAsync, retries with backoff, request-budget waits.\n * Template reconcile (new keys reach old saves) and numbered migrations.\n * Autosave, save on leave, and a BindToClose flush so shutdowns don't lose progress.\n * Erase(userId) for right-to-erasure requests.",
"functions": [
"SaveData.new(config)",
"SaveData:Load(player)",
"SaveData:Get(player)",
"SaveData:WaitFor(player, timeout: number?)",
"SaveData:Erase(userId: number)",
"SaveData:Peek(userId: number)",
"Profile:IsActive()",
"Profile:Save(release: boolean?)",
"Profile:Release()",
"Profile:Reset()"
],
"lines": 388,
"free": false
},
{
"name": "Receipts",
"side": "Server",
"summary": "A MarketplaceService.ProcessReceipt handler that never double-grants and never loses a purchase.",
"usage": "and never loses a purchase.\n\n local Receipts = require(path.to.Studwright.Receipts)\n Receipts.start(store, {\n [123456789] = function(profile, receipt) -- Developer Product id -> grant\n profile.Data.Coins += 1000\n end,\n })\n\nRules it follows (from Roblox's own guidance on ProcessReceipt):\n * Grant only when the buyer's save is loaded on THIS server; otherwise NotProcessedYet,\n and Roblox retries later (for example when the player rejoins).\n * Every PurchaseId is remembered in the save (last 100 kept), so a retried receipt is\n acknowledged without granting twice.\n * PurchaseGranted is returned only after the save containing that PurchaseId succeeded.\n * A grant function that errors does not consume the purchase.",
"functions": [
"Receipts.process(receipt)",
"Receipts.start(store, map)",
"Receipts.add(productId: number, fn)"
],
"lines": 92,
"free": false
},
{
"name": "Codes",
"side": "Server",
"summary": "Promo / Twitter-style codes, redeemed safely on the server.",
"usage": " local codes = Codes.new({\n RELEASE = { reward = function(profile) profile.Data.Coins += 500 end },\n SPOOKY = { reward = giveHat, expires = DateTime.fromIsoDate(\"2026-11-01\").UnixTimestamp },\n [\"1KLIKES\"] = { reward = giveGems, minLevel = 5, levelOf = function(p) return p.Data.Level end },\n })\n local ok, message = codes:Redeem(profile, textFromClient)\n\nCodes are case- and space-insensitive, each one works once per player (remembered in the\nsave), can expire, can be limited to players at or above a level, and attempts are\nthrottled (5 per 30 s per player by default) to stop brute-forcing.",
"functions": [
"Codes.new(list, options)",
"Codes:Add(name: string, def)",
"Codes:Remove(name: string)",
"Codes:Redeem(profile, text)",
"Codes:Active()"
],
"lines": 126,
"free": false
},
{
"name": "DailyRewards",
"side": "Server",
"summary": "A login streak that resets at a fixed hour, not 24 h after the last claim.",
"usage": " local daily = DailyRewards.new({\n rewards = { 100, 150, 200, 300, 400, 600, \"Chest\" }, -- day 1..7, then it cycles\n grant = function(profile, reward, day) ... end,\n })\n local status = daily:Status(profile) -- { canClaim, day, streak, nextIn, reward }\n local ok, reward, day = daily:Claim(profile)\n\nDays are counted in UTC (resetHour, default 0 = midnight UTC). Missing one whole day resets\nthe streak unless graceDays allows it. All checks run on the server from os.time(), so\nchanging the device clock does nothing.",
"functions": [
"DailyRewards.new(config)",
"DailyRewards:dayNumber(t: number)",
"DailyRewards:Status(profile)",
"DailyRewards:Claim(profile)"
],
"lines": 90,
"free": false
},
{
"name": "PlaytimeRewards",
"side": "Server",
"summary": "\"stay 5 / 10 / 20 minutes, get a gift\" for each play session.",
"usage": " local gifts = PlaytimeRewards.new({\n { after = 5 * 60, reward = function(profile) profile.Data.Coins += 100 end, label = \"100 Coins\" },\n { after = 15 * 60, reward = giveSpin, label = \"Free Spin\" },\n })\n gifts:Begin(profile) -- when the save loads\n gifts:Status(profile) -- list of { label, after, remaining, claimed, ready }\n gifts:Claim(profile, 1) -- from a RemoteFunction; the server checks the time\n gifts:End(profile) -- on leave (Cleanup does this if you add it)\n\nThe timer lives on the server, so clients can't fast-forward it. Set `persist = true` to keep\nprogress across sessions on the same UTC day instead of restarting each join.",
"functions": [
"PlaytimeRewards.new(list, options)",
"PlaytimeRewards:Begin(profile)",
"PlaytimeRewards:Elapsed(profile)",
"PlaytimeRewards:Status(profile)",
"PlaytimeRewards:Claim(profile, index: number)",
"PlaytimeRewards:End(profile)"
],
"lines": 116,
"free": false
},
{
"name": "Leaderboard",
"side": "Server",
"summary": "Leaderstats in the player list + an all-time global top list.",
"usage": " Leaderboard.stats(player, { Coins = \"Coins\", Level = \"Level\" }, profile) -- leaderstats folder, kept in sync\n local board = Leaderboard.global({ name = \"TopCoins\", size = 50, refresh = 120 })\n board:Submit(player.UserId, profile.Data.Coins) -- on leave / autosave (throttled per user)\n board:Top() -- { { rank, userId, value, name } ... } (cached)\n board.Updated:Connect(function(rows) ... end)\n\nOrderedDataStores only store integers, so values are floored and capped at 2^53. For values\nthat can grow past that (huge simulator numbers), use Leaderboard.encodeLog / decodeLog.",
"functions": [
"Leaderboard.stats(player, map, profile, interval: number?)",
"Leaderboard.encodeLog(x: number)",
"Leaderboard.decodeLog(n: number)",
"Leaderboard.global(config)",
"Leaderboard:Submit(userId: number, value: number, force: boolean?)",
"Leaderboard:Refresh()",
"Leaderboard:Top()"
],
"lines": 152,
"free": false
},
{
"name": "LootTable",
"side": "Shared",
"summary": "Weighted rolls for eggs, crates, spins and drops, with pity and luck.",
"usage": " local egg = LootTable.new({\n { id = \"Dog\", weight = 60, rarity = \"Common\" },\n { id = \"Cat\", weight = 30, rarity = \"Rare\" },\n { id = \"Dragon\", weight = 9.5, rarity = \"Epic\" },\n { id = \"Phoenix\",weight = 0.5, rarity = \"Legendary\" },\n }, { pity = { rarity = \"Legendary\", after = 150 } })\n\n local item = egg:Roll({ luck = 2, state = profile.Data.EggPity }) -- server only\n egg:Odds({ luck = 2 }) --> { { id = \"Dog\", chance = 0.5797, text = \"57.97%\" }, ... } for the UI\n\nLuck multiplies the weight of every entry rarer than `luckFrom` (default: all but the most common),\nand Odds() shows players the true chances after luck, as Roblox's policy on paid random items asks.\nPity guarantees the pity rarity after `after` rolls without it; the counter lives in a table you\nkeep in the save. Rolls use a server Random object (pass `rng` for seeded tests).",
"functions": [
"LootTable.new(entries, options)",
"LootTable:Odds(options)",
"LootTable:Roll(options)",
"LootTable:RollMany(n: number, options)"
],
"lines": 127,
"free": false
},
{
"name": "Inventory",
"side": "Shared",
"summary": "Stackable items, slot limits and equipping, stored as plain save data.",
"usage": " local inv = Inventory.wrap(profile.Data.Inventory, {\n capacity = 40, -- slots\n maxStack = { Potion = 99, Sword = 1 }, -- default stack limit: 1\n })\n inv:Add(\"Potion\", 5) --> added (number), leftover (number)\n inv:Remove(\"Potion\", 2) --> true / false (never goes negative)\n inv:Count(\"Potion\") --> 3\n inv:Equip(\"Sword\", \"Weapon\")\n inv.Changed:Connect(function(itemId, newCount) ... end)\n\nThe saved shape is DataStore-safe (lists and string keys only):\n { slots = { { id = \"Potion\", n = 3 }, ... }, equipped = { Weapon = \"Sword\" } }",
"functions": [
"Inventory.wrap(data, options)",
"Inventory:StackLimit(id: string)",
"Inventory:Count(id: string)",
"Inventory:Has(id: string, amount: number?)",
"Inventory:FreeSlots()",
"Inventory:SpaceFor(id: string)",
"Inventory:Add(id: string, amount: number?)",
"Inventory:Remove(id: string, amount: number?)",
"Inventory:Equip(id: string, slot: string)",
"Inventory:Unequip(slot: string)",
"Inventory:Equipped(slot: string)",
"Inventory:List()"
],
"lines": 175,
"free": false
},
{
"name": "Currency",
"side": "Shared",
"summary": "Wallets with multipliers, safe spending and rebirths.",
"usage": "local coins = Currency.new(\"Coins\", { max = 1e300 })\ncoins:AddMultiplier(\"VIP\", 2) -- e.g. a Game Pass\ncoins:Give(profile, 10) --> 20 (multiplied)\ncoins:Spend(profile, 15) --> true / false (never below zero)\ncoins.Changed:Connect(function(profile, newValue, delta) ... end)\n\nlocal rebirth = Currency.rebirth({ currency = coins, base = 1e4, growth = 3, key = \"Rebirths\",\n onRebirth = function(profile, count) profile.Data.Level = 1 end })\nrebirth:Cost(profile) --> 10000 for the first, 30000 for the second, ...\nrebirth:Try(profile) --> true / false",
"functions": [
"Currency.new(key: string, options)",
"Currency:Get(profile)",
"Currency:AddMultiplier(name: string, factor: number?)",
"Currency:SetPlayerMultiplier(profile, name: string, factor: number?)",
"Currency:Multiplier(profile)",
"Currency:Give(profile, amount: number, raw: boolean?)",
"Currency:CanAfford(profile, cost: number)",
"Currency:Spend(profile, cost: number)",
"Currency:Forget(profile)",
"Currency.rebirth(config)",
"Rebirth:Count(profile)",
"Rebirth:Cost(profile)",
"Rebirth:Bonus(profile)",
"Rebirth:Try(profile)"
],
"lines": 139,
"free": false
},
{
"name": "RemoteGuard",
"side": "Server",
"summary": "Rate limits and argument checks for RemoteEvents / RemoteFunctions.",
"usage": "Never trust the client: every value an exploiter sends is checked before your code sees it.\n\n local T = RemoteGuard.types\n RemoteGuard.onEvent(remotes.BuyItem, {\n rate = 4, per = 1, -- at most 4 calls per second per player\n args = { T.string(1, 32), T.integer(1, 99) },\n }, function(player, itemId, amount)\n ... -- only runs with a valid itemId and amount\n end)\n\n RemoteGuard.onInvoke(remotes.Redeem, { rate = 1, per = 2, args = { T.string(1, 40) } },\n function(player, code) return codes:Redeem(store:Get(player), code) end)\n\nChecks reject NaN and infinity in numbers, wrong types, over-long strings, unknown enum values,\nextra arguments and tables that are too deep or too big. Rejected calls are counted per player;\n`onReject(player, remoteName, reason)` lets you log or kick.",
"functions": [
"T.number(min: number?, max: number?)",
"T.integer(min: number?, max: number?)",
"T.string(minLen: number?, maxLen: number?)",
"T.boolean()",
"T.oneOf(...)",
"T.Vector3(maxMagnitude: number?)",
"T.CFrame()",
"T.instance(className: string?)",
"T.optional(check)",
"T.array(check, maxItems: number?)",
"T.shape(fields)",
"RemoteGuard.limiter(rate: number, per: number, clock)",
"Limiter:Allow(key)",
"Limiter:Forget(key)",
"RemoteGuard.check(spec, ...)",
"RemoteGuard.wrap(name: string, spec, fn)",
"RemoteGuard.onEvent(remote, spec, fn)",
"RemoteGuard.onInvoke(remote, spec, fn)"
],
"lines": 272,
"free": false
},
{
"name": "Cooldown",
"side": "Shared",
"summary": "Per-player, per-action cooldowns checked on the server.",
"usage": " local cd = Cooldown.new({ Punch = 0.6, Dash = 3, Spin = 86400 })\n if cd:Use(player.UserId, \"Dash\") then ... end -- true and starts the cooldown, or false\n cd:Remaining(player.UserId, \"Dash\") -- seconds left (0 when ready)\n cd:Reset(player.UserId) -- e.g. on respawn or leave\n\nUse a clock that fits the action: os.clock() (default) for combat, os.time() for long\ncooldowns that must survive a server restart (save `Export()` and `Import()` it).",
"functions": [
"Cooldown.new(durations, clock)",
"Cooldown:Remaining(id, action: string)",
"Cooldown:Ready(id, action: string)",
"Cooldown:Use(id, action: string, duration: number?)",
"Cooldown:Reset(id, action: string?)",
"Cooldown:Export(id)",
"Cooldown:Import(id, t)"
],
"lines": 72,
"free": false
},
{
"name": "StateMachine",
"side": "Shared",
"summary": "Clear states for rounds, NPCs, doors and boss phases.",
"usage": " local round = StateMachine.new({\n initial = \"Intermission\",\n states = {\n Intermission = { enter = function(sm) sm:After(15, \"Playing\") end },\n Playing = { enter = startRound, exit = cleanupMap, update = checkWinner },\n Ended = { enter = function(sm) sm:After(5, \"Intermission\") end },\n },\n transitions = { Intermission = { \"Playing\" }, Playing = { \"Ended\" }, Ended = { \"Intermission\" } },\n })\n round:Start()\n round:Go(\"Ended\") -- false if Playing -> Ended weren't allowed\n round.Changed:Connect(function(new, old) ... end)\n RunService.Heartbeat:Connect(function(dt) round:Update(dt) end)\n\nAfter(seconds, state) timers are cancelled automatically when the state changes.",
"functions": [
"StateMachine.new(config)",
"StateMachine:Start()",
"StateMachine:Can(to: string)",
"StateMachine:Go(to: string)",
"StateMachine:Is(name: string)",
"StateMachine:Update(dt: number)",
"StateMachine:After(seconds: number, to: string)"
],
"lines": 105,
"free": false
},
{
"name": "Spring",
"side": "Shared",
"summary": "Smooth, interruptible motion for UI, cameras and bobbing parts.",
"usage": "A damped harmonic spring solved exactly each step, so it stays stable at any frame rate.\n\n local s = Spring.new(0, { speed = 12, damping = 0.8 }) -- damping 1 = no overshoot\n s:SetTarget(1)\n RunService.RenderStepped:Connect(function(dt)\n frame.Size = UDim2.fromScale(s:Step(dt), 0.1)\n end)\n s:Impulse(4) -- kick it (e.g. a coin counter bounce)\n\nWorks on numbers. For Vector3 / UDim2 use one spring per axis or Spring.group(n).",
"functions": [
"Spring.new(initial: number, options)",
"Spring:SetTarget(target: number)",
"Spring:Impulse(velocity: number)",
"Spring:Snap(value: number)",
"Spring:IsResting(epsilon: number?)",
"Spring:Step(dt: number)",
"Spring.group(n: number, initial: number, options)"
],
"lines": 90,
"free": false
},
{
"name": "Signal",
"side": "Shared",
"summary": "A small, fast event object for Luau.",
"usage": "Connect / Once / Wait / Fire / DisconnectAll. Handlers added or removed while a\nsignal is firing are safe: the current Fire uses a snapshot of the handler list.",
"functions": [
"ConnectionClass:Disconnect()",
"Signal.new()",
"Signal:Connect(fn)",
"Signal:Once(fn)",
"Signal:Fire(...)",
"Signal:Wait()",
"Signal:DisconnectAll()"
],
"lines": 73,
"free": true
},
{
"name": "Cleanup",
"side": "Shared",
"summary": "Collect everything a system creates, then undo it in one call.",
"usage": "Accepts Instances, RBXScriptConnections, Studwright Connections, functions, threads\nand any table with a Destroy or Disconnect method. Clean() runs in reverse order\n(last added, first cleaned) and is safe to call twice.",
"functions": [
"Cleanup.new()",
"Cleanup:Add(item)",
"Cleanup:Connect(signal, fn)",
"Cleanup:Remove(item)",
"Cleanup:Count()",
"Cleanup:Clean()",
"Cleanup:AttachTo(instance)"
],
"lines": 91,
"free": true
},
{
"name": "Format",
"side": "Shared",
"summary": "The number and time text every simulator, tycoon and obby needs.",
"usage": "Format.abbreviate(1530000) --> \"1.53M\"\nFormat.commas(1234567) --> \"1,234,567\"\nFormat.clock(3725) --> \"1:02:05\"\nFormat.duration(3725) --> \"1h 2m\"\nFormat.ordinal(22) --> \"22nd\"\nFormat.percent(0.0125) --> \"1.25%\"",
"functions": [
"Format.setSuffixes(list: { string })",
"Format.abbreviate(n: number, decimals: number?)",
"Format.commas(n: number)",
"Format.clock(seconds: number)",
"Format.duration(seconds: number)",
"Format.ordinal(n: number)",
"Format.percent(fraction: number, decimals: number?)"
],
"lines": 120,
"free": true
}
]
}
|