mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-04-20 12:45:05 +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
|
||||
charset = utf-8
|
||||
|
||||
[*.rs]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.json]
|
||||
indent_style = space
|
||||
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:
|
||||
- stable
|
||||
- beta
|
||||
before_install:
|
||||
- pip install hererocks
|
||||
- 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
|
||||
|
||||
## 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)
|
||||
* Protocol version 1, which shifts more responsibility onto the server
|
||||
|
||||
55
README.md
55
README.md
@@ -1,5 +1,5 @@
|
||||
<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>
|
||||
@@ -14,15 +14,12 @@
|
||||
|
||||
<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.
|
||||
|
||||
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
|
||||
|
||||
Rojo has a number of desirable features *right now*:
|
||||
Rojo lets you:
|
||||
|
||||
* Work on scripts from the filesystem, in your favorite editor
|
||||
* 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:
|
||||
|
||||
* Sync rbxmx-format Roblox models bi-directionally between the filesystem and Roblox Studio
|
||||
* Package libraries and plugins into `rbxmx` files from the command line
|
||||
* Sync `rbxmx` models between the filesystem and Roblox Studio
|
||||
* Package projects into `rbxmx` files from the command line
|
||||
|
||||
## Installation
|
||||
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:
|
||||
* Cargo, if you have Rust installed
|
||||
* 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)
|
||||
* The server, a binary written in Rust
|
||||
* The plugin, a Roblox Studio plugin written in Lua
|
||||
|
||||
## 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`.
|
||||
|
||||
### 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
|
||||
mkdir my-new-project
|
||||
@@ -56,9 +63,9 @@ cd my-new-project
|
||||
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
|
||||
{
|
||||
@@ -69,7 +76,7 @@ The default project looks like this:
|
||||
```
|
||||
|
||||
### 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
|
||||
rojo serve
|
||||
@@ -142,9 +149,11 @@ Will turn into these instances in Roblox:
|
||||
* `my-game` (`LocalScript` with source from `my-game/init.client.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
|
||||
{
|
||||
@@ -192,9 +201,7 @@ I also have a couple tools that Rojo intends to replace:
|
||||
## Contributing
|
||||
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!
|
||||
|
||||
The Rojo and Rojo Plugin repositories should stay in sync with eachother, so that the current `master` of each repository can be used together.
|
||||
All pull requests are run against a test suite on Travis CI. That test suite should always pass!
|
||||
|
||||
## License
|
||||
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