roblox-opencode 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (248) hide show
  1. package/README.md +122 -0
  2. package/commands/setup-game.md +108 -0
  3. package/commands/sync-check.md +53 -0
  4. package/core/roblox-core.md +93 -0
  5. package/dist/server.js +167 -0
  6. package/package.json +35 -0
  7. package/skills/roblox-analytics/SKILL.md +277 -0
  8. package/skills/roblox-analytics/references/event-batcher.luau +75 -0
  9. package/skills/roblox-animation-vfx/SKILL.md +1325 -0
  10. package/skills/roblox-architecture/SKILL.md +863 -0
  11. package/skills/roblox-architecture/references/combat-systems.md +1381 -0
  12. package/skills/roblox-code-review/SKILL.md +687 -0
  13. package/skills/roblox-data/SKILL.md +889 -0
  14. package/skills/roblox-data/references/inventory-systems.md +1729 -0
  15. package/skills/roblox-debug/SKILL.md +99 -0
  16. package/skills/roblox-gui/SKILL.md +1103 -0
  17. package/skills/roblox-gui-fusion/SKILL.md +150 -0
  18. package/skills/roblox-gui-fusion/references/inventory.luau +427 -0
  19. package/skills/roblox-gui-fusion/references/settings-menu.luau +579 -0
  20. package/skills/roblox-gui-fusion/references/shop.luau +411 -0
  21. package/skills/roblox-luau-mastery/SKILL.md +1519 -0
  22. package/skills/roblox-monetization/SKILL.md +1084 -0
  23. package/skills/roblox-monetization/references/process-receipt.luau +131 -0
  24. package/skills/roblox-networking/SKILL.md +669 -0
  25. package/skills/roblox-networking/references/remote-validator.luau +193 -0
  26. package/skills/roblox-publish-checklist/SKILL.md +128 -0
  27. package/skills/roblox-runtime/SKILL.md +753 -0
  28. package/skills/roblox-sharp-edges/SKILL.md +295 -0
  29. package/skills/roblox-sync/SKILL.md +126 -0
  30. package/skills/roblox-testing/SKILL.md +943 -0
  31. package/skills/roblox-tooling/SKILL.md +150 -0
  32. package/vendor/LICENSES/ProfileStore-LICENSE +201 -0
  33. package/vendor/LICENSES/RbxUtil-LICENSE +7 -0
  34. package/vendor/LICENSES/promise-LICENSE +21 -0
  35. package/vendor/LICENSES/t-LICENSE +21 -0
  36. package/vendor/LICENSES/testez-LICENSE +201 -0
  37. package/vendor/README.md +84 -0
  38. package/vendor/fusion/Animation/ExternalTime.luau +84 -0
  39. package/vendor/fusion/Animation/Spring.luau +322 -0
  40. package/vendor/fusion/Animation/Stopwatch.luau +128 -0
  41. package/vendor/fusion/Animation/Tween.luau +187 -0
  42. package/vendor/fusion/Animation/getTweenDuration.luau +27 -0
  43. package/vendor/fusion/Animation/getTweenRatio.luau +47 -0
  44. package/vendor/fusion/Animation/lerpType.luau +164 -0
  45. package/vendor/fusion/Animation/packType.luau +100 -0
  46. package/vendor/fusion/Animation/springCoefficients.luau +80 -0
  47. package/vendor/fusion/Animation/unpackType.luau +103 -0
  48. package/vendor/fusion/Colour/Oklab.luau +70 -0
  49. package/vendor/fusion/Colour/sRGB.luau +55 -0
  50. package/vendor/fusion/External.luau +168 -0
  51. package/vendor/fusion/ExternalDebug.luau +70 -0
  52. package/vendor/fusion/Graph/Observer.luau +114 -0
  53. package/vendor/fusion/Graph/castToGraph.luau +29 -0
  54. package/vendor/fusion/Graph/change.luau +81 -0
  55. package/vendor/fusion/Graph/depend.luau +33 -0
  56. package/vendor/fusion/Graph/evaluate.luau +56 -0
  57. package/vendor/fusion/Instances/Attribute.luau +58 -0
  58. package/vendor/fusion/Instances/AttributeChange.luau +47 -0
  59. package/vendor/fusion/Instances/AttributeOut.luau +63 -0
  60. package/vendor/fusion/Instances/Child.luau +21 -0
  61. package/vendor/fusion/Instances/Children.luau +148 -0
  62. package/vendor/fusion/Instances/Hydrate.luau +33 -0
  63. package/vendor/fusion/Instances/New.luau +53 -0
  64. package/vendor/fusion/Instances/OnChange.luau +50 -0
  65. package/vendor/fusion/Instances/OnEvent.luau +54 -0
  66. package/vendor/fusion/Instances/Out.luau +69 -0
  67. package/vendor/fusion/Instances/applyInstanceProps.luau +149 -0
  68. package/vendor/fusion/Instances/defaultProps.luau +194 -0
  69. package/vendor/fusion/LICENSE +21 -0
  70. package/vendor/fusion/Logging/formatError.luau +49 -0
  71. package/vendor/fusion/Logging/messages.luau +52 -0
  72. package/vendor/fusion/Logging/parseError.luau +25 -0
  73. package/vendor/fusion/Memory/checkLifetime.luau +134 -0
  74. package/vendor/fusion/Memory/deriveScope.luau +24 -0
  75. package/vendor/fusion/Memory/deriveScopeImpl.luau +45 -0
  76. package/vendor/fusion/Memory/doCleanup.luau +79 -0
  77. package/vendor/fusion/Memory/innerScope.luau +34 -0
  78. package/vendor/fusion/Memory/legacyCleanup.luau +18 -0
  79. package/vendor/fusion/Memory/needsDestruction.luau +17 -0
  80. package/vendor/fusion/Memory/poisonScope.luau +34 -0
  81. package/vendor/fusion/Memory/scopePool.luau +55 -0
  82. package/vendor/fusion/Memory/scoped.luau +27 -0
  83. package/vendor/fusion/Memory/whichLivesLonger.luau +75 -0
  84. package/vendor/fusion/RobloxExternal.luau +98 -0
  85. package/vendor/fusion/State/Computed.luau +139 -0
  86. package/vendor/fusion/State/For/Disassembly.luau +211 -0
  87. package/vendor/fusion/State/For/ForTypes.luau +30 -0
  88. package/vendor/fusion/State/For/init.luau +110 -0
  89. package/vendor/fusion/State/ForKeys.luau +94 -0
  90. package/vendor/fusion/State/ForPairs.luau +97 -0
  91. package/vendor/fusion/State/ForValues.luau +94 -0
  92. package/vendor/fusion/State/Value.luau +88 -0
  93. package/vendor/fusion/State/castToState.luau +26 -0
  94. package/vendor/fusion/State/peek.luau +31 -0
  95. package/vendor/fusion/State/updateAll.luau +1 -0
  96. package/vendor/fusion/Types.luau +314 -0
  97. package/vendor/fusion/Utility/Contextual.luau +91 -0
  98. package/vendor/fusion/Utility/Safe.luau +23 -0
  99. package/vendor/fusion/Utility/isSimilar.luau +29 -0
  100. package/vendor/fusion/Utility/merge.luau +35 -0
  101. package/vendor/fusion/Utility/nameOf.luau +35 -0
  102. package/vendor/fusion/Utility/never.luau +14 -0
  103. package/vendor/fusion/Utility/nicknames.luau +11 -0
  104. package/vendor/fusion/Utility/xtypeof.luau +27 -0
  105. package/vendor/fusion/init.luau +82 -0
  106. package/vendor/profilestore/init.luau +2243 -0
  107. package/vendor/promise/init.luau +1982 -0
  108. package/vendor/rbxutil/buffer-util/Buffer.test.luau +25 -0
  109. package/vendor/rbxutil/buffer-util/BufferReader.luau +228 -0
  110. package/vendor/rbxutil/buffer-util/BufferWriter.luau +269 -0
  111. package/vendor/rbxutil/buffer-util/DataTypeBuffer.luau +223 -0
  112. package/vendor/rbxutil/buffer-util/Types.luau +60 -0
  113. package/vendor/rbxutil/buffer-util/index.d.ts +153 -0
  114. package/vendor/rbxutil/buffer-util/init.luau +41 -0
  115. package/vendor/rbxutil/buffer-util/package.json +16 -0
  116. package/vendor/rbxutil/buffer-util/wally.toml +9 -0
  117. package/vendor/rbxutil/comm/Client/ClientComm.luau +232 -0
  118. package/vendor/rbxutil/comm/Client/ClientRemoteProperty.luau +156 -0
  119. package/vendor/rbxutil/comm/Client/ClientRemoteSignal.luau +109 -0
  120. package/vendor/rbxutil/comm/Client/init.luau +135 -0
  121. package/vendor/rbxutil/comm/Server/RemoteProperty.luau +295 -0
  122. package/vendor/rbxutil/comm/Server/RemoteSignal.luau +211 -0
  123. package/vendor/rbxutil/comm/Server/ServerComm.luau +211 -0
  124. package/vendor/rbxutil/comm/Server/init.luau +140 -0
  125. package/vendor/rbxutil/comm/Types.luau +18 -0
  126. package/vendor/rbxutil/comm/Util.luau +27 -0
  127. package/vendor/rbxutil/comm/init.luau +35 -0
  128. package/vendor/rbxutil/comm/wally.toml +13 -0
  129. package/vendor/rbxutil/component/init.luau +759 -0
  130. package/vendor/rbxutil/component/init.test.luau +311 -0
  131. package/vendor/rbxutil/component/wally.toml +14 -0
  132. package/vendor/rbxutil/concur/init.luau +542 -0
  133. package/vendor/rbxutil/concur/init.test.luau +364 -0
  134. package/vendor/rbxutil/concur/wally.toml +8 -0
  135. package/vendor/rbxutil/enum-list/init.luau +101 -0
  136. package/vendor/rbxutil/enum-list/init.test.luau +91 -0
  137. package/vendor/rbxutil/enum-list/wally.toml +8 -0
  138. package/vendor/rbxutil/find/index.d.ts +20 -0
  139. package/vendor/rbxutil/find/init.luau +44 -0
  140. package/vendor/rbxutil/find/package.json +17 -0
  141. package/vendor/rbxutil/find/wally.toml +8 -0
  142. package/vendor/rbxutil/input/Gamepad.luau +559 -0
  143. package/vendor/rbxutil/input/Keyboard.luau +124 -0
  144. package/vendor/rbxutil/input/Mouse.luau +278 -0
  145. package/vendor/rbxutil/input/PreferredInput.luau +91 -0
  146. package/vendor/rbxutil/input/Touch.luau +120 -0
  147. package/vendor/rbxutil/input/init.luau +33 -0
  148. package/vendor/rbxutil/input/wally.toml +12 -0
  149. package/vendor/rbxutil/loader/index.d.ts +15 -0
  150. package/vendor/rbxutil/loader/init.luau +137 -0
  151. package/vendor/rbxutil/loader/wally.toml +8 -0
  152. package/vendor/rbxutil/log/index.d.ts +38 -0
  153. package/vendor/rbxutil/log/init.luau +746 -0
  154. package/vendor/rbxutil/log/wally.toml +8 -0
  155. package/vendor/rbxutil/net/init.luau +190 -0
  156. package/vendor/rbxutil/net/wally.toml +8 -0
  157. package/vendor/rbxutil/option/index.d.ts +44 -0
  158. package/vendor/rbxutil/option/init.luau +489 -0
  159. package/vendor/rbxutil/option/init.test.luau +342 -0
  160. package/vendor/rbxutil/option/wally.toml +8 -0
  161. package/vendor/rbxutil/pid/index.d.ts +53 -0
  162. package/vendor/rbxutil/pid/init.luau +195 -0
  163. package/vendor/rbxutil/pid/package.json +16 -0
  164. package/vendor/rbxutil/pid/wally.toml +9 -0
  165. package/vendor/rbxutil/quaternion/index.d.ts +117 -0
  166. package/vendor/rbxutil/quaternion/init.luau +570 -0
  167. package/vendor/rbxutil/quaternion/package.json +16 -0
  168. package/vendor/rbxutil/quaternion/wally.toml +9 -0
  169. package/vendor/rbxutil/query/index.d.ts +43 -0
  170. package/vendor/rbxutil/query/init.luau +117 -0
  171. package/vendor/rbxutil/query/package.json +18 -0
  172. package/vendor/rbxutil/query/wally.toml +9 -0
  173. package/vendor/rbxutil/sequent/index.d.ts +28 -0
  174. package/vendor/rbxutil/sequent/init.luau +340 -0
  175. package/vendor/rbxutil/sequent/package.json +16 -0
  176. package/vendor/rbxutil/sequent/wally.toml +9 -0
  177. package/vendor/rbxutil/ser/init.luau +175 -0
  178. package/vendor/rbxutil/ser/init.test.luau +50 -0
  179. package/vendor/rbxutil/ser/wally.toml +11 -0
  180. package/vendor/rbxutil/shake/index.d.ts +36 -0
  181. package/vendor/rbxutil/shake/init.luau +532 -0
  182. package/vendor/rbxutil/shake/init.test.luau +267 -0
  183. package/vendor/rbxutil/shake/package.json +16 -0
  184. package/vendor/rbxutil/shake/wally.toml +9 -0
  185. package/vendor/rbxutil/signal/index.d.ts +100 -0
  186. package/vendor/rbxutil/signal/init.luau +432 -0
  187. package/vendor/rbxutil/signal/init.test.luau +190 -0
  188. package/vendor/rbxutil/signal/package.json +17 -0
  189. package/vendor/rbxutil/signal/wally.toml +9 -0
  190. package/vendor/rbxutil/silo/TableWatcher.luau +65 -0
  191. package/vendor/rbxutil/silo/Util.luau +55 -0
  192. package/vendor/rbxutil/silo/init.luau +338 -0
  193. package/vendor/rbxutil/silo/init.test.luau +215 -0
  194. package/vendor/rbxutil/silo/wally.toml +8 -0
  195. package/vendor/rbxutil/spring/index.d.ts +40 -0
  196. package/vendor/rbxutil/spring/init.luau +97 -0
  197. package/vendor/rbxutil/spring/package.json +17 -0
  198. package/vendor/rbxutil/spring/wally.toml +8 -0
  199. package/vendor/rbxutil/stream/index.d.ts +88 -0
  200. package/vendor/rbxutil/stream/init.luau +597 -0
  201. package/vendor/rbxutil/stream/package.json +18 -0
  202. package/vendor/rbxutil/stream/wally.toml +9 -0
  203. package/vendor/rbxutil/streamable/Streamable.luau +202 -0
  204. package/vendor/rbxutil/streamable/StreamableUtil.luau +80 -0
  205. package/vendor/rbxutil/streamable/init.luau +8 -0
  206. package/vendor/rbxutil/streamable/wally.toml +12 -0
  207. package/vendor/rbxutil/symbol/init.luau +56 -0
  208. package/vendor/rbxutil/symbol/init.test.luau +37 -0
  209. package/vendor/rbxutil/symbol/wally.toml +8 -0
  210. package/vendor/rbxutil/table-util/init.luau +938 -0
  211. package/vendor/rbxutil/table-util/init.test.luau +439 -0
  212. package/vendor/rbxutil/table-util/wally.toml +8 -0
  213. package/vendor/rbxutil/task-queue/index.d.ts +27 -0
  214. package/vendor/rbxutil/task-queue/init.luau +97 -0
  215. package/vendor/rbxutil/task-queue/wally.toml +8 -0
  216. package/vendor/rbxutil/timer/index.d.ts +81 -0
  217. package/vendor/rbxutil/timer/init.luau +249 -0
  218. package/vendor/rbxutil/timer/init.test.luau +73 -0
  219. package/vendor/rbxutil/timer/wally.toml +11 -0
  220. package/vendor/rbxutil/tree/index.d.ts +15 -0
  221. package/vendor/rbxutil/tree/init.luau +137 -0
  222. package/vendor/rbxutil/tree/wally.toml +8 -0
  223. package/vendor/rbxutil/trove/index.d.ts +46 -0
  224. package/vendor/rbxutil/trove/init.luau +787 -0
  225. package/vendor/rbxutil/trove/init.test.luau +203 -0
  226. package/vendor/rbxutil/trove/wally.toml +8 -0
  227. package/vendor/rbxutil/typed-remote/init.luau +196 -0
  228. package/vendor/rbxutil/typed-remote/wally.toml +8 -0
  229. package/vendor/rbxutil/wait-for/index.d.ts +17 -0
  230. package/vendor/rbxutil/wait-for/init.luau +257 -0
  231. package/vendor/rbxutil/wait-for/init.test.luau +182 -0
  232. package/vendor/rbxutil/wait-for/wally.toml +11 -0
  233. package/vendor/t/t.lua +1350 -0
  234. package/vendor/testez/Context.lua +26 -0
  235. package/vendor/testez/Expectation.lua +311 -0
  236. package/vendor/testez/ExpectationContext.lua +38 -0
  237. package/vendor/testez/LifecycleHooks.lua +89 -0
  238. package/vendor/testez/Reporters/TeamCityReporter.lua +102 -0
  239. package/vendor/testez/Reporters/TextReporter.lua +106 -0
  240. package/vendor/testez/Reporters/TextReporterQuiet.lua +97 -0
  241. package/vendor/testez/TestBootstrap.lua +147 -0
  242. package/vendor/testez/TestEnum.lua +28 -0
  243. package/vendor/testez/TestPlan.lua +304 -0
  244. package/vendor/testez/TestPlanner.lua +40 -0
  245. package/vendor/testez/TestResults.lua +112 -0
  246. package/vendor/testez/TestRunner.lua +188 -0
  247. package/vendor/testez/TestSession.lua +243 -0
  248. package/vendor/testez/init.lua +40 -0
@@ -0,0 +1,943 @@
1
+ ---
2
+ name: roblox-testing
3
+ description: TestEZ BDD testing, mocks, test patterns, coverage strategies for Roblox.
4
+ last_reviewed: 2026-05-21
5
+ ---
6
+
7
+ <!-- Source: brockmartin/roblox-game-skill (MIT) -->
8
+
9
+ # Roblox Testing Patterns Reference
10
+
11
+ ## 1. Overview
12
+
13
+ Load this reference when:
14
+
15
+ - Writing unit or integration tests for Roblox game modules
16
+ - Setting up test infrastructure for a new or existing project
17
+ - Configuring CI/CD pipelines for automated linting, formatting, and test runs
18
+ - Refactoring modules to improve testability
19
+ - Debugging failures caught during playtesting or production monitoring
20
+
21
+ Testing in Roblox is non-trivial because game code depends heavily on engine services (`Players`, `DataStoreService`, `ReplicatedStorage`, etc.) that are unavailable outside Studio. The patterns here show how to write testable code, mock those services, and automate verification at every stage of development.
22
+
23
+ ---
24
+
25
+ ## Quick Reference
26
+
27
+ **Load Full Reference below only when you need specific mock implementations or test patterns.**
28
+
29
+ Key rules:
30
+ - TestEZ is the framework. BDD style: `describe/it/expect`. Files named `*.spec.luau`.
31
+ - Test pure logic modules first (no Roblox dependencies). Highest ROI.
32
+ - Dependency injection for testability: pass services as constructor args, mock in tests.
33
+ - Mock pattern: table that mimics the service interface. Only implement methods you test.
34
+ - `beforeEach` for fresh state per test. Never share mutable state between `it` blocks.
35
+ - Integration tests: test module interactions, not Roblox engine behavior.
36
+ - MCP-powered testing: use execute_luau to run tests in Studio, read output.
37
+ - Don't test Roblox engine behavior (physics, rendering). Test YOUR logic.
38
+ - Run tests via: require(TestEZ).TestBootstrap:run({testRoot})
39
+
40
+ ---
41
+
42
+ ## Full Reference
43
+
44
+ ## 2. TestEZ Framework
45
+
46
+ > **TestEZ is vendored in this harness** at `vendor/testez/` (v0.4.2, Apache 2.0). The agent can place it into your project when you need testing. No Wally install required.
47
+
48
+ ### Test File Conventions
49
+
50
+ - Test files use the suffix `.spec.luau` (e.g., `CurrencyManager.spec.luau`).
51
+ - Each spec file lives alongside or mirrors the module it tests.
52
+ - TestEZ discovers specs by recursively scanning a root container you point it at.
53
+
54
+ ### Core Syntax
55
+
56
+ ```luau
57
+ return function()
58
+ describe("CurrencyManager", function()
59
+ local CurrencyManager
60
+
61
+ beforeEach(function()
62
+ -- Fresh module state before every test
63
+ CurrencyManager = require(script.Parent.CurrencyManager)
64
+ end)
65
+
66
+ afterEach(function()
67
+ -- Teardown: reset any shared state
68
+ end)
69
+
70
+ it("should initialize a player with zero gold", function()
71
+ local data = CurrencyManager.newPlayerData()
72
+ expect(data.gold).to.equal(0)
73
+ end)
74
+
75
+ it("should add currency correctly", function()
76
+ local data = CurrencyManager.newPlayerData()
77
+ CurrencyManager.addGold(data, 100)
78
+ expect(data.gold).to.equal(100)
79
+ end)
80
+
81
+ it("should never allow negative gold", function()
82
+ local data = CurrencyManager.newPlayerData()
83
+ CurrencyManager.addGold(data, 50)
84
+ CurrencyManager.removeGold(data, 999)
85
+ expect(data.gold).to.equal(0)
86
+ end)
87
+ end)
88
+ end
89
+ ```
90
+
91
+ ### Assertion API Highlights
92
+
93
+ ```luau
94
+ expect(value).to.equal(expected) -- strict equality
95
+ expect(value).to.be.ok() -- truthy
96
+ expect(value).to.be.a("table") -- type check
97
+ expect(value).never.to.equal(unexpected) -- negation
98
+ expect(function()
99
+ error("boom")
100
+ end).to.throw() -- error expected
101
+ expect(value).to.be.near(3.14, 0.01) -- float tolerance
102
+ ```
103
+
104
+ ### Running Tests Inside Studio
105
+
106
+ Create a test runner script in `ServerScriptService`:
107
+
108
+ ```luau
109
+ local TestEZ = require(game.ReplicatedStorage.DevPackages.TestEZ)
110
+ local results = TestEZ.TestBootstrap:run({
111
+ game.ReplicatedStorage.Shared, -- scan these containers
112
+ game.ServerScriptService.Server,
113
+ })
114
+ ```
115
+
116
+ ---
117
+
118
+ ## 3. Unit Testing ModuleScripts
119
+
120
+ Unit tests target **pure logic** - functions that take inputs and return outputs without touching engine APIs.
121
+
122
+ ### Good Candidates for Unit Testing
123
+
124
+ | Module Type | Examples |
125
+ |---|---|
126
+ | Damage calculation | `calculateDamage(baseDmg, armor, crit)` |
127
+ | Inventory operations | `addItem(inventory, itemId, qty)` |
128
+ | Data transforms | `serializeLoadout(loadout)` / `deserializeLoadout(raw)` |
129
+ | Config validators | `validateWeaponConfig(config)` |
130
+ | Math/utility | `clamp`, `lerp`, `formatNumber` |
131
+
132
+ ### Pattern: Test a Pure Module
133
+
134
+ Module under test (`DamageCalc.luau`):
135
+
136
+ ```luau
137
+ local DamageCalc = {}
138
+
139
+ function DamageCalc.calculate(baseDamage: number, armor: number, isCrit: boolean): number
140
+ local reduction = math.clamp(armor / 100, 0, 0.75)
141
+ local damage = baseDamage * (1 - reduction)
142
+ if isCrit then
143
+ damage *= 2
144
+ end
145
+ return math.floor(damage)
146
+ end
147
+
148
+ return DamageCalc
149
+ ```
150
+
151
+ Spec (`DamageCalc.spec.luau`):
152
+
153
+ ```luau
154
+ return function()
155
+ local DamageCalc = require(script.Parent.DamageCalc)
156
+
157
+ describe("calculate", function()
158
+ it("should apply armor reduction", function()
159
+ -- 50 armor = 50% reduction on 100 base = 50
160
+ expect(DamageCalc.calculate(100, 50, false)).to.equal(50)
161
+ end)
162
+
163
+ it("should cap armor reduction at 75%", function()
164
+ expect(DamageCalc.calculate(100, 200, false)).to.equal(25)
165
+ end)
166
+
167
+ it("should double damage on crit", function()
168
+ expect(DamageCalc.calculate(100, 0, true)).to.equal(200)
169
+ end)
170
+
171
+ it("should floor the result", function()
172
+ expect(DamageCalc.calculate(33, 10, false)).to.equal(29)
173
+ end)
174
+ end)
175
+ end
176
+ ```
177
+
178
+ ### Keep Modules Testable
179
+
180
+ The key rule: **avoid calling `game:GetService()` or accessing `game.*` directly inside functions you want to unit test.** Extract engine interactions to the boundary and pass data in.
181
+
182
+ ```luau
183
+ -- BAD: untestable, reaches into the engine
184
+ function Module.getPlayerHealth(player)
185
+ local char = player.Character
186
+ local humanoid = char:FindFirstChildOfClass("Humanoid")
187
+ return humanoid.Health
188
+ end
189
+
190
+ -- GOOD: testable, accepts the value it needs
191
+ function Module.isLowHealth(currentHealth: number, threshold: number): boolean
192
+ return currentHealth <= threshold
193
+ end
194
+ ```
195
+
196
+ ---
197
+
198
+ ## 4. Dependency Injection for Testability
199
+
200
+ When a module must interact with a Roblox service, inject the service as a parameter instead of hard-coding `game:GetService()`.
201
+
202
+ ### Pattern: Constructor Injection
203
+
204
+ ```luau
205
+ local InventoryManager = {}
206
+ InventoryManager.__index = InventoryManager
207
+
208
+ -- Accept services through the constructor
209
+ function InventoryManager.new(dataStoreService, messagingService)
210
+ local self = setmetatable({}, InventoryManager)
211
+ self._dataStore = dataStoreService:GetDataStore("Inventory")
212
+ self._messaging = messagingService
213
+ return self
214
+ end
215
+
216
+ function InventoryManager:saveInventory(playerId: number, inventory: { [string]: number })
217
+ local key = `inv_{playerId}`
218
+ self._dataStore:SetAsync(key, inventory)
219
+ end
220
+
221
+ function InventoryManager:loadInventory(playerId: number): { [string]: number }
222
+ local key = `inv_{playerId}`
223
+ return self._dataStore:GetAsync(key) or {}
224
+ end
225
+
226
+ return InventoryManager
227
+ ```
228
+
229
+ ### Production Wiring
230
+
231
+ ```luau
232
+ local DataStoreService = game:GetService("DataStoreService")
233
+ local MessagingService = game:GetService("MessagingService")
234
+ local InventoryManager = require(path.to.InventoryManager)
235
+
236
+ local manager = InventoryManager.new(DataStoreService, MessagingService)
237
+ ```
238
+
239
+ ### Test Wiring (inject mocks)
240
+
241
+ ```luau
242
+ local MockDataStoreService = require(script.Parent.Mocks.MockDataStoreService)
243
+ local InventoryManager = require(script.Parent.InventoryManager)
244
+
245
+ local manager = InventoryManager.new(MockDataStoreService.new(), mockMessaging)
246
+ ```
247
+
248
+ ### Alternative: Module-Level Injection via `.init()`
249
+
250
+ For modules that are singletons rather than classes:
251
+
252
+ ```luau
253
+ local Module = {}
254
+ local _players = nil
255
+
256
+ function Module.init(playersService)
257
+ _players = playersService
258
+ end
259
+
260
+ function Module.getPlayerCount(): number
261
+ return #_players:GetPlayers()
262
+ end
263
+
264
+ return Module
265
+ ```
266
+
267
+ ---
268
+
269
+ ## 5. Mocking Roblox Services
270
+
271
+ ### Mock Players Service
272
+
273
+ ```luau
274
+ local MockPlayers = {}
275
+ MockPlayers.__index = MockPlayers
276
+
277
+ function MockPlayers.new()
278
+ local self = setmetatable({}, MockPlayers)
279
+ self._players = {}
280
+ self.PlayerAdded = MockSignal.new()
281
+ self.PlayerRemoving = MockSignal.new()
282
+ return self
283
+ end
284
+
285
+ function MockPlayers:GetPlayers()
286
+ return self._players
287
+ end
288
+
289
+ function MockPlayers:addFakePlayer(mockPlayer)
290
+ table.insert(self._players, mockPlayer)
291
+ self.PlayerAdded:Fire(mockPlayer)
292
+ end
293
+
294
+ function MockPlayers:removeFakePlayer(mockPlayer)
295
+ local idx = table.find(self._players, mockPlayer)
296
+ if idx then
297
+ table.remove(self._players, idx)
298
+ self.PlayerRemoving:Fire(mockPlayer)
299
+ end
300
+ end
301
+
302
+ return MockPlayers
303
+ ```
304
+
305
+ ### Mock Signal (for RBXScriptSignal-like behavior)
306
+
307
+ ```luau
308
+ local MockSignal = {}
309
+ MockSignal.__index = MockSignal
310
+
311
+ function MockSignal.new()
312
+ return setmetatable({ _connections = {} }, MockSignal)
313
+ end
314
+
315
+ function MockSignal:Connect(callback)
316
+ table.insert(self._connections, callback)
317
+ return {
318
+ Disconnect = function(conn)
319
+ local idx = table.find(self._connections, callback)
320
+ if idx then
321
+ table.remove(self._connections, idx)
322
+ end
323
+ end,
324
+ }
325
+ end
326
+
327
+ function MockSignal:Fire(...)
328
+ for _, cb in self._connections do
329
+ task.spawn(cb, ...)
330
+ end
331
+ end
332
+
333
+ return MockSignal
334
+ ```
335
+
336
+ ### Mock DataStoreService (In-Memory)
337
+
338
+ ```luau
339
+ local MockDataStore = {}
340
+ MockDataStore.__index = MockDataStore
341
+
342
+ function MockDataStore.new()
343
+ return setmetatable({ _data = {} }, MockDataStore)
344
+ end
345
+
346
+ function MockDataStore:GetAsync(key)
347
+ return self._data[key]
348
+ end
349
+
350
+ function MockDataStore:SetAsync(key, value)
351
+ self._data[key] = value
352
+ end
353
+
354
+ function MockDataStore:UpdateAsync(key, transformFunction)
355
+ local old = self._data[key]
356
+ self._data[key] = transformFunction(old)
357
+ end
358
+
359
+ function MockDataStore:RemoveAsync(key)
360
+ self._data[key] = nil
361
+ end
362
+
363
+ -- ------------------------------------------------
364
+
365
+ local MockDataStoreService = {}
366
+ MockDataStoreService.__index = MockDataStoreService
367
+
368
+ function MockDataStoreService.new()
369
+ return setmetatable({ _stores = {} }, MockDataStoreService)
370
+ end
371
+
372
+ function MockDataStoreService:GetDataStore(name)
373
+ if not self._stores[name] then
374
+ self._stores[name] = MockDataStore.new()
375
+ end
376
+ return self._stores[name]
377
+ end
378
+
379
+ return MockDataStoreService
380
+ ```
381
+
382
+ ### Mock MarketplaceService
383
+
384
+ ```luau
385
+ local MockMarketplaceService = {}
386
+ MockMarketplaceService.__index = MockMarketplaceService
387
+
388
+ function MockMarketplaceService.new(ownedGamepasses: { [number]: boolean }?)
389
+ local self = setmetatable({}, MockMarketplaceService)
390
+ self._ownedPasses = ownedGamepasses or {}
391
+ self.PromptGamePassPurchaseFinished = MockSignal.new()
392
+ return self
393
+ end
394
+
395
+ function MockMarketplaceService:UserOwnsGamePassAsync(_userId, gamePassId)
396
+ return self._ownedPasses[gamePassId] == true
397
+ end
398
+
399
+ return MockMarketplaceService
400
+ ```
401
+
402
+ ---
403
+
404
+ ## 6. Integration Testing
405
+
406
+ Integration tests verify that **multiple modules work together correctly** - data flows across boundaries and side effects happen as expected.
407
+
408
+ ### Pattern: DataManager + InventoryManager
409
+
410
+ ```luau
411
+ return function()
412
+ local MockDataStoreService = require(script.Parent.Mocks.MockDataStoreService)
413
+ local DataManager = require(script.Parent.DataManager)
414
+ local InventoryManager = require(script.Parent.InventoryManager)
415
+
416
+ describe("DataManager + InventoryManager integration", function()
417
+ local mockDSS
418
+ local dataMgr
419
+ local invMgr
420
+
421
+ beforeEach(function()
422
+ mockDSS = MockDataStoreService.new()
423
+ dataMgr = DataManager.new(mockDSS)
424
+ invMgr = InventoryManager.new(mockDSS)
425
+ end)
426
+
427
+ it("should persist inventory through save/load cycle", function()
428
+ local playerId = 12345
429
+ local inventory = { Sword = 1, Shield = 2, Potion = 10 }
430
+
431
+ invMgr:saveInventory(playerId, inventory)
432
+ local loaded = invMgr:loadInventory(playerId)
433
+
434
+ expect(loaded.Sword).to.equal(1)
435
+ expect(loaded.Shield).to.equal(2)
436
+ expect(loaded.Potion).to.equal(10)
437
+ end)
438
+
439
+ it("should return empty inventory for new player", function()
440
+ local loaded = invMgr:loadInventory(99999)
441
+ expect(next(loaded)).to.equal(nil)
442
+ end)
443
+ end)
444
+ end
445
+ ```
446
+
447
+ ### Testing RemoteEvent Flows
448
+
449
+ For end-to-end remote flows in a test environment, create mock remotes:
450
+
451
+ ```luau
452
+ local MockRemoteEvent = {}
453
+ MockRemoteEvent.__index = MockRemoteEvent
454
+
455
+ function MockRemoteEvent.new()
456
+ local self = setmetatable({}, MockRemoteEvent)
457
+ self.OnServerEvent = MockSignal.new()
458
+ self.OnClientEvent = MockSignal.new()
459
+ return self
460
+ end
461
+
462
+ function MockRemoteEvent:FireServer(...)
463
+ self.OnServerEvent:Fire(nil, ...) -- nil = fake player in test
464
+ end
465
+
466
+ function MockRemoteEvent:FireClient(_player, ...)
467
+ self.OnClientEvent:Fire(...)
468
+ end
469
+
470
+ function MockRemoteEvent:FireAllClients(...)
471
+ self.OnClientEvent:Fire(...)
472
+ end
473
+
474
+ return MockRemoteEvent
475
+ ```
476
+
477
+ Usage in a test:
478
+
479
+ ```luau
480
+ it("should process purchase request and respond", function()
481
+ local remote = MockRemoteEvent.new()
482
+ local responded = false
483
+
484
+ -- Simulate server handler
485
+ remote.OnServerEvent:Connect(function(_player, itemId)
486
+ -- Server validates and responds
487
+ local success = ShopManager:tryPurchase(itemId, playerData)
488
+ remote:FireClient(nil, success, itemId)
489
+ end)
490
+
491
+ -- Capture client response
492
+ remote.OnClientEvent:Connect(function(success, itemId)
493
+ responded = true
494
+ expect(success).to.equal(true)
495
+ expect(itemId).to.equal("Sword")
496
+ end)
497
+
498
+ remote:FireServer("Sword")
499
+ expect(responded).to.equal(true)
500
+ end)
501
+ ```
502
+
503
+ ---
504
+
505
+ ## 7. MCP-Powered Testing
506
+
507
+ When Roblox Studio MCP tools are available, use them for automated smoke testing without leaving your editor.
508
+
509
+ ### Automated Smoke Test Workflow
510
+
511
+ ```
512
+ 1. start_playtest()
513
+ - Launches a local test server in Studio
514
+
515
+ 2. wait 5-10 seconds for game initialization
516
+
517
+ 3. get_playtest_output()
518
+ - Captures Output window logs
519
+ - Look for: errors, warnings, "Script timeout" messages
520
+
521
+ 4. Analyze the output
522
+ - Any red error lines? -> investigate and fix
523
+ - Module load failures? -> check requires and paths
524
+ - DataStore errors? -> check mock/fallback setup
525
+
526
+ 5. stop_playtest()
527
+ - Ends the session cleanly
528
+ ```
529
+
530
+ ### Iterative Debugging Loop with MCP
531
+
532
+ ```
533
+ REPEAT:
534
+ 1. Apply code fix
535
+ 2. start_playtest()
536
+ 3. get_playtest_output() - scan for the specific error you are fixing
537
+ 4. If error persists → stop_playtest(), refine fix, go to 1
538
+ 5. If error gone → stop_playtest(), move on
539
+ ```
540
+
541
+ ### Checking for Specific Errors
542
+
543
+ After `get_playtest_output()`, filter for critical patterns:
544
+
545
+ - `"error"` or `"Error"` - runtime errors
546
+ - `"attempt to index nil"` - missing references
547
+ - `"Infinite yield possible"` - WaitForChild timeouts
548
+ - `"HTTP 429"` - DataStore throttling
549
+ - `"not a valid member"` - API misuse or renamed properties
550
+
551
+ ---
552
+
553
+ ## 8. Manual Testing Workflows
554
+
555
+ Automated tests do not cover everything. Use this checklist for manual playtesting:
556
+
557
+ ### Pre-Release Checklist
558
+
559
+ - [ ] **Mobile playtest** - Touch controls work, UI fits small screens, no overlapping buttons
560
+ - [ ] **Multi-player test** (Studio local server, 2+ players) - Replication works, no desync, RemoteEvents fire correctly
561
+ - [ ] **Edge cases**:
562
+ - Disconnect mid-save (does data persist or rollback cleanly?)
563
+ - Rejoin immediately after leaving
564
+ - Rapid-fire actions (spam click buy button, spam attack)
565
+ - Inventory at max capacity
566
+ - [ ] **Monetization flow**:
567
+ - Game pass ownership detection works
568
+ - Developer product purchase prompt appears
569
+ - Receipt processing completes and grants items
570
+ - Duplicate receipt handling (idempotent processing)
571
+ - [ ] **First-time user experience** - New player gets default data, tutorial triggers, no errors in output
572
+ - [ ] **Performance** - MicroProfiler shows no frame spikes on spawn, no memory leaks during extended play
573
+
574
+ ### Studio Test Server (Multi-Player)
575
+
576
+ 1. File -> Test -> Start (set player count to 2+)
577
+ 2. Each client window is a separate player instance
578
+ 3. Server window shows server-side output
579
+ 4. Verify: leaderboards update for both, chat works, interactions replicate
580
+
581
+ ---
582
+
583
+ ## 9. Test Organization
584
+
585
+ ### Project Structure
586
+
587
+ ```
588
+ ServerScriptService/
589
+ Tests/
590
+ TestRunner (Script)
591
+ Specs/
592
+ CurrencyManager.spec
593
+ DamageCalc.spec
594
+ Mocks/
595
+ MockDataStoreService
596
+ MockPlayers
597
+ ```
598
+
599
+ ### Naming Conventions
600
+
601
+ | Convention | Example |
602
+ |---|---|
603
+ | Spec file suffix | `CurrencyManager.spec.luau` |
604
+ | Mock file prefix | `MockDataStoreService.luau` |
605
+ | Describe block | `describe("CurrencyManager")` |
606
+ | Test name | `it("should deduct gold on purchase")` |
607
+
608
+ ---
609
+
610
+ ---
611
+
612
+ ## 11. Best Practices
613
+
614
+ ### Test Critical Paths First
615
+
616
+ Prioritize tests for code where bugs cost the most:
617
+
618
+ 1. **Data save/load** - A bug here can wipe player progress. Test serialization, deserialization, migration, and edge cases (empty data, corrupt data).
619
+ 2. **Purchases and monetization** - Receipt processing must be idempotent. Test duplicate receipts, test granting after purchase, test failure recovery.
620
+ 3. **Combat damage / core gameplay math** - Players notice immediately when damage numbers are wrong. Test crit, armor, buffs, edge values.
621
+ 4. **Server-side validation** - Every RemoteEvent handler that accepts client input must validate. Test with out-of-range values, wrong types, and nil.
622
+
623
+ ### General Guidelines
624
+
625
+ - **Keep tests fast.** Each test should run in milliseconds. If a test needs `task.wait()`, you are likely testing integration-level behavior - separate it from unit tests.
626
+ - **One assertion focus per test.** A test named `"should add gold"` should test adding gold, not also test removing gold and checking the balance format.
627
+ - **Test server-side validation independently.** Do not rely on the client sending correct data. Write tests that call server validation functions with malicious inputs.
628
+ - **Write a test before fixing a bug.** Reproduce the bug in a failing test first, then fix the code until the test passes. This prevents regressions.
629
+ - **Use deterministic data.** Avoid `math.random()` in tests. Use fixed seed values or hardcoded inputs so failures are reproducible.
630
+ - **Reset state in `beforeEach`.** Never let one test depend on the side effects of another.
631
+
632
+ ---
633
+
634
+ ## 12. Anti-Patterns
635
+
636
+ ### Testing Only Manually in Studio
637
+
638
+ **Problem:** You click around in Studio, it seems to work, you ship. A week later an edge case surfaces in production.
639
+
640
+ **Fix:** Write automated tests for core logic. Manual playtesting supplements automated tests; it does not replace them.
641
+
642
+ ### No Tests for Monetization Code
643
+
644
+ **Problem:** Receipt processing is written once, never tested, and breaks silently. Players pay real money and receive nothing.
645
+
646
+ **Fix:** Unit test `processReceipt` with mock MarketplaceService. Test duplicate receipts, test every product ID, test failure paths.
647
+
648
+ ### Untestable Tightly-Coupled Modules
649
+
650
+ **Problem:**
651
+
652
+ ```luau
653
+ -- Everything is hardcoded; impossible to test without a live game
654
+ function Module.onPlayerJoin()
655
+ local player = game.Players.LocalPlayer
656
+ local data = game:GetService("DataStoreService"):GetDataStore("Main"):GetAsync(player.UserId)
657
+ local gui = player.PlayerGui:WaitForChild("MainUI")
658
+ gui.GoldLabel.Text = tostring(data.gold)
659
+ end
660
+ ```
661
+
662
+ **Fix:** Break it apart. Pure logic in one module, engine glue in another. Inject services.
663
+
664
+ ```luau
665
+ -- Pure logic (testable)
666
+ function CurrencyFormatter.formatGold(gold: number): string
667
+ return tostring(gold)
668
+ end
669
+
670
+ -- Glue code (thin, not unit tested, covered by integration/manual tests)
671
+ function UIController.updateGoldDisplay(player, gold)
672
+ local gui = player.PlayerGui:WaitForChild("MainUI")
673
+ gui.GoldLabel.Text = CurrencyFormatter.formatGold(gold)
674
+ end
675
+ ```
676
+
677
+ ### Tests That Depend on Execution Order
678
+
679
+ **Problem:** Test B passes only if Test A runs first because A sets up shared state.
680
+
681
+ **Fix:** Use `beforeEach` to create fresh state for every test. Each test must be independently runnable.
682
+
683
+ ### Ignoring Flaky Tests
684
+
685
+ **Problem:** A test sometimes passes and sometimes fails. The team marks it as "known flaky" and ignores it.
686
+
687
+ **Fix:** Flaky tests usually indicate shared mutable state, timing issues, or race conditions. Fix the root cause or delete the test - a flaky test is worse than no test because it erodes trust in the suite.
688
+
689
+ ---
690
+
691
+ ## Full Example: CurrencyManager with Tests
692
+
693
+ ### Module (`CurrencyManager.luau`)
694
+
695
+ ```luau
696
+ local CurrencyManager = {}
697
+ CurrencyManager.__index = CurrencyManager
698
+
699
+ export type CurrencyData = {
700
+ gold: number,
701
+ gems: number,
702
+ }
703
+
704
+ function CurrencyManager.new(dataStoreService)
705
+ local self = setmetatable({}, CurrencyManager)
706
+ self._store = dataStoreService:GetDataStore("Currency")
707
+ self._cache = {} :: { [number]: CurrencyData }
708
+ return self
709
+ end
710
+
711
+ function CurrencyManager.newPlayerData(): CurrencyData
712
+ return {
713
+ gold = 0,
714
+ gems = 0,
715
+ }
716
+ end
717
+
718
+ function CurrencyManager:loadPlayer(playerId: number): CurrencyData
719
+ local raw = self._store:GetAsync(`currency_{playerId}`)
720
+ local data = raw or CurrencyManager.newPlayerData()
721
+ self._cache[playerId] = data
722
+ return data
723
+ end
724
+
725
+ function CurrencyManager:savePlayer(playerId: number)
726
+ local data = self._cache[playerId]
727
+ if data then
728
+ self._store:SetAsync(`currency_{playerId}`, data)
729
+ end
730
+ end
731
+
732
+ function CurrencyManager:getGold(playerId: number): number
733
+ local data = self._cache[playerId]
734
+ return if data then data.gold else 0
735
+ end
736
+
737
+ function CurrencyManager:addGold(playerId: number, amount: number): boolean
738
+ if amount <= 0 then
739
+ return false
740
+ end
741
+ local data = self._cache[playerId]
742
+ if not data then
743
+ return false
744
+ end
745
+ data.gold += amount
746
+ return true
747
+ end
748
+
749
+ function CurrencyManager:removeGold(playerId: number, amount: number): boolean
750
+ if amount <= 0 then
751
+ return false
752
+ end
753
+ local data = self._cache[playerId]
754
+ if not data then
755
+ return false
756
+ end
757
+ if data.gold < amount then
758
+ return false -- insufficient funds
759
+ end
760
+ data.gold -= amount
761
+ return true
762
+ end
763
+
764
+ function CurrencyManager:transferGold(fromId: number, toId: number, amount: number): boolean
765
+ if not self:removeGold(fromId, amount) then
766
+ return false
767
+ end
768
+ if not self:addGold(toId, amount) then
769
+ -- Rollback
770
+ self:addGold(fromId, amount)
771
+ return false
772
+ end
773
+ return true
774
+ end
775
+
776
+ return CurrencyManager
777
+ ```
778
+
779
+ ### Test (`CurrencyManager.spec.luau`)
780
+
781
+ ```luau
782
+ return function()
783
+ local MockDataStoreService = require(script.Parent.Parent.Mocks.MockDataStoreService)
784
+ local CurrencyManager = require(script.Parent.CurrencyManager)
785
+
786
+ describe("CurrencyManager", function()
787
+ local mockDSS
788
+ local manager
789
+
790
+ beforeEach(function()
791
+ mockDSS = MockDataStoreService.new()
792
+ manager = CurrencyManager.new(mockDSS)
793
+ end)
794
+
795
+ describe("newPlayerData", function()
796
+ it("should return zero gold and zero gems", function()
797
+ local data = CurrencyManager.newPlayerData()
798
+ expect(data.gold).to.equal(0)
799
+ expect(data.gems).to.equal(0)
800
+ end)
801
+ end)
802
+
803
+ describe("loadPlayer", function()
804
+ it("should return default data for a new player", function()
805
+ local data = manager:loadPlayer(1001)
806
+ expect(data.gold).to.equal(0)
807
+ expect(data.gems).to.equal(0)
808
+ end)
809
+
810
+ it("should return saved data for an existing player", function()
811
+ -- Pre-populate the mock store
812
+ local store = mockDSS:GetDataStore("Currency")
813
+ store:SetAsync("currency_1001", { gold = 500, gems = 10 })
814
+
815
+ local data = manager:loadPlayer(1001)
816
+ expect(data.gold).to.equal(500)
817
+ expect(data.gems).to.equal(10)
818
+ end)
819
+ end)
820
+
821
+ describe("savePlayer", function()
822
+ it("should persist data to the store", function()
823
+ manager:loadPlayer(1001)
824
+ manager:addGold(1001, 250)
825
+ manager:savePlayer(1001)
826
+
827
+ -- Verify by reading directly from the mock store
828
+ local store = mockDSS:GetDataStore("Currency")
829
+ local raw = store:GetAsync("currency_1001")
830
+ expect(raw.gold).to.equal(250)
831
+ end)
832
+
833
+ it("should do nothing for an unloaded player", function()
834
+ -- Should not error
835
+ manager:savePlayer(9999)
836
+ end)
837
+ end)
838
+
839
+ describe("addGold", function()
840
+ it("should increase gold by the given amount", function()
841
+ manager:loadPlayer(1001)
842
+ local ok = manager:addGold(1001, 100)
843
+ expect(ok).to.equal(true)
844
+ expect(manager:getGold(1001)).to.equal(100)
845
+ end)
846
+
847
+ it("should accumulate across multiple calls", function()
848
+ manager:loadPlayer(1001)
849
+ manager:addGold(1001, 50)
850
+ manager:addGold(1001, 75)
851
+ expect(manager:getGold(1001)).to.equal(125)
852
+ end)
853
+
854
+ it("should reject zero amount", function()
855
+ manager:loadPlayer(1001)
856
+ local ok = manager:addGold(1001, 0)
857
+ expect(ok).to.equal(false)
858
+ expect(manager:getGold(1001)).to.equal(0)
859
+ end)
860
+
861
+ it("should reject negative amount", function()
862
+ manager:loadPlayer(1001)
863
+ local ok = manager:addGold(1001, -50)
864
+ expect(ok).to.equal(false)
865
+ end)
866
+
867
+ it("should fail for unloaded player", function()
868
+ local ok = manager:addGold(9999, 100)
869
+ expect(ok).to.equal(false)
870
+ end)
871
+ end)
872
+
873
+ describe("removeGold", function()
874
+ it("should decrease gold by the given amount", function()
875
+ manager:loadPlayer(1001)
876
+ manager:addGold(1001, 200)
877
+ local ok = manager:removeGold(1001, 50)
878
+ expect(ok).to.equal(true)
879
+ expect(manager:getGold(1001)).to.equal(150)
880
+ end)
881
+
882
+ it("should reject removal exceeding balance", function()
883
+ manager:loadPlayer(1001)
884
+ manager:addGold(1001, 30)
885
+ local ok = manager:removeGold(1001, 50)
886
+ expect(ok).to.equal(false)
887
+ expect(manager:getGold(1001)).to.equal(30) -- unchanged
888
+ end)
889
+
890
+ it("should reject zero amount", function()
891
+ manager:loadPlayer(1001)
892
+ local ok = manager:removeGold(1001, 0)
893
+ expect(ok).to.equal(false)
894
+ end)
895
+
896
+ it("should reject negative amount", function()
897
+ manager:loadPlayer(1001)
898
+ local ok = manager:removeGold(1001, -10)
899
+ expect(ok).to.equal(false)
900
+ end)
901
+ end)
902
+
903
+ describe("transferGold", function()
904
+ it("should move gold from one player to another", function()
905
+ manager:loadPlayer(1001)
906
+ manager:loadPlayer(1002)
907
+ manager:addGold(1001, 500)
908
+
909
+ local ok = manager:transferGold(1001, 1002, 200)
910
+ expect(ok).to.equal(true)
911
+ expect(manager:getGold(1001)).to.equal(300)
912
+ expect(manager:getGold(1002)).to.equal(200)
913
+ end)
914
+
915
+ it("should fail if sender has insufficient funds", function()
916
+ manager:loadPlayer(1001)
917
+ manager:loadPlayer(1002)
918
+ manager:addGold(1001, 50)
919
+
920
+ local ok = manager:transferGold(1001, 1002, 100)
921
+ expect(ok).to.equal(false)
922
+ expect(manager:getGold(1001)).to.equal(50) -- unchanged
923
+ expect(manager:getGold(1002)).to.equal(0) -- unchanged
924
+ end)
925
+
926
+ it("should fail if recipient is not loaded", function()
927
+ manager:loadPlayer(1001)
928
+ manager:addGold(1001, 500)
929
+
930
+ local ok = manager:transferGold(1001, 9999, 100)
931
+ expect(ok).to.equal(false)
932
+ expect(manager:getGold(1001)).to.equal(500) -- rollback
933
+ end)
934
+ end)
935
+
936
+ describe("getGold", function()
937
+ it("should return 0 for unloaded player", function()
938
+ expect(manager:getGold(9999)).to.equal(0)
939
+ end)
940
+ end)
941
+ end)
942
+ end
943
+ ```