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
  }
 ]
}