mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-04-20 20:55:50 +00:00
Merge plugin back into main repository (#49)
This commit is contained in:
committed by
GitHub
parent
c8f837d726
commit
6fa925a402
@@ -4,12 +4,6 @@ root = true
|
|||||||
end_of_line = lf
|
end_of_line = lf
|
||||||
charset = utf-8
|
charset = utf-8
|
||||||
|
|
||||||
[*.rs]
|
|
||||||
indent_style = space
|
|
||||||
indent_size = 4
|
|
||||||
trim_trailing_whitespace = true
|
|
||||||
insert_final_newline = true
|
|
||||||
|
|
||||||
[*.json]
|
[*.json]
|
||||||
indent_style = space
|
indent_style = space
|
||||||
indent_size = 2
|
indent_size = 2
|
||||||
|
|||||||
15
.gitmodules
vendored
Normal file
15
.gitmodules
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[submodule "plugin/modules/roact"]
|
||||||
|
path = plugin/modules/roact
|
||||||
|
url = https://github.com/Roblox/roact.git
|
||||||
|
[submodule "plugin/modules/rodux"]
|
||||||
|
path = plugin/modules/rodux
|
||||||
|
url = https://github.com/Roblox/rodux.git
|
||||||
|
[submodule "plugin/modules/roact-rodux"]
|
||||||
|
path = plugin/modules/roact-rodux
|
||||||
|
url = https://github.com/Roblox/roact-rodux.git
|
||||||
|
[submodule "plugin/modules/testez"]
|
||||||
|
path = plugin/modules/testez
|
||||||
|
url = https://github.com/Roblox/testez.git
|
||||||
|
[submodule "plugin/modules/lemur"]
|
||||||
|
path = plugin/modules/lemur
|
||||||
|
url = https://github.com/LPGhatguy/lemur.git
|
||||||
42
.travis.yml
42
.travis.yml
@@ -1,5 +1,39 @@
|
|||||||
language: rust
|
matrix:
|
||||||
|
include:
|
||||||
|
- language: python
|
||||||
|
env:
|
||||||
|
- LUA="lua=5.1"
|
||||||
|
|
||||||
rust:
|
before_install:
|
||||||
- stable
|
- pip install hererocks
|
||||||
- beta
|
- hererocks lua_install -r^ --$LUA
|
||||||
|
- export PATH=$PATH:$PWD/lua_install/bin
|
||||||
|
|
||||||
|
install:
|
||||||
|
- luarocks install luafilesystem
|
||||||
|
- luarocks install busted
|
||||||
|
- luarocks install luacov
|
||||||
|
- luarocks install luacov-coveralls
|
||||||
|
- luarocks install luacheck
|
||||||
|
|
||||||
|
script:
|
||||||
|
- cd plugin
|
||||||
|
- luacheck src
|
||||||
|
- lua -lluacov spec.lua
|
||||||
|
|
||||||
|
after_success:
|
||||||
|
- cd plugin
|
||||||
|
- luacov-coveralls -e $TRAVIS_BUILD_DIR/lua_install
|
||||||
|
|
||||||
|
- language: rust
|
||||||
|
rust: stable
|
||||||
|
|
||||||
|
script:
|
||||||
|
- cd server
|
||||||
|
- cargo test --verbose
|
||||||
|
- language: rust
|
||||||
|
rust: beta
|
||||||
|
|
||||||
|
script:
|
||||||
|
- cd server
|
||||||
|
- cargo test --verbose
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
# Rojo Change Log
|
# Rojo Change Log
|
||||||
|
|
||||||
## Current Master
|
## Current Master
|
||||||
*No changes*
|
* Merged plugin repository into main Rojo repository for easier tracking
|
||||||
|
* Improved `RouteMap` object tracking; this should fix some cases of duplicated instances being synced into the tree.
|
||||||
|
|
||||||
## 0.4.0 (March 27, 2018)
|
## 0.4.0 (March 27, 2018)
|
||||||
* Protocol version 1, which shifts more responsibility onto the server
|
* Protocol version 1, which shifts more responsibility onto the server
|
||||||
|
|||||||
57
README.md
57
README.md
@@ -1,5 +1,5 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="assets/rojo-logo.png" alt="Rojo" height="150" />
|
<img src="assets/rojo-logo.png" alt="Rojo" height="217" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div> </div>
|
<div> </div>
|
||||||
@@ -14,15 +14,12 @@
|
|||||||
|
|
||||||
<hr />
|
<hr />
|
||||||
|
|
||||||
**Rojo** is a flexible multi-tool designed for creating robust Roblox projects. It's in early development, but is still useful for many projects.
|
**Rojo** is a flexible multi-tool designed for creating robust Roblox projects.
|
||||||
|
|
||||||
It's designed for power users who want to use the **best tools available** for building games, libraries, and plugins.
|
It's designed for power users who want to use the **best tools available** for building games, libraries, and plugins.
|
||||||
|
|
||||||
This is the main Rojo repository, containing the Rojo CLI. The Roblox Studio plugin is contained in [the rojo-plugin repository](https://github.com/LPGhatguy/rojo-plugin).
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
Rojo lets you:
|
||||||
Rojo has a number of desirable features *right now*:
|
|
||||||
|
|
||||||
* Work on scripts from the filesystem, in your favorite editor
|
* Work on scripts from the filesystem, in your favorite editor
|
||||||
* Version your place, library, or plugin using Git or another VCS
|
* Version your place, library, or plugin using Git or another VCS
|
||||||
@@ -30,24 +27,34 @@ Rojo has a number of desirable features *right now*:
|
|||||||
|
|
||||||
Later this year, Rojo will be able to:
|
Later this year, Rojo will be able to:
|
||||||
|
|
||||||
* Sync rbxmx-format Roblox models bi-directionally between the filesystem and Roblox Studio
|
* Sync `rbxmx` models between the filesystem and Roblox Studio
|
||||||
* Package libraries and plugins into `rbxmx` files from the command line
|
* Package projects into `rbxmx` files from the command line
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
Rojo has two components:
|
Rojo has two components:
|
||||||
* The command line interface (CLI), written in Rust
|
|
||||||
* The [Roblox Studio plugin](https://www.roblox.com/library/1211549683/Rojo), written in Lua
|
|
||||||
|
|
||||||
To install the command line tool, there are two options:
|
* The server, a binary written in Rust
|
||||||
* Cargo, if you have Rust installed
|
* The plugin, a Roblox Studio plugin written in Lua
|
||||||
* Use `cargo install rojo` -- Rojo will be available with the `rojo` command
|
|
||||||
* Download a pre-built Windows binary from [the GitHub releases page](https://github.com/LPGhatguy/rojo/releases)
|
|
||||||
|
|
||||||
## Usage
|
It's important that the plugin and server are compatible. The plugin will show errors in the Roblox Studio Output window if there is a version mismatch.
|
||||||
|
|
||||||
|
To install the server, either:
|
||||||
|
|
||||||
|
* If you have Rust installed, use `cargo install rojo`
|
||||||
|
* Or, download a pre-built Windows binary from [the GitHub releases page](https://github.com/LPGhatguy/rojo/releases)
|
||||||
|
|
||||||
|
To install the plugin, either:
|
||||||
|
|
||||||
|
* Install the plugin from the [Roblox plugin page](https://www.roblox.com/library/1211549683/Rojo).
|
||||||
|
* This gives you less control over what version you install -- you will always have the latest version.
|
||||||
|
* Or, download the latest release from [the GitHub releases section](https://github.com/LPGhatguy/rojo/releases) and install it into your Roblox plugins folder
|
||||||
|
* You can open this folder by clicking the "Plugins Folder" button from the Plugins toolbar in Roblox Studio
|
||||||
|
|
||||||
|
## Server Usage
|
||||||
For more help, use `rojo help`.
|
For more help, use `rojo help`.
|
||||||
|
|
||||||
### New Project
|
### New Project
|
||||||
Just create a new folder and tell Rojo to initialize it!
|
Create a new folder, then use `rojo init` inside that folder to initialize an empty project:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
mkdir my-new-project
|
mkdir my-new-project
|
||||||
@@ -56,9 +63,9 @@ cd my-new-project
|
|||||||
rojo init
|
rojo init
|
||||||
```
|
```
|
||||||
|
|
||||||
Rojo will create an empty project in the directory.
|
Rojo will create an empty project file named `rojo.json` in the directory.
|
||||||
|
|
||||||
The default project looks like this:
|
The default project file is:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -69,7 +76,7 @@ The default project looks like this:
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Start Dev Server
|
### Start Dev Server
|
||||||
To create a server that allows the Rojo Studio plugin to access your project, use:
|
To start the Rojo dev server, use:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
rojo serve
|
rojo serve
|
||||||
@@ -83,7 +90,7 @@ The tool will tell you whether it found an existing project. You should then be
|
|||||||
In the mean-time, manually migrating scripts is probably the best route forward.
|
In the mean-time, manually migrating scripts is probably the best route forward.
|
||||||
|
|
||||||
### Syncing into Roblox
|
### Syncing into Roblox
|
||||||
In order to sync code into Roblox, you'll need toadd one or more *partitions* to your configuration. A partition tells Rojo how to map directories on your filesystem to Roblox objects.
|
In order to sync code into Roblox, you'll need to add one or more *partitions* to your configuration. A partition tells Rojo how to map directories on your filesystem to Roblox objects.
|
||||||
|
|
||||||
Each entry in the `partitions` map has a unique name, a filesystem path, and the full name of the Roblox object to sync into.
|
Each entry in the `partitions` map has a unique name, a filesystem path, and the full name of the Roblox object to sync into.
|
||||||
|
|
||||||
@@ -142,9 +149,11 @@ Will turn into these instances in Roblox:
|
|||||||
* `my-game` (`LocalScript` with source from `my-game/init.client.lua`)
|
* `my-game` (`LocalScript` with source from `my-game/init.client.lua`)
|
||||||
* `foo` (`ModuleScript` with source from `my-game/foo.lua`)
|
* `foo` (`ModuleScript` with source from `my-game/foo.lua`)
|
||||||
|
|
||||||
`*.model.json` files are intended as a simple way to represent non-script Roblox instances on the filesystem until `rbxmx` and `rbxlx` support is implemented in Rojo.
|
`*.model.json` files are a way to represent simple Roblox instances on the filesystem until `rbxmx` and `rbxlx` support is implemented in Rojo.
|
||||||
|
|
||||||
JSON Model files are fairly strict, with every property being required. They generally look like this:
|
This feature is intended for small instances, like `RemoteEvent` or `*Value` objects.
|
||||||
|
|
||||||
|
JSON Model files are strict, with every property being required. They look like this:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -192,9 +201,7 @@ I also have a couple tools that Rojo intends to replace:
|
|||||||
## Contributing
|
## Contributing
|
||||||
Pull requests are welcome!
|
Pull requests are welcome!
|
||||||
|
|
||||||
The `master` branch of both repositories have tests running on Travis for every commit and pull request. The test suite on `master` should always pass!
|
All pull requests are run against a test suite on Travis CI. That test suite should always pass!
|
||||||
|
|
||||||
The Rojo and Rojo Plugin repositories should stay in sync with eachother, so that the current `master` of each repository can be used together.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
Rojo is available under the terms of the MIT license. See [LICENSE.md](LICENSE.md) for details.
|
Rojo is available under the terms of the MIT license. See [LICENSE.md](LICENSE.md) for details.
|
||||||
4
plugin/.editorconfig
Normal file
4
plugin/.editorconfig
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
[*.lua]
|
||||||
|
indent_style = tab
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
insert_final_newline = true
|
||||||
1
plugin/.gitignore
vendored
Normal file
1
plugin/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/luacov.*
|
||||||
56
plugin/.luacheckrc
Normal file
56
plugin/.luacheckrc
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
stds.roblox = {
|
||||||
|
read_globals = {
|
||||||
|
game = {
|
||||||
|
other_fields = true,
|
||||||
|
},
|
||||||
|
|
||||||
|
-- Roblox globals
|
||||||
|
"script",
|
||||||
|
|
||||||
|
-- Extra functions
|
||||||
|
"tick", "warn", "spawn",
|
||||||
|
"wait", "settings", "typeof",
|
||||||
|
|
||||||
|
-- Types
|
||||||
|
"Vector2", "Vector3",
|
||||||
|
"Color3",
|
||||||
|
"UDim", "UDim2",
|
||||||
|
"Rect",
|
||||||
|
"CFrame",
|
||||||
|
"Enum",
|
||||||
|
"Instance",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stds.plugin = {
|
||||||
|
read_globals = {
|
||||||
|
"plugin",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stds.testez = {
|
||||||
|
read_globals = {
|
||||||
|
"describe",
|
||||||
|
"it", "itFOCUS", "itSKIP",
|
||||||
|
"FOCUS", "SKIP", "HACK_NO_XPCALL",
|
||||||
|
"expect",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ignore = {
|
||||||
|
"212", -- unused arguments
|
||||||
|
"421", -- shadowing local variable
|
||||||
|
"422", -- shadowing argument
|
||||||
|
"431", -- shadowing upvalue
|
||||||
|
"432", -- shadowing upvalue argument
|
||||||
|
}
|
||||||
|
|
||||||
|
std = "lua51+roblox"
|
||||||
|
|
||||||
|
files["**/*.server.lua"] = {
|
||||||
|
std = "+plugin",
|
||||||
|
}
|
||||||
|
|
||||||
|
files["**/*.spec.lua"] = {
|
||||||
|
std = "+testez",
|
||||||
|
}
|
||||||
8
plugin/.luacov
Normal file
8
plugin/.luacov
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
return {
|
||||||
|
include = {
|
||||||
|
"^src",
|
||||||
|
},
|
||||||
|
exclude = {
|
||||||
|
"%.spec$",
|
||||||
|
},
|
||||||
|
}
|
||||||
1
plugin/modules/lemur
Submodule
1
plugin/modules/lemur
Submodule
Submodule plugin/modules/lemur added at 852c71b897
1
plugin/modules/roact
Submodule
1
plugin/modules/roact
Submodule
Submodule plugin/modules/roact added at bbb0663161
1
plugin/modules/roact-rodux
Submodule
1
plugin/modules/roact-rodux
Submodule
Submodule plugin/modules/roact-rodux added at 97fbfee90a
1
plugin/modules/rodux
Submodule
1
plugin/modules/rodux
Submodule
Submodule plugin/modules/rodux added at b8ba486335
1
plugin/modules/testez
Submodule
1
plugin/modules/testez
Submodule
Submodule plugin/modules/testez added at 442b71926d
30
plugin/rojo.json
Normal file
30
plugin/rojo.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "rojo",
|
||||||
|
"servePort": 8000,
|
||||||
|
"partitions": {
|
||||||
|
"plugin": {
|
||||||
|
"path": "src",
|
||||||
|
"target": "ReplicatedStorage.Rojo.plugin"
|
||||||
|
},
|
||||||
|
"modules/roact": {
|
||||||
|
"path": "modules/roact/lib",
|
||||||
|
"target": "ReplicatedStorage.Rojo.modules.Roact"
|
||||||
|
},
|
||||||
|
"modules/rodux": {
|
||||||
|
"path": "modules/rodux/lib",
|
||||||
|
"target": "ReplicatedStorage.Rojo.modules.Rodux"
|
||||||
|
},
|
||||||
|
"modules/roact-rodux": {
|
||||||
|
"path": "modules/roact-rodux/lib",
|
||||||
|
"target": "ReplicatedStorage.Rojo.modules.RoactRodux"
|
||||||
|
},
|
||||||
|
"modules/testez": {
|
||||||
|
"path": "modules/testez/lib",
|
||||||
|
"target": "ReplicatedStorage.TestEZ"
|
||||||
|
},
|
||||||
|
"tests": {
|
||||||
|
"path": "tests",
|
||||||
|
"target": "TestService"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
69
plugin/spec.lua
Normal file
69
plugin/spec.lua
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
--[[
|
||||||
|
Loads our library and all of its dependencies, then runs tests using TestEZ.
|
||||||
|
]]
|
||||||
|
|
||||||
|
-- If you add any dependencies, add them to this table so they'll be loaded!
|
||||||
|
local LOAD_MODULES = {
|
||||||
|
{"src", "Plugin"},
|
||||||
|
{"modules/testez/lib", "TestEZ"},
|
||||||
|
}
|
||||||
|
|
||||||
|
-- This makes sure we can load Lemur and other libraries that depend on init.lua
|
||||||
|
package.path = package.path .. ";?/init.lua"
|
||||||
|
|
||||||
|
-- If this fails, make sure you've run `lua bin/install-dependencies.lua` first!
|
||||||
|
local lemur = require("modules.lemur")
|
||||||
|
|
||||||
|
--[[
|
||||||
|
Collapses ModuleScripts named 'init' into their parent folders.
|
||||||
|
|
||||||
|
This is the same result as the collapsing mechanism from Rojo.
|
||||||
|
]]
|
||||||
|
local function collapse(root)
|
||||||
|
local init = root:FindFirstChild("init")
|
||||||
|
if init then
|
||||||
|
init.Name = root.Name
|
||||||
|
init.Parent = root.Parent
|
||||||
|
|
||||||
|
for _, child in ipairs(root:GetChildren()) do
|
||||||
|
child.Parent = init
|
||||||
|
end
|
||||||
|
|
||||||
|
root:Destroy()
|
||||||
|
root = init
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, child in ipairs(root:GetChildren()) do
|
||||||
|
if child:IsA("Folder") then
|
||||||
|
collapse(child)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return root
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Create a virtual Roblox tree
|
||||||
|
local habitat = lemur.Habitat.new()
|
||||||
|
|
||||||
|
-- We'll put all of our library code and dependencies here
|
||||||
|
local Root = lemur.Instance.new("Folder")
|
||||||
|
Root.Name = "Root"
|
||||||
|
|
||||||
|
-- Load all of the modules specified above
|
||||||
|
for _, module in ipairs(LOAD_MODULES) do
|
||||||
|
local container = lemur.Instance.new("Folder", Root)
|
||||||
|
container.Name = module[2]
|
||||||
|
habitat:loadFromFs(module[1], container)
|
||||||
|
end
|
||||||
|
|
||||||
|
collapse(Root)
|
||||||
|
|
||||||
|
-- Load TestEZ and run our tests
|
||||||
|
local TestEZ = habitat:require(Root.TestEZ)
|
||||||
|
|
||||||
|
local results = TestEZ.TestBootstrap:run(Root.Plugin, TestEZ.Reporters.TextReporter)
|
||||||
|
|
||||||
|
-- Did something go wrong?
|
||||||
|
if results.failureCount > 0 then
|
||||||
|
os.exit(1)
|
||||||
|
end
|
||||||
7
plugin/src/Config.lua
Normal file
7
plugin/src/Config.lua
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
return {
|
||||||
|
pollingRate = 0.2,
|
||||||
|
version = {0, 4, 1},
|
||||||
|
expectedServerVersionString = "0.4.x",
|
||||||
|
protocolVersion = 1,
|
||||||
|
dev = false,
|
||||||
|
}
|
||||||
7
plugin/src/Config.spec.lua
Normal file
7
plugin/src/Config.spec.lua
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
return function()
|
||||||
|
local Config = require(script.Parent.Config)
|
||||||
|
|
||||||
|
it("should have 'dev' disabled", function()
|
||||||
|
expect(Config.dev).to.equal(false)
|
||||||
|
end)
|
||||||
|
end
|
||||||
67
plugin/src/Http.lua
Normal file
67
plugin/src/Http.lua
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
local HttpService = game:GetService("HttpService")
|
||||||
|
|
||||||
|
local HTTP_DEBUG = false
|
||||||
|
|
||||||
|
local Promise = require(script.Parent.Promise)
|
||||||
|
local HttpError = require(script.Parent.HttpError)
|
||||||
|
local HttpResponse = require(script.Parent.HttpResponse)
|
||||||
|
|
||||||
|
local function dprint(...)
|
||||||
|
if HTTP_DEBUG then
|
||||||
|
print(...)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local Http = {}
|
||||||
|
Http.__index = Http
|
||||||
|
|
||||||
|
function Http.new(baseUrl)
|
||||||
|
assert(type(baseUrl) == "string", "Http.new needs a baseUrl!")
|
||||||
|
|
||||||
|
local http = {
|
||||||
|
baseUrl = baseUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
setmetatable(http, Http)
|
||||||
|
|
||||||
|
return http
|
||||||
|
end
|
||||||
|
|
||||||
|
function Http:get(endpoint)
|
||||||
|
dprint("\nGET", endpoint)
|
||||||
|
return Promise.new(function(resolve, reject)
|
||||||
|
spawn(function()
|
||||||
|
local ok, result = pcall(function()
|
||||||
|
return HttpService:GetAsync(self.baseUrl .. endpoint, true)
|
||||||
|
end)
|
||||||
|
|
||||||
|
if ok then
|
||||||
|
dprint("\t", result, "\n")
|
||||||
|
resolve(HttpResponse.new(result))
|
||||||
|
else
|
||||||
|
reject(HttpError.fromErrorString(result))
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Http:post(endpoint, body)
|
||||||
|
dprint("\nPOST", endpoint)
|
||||||
|
dprint(body)
|
||||||
|
return Promise.new(function(resolve, reject)
|
||||||
|
spawn(function()
|
||||||
|
local ok, result = pcall(function()
|
||||||
|
return HttpService:PostAsync(self.baseUrl .. endpoint, body)
|
||||||
|
end)
|
||||||
|
|
||||||
|
if ok then
|
||||||
|
dprint("\t", result, "\n")
|
||||||
|
resolve(HttpResponse.new(result))
|
||||||
|
else
|
||||||
|
reject(HttpError.fromErrorString(result))
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
return Http
|
||||||
57
plugin/src/HttpError.lua
Normal file
57
plugin/src/HttpError.lua
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
local HttpError = {}
|
||||||
|
HttpError.__index = HttpError
|
||||||
|
|
||||||
|
HttpError.Error = {
|
||||||
|
HttpNotEnabled = {
|
||||||
|
message = "Rojo requires HTTP access, which is not enabled.\n" ..
|
||||||
|
"Check your game settings, located in the 'Home' tab of Studio.",
|
||||||
|
},
|
||||||
|
ConnectFailed = {
|
||||||
|
message = "Rojo plugin couldn't connect to the Rojo server.\n" ..
|
||||||
|
"Make sure the server is running -- use 'Rojo serve' to run it!",
|
||||||
|
},
|
||||||
|
Unknown = {
|
||||||
|
message = "Rojo encountered an unknown error: {{message}}",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function HttpError.new(type, extraMessage)
|
||||||
|
extraMessage = extraMessage or ""
|
||||||
|
local message = type.message:gsub("{{message}}", extraMessage)
|
||||||
|
|
||||||
|
local err = {
|
||||||
|
type = type,
|
||||||
|
message = message,
|
||||||
|
}
|
||||||
|
|
||||||
|
setmetatable(err, HttpError)
|
||||||
|
|
||||||
|
return err
|
||||||
|
end
|
||||||
|
|
||||||
|
function HttpError:__tostring()
|
||||||
|
return self.message
|
||||||
|
end
|
||||||
|
|
||||||
|
--[[
|
||||||
|
This method shouldn't have to exist. Ugh.
|
||||||
|
]]
|
||||||
|
function HttpError.fromErrorString(err)
|
||||||
|
err = err:lower()
|
||||||
|
|
||||||
|
if err:find("^http requests are not enabled") then
|
||||||
|
return HttpError.new(HttpError.Error.HttpNotEnabled)
|
||||||
|
end
|
||||||
|
|
||||||
|
if err:find("^curl error") then
|
||||||
|
return HttpError.new(HttpError.Error.ConnectFailed)
|
||||||
|
end
|
||||||
|
|
||||||
|
return HttpError.new(HttpError.Error.Unknown, err)
|
||||||
|
end
|
||||||
|
|
||||||
|
function HttpError:report()
|
||||||
|
warn(self.message)
|
||||||
|
end
|
||||||
|
|
||||||
|
return HttpError
|
||||||
20
plugin/src/HttpResponse.lua
Normal file
20
plugin/src/HttpResponse.lua
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
local HttpService = game:GetService("HttpService")
|
||||||
|
|
||||||
|
local HttpResponse = {}
|
||||||
|
HttpResponse.__index = HttpResponse
|
||||||
|
|
||||||
|
function HttpResponse.new(body)
|
||||||
|
local response = {
|
||||||
|
body = body,
|
||||||
|
}
|
||||||
|
|
||||||
|
setmetatable(response, HttpResponse)
|
||||||
|
|
||||||
|
return response
|
||||||
|
end
|
||||||
|
|
||||||
|
function HttpResponse:json()
|
||||||
|
return HttpService:JSONDecode(self.body)
|
||||||
|
end
|
||||||
|
|
||||||
|
return HttpResponse
|
||||||
79
plugin/src/Main.server.lua
Normal file
79
plugin/src/Main.server.lua
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
if not plugin then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local Plugin = require(script.Parent.Plugin)
|
||||||
|
local Config = require(script.Parent.Config)
|
||||||
|
local Version = require(script.Parent.Version)
|
||||||
|
|
||||||
|
--[[
|
||||||
|
Check if the user is using a newer version of Rojo than last time. If they
|
||||||
|
are, show them a reminder to make sure they check their server version.
|
||||||
|
]]
|
||||||
|
local function checkUpgrade()
|
||||||
|
local lastVersion = plugin:GetSetting("LastRojoVersion")
|
||||||
|
|
||||||
|
if lastVersion then
|
||||||
|
local wasUpgraded = Version.compare(Config.version, lastVersion) == 1
|
||||||
|
|
||||||
|
if wasUpgraded then
|
||||||
|
local message = (
|
||||||
|
"\nRojo detected an upgrade from version %s to version %s." ..
|
||||||
|
"\nMake sure you have also upgraded your server!" ..
|
||||||
|
"\n\nRojo version %s is intended for use with server version %s.\n"
|
||||||
|
):format(
|
||||||
|
Version.display(lastVersion), Version.display(Config.version),
|
||||||
|
Version.display(Config.version), Config.expectedServerVersionString
|
||||||
|
)
|
||||||
|
|
||||||
|
print(message)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- When developing Rojo, there's no use in storing that version number.
|
||||||
|
if not Config.dev then
|
||||||
|
plugin:SetSetting("LastRojoVersion", Config.version)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function main()
|
||||||
|
local pluginInstance = Plugin.new()
|
||||||
|
|
||||||
|
local displayedVersion = Config.dev and "DEV" or Version.display(Config.version)
|
||||||
|
|
||||||
|
local toolbar = plugin:CreateToolbar("Rojo Plugin " .. displayedVersion)
|
||||||
|
|
||||||
|
toolbar:CreateButton("Test Connection", "Connect to Rojo Server", "")
|
||||||
|
.Click:Connect(function()
|
||||||
|
checkUpgrade()
|
||||||
|
|
||||||
|
pluginInstance:connect()
|
||||||
|
:catch(function(err)
|
||||||
|
warn(err)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
toolbar:CreateButton("Sync In", "Sync into Roblox Studio", "")
|
||||||
|
.Click:Connect(function()
|
||||||
|
checkUpgrade()
|
||||||
|
|
||||||
|
pluginInstance:syncIn()
|
||||||
|
:catch(function(err)
|
||||||
|
warn(err)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
toolbar:CreateButton("Toggle Polling", "Poll server for changes", "")
|
||||||
|
.Click:Connect(function()
|
||||||
|
checkUpgrade()
|
||||||
|
|
||||||
|
spawn(function()
|
||||||
|
pluginInstance:togglePolling()
|
||||||
|
:catch(function(err)
|
||||||
|
warn(err)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
main()
|
||||||
198
plugin/src/Plugin.lua
Normal file
198
plugin/src/Plugin.lua
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
local Config = require(script.Parent.Config)
|
||||||
|
local Http = require(script.Parent.Http)
|
||||||
|
local Server = require(script.Parent.Server)
|
||||||
|
local Promise = require(script.Parent.Promise)
|
||||||
|
local Reconciler = require(script.Parent.Reconciler)
|
||||||
|
|
||||||
|
local function collectMatch(source, pattern)
|
||||||
|
local result = {}
|
||||||
|
|
||||||
|
for match in source:gmatch(pattern) do
|
||||||
|
table.insert(result, match)
|
||||||
|
end
|
||||||
|
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
|
||||||
|
local Plugin = {}
|
||||||
|
Plugin.__index = Plugin
|
||||||
|
|
||||||
|
function Plugin.new()
|
||||||
|
local address = "localhost"
|
||||||
|
local port = Config.dev and 8001 or 8000
|
||||||
|
|
||||||
|
local remote = ("http://%s:%d"):format(address, port)
|
||||||
|
|
||||||
|
local self = {
|
||||||
|
_http = Http.new(remote),
|
||||||
|
_reconciler = Reconciler.new(),
|
||||||
|
_server = nil,
|
||||||
|
_polling = false,
|
||||||
|
}
|
||||||
|
|
||||||
|
setmetatable(self, Plugin)
|
||||||
|
|
||||||
|
do
|
||||||
|
local screenGui = Instance.new("ScreenGui")
|
||||||
|
screenGui.Name = "Rojo UI"
|
||||||
|
screenGui.Parent = game.CoreGui
|
||||||
|
screenGui.DisplayOrder = -1
|
||||||
|
screenGui.Enabled = false
|
||||||
|
|
||||||
|
local label = Instance.new("TextLabel")
|
||||||
|
label.Font = Enum.Font.SourceSans
|
||||||
|
label.TextSize = 20
|
||||||
|
label.Text = "Rojo polling..."
|
||||||
|
label.BackgroundColor3 = Color3.fromRGB(31, 31, 31)
|
||||||
|
label.BackgroundTransparency = 0.5
|
||||||
|
label.BorderSizePixel = 0
|
||||||
|
label.TextColor3 = Color3.new(1, 1, 1)
|
||||||
|
label.Size = UDim2.new(0, 120, 0, 28)
|
||||||
|
label.Position = UDim2.new(0, 0, 0, 0)
|
||||||
|
label.Parent = screenGui
|
||||||
|
|
||||||
|
self._label = screenGui
|
||||||
|
end
|
||||||
|
|
||||||
|
return self
|
||||||
|
end
|
||||||
|
|
||||||
|
function Plugin:server()
|
||||||
|
if not self._server then
|
||||||
|
self._server = Server.connect(self._http)
|
||||||
|
:catch(function(err)
|
||||||
|
self._server = nil
|
||||||
|
return Promise.reject(err)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
return self._server
|
||||||
|
end
|
||||||
|
|
||||||
|
function Plugin:connect()
|
||||||
|
print("Testing connection...")
|
||||||
|
|
||||||
|
return self:server()
|
||||||
|
:andThen(function(server)
|
||||||
|
return server:getInfo()
|
||||||
|
end)
|
||||||
|
:andThen(function(result)
|
||||||
|
print("Server found!")
|
||||||
|
print("Protocol version:", result.protocolVersion)
|
||||||
|
print("Server version:", result.serverVersion)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Plugin:togglePolling()
|
||||||
|
if self._polling then
|
||||||
|
self:stopPolling()
|
||||||
|
|
||||||
|
return Promise.resolve(nil)
|
||||||
|
else
|
||||||
|
return self:startPolling()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Plugin:stopPolling()
|
||||||
|
if not self._polling then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
print("Stopped polling.")
|
||||||
|
|
||||||
|
self._polling = false
|
||||||
|
self._label.Enabled = false
|
||||||
|
end
|
||||||
|
|
||||||
|
function Plugin:_pull(server, project, routes)
|
||||||
|
local items = server:read(routes):await()
|
||||||
|
|
||||||
|
for index = 1, #routes do
|
||||||
|
local itemRoute = routes[index]
|
||||||
|
local partitionName = itemRoute[1]
|
||||||
|
local partition = project.partitions[partitionName]
|
||||||
|
local item = items[index]
|
||||||
|
|
||||||
|
local partitionRoute = collectMatch(partition.target, "[^.]+")
|
||||||
|
|
||||||
|
-- If the item route's length was 1, we need to rename the instance to
|
||||||
|
-- line up with the partition's root object name.
|
||||||
|
--
|
||||||
|
-- This is a HACK!
|
||||||
|
if #itemRoute == 1 then
|
||||||
|
if item then
|
||||||
|
local objectName = partition.target:match("[^.]+$")
|
||||||
|
item.Name = objectName
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local fullRoute = {}
|
||||||
|
for _, piece in ipairs(partitionRoute) do
|
||||||
|
table.insert(fullRoute, piece)
|
||||||
|
end
|
||||||
|
|
||||||
|
for i = 2, #itemRoute do
|
||||||
|
table.insert(fullRoute, itemRoute[i])
|
||||||
|
end
|
||||||
|
|
||||||
|
self._reconciler:reconcileRoute(fullRoute, item, itemRoute)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Plugin:startPolling()
|
||||||
|
if self._polling then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
print("Starting to poll...")
|
||||||
|
|
||||||
|
self._polling = true
|
||||||
|
self._label.Enabled = true
|
||||||
|
|
||||||
|
return self:server()
|
||||||
|
:andThen(function(server)
|
||||||
|
self:syncIn():await()
|
||||||
|
|
||||||
|
local project = server:getInfo():await().project
|
||||||
|
|
||||||
|
while self._polling do
|
||||||
|
local changes = server:getChanges():await()
|
||||||
|
|
||||||
|
if #changes > 0 then
|
||||||
|
local routes = {}
|
||||||
|
|
||||||
|
for _, change in ipairs(changes) do
|
||||||
|
table.insert(routes, change.route)
|
||||||
|
end
|
||||||
|
|
||||||
|
self:_pull(server, project, routes)
|
||||||
|
end
|
||||||
|
|
||||||
|
wait(Config.pollingRate)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
:catch(function()
|
||||||
|
self:stopPolling()
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Plugin:syncIn()
|
||||||
|
print("Syncing from server...")
|
||||||
|
|
||||||
|
return self:server()
|
||||||
|
:andThen(function(server)
|
||||||
|
local project = server:getInfo():await().project
|
||||||
|
|
||||||
|
local routes = {}
|
||||||
|
|
||||||
|
for name in pairs(project.partitions) do
|
||||||
|
table.insert(routes, {name})
|
||||||
|
end
|
||||||
|
|
||||||
|
self:_pull(server, project, routes)
|
||||||
|
|
||||||
|
print("Sync successful!")
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
return Plugin
|
||||||
311
plugin/src/Promise.lua
Normal file
311
plugin/src/Promise.lua
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
--[[
|
||||||
|
An implementation of Promises similar to Promise/A+.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local PROMISE_DEBUG = false
|
||||||
|
|
||||||
|
-- If promise debugging is on, use a version of pcall that warns on failure.
|
||||||
|
-- This is useful for finding errors that happen within Promise itself.
|
||||||
|
local wpcall
|
||||||
|
if PROMISE_DEBUG then
|
||||||
|
wpcall = function(f, ...)
|
||||||
|
local result = { pcall(f, ...) }
|
||||||
|
|
||||||
|
if not result[1] then
|
||||||
|
warn(result[2])
|
||||||
|
end
|
||||||
|
|
||||||
|
return unpack(result)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
wpcall = pcall
|
||||||
|
end
|
||||||
|
|
||||||
|
--[[
|
||||||
|
Creates a function that invokes a callback with correct error handling and
|
||||||
|
resolution mechanisms.
|
||||||
|
]]
|
||||||
|
local function createAdvancer(callback, resolve, reject)
|
||||||
|
return function(...)
|
||||||
|
local result = { wpcall(callback, ...) }
|
||||||
|
local ok = table.remove(result, 1)
|
||||||
|
|
||||||
|
if ok then
|
||||||
|
resolve(unpack(result))
|
||||||
|
else
|
||||||
|
reject(unpack(result))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function isEmpty(t)
|
||||||
|
return next(t) == nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local Promise = {}
|
||||||
|
Promise.__index = Promise
|
||||||
|
|
||||||
|
Promise.Status = {
|
||||||
|
Started = "Started",
|
||||||
|
Resolved = "Resolved",
|
||||||
|
Rejected = "Rejected",
|
||||||
|
}
|
||||||
|
|
||||||
|
--[[
|
||||||
|
Constructs a new Promise with the given initializing callback.
|
||||||
|
|
||||||
|
This is generally only called when directly wrapping a non-promise API into
|
||||||
|
a promise-based version.
|
||||||
|
|
||||||
|
The callback will receive 'resolve' and 'reject' methods, used to start
|
||||||
|
invoking the promise chain.
|
||||||
|
|
||||||
|
For example:
|
||||||
|
|
||||||
|
local function get(url)
|
||||||
|
return Promise.new(function(resolve, reject)
|
||||||
|
spawn(function()
|
||||||
|
resolve(HttpService:GetAsync(url))
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
get("https://google.com")
|
||||||
|
:andThen(function(stuff)
|
||||||
|
print("Got some stuff!", stuff)
|
||||||
|
end)
|
||||||
|
]]
|
||||||
|
function Promise.new(callback)
|
||||||
|
local promise = {
|
||||||
|
-- Used to locate where a promise was created
|
||||||
|
_source = debug.traceback(),
|
||||||
|
|
||||||
|
-- A tag to identify us as a promise
|
||||||
|
_type = "Promise",
|
||||||
|
|
||||||
|
_status = Promise.Status.Started,
|
||||||
|
|
||||||
|
-- A table containing a list of all results, whether success or failure.
|
||||||
|
-- Only valid if _status is set to something besides Started
|
||||||
|
_value = nil,
|
||||||
|
|
||||||
|
-- If an error occurs with no observers, this will be set.
|
||||||
|
_unhandledRejection = false,
|
||||||
|
|
||||||
|
-- Queues representing functions we should invoke when we update!
|
||||||
|
_queuedResolve = {},
|
||||||
|
_queuedReject = {},
|
||||||
|
}
|
||||||
|
|
||||||
|
setmetatable(promise, Promise)
|
||||||
|
|
||||||
|
local function resolve(...)
|
||||||
|
promise:_resolve(...)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function reject(...)
|
||||||
|
promise:_reject(...)
|
||||||
|
end
|
||||||
|
|
||||||
|
local ok, err = wpcall(callback, resolve, reject)
|
||||||
|
|
||||||
|
if not ok and promise._status == Promise.Status.Started then
|
||||||
|
reject(err)
|
||||||
|
end
|
||||||
|
|
||||||
|
return promise
|
||||||
|
end
|
||||||
|
|
||||||
|
--[[
|
||||||
|
Create a promise that represents the immediately resolved value.
|
||||||
|
]]
|
||||||
|
function Promise.resolve(value)
|
||||||
|
return Promise.new(function(resolve)
|
||||||
|
resolve(value)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
--[[
|
||||||
|
Create a promise that represents the immediately rejected value.
|
||||||
|
]]
|
||||||
|
function Promise.reject(value)
|
||||||
|
return Promise.new(function(_, reject)
|
||||||
|
reject(value)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
--[[
|
||||||
|
Returns a new promise that:
|
||||||
|
* is resolved when all input promises resolve
|
||||||
|
* is rejected if ANY input promises reject
|
||||||
|
]]
|
||||||
|
function Promise.all(...)
|
||||||
|
error("unimplemented", 2)
|
||||||
|
end
|
||||||
|
|
||||||
|
--[[
|
||||||
|
Is the given object a Promise instance?
|
||||||
|
]]
|
||||||
|
function Promise.is(object)
|
||||||
|
if type(object) ~= "table" then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
return object._type == "Promise"
|
||||||
|
end
|
||||||
|
|
||||||
|
--[[
|
||||||
|
Creates a new promise that receives the result of this promise.
|
||||||
|
|
||||||
|
The given callbacks are invoked depending on that result.
|
||||||
|
]]
|
||||||
|
function Promise:andThen(successHandler, failureHandler)
|
||||||
|
self._unhandledRejection = false
|
||||||
|
|
||||||
|
-- Create a new promise to follow this part of the chain
|
||||||
|
return Promise.new(function(resolve, reject)
|
||||||
|
-- Our default callbacks just pass values onto the next promise.
|
||||||
|
-- This lets success and failure cascade correctly!
|
||||||
|
|
||||||
|
local successCallback = resolve
|
||||||
|
if successHandler then
|
||||||
|
successCallback = createAdvancer(successHandler, resolve, reject)
|
||||||
|
end
|
||||||
|
|
||||||
|
local failureCallback = reject
|
||||||
|
if failureHandler then
|
||||||
|
failureCallback = createAdvancer(failureHandler, resolve, reject)
|
||||||
|
end
|
||||||
|
|
||||||
|
if self._status == Promise.Status.Started then
|
||||||
|
-- If we haven't resolved yet, put ourselves into the queue
|
||||||
|
table.insert(self._queuedResolve, successCallback)
|
||||||
|
table.insert(self._queuedReject, failureCallback)
|
||||||
|
elseif self._status == Promise.Status.Resolved then
|
||||||
|
-- This promise has already resolved! Trigger success immediately.
|
||||||
|
successCallback(unpack(self._value))
|
||||||
|
elseif self._status == Promise.Status.Rejected then
|
||||||
|
-- This promise died a terrible death! Trigger failure immediately.
|
||||||
|
failureCallback(unpack(self._value))
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
--[[
|
||||||
|
Used to catch any errors that may have occurred in the promise.
|
||||||
|
]]
|
||||||
|
function Promise:catch(failureCallback)
|
||||||
|
return self:andThen(nil, failureCallback)
|
||||||
|
end
|
||||||
|
|
||||||
|
--[[
|
||||||
|
Yield until the promise is completed.
|
||||||
|
|
||||||
|
This matches the execution model of normal Roblox functions.
|
||||||
|
]]
|
||||||
|
function Promise:await()
|
||||||
|
self._unhandledRejection = false
|
||||||
|
|
||||||
|
if self._status == Promise.Status.Started then
|
||||||
|
local result
|
||||||
|
local bindable = Instance.new("BindableEvent")
|
||||||
|
|
||||||
|
self:andThen(function(...)
|
||||||
|
result = {...}
|
||||||
|
bindable:Fire(true)
|
||||||
|
end, function(...)
|
||||||
|
result = {...}
|
||||||
|
bindable:Fire(false)
|
||||||
|
end)
|
||||||
|
|
||||||
|
local ok = bindable.Event:Wait()
|
||||||
|
bindable:Destroy()
|
||||||
|
|
||||||
|
if not ok then
|
||||||
|
error(tostring(result[1]), 2)
|
||||||
|
end
|
||||||
|
|
||||||
|
return unpack(result)
|
||||||
|
elseif self._status == Promise.Status.Resolved then
|
||||||
|
return unpack(self._value)
|
||||||
|
elseif self._status == Promise.Status.Rejected then
|
||||||
|
error(tostring(self._value[1]), 2)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Promise:_resolve(...)
|
||||||
|
if self._status ~= Promise.Status.Started then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- If the resolved value was a Promise, we chain onto it!
|
||||||
|
if Promise.is((...)) then
|
||||||
|
-- Without this warning, arguments sometimes mysteriously disappear
|
||||||
|
if select("#", ...) > 1 then
|
||||||
|
local message = (
|
||||||
|
"When returning a Promise from andThen, extra arguments are " ..
|
||||||
|
"discarded! See:\n\n%s"
|
||||||
|
):format(
|
||||||
|
self._source
|
||||||
|
)
|
||||||
|
warn(message)
|
||||||
|
end
|
||||||
|
|
||||||
|
(...):andThen(function(...)
|
||||||
|
self:_resolve(...)
|
||||||
|
end, function(...)
|
||||||
|
self:_reject(...)
|
||||||
|
end)
|
||||||
|
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
self._status = Promise.Status.Resolved
|
||||||
|
self._value = {...}
|
||||||
|
|
||||||
|
-- We assume that these callbacks will not throw errors.
|
||||||
|
for _, callback in ipairs(self._queuedResolve) do
|
||||||
|
callback(...)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Promise:_reject(...)
|
||||||
|
if self._status ~= Promise.Status.Started then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
self._status = Promise.Status.Rejected
|
||||||
|
self._value = {...}
|
||||||
|
|
||||||
|
-- If there are any rejection handlers, call those!
|
||||||
|
if not isEmpty(self._queuedReject) then
|
||||||
|
-- We assume that these callbacks will not throw errors.
|
||||||
|
for _, callback in ipairs(self._queuedReject) do
|
||||||
|
callback(...)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
-- At this point, no one was able to observe the error.
|
||||||
|
-- An error handler might still be attached if the error occurred
|
||||||
|
-- synchronously. We'll wait one tick, and if there are still no
|
||||||
|
-- observers, then we should put a message in the console.
|
||||||
|
|
||||||
|
self._unhandledRejection = true
|
||||||
|
local err = tostring((...))
|
||||||
|
|
||||||
|
spawn(function()
|
||||||
|
-- Someone observed the error, hooray!
|
||||||
|
if not self._unhandledRejection then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Build a reasonable message
|
||||||
|
local message = ("Unhandled promise rejection:\n\n%s\n\n%s"):format(
|
||||||
|
err,
|
||||||
|
self._source
|
||||||
|
)
|
||||||
|
warn(message)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return Promise
|
||||||
262
plugin/src/Promise.spec.lua
Normal file
262
plugin/src/Promise.spec.lua
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
return function()
|
||||||
|
local Promise = require(script.Parent.Promise)
|
||||||
|
|
||||||
|
describe("Promise.new", function()
|
||||||
|
it("should instantiate with a callback", function()
|
||||||
|
local promise = Promise.new(function() end)
|
||||||
|
|
||||||
|
expect(promise).to.be.ok()
|
||||||
|
end)
|
||||||
|
|
||||||
|
it("should invoke the given callback with resolve and reject", function()
|
||||||
|
local callCount = 0
|
||||||
|
local resolveArg
|
||||||
|
local rejectArg
|
||||||
|
|
||||||
|
local promise = Promise.new(function(resolve, reject)
|
||||||
|
callCount = callCount + 1
|
||||||
|
resolveArg = resolve
|
||||||
|
rejectArg = reject
|
||||||
|
end)
|
||||||
|
|
||||||
|
expect(promise).to.be.ok()
|
||||||
|
|
||||||
|
expect(callCount).to.equal(1)
|
||||||
|
expect(resolveArg).to.be.a("function")
|
||||||
|
expect(rejectArg).to.be.a("function")
|
||||||
|
expect(promise._status).to.equal(Promise.Status.Started)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it("should resolve promises on resolve()", function()
|
||||||
|
local callCount = 0
|
||||||
|
|
||||||
|
local promise = Promise.new(function(resolve)
|
||||||
|
callCount = callCount + 1
|
||||||
|
resolve()
|
||||||
|
end)
|
||||||
|
|
||||||
|
expect(promise).to.be.ok()
|
||||||
|
expect(callCount).to.equal(1)
|
||||||
|
expect(promise._status).to.equal(Promise.Status.Resolved)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it("should reject promises on reject()", function()
|
||||||
|
local callCount = 0
|
||||||
|
|
||||||
|
local promise = Promise.new(function(resolve, reject)
|
||||||
|
callCount = callCount + 1
|
||||||
|
reject()
|
||||||
|
end)
|
||||||
|
|
||||||
|
expect(promise).to.be.ok()
|
||||||
|
expect(callCount).to.equal(1)
|
||||||
|
expect(promise._status).to.equal(Promise.Status.Rejected)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it("should reject on error in callback", function()
|
||||||
|
local callCount = 0
|
||||||
|
|
||||||
|
local promise = Promise.new(function()
|
||||||
|
callCount = callCount + 1
|
||||||
|
error("hahah")
|
||||||
|
end)
|
||||||
|
|
||||||
|
expect(promise).to.be.ok()
|
||||||
|
expect(callCount).to.equal(1)
|
||||||
|
expect(promise._status).to.equal(Promise.Status.Rejected)
|
||||||
|
expect(promise._value[1]:find("hahah")).to.be.ok()
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
describe("Promise.resolve", function()
|
||||||
|
it("should immediately resolve with a value", function()
|
||||||
|
local promise = Promise.resolve(5)
|
||||||
|
|
||||||
|
expect(promise).to.be.ok()
|
||||||
|
expect(promise._status).to.equal(Promise.Status.Resolved)
|
||||||
|
expect(promise._value[1]).to.equal(5)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it("should chain onto passed promises", function()
|
||||||
|
local promise = Promise.resolve(Promise.new(function(_, reject)
|
||||||
|
reject(7)
|
||||||
|
end))
|
||||||
|
|
||||||
|
expect(promise).to.be.ok()
|
||||||
|
expect(promise._status).to.equal(Promise.Status.Rejected)
|
||||||
|
expect(promise._value[1]).to.equal(7)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
describe("Promise.reject", function()
|
||||||
|
it("should immediately reject with a value", function()
|
||||||
|
local promise = Promise.reject(6)
|
||||||
|
|
||||||
|
expect(promise).to.be.ok()
|
||||||
|
expect(promise._status).to.equal(Promise.Status.Rejected)
|
||||||
|
expect(promise._value[1]).to.equal(6)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it("should pass a promise as-is as an error", function()
|
||||||
|
local innerPromise = Promise.new(function(resolve)
|
||||||
|
resolve(6)
|
||||||
|
end)
|
||||||
|
|
||||||
|
local promise = Promise.reject(innerPromise)
|
||||||
|
|
||||||
|
expect(promise).to.be.ok()
|
||||||
|
expect(promise._status).to.equal(Promise.Status.Rejected)
|
||||||
|
expect(promise._value[1]).to.equal(innerPromise)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
describe("Promise:andThen", function()
|
||||||
|
it("should chain onto resolved promises", function()
|
||||||
|
local args
|
||||||
|
local argsLength
|
||||||
|
local callCount = 0
|
||||||
|
local badCallCount = 0
|
||||||
|
|
||||||
|
local promise = Promise.resolve(5)
|
||||||
|
|
||||||
|
local chained = promise
|
||||||
|
:andThen(function(...)
|
||||||
|
args = {...}
|
||||||
|
argsLength = select("#", ...)
|
||||||
|
callCount = callCount + 1
|
||||||
|
end, function()
|
||||||
|
badCallCount = badCallCount + 1
|
||||||
|
end)
|
||||||
|
|
||||||
|
expect(badCallCount).to.equal(0)
|
||||||
|
|
||||||
|
expect(callCount).to.equal(1)
|
||||||
|
expect(argsLength).to.equal(1)
|
||||||
|
expect(args[1]).to.equal(5)
|
||||||
|
|
||||||
|
expect(promise).to.be.ok()
|
||||||
|
expect(promise._status).to.equal(Promise.Status.Resolved)
|
||||||
|
expect(promise._value[1]).to.equal(5)
|
||||||
|
|
||||||
|
expect(chained).to.be.ok()
|
||||||
|
expect(chained).never.to.equal(promise)
|
||||||
|
expect(chained._status).to.equal(Promise.Status.Resolved)
|
||||||
|
expect(#chained._value).to.equal(0)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it("should chain onto rejected promises", function()
|
||||||
|
local args
|
||||||
|
local argsLength
|
||||||
|
local callCount = 0
|
||||||
|
local badCallCount = 0
|
||||||
|
|
||||||
|
local promise = Promise.reject(5)
|
||||||
|
|
||||||
|
local chained = promise
|
||||||
|
:andThen(function(...)
|
||||||
|
badCallCount = badCallCount + 1
|
||||||
|
end, function(...)
|
||||||
|
args = {...}
|
||||||
|
argsLength = select("#", ...)
|
||||||
|
callCount = callCount + 1
|
||||||
|
end)
|
||||||
|
|
||||||
|
expect(badCallCount).to.equal(0)
|
||||||
|
|
||||||
|
expect(callCount).to.equal(1)
|
||||||
|
expect(argsLength).to.equal(1)
|
||||||
|
expect(args[1]).to.equal(5)
|
||||||
|
|
||||||
|
expect(promise).to.be.ok()
|
||||||
|
expect(promise._status).to.equal(Promise.Status.Rejected)
|
||||||
|
expect(promise._value[1]).to.equal(5)
|
||||||
|
|
||||||
|
expect(chained).to.be.ok()
|
||||||
|
expect(chained).never.to.equal(promise)
|
||||||
|
expect(chained._status).to.equal(Promise.Status.Resolved)
|
||||||
|
expect(#chained._value).to.equal(0)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it("should chain onto asynchronously resolved promises", function()
|
||||||
|
local args
|
||||||
|
local argsLength
|
||||||
|
local callCount = 0
|
||||||
|
local badCallCount = 0
|
||||||
|
|
||||||
|
local startResolution
|
||||||
|
local promise = Promise.new(function(resolve)
|
||||||
|
startResolution = resolve
|
||||||
|
end)
|
||||||
|
|
||||||
|
local chained = promise
|
||||||
|
:andThen(function(...)
|
||||||
|
args = {...}
|
||||||
|
argsLength = select("#", ...)
|
||||||
|
callCount = callCount + 1
|
||||||
|
end, function()
|
||||||
|
badCallCount = badCallCount + 1
|
||||||
|
end)
|
||||||
|
|
||||||
|
expect(callCount).to.equal(0)
|
||||||
|
expect(badCallCount).to.equal(0)
|
||||||
|
|
||||||
|
startResolution(6)
|
||||||
|
|
||||||
|
expect(badCallCount).to.equal(0)
|
||||||
|
|
||||||
|
expect(callCount).to.equal(1)
|
||||||
|
expect(argsLength).to.equal(1)
|
||||||
|
expect(args[1]).to.equal(6)
|
||||||
|
|
||||||
|
expect(promise).to.be.ok()
|
||||||
|
expect(promise._status).to.equal(Promise.Status.Resolved)
|
||||||
|
expect(promise._value[1]).to.equal(6)
|
||||||
|
|
||||||
|
expect(chained).to.be.ok()
|
||||||
|
expect(chained).never.to.equal(promise)
|
||||||
|
expect(chained._status).to.equal(Promise.Status.Resolved)
|
||||||
|
expect(#chained._value).to.equal(0)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it("should chain onto asynchronously rejected promises", function()
|
||||||
|
local args
|
||||||
|
local argsLength
|
||||||
|
local callCount = 0
|
||||||
|
local badCallCount = 0
|
||||||
|
|
||||||
|
local startResolution
|
||||||
|
local promise = Promise.new(function(_, reject)
|
||||||
|
startResolution = reject
|
||||||
|
end)
|
||||||
|
|
||||||
|
local chained = promise
|
||||||
|
:andThen(function()
|
||||||
|
badCallCount = badCallCount + 1
|
||||||
|
end, function(...)
|
||||||
|
args = {...}
|
||||||
|
argsLength = select("#", ...)
|
||||||
|
callCount = callCount + 1
|
||||||
|
end)
|
||||||
|
|
||||||
|
expect(callCount).to.equal(0)
|
||||||
|
expect(badCallCount).to.equal(0)
|
||||||
|
|
||||||
|
startResolution(6)
|
||||||
|
|
||||||
|
expect(badCallCount).to.equal(0)
|
||||||
|
|
||||||
|
expect(callCount).to.equal(1)
|
||||||
|
expect(argsLength).to.equal(1)
|
||||||
|
expect(args[1]).to.equal(6)
|
||||||
|
|
||||||
|
expect(promise).to.be.ok()
|
||||||
|
expect(promise._status).to.equal(Promise.Status.Rejected)
|
||||||
|
expect(promise._value[1]).to.equal(6)
|
||||||
|
|
||||||
|
expect(chained).to.be.ok()
|
||||||
|
expect(chained).never.to.equal(promise)
|
||||||
|
expect(chained._status).to.equal(Promise.Status.Resolved)
|
||||||
|
expect(#chained._value).to.equal(0)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end
|
||||||
203
plugin/src/Reconciler.lua
Normal file
203
plugin/src/Reconciler.lua
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
local RouteMap = require(script.Parent.RouteMap)
|
||||||
|
|
||||||
|
local function classEqual(rbx, className)
|
||||||
|
if className == "*" then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
return rbx.ClassName == className
|
||||||
|
end
|
||||||
|
|
||||||
|
local function reparent(rbx, parent)
|
||||||
|
if rbx then
|
||||||
|
-- It's possible that 'rbx' is a service or some other object that we
|
||||||
|
-- can't change the parent of. That's the only reason why Parent would
|
||||||
|
-- fail except for rbx being previously destroyed!
|
||||||
|
pcall(function()
|
||||||
|
rbx.Parent = parent
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--[[
|
||||||
|
Attempts to match up Roblox instances and object specifiers for
|
||||||
|
reconciliation.
|
||||||
|
|
||||||
|
An object is considered a match if they have the same Name and ClassName.
|
||||||
|
|
||||||
|
primaryChildren and secondaryChildren can each be either a list of Roblox
|
||||||
|
instances or object specifiers. Since they share a common shape, switching
|
||||||
|
the two around isn't problematic!
|
||||||
|
|
||||||
|
visited is expected to be an empty table initially. It will be filled with
|
||||||
|
the set of children that have been visited so far.
|
||||||
|
]]
|
||||||
|
local function findNextChildPair(primaryChildren, secondaryChildren, visited)
|
||||||
|
for _, primaryChild in ipairs(primaryChildren) do
|
||||||
|
if not visited[primaryChild] then
|
||||||
|
visited[primaryChild] = true
|
||||||
|
|
||||||
|
for _, secondaryChild in ipairs(secondaryChildren) do
|
||||||
|
if primaryChild.ClassName == secondaryChild.ClassName and primaryChild.Name == secondaryChild.Name then
|
||||||
|
visited[secondaryChild] = true
|
||||||
|
|
||||||
|
return primaryChild, secondaryChild
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return primaryChild, nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return nil, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local Reconciler = {}
|
||||||
|
Reconciler.__index = Reconciler
|
||||||
|
|
||||||
|
function Reconciler.new()
|
||||||
|
local reconciler = {
|
||||||
|
_routeMap = RouteMap.new(),
|
||||||
|
}
|
||||||
|
|
||||||
|
setmetatable(reconciler, Reconciler)
|
||||||
|
|
||||||
|
return reconciler
|
||||||
|
end
|
||||||
|
|
||||||
|
--[[
|
||||||
|
A semi-smart algorithm that attempts to apply the given item's children to
|
||||||
|
an existing Roblox object.
|
||||||
|
]]
|
||||||
|
function Reconciler:_reconcileChildren(rbx, item)
|
||||||
|
local visited = {}
|
||||||
|
local rbxChildren = rbx:GetChildren()
|
||||||
|
|
||||||
|
-- Reconcile any children that were added or updated
|
||||||
|
while true do
|
||||||
|
local itemChild, rbxChild = findNextChildPair(item.Children, rbxChildren, visited)
|
||||||
|
|
||||||
|
if not itemChild then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
|
||||||
|
reparent(self:reconcile(rbxChild, itemChild), rbx)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Reconcile any children that were deleted
|
||||||
|
while true do
|
||||||
|
local rbxChild, itemChild = findNextChildPair(rbxChildren, item.Children, visited)
|
||||||
|
|
||||||
|
if not rbxChild then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
|
||||||
|
reparent(self:reconcile(rbxChild, itemChild), rbx)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--[[
|
||||||
|
Construct a new Roblox object from the given item.
|
||||||
|
]]
|
||||||
|
function Reconciler:_reify(item)
|
||||||
|
local className = item.ClassName
|
||||||
|
|
||||||
|
-- "*" represents a match of any class. It reifies as a folder!
|
||||||
|
if className == "*" then
|
||||||
|
className = "Folder"
|
||||||
|
end
|
||||||
|
|
||||||
|
local rbx = Instance.new(className)
|
||||||
|
rbx.Name = item.Name
|
||||||
|
|
||||||
|
for key, property in pairs(item.Properties) do
|
||||||
|
-- TODO: Check for compound types, like Vector3!
|
||||||
|
rbx[key] = property.Value
|
||||||
|
end
|
||||||
|
|
||||||
|
self:_reconcileChildren(rbx, item)
|
||||||
|
|
||||||
|
if item.Route then
|
||||||
|
self._routeMap:insert(item.Route, rbx)
|
||||||
|
end
|
||||||
|
|
||||||
|
return rbx
|
||||||
|
end
|
||||||
|
|
||||||
|
--[[
|
||||||
|
Apply the changes represented by the given item to a Roblox object that's a
|
||||||
|
child of the given instance.
|
||||||
|
]]
|
||||||
|
function Reconciler:reconcile(rbx, item)
|
||||||
|
-- Item was deleted
|
||||||
|
if not item then
|
||||||
|
if rbx then
|
||||||
|
rbx:Destroy()
|
||||||
|
end
|
||||||
|
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Item was created!
|
||||||
|
if not rbx then
|
||||||
|
return self:_reify(item)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Item changed type!
|
||||||
|
if not classEqual(rbx, item.ClassName) then
|
||||||
|
rbx:Destroy()
|
||||||
|
|
||||||
|
rbx = self:_reify(item)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Apply all properties, Roblox will de-duplicate changes
|
||||||
|
for key, property in pairs(item.Properties) do
|
||||||
|
-- TODO: Transform property value based on property.Type
|
||||||
|
-- Right now, we assume that 'value' is primitive!
|
||||||
|
|
||||||
|
rbx[key] = property.Value
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Use a dumb algorithm for reconciling children
|
||||||
|
self:_reconcileChildren(rbx, item)
|
||||||
|
|
||||||
|
return rbx
|
||||||
|
end
|
||||||
|
|
||||||
|
function Reconciler:reconcileRoute(route, item, itemRoute)
|
||||||
|
local parent
|
||||||
|
local rbx = game
|
||||||
|
|
||||||
|
for i = 1, #route do
|
||||||
|
local piece = route[i]
|
||||||
|
|
||||||
|
local child = rbx:FindFirstChild(piece)
|
||||||
|
|
||||||
|
-- We should get services instead of making folders here.
|
||||||
|
if rbx == game and not child then
|
||||||
|
local _
|
||||||
|
_, child = pcall(game.GetService, game, piece)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- We don't want to create a folder if we're reaching our target item!
|
||||||
|
if not child and i ~= #route then
|
||||||
|
child = Instance.new("Folder")
|
||||||
|
child.Parent = rbx
|
||||||
|
child.Name = piece
|
||||||
|
end
|
||||||
|
|
||||||
|
parent = rbx
|
||||||
|
rbx = child
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Let's check the route map!
|
||||||
|
if not rbx then
|
||||||
|
rbx = self._routeMap:get(itemRoute)
|
||||||
|
end
|
||||||
|
|
||||||
|
rbx = self:reconcile(rbx, item)
|
||||||
|
|
||||||
|
reparent(rbx, parent)
|
||||||
|
end
|
||||||
|
|
||||||
|
return Reconciler
|
||||||
103
plugin/src/RouteMap.lua
Normal file
103
plugin/src/RouteMap.lua
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
--[[
|
||||||
|
A map from Route objects (given by the server) to Roblox instances (created
|
||||||
|
by the plugin).
|
||||||
|
]]
|
||||||
|
|
||||||
|
local function hashRoute(route)
|
||||||
|
return table.concat(route, "/")
|
||||||
|
end
|
||||||
|
|
||||||
|
local RouteMap = {}
|
||||||
|
RouteMap.__index = RouteMap
|
||||||
|
|
||||||
|
function RouteMap.new()
|
||||||
|
local self = {
|
||||||
|
_map = {},
|
||||||
|
_reverseMap = {},
|
||||||
|
_connectionsByRbx = {},
|
||||||
|
}
|
||||||
|
|
||||||
|
setmetatable(self, RouteMap)
|
||||||
|
|
||||||
|
return self
|
||||||
|
end
|
||||||
|
|
||||||
|
function RouteMap:insert(route, rbx)
|
||||||
|
local hashed = hashRoute(route)
|
||||||
|
|
||||||
|
self._map[hashed] = rbx
|
||||||
|
self._reverseMap[rbx] = hashed
|
||||||
|
self._connectionsByRbx[rbx] = rbx.AncestryChanged:Connect(function(_, parent)
|
||||||
|
if parent == nil then
|
||||||
|
self:removeByRbx(rbx)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
function RouteMap:get(route)
|
||||||
|
return self._map[hashRoute(route)]
|
||||||
|
end
|
||||||
|
|
||||||
|
function RouteMap:removeByRoute(route)
|
||||||
|
local hashedRoute = hashRoute(route)
|
||||||
|
local rbx = self._map[hashedRoute]
|
||||||
|
|
||||||
|
if rbx then
|
||||||
|
self._map[hashedRoute] = nil
|
||||||
|
self._reverseMap[rbx] = nil
|
||||||
|
self._connectionsByRbx[rbx] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function RouteMap:removeByRbx(rbx)
|
||||||
|
local hashedRoute = self._reverseMap[rbx]
|
||||||
|
|
||||||
|
if hashedRoute then
|
||||||
|
self._map[hashedRoute] = nil
|
||||||
|
self._reverseMap[rbx] = nil
|
||||||
|
self._connectionsByRbx[rbx] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function RouteMap:removeRbxDescendants(parentRbx)
|
||||||
|
for rbx in pairs(self._reverseMap) do
|
||||||
|
if rbx:IsDescendantOf(parentRbx) then
|
||||||
|
self:removeByRbx(rbx)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function RouteMap:clear()
|
||||||
|
self._map = {}
|
||||||
|
self._reverseMap = {}
|
||||||
|
|
||||||
|
for object in pairs(self._connectionsByRbx) do
|
||||||
|
object:Disconnect()
|
||||||
|
end
|
||||||
|
|
||||||
|
self._connectionsByRbx = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
function RouteMap:visualize()
|
||||||
|
-- Log all of our keys so that the visualization has a stable order.
|
||||||
|
local keys = {}
|
||||||
|
|
||||||
|
for key in pairs(self._map) do
|
||||||
|
table.insert(keys, key)
|
||||||
|
end
|
||||||
|
|
||||||
|
table.sort(keys)
|
||||||
|
|
||||||
|
local buffer = {}
|
||||||
|
for _, key in ipairs(keys) do
|
||||||
|
local visualized = ("- %s: %s"):format(
|
||||||
|
key,
|
||||||
|
self._map[key]:GetFullName()
|
||||||
|
)
|
||||||
|
table.insert(buffer, visualized)
|
||||||
|
end
|
||||||
|
|
||||||
|
return table.concat(buffer, "\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
return RouteMap
|
||||||
89
plugin/src/Server.lua
Normal file
89
plugin/src/Server.lua
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
local HttpService = game:GetService("HttpService")
|
||||||
|
|
||||||
|
local Config = require(script.Parent.Config)
|
||||||
|
local Promise = require(script.Parent.Promise)
|
||||||
|
local Version = require(script.Parent.Version)
|
||||||
|
|
||||||
|
local Server = {}
|
||||||
|
Server.__index = Server
|
||||||
|
|
||||||
|
--[[
|
||||||
|
Create a new Server using the given HTTP implementation and replacer.
|
||||||
|
|
||||||
|
If the context becomes invalid, `replacer` will be invoked with a new
|
||||||
|
context that should be suitable to replace this one.
|
||||||
|
|
||||||
|
Attempting to invoke methods on an invalid conext will throw errors!
|
||||||
|
]]
|
||||||
|
function Server.connect(http)
|
||||||
|
local context = {
|
||||||
|
http = http,
|
||||||
|
serverId = nil,
|
||||||
|
currentTime = 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
setmetatable(context, Server)
|
||||||
|
|
||||||
|
return context:_start()
|
||||||
|
end
|
||||||
|
|
||||||
|
function Server:_start()
|
||||||
|
return self:getInfo()
|
||||||
|
:andThen(function(response)
|
||||||
|
if response.protocolVersion ~= Config.protocolVersion then
|
||||||
|
local message = (
|
||||||
|
"Found a Rojo dev server, but it's using a different protocol version, and is incompatible." ..
|
||||||
|
"\nMake sure you have matching versions of both the Rojo plugin and server!" ..
|
||||||
|
"\n\nYour client is version %s, with protocol version %s. It expects server version %s." ..
|
||||||
|
"\nYour server is version %s, with protocol version %s." ..
|
||||||
|
"\n\nGo to https://github.com/LPGhatguy/rojo for more details."
|
||||||
|
):format(
|
||||||
|
Version.display(Config.version), Config.protocolVersion,
|
||||||
|
Config.expectedServerVersionString,
|
||||||
|
response.serverVersion, response.protocolVersion
|
||||||
|
)
|
||||||
|
|
||||||
|
return Promise.reject(message)
|
||||||
|
end
|
||||||
|
|
||||||
|
self.serverId = response.serverId
|
||||||
|
self.currentTime = response.currentTime
|
||||||
|
|
||||||
|
return self
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Server:getInfo()
|
||||||
|
return self.http:get("/")
|
||||||
|
:andThen(function(response)
|
||||||
|
response = response:json()
|
||||||
|
|
||||||
|
return response
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Server:read(paths)
|
||||||
|
local body = HttpService:JSONEncode(paths)
|
||||||
|
|
||||||
|
return self.http:post("/read", body)
|
||||||
|
:andThen(function(response)
|
||||||
|
response = response:json()
|
||||||
|
|
||||||
|
return response.items
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Server:getChanges()
|
||||||
|
local url = ("/changes/%f"):format(self.currentTime)
|
||||||
|
|
||||||
|
return self.http:get(url)
|
||||||
|
:andThen(function(response)
|
||||||
|
response = response:json()
|
||||||
|
|
||||||
|
self.currentTime = response.currentTime
|
||||||
|
|
||||||
|
return response.changes
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
return Server
|
||||||
40
plugin/src/Version.lua
Normal file
40
plugin/src/Version.lua
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
local function compare(a, b)
|
||||||
|
if a > b then
|
||||||
|
return 1
|
||||||
|
elseif a < b then
|
||||||
|
return -1
|
||||||
|
end
|
||||||
|
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
|
||||||
|
local Version = {}
|
||||||
|
|
||||||
|
--[[
|
||||||
|
Compares two versions of the form {major, minor, revision}.
|
||||||
|
|
||||||
|
If a is newer than b, 1.
|
||||||
|
If a is older than b, -1.
|
||||||
|
If a and b are the same, 0.
|
||||||
|
]]
|
||||||
|
function Version.compare(a, b)
|
||||||
|
local major = compare(a[1], b[1])
|
||||||
|
local minor = compare(a[2] or 0, b[2] or 0)
|
||||||
|
local revision = compare(a[3] or 0, b[3] or 0)
|
||||||
|
|
||||||
|
if major ~= 0 then
|
||||||
|
return major
|
||||||
|
end
|
||||||
|
|
||||||
|
if minor ~= 0 then
|
||||||
|
return minor
|
||||||
|
end
|
||||||
|
|
||||||
|
return revision
|
||||||
|
end
|
||||||
|
|
||||||
|
function Version.display(version)
|
||||||
|
return table.concat(version, ".")
|
||||||
|
end
|
||||||
|
|
||||||
|
return Version
|
||||||
28
plugin/src/Version.spec.lua
Normal file
28
plugin/src/Version.spec.lua
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
return function()
|
||||||
|
local Version = require(script.Parent.Version)
|
||||||
|
|
||||||
|
it("should compare equal versions", function()
|
||||||
|
expect(Version.compare({1, 2, 3}, {1, 2, 3})).to.equal(0)
|
||||||
|
expect(Version.compare({0, 4, 0}, {0, 4})).to.equal(0)
|
||||||
|
expect(Version.compare({0, 0, 123}, {0, 0, 123})).to.equal(0)
|
||||||
|
expect(Version.compare({26}, {26})).to.equal(0)
|
||||||
|
expect(Version.compare({26, 42}, {26, 42})).to.equal(0)
|
||||||
|
expect(Version.compare({1, 0, 0}, {1})).to.equal(0)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it("should compare newer, older versions", function()
|
||||||
|
expect(Version.compare({1}, {0})).to.equal(1)
|
||||||
|
expect(Version.compare({1, 1}, {1, 0})).to.equal(1)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it("should compare different major versions", function()
|
||||||
|
expect(Version.compare({1, 3, 2}, {2, 2, 1})).to.equal(-1)
|
||||||
|
expect(Version.compare({1, 2}, {2, 1})).to.equal(-1)
|
||||||
|
expect(Version.compare({1}, {2})).to.equal(-1)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it("should compare different minor versions", function()
|
||||||
|
expect(Version.compare({1, 2, 3}, {1, 3, 2})).to.equal(-1)
|
||||||
|
expect(Version.compare({50, 1}, {50, 2})).to.equal(-1)
|
||||||
|
end)
|
||||||
|
end
|
||||||
4
plugin/src/runTests.lua
Normal file
4
plugin/src/runTests.lua
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
return function()
|
||||||
|
local TestEZ = require(script.Parent.Parent.TestEZ)
|
||||||
|
TestEZ.TestBootstrap:run(script.Parent)
|
||||||
|
end
|
||||||
2
plugin/tests/runTests.server.lua
Normal file
2
plugin/tests/runTests.server.lua
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
local TestEZ = require(game.ReplicatedStorage.TestEZ)
|
||||||
|
TestEZ.TestBootstrap:run(game.ReplicatedStorage.Rojo.plugin)
|
||||||
5
server/.editorconfig
Normal file
5
server/.editorconfig
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
[*.rs]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
insert_final_newline = true
|
||||||
0
.gitignore → server/.gitignore
vendored
0
.gitignore → server/.gitignore
vendored
0
Cargo.lock → server/Cargo.lock
generated
0
Cargo.lock → server/Cargo.lock
generated
Reference in New Issue
Block a user