13 |
14 | Anywhere in ReplicatedStorage will work, but it's recommended to be a direct child.
15 |
16 | ## 2. Create Your Namespace
17 |
18 | To make a **namespace**, create a Folder in [ServerScriptService](https://developer.roblox.com/en-us/api-reference/class/ServerScriptService) and/or [ReplicatedStorage](https://developer.roblox.com/en-us/api-reference/class/ReplicatedStorage). The Name of this folder should be distinct like any other object in the hierarchy, and ideally shouldn't contain symbols.
19 |
20 |
game
21 | ├ ReplicatedStorage
22 | │ └ MyGame ← Create this Folder (for client stuff)...
23 | └ ServerScriptService
24 | └ MyGame ← ...and/or this folder (for server stuff)
25 |
26 |
27 | It's recommended you use [Pascal case](https://en.wikipedia.org/wiki/Pascal_case) for the name of your namespace. Examples: `MyLibrary`, `SomeOtherLibrary`, etc
28 |
29 | ## 3. Add ModuleScripts, and Everything Else
30 |
31 | Add whatever ModuleScripts to your namespace folder you like. As with the namespace folder, you should follow a consistent naming scheme for your modules. Avoid non-alphanumeric characters. If a module needs assets, you can include those within it or somewhere else in your namespace folder.
32 |
33 | Consider the following heirarchy, where the contents of `Tools` are ModuleScripts:
34 |
35 |
47 |
48 | ## 4. Require Your ModuleScripts
49 |
50 | A string passed to `require` should first specify a namespace, followed by a colon, then the module. If the Module is within other objects, separate their names with a period. Using the heirarchy above, from (3):
51 |
52 | ```lua
53 | -- Some script in your game:
54 | local require = require(game:GetService("ReplicatedStorage"):WaitForChild("Modules"))
55 |
56 | local ServerManager = require("MyGame:ServerManager")
57 | local Sword = require("MyGame:Tools.Sword")
58 | local Slingshot = require("MyGame:Tools.Slingshot")
59 | ```
60 |
61 | _Modules_ uses the following process to locate the module:
62 |
63 | 1. If no namespace was specified, assume the Modules ModuleScript is the namespace.
64 | 2. Check for client module first: look for the namespace in ReplicatedStorage, if found, check it for the module.
65 | 3. Check for server modules second: look for the namespace in ServerScriptService, if found, check it for the module. If either the namespace or module is missing, raise a "module not found" error.
66 |
67 | If both the client and server require a shared library, but that shared library has a dependency only relevant to either the client or server, you can use `require.server` or `require.client` to skip the require and return nil if the code isn't running on that network peer:
68 |
69 | ```lua
70 | -- Some ModuleScript that is required on both client and server
71 | local require = require(game:GetService("ReplicatedStorage"):WaitForChild("Modules"))
72 |
73 | -- Client won't be able to access this
74 | local ServerManager = require.server("MyGame:ServerManager")
75 |
76 | local MySharedLibrary = {}
77 |
78 | -- Perhaps in MySharedLibrary there's a function which is
79 | -- only called by the server, and that function might use ServerManager,
80 | -- but we still want to enable the client to require this module for the other bits.
81 |
82 | return MySharedLibrary
83 | ```
84 |
85 | This is useful in a variety of cases. Most notably, if constants in a shared class module are needed by both the client and server, constructing the class might only be possible on the server. This could due to certain server dependencies, but using `require.server`, the client can skip over those dependencies since it wouldn't be constructing the object and using those dependencies.
86 |
87 | ## 5. Use a "Replicated" Folder (Optional)
88 |
89 | In some cases, like using [Roblox Packages](https://developer.roblox.com/en-us/articles/roblox-packages), it's desirable to unify all of a namespace's code (both server and client code) into one root object. You can replicate only part of a namespace folder in ServerScriptService using a "Replicated" Folder:
90 |
91 | For namespace Folders in ServerScriptService, you may add a Folder named "Replicated". Such a folder is automatically replicated to clients: the folder is moved to ReplicatedStorage and renamed to the same as the namespace. Therefore, any ModuleScripts inside the Replicated folder can be required as if the Module were placed in ReplicatedStorage in the first place.
92 |
93 | Consider the following game structure, which uses both ServerScriptService and ReplicatedStorage namespace folders. To unify the codebase into one object, we can move the client folder into the server folder and rename it "Replicated".
94 |
95 |
104 |
105 | After moving the ReplicatedStorage namespace folder to the ServerScriptService folder, and renaming it to "Replicated", the structure should now look like this:
106 |
107 |
game
108 | ├ ReplicatedStorage
109 | │ └ Modules
110 | └ ServerScriptService
111 | └ MyNamespace
112 | ├ JustAServerModule
113 | └ Replicated ← When Modules is loaded, this is moved to:
114 | └ SomeSharedModule ReplicatedStorage.MyNamespace.SomeSharedModule
115 | (which is what we had before, but now there's
116 | a single root object for all of MyNamespace!)
117 |
118 |
119 | ## 6. Check out the Goodies
120 |
121 | Modules comes with a few really useful classes you should never leave home without. See [Overview#Structure](index.md#structure) for a list, or check out a few of these: [Event](api/Event.md), [Maid](api/Maid.md), [StateMachine](api/StateMachine.md).
122 |
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
1 | # Modules
2 |
3 |
4 |
5 | > Seriously, another dependency loader for Roblox? –Somebody, probably
6 |
7 | _Modules_ is a simple dependency loader for the [Roblox engine](https://www.roblox.com). It's a single [ModuleScript](https://developer.roblox.com/en-us/api-reference/class/ModuleScript) named "Modules" which exists in [ReplicatedStorage](https://developer.roblox.com/en-us/api-reference/class/ReplicatedStorage), and it is designed to replace the built-in `require` function.
8 |
9 | ```lua
10 | local require = require(game:GetService("ReplicatedStorage"):WaitForChild("Modules"))
11 |
12 | local MyModule = require("MyNamespace:MyModule")
13 | ```
14 |
15 | _[Visit the Getting Started guide to learn the basics →](getting-started.md)_
16 |
17 | ## Download & Install
18 |
19 | There's several ways you can get it:
20 |
21 | * _[Take the Model from Roblox.com →](https://www.roblox.com/library/5618924671/Modules-v1-1-0)_
22 | * _[Download from the GitHub releases page →](https://github.com/Ozzypig/Modules/releases/)_
23 | * Advanced: build _Modules_ from source using [Rojo 0.5.x](https://github.com/Roblox/rojo)
24 |
25 | Once available, insert the Model into your Roblox place, then move the root "Modules" ModuleScript into ReplicatedStorage.
26 |
27 | ## Structure
28 |
29 | _Modules_ also includes some super common patterns as included ModuleScripts. Check out the structure here:
30 |
31 |
└ ReplicatedStorage
32 | └ Modules ← This is the root ModuleScript
33 | ├ Event
34 | ├ Maid
35 | ├ StateMachine
36 | │ └ State
37 | └ class
38 |
39 |
40 | Each of these can be required by simply providing its name without a namespace. For example:
41 |
42 | ```lua
43 | local Event = require("Event")
44 | local State = require("StateMachine.State")
45 | ```
46 |
--------------------------------------------------------------------------------
/ldoc2mkdocs/.gitignore:
--------------------------------------------------------------------------------
1 | __pycache__/
2 |
--------------------------------------------------------------------------------
/ldoc2mkdocs/LDoc2MkDocs.py:
--------------------------------------------------------------------------------
1 | """
2 | LDoc2MkDocs
3 | """
4 |
5 | import os
6 | import json
7 |
8 | from pathlib import Path
9 | from jinja2 import Environment, FileSystemLoader
10 |
11 | from .filters import filters
12 |
13 | class LDoc2MkDocs:
14 |
15 | def __init__(self, doc_json_path, out_path, templates_dir_path, pretty=False):
16 | self.doc_json_path = doc_json_path
17 | self.out_path = out_path
18 | self.pretty = pretty
19 | self.pretty_path = out_path / 'docs.pretty.json'
20 | self.templates_dir_path = templates_dir_path
21 |
22 | self.env = Environment(
23 | loader=FileSystemLoader(str(self.templates_dir_path)),
24 | autoescape=False
25 | )
26 | for filter_name in filters.keys():
27 | self.env.filters[filter_name] = filters[filter_name]
28 |
29 | self.api_path = out_path / 'api'
30 |
31 | def convert(self):
32 | # Load JSON from file
33 | doc_data = self.read_doc_json()
34 |
35 | # Write a pretty version to file, for debugging
36 | if self.pretty:
37 | self.write_pretty_json(doc_data)
38 |
39 | # Build various globals
40 | self.env.globals['ldoc_raw'] = doc_data
41 | self.env.globals['ldoc'] = self.process_ldoc_entries(doc_data)
42 |
43 | # Create a path for API md files
44 | self.api_path.mkdir(exist_ok=True)
45 |
46 | # Convert entries into markdown
47 | for entry in doc_data:
48 | self.entry_to_md(entry, self.api_path / (entry['name'] + '.md'))
49 |
50 | def process_ldoc_entries(self, doc_data):
51 | ldocEntriesByName = dict()
52 | for entry in doc_data:
53 | ldocEntriesByName[entry['name']] = entry
54 | for item in entry['items']:
55 | item['refmod'] = entry['name']
56 | item['refanchor'] = item['name']
57 | ldocEntriesByName[item['name']] = item
58 | if not (':' in item['name'] or '.' in item['name']):
59 | fqName = entry['name'] + (':' if entry['type'] == 'classmod' and item['type'] not in ('staticfunction',) else '.') + item['name']
60 | if fqName not in ldocEntriesByName:
61 | ldocEntriesByName[fqName] = item
62 | return ldocEntriesByName
63 |
64 | def read_doc_json(self):
65 | with open(str(self.doc_json_path), 'r') as f:
66 | return json.loads(f.read())
67 |
68 | def write_pretty_json(self, data):
69 | with open(str(self.pretty_path), 'w') as f:
70 | f.write(json.dumps(data, indent='\t'))
71 | print('Wrote indented json to: ' + str(self.pretty_path))
72 |
73 | def entry_to_md(self, entry, filepath):
74 | with open(str(filepath), 'w') as f:
75 | f.write(self.choose_entry_template(entry).render({
76 | 'entry': entry
77 | }))
78 |
79 | def choose_entry_template(self, entry):
80 | return self.env.get_template('template.md')
81 |
82 |
--------------------------------------------------------------------------------
/ldoc2mkdocs/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ozzypig/Modules/e51d56366e7e3a107e9a478db10f7cb0aa2cb048/ldoc2mkdocs/__init__.py
--------------------------------------------------------------------------------
/ldoc2mkdocs/__main__.py:
--------------------------------------------------------------------------------
1 | """
2 | ldoc2mkdocs.py
3 |
4 | Usage: python ldoc2md.py DOCJSON OUTPATH
5 |
6 | Converts a json file containing a dump of LDoc's raw data output into Markdown ready for mkdoc.
7 | Uses Jinja2 templates to generate API doc pages from the
8 |
9 | """
10 |
11 | from click import command, argument, option, format_filename, Path as click_Path
12 | from pathlib import Path
13 |
14 | from .LDoc2MkDocs import LDoc2MkDocs
15 |
16 | templates_dir_path = Path(__file__).parent.parent / 'doc-templates'
17 |
18 | @command(help='Convert a JSON file containing a dump of LDoc data into mkdocs-ready markdown files')
19 | @argument('doc_json_path', type=click_Path(exists=True, file_okay=True, dir_okay=False, readable=True))
20 | @argument('out_path', type=click_Path(exists=True, file_okay=False, dir_okay=True, writable=True))
21 | @option('-p', '--pretty', is_flag=True, help='Should a prettified copy of the json file be output as well?')
22 | def ldoc2mkdocs(doc_json_path, out_path, pretty):
23 | ldoc2mkdocs = LDoc2MkDocs(
24 | Path(doc_json_path),
25 | Path(out_path),
26 | templates_dir_path,
27 | pretty=pretty
28 | )
29 | ldoc2mkdocs.convert()
30 |
31 | if __name__ == '__main__':
32 | ldoc2mkdocs()
33 |
--------------------------------------------------------------------------------
/ldoc2mkdocs/filters.py:
--------------------------------------------------------------------------------
1 | import re
2 |
3 | from jinja2 import Markup, environmentfilter
4 |
5 | def uri_fragment(s):
6 | return s.replace('[^a-zA-Z_-/?]', '')
7 |
8 | def anchor_here(s):
9 | return Markup(''.format(uri_fragment(s)))
10 |
11 | lua_types = (
12 | 'nil', 'boolean', 'number', 'string', 'function', 'userdata', 'table',
13 | '...', 'float', 'double', 'integer', 'bool', 'array', 'dictionary'
14 | )
15 | roblox_doc = 'https://developer.roblox.com/api-reference/'
16 | lua_manual = 'https://www.lua.org/manual/5.1/manual.html'
17 |
18 | @environmentfilter
19 | def xref_link(env, api_name):
20 | if api_name[0:4] == 'rbx:':
21 | api_name = api_name[4:]
22 | text = api_name
23 | if api_name[0:9] == 'datatype/':
24 | text = api_name[9:]
25 | elif api_name[0:5] == 'enum/':
26 | text = api_name[5:]
27 | elif api_name[0:6] == 'class/':
28 | text = api_name[6:]
29 | return (text, roblox_doc + api_name)
30 | elif api_name in lua_types:
31 | return (api_name, lua_manual)
32 |
33 | raw_data = env.globals['ldoc'].get(api_name)
34 | if raw_data:
35 | # Determine what md file we should cross-reference
36 | md_file = api_name + '.md'
37 | if 'refmod' in raw_data:
38 | md_file = env.globals['ldoc'][raw_data['refmod']]['name'] + '.md'
39 | target = md_file
40 | if 'refanchor' in raw_data:
41 | target += '#-' + uri_fragment(raw_data['refanchor'])
42 | return (api_name, target)
43 | else:
44 | raise Exception('unknown xref: ' + api_name)
45 |
46 | @environmentfilter
47 | def xref(env, text):
48 | text, target = xref_link(env, text)
49 | return '[{text}]({target})'.format(
50 | text=text,
51 | target=target
52 | )
53 |
54 | @environmentfilter
55 | def xref_to(env, text, api_name):
56 | return '[{text}]({target})'.format(
57 | text=text,
58 | target=xref_link(env, api_name)
59 | )
60 |
61 | # These regular expressions parse out LDoc-style cross references
62 | # For example @{Module} or @{Module|text}
63 | _re_xref_with_text = re.compile(r'@{(?P[^\}\|]+)\|(?P[^}]+)}')
64 | _re_xref = re.compile(r'@{(?P[^\}]+)}')
65 |
66 | """Jinja2 filter which transforms LDoc-style cross-references into Markdown links appropriate for mkdocs"""
67 | @environmentfilter
68 | def ldoc_filter(env, s):
69 | def repl(match):
70 | d = match.groupdict()
71 | api_name = d['api_name']
72 |
73 | raw_data = env.globals['ldoc'].get(api_name)
74 |
75 | text = d.get('text', api_name)
76 |
77 | try:
78 | _, target = xref_link(env, api_name)
79 | return '[{text}]({target})'.format(text=text, target=target)
80 | except:
81 | return '{} (xref: \"{}\")'.format(text, api_name)
82 |
83 | s = _re_xref_with_text.sub(repl, s)
84 | s = _re_xref.sub(repl, s)
85 | return Markup(s)
86 |
87 | filters = {
88 | 'anchor_here': anchor_here,
89 | 'xref': xref,
90 | 'xref_link': xref_link,
91 | 'xref_to': xref_to,
92 | 'ldoc': ldoc_filter,
93 | }
94 |
--------------------------------------------------------------------------------
/mkdocs.yml:
--------------------------------------------------------------------------------
1 | site_name: Modules Documentation
2 | repo_name: Ozzypig/Modules
3 | repo_url: https://github.com/Ozzypig/Modules
4 |
5 | nav:
6 | - Overview: index.md
7 | - Getting Started: getting-started.md
8 | - API:
9 | - Modules:
10 | - 'ModuleLoader': api/ModuleLoader.md
11 | - 'class': api/class.md
12 | - Classes:
13 | - 'Maid': api/Maid.md
14 | - 'Event': api/Event.md
15 | - 'StateMachine': api/StateMachine.md
16 | - 'State': api/State.md
17 |
18 | docs_dir: docs-build
19 |
20 | theme:
21 | name: material #readthedocs
22 | favicon: Modules-32.png
23 | logo: Modules-256.png
24 | icon:
25 | repo: fontawesome/brands/github
26 | palette:
27 | primary: deep orange
28 | accent: amber
29 |
30 | extra_css:
31 | - extra.css
32 | - https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.1.1/styles/default.min.css
33 |
34 | extra_javascript:
35 | - https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.1.1/highlight.min.js
36 | - javascripts/config.js
37 |
38 | edit_uri: ""
39 |
40 | markdown_extensions:
41 | - codehilite
42 |
--------------------------------------------------------------------------------
/requirements-docs.txt:
--------------------------------------------------------------------------------
1 | mkdocs
2 | pymdown-extensions
3 | mkdocs-material
4 | click
5 | Jinja2
--------------------------------------------------------------------------------
/selene.toml:
--------------------------------------------------------------------------------
1 | std = "roblox"
2 |
3 | [config]
4 | unused_variable = { allow_unused_self = true }
5 | multiple_statements = { one_line_if = "allow" }
6 | empty_if = { comments_count = true }
7 |
--------------------------------------------------------------------------------
/src/Event.lua:
--------------------------------------------------------------------------------
1 | --[[-- Implementation of Roblox's event API using a @{rbx:class/BindableEvent|BindableEvent}.
2 | Event re-implements Roblox's event API (connect, fire, wait) by wrapping a
3 | @{rbx:class/BindableEvent|BindableEvent}.
4 |
5 | This Event implementation is based on the [Signal from Nevermore Engine by
6 | Quenty](https://github.com/Quenty/NevermoreEngine/blob/version2/Modules/Shared/Events/Signal.lua).
7 |
8 | #### Why?
9 |
10 | This implementation does not suffer from the restrictions normally introduced by using
11 | a BindableEvent. When firing a BindableEvent, the Roblox engine makes a copy of the values
12 | passed to @{rbx:function/BindableEvent/Fire|BindableEvent:Fire}. On the other hand, this class
13 | temporarily stores the values passed to @{Event:fire|fire}, fires the wrapped BindableEvent
14 | without arguments. The values are then retrieved and passed appropriately.
15 |
16 | This means that the _exact_ same values passed to @{Event:fire|fire} are
17 | sent to @{Event:connect|connected} handler functions and also returned by @{Event:wait|wait},
18 | rather than copies. This includes tables with metatables, and other values that are normally
19 | not serializable by Roblox.
20 |
21 | #### Usage
22 | ```lua
23 | local zoneCapturedEvent = Event.new()
24 |
25 | -- Hook up a handler function using connect
26 | local function onZoneCaptured(teamName)
27 | print("The zone was captured by: " .. teamName)
28 | end
29 | zoneCapturedEvent:connect(onZoneCaptured)
30 |
31 | -- Or use wait, if you like that sort of thing
32 | local teamName
33 | while true do
34 | teamName = zoneCapturedEvent:wait()
35 | print("The zone was captured by: " .. teamName)
36 | end
37 |
38 | -- Trigger the event using fire
39 | zoneCapturedEvent:fire("Blue team")
40 | zoneCapturedEvent:fire("Red team")
41 |
42 | -- Remember to call cleanup then forget about the event when
43 | -- it is no longer needed!
44 | zoneCapturedEvent:cleanup()
45 | zoneCapturedEvent = nil
46 | ```
47 | ]]
48 | -- @classmod Event
49 |
50 | local Event = {}
51 | Event.__index = Event
52 | Event.ClassName = "Event"
53 |
54 | --- Constructs a new Event.
55 | -- @staticfunction Event.new
56 | -- @constructor
57 | -- @treturn Event
58 | function Event.new()
59 | local self = setmetatable({}, Event)
60 |
61 | self._bindableEvent = Instance.new("BindableEvent")
62 | self._argData = nil
63 | self._argCount = nil -- Prevent edge case of :Fire("A", nil) --> "A" instead of "A", nil
64 |
65 | return self
66 | end
67 |
68 | --- Connect a new handler function to the event. Returns a connection object that can be disconnected.
69 | -- @tparam function handler Function handler called with arguments passed when `:Fire(...)` is called
70 | -- @treturn rbx:datatype/RBXScriptConnection Connection object that can be disconnected
71 | function Event:connect(handler)
72 | if not (type(handler) == "function") then
73 | error(("connect(%s)"):format(typeof(handler)), 2)
74 | end
75 |
76 | return self._bindableEvent.Event:Connect(function()
77 | handler(unpack(self._argData, 1, self._argCount))
78 | end)
79 | end
80 |
81 | --- Wait for @{Event:fire|fire} to be called, then return the arguments it was given.
82 | -- @treturn ... Variable arguments from connection
83 | function Event:wait()
84 | self._bindableEvent.Event:Wait()
85 | assert(self._argData, "Missing arg data, likely due to :TweenSize/Position corrupting threadrefs.")
86 | return unpack(self._argData, 1, self._argCount)
87 | end
88 |
89 | --- Fire the event with the given arguments. All handlers will be invoked. Handlers follow
90 | -- Roblox Event conventions.
91 | -- @param ... Variable arguments to pass to handler
92 | function Event:fire(...)
93 | self._argData = {...}
94 | self._argCount = select("#", ...)
95 | self._bindableEvent:Fire()
96 | self._argData = nil
97 | self._argCount = nil
98 | end
99 |
100 | --- Disconnects all connected events to the Event. Voids the Event as unusable.
101 | function Event:cleanup()
102 | if self._bindableEvent then
103 | self._bindableEvent:Destroy()
104 | self._bindableEvent = nil
105 | end
106 |
107 | self._argData = nil
108 | self._argCount = nil
109 | end
110 |
111 | -- Aliases
112 | Event.Wait = Event.wait
113 | Event.Fire = Event.fire
114 | Event.Connect = Event.connect
115 | Event.Destroy = Event.cleanup
116 |
117 | return Event
--------------------------------------------------------------------------------
/src/Maid.lua:
--------------------------------------------------------------------------------
1 | --[[- Utility object for cleaning up, destroying and otherwising releasing resources.
2 | A **Maid** is provided tasks which it will handle when it is told to @{Maid:cleanup|cleanup}.
3 | A task may be a function, connection, Roblox Instance, or table with a `cleanup` function.
4 | Connections are always disconnected before other tasks.
5 |
6 | This Maid implementation is based on the
7 | [Maid from Nevermore Engine by Quenty](https://github.com/Quenty/NevermoreEngine/blob/version2/Modules/Shared/Events/Maid.lua).
8 |
9 | #### Usage
10 |
11 | ```lua
12 | local maid = Maid.new()
13 |
14 | -- Something you might need to clean up:
15 | local part = workspace.SomePart
16 |
17 | -- Add tasks to the maid. A task can be...
18 | maid:addTask(part) -- a Roblox instance
19 | maid:addTask(part.Touched:Connect(...)) -- a connection
20 | maid:addTask(function() ... end) -- a function
21 | maid:addTask(Event.new()) -- something with a cleanup function
22 |
23 | -- You can add tasks by id:
24 | maid["somePart"] = Instance.new("Part") -- "somePart" is a task id
25 | maid["somePart"] = Instance.new("Part") -- the first part gets cleaned up
26 | -- because "somePart" got overwritten!
27 |
28 | -- Instruct the maid to perform all tasks:
29 | maid:cleanup()
30 | ```
31 | ]]
32 | -- @classmod Maid
33 |
34 | local Maid = {}
35 | --Maid.__index = Maid
36 |
37 | --- Constructs a new Maid.
38 | -- @constructor
39 | -- @treturn Maid
40 | function Maid.new()
41 | local self = setmetatable({
42 |
43 | --- Stores this maid's tasks
44 | -- @field Maid.tasks
45 | -- @treturn table
46 | tasks = {};
47 |
48 | }, Maid)
49 | return self
50 | end
51 |
52 | function Maid:__index(index)
53 | if Maid[index] then
54 | return Maid[index]
55 | else
56 | return self.tasks[index]
57 | end
58 | end
59 |
60 | function Maid:__newindex(index, newTask)
61 | if type(Maid[index]) ~= "nil" then
62 | error(("\"%s\" is reserved"):format(tostring(index)), 2)
63 | end
64 | local oldTask = self.tasks[index]
65 | self.tasks[index] = newTask
66 | if oldTask then self:performTask(oldTask) end
67 | end
68 |
69 | --- Executes the given task
70 | function Maid:performTask(task)
71 | local ty = typeof(task)
72 | if ty == "function" then
73 | task()
74 | elseif ty == "Instance" then
75 | task:Destroy()
76 | elseif ty == "RBXScriptConnection" then
77 | task:disconnect()
78 | elseif task.cleanup then
79 | task:cleanup()
80 | else
81 | error(("unknown task type \"%s\""):format(ty))
82 | end
83 | end
84 |
85 | --- Give this maid a task, and returns its id.
86 | -- @treturn number the task id
87 | function Maid:addTask(task)
88 | assert(task, "Task expected")
89 | local taskId = #self.tasks + 1
90 | self[taskId] = task
91 | return taskId
92 | end
93 | Maid.giveTask = Maid.addTask
94 |
95 | --- Cause the maid to do all of its tasks then forget about them
96 | function Maid:cleanup()
97 | local tasks = self.tasks
98 |
99 | -- Disconnect first
100 | for index, task in pairs(tasks) do
101 | if typeof(task) == "RBXScriptConnection" then
102 | tasks[index] = nil
103 | task:disconnect()
104 | end
105 | end
106 |
107 | -- Clear tasks table (don't use generic for here)
108 | local index, task = next(tasks)
109 | while type(task) ~= "nil" do
110 | self:performTask(task)
111 | tasks[index] = nil
112 | index, task = next(tasks)
113 | end
114 | end
115 |
116 | return Maid
--------------------------------------------------------------------------------
/src/StateMachine/State.lua:
--------------------------------------------------------------------------------
1 | --[[-- A uniquely identified state of a @{StateMachine}.
2 | A **State** is an object with a unique @{State.id|id} that represents one possible state of a parent @{StateMachine}.
3 | A state can be associated with only one machine (its parent), and its id must be unique within its machine.
4 | When the parent machine has entered a state, that state is considered @{State:isActive|active} until it
5 | @{State:leave|leaves} the state.
6 |
7 | States can be @{State.new|constructed}, @{StateMachine:addState|added} and @{State:cleanup|cleaned up} manually,
8 | or they may be created through @{StateMachine:newState}. For manually-constructed states, it's helpful to
9 | @{Maid:addTask|add} the state as a task to the machine's @{StateMachine.maid|maid} so it is cleaned up
10 |
11 | #### Event-based Usage
12 |
13 | ```lua
14 | local sm = StateMachine.new()
15 |
16 | -- Create some states
17 | local stateGame = sm:newState("game")
18 | local stateShop = sm:newState("shop")
19 | stateShop.onEnter:connect(function ()
20 | print("Welcome to the shop!")
21 | end)
22 | stateShop.onLeave:connect(function ()
23 | print("Come back soon.")
24 | end)
25 |
26 | -- Make some transitions
27 | sm:transition("game")
28 | sm:transition("shop") --> Welcome to the shop!
29 | sm:transition("game") --> Come back soon.
30 | ```
31 |
32 | #### Subclass Usage
33 |
34 | ```lua
35 | local CounterState = setmetatable({}, State)
36 | CounterState.__index = CounterState
37 |
38 | function CounterState.new(...)
39 | local self = setmetatable(State.new(...), CounterState)
40 |
41 | -- Tracks number of times it has been transitioned to
42 | self.transitionCount = 0
43 |
44 | return self
45 | end
46 |
47 | function CounterState:enter(...)
48 | State.enter(self, ...) -- call super
49 | self.transitionCount = self.transitionCount + 1
50 | end
51 | ```
52 |
53 | ##### Usage
54 |
55 | ```lua
56 | local sm = StateMachine.new()
57 | sm.StateClass = CounterState -- StateMachine:newState now uses CounterState
58 |
59 | local firstState = sm:newState("first")
60 | sm:newState("second")
61 | sm:newState("third")
62 |
63 | sm:transition("first")
64 | sm:transition("second")
65 | sm:transition("first")
66 |
67 | print("Transitions: " .. firstState.transitionCount) --> Transitions: 2
68 | ```
69 |
70 | ]]
71 | -- @classmod State
72 |
73 | local Maid = require(script.Parent.Parent:WaitForChild("Maid"))
74 | local Event = require(script.Parent.Parent:WaitForChild("Event"))
75 |
76 | local State = {}
77 | State.__index = State
78 |
79 | --- Construct a new state given the @{StateMachine} it should belong to, its unique id within this machine,
80 | -- and optionally a function to immediately connect to the @{State.onEnter|onEnter} event.
81 | -- This is called by @{StateMachine:newState}, automatically providing the machine to this constructor.
82 | -- @tparam StateMachine machine The `StateMachine` to which the new state should belong.
83 | -- @tparam string id An identifier for the new state, must be unique within the parent `StateMachine`.
84 | -- @tparam[opt] function onEnterCallback A `function` to immediately connect to @{State.onEnter|onEnter}
85 | -- @constructor
86 | function State.new(machine, id, onEnterCallback)
87 | if type(id) ~= "string" then error("State.new expects string id", 3) end
88 | local self = setmetatable({
89 | --- The parent @{StateMachine} which owns this state and can @{StateMachine:transition|transition} to it.
90 | -- @treturn StateMachine
91 | machine = machine;
92 |
93 | --- The unique string that identifies this state in its machine
94 | -- @treturn string
95 | id = id;
96 |
97 | --- A @{Maid} invoked upon @{StateMachine:cleanup|cleanup}
98 | -- @treturn Maid
99 | maid = Maid.new();
100 |
101 | --- Fires when the parent @{State.machine|machine} @{StateMachine:transition|transitions} into this state.
102 | -- @event onEnter
103 | -- @tparam State prevState The @{State} which the parent {@State.machine|machine} had left (if any)
104 | onEnter = Event.new();
105 |
106 | --- Fires when parent @{State.machine|machine} @{StateMachine:transition|transitions} out of this state.
107 | -- @event onLeave
108 | -- @tparam State nextState The @{State} which the parent {@State.machine|machine} will enter (if any)
109 | onLeave = Event.new();
110 |
111 | --- Tracks the "Active" States of any @{StateMachine:newSubmachine|sub-machines} for this state,
112 | -- transitioning to them when this state is @{State.onEnter|onEnter}.
113 | -- @private
114 | submachineActiveStates = {};
115 |
116 | --- Tracks the "Inactive" States of any @{StateMachine:newSubmachine|sub-machines} for this state,
117 | -- transitioning to them when this state is @{State.onLeave|onLeave}.
118 | -- @private
119 | submachineInactiveStates = {};
120 | }, State)
121 | self.maid:addTask(self.onEnter)
122 | self.maid:addTask(self.onLeave)
123 |
124 | if type(onEnterCallback) == "function" then
125 | self.maid:addTask(self.onEnter:connect(onEnterCallback))
126 | elseif type(onEnterCallback) == "nil" then
127 | -- that's ok
128 | else
129 | error("State.new() was given non-function onEnterCallback (" .. type(onEnterCallback) .. ", " .. tostring(onEnterCallback) .. ")")
130 | end
131 |
132 | return self
133 | end
134 |
135 | --- Returns a string with this state's @{State.id|id}.
136 | function State:__tostring()
137 | return (""):format(self.id)
138 | end
139 |
140 | --- Clean up resources used by this state.
141 | -- Careful, this function doesn't remove this state from the parent @{State.machine|machine}!
142 | function State:cleanup()
143 | self.submachineInactiveStates = nil
144 | self.submachineActiveStates = nil
145 | self.machine = nil
146 | if self.maid then
147 | self.maid:cleanup()
148 | self.maid = nil
149 | end
150 | end
151 |
152 | --- Called when the parent @{State.machine|machine} enters this state, firing
153 | -- the @{State.onEnter|onEnter} event with all given arguments.
154 | -- If this state has any @{StateMachine:newSubmachine|sub-machines},
155 | -- they will transition to the "Active" state.
156 | function State:enter(...)
157 | self.onEnter:fire(...)
158 | for submachine, activeState in pairs(self.submachineActiveStates) do
159 | if not submachine:isInState(activeState) then
160 | submachine:transition(activeState)
161 | end
162 | end
163 | end
164 |
165 | --- Called when the parent @{State.machine|machine} leaves this state, firing
166 | -- the @{State.onLeave|onLeave} event with all given arguments.
167 | -- If this state has any @{StateMachine:newSubmachine|sub-machines},
168 | -- they will transition to the "Inactive" state.
169 | function State:leave(...)
170 | self.onLeave:fire(...)
171 | for submachine, inactiveState in pairs(self.submachineInactiveStates) do
172 | if not submachine:isInState(inactiveState) then
173 | submachine:transition(inactiveState)
174 | end
175 | end
176 | end
177 |
178 | --- Returns whether the parent @{State.machine|machine} is @{StateMachine:isInState|currently in} this state.
179 | -- @treturn boolean
180 | function State:isActive()
181 | return self.machine:isInState(self)
182 | end
183 |
184 | --- Orders the parent @{State.machine|machine} to @{StateMachine:transition|transition} to this state.
185 | -- @return The result of the @{StateMachine:transition|transition}.
186 | function State:transition()
187 | return self.machine:transition(self)
188 | end
189 |
190 | --- Helper function for @{StateMachine:newSubmachine}
191 | -- @private
192 | function State:addSubmachine(submachine, inactiveState, activeState)
193 | self.submachineInactiveStates[submachine] = inactiveState
194 | self.submachineActiveStates[submachine] = activeState
195 | return inactiveState, activeState
196 | end
197 |
198 | return State
199 |
--------------------------------------------------------------------------------
/src/StateMachine/init.lua:
--------------------------------------------------------------------------------
1 | --[[-- A mechanism that transitions between @{State|states}.
2 |
3 | A **StateMachine** (or machine) is a mechanism that @{StateMachine:transition|transitions} between several
4 | @{State|states} that have been @{StateMachine:addState|added}, usually through @{StateMachine:newState|newState}.
5 | When a transition is performed, the previous state is @{State:leave|left}, the
6 | @{StateMachine.onTransition|onTransition} event fires, and the new state is @{State:enter|entered}.
7 | Machines do not begin in any state in particular, but rather a nil state.
8 |
9 | #### Event-based Usage
10 |
11 | ```lua
12 | local sm = StateMachine.new()
13 |
14 | -- Create some states
15 | local stateGame = sm:newState("game")
16 | local stateShop = sm:newState("shop")
17 | stateShop.onEnter:connect(function ()
18 | print("Welcome to the shop!")
19 | end)
20 | stateShop.onLeave:connect(function ()
21 | print("Come back soon.")
22 | end)
23 |
24 | -- Make some transitions
25 | sm:transition("game")
26 | sm:transition("shop") --> Welcome to the shop!
27 | sm:transition("game") --> Come back soon.
28 | ```
29 |
30 | #### Subclass Usage
31 |
32 | ```lua
33 | local MyMachine = setmetatable({}, StateMachine)
34 | MyMachine.__index = MyMachine
35 |
36 | function MyMachine.new()
37 | local self = setmetatable(StateMachine.new(), MyMachine)
38 |
39 | -- States
40 | self.defaultState = self:newState("default")
41 | self.spamState = self:newState("spam")
42 | self.eggsState = self:newState("eggs")
43 | self.maid:addTask(self.spamState.onEnter:connect(function ()
44 | self:onSpamStateEntered()
45 | end))
46 |
47 | -- Transition counter
48 | self.transitionCounter = 0
49 |
50 | -- Default state
51 | self:transition(self.defaultState)
52 |
53 | return self
54 | end
55 |
56 | function MyMachine:transition(...)
57 | StateMachine.transition(self, ...) -- call super
58 | self.transitionCounter = self.transitionCounter + 1
59 | print("Transitions: " .. self.transitionCounter)
60 | end
61 |
62 | function MyMachine:onSpamStateEntered()
63 | print("Spam!")
64 | end
65 | ```
66 |
67 | ##### Usage
68 |
69 | ```lua
70 | local myMachine = MyMachine.new()
71 | myMachine:transition("spam")
72 | myMachine:transition("eggs")
73 | ```
74 |
75 | #### Sub-machines
76 |
77 | Certain @{State|states} may control their own StateMachine (a sub-StateMachine or submachine). When a state with a sub-machine is entered,
78 | the submachine enters the "Active" state. Upon leaving, it enters the "Inactive" state.
79 | ]]
80 | --@classmod StateMachine
81 |
82 | local Maid = require(script.Parent:WaitForChild("Maid"))
83 | local Event = require(script.Parent:WaitForChild("Event"))
84 |
85 | local State = require(script:WaitForChild("State"))
86 |
87 | local StateMachine = {}
88 | StateMachine.__index = StateMachine
89 |
90 | --- The @{State} class used when creating a @{StateMachine:newState|new state} via this machine.
91 | -- @field StateMachine.StateClass
92 | StateMachine.StateClass = State
93 |
94 | --- The @{StateMachine} class used when creating a @{StateMachine:newSubmachine|new submachine} for a state via this machine.
95 | -- @field StateMachine.SubStateMachineClass
96 | StateMachine.SubStateMachineClass = StateMachine
97 |
98 | --- Construct a new StateMachine.
99 | -- The new machine has no states @{StateMachine:addState|added} to it, and is not in any state to begin with.
100 | -- @constructor
101 | -- @treturn StateMachine The new state machine with no @{State|states}.
102 | function StateMachine.new()
103 | local self = setmetatable({
104 | --- Refers to the current @{State} the machine is in, if any. Use @{StateMachine:isInState|isInState}
105 | -- to check the current state by object or id.
106 | state = nil;
107 |
108 | --- Dictionary of @{State|states} by @{State.id|id} that have been @{StateMachine:addState|added}
109 | -- @treturn dictionary
110 | states = {};
111 |
112 | --- A @{Maid} invoked upon @{StateMachine:cleanup|cleanup}
113 | -- Cleans up @{StateMachine.onTransition|onTransition} and states constructed
114 | -- through @{StateMachine:newState|newState}.
115 | -- @treturn Maid
116 | maid = Maid.new();
117 |
118 | --- Fires when the machine @{StateMachine:transition|transitions} between states.
119 | -- @event onTransition
120 | -- @tparam State oldState The state which the machine is leaving, if any
121 | -- @tparam State newState The state whihc the machine is entering, if any
122 | onTransition = Event.new();
123 |
124 | --- A flag which enables transition @{StateMachine:print|printing} for this machine.
125 | -- @treturn boolean
126 | debugMode = false;
127 | }, StateMachine)
128 | self.maid:addTask(self.onTransition)
129 | return self
130 | end
131 |
132 | --- Returns a string with the current @{State} this machine is in (if any), calling @{State:__tostring}.
133 | function StateMachine:__tostring()
134 | return (""):format(self.state and tostring(self.state) or "")
135 | end
136 |
137 | --- Wraps the default `print` function; does nothing if @{StateMachine.debugMode|debugMode} is false.
138 | function StateMachine:print(...)
139 | if self.debugMode then
140 | print(...)
141 | end
142 | end
143 |
144 | --- Clean up resources used by this machine by calling @{Maid:cleanup|cleanup} on
145 | -- this machine's @{StateMachine.maid|maid}.
146 | -- States created with @{StateMachine:newState|newState} are cleaned up as well.
147 | function StateMachine:cleanup()
148 | if self.maid then
149 | self.maid:cleanup()
150 | self.maid = nil
151 | end
152 | self.states = nil
153 | end
154 |
155 | --- Add a @{State|State} to this machine's @{StateMachine.states|states}.
156 | -- @tparam State state A @{State|State} object
157 | -- @return The @{State|state} that was added.
158 | function StateMachine:addState(state)
159 | --if not state or getmetatable(state) ~= State then error("StateMachine:addState() expects state", 2) end
160 | self.states[state.id] = state
161 | return state
162 | end
163 |
164 | --- @{State.new|Construct} and @{StateMachine:addState|add} a new @{State|state} of type
165 | -- @{StateMachine.StateClass|StateClass} (by default, @{State}) for this machine.
166 | -- The state is @{Maid:addTask|added} as a @{StateMachine.maid|maid} task.
167 | -- @return The newly constructed @{State|state} .
168 | function StateMachine:newState(...)
169 | local state = self.StateClass.new(self, ...)
170 | self.maid:addTask(state)
171 | return self:addState(state)
172 | end
173 |
174 | --- Determines whether this machine has a @{State|state} with the given id.
175 | -- @tparam string id The id of the state to check.
176 | -- @treturn boolean Whether the machine has a @{State|state} with the given id.
177 | function StateMachine:hasState(id)
178 | return self.states[id] ~= nil
179 | end
180 |
181 | --- Get a @{State|state} by id that was previously @{StateMachine:addState|added} to this machine.
182 | -- @tparam string id The id of the state.
183 | -- @treturn State The state, or nil if no state was added with the given id.
184 | function StateMachine:getState(id)
185 | return self.states[id]
186 | end
187 |
188 | --- If given an id of a state, return the state with that id (or produce an error no such state exists).
189 | -- Otherwise, returns the given state.
190 | -- @private
191 | function StateMachine:_stateArg(stateOrId)
192 | local state = stateOrId
193 | if type(stateOrId) == "string" then
194 | state = self:hasState(stateOrId)
195 | and self:getState(stateOrId)
196 | or error("Unknown state id: " .. tostring(stateOrId))
197 | --else
198 | -- TODO: verify stateOrId is in fact a State
199 | end
200 | return state
201 | end
202 |
203 | --- Returns whether the machine is currently in the given state or state with given id
204 | -- @tparam ?State|string state The state or id of the state to check
205 | -- @treturn boolean
206 | function StateMachine:isInState(state)
207 | assert(type(state) ~= "nil", "Must provide non-nil state")
208 | return self.state == self:_stateArg(state)
209 | end
210 |
211 | --- Transition the machine to another state, firing all involved events in the process.
212 | -- This method will @{StateMachine:print|print} transitions before making them if the machine
213 | -- has @{StateMachine.debugMode|debugMode} set.
214 | -- Events are fired in the following order: @{State.onLeave|old state onLeave},
215 | -- @{StateMachine.onTransition|machine onTransition}, then finally @{State.onEnter|new state onEnter}.
216 | -- @tparam ?State|string stateNew The state to which the machine should transition, or its `id`.
217 | function StateMachine:transition(stateNew)
218 | if type(stateNew) == "string" then stateNew = self:hasState(stateNew) and self:getState(stateNew) or error("Unknown state id: " .. tostring(stateNew), 2) end
219 | if type(stateNew) == "nil" then error("StateMachine:transition() requires state", 2) end
220 | --if getmetatable(stateNew) ~= State then error("StateMachine:transition() expects state", 2) end
221 |
222 | local stateOld = self.state
223 | self:print(("%s -> %s"):format(stateOld and stateOld.id or "(none)", stateNew and stateNew.id or "(none)"))
224 |
225 | self.state = stateNew
226 | if stateOld then stateOld:leave(stateNew) end
227 | self.onTransition:fire(stateOld, stateNew)
228 | if stateNew then stateNew:enter(stateOld) end
229 | end
230 |
231 | --[[-- Create a StateMachine of type @{StateMachine.SubStateMachineClass|SubStateMachineClass}, given a @{State|state}.
232 | Two @{StateMachine:newState|new states} are created on the sub-StateMachine with ids "Active" and "Inactive":
233 |
234 | * When the parent machine @{State.onEnter|enters} the given state, the sub-StateMachine transitions to "Active".
235 | * When the parent machine @{State.onLeave|leaves} the given state, the sub-StateMachine transitions to "Inactive".
236 |
237 | The sub-StateMachine is @{Maid:addTask|added} as a task to this machine's @{StateMachine.maid|maid}.
238 | ]]
239 | -- @tparam State state The @{State} to implement the new sub-machine.
240 | function StateMachine:newSubmachine(state)
241 | local submachine = self.SubStateMachineClass.new()
242 | self.maid:addTask(submachine)
243 |
244 | local inactiveState = submachine:newState("Inactive")
245 | local activeState = submachine:newState("Active")
246 |
247 | -- Initial transition
248 | if self:isInState(state) then
249 | submachine:transition(activeState)
250 | else
251 | submachine:transition(inactiveState)
252 | end
253 |
254 | return submachine, state:addSubmachine(submachine, inactiveState, activeState)
255 | end
256 |
257 | return StateMachine
258 |
--------------------------------------------------------------------------------
/src/class.lua:
--------------------------------------------------------------------------------
1 | --- @module class
2 | -- Utility for working with idiomatic Lua object-oriented patterns
3 |
4 | local class = {}
5 |
6 | --- Given an `object`, return its class
7 | function class.classOf(object)
8 | return getmetatable(object)
9 | end
10 |
11 | --- Given an `object` and a `class`, return if that object is an instance of another class/superclass
12 | -- Equivalent to @{class.extends}(@{class.classOf}(object), `cls`)
13 | function class.instanceOf(object, cls)
14 | return class.extends(class.classOf(object), cls)
15 | end
16 |
17 | --- Get the superclass of a class
18 | function class.getSuperclass(theClass)
19 | local meta = getmetatable(theClass)
20 | return meta and meta.__index
21 | end
22 |
23 | --- Check if one class extends another class
24 | function class.extends(subclass, superclass)
25 | assert(type(subclass) == "table")
26 | assert(type(superclass) == "table")
27 | local c = subclass
28 | repeat
29 | if c == superclass then return true end
30 | c = class.getSuperclass(c)
31 | until not c
32 | return false
33 | end
34 |
35 | return class
36 |
--------------------------------------------------------------------------------
/src/init.lua:
--------------------------------------------------------------------------------
1 | --- The main ModuleLoader, designed to replace the built-in require function.
2 | -- ModuleLoader is the main object returned by the "Modules" ModuleScript. It is designed to
3 | -- replace the built-in `require` function, retaining all its normal behaviors while also adding
4 | -- more:
5 | --
6 | -- * Call with a string, eg `require("Namespace:ModuleName")`, to require a Module in a Namespace
7 | -- * Call `require.server` to skip requires if on the client (get nil instead)
8 | -- * Call `require.client` to skip requires if on the server (get nil instead)
9 | --
10 | -- @usage local require = require(game:GetService("ReplicatedStorage"):WaitForChild("Modules"))
11 | -- @module ModuleLoader
12 |
13 | local RunService = game:GetService("RunService")
14 | local ReplicatedStorage = game:GetService("ReplicatedStorage")
15 | local ServerScriptService = game:GetService("ServerScriptService")
16 |
17 | local isServer = RunService:IsServer()
18 | local isClient = RunService:IsClient()
19 |
20 | local ModuleLoader = {}
21 | setmetatable(ModuleLoader, ModuleLoader)
22 |
23 | ModuleLoader.NAME_SPLIT_PATTERN = "%."
24 | ModuleLoader.VERSION = require(script.version)
25 | ModuleLoader.DEBUG_MODE = (function ()
26 | return script:FindFirstChild("DebugEnablede")
27 | or (script:FindFirstChild("Debug")
28 | and script.Debug:IsA("BoolValue")
29 | and script.Debug.Value)
30 | end)()
31 |
32 | --- Starting with `parent`, call FindFirstChild using each name in the `names` array
33 | -- until one is found
34 | --@return Instance
35 | local function getObject(parent, names)
36 | assert(typeof(parent) == "Instance")
37 | assert(type(names) == "table")
38 | local object = parent
39 | for i = 1, #names do
40 | if not object then return end
41 | object = object:FindFirstChild(names[i])
42 | end
43 | return object
44 | end
45 |
46 | --- Split the given string by repeatedly calling find on it using the given separator
47 | local function split(str, sep)
48 | local strs = {}
49 | local s, e
50 | while true do
51 | s, e = str:find(sep)
52 | if s then
53 | table.insert(strs, str:sub(1, s - 1))
54 | str = str:sub(e + 1)
55 | else
56 | break
57 | end
58 | end
59 | table.insert(strs, str)
60 | return strs
61 | end
62 |
63 | --- Given a string like "Module.Submodule.With%.Period.Whatever", split it on . and return a table of strings
64 | local function splitNames(str)
65 | local t = split(str, ModuleLoader.NAME_SPLIT_PATTERN)
66 | local i = 1
67 | while i <= #t do
68 | if t[i]:sub(t[i]:len()) == "%" then
69 | -- Trim the trailing %
70 | t[i] = t[i]:sub(1, t[i]:len() - 1)
71 | -- Concatenate with the next string (if there is one)
72 | if i < #t then
73 | t[i] = t[i] .. "." .. table.remove(t, i + 1)
74 | end
75 | else
76 | -- Move to the next
77 | i = i + 1
78 | end
79 | end
80 | return t
81 | end
82 |
83 | --- Prints only if `DEBUG_MODE` is true.
84 | -- @function ModuleLoader:_print
85 | -- @local
86 | function ModuleLoader:_print(...)
87 | if ModuleLoader.DEBUG_MODE then
88 | print(...)
89 | end
90 | end
91 |
92 | --- Finds a module given its fully-qualified name
93 | -- @private
94 | function ModuleLoader:_findModule(fqName)
95 | assert(type(fqName) == "string")
96 |
97 | -- Determine the namespace and module name - the default namespace is "Modules"
98 | local namespace = "Modules"
99 | local moduleName = fqName
100 |
101 | -- A colon indicates that a namespace is specified - split it if so
102 | local s,e = fqName:find("[^%%]:")
103 | if s then
104 | namespace = fqName:sub(1, s)
105 | moduleName = fqName:sub(e + 1)
106 | end
107 |
108 | local namespaceNames = splitNames(namespace)
109 | local moduleNames = splitNames(moduleName)
110 |
111 | -- Try to find what we're looking for on the client
112 | local namespaceClient = namespace == script.Name and script
113 | or getObject(ReplicatedStorage, namespaceNames)
114 | local moduleClient = getObject(namespaceClient, moduleNames)
115 |
116 | if not isServer then
117 | if not namespaceClient then
118 | error(("Could not find client namespace: %s"):format(namespace), 3)
119 | end
120 | if not moduleClient then
121 | error(("Could not find client module: %s"):format(fqName), 3)
122 | end
123 | return moduleClient
124 | elseif moduleClient then
125 | return moduleClient
126 | end
127 |
128 | -- Try to find what we're looking for on the server
129 | local moduleServer
130 | local namespaceServer = namespace == script.Name and script
131 | or getObject(ServerScriptService, namespaceNames)
132 | if not namespaceServer then
133 | error(("Could not find namespace: %s"):format(namespace), 3)
134 | end
135 | moduleServer = getObject(namespaceServer, moduleNames)
136 | if not moduleServer then
137 | error(("Could not find module: %s"):format(fqName), 3)
138 | end
139 | return moduleServer
140 | end
141 |
142 | ModuleLoader.SAFE_REQUIRE_WARN_TIMEOUT = .5
143 |
144 | --- A wrapper for require which causes a warning if the module is taking too long to load.
145 | -- @private
146 | function ModuleLoader:_safe_require(mod, requiring_mod)
147 | local startTime = os.clock()
148 | local conn
149 | conn = RunService.Stepped:connect(function ()
150 | if os.clock() >= startTime + ModuleLoader.SAFE_REQUIRE_WARN_TIMEOUT then
151 | warn(("%s -> %s is taking too long"):format(tostring(requiring_mod), tostring(mod)))
152 | if conn then
153 | conn:disconnect()
154 | conn = nil
155 | end
156 | end
157 | end)
158 | local retval
159 | local success, err = pcall(function ()
160 | retval = require(mod)
161 | end)
162 | if not success then
163 | if type(retval) == "nil" and err:find("exactly one value") then
164 | error("Module did not return exactly one value: " .. mod:GetFullName(), 3)
165 | else
166 | error("Module " .. mod:GetFullName() .. " experienced an error while loading: " .. err, 3)
167 | end
168 | --else
169 | -- We're good to go
170 | end
171 | if conn then
172 | conn:disconnect()
173 | conn = nil
174 | end
175 | return retval
176 | end
177 |
178 | --- When called by a function that replaces `require`, this function returns the LuaSourceContainer
179 | -- which called `require`.
180 | --@private
181 | function ModuleLoader:_getRequiringScript()
182 | return getfenv(3).script
183 | end
184 |
185 | --- Basic memoization pattern
186 | -- @private
187 | ModuleLoader._cache = {}
188 |
189 | --- Main logic of all flavors of require calls.
190 | -- @private
191 | function ModuleLoader:_require(object, requiring_script)
192 | if not requiring_script then
193 | requiring_script = ModuleLoader:_getRequiringScript()
194 | end
195 | self:_print(("%s -> %s%s"):format(
196 | tostring(requiring_script),
197 | tostring(object),
198 | (ModuleLoader._cache[object] ~= nil and " (cached)" or "")
199 | ))
200 |
201 | if ModuleLoader._cache[object] then
202 | return ModuleLoader._cache[object]
203 | end
204 |
205 | local object_type = typeof(object)
206 | local retval
207 | if object_type == "number" then
208 | -- Use plain require instead of _safe_require, as asset IDs need to load
209 | retval = require(object)
210 | elseif object_type == "Instance" then
211 | if object:IsA("ModuleScript") then
212 | retval = ModuleLoader:_safe_require(object, requiring_script)
213 | else
214 | error("Non-ModuleScript passed to require: " .. object:GetFullName(), 2)
215 | end
216 | elseif object_type == "string" then
217 | local moduleScript = self:_findModule(object)
218 | assert(moduleScript, ("Could not find module: %s"):format(tostring(object)))
219 | retval = ModuleLoader:_safe_require(moduleScript, requiring_script)
220 | elseif object_type == "nil" then
221 | error("require expects ModuleScript, asset id or string", 2)
222 | else
223 | error("Unknown type passed to require: " .. object_type, 2)
224 | end
225 |
226 | assert(retval, "No retval from require")
227 | ModuleLoader._cache[object] = retval
228 | return retval
229 | end
230 |
231 | --- The main `require` overload.
232 | -- @param object An object normally passed to require OR a string
233 | function ModuleLoader.require(object)
234 | local script = ModuleLoader:_getRequiringScript()
235 | return ModuleLoader:_require(object, script)
236 | end
237 |
238 | --- Alias for calling @{ModuleLoader.require}, useful when using ModuleLoader as a `require` replacement.
239 | function ModuleLoader:__call(object)
240 | local script = self:_getRequiringScript()
241 | return self:_require(object, script)
242 | end
243 |
244 | --- Like @{ModuleLoader.require|require}, but returns nil if not ran on the server.
245 | function ModuleLoader.server(object)
246 | local requiringScript = ModuleLoader:_getRequiringScript()
247 | if isServer then
248 | return ModuleLoader:_require(object, requiringScript)
249 | else
250 | return nil
251 | end
252 | end
253 |
254 | --- Like @{ModuleLoader.require|require}, but returns nil if not ran on a client.
255 | function ModuleLoader.client(object)
256 | local requiringScript = ModuleLoader:_getRequiringScript()
257 | if isClient then
258 | return ModuleLoader:_require(object, requiringScript)
259 | else
260 | return nil
261 | end
262 | end
263 |
264 | --- Copies "Replicated" folders in ServerScriptService modules to ReplicatedStorage, renaming them.
265 | -- Also signals that all libraries have been replicated.
266 | -- @private
267 | function ModuleLoader._replicateLibraries()
268 | -- Search for modules with "-Replicated" at the end and replicate them
269 | -- Alternatively, child folder named "Replicated" is moved and renamed
270 | for _, child in pairs(ServerScriptService:GetChildren()) do
271 | if child:IsA("Folder") then
272 | if child.Name:find("%-Replicated$") then
273 | child.Name = child.Name:sub(1, child.Name:len() - 11)
274 | child.Parent = ReplicatedStorage
275 | elseif child:FindFirstChild("Replicated") then
276 | local rep = child.Replicated
277 | rep.Name = child.Name
278 | rep.Parent = ReplicatedStorage
279 | end
280 | end
281 | end
282 | ModuleLoader._signalAllLibrariesReplicated()
283 | end
284 |
285 | ModuleLoader.ALL_LIBRARIES_REPLICATED = "AllLibrariesReplicated"
286 | --- Signals to clients that all libraries have been replicated by creating a foldr in ReplicatedStorage.
287 | -- @private
288 | function ModuleLoader._signalAllLibrariesReplicated()
289 | local allLibrariesReplicatedTag = Instance.new("Folder")
290 | allLibrariesReplicatedTag.Name = ModuleLoader.ALL_LIBRARIES_REPLICATED
291 | allLibrariesReplicatedTag.Parent = ReplicatedStorage
292 | end
293 |
294 | --- Waits for the server to signal that all libraries have been replicated.
295 | -- @private
296 | function ModuleLoader._waitForLibrariesToReplicate()
297 | ReplicatedStorage:WaitForChild(ModuleLoader.ALL_LIBRARIES_REPLICATED)
298 | end
299 |
300 | --- Returns the version of the ModuleLoader being used
301 | -- @function ModuleLoader:__tostring
302 | function ModuleLoader:__tostring()
303 | return (""):format(self.VERSION)
304 | end
305 |
306 | -- Server should replicate
307 | if isServer then ModuleLoader._replicateLibraries() end
308 | -- Client should wait for modules to replicate
309 | if isClient then ModuleLoader._waitForLibrariesToReplicate() end
310 |
311 | return ModuleLoader
312 |
--------------------------------------------------------------------------------
/src/version/build-meta-head.lua:
--------------------------------------------------------------------------------
1 | --- @module build-meta
2 | -- This module is created automatically by the Makefile when Modules is built
3 | -- This content is prepended to build-meta.lua
4 | return {}
5 |
--------------------------------------------------------------------------------
/src/version/init.lua:
--------------------------------------------------------------------------------
1 | --- @module version.test
2 | -- Tests for version module
3 |
4 | local VERSION = {
5 | SOFTWARE_NAME = "Modules";
6 | MAJOR = 1;
7 | MINOR = 1;
8 | PATCH = 1;
9 | PRERELEASE = {};
10 | BUILD_METADATA = script:FindFirstChild("build-meta")
11 | and require(script["build-meta"]) or {};
12 | }
13 |
14 | local function toStringCopy(t)
15 | local t2 = {}
16 | for k, v in pairs(t) do
17 | t2[k] = tostring(v)
18 | end
19 | return t2
20 | end
21 |
22 | local function getVersionName(v)
23 | local PRERELEASE = toStringCopy(v.PRERELEASE)
24 | local BUILD_METADATA = toStringCopy(v.BUILD_METADATA)
25 |
26 | return ("%s %d.%d.%d%s%s"):format(
27 | v.SOFTWARE_NAME, v.MAJOR, v.MINOR, v.PATCH,
28 | #PRERELEASE > 0 and "-" .. table.concat(PRERELEASE, ".") or "",
29 | #BUILD_METADATA > 0 and "+" .. table.concat(BUILD_METADATA, ".") or ""
30 | )
31 | end
32 |
33 | VERSION.NAME = getVersionName(VERSION)
34 |
35 | return VERSION
36 |
--------------------------------------------------------------------------------
/test.project.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Modules",
3 | "tree": {
4 | "$className": "DataModel",
5 | "ReplicatedStorage": {
6 | "$className": "ReplicatedStorage",
7 | "$ignoreUnknownInstances": true,
8 | "$path": "test/ReplicatedStorage",
9 |
10 | "Modules": {
11 | "$path": "src"
12 | }
13 | },
14 | "ServerScriptService": {
15 | "$className": "ServerScriptService",
16 | "$ignoreUnknownInstances": true,
17 | "$path": "test/ServerScriptService"
18 | },
19 | "StarterPlayer": {
20 | "$className": "StarterPlayer",
21 | "$ignoreUnknownInstances": true,
22 | "StarterPlayerScripts": {
23 | "$className": "StarterPlayerScripts",
24 | "$path": "test/StarterPlayer/StarterPlayerScripts"
25 | }
26 | },
27 | "Players": {
28 | "$className": "Players",
29 | "$ignoreUnknownInstances": true,
30 | "$properties": {
31 | "CharacterAutoLoads": false
32 | }
33 | }
34 | }
35 | }
--------------------------------------------------------------------------------
/test/ReplicatedStorage/ModulesTest/Event.test.lua:
--------------------------------------------------------------------------------
1 | --- @module Event.test
2 | -- Tests for Event class
3 |
4 | local ReplicatedStorage = game:GetService("ReplicatedStorage")
5 | local Modules = ReplicatedStorage:WaitForChild("Modules")
6 | local Event = require(Modules:WaitForChild("Event"))
7 |
8 | local EventTests = {}
9 |
10 | EventTests["test_Event"] = function ()
11 | -- Utility
12 | local myFunctionDidRun = false
13 | local function myFunction()
14 | myFunctionDidRun = true
15 | end
16 |
17 | -- Event.new
18 | local event = Event.new()
19 | assert(type(event) ~= "nil")
20 |
21 | -- Event:connect
22 | local connection = event:connect(myFunction)
23 | assert(type(connection) ~= "nil", "Event:connect should return a connection")
24 |
25 | -- Event:fire
26 | event:fire()
27 | assert(myFunctionDidRun, "Event:fire should run connected functions")
28 |
29 | -- Connection:disconnect()
30 | myFunctionDidRun = false
31 | connection:disconnect()
32 | connection = nil
33 | event:fire()
34 | assert(not myFunctionDidRun, "Event:fire should not run disconnected functions")
35 |
36 | -- Event:cleanup
37 | event:cleanup()
38 | event = nil
39 | end
40 |
41 | return EventTests
42 |
--------------------------------------------------------------------------------
/test/ReplicatedStorage/ModulesTest/Maid.test.lua:
--------------------------------------------------------------------------------
1 | --- @module Maid.test
2 | -- Tests for Maid class
3 |
4 | local ReplicatedStorage = game:GetService("ReplicatedStorage")
5 | local Modules = ReplicatedStorage:WaitForChild("Modules")
6 | local Maid = require(Modules:WaitForChild("Maid"))
7 |
8 | local MaidTests = {}
9 |
10 | MaidTests["test_Maid:addTask(function)"] = function ()
11 | -- Setup
12 | local myFunctionDidRun = false
13 | local function myFunction()
14 | myFunctionDidRun = true
15 | end
16 |
17 | -- Maid
18 | local maid = Maid.new()
19 | maid:addTask(myFunction)
20 | maid:cleanup()
21 | maid = nil
22 |
23 | assert(myFunctionDidRun, "Maid should call a function added as a task")
24 | end
25 |
26 | MaidTests["test_Maid:addTask(connection)"] = function ()
27 | -- Setup
28 | local myFunctionDidRun = false
29 | local function myFunction()
30 | myFunctionDidRun = true
31 | end
32 |
33 | local object = Instance.new("Folder")
34 | object.Name = "A"
35 | local connection = object.Changed:Connect(myFunction)
36 |
37 | -- Maid
38 | local maid = Maid.new()
39 | maid:addTask(connection)
40 | maid:cleanup()
41 | maid = nil
42 |
43 | -- Cause Changed to fire
44 | object.Name = "B"
45 |
46 | assert(not myFunctionDidRun, "Maid should disconnect a connection added as a task")
47 | end
48 |
49 | MaidTests["test_Maid:addTask(Instance)"] = function ()
50 | -- Setup
51 | local object = Instance.new("Folder")
52 | object.Parent = workspace
53 |
54 | -- Maid
55 | local maid = Maid.new()
56 | maid:addTask(object)
57 | maid:cleanup()
58 | maid = nil
59 |
60 | assert(not object.Parent, "Maid should disconnect a connection added as a task")
61 | end
62 |
63 | MaidTests["test_Maid:addTask(cleanup)"] = function ()
64 | -- Setup
65 | local myFunctionDidRun = false
66 | local function myFunction()
67 | myFunctionDidRun = true
68 | end
69 |
70 | -- Maid.new
71 | local maid = Maid.new()
72 | maid:addTask({ cleanup = myFunction })
73 | maid:cleanup()
74 | maid = nil
75 |
76 | assert(myFunctionDidRun, "Maid should call cleanup on tasks that have it")
77 | end
78 |
79 | return MaidTests
80 |
--------------------------------------------------------------------------------
/test/ReplicatedStorage/ModulesTest/Modules.test/PlainModule.lua:
--------------------------------------------------------------------------------
1 | --- @module PlainModule
2 | -- A simple Module used by Modules.test
3 |
4 | local mod = {}
5 |
6 | mod.name = "PlainModule"
7 |
8 | return mod
9 |
--------------------------------------------------------------------------------
/test/ReplicatedStorage/ModulesTest/Modules.test/init.lua:
--------------------------------------------------------------------------------
1 | --- @module Modules.test
2 | -- Tests for ModuleLoader module
3 |
4 | local ReplicatedStorage = game:GetService("ReplicatedStorage")
5 | local ServerScriptService = game:GetService("ServerScriptService")
6 | local Modules = ReplicatedStorage:WaitForChild("Modules")
7 |
8 | local ModuleLoader = require(Modules)
9 |
10 | local ModuleLoaderTests = {}
11 |
12 | ModuleLoaderTests["test_ModuleLoader.require(ModuleScript)"] = function ()
13 | local scr = script.PlainModule
14 | local PlainModule = require(scr)
15 | assert(ModuleLoader(scr) == PlainModule,
16 | "Calling ModuleLoader.__call with a ModuleScript should return the same as require")
17 | assert(ModuleLoader.require(scr) == PlainModule,
18 | "Calling ModuleLoader.require with a ModuleScript should return the same as require")
19 | end
20 |
21 | ModuleLoaderTests["test_ModuleLoader.require()_client"] = function ()
22 | local someClientNamespace = assert(ReplicatedStorage:FindFirstChild("SomeClientNamespace"), "SomeClientNamespace missing")
23 | local someClientModule = assert(someClientNamespace:FindFirstChild("SomeClientModule"), "SomeClientModule missing")
24 | local someClientModuleWithAPeriod = assert(someClientNamespace:FindFirstChild("SomeClientModule.WithAPeriod"), "SomeClientModule.WithAPeriod missing")
25 |
26 | local SomeClientModule = require(someClientModule)
27 | assert(ModuleLoader("SomeClientNamespace:SomeClientModule") == SomeClientModule,
28 | "Calling ModuleLoader.require should properly find modules in client namespaces")
29 |
30 | local SomeClientModuleWithAPeriod = require(someClientModuleWithAPeriod)
31 | local SomeClientModuleWithAPeriod_ = ModuleLoader("SomeClientNamespace:SomeClientModule%.WithAPeriod")
32 | assert(SomeClientModuleWithAPeriod_ == SomeClientModuleWithAPeriod,
33 | "Calling ModuleLoader.require should properly find modules whose names have %-escaped periods")
34 | end
35 |
36 | ModuleLoaderTests["test_ModuleLoader.require()_server"] = function ()
37 | local someServerNamespace = assert(ServerScriptService:FindFirstChild("SomeServerNamespace"), "SomeServerNamespace missing")
38 | local someServerModule = assert(someServerNamespace:FindFirstChild("SomeServerModule"), "SomeServerModule missing")
39 |
40 | local SomeServerModule = require(someServerModule)
41 | assert(ModuleLoader("SomeServerNamespace:SomeServerModule") == SomeServerModule,
42 | "Calling ModuleLoader.require should properly find modules in server namespaces")
43 | end
44 |
45 | ModuleLoaderTests["test_ModuleLoader.require()_server_replicated"] = function ()
46 | -- Normally this is in ServerScriptService.SomeServerNamespace.Replicated, but the module should
47 | -- have moved this to ReplicatedStorage.SomeServerNamespace.SomeReplicatedBit !
48 | local someServerNamespaceReplicated = assert(ReplicatedStorage:FindFirstChild("SomeServerNamespace"), "SomeServerNamespace (replicated) missing")
49 | local someReplicatedBit = assert(someServerNamespaceReplicated:FindFirstChild("SomeReplicatedBit"), "SomeReplicatedBit missing")
50 |
51 | local SomeReplicatedBit = require(someReplicatedBit)
52 | assert(ModuleLoader("SomeServerNamespace:SomeReplicatedBit") == SomeReplicatedBit,
53 | "Calling ModuleLoader.require should properly find replicated parts of server namespaces")
54 | end
55 |
56 | return ModuleLoaderTests
57 |
--------------------------------------------------------------------------------
/test/ReplicatedStorage/ModulesTest/StateMachine.test.lua:
--------------------------------------------------------------------------------
1 | --- @module StateMachine.test
2 | -- Tests for StateMachine class
3 |
4 | local ReplicatedStorage = game:GetService("ReplicatedStorage")
5 | local Modules = ReplicatedStorage:WaitForChild("Modules")
6 | local StateMachine = require(Modules:WaitForChild("StateMachine"))
7 |
8 | local StateMachineTests = {}
9 |
10 | StateMachineTests["test_StateMachine"] = function ()
11 | -- Setup
12 | local onEnterDidRun = false
13 | local onLeaveDidRun = false
14 |
15 | -- StateMachine
16 | local sm = StateMachine.new()
17 | local state1 = sm:newState("state1")
18 | state1.onLeave:connect(function ()
19 | onLeaveDidRun = true
20 | end)
21 | local state2 = sm:newState("state2")
22 | state2.onEnter:connect(function ()
23 | onEnterDidRun = true
24 | end)
25 |
26 | assert(sm:hasState("state1"), "StateMachine:newState should add the constructed state")
27 |
28 | -- First, transition to first state
29 | sm:transition(state1)
30 | assert(sm:isInState(state1), "StateMachine:transition should transition the StateMachine to the given state")
31 |
32 | -- Then, transition to second state
33 | sm:transition(state2)
34 |
35 | -- Check that things worked fine
36 | assert(onLeaveDidRun, "State.onLeave should fire when a state is left")
37 | assert(onEnterDidRun, "State.onEnter should fire when a state is entered")
38 |
39 | sm:cleanup()
40 | end
41 |
42 | StateMachineTests["test_StateMachine:newSubmachine(state)"] = function ()
43 | -- StateMachine
44 | local sm = StateMachine.new()
45 |
46 | -- States
47 | local state1 = sm:newState("state1")
48 | local state2 = sm:newState("state2")
49 | sm:transition(state1)
50 |
51 | -- Sub-StateMachines
52 | local subm1, subm1inactive, subm1active = sm:newSubmachine(state1)
53 | --local subm1state1 = subm1:newState("subm1state1")
54 | local subm2, subm2inactive, subm2active = sm:newSubmachine(state2)
55 | local subm2state1 = subm1:newState("subm2state1")
56 |
57 | assert(subm1:isInState(subm1active), "Submachine should start in Active state when parent StateMachine is in the submachine state")
58 | assert(subm2:isInState(subm2inactive), "Submachine should start in Inactive state when parent StateMachine is not in the submachine state")
59 |
60 | sm:transition(state2)
61 | assert(subm1:isInState(subm1inactive), "After transitioning away from parent state, submachine should transition to Inactive state")
62 | assert(subm2:isInState(subm2active), "After transitioning to parent state, submachine should transition to Active state")
63 |
64 | subm2:transition(subm2state1)
65 | assert(subm2:isInState(subm2state1), "Submachine should be able to transition while parent state is active")
66 |
67 | sm:transition(state1)
68 | assert(subm2:isInState(subm2inactive), "Submachine should return to Inactivate state when parent StateMachine State is left")
69 | end
70 |
71 | return StateMachineTests
72 |
--------------------------------------------------------------------------------
/test/ReplicatedStorage/ModulesTest/class.test.lua:
--------------------------------------------------------------------------------
1 | --- Tests for class module
2 | --@module class.test
3 |
4 | local ReplicatedStorage = game:GetService("ReplicatedStorage")
5 | local Modules = ReplicatedStorage:WaitForChild("Modules")
6 | local class = require(Modules:WaitForChild("class"))
7 |
8 | -- A simple, idiomatic Lua class
9 | local MyClass = {}
10 | MyClass.name = "MyClass"
11 | MyClass.__index = MyClass
12 |
13 | function MyClass.new()
14 | return setmetatable({name = "MyClass(object)"}, MyClass)
15 | end
16 |
17 | -- A simple, idiomatic Lua subclass
18 | local MySubclass = setmetatable({}, MyClass)
19 | MySubclass.name = "MySubclass"
20 | MySubclass.__index = MySubclass
21 |
22 | function MySubclass.new()
23 | local self = setmetatable(MyClass.new(), MySubclass)
24 | self.name = "MySubclass(object)"
25 | return self
26 | end
27 |
28 | -- Yet another class
29 | local MyOtherClass = {}
30 | MyOtherClass.name = "MyOtherClass"
31 | MyOtherClass.__index = MyOtherClass
32 |
33 | function MyOtherClass.new()
34 | return setmetatable({name = "MyOtherClass"}, MyOtherClass)
35 | end
36 |
37 | -- Begin tests
38 |
39 | local classTests = {}
40 |
41 | classTests["test_class.classOf"] = function ()
42 | local myClassObject = MyClass.new()
43 | local myOtherClassObject = MyOtherClass.new()
44 | local mySubclassObject = MySubclass.new()
45 |
46 | assert(class.classOf(myClassObject) == MyClass, "class.classOf should identify class of basic object")
47 | assert(class.classOf(myOtherClassObject) == MyOtherClass, "class.classOf should identify class of basic object")
48 | assert(class.classOf(mySubclassObject) == MySubclass, "class.classOf should respect subclass relationship")
49 | end
50 |
51 | classTests["test_class.getSuperclass"] = function ()
52 | assert(class.getSuperclass(MySubclass) == MyClass, "class.getSuperclass should respect subclass relationship")
53 | assert(type(class.getSuperclass(MyClass)) == "nil", "class.getSuperclass should return nil when there is no superclass")
54 | assert(type(class.getSuperclass(MyOtherClass)) == "nil", "class.getSuperclass should return nil when there is no superclass")
55 | end
56 |
57 | classTests["test_class.extends"] = function ()
58 | assert( class.extends(MySubclass, MyClass), "class.extends should respect subclass relationship")
59 | assert(not class.extends(MyClass, MyOtherClass), "class.extends should respect subclass relationship in correct direction")
60 | end
61 |
62 | classTests["test_class.instanceOf"] = function ()
63 | local myClassObject = MyClass.new()
64 | local myOtherClassObject = MyOtherClass.new()
65 | local mySubclassObject = MySubclass.new()
66 |
67 | assert( class.instanceOf(myClassObject, MyClass), "class.instanceOf should identify class of basic object")
68 | assert( class.instanceOf(mySubclassObject, MyClass), "class.instanceOf should respect subclass relationship")
69 | assert(not class.instanceOf(myOtherClassObject, MyClass), "class.instanceOf should return false if the object is not an instance of the class")
70 | assert(not class.instanceOf(myClassObject, MyOtherClass), "class.instanceOf should return false if the object is not an instance of the class")
71 | assert(not class.instanceOf(mySubclassObject, MyOtherClass), "class.instanceOf should return false if the object is not an instance of the class")
72 | end
73 |
74 | return classTests
75 |
--------------------------------------------------------------------------------
/test/ReplicatedStorage/ModulesTest/version.test.lua:
--------------------------------------------------------------------------------
1 | --- @module version
2 | -- Describes the current version of Modules
3 |
4 | local ReplicatedStorage = game:GetService("ReplicatedStorage")
5 | local Modules = ReplicatedStorage:WaitForChild("Modules")
6 | local version = require(Modules:WaitForChild("version"))
7 |
8 | local versionTests = {}
9 |
10 | versionTests["test_version"] = function ()
11 | assert(version.MAJOR >= 0, "version.MAJOR should be positive")
12 | assert(version.MINOR >= 0, "version.MINOR should be positive")
13 | assert(version.PATCH >= 0, "version.PATCH should be positive")
14 | end
15 |
16 | return versionTests
17 |
--------------------------------------------------------------------------------
/test/ReplicatedStorage/SomeClientNamespace/SomeClientModule.WithAPeriod.lua:
--------------------------------------------------------------------------------
1 | return "SomeClientModule.WithAPeriod"
2 |
--------------------------------------------------------------------------------
/test/ReplicatedStorage/SomeClientNamespace/SomeClientModule.lua:
--------------------------------------------------------------------------------
1 | return "SomeClientModule"
2 |
--------------------------------------------------------------------------------
/test/ReplicatedStorage/TestRunner.lua:
--------------------------------------------------------------------------------
1 | --- Given a table of functions ("tests"), runs each function and records the results
2 | --@classmod TestRunner
3 |
4 | local TestRunner = {}
5 | TestRunner.__index = TestRunner
6 | TestRunner.MODULESCRIPT_NAME_PATTERN = "%.?[tT]est$"
7 | TestRunner.FUNCTION_NAME_PATTERN_PREFIX = "^[tT]est_?"
8 | TestRunner.FUNCTION_NAME_PATTERN_POSTFIX = "_?[tT]est"
9 |
10 | --- Determines if the given name of a function indicates that it is
11 | -- a test function.
12 | function TestRunner.functionNameIndicatesTest(name)
13 | return name:match(TestRunner.FUNCTION_NAME_PATTERN_PREFIX)
14 | or name:match(TestRunner.FUNCTION_NAME_PATTERN_POSTFIX)
15 | end
16 |
17 | --- Determines whether the given object is a ModuleScript containing tests
18 | function TestRunner.isTestModule(object)
19 | return object:IsA("ModuleScript") and object.Name:match(TestRunner.MODULESCRIPT_NAME_PATTERN)
20 | end
21 |
22 | --- Recurses an object for test module scripts and calls foundTestModuleScript for each one found
23 | function TestRunner.recurseForTestModules(object, foundTestModuleScript)
24 | if TestRunner.isTestModule(object) then
25 | foundTestModuleScript(object)
26 | end
27 | for _, child in pairs(object:GetChildren()) do
28 | TestRunner.recurseForTestModules(child, foundTestModuleScript)
29 | end
30 | end
31 |
32 | --- Constructs a TestRunner using tests gathered from a root object,
33 | -- which is recursed for any ModuleScripts whose names end in the given
34 | -- pattern, "test"/"Test" or ".test"/".Test"
35 | function TestRunner.gather(object)
36 | local tests = {} --[name] = func
37 | local testNames = {} --[func] = name
38 | local numTests = 0
39 |
40 | -- Add tests to the table
41 | TestRunner.recurseForTestModules(object, function (testModule)
42 | local testsToAdd = require(testModule)
43 | assert(type(testsToAdd) == "table", ("%s should return a table of test functions, returned %s: %s"):format(
44 | testModule:GetFullName(), type(testsToAdd), tostring(testsToAdd)
45 | ))
46 | local testsInThisModule = 0
47 | for name, func in pairs(testsToAdd) do
48 | if type(func) == "function" and TestRunner.functionNameIndicatesTest(name) then
49 | tests[name] = assert(not tests[name] and func, ("Test with name %s already exists"):format(name))
50 | testNames[func] = assert(not testNames[func], ("Duplicate tests: %s and %s"):format(name or "nil", testNames[func] or "nil"))
51 | numTests = numTests + 1
52 | testsInThisModule = testsInThisModule + 1
53 | end
54 | end
55 | assert(testsInThisModule > 0, ("%s should contain at least one test function"):format(testModule:GetFullName()))
56 | end)
57 |
58 | assert(numTests > 0, ("TestRunner.gather found no tests in %s"):format(object:GetFullName()))
59 |
60 | return TestRunner.new(tests)
61 | end
62 |
63 | --- Construct a new TestRunner
64 | function TestRunner.new(tests)
65 | local self = setmetatable({
66 | tests = assert(type(tests) == "table" and tests);
67 | testNames = {};
68 | ran = false;
69 | passed = 0;
70 | failed = 0;
71 | errors = {};
72 | retvals = {};
73 | printPrefix = nil;
74 | }, TestRunner)
75 | self.printFunc = function (...)
76 | return self:_print(...)
77 | end
78 |
79 | -- Gather all test names, then sort
80 | for name, _func in pairs(tests) do
81 | table.insert(self.testNames, name)
82 | end
83 | table.sort(self.testNames)
84 |
85 | return self
86 | end
87 |
88 | --- Runs the tests provided to this TestRunner (can only be done once)
89 | function TestRunner:run()
90 | assert(not self.ran, "Tests already run")
91 | self.ran = true
92 | for _i, name in pairs(self.testNames) do
93 | local func = self.tests[name]
94 | self:_runTest(name, func)
95 | end
96 | end
97 |
98 | --- A print override
99 | function TestRunner:_print(...)
100 | print(self.printPrefix or "[TestRunner]", ...)
101 | end
102 |
103 | --- Runs a specific test and records the results
104 | function TestRunner:_runTest(name, func)
105 | self:_print(("\t=== %s ==="):format(name))
106 | -- Override print inthe test function
107 | --getfenv(func).print = self.printFunc
108 | -- Reset the print flag
109 | local retvals = {xpcall(func, function (err)
110 | self:_print(debug.traceback(err, 2))
111 | end)}
112 | if table.remove(retvals, 1) then
113 | self:_print("Pass")
114 | self.passed = self.passed + 1
115 | self.retvals[name] = retvals
116 | else
117 | self.failed = self.failed + 1
118 | --self.errors[name] = retvals[1]
119 | self:_print("Fail") --\t" .. self.errors[name])
120 | end
121 | end
122 |
123 | --- Reports the results of the tests ran by this TestRunner
124 | function TestRunner:report()
125 | assert(self.ran, "Tests not run")
126 | self:_print("\t====== RESULTS ======")
127 | if self.passed > 0 then
128 | self:_print(("%d tests passed"):format(self.passed))
129 | end
130 | if self.failed > 0 then
131 | self:_print(("%d tests failed"):format(self.failed))
132 | end
133 | end
134 |
135 | --- Run tests and report results
136 | function TestRunner:runAndReport()
137 | self:run()
138 | self:report()
139 | end
140 |
141 | return TestRunner
142 |
--------------------------------------------------------------------------------
/test/ServerScriptService/ModulesTest/RunTests.server.lua:
--------------------------------------------------------------------------------
1 | --- Invokes TestRunner with the tests in ServerTests
2 |
3 | local ReplicatedStorage = game:GetService("ReplicatedStorage")
4 |
5 | local TestRunner = require(ReplicatedStorage:WaitForChild("TestRunner"))
6 |
7 | local testContainer = ReplicatedStorage:WaitForChild("ModulesTest")
8 |
9 | local function main()
10 | local testRunner = TestRunner.gather(testContainer)
11 | testRunner:runAndReport()
12 | end
13 | main()
14 |
--------------------------------------------------------------------------------
/test/ServerScriptService/ModulesTest/ServerTests/Modules.test.lua:
--------------------------------------------------------------------------------
1 | local require = require(game:GetService("ReplicatedStorage"):WaitForChild("Modules"))
2 |
3 | local server = {}
4 |
5 | function server.test_version()
6 | assert(require("version") == require.VERSION)
7 | end
8 |
9 | function server.test_hello_world()
10 | print("Hello, server")
11 | end
12 |
13 | function server.test_kaboom()
14 | --error("Kaboom")
15 | end
16 |
17 | return server
18 |
--------------------------------------------------------------------------------
/test/ServerScriptService/SomeServerNamespace/Replicated/SomeReplicatedBit.lua:
--------------------------------------------------------------------------------
1 | return "SomeReplicatedBit"
2 |
--------------------------------------------------------------------------------
/test/ServerScriptService/SomeServerNamespace/SomeServerModule.lua:
--------------------------------------------------------------------------------
1 | return "SomeServerModule"
2 |
--------------------------------------------------------------------------------
/test/StarterPlayer/StarterPlayerScripts/ClientTests/Modules.test.lua:
--------------------------------------------------------------------------------
1 | local require = require(game:GetService("ReplicatedStorage"):WaitForChild("Modules"))
2 |
3 | local ModulesClient = {}
4 |
5 | function ModulesClient.test_version()
6 | assert(require("version") == require.VERSION)
7 | end
8 |
9 | return ModulesClient
10 |
--------------------------------------------------------------------------------
/test/StarterPlayer/StarterPlayerScripts/RunTests.client.lua:
--------------------------------------------------------------------------------
1 | --- Invokes TestRunner with the tests in ClientTests
2 | local TestRunner = require(game:GetService("ReplicatedStorage"):WaitForChild("TestRunner"))
3 |
4 | local testContainer = script.Parent.ClientTests
5 |
6 | local function main()
7 | local testRunner = TestRunner.gather(testContainer)
8 | testRunner:runAndReport()
9 | end
10 | main()
11 |
--------------------------------------------------------------------------------