[Scummvm-git-logs] scummvm-sites multiplayer -> 8c88e8770dd51893c0e7dd4bdd4f95387a49fa98

sev- noreply at scummvm.org
Tue Mar 28 23:16:14 UTC 2023


This automated email contains information about 21 new commits which have been
pushed to the 'scummvm-sites' repo located at https://github.com/scummvm/scummvm-sites .

Summary:
5548e39a11 MULTIPLAYER: Initial codebase rewrite.
28043e8ce7 MULTIPLAYER: DOCKER: Use Debian image.
512fbde9e6 MULTIPLAYER: Relay support.
44ac81fbf2 MULTIPLAYER: Add supported Backyard Sports titles.
ec3d0ded4f MULTIPLAYER: WEB: Don't use url_for for static.
979f88da2d MULTIPLAYER: Remove output whitespace
8432f3566e MULTIPLAYER: Add lobby server code.
c375eb861f MULTIPLAYER: LOBBY: Send session server address.
ad9bbd832d MULTIPLAYER: LOBBY: Rename login endpoint.
08e75d131a MULTIPLAYER: LOBBY: Removed relay messages.
da987f4117 MULTIPLAYER: Improve relay setup through Redis.
b0c13bda75 MULTIPLAYER: Accept and show max players.
0eb4eebe28 README: Update repo link
85b476f346 MULTIPLAYER: Format Python code.
e1a7a482a9 MULTIPLAYER: Add logo and adjust paths to it.
47a3ac5a40 MULTIPLAYER: Add missing end line.
a59fb4347c MULTIPLAYER: Replace aioredis with redis, update README
8190b98cbc MULTIPLAYER: Make redis start before server
d002097a1b MULTIPLAYER: Base web image on python:3.9
08b6a47f2f MULTIPLAYER: Add GPLv3 license and file headers.
8c88e8770d MULTIPLAYER: Sanity check game data packets.


Commit: 5548e39a110518c1bbc341506c3121410bbf55aa
    https://github.com/scummvm/scummvm-sites/commit/5548e39a110518c1bbc341506c3121410bbf55aa
Author: Little Cat (toontownlittlecat at gmail.com)
Date: 2023-03-29T01:16:01+02:00

Commit Message:
MULTIPLAYER: Initial codebase rewrite.

Session creation, storage, and joining works.

Includes web server using FastAPI.

Changed paths:
  A Dockerfile
  A main.py
  A requirements.txt
  A web/Dockerfile
  A web/config.py
  A web/main.py
  A web/requirements.txt
  A web/static/icons/moonbase.png
  A web/static/style.css
  A web/templates/game.html
  A web/templates/index.html
  R composer.json
  R composer.lock
  R logs/README.md
  R phpunit.xml
  R public/.htaccess
  R public/cloud-style.css
  R public/images/box.png
  R public/images/dropbox.png
  R public/images/google_drive.png
  R public/images/onedrive.png
  R public/index.php
  R src/dependencies.php
  R src/middleware.php
  R src/routes.php
  R src/settings.php
  R templates/index.phtml
  R templates/token.phtml
  R tests/Functional/BaseTestCase.php
  R tests/Functional/HomepageTest.php
    .gitignore
    README.md
    docker-compose.yml


diff --git a/.gitignore b/.gitignore
index adf1997..68bc17f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,160 @@
-/vendor/
-/logs/*
-!/logs/README.md
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+#  Usually these files are written by a python script from a template
+#  before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py,cover
+.hypothesis/
+.pytest_cache/
+cover/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+.pybuilder/
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+#   For a library or package, you might want to ignore these files since the code is
+#   intended to run in multiple environments; otherwise, check them in:
+# .python-version
+
+# pipenv
+#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+#   However, in case of collaboration, if having platform-specific dependencies or dependencies
+#   having no cross-platform support, pipenv may install dependencies that don't work, or not
+#   install all needed dependencies.
+#Pipfile.lock
+
+# poetry
+#   Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
+#   This is especially recommended for binary packages to ensure reproducibility, and is more
+#   commonly ignored for libraries.
+#   https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
+#poetry.lock
+
+# pdm
+#   Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
+#pdm.lock
+#   pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
+#   in version control.
+#   https://pdm.fming.dev/#use-with-ide
+.pdm.toml
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule
+celerybeat.pid
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# Cython debug symbols
+cython_debug/
+
+# PyCharm
+#  JetBrains specific template is maintained in a separate JetBrains.gitignore that can
+#  be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
+#  and can be added to the global gitignore or merged into this file.  For a more nuclear
+#  option (not recommended) you can uncomment the following to ignore the entire idea folder.
+#.idea/
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..3f16dba
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,9 @@
+FROM python:3.9-buster
+
+WORKDIR /usr/src/app
+
+COPY requirements.txt ./
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY . .
+CMD [ "python", "main.py" ]
\ No newline at end of file
diff --git a/README.md b/README.md
index d7091a3..1f7197d 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,48 @@
-# ScummVM Multiplayer Server
+# ScummVM Multiplayer
 
-Currently supports multiplayer games for Moonbase Commander
+This project contains code for hosting online multiplayer lobbies for Moonbase Commander.  This is currently WIP.
 
+## Getting Started
+### Installing
+Both the session and web servers requires Redis to be installed.  This is needed for both servers to share session data to each other.
+
+Clone this repo and checkout to the multiplayer branch:
+```
+git clone https://github.com/LittleToonCat/scummvm-sites.git
+
+git checkout multiplayer
+```
+
+To start the session server, create a new virtual envrionment and install the requirements.
+```
+python3 -m venv .env
+source .env/bin/activate
+
+python3 -m pip install -r requirements.txt
+```
+if you're planning to run the web server, install the requirements located in the web directory.
+```
+python3 -m pip install -r web/requirements.txt
+```
+
+### Running
+To run the session server, simply run the main.py script
+```
+python3 main.py
+```
+It should listen for connections on port 9120.  Remember to configure ScummVM to connect to localhost or whatever address your server is running in.
+
+Running a web server is unnecessary if you just want your server to host sessions, but if you want to, you can start one up by using uvicorn.
+```
+cd web
+uvicorn main:app --reload
+```
+
+## Deployment
+Both the session and web server can be run within Docker via docker-compose.
+```
+docker-compose build
+docker-compose up
+```
+
+This will build Docker images for both servers and starts a container for them simultaneously alongside with Redis.
\ No newline at end of file
diff --git a/composer.json b/composer.json
deleted file mode 100644
index fcc8057..0000000
--- a/composer.json
+++ /dev/null
@@ -1,47 +0,0 @@
-{
-    "name": "scummvm/multiplayer",
-    "description": "ScummVM Multiplayer server",
-    "keywords": ["scummvm"],
-    "homepage": "https://multiplayer.scummvm.org",
-    "license": "MIT",
-    "authors": [
-        {
-          "name": "Matan Bareket",
-          "email": "mataniko at scummvm.org"
-        },
-        {
-          "name": "Eugene Sandulenko",
-          "email": "sev at scummvm.org"
-        }
-    ],
-    "require": {
-        "php": ">=5.6",
-        "guzzlehttp/guzzle": "^6.3",
-        "monolog/monolog": "^1.17",
-        "nikolaposa/rate-limit": "^1.0",
-        "pragmarx/random": "^0.2.2",
-        "predis/predis": "^1.1",
-        "slim/php-view": "^2.0",
-        "slim/slim": "^3.1"
-    },
-    "require-dev": {
-        "phpunit/phpunit": ">=5.0",
-        "squizlabs/php_codesniffer": "^3.4"
-    },
-    "autoload-dev": {
-        "psr-4": {
-            "Tests\\": "tests/"
-        }
-    },
-    "config": {
-        "process-timeout": 0,
-        "sort-packages": true
-    },
-    "scripts": {
-        "start": "php -S localhost:8080 -t public",
-        "test": "phpunit",
-        "lint": [
-          "phpcbf ./src"
-        ]
-    }
-}
diff --git a/composer.lock b/composer.lock
deleted file mode 100644
index 1a99022..0000000
--- a/composer.lock
+++ /dev/null
@@ -1,2407 +0,0 @@
-{
-    "_readme": [
-        "This file locks the dependencies of your project to a known state",
-        "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
-        "This file is @generated automatically"
-    ],
-    "content-hash": "52c7975a1c8df80709566b48118c2dc5",
-    "packages": [
-        {
-            "name": "container-interop/container-interop",
-            "version": "1.2.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/container-interop/container-interop.git",
-                "reference": "79cbf1341c22ec75643d841642dd5d6acd83bdb8"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/container-interop/container-interop/zipball/79cbf1341c22ec75643d841642dd5d6acd83bdb8",
-                "reference": "79cbf1341c22ec75643d841642dd5d6acd83bdb8",
-                "shasum": ""
-            },
-            "require": {
-                "psr/container": "^1.0"
-            },
-            "type": "library",
-            "autoload": {
-                "psr-4": {
-                    "Interop\\Container\\": "src/Interop/Container/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "description": "Promoting the interoperability of container objects (DIC, SL, etc.)",
-            "homepage": "https://github.com/container-interop/container-interop",
-            "time": "2017-02-14T19:40:03+00:00"
-        },
-        {
-            "name": "guzzlehttp/guzzle",
-            "version": "6.3.3",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/guzzle/guzzle.git",
-                "reference": "407b0cb880ace85c9b63c5f9551db498cb2d50ba"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/guzzle/guzzle/zipball/407b0cb880ace85c9b63c5f9551db498cb2d50ba",
-                "reference": "407b0cb880ace85c9b63c5f9551db498cb2d50ba",
-                "shasum": ""
-            },
-            "require": {
-                "guzzlehttp/promises": "^1.0",
-                "guzzlehttp/psr7": "^1.4",
-                "php": ">=5.5"
-            },
-            "require-dev": {
-                "ext-curl": "*",
-                "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.4 || ^7.0",
-                "psr/log": "^1.0"
-            },
-            "suggest": {
-                "psr/log": "Required for using the Log middleware"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "6.3-dev"
-                }
-            },
-            "autoload": {
-                "files": [
-                    "src/functions_include.php"
-                ],
-                "psr-4": {
-                    "GuzzleHttp\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Michael Dowling",
-                    "email": "mtdowling at gmail.com",
-                    "homepage": "https://github.com/mtdowling"
-                }
-            ],
-            "description": "Guzzle is a PHP HTTP client library",
-            "homepage": "http://guzzlephp.org/",
-            "keywords": [
-                "client",
-                "curl",
-                "framework",
-                "http",
-                "http client",
-                "rest",
-                "web service"
-            ],
-            "time": "2018-04-22T15:46:56+00:00"
-        },
-        {
-            "name": "guzzlehttp/promises",
-            "version": "v1.3.1",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/guzzle/promises.git",
-                "reference": "a59da6cf61d80060647ff4d3eb2c03a2bc694646"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/guzzle/promises/zipball/a59da6cf61d80060647ff4d3eb2c03a2bc694646",
-                "reference": "a59da6cf61d80060647ff4d3eb2c03a2bc694646",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=5.5.0"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^4.0"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "1.4-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "GuzzleHttp\\Promise\\": "src/"
-                },
-                "files": [
-                    "src/functions_include.php"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Michael Dowling",
-                    "email": "mtdowling at gmail.com",
-                    "homepage": "https://github.com/mtdowling"
-                }
-            ],
-            "description": "Guzzle promises library",
-            "keywords": [
-                "promise"
-            ],
-            "time": "2016-12-20T10:07:11+00:00"
-        },
-        {
-            "name": "guzzlehttp/psr7",
-            "version": "1.6.1",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/guzzle/psr7.git",
-                "reference": "239400de7a173fe9901b9ac7c06497751f00727a"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/guzzle/psr7/zipball/239400de7a173fe9901b9ac7c06497751f00727a",
-                "reference": "239400de7a173fe9901b9ac7c06497751f00727a",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=5.4.0",
-                "psr/http-message": "~1.0",
-                "ralouphie/getallheaders": "^2.0.5 || ^3.0.0"
-            },
-            "provide": {
-                "psr/http-message-implementation": "1.0"
-            },
-            "require-dev": {
-                "ext-zlib": "*",
-                "phpunit/phpunit": "~4.8.36 || ^5.7.27 || ^6.5.8"
-            },
-            "suggest": {
-                "zendframework/zend-httphandlerrunner": "Emit PSR-7 responses"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "1.6-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "GuzzleHttp\\Psr7\\": "src/"
-                },
-                "files": [
-                    "src/functions_include.php"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Michael Dowling",
-                    "email": "mtdowling at gmail.com",
-                    "homepage": "https://github.com/mtdowling"
-                },
-                {
-                    "name": "Tobias Schultze",
-                    "homepage": "https://github.com/Tobion"
-                }
-            ],
-            "description": "PSR-7 message implementation that also provides common utility methods",
-            "keywords": [
-                "http",
-                "message",
-                "psr-7",
-                "request",
-                "response",
-                "stream",
-                "uri",
-                "url"
-            ],
-            "time": "2019-07-01T23:21:34+00:00"
-        },
-        {
-            "name": "monolog/monolog",
-            "version": "1.25.1",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/Seldaek/monolog.git",
-                "reference": "70e65a5470a42cfec1a7da00d30edb6e617e8dcf"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/Seldaek/monolog/zipball/70e65a5470a42cfec1a7da00d30edb6e617e8dcf",
-                "reference": "70e65a5470a42cfec1a7da00d30edb6e617e8dcf",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=5.3.0",
-                "psr/log": "~1.0"
-            },
-            "provide": {
-                "psr/log-implementation": "1.0.0"
-            },
-            "require-dev": {
-                "aws/aws-sdk-php": "^2.4.9 || ^3.0",
-                "doctrine/couchdb": "~1.0 at dev",
-                "graylog2/gelf-php": "~1.0",
-                "jakub-onderka/php-parallel-lint": "0.9",
-                "php-amqplib/php-amqplib": "~2.4",
-                "php-console/php-console": "^3.1.3",
-                "phpunit/phpunit": "~4.5",
-                "phpunit/phpunit-mock-objects": "2.3.0",
-                "ruflin/elastica": ">=0.90 <3.0",
-                "sentry/sentry": "^0.13",
-                "swiftmailer/swiftmailer": "^5.3|^6.0"
-            },
-            "suggest": {
-                "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB",
-                "doctrine/couchdb": "Allow sending log messages to a CouchDB server",
-                "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)",
-                "ext-mongo": "Allow sending log messages to a MongoDB server",
-                "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server",
-                "mongodb/mongodb": "Allow sending log messages to a MongoDB server via PHP Driver",
-                "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib",
-                "php-console/php-console": "Allow sending log messages to Google Chrome",
-                "rollbar/rollbar": "Allow sending log messages to Rollbar",
-                "ruflin/elastica": "Allow sending log messages to an Elastic Search server",
-                "sentry/sentry": "Allow sending log messages to a Sentry server"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "2.0.x-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Monolog\\": "src/Monolog"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Jordi Boggiano",
-                    "email": "j.boggiano at seld.be",
-                    "homepage": "http://seld.be"
-                }
-            ],
-            "description": "Sends your logs to files, sockets, inboxes, databases and various web services",
-            "homepage": "http://github.com/Seldaek/monolog",
-            "keywords": [
-                "log",
-                "logging",
-                "psr-3"
-            ],
-            "time": "2019-09-06T13:49:17+00:00"
-        },
-        {
-            "name": "nikic/fast-route",
-            "version": "v1.3.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/nikic/FastRoute.git",
-                "reference": "181d480e08d9476e61381e04a71b34dc0432e812"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/nikic/FastRoute/zipball/181d480e08d9476e61381e04a71b34dc0432e812",
-                "reference": "181d480e08d9476e61381e04a71b34dc0432e812",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=5.4.0"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^4.8.35|~5.7"
-            },
-            "type": "library",
-            "autoload": {
-                "psr-4": {
-                    "FastRoute\\": "src/"
-                },
-                "files": [
-                    "src/functions.php"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Nikita Popov",
-                    "email": "nikic at php.net"
-                }
-            ],
-            "description": "Fast request router for PHP",
-            "keywords": [
-                "router",
-                "routing"
-            ],
-            "time": "2018-02-13T20:26:39+00:00"
-        },
-        {
-            "name": "nikolaposa/rate-limit",
-            "version": "1.0.1",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/nikolaposa/rate-limit.git",
-                "reference": "8c1c6a08b7dff2e87a2335b0dd20f123fece0aa2"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/nikolaposa/rate-limit/zipball/8c1c6a08b7dff2e87a2335b0dd20f123fece0aa2",
-                "reference": "8c1c6a08b7dff2e87a2335b0dd20f123fece0aa2",
-                "shasum": ""
-            },
-            "require": {
-                "php": "^7.0",
-                "psr/http-message": "^1.0"
-            },
-            "require-dev": {
-                "friendsofphp/php-cs-fixer": "^2.0",
-                "phpunit/phpunit": "^4.7 | ^5.0",
-                "zendframework/zend-diactoros": "^1.3"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "1.0.x-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "RateLimit\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Nikola Poša",
-                    "email": "posa.nikola at gmail.com",
-                    "homepage": "http://www.nikolaposa.in.rs"
-                }
-            ],
-            "description": "Standalone component that facilitates rate-limiting functionality. Also provides a middleware designed for API and/or other application endpoints.",
-            "keywords": [
-                "middleware",
-                "rate limit"
-            ],
-            "time": "2017-10-25T19:47:36+00:00"
-        },
-        {
-            "name": "pimple/pimple",
-            "version": "v3.2.3",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/silexphp/Pimple.git",
-                "reference": "9e403941ef9d65d20cba7d54e29fe906db42cf32"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/silexphp/Pimple/zipball/9e403941ef9d65d20cba7d54e29fe906db42cf32",
-                "reference": "9e403941ef9d65d20cba7d54e29fe906db42cf32",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=5.3.0",
-                "psr/container": "^1.0"
-            },
-            "require-dev": {
-                "symfony/phpunit-bridge": "^3.2"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "3.2.x-dev"
-                }
-            },
-            "autoload": {
-                "psr-0": {
-                    "Pimple": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Fabien Potencier",
-                    "email": "fabien at symfony.com"
-                }
-            ],
-            "description": "Pimple, a simple Dependency Injection Container",
-            "homepage": "http://pimple.sensiolabs.org",
-            "keywords": [
-                "container",
-                "dependency injection"
-            ],
-            "time": "2018-01-21T07:42:36+00:00"
-        },
-        {
-            "name": "pragmarx/random",
-            "version": "v0.2.2",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/antonioribeiro/random.git",
-                "reference": "daf08a189c5d2d40d1a827db46364d3a741a51b7"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/antonioribeiro/random/zipball/daf08a189c5d2d40d1a827db46364d3a741a51b7",
-                "reference": "daf08a189c5d2d40d1a827db46364d3a741a51b7",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=7.0"
-            },
-            "require-dev": {
-                "fzaninotto/faker": "~1.7",
-                "phpunit/phpunit": "~6.4",
-                "pragmarx/trivia": "~0.1",
-                "squizlabs/php_codesniffer": "^2.3"
-            },
-            "suggest": {
-                "fzaninotto/faker": "Allows you to get dozens of randomized types",
-                "pragmarx/trivia": "For the trivia database"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "1.0-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "PragmaRX\\Random\\": "src"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Antonio Carlos Ribeiro",
-                    "email": "acr at antoniocarlosribeiro.com",
-                    "homepage": "https://antoniocarlosribeiro.com",
-                    "role": "Developer"
-                }
-            ],
-            "description": "Create random chars, numbers, strings",
-            "homepage": "https://github.com/antonioribeiro/random",
-            "keywords": [
-                "Randomize",
-                "faker",
-                "pragmarx",
-                "random",
-                "random number",
-                "random pattern",
-                "random string"
-            ],
-            "time": "2017-11-21T05:26:22+00:00"
-        },
-        {
-            "name": "predis/predis",
-            "version": "v1.1.1",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/nrk/predis.git",
-                "reference": "f0210e38881631afeafb56ab43405a92cafd9fd1"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/nrk/predis/zipball/f0210e38881631afeafb56ab43405a92cafd9fd1",
-                "reference": "f0210e38881631afeafb56ab43405a92cafd9fd1",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=5.3.9"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "~4.8"
-            },
-            "suggest": {
-                "ext-curl": "Allows access to Webdis when paired with phpiredis",
-                "ext-phpiredis": "Allows faster serialization and deserialization of the Redis protocol"
-            },
-            "type": "library",
-            "autoload": {
-                "psr-4": {
-                    "Predis\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Daniele Alessandri",
-                    "email": "suppakilla at gmail.com",
-                    "homepage": "http://clorophilla.net"
-                }
-            ],
-            "description": "Flexible and feature-complete Redis client for PHP and HHVM",
-            "homepage": "http://github.com/nrk/predis",
-            "keywords": [
-                "nosql",
-                "predis",
-                "redis"
-            ],
-            "time": "2016-06-16T16:22:20+00:00"
-        },
-        {
-            "name": "psr/container",
-            "version": "1.0.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/php-fig/container.git",
-                "reference": "b7ce3b176482dbbc1245ebf52b181af44c2cf55f"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/php-fig/container/zipball/b7ce3b176482dbbc1245ebf52b181af44c2cf55f",
-                "reference": "b7ce3b176482dbbc1245ebf52b181af44c2cf55f",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=5.3.0"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "1.0.x-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Psr\\Container\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "PHP-FIG",
-                    "homepage": "http://www.php-fig.org/"
-                }
-            ],
-            "description": "Common Container Interface (PHP FIG PSR-11)",
-            "homepage": "https://github.com/php-fig/container",
-            "keywords": [
-                "PSR-11",
-                "container",
-                "container-interface",
-                "container-interop",
-                "psr"
-            ],
-            "time": "2017-02-14T16:28:37+00:00"
-        },
-        {
-            "name": "psr/http-message",
-            "version": "1.0.1",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/php-fig/http-message.git",
-                "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/php-fig/http-message/zipball/f6561bf28d520154e4b0ec72be95418abe6d9363",
-                "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=5.3.0"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "1.0.x-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Psr\\Http\\Message\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "PHP-FIG",
-                    "homepage": "http://www.php-fig.org/"
-                }
-            ],
-            "description": "Common interface for HTTP messages",
-            "homepage": "https://github.com/php-fig/http-message",
-            "keywords": [
-                "http",
-                "http-message",
-                "psr",
-                "psr-7",
-                "request",
-                "response"
-            ],
-            "time": "2016-08-06T14:39:51+00:00"
-        },
-        {
-            "name": "psr/log",
-            "version": "1.1.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/php-fig/log.git",
-                "reference": "6c001f1daafa3a3ac1d8ff69ee4db8e799a654dd"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/php-fig/log/zipball/6c001f1daafa3a3ac1d8ff69ee4db8e799a654dd",
-                "reference": "6c001f1daafa3a3ac1d8ff69ee4db8e799a654dd",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=5.3.0"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "1.0.x-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Psr\\Log\\": "Psr/Log/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "PHP-FIG",
-                    "homepage": "http://www.php-fig.org/"
-                }
-            ],
-            "description": "Common interface for logging libraries",
-            "homepage": "https://github.com/php-fig/log",
-            "keywords": [
-                "log",
-                "psr",
-                "psr-3"
-            ],
-            "time": "2018-11-20T15:27:04+00:00"
-        },
-        {
-            "name": "ralouphie/getallheaders",
-            "version": "3.0.3",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/ralouphie/getallheaders.git",
-                "reference": "120b605dfeb996808c31b6477290a714d356e822"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822",
-                "reference": "120b605dfeb996808c31b6477290a714d356e822",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=5.6"
-            },
-            "require-dev": {
-                "php-coveralls/php-coveralls": "^2.1",
-                "phpunit/phpunit": "^5 || ^6.5"
-            },
-            "type": "library",
-            "autoload": {
-                "files": [
-                    "src/getallheaders.php"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Ralph Khattar",
-                    "email": "ralph.khattar at gmail.com"
-                }
-            ],
-            "description": "A polyfill for getallheaders.",
-            "time": "2019-03-08T08:55:37+00:00"
-        },
-        {
-            "name": "slim/php-view",
-            "version": "2.2.1",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/slimphp/PHP-View.git",
-                "reference": "a13ada9d7962ca1b48799c0d9ffbca4c33245aed"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/slimphp/PHP-View/zipball/a13ada9d7962ca1b48799c0d9ffbca4c33245aed",
-                "reference": "a13ada9d7962ca1b48799c0d9ffbca4c33245aed",
-                "shasum": ""
-            },
-            "require": {
-                "psr/http-message": "^1.0"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^4.8",
-                "slim/slim": "^3.0"
-            },
-            "type": "library",
-            "autoload": {
-                "psr-4": {
-                    "Slim\\Views\\": "src"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Glenn Eggleton",
-                    "email": "geggleto at gmail.com"
-                }
-            ],
-            "description": "Render PHP view scripts into a PSR-7 Response object.",
-            "keywords": [
-                "framework",
-                "php",
-                "phtml",
-                "renderer",
-                "slim",
-                "template",
-                "view"
-            ],
-            "time": "2019-04-15T20:43:28+00:00"
-        },
-        {
-            "name": "slim/slim",
-            "version": "3.12.2",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/slimphp/Slim.git",
-                "reference": "200c6143f15baa477601879b64ab2326847aac0b"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/slimphp/Slim/zipball/200c6143f15baa477601879b64ab2326847aac0b",
-                "reference": "200c6143f15baa477601879b64ab2326847aac0b",
-                "shasum": ""
-            },
-            "require": {
-                "container-interop/container-interop": "^1.2",
-                "ext-json": "*",
-                "ext-libxml": "*",
-                "ext-simplexml": "*",
-                "nikic/fast-route": "^1.0",
-                "php": ">=5.5.0",
-                "pimple/pimple": "^3.0",
-                "psr/container": "^1.0",
-                "psr/http-message": "^1.0"
-            },
-            "provide": {
-                "psr/http-message-implementation": "1.0"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^4.0",
-                "squizlabs/php_codesniffer": "^2.5"
-            },
-            "type": "library",
-            "autoload": {
-                "psr-4": {
-                    "Slim\\": "Slim"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Josh Lockhart",
-                    "email": "hello at joshlockhart.com",
-                    "homepage": "https://joshlockhart.com"
-                },
-                {
-                    "name": "Andrew Smith",
-                    "email": "a.smith at silentworks.co.uk",
-                    "homepage": "http://silentworks.co.uk"
-                },
-                {
-                    "name": "Rob Allen",
-                    "email": "rob at akrabat.com",
-                    "homepage": "http://akrabat.com"
-                },
-                {
-                    "name": "Gabriel Manricks",
-                    "email": "gmanricks at me.com",
-                    "homepage": "http://gabrielmanricks.com"
-                }
-            ],
-            "description": "Slim is a PHP micro framework that helps you quickly write simple yet powerful web applications and APIs",
-            "homepage": "https://slimframework.com",
-            "keywords": [
-                "api",
-                "framework",
-                "micro",
-                "router"
-            ],
-            "time": "2019-08-20T18:46:05+00:00"
-        }
-    ],
-    "packages-dev": [
-        {
-            "name": "doctrine/instantiator",
-            "version": "1.2.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/doctrine/instantiator.git",
-                "reference": "a2c590166b2133a4633738648b6b064edae0814a"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/doctrine/instantiator/zipball/a2c590166b2133a4633738648b6b064edae0814a",
-                "reference": "a2c590166b2133a4633738648b6b064edae0814a",
-                "shasum": ""
-            },
-            "require": {
-                "php": "^7.1"
-            },
-            "require-dev": {
-                "doctrine/coding-standard": "^6.0",
-                "ext-pdo": "*",
-                "ext-phar": "*",
-                "phpbench/phpbench": "^0.13",
-                "phpstan/phpstan-phpunit": "^0.11",
-                "phpstan/phpstan-shim": "^0.11",
-                "phpunit/phpunit": "^7.0"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "1.2.x-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Marco Pivetta",
-                    "email": "ocramius at gmail.com",
-                    "homepage": "http://ocramius.github.com/"
-                }
-            ],
-            "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors",
-            "homepage": "https://www.doctrine-project.org/projects/instantiator.html",
-            "keywords": [
-                "constructor",
-                "instantiate"
-            ],
-            "time": "2019-03-17T17:37:11+00:00"
-        },
-        {
-            "name": "myclabs/deep-copy",
-            "version": "1.9.3",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/myclabs/DeepCopy.git",
-                "reference": "007c053ae6f31bba39dfa19a7726f56e9763bbea"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/007c053ae6f31bba39dfa19a7726f56e9763bbea",
-                "reference": "007c053ae6f31bba39dfa19a7726f56e9763bbea",
-                "shasum": ""
-            },
-            "require": {
-                "php": "^7.1"
-            },
-            "replace": {
-                "myclabs/deep-copy": "self.version"
-            },
-            "require-dev": {
-                "doctrine/collections": "^1.0",
-                "doctrine/common": "^2.6",
-                "phpunit/phpunit": "^7.1"
-            },
-            "type": "library",
-            "autoload": {
-                "psr-4": {
-                    "DeepCopy\\": "src/DeepCopy/"
-                },
-                "files": [
-                    "src/DeepCopy/deep_copy.php"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "description": "Create deep copies (clones) of your objects",
-            "keywords": [
-                "clone",
-                "copy",
-                "duplicate",
-                "object",
-                "object graph"
-            ],
-            "time": "2019-08-09T12:45:53+00:00"
-        },
-        {
-            "name": "phar-io/manifest",
-            "version": "1.0.3",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/phar-io/manifest.git",
-                "reference": "7761fcacf03b4d4f16e7ccb606d4879ca431fcf4"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/phar-io/manifest/zipball/7761fcacf03b4d4f16e7ccb606d4879ca431fcf4",
-                "reference": "7761fcacf03b4d4f16e7ccb606d4879ca431fcf4",
-                "shasum": ""
-            },
-            "require": {
-                "ext-dom": "*",
-                "ext-phar": "*",
-                "phar-io/version": "^2.0",
-                "php": "^5.6 || ^7.0"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "1.0.x-dev"
-                }
-            },
-            "autoload": {
-                "classmap": [
-                    "src/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Arne Blankerts",
-                    "email": "arne at blankerts.de",
-                    "role": "Developer"
-                },
-                {
-                    "name": "Sebastian Heuer",
-                    "email": "sebastian at phpeople.de",
-                    "role": "Developer"
-                },
-                {
-                    "name": "Sebastian Bergmann",
-                    "email": "sebastian at phpunit.de",
-                    "role": "Developer"
-                }
-            ],
-            "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)",
-            "time": "2018-07-08T19:23:20+00:00"
-        },
-        {
-            "name": "phar-io/version",
-            "version": "2.0.1",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/phar-io/version.git",
-                "reference": "45a2ec53a73c70ce41d55cedef9063630abaf1b6"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/phar-io/version/zipball/45a2ec53a73c70ce41d55cedef9063630abaf1b6",
-                "reference": "45a2ec53a73c70ce41d55cedef9063630abaf1b6",
-                "shasum": ""
-            },
-            "require": {
-                "php": "^5.6 || ^7.0"
-            },
-            "type": "library",
-            "autoload": {
-                "classmap": [
-                    "src/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Arne Blankerts",
-                    "email": "arne at blankerts.de",
-                    "role": "Developer"
-                },
-                {
-                    "name": "Sebastian Heuer",
-                    "email": "sebastian at phpeople.de",
-                    "role": "Developer"
-                },
-                {
-                    "name": "Sebastian Bergmann",
-                    "email": "sebastian at phpunit.de",
-                    "role": "Developer"
-                }
-            ],
-            "description": "Library for handling version information and constraints",
-            "time": "2018-07-08T19:19:57+00:00"
-        },
-        {
-            "name": "phpdocumentor/reflection-common",
-            "version": "2.0.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/phpDocumentor/ReflectionCommon.git",
-                "reference": "63a995caa1ca9e5590304cd845c15ad6d482a62a"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/63a995caa1ca9e5590304cd845c15ad6d482a62a",
-                "reference": "63a995caa1ca9e5590304cd845c15ad6d482a62a",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=7.1"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "~6"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "2.x-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "phpDocumentor\\Reflection\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Jaap van Otterdijk",
-                    "email": "opensource at ijaap.nl"
-                }
-            ],
-            "description": "Common reflection classes used by phpdocumentor to reflect the code structure",
-            "homepage": "http://www.phpdoc.org",
-            "keywords": [
-                "FQSEN",
-                "phpDocumentor",
-                "phpdoc",
-                "reflection",
-                "static analysis"
-            ],
-            "time": "2018-08-07T13:53:10+00:00"
-        },
-        {
-            "name": "phpdocumentor/reflection-docblock",
-            "version": "4.3.2",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git",
-                "reference": "b83ff7cfcfee7827e1e78b637a5904fe6a96698e"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/b83ff7cfcfee7827e1e78b637a5904fe6a96698e",
-                "reference": "b83ff7cfcfee7827e1e78b637a5904fe6a96698e",
-                "shasum": ""
-            },
-            "require": {
-                "php": "^7.0",
-                "phpdocumentor/reflection-common": "^1.0.0 || ^2.0.0",
-                "phpdocumentor/type-resolver": "~0.4 || ^1.0.0",
-                "webmozart/assert": "^1.0"
-            },
-            "require-dev": {
-                "doctrine/instantiator": "^1.0.5",
-                "mockery/mockery": "^1.0",
-                "phpunit/phpunit": "^6.4"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "4.x-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "phpDocumentor\\Reflection\\": [
-                        "src/"
-                    ]
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Mike van Riel",
-                    "email": "me at mikevanriel.com"
-                }
-            ],
-            "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.",
-            "time": "2019-09-12T14:27:41+00:00"
-        },
-        {
-            "name": "phpdocumentor/type-resolver",
-            "version": "1.0.1",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/phpDocumentor/TypeResolver.git",
-                "reference": "2e32a6d48972b2c1976ed5d8967145b6cec4a4a9"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/2e32a6d48972b2c1976ed5d8967145b6cec4a4a9",
-                "reference": "2e32a6d48972b2c1976ed5d8967145b6cec4a4a9",
-                "shasum": ""
-            },
-            "require": {
-                "php": "^7.1",
-                "phpdocumentor/reflection-common": "^2.0"
-            },
-            "require-dev": {
-                "ext-tokenizer": "^7.1",
-                "mockery/mockery": "~1",
-                "phpunit/phpunit": "^7.0"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "1.x-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "phpDocumentor\\Reflection\\": "src"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Mike van Riel",
-                    "email": "me at mikevanriel.com"
-                }
-            ],
-            "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names",
-            "time": "2019-08-22T18:11:29+00:00"
-        },
-        {
-            "name": "phpspec/prophecy",
-            "version": "1.9.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/phpspec/prophecy.git",
-                "reference": "f6811d96d97bdf400077a0cc100ae56aa32b9203"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/phpspec/prophecy/zipball/f6811d96d97bdf400077a0cc100ae56aa32b9203",
-                "reference": "f6811d96d97bdf400077a0cc100ae56aa32b9203",
-                "shasum": ""
-            },
-            "require": {
-                "doctrine/instantiator": "^1.0.2",
-                "php": "^5.3|^7.0",
-                "phpdocumentor/reflection-docblock": "^2.0|^3.0.2|^4.0|^5.0",
-                "sebastian/comparator": "^1.1|^2.0|^3.0",
-                "sebastian/recursion-context": "^1.0|^2.0|^3.0"
-            },
-            "require-dev": {
-                "phpspec/phpspec": "^2.5|^3.2",
-                "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.5 || ^7.1"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "1.8.x-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Prophecy\\": "src/Prophecy"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Konstantin Kudryashov",
-                    "email": "ever.zet at gmail.com",
-                    "homepage": "http://everzet.com"
-                },
-                {
-                    "name": "Marcello Duarte",
-                    "email": "marcello.duarte at gmail.com"
-                }
-            ],
-            "description": "Highly opinionated mocking framework for PHP 5.3+",
-            "homepage": "https://github.com/phpspec/prophecy",
-            "keywords": [
-                "Double",
-                "Dummy",
-                "fake",
-                "mock",
-                "spy",
-                "stub"
-            ],
-            "time": "2019-10-03T11:07:50+00:00"
-        },
-        {
-            "name": "phpunit/php-code-coverage",
-            "version": "6.1.4",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/sebastianbergmann/php-code-coverage.git",
-                "reference": "807e6013b00af69b6c5d9ceb4282d0393dbb9d8d"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/807e6013b00af69b6c5d9ceb4282d0393dbb9d8d",
-                "reference": "807e6013b00af69b6c5d9ceb4282d0393dbb9d8d",
-                "shasum": ""
-            },
-            "require": {
-                "ext-dom": "*",
-                "ext-xmlwriter": "*",
-                "php": "^7.1",
-                "phpunit/php-file-iterator": "^2.0",
-                "phpunit/php-text-template": "^1.2.1",
-                "phpunit/php-token-stream": "^3.0",
-                "sebastian/code-unit-reverse-lookup": "^1.0.1",
-                "sebastian/environment": "^3.1 || ^4.0",
-                "sebastian/version": "^2.0.1",
-                "theseer/tokenizer": "^1.1"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^7.0"
-            },
-            "suggest": {
-                "ext-xdebug": "^2.6.0"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "6.1-dev"
-                }
-            },
-            "autoload": {
-                "classmap": [
-                    "src/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Sebastian Bergmann",
-                    "email": "sebastian at phpunit.de",
-                    "role": "lead"
-                }
-            ],
-            "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.",
-            "homepage": "https://github.com/sebastianbergmann/php-code-coverage",
-            "keywords": [
-                "coverage",
-                "testing",
-                "xunit"
-            ],
-            "time": "2018-10-31T16:06:48+00:00"
-        },
-        {
-            "name": "phpunit/php-file-iterator",
-            "version": "2.0.2",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/sebastianbergmann/php-file-iterator.git",
-                "reference": "050bedf145a257b1ff02746c31894800e5122946"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/050bedf145a257b1ff02746c31894800e5122946",
-                "reference": "050bedf145a257b1ff02746c31894800e5122946",
-                "shasum": ""
-            },
-            "require": {
-                "php": "^7.1"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^7.1"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "2.0.x-dev"
-                }
-            },
-            "autoload": {
-                "classmap": [
-                    "src/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Sebastian Bergmann",
-                    "email": "sebastian at phpunit.de",
-                    "role": "lead"
-                }
-            ],
-            "description": "FilterIterator implementation that filters files based on a list of suffixes.",
-            "homepage": "https://github.com/sebastianbergmann/php-file-iterator/",
-            "keywords": [
-                "filesystem",
-                "iterator"
-            ],
-            "time": "2018-09-13T20:33:42+00:00"
-        },
-        {
-            "name": "phpunit/php-text-template",
-            "version": "1.2.1",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/sebastianbergmann/php-text-template.git",
-                "reference": "31f8b717e51d9a2afca6c9f046f5d69fc27c8686"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/31f8b717e51d9a2afca6c9f046f5d69fc27c8686",
-                "reference": "31f8b717e51d9a2afca6c9f046f5d69fc27c8686",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=5.3.3"
-            },
-            "type": "library",
-            "autoload": {
-                "classmap": [
-                    "src/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Sebastian Bergmann",
-                    "email": "sebastian at phpunit.de",
-                    "role": "lead"
-                }
-            ],
-            "description": "Simple template engine.",
-            "homepage": "https://github.com/sebastianbergmann/php-text-template/",
-            "keywords": [
-                "template"
-            ],
-            "time": "2015-06-21T13:50:34+00:00"
-        },
-        {
-            "name": "phpunit/php-timer",
-            "version": "2.1.2",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/sebastianbergmann/php-timer.git",
-                "reference": "1038454804406b0b5f5f520358e78c1c2f71501e"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/1038454804406b0b5f5f520358e78c1c2f71501e",
-                "reference": "1038454804406b0b5f5f520358e78c1c2f71501e",
-                "shasum": ""
-            },
-            "require": {
-                "php": "^7.1"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^7.0"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "2.1-dev"
-                }
-            },
-            "autoload": {
-                "classmap": [
-                    "src/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Sebastian Bergmann",
-                    "email": "sebastian at phpunit.de",
-                    "role": "lead"
-                }
-            ],
-            "description": "Utility class for timing",
-            "homepage": "https://github.com/sebastianbergmann/php-timer/",
-            "keywords": [
-                "timer"
-            ],
-            "time": "2019-06-07T04:22:29+00:00"
-        },
-        {
-            "name": "phpunit/php-token-stream",
-            "version": "3.1.1",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/sebastianbergmann/php-token-stream.git",
-                "reference": "995192df77f63a59e47f025390d2d1fdf8f425ff"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/995192df77f63a59e47f025390d2d1fdf8f425ff",
-                "reference": "995192df77f63a59e47f025390d2d1fdf8f425ff",
-                "shasum": ""
-            },
-            "require": {
-                "ext-tokenizer": "*",
-                "php": "^7.1"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^7.0"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "3.1-dev"
-                }
-            },
-            "autoload": {
-                "classmap": [
-                    "src/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Sebastian Bergmann",
-                    "email": "sebastian at phpunit.de"
-                }
-            ],
-            "description": "Wrapper around PHP's tokenizer extension.",
-            "homepage": "https://github.com/sebastianbergmann/php-token-stream/",
-            "keywords": [
-                "tokenizer"
-            ],
-            "time": "2019-09-17T06:23:10+00:00"
-        },
-        {
-            "name": "phpunit/phpunit",
-            "version": "7.5.16",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/sebastianbergmann/phpunit.git",
-                "reference": "316afa6888d2562e04aeb67ea7f2017a0eb41661"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/316afa6888d2562e04aeb67ea7f2017a0eb41661",
-                "reference": "316afa6888d2562e04aeb67ea7f2017a0eb41661",
-                "shasum": ""
-            },
-            "require": {
-                "doctrine/instantiator": "^1.1",
-                "ext-dom": "*",
-                "ext-json": "*",
-                "ext-libxml": "*",
-                "ext-mbstring": "*",
-                "ext-xml": "*",
-                "myclabs/deep-copy": "^1.7",
-                "phar-io/manifest": "^1.0.2",
-                "phar-io/version": "^2.0",
-                "php": "^7.1",
-                "phpspec/prophecy": "^1.7",
-                "phpunit/php-code-coverage": "^6.0.7",
-                "phpunit/php-file-iterator": "^2.0.1",
-                "phpunit/php-text-template": "^1.2.1",
-                "phpunit/php-timer": "^2.1",
-                "sebastian/comparator": "^3.0",
-                "sebastian/diff": "^3.0",
-                "sebastian/environment": "^4.0",
-                "sebastian/exporter": "^3.1",
-                "sebastian/global-state": "^2.0",
-                "sebastian/object-enumerator": "^3.0.3",
-                "sebastian/resource-operations": "^2.0",
-                "sebastian/version": "^2.0.1"
-            },
-            "conflict": {
-                "phpunit/phpunit-mock-objects": "*"
-            },
-            "require-dev": {
-                "ext-pdo": "*"
-            },
-            "suggest": {
-                "ext-soap": "*",
-                "ext-xdebug": "*",
-                "phpunit/php-invoker": "^2.0"
-            },
-            "bin": [
-                "phpunit"
-            ],
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "7.5-dev"
-                }
-            },
-            "autoload": {
-                "classmap": [
-                    "src/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Sebastian Bergmann",
-                    "email": "sebastian at phpunit.de",
-                    "role": "lead"
-                }
-            ],
-            "description": "The PHP Unit Testing framework.",
-            "homepage": "https://phpunit.de/",
-            "keywords": [
-                "phpunit",
-                "testing",
-                "xunit"
-            ],
-            "time": "2019-09-14T09:08:39+00:00"
-        },
-        {
-            "name": "sebastian/code-unit-reverse-lookup",
-            "version": "1.0.1",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git",
-                "reference": "4419fcdb5eabb9caa61a27c7a1db532a6b55dd18"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/4419fcdb5eabb9caa61a27c7a1db532a6b55dd18",
-                "reference": "4419fcdb5eabb9caa61a27c7a1db532a6b55dd18",
-                "shasum": ""
-            },
-            "require": {
-                "php": "^5.6 || ^7.0"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^5.7 || ^6.0"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "1.0.x-dev"
-                }
-            },
-            "autoload": {
-                "classmap": [
-                    "src/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Sebastian Bergmann",
-                    "email": "sebastian at phpunit.de"
-                }
-            ],
-            "description": "Looks up which function or method a line of code belongs to",
-            "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/",
-            "time": "2017-03-04T06:30:41+00:00"
-        },
-        {
-            "name": "sebastian/comparator",
-            "version": "3.0.2",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/sebastianbergmann/comparator.git",
-                "reference": "5de4fc177adf9bce8df98d8d141a7559d7ccf6da"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/5de4fc177adf9bce8df98d8d141a7559d7ccf6da",
-                "reference": "5de4fc177adf9bce8df98d8d141a7559d7ccf6da",
-                "shasum": ""
-            },
-            "require": {
-                "php": "^7.1",
-                "sebastian/diff": "^3.0",
-                "sebastian/exporter": "^3.1"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^7.1"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "3.0-dev"
-                }
-            },
-            "autoload": {
-                "classmap": [
-                    "src/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Jeff Welch",
-                    "email": "whatthejeff at gmail.com"
-                },
-                {
-                    "name": "Volker Dusch",
-                    "email": "github at wallbash.com"
-                },
-                {
-                    "name": "Bernhard Schussek",
-                    "email": "bschussek at 2bepublished.at"
-                },
-                {
-                    "name": "Sebastian Bergmann",
-                    "email": "sebastian at phpunit.de"
-                }
-            ],
-            "description": "Provides the functionality to compare PHP values for equality",
-            "homepage": "https://github.com/sebastianbergmann/comparator",
-            "keywords": [
-                "comparator",
-                "compare",
-                "equality"
-            ],
-            "time": "2018-07-12T15:12:46+00:00"
-        },
-        {
-            "name": "sebastian/diff",
-            "version": "3.0.2",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/sebastianbergmann/diff.git",
-                "reference": "720fcc7e9b5cf384ea68d9d930d480907a0c1a29"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/720fcc7e9b5cf384ea68d9d930d480907a0c1a29",
-                "reference": "720fcc7e9b5cf384ea68d9d930d480907a0c1a29",
-                "shasum": ""
-            },
-            "require": {
-                "php": "^7.1"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^7.5 || ^8.0",
-                "symfony/process": "^2 || ^3.3 || ^4"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "3.0-dev"
-                }
-            },
-            "autoload": {
-                "classmap": [
-                    "src/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Kore Nordmann",
-                    "email": "mail at kore-nordmann.de"
-                },
-                {
-                    "name": "Sebastian Bergmann",
-                    "email": "sebastian at phpunit.de"
-                }
-            ],
-            "description": "Diff implementation",
-            "homepage": "https://github.com/sebastianbergmann/diff",
-            "keywords": [
-                "diff",
-                "udiff",
-                "unidiff",
-                "unified diff"
-            ],
-            "time": "2019-02-04T06:01:07+00:00"
-        },
-        {
-            "name": "sebastian/environment",
-            "version": "4.2.2",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/sebastianbergmann/environment.git",
-                "reference": "f2a2c8e1c97c11ace607a7a667d73d47c19fe404"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/f2a2c8e1c97c11ace607a7a667d73d47c19fe404",
-                "reference": "f2a2c8e1c97c11ace607a7a667d73d47c19fe404",
-                "shasum": ""
-            },
-            "require": {
-                "php": "^7.1"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^7.5"
-            },
-            "suggest": {
-                "ext-posix": "*"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "4.2-dev"
-                }
-            },
-            "autoload": {
-                "classmap": [
-                    "src/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Sebastian Bergmann",
-                    "email": "sebastian at phpunit.de"
-                }
-            ],
-            "description": "Provides functionality to handle HHVM/PHP environments",
-            "homepage": "http://www.github.com/sebastianbergmann/environment",
-            "keywords": [
-                "Xdebug",
-                "environment",
-                "hhvm"
-            ],
-            "time": "2019-05-05T09:05:15+00:00"
-        },
-        {
-            "name": "sebastian/exporter",
-            "version": "3.1.2",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/sebastianbergmann/exporter.git",
-                "reference": "68609e1261d215ea5b21b7987539cbfbe156ec3e"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/68609e1261d215ea5b21b7987539cbfbe156ec3e",
-                "reference": "68609e1261d215ea5b21b7987539cbfbe156ec3e",
-                "shasum": ""
-            },
-            "require": {
-                "php": "^7.0",
-                "sebastian/recursion-context": "^3.0"
-            },
-            "require-dev": {
-                "ext-mbstring": "*",
-                "phpunit/phpunit": "^6.0"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "3.1.x-dev"
-                }
-            },
-            "autoload": {
-                "classmap": [
-                    "src/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Sebastian Bergmann",
-                    "email": "sebastian at phpunit.de"
-                },
-                {
-                    "name": "Jeff Welch",
-                    "email": "whatthejeff at gmail.com"
-                },
-                {
-                    "name": "Volker Dusch",
-                    "email": "github at wallbash.com"
-                },
-                {
-                    "name": "Adam Harvey",
-                    "email": "aharvey at php.net"
-                },
-                {
-                    "name": "Bernhard Schussek",
-                    "email": "bschussek at gmail.com"
-                }
-            ],
-            "description": "Provides the functionality to export PHP variables for visualization",
-            "homepage": "http://www.github.com/sebastianbergmann/exporter",
-            "keywords": [
-                "export",
-                "exporter"
-            ],
-            "time": "2019-09-14T09:02:43+00:00"
-        },
-        {
-            "name": "sebastian/global-state",
-            "version": "2.0.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/sebastianbergmann/global-state.git",
-                "reference": "e8ba02eed7bbbb9e59e43dedd3dddeff4a56b0c4"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/e8ba02eed7bbbb9e59e43dedd3dddeff4a56b0c4",
-                "reference": "e8ba02eed7bbbb9e59e43dedd3dddeff4a56b0c4",
-                "shasum": ""
-            },
-            "require": {
-                "php": "^7.0"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^6.0"
-            },
-            "suggest": {
-                "ext-uopz": "*"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "2.0-dev"
-                }
-            },
-            "autoload": {
-                "classmap": [
-                    "src/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Sebastian Bergmann",
-                    "email": "sebastian at phpunit.de"
-                }
-            ],
-            "description": "Snapshotting of global state",
-            "homepage": "http://www.github.com/sebastianbergmann/global-state",
-            "keywords": [
-                "global state"
-            ],
-            "time": "2017-04-27T15:39:26+00:00"
-        },
-        {
-            "name": "sebastian/object-enumerator",
-            "version": "3.0.3",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/sebastianbergmann/object-enumerator.git",
-                "reference": "7cfd9e65d11ffb5af41198476395774d4c8a84c5"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/7cfd9e65d11ffb5af41198476395774d4c8a84c5",
-                "reference": "7cfd9e65d11ffb5af41198476395774d4c8a84c5",
-                "shasum": ""
-            },
-            "require": {
-                "php": "^7.0",
-                "sebastian/object-reflector": "^1.1.1",
-                "sebastian/recursion-context": "^3.0"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^6.0"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "3.0.x-dev"
-                }
-            },
-            "autoload": {
-                "classmap": [
-                    "src/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Sebastian Bergmann",
-                    "email": "sebastian at phpunit.de"
-                }
-            ],
-            "description": "Traverses array structures and object graphs to enumerate all referenced objects",
-            "homepage": "https://github.com/sebastianbergmann/object-enumerator/",
-            "time": "2017-08-03T12:35:26+00:00"
-        },
-        {
-            "name": "sebastian/object-reflector",
-            "version": "1.1.1",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/sebastianbergmann/object-reflector.git",
-                "reference": "773f97c67f28de00d397be301821b06708fca0be"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/773f97c67f28de00d397be301821b06708fca0be",
-                "reference": "773f97c67f28de00d397be301821b06708fca0be",
-                "shasum": ""
-            },
-            "require": {
-                "php": "^7.0"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^6.0"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "1.1-dev"
-                }
-            },
-            "autoload": {
-                "classmap": [
-                    "src/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Sebastian Bergmann",
-                    "email": "sebastian at phpunit.de"
-                }
-            ],
-            "description": "Allows reflection of object attributes, including inherited and non-public ones",
-            "homepage": "https://github.com/sebastianbergmann/object-reflector/",
-            "time": "2017-03-29T09:07:27+00:00"
-        },
-        {
-            "name": "sebastian/recursion-context",
-            "version": "3.0.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/sebastianbergmann/recursion-context.git",
-                "reference": "5b0cd723502bac3b006cbf3dbf7a1e3fcefe4fa8"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/5b0cd723502bac3b006cbf3dbf7a1e3fcefe4fa8",
-                "reference": "5b0cd723502bac3b006cbf3dbf7a1e3fcefe4fa8",
-                "shasum": ""
-            },
-            "require": {
-                "php": "^7.0"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^6.0"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "3.0.x-dev"
-                }
-            },
-            "autoload": {
-                "classmap": [
-                    "src/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Jeff Welch",
-                    "email": "whatthejeff at gmail.com"
-                },
-                {
-                    "name": "Sebastian Bergmann",
-                    "email": "sebastian at phpunit.de"
-                },
-                {
-                    "name": "Adam Harvey",
-                    "email": "aharvey at php.net"
-                }
-            ],
-            "description": "Provides functionality to recursively process PHP variables",
-            "homepage": "http://www.github.com/sebastianbergmann/recursion-context",
-            "time": "2017-03-03T06:23:57+00:00"
-        },
-        {
-            "name": "sebastian/resource-operations",
-            "version": "2.0.1",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/sebastianbergmann/resource-operations.git",
-                "reference": "4d7a795d35b889bf80a0cc04e08d77cedfa917a9"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/4d7a795d35b889bf80a0cc04e08d77cedfa917a9",
-                "reference": "4d7a795d35b889bf80a0cc04e08d77cedfa917a9",
-                "shasum": ""
-            },
-            "require": {
-                "php": "^7.1"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "2.0-dev"
-                }
-            },
-            "autoload": {
-                "classmap": [
-                    "src/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Sebastian Bergmann",
-                    "email": "sebastian at phpunit.de"
-                }
-            ],
-            "description": "Provides a list of PHP built-in functions that operate on resources",
-            "homepage": "https://www.github.com/sebastianbergmann/resource-operations",
-            "time": "2018-10-04T04:07:39+00:00"
-        },
-        {
-            "name": "sebastian/version",
-            "version": "2.0.1",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/sebastianbergmann/version.git",
-                "reference": "99732be0ddb3361e16ad77b68ba41efc8e979019"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/99732be0ddb3361e16ad77b68ba41efc8e979019",
-                "reference": "99732be0ddb3361e16ad77b68ba41efc8e979019",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=5.6"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "2.0.x-dev"
-                }
-            },
-            "autoload": {
-                "classmap": [
-                    "src/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Sebastian Bergmann",
-                    "email": "sebastian at phpunit.de",
-                    "role": "lead"
-                }
-            ],
-            "description": "Library that helps with managing the version number of Git-hosted PHP projects",
-            "homepage": "https://github.com/sebastianbergmann/version",
-            "time": "2016-10-03T07:35:21+00:00"
-        },
-        {
-            "name": "squizlabs/php_codesniffer",
-            "version": "3.5.1",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/squizlabs/PHP_CodeSniffer.git",
-                "reference": "82cd0f854ceca17731d6d019c7098e3755c45060"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/82cd0f854ceca17731d6d019c7098e3755c45060",
-                "reference": "82cd0f854ceca17731d6d019c7098e3755c45060",
-                "shasum": ""
-            },
-            "require": {
-                "ext-simplexml": "*",
-                "ext-tokenizer": "*",
-                "ext-xmlwriter": "*",
-                "php": ">=5.4.0"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0"
-            },
-            "bin": [
-                "bin/phpcs",
-                "bin/phpcbf"
-            ],
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "3.x-dev"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Greg Sherwood",
-                    "role": "lead"
-                }
-            ],
-            "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.",
-            "homepage": "https://github.com/squizlabs/PHP_CodeSniffer",
-            "keywords": [
-                "phpcs",
-                "standards"
-            ],
-            "time": "2019-10-16T21:14:26+00:00"
-        },
-        {
-            "name": "symfony/polyfill-ctype",
-            "version": "v1.12.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/symfony/polyfill-ctype.git",
-                "reference": "550ebaac289296ce228a706d0867afc34687e3f4"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/550ebaac289296ce228a706d0867afc34687e3f4",
-                "reference": "550ebaac289296ce228a706d0867afc34687e3f4",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=5.3.3"
-            },
-            "suggest": {
-                "ext-ctype": "For best performance"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "1.12-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Symfony\\Polyfill\\Ctype\\": ""
-                },
-                "files": [
-                    "bootstrap.php"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Gert de Pagter",
-                    "email": "BackEndTea at gmail.com"
-                },
-                {
-                    "name": "Symfony Community",
-                    "homepage": "https://symfony.com/contributors"
-                }
-            ],
-            "description": "Symfony polyfill for ctype functions",
-            "homepage": "https://symfony.com",
-            "keywords": [
-                "compatibility",
-                "ctype",
-                "polyfill",
-                "portable"
-            ],
-            "time": "2019-08-06T08:03:45+00:00"
-        },
-        {
-            "name": "theseer/tokenizer",
-            "version": "1.1.3",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/theseer/tokenizer.git",
-                "reference": "11336f6f84e16a720dae9d8e6ed5019efa85a0f9"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/theseer/tokenizer/zipball/11336f6f84e16a720dae9d8e6ed5019efa85a0f9",
-                "reference": "11336f6f84e16a720dae9d8e6ed5019efa85a0f9",
-                "shasum": ""
-            },
-            "require": {
-                "ext-dom": "*",
-                "ext-tokenizer": "*",
-                "ext-xmlwriter": "*",
-                "php": "^7.0"
-            },
-            "type": "library",
-            "autoload": {
-                "classmap": [
-                    "src/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Arne Blankerts",
-                    "email": "arne at blankerts.de",
-                    "role": "Developer"
-                }
-            ],
-            "description": "A small library for converting tokenized PHP source code into XML and potentially other formats",
-            "time": "2019-06-13T22:48:21+00:00"
-        },
-        {
-            "name": "webmozart/assert",
-            "version": "1.5.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/webmozart/assert.git",
-                "reference": "88e6d84706d09a236046d686bbea96f07b3a34f4"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/webmozart/assert/zipball/88e6d84706d09a236046d686bbea96f07b3a34f4",
-                "reference": "88e6d84706d09a236046d686bbea96f07b3a34f4",
-                "shasum": ""
-            },
-            "require": {
-                "php": "^5.3.3 || ^7.0",
-                "symfony/polyfill-ctype": "^1.8"
-            },
-            "require-dev": {
-                "phpunit/phpunit": "^4.8.36 || ^7.5.13"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "1.3-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Webmozart\\Assert\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Bernhard Schussek",
-                    "email": "bschussek at gmail.com"
-                }
-            ],
-            "description": "Assertions to validate method input/output with nice error messages.",
-            "keywords": [
-                "assert",
-                "check",
-                "validate"
-            ],
-            "time": "2019-08-24T08:43:50+00:00"
-        }
-    ],
-    "aliases": [],
-    "minimum-stability": "stable",
-    "stability-flags": [],
-    "prefer-stable": false,
-    "prefer-lowest": false,
-    "platform": {
-        "php": ">=5.6"
-    },
-    "platform-dev": []
-}
diff --git a/docker-compose.yml b/docker-compose.yml
index e77790d..574361b 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,18 +1,16 @@
-version: '2'
-
-volumes:
-    logs:
-        driver: local
-
+version: "3.9"
 services:
-    slim:
-        image: php:7-alpine
-        working_dir: /var/www
-        command: php -S 0.0.0.0:8080 -t public
-        environment:
-            docker: "true"
-        ports:
-            - 8080:8080
-        volumes:
-            - .:/var/www
-            - logs:/var/www/logs
+  server:
+    build: .
+    environment:
+      - REDIS_HOST=redis
+    ports:
+      - "9120:9120/udp"
+  web:
+    build: ./web/
+    environment:
+      - "REDIS_URL=redis://redis:6379"
+    ports:
+      - "80:80"
+  redis:
+    image: "redis:latest"
\ No newline at end of file
diff --git a/logs/README.md b/logs/README.md
deleted file mode 100644
index d4a602e..0000000
--- a/logs/README.md
+++ /dev/null
@@ -1 +0,0 @@
-Your Slim Framework application's log files will be written to this directory.
diff --git a/main.py b/main.py
new file mode 100644
index 0000000..d3cda69
--- /dev/null
+++ b/main.py
@@ -0,0 +1,182 @@
+import json
+import logging
+import os
+import signal
+
+import enet
+import redis as redis_package
+
+# Games to accept:
+GAMES = [
+	"moonbase" # Moonbase Commander (v1.0/v1.1/Demo)
+]
+
+# Version variants to accept for a specific game.
+# If none exist but game exist in the GAMES list,
+# that means there's only one game version.
+VERSIONS = {
+	"moonbase": ["1.0", "1.1", "Demo"]
+}
+
+def get_full_game_names():
+	games = []
+	for game in GAMES:
+		versions = VERSIONS.get(game)
+		if versions:
+			for version in versions:
+				games.append(f"{game}:{version}")
+		else:
+			games.append(game)
+	return games
+
+if os.environ.get("DEBUG"):
+	logging.basicConfig(level=logging.DEBUG)
+
+redis = redis_package.Redis(os.environ.get("REDIS_HOST", "127.0.0.1"),
+							retry_on_timeout=True, decode_responses=True)
+for game in get_full_game_names():
+	# Reset session counter
+	redis.set(f"{game}:counter", 0)
+	if redis.exists(f"{game}:sessions"):
+		# Clear out the sessions
+		logging.info(f"Clearing out {game} sessions")
+		for session_id in range(redis.llen(f"{game}:sessions")):
+			redis.delete(f"{game}.session:{session_id}")
+		redis.delete(f"{game}:sessions")
+		redis.delete(f"{game}:sessionByAddress")
+
+# Create our host to listen connections from.  4095 is the maxinum amount.
+host = enet.Host(enet.Address(b"0.0.0.0", 9120), peerCount=4095, channelLimit=1)
+print("Listening for messages in port 9120", flush=True)
+
+def send(peer, data):
+	logging.debug(f"{peer.address}: OUT: {data}")
+	data = json.dumps(data).encode()
+	peer.send(0, enet.Packet(data, enet.PACKET_FLAG_RELIABLE))
+
+def get_peer_by_address(address):
+	for peer in host.peers:
+		if str(peer.address) == address:
+			return peer
+	return None
+
+def get_session_by_address(game, address):
+	session_id = redis.hget(f"{game}:sessionByAddress", str(address))
+	if session_id:
+		return redis.hgetall(f"{game}:session:{session_id}")
+	return None
+
+def create_session(name, address):
+	# Get our new session ID
+	session_id = redis.incr(f"{game}:counter")
+	# Create and store our new session
+	redis.hset(f"{game}:session:{session_id}", 
+		mapping={"name": name, "players": 0, "address": str(event.peer.address)})
+	# Add session to sessions list
+	redis.rpush(f"{game}:sessions", session_id)
+
+	# Store to address to session hash
+	redis.hset(f"{game}:sessionByAddress", str(address), session_id)
+
+	logging.debug(f"{address}: NEW SESSION: \"{name}\"")
+	return session_id
+
+do_loop = True
+def exit(*args):
+	global do_loop
+	do_loop = False
+
+# For Docker, they grace stop with SIGTERM
+signal.signal(signal.SIGTERM, exit)
+# SIGINT: Ctrl+C KeyboardInterrupt
+signal.signal(signal.SIGINT, exit)
+
+while do_loop:
+	# Main event loop
+	event = host.service(1000)
+	if event.type == enet.EVENT_TYPE_CONNECT:
+		logging.debug(f"{event.peer.address}: CONNECT")
+	elif event.type == enet.EVENT_TYPE_DISCONNECT:
+		logging.debug(f"{event.peer.address}: DISCONNECT")
+		# Close out sessions relating to the address
+		for game in get_full_game_names():
+			session_id = redis.hget(f"{game}:sessionByAddress", str(event.peer.address))
+			if session_id:
+				redis.delete(f"{game}:session:{session_id}")
+				redis.lrem(f"{game}:sessions", 0, session_id)
+				redis.hdel(f"{game}:sessionByAddress", str(event.peer.address))
+
+	elif event.type == enet.EVENT_TYPE_RECEIVE:
+		logging.debug(f"{event.peer.address}: IN: {event.packet.data}")
+		# TODO: Relays
+		try:
+			data = json.loads(event.packet.data)
+		except:
+			logging.warning(f"{event.peer.address}: Received non-JSON data.")
+			continue
+		command = data.get("cmd")
+		game = data.get("game")
+		version = data.get("version")
+		if not command:
+			logging.warning(f"{event.peer.address}: Command missing")
+			continue
+		if not game:
+			logging.warning(f"{event.peer.address}: Game missing")
+			continue
+		
+		if game not in GAMES:
+			logging.warning(f"Game \"{game}\" not supported.")
+			continue
+		
+		versions = VERSIONS.get(game)
+		if versions:
+			if not version:
+				logging.warning(f"{event.peer.address}: Version missing")
+				continue
+			
+			if version not in version:
+				logging.warning(f"Game \"{game}\" with version \"{version}\" not supported.")
+				continue
+
+			# Update the game to contain the version
+			game = f"{game}:{version}"
+		
+		if command == "host_session":
+			name = data.get("name")
+
+			session_id = create_session(name, event.peer.address)
+			send(event.peer, {"cmd": "host_session_resp", "id": session_id})
+		
+		elif command == "update_players":
+			players = data.get("players")
+
+			session_id = redis.hget(f"{game}:sessionByAddress", str(event.peer.address))
+			if session_id:
+				redis.hset(f"{game}:session:{session_id}", "players", players)
+
+		elif command == "get_sessions":
+			sessions = []
+
+			num_sessions = redis.llen(f"{game}:sessions")
+			session_ids = redis.lrange(f"{game}:sessions", 0, num_sessions)
+			for id in session_ids:
+				session = redis.hgetall(f"{game}:session:{id}")
+				sessions.append({"id": int(id), "name": session["name"],
+								 "players": int(session["players"]), "address": str(session["address"])})
+			
+			send(event.peer, {"cmd": "get_sessions_resp",
+							  "address": str(event.peer.address), "sessions": sessions})
+		elif command == "join_session":
+			session_id = data.get("id")
+
+			if not (redis.exists(f"{game}:session:{id}")):
+				logging.warn(f"Session {game}:{session_id} not found")
+				continue
+
+			address = redis.hget(f"{game}:session:{id}", "address")
+			peer = get_peer_by_address(address)
+			if not peer:
+				continue
+			
+			# Send the joiner's address to the hoster for hole-punching
+			send(peer, {"cmd": "joining_session", "address": str(event.peer.address)})
diff --git a/phpunit.xml b/phpunit.xml
deleted file mode 100644
index c1441fd..0000000
--- a/phpunit.xml
+++ /dev/null
@@ -1,7 +0,0 @@
-<phpunit bootstrap="vendor/autoload.php">
-    <testsuites>
-        <testsuite name="SlimSkeleton">
-            <directory>tests</directory>
-        </testsuite>
-    </testsuites>
-</phpunit>
\ No newline at end of file
diff --git a/public/.htaccess b/public/.htaccess
deleted file mode 100644
index f5d1969..0000000
--- a/public/.htaccess
+++ /dev/null
@@ -1,21 +0,0 @@
-<IfModule mod_rewrite.c>
-  RewriteEngine On
-
-  # Some hosts may require you to use the `RewriteBase` directive.
-  # Determine the RewriteBase automatically and set it as environment variable.
-  # If you are using Apache aliases to do mass virtual hosting or installed the
-  # project in a subdirectory, the base path will be prepended to allow proper
-  # resolution of the index.php file and to redirect to the correct URI. It will
-  # work in environments without path prefix as well, providing a safe, one-size
-  # fits all solution. But as you do not need it in this case, you can comment
-  # the following 2 lines to eliminate the overhead.
-  RewriteCond %{REQUEST_URI}::$1 ^(/.+)/(.*)::\2$
-  RewriteRule ^(.*) - [E=BASE:%1]
-  
-  # If the above doesn't work you might need to set the `RewriteBase` directive manually, it should be the
-  # absolute physical path to the directory that contains this htaccess file.
-  # RewriteBase /
-
-  RewriteCond %{REQUEST_FILENAME} !-f
-  RewriteRule ^ index.php [QSA,L]
-</IfModule>
diff --git a/public/images/box.png b/public/images/box.png
deleted file mode 100644
index a7c73ca..0000000
Binary files a/public/images/box.png and /dev/null differ
diff --git a/public/images/dropbox.png b/public/images/dropbox.png
deleted file mode 100644
index f78401e..0000000
Binary files a/public/images/dropbox.png and /dev/null differ
diff --git a/public/images/google_drive.png b/public/images/google_drive.png
deleted file mode 100644
index c91d8f1..0000000
Binary files a/public/images/google_drive.png and /dev/null differ
diff --git a/public/images/onedrive.png b/public/images/onedrive.png
deleted file mode 100644
index 6b29f14..0000000
Binary files a/public/images/onedrive.png and /dev/null differ
diff --git a/public/index.php b/public/index.php
deleted file mode 100644
index 4b56727..0000000
--- a/public/index.php
+++ /dev/null
@@ -1,33 +0,0 @@
-<?php
-if (PHP_SAPI == 'cli-server') {
-    // To help the built-in PHP dev server, check if the request was actually for
-    // something which should probably be served as a static file
-    $url  = parse_url($_SERVER['REQUEST_URI']);
-    $file = __DIR__ . $url['path'];
-    if (is_file($file)) {
-        return false;
-    }
-}
-
-require __DIR__ . '/../vendor/autoload.php';
-
-session_start();
-
-// Instantiate the app
-$settings = include __DIR__ . '/../src/settings.php';
-$app = new \Slim\App($settings);
-
-// Set up dependencies
-$dependencies = include __DIR__ . '/../src/dependencies.php';
-$dependencies($app);
-
-// Register middleware
-$middleware = include __DIR__ . '/../src/middleware.php';
-$middleware($app);
-
-// Register routes
-$routes = include __DIR__ . '/../src/routes.php';
-$routes($app);
-
-// Run app
-$app->run();
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..4b09e90
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,2 @@
+pyenet==1.3.14
+redis==4.3.4
\ No newline at end of file
diff --git a/src/dependencies.php b/src/dependencies.php
deleted file mode 100644
index 8e2692e..0000000
--- a/src/dependencies.php
+++ /dev/null
@@ -1,22 +0,0 @@
-<?php
-
-use Slim\App;
-
-return function (App $app) {
-    $container = $app->getContainer();
-
-    // view renderer
-    $container['renderer'] = function ($c) {
-        $settings = $c->get('settings')['renderer'];
-        return new \Slim\Views\PhpRenderer($settings['template_path']);
-    };
-
-    // monolog
-    $container['logger'] = function ($c) {
-        $settings = $c->get('settings')['logger'];
-        $logger = new \Monolog\Logger($settings['name']);
-        $logger->pushProcessor(new \Monolog\Processor\UidProcessor());
-        $logger->pushHandler(new \Monolog\Handler\StreamHandler($settings['path'], $settings['level']));
-        return $logger;
-    };
-};
diff --git a/src/middleware.php b/src/middleware.php
deleted file mode 100644
index 53984bb..0000000
--- a/src/middleware.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-use Slim\App;
-
-return function (App $app) {
-    // e.g: $app->add(new \Slim\Csrf\Guard);
-
-    $app->add(
-        \RateLimit\Middleware\RateLimitMiddleware::createDefault(
-            \RateLimit\RateLimiterFactory::createRedisBackedRateLimiter(
-                [
-                'host' => 'localhost',
-                'port' => 6379,
-			], 500, 60
-            ),
-            [
-              'limitExceededHandler' => function ($request, $response) {
-                return $response->withJson(
-                    [
-                      'message' => 'API rate limit exceeded',
-                      ], 429
-                );
-              },
-            ]
-        )
-    );
-};
diff --git a/src/routes.php b/src/routes.php
deleted file mode 100644
index ce724c6..0000000
--- a/src/routes.php
+++ /dev/null
@@ -1,399 +0,0 @@
-<?php
-
-use Slim\App;
-use Slim\Http\Request;
-use Slim\Http\Response;
-
-define("KEYPREFIX", "moonbase");
-define("NET_SEND_TYPE_INDIVIDUAL", 1);
-define("NET_SEND_TYPE_GROUP", 2);
-define("NET_SEND_TYPE_HOST", 3);
-define("NET_SEND_TYPE_ALL", 4);
-
-function redisConnect(array $redisOptions = []) {
-	$redisOptions = array_merge([
-		'host' => '127.0.0.1',
-		'port' => 6379,
-		'timeout' => 0.0,
-	], $redisOptions);
-
-	$redis = new Redis();
-
-	$redis->connect($redisOptions['host'], $redisOptions['port'], $redisOptions['timeout']);
-
-	return $redis;
-}
-
-function getUserIpAddr() {
-	if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
-		//ip from share internet
-		$ip = $_SERVER['HTTP_CLIENT_IP'];
-	} elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
-		//ip pass from proxy
-		$ip = $_SERVER['HTTP_X_FORWARDED_FOR'];
-	} else {
-		$ip = $_SERVER['REMOTE_ADDR'];
-	}
-	return $ip;
-}
-
-return function (App $app) {
-	$container = $app->getContainer();
-
-	$app->get(
-		'/', function (Request $request, Response $response, array $args) use ($container) {
-			// Sample log message
-			$container->get('logger')->info("Slim-Skeleton '/' route");
-
-			// Render index view
-			return $container->get('renderer')->render($response, 'index.phtml', $args);
-		}
-	);
-
-	$app->post(
-		'/moonbase/createsession', function (Request $request, Response $response, array $args) use ($container) {
-			// Sample log message
-			$container->get('logger')->info("Slim-Skeleton '/moonbase/createsession' route");
-
-			$parsedBody = $request->getParsedBody();
-
-			if (array_key_exists('name', $parsedBody)) {
-				$container->get('logger')->info("Got $parsedBody[name]");
-			}
-
-			$ip = getUserIpAddr();
-
-			$redis = redisConnect();
-
-			$keys = $redis->keys(KEYPREFIX.";sessions;$ip;*");
-
-			if (!sizeof($keys)) {
-				$sessionid = rand();
-
-				$redis->setEx(KEYPREFIX.";sessions;$ip;$sessionid", 3600, "{\"sessionid\": $sessionid, \"name\": \"$parsedBody[name]\"}");
-
-				$container->get('logger')->info("Generated session $sessionid");
-			} else {
-				$session = $redis->get($keys[0]);
-
-				$par = json_decode($session);
-
-				$sessionid = $par->sessionid;
-
-				$container->get('logger')->info("Retrieved session $sessionid");
-			}
-
-			return $response->withJson(["sessionid" => $sessionid]);
-		}
-	);
-
-	$app->post(
-		'/moonbase/disablesession', function (Request $request, Response $response, array $args) use ($container) {
-			// Sample log message
-			$container->get('logger')->info("Slim-Skeleton '/moonbase/disablesession' route");
-
-			$parsedBody = $request->getParsedBody();
-
-			if (!array_key_exists('sessionid', $parsedBody)) {
-				return $response->withJson(["error" => "No sessionid specified"]);
-			}
-
-			$sessionid = $parsedBody['sessionid'];
-
-			$redis = redisConnect();
-
-			$keys = $redis->keys(KEYPREFIX.";sessions;*;$sessionid");
-
-			if (!sizeof($keys)) {
-				return $response->withJson(["error" => "Unknown sessionid $sessionid"]);
-			}
-
-			$sessionkey = $keys[0];
-
-			$session = json_decode($redis->get($sessionkey));
-
-			$session->disabled = 1;
-
-			$redis->setEx($sessionkey, 3600, json_encode($session));
-
-			return $response->withJson([]);
-		}
-	);
-
-	$app->post(
-		'/moonbase/endsession', function (Request $request, Response $response, array $args) use ($container) {
-			// Sample log message
-			$container->get('logger')->info("Slim-Skeleton '/moonbase/endsession' route");
-
-			$parsedBody = $request->getParsedBody();
-
-			if (!array_key_exists('sessionid', $parsedBody)) {
-				return $response->withJson(["error" => "No sessionid specified"]);
-			}
-
-			$sessionid = $parsedBody['sessionid'];
-			$userid = $parsedBody['userid'] ?? 0;
-
-			$redis = redisConnect();
-
-			$keys = $redis->keys(KEYPREFIX.";sessions;*;$sessionid");
-
-			if (!sizeof($keys)) {
-				return $response->withJson(["error" => "Unknown sessionid $sessionid"]);
-			}
-
-			$sessionkey = $keys[0];
-
-			$session = json_decode($redis->get($sessionkey));
-
-			if ($session->host != $userid) {
-				return $response->withJson(["error" => "Unauthorized user $userid"]);
-			}
-
-			$redis->unlink($keys);
-
-			return $response->withJson([]);
-		}
-	);
-
-	$app->post(
-		'/moonbase/adduser', function (Request $request, Response $response, array $args) use ($container) {
-			// Sample log message
-			$container->get('logger')->info("Slim-Skeleton '/moonbase/adduser' route");
-
-			$parsedBody = $request->getParsedBody();
-
-			if (!array_key_exists('sessionid', $parsedBody)) {
-				return $response->withJson(["error" => "No sessionid specified"]);
-			}
-
-			$sessionid = $parsedBody['sessionid'];
-
-			$redis = redisConnect();
-
-			$keys = $redis->keys(KEYPREFIX.";sessions;*;$sessionid");
-
-			if (!sizeof($keys)) {
-				return $response->withJson(["error" => "Unknown sessionid $sessionid"]);
-			}
-
-			$sessionkey = $keys[0];
-
-			$session = json_decode($redis->get($sessionkey));
-
-			$playerid = rand();
-			$playerkey = rand();
-
-			if (array_key_exists('players', $session)) {
-				if (sizeof($session->players) > 3) {
-					return $response->withJson(["error" => "Too many players in $sessionid"]);
-				}
-			} else {
-				$session->players = [];
-				$session->host = $playerid;
-			}
-
-			array_push($session->players, [ "shortname" => $parsedBody['shortname'],
-											"longname"  => $parsedBody['longname'],
-											"id"        => $playerid]);
-
-			$redis->setEx($sessionkey, 3600, json_encode($session));
-
-			if (sizeof($session->players) > 1) {
-				$players = json_decode($redis->get(KEYPREFIX.";players;$sessionid"));
-			} else {
-				$players = [];
-			}
-
-			array_push($players, ["id" => $playerid, "playerkey" => $playerkey]);
-
-			$redis->setEx(KEYPREFIX.";players;$sessionid", 3600, json_encode($players));
-
-			return $response->withJson(["userid" => $playerid, "playerkey" => $playerkey]);
-		}
-	);
-
-	$app->post(
-		'/moonbase/removeuser', function (Request $request, Response $response, array $args) use ($container) {
-			// Sample log message
-			$container->get('logger')->info("Slim-Skeleton '/moonbase/removeuser' route");
-
-			$parsedBody = $request->getParsedBody();
-
-			if (!array_key_exists('sessionid', $parsedBody)) {
-				return $response->withJson(["error" => "No sessionid specified"]);
-			}
-
-			$sessionid = $parsedBody['sessionid'];
-
-			if (!array_key_exists('userid', $parsedBody)) {
-				return $response->withJson(["error" => "No userid specified"]);
-			}
-
-			$userid = $parsedBody['userid'];
-
-			$redis = redisConnect();
-
-			$keys = $redis->keys(KEYPREFIX.";sessions;*;$sessionid");
-
-			if (!sizeof($keys)) {
-				return $response->withJson(["error" => "Unknown sessionid $sessionid"]);
-			}
-
-			$sessionkey = $keys[0];
-
-			$session = json_decode($redis->get($sessionkey));
-
-			for ($i = 0; $i < count($session->players); $i++) {
-				if ($session->players[$i]->id == $userid) {
-					unset($session->players[$i]);
-					$redis->setEx($sessionkey, 3600, json_encode($session));
-
-					return $response->withJson([]);
-				}
-			}
-
-			return $response->withJson(["error" => "Unknown userid $userid in session $sessionid"]);
-		}
-	);
-
-	$app->get(
-		'/moonbase/lobbies', function (Request $request, Response $response, array $args) use ($container) {
-			$container->get('logger')->info("Slim-Skeleton '/moonbase/lobbies' route");
-
-			$redis = redisConnect();
-
-			$keys = $redis->keys(KEYPREFIX.";sessions;*");
-
-			$res = [];
-
-			foreach ($keys as $key) {
-				$session = json_decode($redis->get($key));
-
-				if (!array_key_exists('disabled', $session))
-					array_push($res, $session);
-			}
-
-			return $response->withJson($res);
-		}
-	);
-
-	$app->post(
-		'/moonbase/packet', function (Request $request, Response $response, array $args) use ($container) {
-			$container->get('logger')->info("Slim-Skeleton '/moonbase/packet' route");
-
-			$container->get('logger')->info("got " .$request->getBody());
-
-			$parsedBody = $request->getParsedBody();
-
-			if (!array_key_exists('sessionid', $parsedBody)) {
-				return $response->withJson(["error" => "No sessionid specified"]);
-			}
-
-			$sessionid = $parsedBody['sessionid'];
-
-			$container->get('logger')->info("/moonbase/packet: sess: $sessionid");
-
-			// Get session
-			$redis = redisConnect();
-
-			$keys = $redis->keys(KEYPREFIX.";sessions;*;$sessionid");
-
-			if (!sizeof($keys)) {
-				return $response->withJson(["error" => "Unknown sessionid $sessionid"]);
-			}
-
-			$count = $redis->incr(KEYPREFIX.";packets;$sessionid");
-
-			$redis->setEx(KEYPREFIX.";packets;$sessionid;$count", 3600, json_encode($parsedBody));
-
-			return $response->withJson([]);
-		}
-	);
-
-	$app->post(
-		'/moonbase/getpacket', function (Request $request, Response $response, array $args) use ($container) {
-			// Sample log message
-			//$container->get('logger')->info("Slim-Skeleton '/moonbase/getpacket' route");
-
-			$parsedBody = $request->getParsedBody();
-
-			if (!array_key_exists('sessionid', $parsedBody)) {
-				return $response->withJson(["error" => "No sessionid specified"]);
-			}
-
-			$sessionid = $parsedBody['sessionid'];
-
-			if (!array_key_exists('playerid', $parsedBody)) {
-				return $response->withJson(["error" => "No playerid specified"]);
-			}
-
-			$playerid = $parsedBody['playerid'];
-
-
-			$redis = redisConnect();
-
-			$sessioncount = $redis->get(KEYPREFIX.";packets;$sessionid");
-			$playercount  = $redis->get(KEYPREFIX.";players;$sessionid;$playerid");
-
-			$keys = $redis->keys(KEYPREFIX.";sessions;*;$sessionid");
-
-			if (!sizeof($keys)) {
-				return $response->withJson(["error" => "Unknown sessionid $sessionid"]);
-			}
-
-			$sessionkey = $keys[0];
-
-			$session = json_decode($redis->get($sessionkey));
-
-			for (;;) {
-				if ($playercount >= $sessioncount)	// No more packets
-					return $response->withJson(["size" => 0]);
-
-				$playercount = $redis->incr(KEYPREFIX.";players;$sessionid;$playerid");
-
-				$container->get('logger')->info("'/moonbase/getpacket' reading packet $playercount");
-
-				if (!$redis->exists(KEYPREFIX.";packets;$sessionid;$playercount")) {
-					return $response->withJson(["error" => "Too big playercount: $playercount > $sessioncount"]);
-				}
-
-				$packet = json_decode($redis->get(KEYPREFIX.";packets;$sessionid;$playercount"));
-
-				$from = $packet->from;
-				$type = $packet->type;
-
-				$to = -1;
-				switch ($type) {
-				case NET_SEND_TYPE_INDIVIDUAL: // 1
-					$to = $packet->toparam;
-					break;
-
-				case NET_SEND_TYPE_GROUP: // 2
-					$to = -1;
-					break;
-
-				case NET_SEND_TYPE_HOST: // 3
-					$to = $session->host;
-					break;
-
-				case NET_SEND_TYPE_ALL: // 4
-				default:
-					$to = -1;
-					break;
-				}
-
-				$container->get('logger')->info("'/moonbase/getpacket' type: $type from: $from, to: $to, playerid: $playerid, size: " . $packet->size);
-
-				if (($to == -1 && $from != $playerid) || $to == $playerid) { // Send to all or to me
-					return $response->withJson($packet);
-				} else {
-					$container->get('logger')->info("'/moonbase/getpacket' not ours: to $to");
-				}
-
-				# It is not pur packet, loop over to next one
-			}
-		}
-	);
-
-};
diff --git a/src/settings.php b/src/settings.php
deleted file mode 100644
index 2346883..0000000
--- a/src/settings.php
+++ /dev/null
@@ -1,19 +0,0 @@
-<?php
-return [
-    'settings' => [
-        'displayErrorDetails' => true, // set to false in production
-        'addContentLengthHeader' => false, // Allow the web server to send the content-length header
-
-        // Renderer settings
-        'renderer' => [
-            'template_path' => __DIR__ . '/../templates/',
-        ],
-
-        // Monolog settings
-        'logger' => [
-            'name' => 'slim-app',
-            'path' => isset($_ENV['docker']) ? 'php://stdout' : __DIR__ . '/../logs/app.log',
-            'level' => \Monolog\Logger::DEBUG,
-        ],
-    ],
-];
diff --git a/templates/index.phtml b/templates/index.phtml
deleted file mode 100644
index 7ee7a49..0000000
--- a/templates/index.phtml
+++ /dev/null
@@ -1,36 +0,0 @@
-<!doctype html>
-<html>
-    <head>
-        <title>ScummVM</title>
-        <meta charset="utf-8"/>
-        <link rel="stylesheet" type="text/css" href="/cloud-style.css"/>
-    </head>
-    <body>
-        <div class="container">
-            <div class="header">
-                <img src="https://scummvm.org/images/scummvm_logo.png"/>
-            </div>
-            <div class="content">
-                <p>Which cloud do you want to connect?</p>
-                <div class="links-list">
-                    <a href="/dropbox" class="link">
-                        <img src="/images/dropbox.png" />
-                        <b>Dropbox</b>
-                    </a>
-                    <a href="/onedrive" class="link">
-                        <img src="/images/onedrive.png" />
-                        <b>OneDrive</b>
-                    </a>
-                    <a href="/gdrive" class="link">
-                        <img src="/images/google_drive.png" />
-                        <b>Google Drive</b>
-                    </a>
-                    <a href="/box" class="link">
-                        <img src="/images/box.png" />
-                        <b>Box</b>
-                    </a>
-                </div>
-            </div>
-        </div>
-    </body>
-</html>
diff --git a/templates/token.phtml b/templates/token.phtml
deleted file mode 100644
index 062a530..0000000
--- a/templates/token.phtml
+++ /dev/null
@@ -1,18 +0,0 @@
-<!doctype html>
-<html>
-    <head>
-        <title>ScummVM</title>
-        <meta charset="utf-8"/>
-        <link rel="stylesheet" type="text/css" href="/cloud-style.css"/>
-    </head>
-    <body>
-        <div class="container">
-            <div class="header">
-                <img src="https://scummvm.org/images/scummvm_logo.png"/>
-            </div>
-            <div class="content">
-                <p>Please enter the following code in ScummVM: <span class="shortcode"><?= $shortcode ?></span></p>
-            </div>
-        </div>
-    </body>
-</html>
diff --git a/tests/Functional/BaseTestCase.php b/tests/Functional/BaseTestCase.php
deleted file mode 100644
index 5e96209..0000000
--- a/tests/Functional/BaseTestCase.php
+++ /dev/null
@@ -1,81 +0,0 @@
-<?php
-
-namespace Tests\Functional;
-
-use Slim\App;
-use Slim\Http\Request;
-use Slim\Http\Response;
-use Slim\Http\Environment;
-use PHPUnit\Framework\TestCase;
-
-/**
- * This is an example class that shows how you could set up a method that
- * runs the application. Note that it doesn't cover all use-cases and is
- * tuned to the specifics of this skeleton app, so if your needs are
- * different, you'll need to change it.
- */
-class BaseTestCase extends TestCase
-{
-    /**
-     * Use middleware when running application?
-     *
-     * @var bool
-     */
-    protected $withMiddleware = true;
-
-    /**
-     * Process the application given a request method and URI
-     *
-     * @param  string            $requestMethod the request method (e.g. GET, POST, etc.)
-     * @param  string            $requestUri    the request URI
-     * @param  array|object|null $requestData   the request data
-     * @return \Slim\Http\Response
-     */
-    public function runApp($requestMethod, $requestUri, $requestData = null)
-    {
-        // Create a mock environment for testing with
-        $environment = Environment::mock(
-            [
-                'REQUEST_METHOD' => $requestMethod,
-                'REQUEST_URI' => $requestUri
-            ]
-        );
-
-        // Set up a request object based on the environment
-        $request = Request::createFromEnvironment($environment);
-
-        // Add request data, if it exists
-        if (isset($requestData)) {
-            $request = $request->withParsedBody($requestData);
-        }
-
-        // Set up a response object
-        $response = new Response();
-
-        // Use the application settings
-        $settings = include __DIR__ . '/../../src/settings.php';
-
-        // Instantiate the application
-        $app = new App($settings);
-
-        // Set up dependencies
-        $dependencies = include __DIR__ . '/../../src/dependencies.php';
-        $dependencies($app);
-
-        // Register middleware
-        if ($this->withMiddleware) {
-            $middleware = include __DIR__ . '/../../src/middleware.php';
-            $middleware($app);
-        }
-
-        // Register routes
-        $routes = include __DIR__ . '/../../src/routes.php';
-        $routes($app);
-
-        // Process the application
-        $response = $app->process($request, $response);
-
-        // Return the response
-        return $response;
-    }
-}
diff --git a/tests/Functional/HomepageTest.php b/tests/Functional/HomepageTest.php
deleted file mode 100644
index 6bcf043..0000000
--- a/tests/Functional/HomepageTest.php
+++ /dev/null
@@ -1,40 +0,0 @@
-<?php
-
-namespace Tests\Functional;
-
-class HomepageTest extends BaseTestCase
-{
-    /**
-     * Test that the index route returns a rendered response containing the text 'SlimFramework' but not a greeting
-     */
-    public function testGetHomepageWithoutName()
-    {
-        $response = $this->runApp('GET', '/');
-
-        $this->assertEquals(200, $response->getStatusCode());
-        $this->assertContains('SlimFramework', (string)$response->getBody());
-        $this->assertNotContains('Hello', (string)$response->getBody());
-    }
-
-    /**
-     * Test that the index route with optional name argument returns a rendered greeting
-     */
-    public function testGetHomepageWithGreeting()
-    {
-        $response = $this->runApp('GET', '/name');
-
-        $this->assertEquals(200, $response->getStatusCode());
-        $this->assertContains('Hello name!', (string)$response->getBody());
-    }
-
-    /**
-     * Test that the index route won't accept a post request
-     */
-    public function testPostHomepageNotAllowed()
-    {
-        $response = $this->runApp('POST', '/', ['test']);
-
-        $this->assertEquals(405, $response->getStatusCode());
-        $this->assertContains('Method not allowed', (string)$response->getBody());
-    }
-}
diff --git a/web/Dockerfile b/web/Dockerfile
new file mode 100644
index 0000000..52eb120
--- /dev/null
+++ b/web/Dockerfile
@@ -0,0 +1,7 @@
+FROM tiangolo/uvicorn-gunicorn-fastapi:python3.9
+
+COPY ./requirements.txt /app/requirements.txt
+
+RUN pip install --no-cache-dir --upgrade -r /app/requirements.txt
+
+COPY ./ /app
\ No newline at end of file
diff --git a/web/config.py b/web/config.py
new file mode 100644
index 0000000..7a7fd39
--- /dev/null
+++ b/web/config.py
@@ -0,0 +1,17 @@
+# Games to accept:
+GAMES = [
+	"moonbase", # Moonbase Commander (v1.0/v1.1/Demo)
+]
+
+# Full names of accepted games.  Make sure that they
+# match with the GAMES list above
+NAMES = {
+    "moonbase": "Moonbase Commander",
+}
+
+# Version variants to accept for a specific game.
+# If none exist but game exist in the GAMES list,
+# that means there's only one game version.
+VERSIONS = {
+	"moonbase": ["1.0", "1.1", "Demo"]
+}
\ No newline at end of file
diff --git a/web/main.py b/web/main.py
new file mode 100644
index 0000000..79c261a
--- /dev/null
+++ b/web/main.py
@@ -0,0 +1,69 @@
+import os
+
+from fastapi import FastAPI
+from fastapi.templating import Jinja2Templates
+from fastapi.staticfiles import StaticFiles
+from fastapi.requests import Request
+from fastapi.routing import Mount
+
+import aioredis
+
+from config import *
+
+routes = (
+	Mount('/static', StaticFiles(directory='static'), name='static'),
+)
+
+app = FastAPI(debug=os.environ.get('DEBUG', False), routes=routes, title="ScummVM Multiplayer")
+
+templates = Jinja2Templates(directory="templates")
+
+redis = aioredis.from_url(os.environ.get("REDIS_URL", "redis://localhost:6379"),
+						  retry_on_timeout=True, decode_responses=True)
+
+ at app.get('/')
+def index(request: Request, format: str = 'html'):
+	if format == 'json':
+		return {"games": NAMES}
+	return templates.TemplateResponse("index.html", {'request': request, 'games': GAMES, 'names': NAMES})
+
+async def get_sessions(game: str, version: str = None):
+	sessions = []
+	key = game
+	if version:
+		key += f":{version}"
+
+	num_sessions = await redis.llen(f"{key}:sessions")
+	session_ids = await redis.lrange(f"{key}:sessions", 0, num_sessions)
+	for id in session_ids:
+		session = await redis.hgetall(f"{key}:session:{id}")
+		sessions.append({"id": int(id), "version": version, "name": session["name"],
+						"players": int(session["players"]), "address": str(session["address"])})
+	return sessions
+
+ at app.get('/{game}')
+async def game_page(request: Request, game: str, version: str = None, format: str = 'html'):
+	sessions = []
+	error = None
+	if game in GAMES:
+		versions = VERSIONS.get(game)
+		if versions:
+			version_request = version
+			if version_request and version_request in versions:
+				# Get sessions for a specific version
+				sessions = await get_sessions(game, version_request)
+			else:
+				# Get sessions for all versions
+				for version in versions:
+					sessions += await get_sessions(game, version)
+		else:
+			# No version variants
+			sessions = await get_sessions(game)
+	else:
+		error = f"Not supported game: \"{game}\""
+
+	if format == 'json':
+		if error:
+			return {"error": error}
+		return {"sessions": sessions}
+	return templates.TemplateResponse("game.html", {'request': request, 'name': NAMES.get(game, game), 'sessions': sessions, 'error': error})
diff --git a/web/requirements.txt b/web/requirements.txt
new file mode 100644
index 0000000..3af1006
--- /dev/null
+++ b/web/requirements.txt
@@ -0,0 +1,4 @@
+fastapi==0.89.1
+aioredis==2.0.1
+uvicorn==0.20.0
+Jinja2==3.1.2
\ No newline at end of file
diff --git a/web/static/icons/moonbase.png b/web/static/icons/moonbase.png
new file mode 100644
index 0000000..ef898ff
Binary files /dev/null and b/web/static/icons/moonbase.png differ
diff --git a/public/cloud-style.css b/web/static/style.css
similarity index 53%
rename from public/cloud-style.css
rename to web/static/style.css
index 8b8d977..e9379e1 100644
--- a/public/cloud-style.css
+++ b/web/static/style.css
@@ -2,6 +2,10 @@ html {
     background: #c60;
 }
 
+body {
+    font-family: 'verdana', 'tahoma', 'arial', 'helvetica', sans-serif;
+}
+
 .container {
     width: 80%;
     max-width: 500pt;
@@ -16,11 +20,14 @@ html {
 
 .content {
     padding: 8pt;
-    background: #FFF;
-    font-family: 'verdana', 'tahoma', 'arial', 'helvetica', sans-serif;
+    background: #fff2cd;
     font-size: 16pt;
 }
 
+.small {
+    font-size: 8pt;
+}
+
 .content p {
     margin: 0 0 6pt 0;
     text-align: center;
@@ -32,15 +39,13 @@ html {
     flex-wrap: wrap;
 }
 
-a { color: #434343; }
-
-a:hover { color: #0080FF; }
+a { color: #000000; }
 
 a.link {
     display: inline-block;
-    min-width: calc(25% - 2*10pt);
-    width: calc(25% - 2*10pt);
-    height: calc(25% - 2*10pt);
+    min-width: calc(20% - 2*10pt);
+    width: calc(20% - 2*10pt);
+    height: calc(20% - 2*10pt);
     padding: 10pt;
     margin: 0;
     border: 0;
@@ -49,7 +54,7 @@ a.link {
 }
 
 a.link:hover {
-    background: #F3F3F3;
+    background: #00ce31;
 }
 
 a.link > img {
@@ -59,15 +64,33 @@ a.link > img {
 }
 
 a.link > b {
-    font-size: 16pt;
+    font-size: 14pt;
 }
 
 .shortcode {
   font-family: monospace;
 }
 
+/* Style the button that is used to open and close the collapsible content */
+.collapsible {
+    background-color: #eee;
+    color: #444;
+    cursor: pointer;
+    padding: 18px;
+    width: 100%;
+    border: none;
+    text-align: left;
+    outline: none;
+    font-size: 15px;
+  }
+  
+  /* Add a background color to the button if it is clicked on (add the .active class with JS), and when you move the mouse over it (hover) */
+  .active, .collapsible:hover {
+    background-color: #cccccc;
+  }
+
 @media only screen and (max-width: 640px) {
   a.link > b {
     font-size: 10pt;
-}
-}
+  }
+}
\ No newline at end of file
diff --git a/web/templates/game.html b/web/templates/game.html
new file mode 100644
index 0000000..121e8aa
--- /dev/null
+++ b/web/templates/game.html
@@ -0,0 +1,32 @@
+<!DOCTYPE html>
+<html>
+    <head>
+        <title>ScummVM Multiplayer</title>
+        <link rel="stylesheet" type="text/css" href="{{url_for('static', path='style.css')}}"/>
+        <link rel="icon" type="image/x-icon" href="https://scummvm.org/favicon.ico">
+    </head>
+    <body>
+        <div class="container">
+            <div class="header">
+                <img src="https://scummvm.org/images/scummvm_logo.png"/>
+            </div>
+            <div class="content">
+            {% if error -%}
+                <p>{{error}}</p>
+            {% else -%}
+                {% if not sessions -%}
+                <p>There are no {{name}} games currently.</p>
+                {% else -%}
+                <dl>
+                {% for session in sessions -%}
+                    <dt>{{session["name"]}}</li>
+                    <dd>- Players: {{session["players"]}}/4</dd>
+                    <dd>- Version: {{session["version"]}}</dd>
+                {% endfor -%}
+                </dl>
+                {% endif -%}
+            {% endif -%}
+            </div>
+        </div>
+    </body>
+</html>
\ No newline at end of file
diff --git a/web/templates/index.html b/web/templates/index.html
new file mode 100644
index 0000000..83b0d86
--- /dev/null
+++ b/web/templates/index.html
@@ -0,0 +1,26 @@
+<!DOCTYPE html>
+<html>
+    <head>
+        <title>ScummVM Multiplayer</title>
+        <link rel="stylesheet" type="text/css" href="{{url_for('static', path='style.css')}}"/>
+        <link rel="icon" type="image/x-icon" href="https://scummvm.org/favicon.ico">
+    </head>
+    <body>
+        <div class="container">
+            <div class="header">
+                <img src="https://scummvm.org/images/scummvm_logo.png"/>
+            </div>
+            <div class="content">
+                <p>Multiplayer Lobbies</p>
+                <div class="links-list">
+                {% for game in games -%}
+                <a href="/{{game}}" class="link">
+                    <img src="{{url_for('static', path='icons/{}.png'.format(game))}}" />
+                    <b>{{names[game]}}</b>
+                </a>
+                {% endfor -%}
+                </div>
+            </div>
+        </div>
+    </body>
+</html>
\ No newline at end of file


Commit: 28043e8ce7f6583bef67d23f3d3733fb127924a4
    https://github.com/scummvm/scummvm-sites/commit/28043e8ce7f6583bef67d23f3d3733fb127924a4
Author: Little Cat (toontownlittlecat at gmail.com)
Date: 2023-03-29T01:16:01+02:00

Commit Message:
MULTIPLAYER: DOCKER: Use Debian image.

Changed paths:
    Dockerfile


diff --git a/Dockerfile b/Dockerfile
index 3f16dba..f44b28f 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,9 +1,16 @@
-FROM python:3.9-buster
+FROM debian:bullseye
+
+# Install pyenet from apt
+RUN apt-get update && apt-get install -y \
+    python3 \
+    python3-enet \
+    python3-pip \
+    && rm -rf /var/lib/apt/lists/*
 
 WORKDIR /usr/src/app
 
-COPY requirements.txt ./
-RUN pip install --no-cache-dir -r requirements.txt
+RUN pip install --no-cache-dir \
+    redis
 
 COPY . .
-CMD [ "python", "main.py" ]
\ No newline at end of file
+CMD [ "python3", "main.py" ]
\ No newline at end of file


Commit: 512fbde9e69995a7ae8173be601ec3f5af586a02
    https://github.com/scummvm/scummvm-sites/commit/512fbde9e69995a7ae8173be601ec3f5af586a02
Author: Little Cat (toontownlittlecat at gmail.com)
Date: 2023-03-29T01:16:01+02:00

Commit Message:
MULTIPLAYER: Relay support.

Changed paths:
  A net_defines.py
    main.py


diff --git a/main.py b/main.py
index d3cda69..072e7dd 100644
--- a/main.py
+++ b/main.py
@@ -5,6 +5,7 @@ import signal
 
 import enet
 import redis as redis_package
+from net_defines import *
 
 # Games to accept:
 GAMES = [
@@ -49,24 +50,24 @@ for game in get_full_game_names():
 host = enet.Host(enet.Address(b"0.0.0.0", 9120), peerCount=4095, channelLimit=1)
 print("Listening for messages in port 9120", flush=True)
 
-def send(peer, data):
+def send(peer, data: dict):
 	logging.debug(f"{peer.address}: OUT: {data}")
 	data = json.dumps(data).encode()
 	peer.send(0, enet.Packet(data, enet.PACKET_FLAG_RELIABLE))
 
-def get_peer_by_address(address):
+def get_peer_by_address(address: str):
 	for peer in host.peers:
 		if str(peer.address) == address:
 			return peer
 	return None
 
-def get_session_by_address(game, address):
+def get_session_by_address(game: str, address: str):
 	session_id = redis.hget(f"{game}:sessionByAddress", str(address))
 	if session_id:
 		return redis.hgetall(f"{game}:session:{session_id}")
 	return None
 
-def create_session(name, address):
+def create_session(name: str, address: str):
 	# Get our new session ID
 	session_id = redis.incr(f"{game}:counter")
 	# Create and store our new session
@@ -81,6 +82,96 @@ def create_session(name, address):
 	logging.debug(f"{address}: NEW SESSION: \"{name}\"")
 	return session_id
 
+def relay_data(data, sent_peer):
+	from_user = data.get("from")
+	type_of_send = data.get("to")
+	send_type_param = data.get("toparam")
+
+	session_id = relays.get(sent_peer)
+	if not session_id:
+		return
+	
+	peers_by_user_id = session_to_relay_user_ids.get(session_id)
+	if not peers_by_user_id:
+		logging.warning(f"relay_data: Missing peers on session_to_relay_user_ids[{session_id}]!")
+		return
+
+	user_id_by_peers = {v: k for k, v in peers_by_user_id.items()}
+	
+	logging.debug(f"relay_data: Players of session {session_id}:")
+	for user_id, peer in peers_by_user_id.items():
+		logging.debug(f"relay_data:  - {user_id}: {str(peer.address)}")
+	
+	if user_id_by_peers.get(sent_peer) != 1:
+		# To make things easier, just send all non-host data to the host.
+		# It'll send it back to us if it actually needs to be relayed somewhere.
+		host_peer = peers_by_user_id.get(1)
+		if not host_peer:
+			logging.warning("relay_data: Host user (1) is missing!")
+			return
+		logging.debug(f"relay_data: Relaying data from user {user_id_by_peers.get(sent_peer)} to host (1).")
+		send(host_peer, data)
+		return
+	
+	peers_to_send = set()
+	if type_of_send == PN_SENDTYPE_INDIVIDUAL:
+		peer = peers_by_user_id.get(send_type_param)
+		if not peer:
+			logging.warning(f"relay_data: user {send_type_param} not in relay, Host does not know, something might be wrong.")
+			return
+		logging.debug(f"relay_data: Relaying data to user {send_type_param}")
+		peers_to_send.add(peer)
+	elif type_of_send == PN_SENDTYPE_GROUP:
+		logging.warning("STUB: PN_SENDTYPE_GROUP")
+		return
+	elif type_of_send == PN_SENDTYPE_HOST:
+		# Chances are that the host is user_id 1.
+		peer = peers_by_user_id.get(1)
+		if not peer:
+			return
+		logging.debug(f"relay_data: Relaying data to host (user 1)")
+		peers_to_send.add(peer)
+	elif type_of_send == PN_SENDTYPE_ALL:
+		# Send to all peers
+		for peer in peers_by_user_id.values():
+			peers_to_send.add(peer)
+	
+		logging.debug(f"relay_data: Relaying data to all peers: {str(list(peers_by_user_id.keys()))}")
+	else:
+		logging.warning(f"relay_data: Unknown type of send: {type_of_send}")
+	
+	# Remove self from set.
+	if sent_peer in peers_to_send:
+		peers_to_send.remove(sent_peer)
+
+	for peer in peers_to_send:
+		send(peer, data)
+	
+def remove_user_from_relay(peer):
+	session_id = relays.get(peer)
+	if not session_id:
+		return
+	
+	del relays[peer]
+	
+	peers_by_user_id = session_to_relay_user_ids.get(session_id)
+	if not peers_by_user_id:
+		return
+
+	user_id_by_peers = {v: k for k, v in peers_by_user_id.items()}
+	user_id = user_id_by_peers.get(peer)
+	if not user_id:
+		return
+	
+	del session_to_relay_user_ids[session_id][user_id]
+
+	# Send the remove_user request to the host.
+	host_peer = peers_by_user_id.get(1)
+	if not host_peer:
+		return
+
+	send(host_peer, {"cmd": "remove_user", "id": user_id})
+
 do_loop = True
 def exit(*args):
 	global do_loop
@@ -91,6 +182,9 @@ signal.signal(signal.SIGTERM, exit)
 # SIGINT: Ctrl+C KeyboardInterrupt
 signal.signal(signal.SIGINT, exit)
 
+relays = {} # peer: sessionId
+session_to_relay_user_ids = {} # sessionId: {userId: peer}
+
 while do_loop:
 	# Main event loop
 	event = host.service(1000)
@@ -106,15 +200,28 @@ while do_loop:
 				redis.lrem(f"{game}:sessions", 0, session_id)
 				redis.hdel(f"{game}:sessionByAddress", str(event.peer.address))
 
+			# Cleanup Relays (if any):
+				if session_id in session_to_relay_user_ids:
+					del session_to_relay_user_ids[session_id]
+			remove_user_from_relay(event.peer)
+
+
 	elif event.type == enet.EVENT_TYPE_RECEIVE:
 		logging.debug(f"{event.peer.address}: IN: {event.packet.data}")
-		# TODO: Relays
 		try:
 			data = json.loads(event.packet.data)
 		except:
-			logging.warning(f"{event.peer.address}: Received non-JSON data.")
+			logging.warning(f"{event.peer.address}: Received non-JSON data.", event.packet.data.decode())
 			continue
 		command = data.get("cmd")
+
+		if command == "game":
+			relay_data(data, event.peer)
+			continue
+		elif command == "remove_user":
+			remove_user_from_relay(event.peer)
+			continue
+		
 		game = data.get("game")
 		version = data.get("version")
 		if not command:
@@ -170,7 +277,7 @@ while do_loop:
 			session_id = data.get("id")
 
 			if not (redis.exists(f"{game}:session:{id}")):
-				logging.warn(f"Session {game}:{session_id} not found")
+				logging.warning(f"Session {game}:{session_id} not found")
 				continue
 
 			address = redis.hget(f"{game}:session:{id}", "address")
@@ -180,3 +287,46 @@ while do_loop:
 			
 			# Send the joiner's address to the hoster for hole-punching
 			send(peer, {"cmd": "joining_session", "address": str(event.peer.address)})
+		elif command == "start_relay":
+			session_id = data.get("session")
+			
+			if not (redis.exists(f"{game}:session:{id}")):
+				logging.warning(f"Session {game}:{session_id} not found")
+				continue
+
+			address = redis.hget(f"{game}:session:{id}", "address")
+			peer = get_peer_by_address(address)
+			if not peer:
+				continue
+
+			if session_id not in session_to_relay_user_ids:
+				# The host peer is usually always has the userId of 1:
+				session_to_relay_user_ids[session_id] = {1: peer}
+			if peer not in relays:
+				relays[peer] = session_id
+			
+			# Send the add_user request to the host (with joiner's address for context):
+			send(peer, {"cmd": "add_user_for_relay", "address": str(event.peer.address)})
+		elif command == "add_user_resp":
+			address = data.get("address")
+			user_id = data.get("id")
+
+			session_id = int(redis.hget(f"{game}:sessionByAddress", str(event.peer.address)))
+			if not session_id:
+				logging.warning(f"Could not find session for address {str(event.peer.address)}!")
+				continue
+			if session_id not in session_to_relay_user_ids:
+				logging.warning(f"Session ID {session_id} not found in session2RelayUserIds!")
+				continue
+			if user_id in session_to_relay_user_ids[session_id]:
+				logging.warning(f"Duplicate user ID {user_id} in session2RelayUserIds[{session_id}]!")
+				continue
+			peer = get_peer_by_address(address)
+			if not peer:
+				logging.warning(f"Could not find peer for address: {address}!")
+				continue
+
+			session_to_relay_user_ids[session_id][user_id] = peer
+			relays[peer] = session_id
+			# Send the response back to the peer:
+			send(peer, {"cmd": "add_user_resp", "id": user_id})
diff --git a/net_defines.py b/net_defines.py
new file mode 100644
index 0000000..367622e
--- /dev/null
+++ b/net_defines.py
@@ -0,0 +1,8 @@
+# Copied from engines/scumm/he/moonbase/net_defines.h
+
+# These are used for relaying.
+PN_PRIORITY_HIGH = 			0x00000001
+PN_SENDTYPE_INDIVIDUAL =	1
+PN_SENDTYPE_GROUP =			2
+PN_SENDTYPE_HOST =			3
+PN_SENDTYPE_ALL =			4
\ No newline at end of file


Commit: 44ac81fbf23278d16fdc41d3f70ea1f2271a759d
    https://github.com/scummvm/scummvm-sites/commit/44ac81fbf23278d16fdc41d3f70ea1f2271a759d
Author: Little Cat (toontownlittlecat at gmail.com)
Date: 2023-03-29T01:16:01+02:00

Commit Message:
MULTIPLAYER: Add supported Backyard Sports titles.

Changed paths:
  A web/static/icons/baseball2001.png
  A web/static/icons/football.png
  A web/static/icons/football2002.png
    main.py
    web/config.py


diff --git a/main.py b/main.py
index 072e7dd..d9df2bb 100644
--- a/main.py
+++ b/main.py
@@ -9,7 +9,10 @@ from net_defines import *
 
 # Games to accept:
 GAMES = [
-	"moonbase" # Moonbase Commander (v1.0/v1.1/Demo)
+	"football", # Backyard Football (1999)
+	"baseball2001", # Backyard Baseball 2001
+	"football2002", # Backyard Football 2002 
+	"moonbase", # Moonbase Commander (v1.0/v1.1/Demo)
 ]
 
 # Version variants to accept for a specific game.
diff --git a/web/config.py b/web/config.py
index 7a7fd39..c4e81a6 100644
--- a/web/config.py
+++ b/web/config.py
@@ -1,12 +1,18 @@
 # Games to accept:
 GAMES = [
+	"football", # Backyard Football (1999)
+	"baseball2001", # Backyard Baseball 2001
+	"football2002", # Backyard Football 2002 
 	"moonbase", # Moonbase Commander (v1.0/v1.1/Demo)
 ]
 
 # Full names of accepted games.  Make sure that they
 # match with the GAMES list above
 NAMES = {
-    "moonbase": "Moonbase Commander",
+	"football": "Backyard Football",
+	"baseball2001": "Backyard Baseball 2001",
+	"football2002": "Backyard Football 2002",
+	"moonbase": "Moonbase Commander"
 }
 
 # Version variants to accept for a specific game.
diff --git a/web/static/icons/baseball2001.png b/web/static/icons/baseball2001.png
new file mode 100644
index 0000000..84977e7
Binary files /dev/null and b/web/static/icons/baseball2001.png differ
diff --git a/web/static/icons/football.png b/web/static/icons/football.png
new file mode 100644
index 0000000..747baff
Binary files /dev/null and b/web/static/icons/football.png differ
diff --git a/web/static/icons/football2002.png b/web/static/icons/football2002.png
new file mode 100644
index 0000000..e95a3c4
Binary files /dev/null and b/web/static/icons/football2002.png differ


Commit: ec3d0ded4fcd9d05f45cf65051925768feb5b4a7
    https://github.com/scummvm/scummvm-sites/commit/ec3d0ded4fcd9d05f45cf65051925768feb5b4a7
Author: Little Cat (toontownlittlecat at gmail.com)
Date: 2023-03-29T01:16:01+02:00

Commit Message:
MULTIPLAYER: WEB: Don't use url_for for static.

Changed paths:
    web/templates/game.html
    web/templates/index.html


diff --git a/web/templates/game.html b/web/templates/game.html
index 121e8aa..e349504 100644
--- a/web/templates/game.html
+++ b/web/templates/game.html
@@ -2,7 +2,7 @@
 <html>
     <head>
         <title>ScummVM Multiplayer</title>
-        <link rel="stylesheet" type="text/css" href="{{url_for('static', path='style.css')}}"/>
+        <link rel="stylesheet" type="text/css" href="/static/style.css"/>
         <link rel="icon" type="image/x-icon" href="https://scummvm.org/favicon.ico">
     </head>
     <body>
diff --git a/web/templates/index.html b/web/templates/index.html
index 83b0d86..a306038 100644
--- a/web/templates/index.html
+++ b/web/templates/index.html
@@ -2,7 +2,7 @@
 <html>
     <head>
         <title>ScummVM Multiplayer</title>
-        <link rel="stylesheet" type="text/css" href="{{url_for('static', path='style.css')}}"/>
+        <link rel="stylesheet" type="text/css" href="/static/style.css"/>
         <link rel="icon" type="image/x-icon" href="https://scummvm.org/favicon.ico">
     </head>
     <body>
@@ -15,7 +15,7 @@
                 <div class="links-list">
                 {% for game in games -%}
                 <a href="/{{game}}" class="link">
-                    <img src="{{url_for('static', path='icons/{}.png'.format(game))}}" />
+                    <img src="{{'/static/icons/{}.png'.format(game)}}" />
                     <b>{{names[game]}}</b>
                 </a>
                 {% endfor -%}


Commit: 979f88da2dfb8da53b43efabd093c10ecfcd2a78
    https://github.com/scummvm/scummvm-sites/commit/979f88da2dfb8da53b43efabd093c10ecfcd2a78
Author: Little Cat (toontownlittlecat at gmail.com)
Date: 2023-03-29T01:16:01+02:00

Commit Message:
MULTIPLAYER: Remove output whitespace

Changed paths:
    main.py


diff --git a/main.py b/main.py
index d9df2bb..3e74cf4 100644
--- a/main.py
+++ b/main.py
@@ -55,7 +55,7 @@ print("Listening for messages in port 9120", flush=True)
 
 def send(peer, data: dict):
 	logging.debug(f"{peer.address}: OUT: {data}")
-	data = json.dumps(data).encode()
+	data = json.dumps(data, separators=(',', ':')).encode()
 	peer.send(0, enet.Packet(data, enet.PACKET_FLAG_RELIABLE))
 
 def get_peer_by_address(address: str):


Commit: 8432f3566e4cf1c4df2a68ab72116cc7c3bcef12
    https://github.com/scummvm/scummvm-sites/commit/8432f3566e4cf1c4df2a68ab72116cc7c3bcef12
Author: Little Cat (toontownlittlecat at gmail.com)
Date: 2023-03-29T01:16:01+02:00

Commit Message:
MULTIPLAYER: Add lobby server code.

Changed paths:
  A lobby/.dockerignore
  A lobby/.gitignore
  A lobby/Dockerfile
  A lobby/README.md
  A lobby/config.yaml
  A lobby/database/Redis.js
  A lobby/database/WebAPI.js
  A lobby/discord/Discord.js
  A lobby/global/Areas.js
  A lobby/global/EventLogger.js
  A lobby/global/Stats.js
  A lobby/net/AreaMessages.js
  A lobby/net/ChallengeMessages.js
  A lobby/net/DatabaseMessages.js
  A lobby/net/NetworkConnection.js
  A lobby/net/NetworkListener.js
  A lobby/net/SessionMessages.js
  A lobby/package-lock.json
  A lobby/package.json
  A lobby/run.js
    README.md
    docker-compose.yml


diff --git a/README.md b/README.md
index 1f7197d..b927000 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
 # ScummVM Multiplayer
 
-This project contains code for hosting online multiplayer lobbies for Moonbase Commander.  This is currently WIP.
+This project contains code for hosting online multiplayer lobbies for compatable Humongous Entertainment games.
 
 ## Getting Started
 ### Installing
@@ -20,7 +20,11 @@ source .env/bin/activate
 
 python3 -m pip install -r requirements.txt
 ```
-if you're planning to run the web server, install the requirements located in the web directory.
+
+Backyard Football and Backyard Baseball 2001 needs a lobby server to play online.  You will need
+[Node.js](https://nodejs.org/en/) installed to run it.
+
+If you're planning to run the web server, install the requirements located in the web directory.
 ```
 python3 -m pip install -r web/requirements.txt
 ```
@@ -32,17 +36,28 @@ python3 main.py
 ```
 It should listen for connections on port 9120.  Remember to configure ScummVM to connect to localhost or whatever address your server is running in.
 
+To start the lobby server, go to the `lobby` directory and install the dependencies:
+```
+cd lobby
+npm install
+```
+
+After that's done, you can simply run the `run.js` file.
+```
+node run.js
+```
+
 Running a web server is unnecessary if you just want your server to host sessions, but if you want to, you can start one up by using uvicorn.
 ```
 cd web
 uvicorn main:app --reload
 ```
 
-## Deployment
-Both the session and web server can be run within Docker via docker-compose.
+## Docker
+The session, lobby and web server can be run within Docker via docker-compose.
 ```
 docker-compose build
 docker-compose up
 ```
 
-This will build Docker images for both servers and starts a container for them simultaneously alongside with Redis.
\ No newline at end of file
+This will build Docker images for all three servers and starts a container for them simultaneously alongside with Redis.
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
index 574361b..837f6a7 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -6,6 +6,12 @@ services:
       - REDIS_HOST=redis
     ports:
       - "9120:9120/udp"
+  lobby:
+    build: ./lobby/
+    environment:
+      - REDIS_HOST=redis
+    ports:
+      - "9130:9130"
   web:
     build: ./web/
     environment:
diff --git a/lobby/.dockerignore b/lobby/.dockerignore
new file mode 100644
index 0000000..7a8827b
--- /dev/null
+++ b/lobby/.dockerignore
@@ -0,0 +1,4 @@
+node_modules
+npm-debug.log
+.gitignore
+**/.git
\ No newline at end of file
diff --git a/lobby/.gitignore b/lobby/.gitignore
new file mode 100644
index 0000000..8f536d9
--- /dev/null
+++ b/lobby/.gitignore
@@ -0,0 +1,121 @@
+# Project specific
+credentials.yaml
+
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+lerna-debug.log*
+.pnpm-debug.log*
+
+# Diagnostic reports (https://nodejs.org/api/report.html)
+report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
+
+# Runtime data
+pids
+*.pid
+*.seed
+*.pid.lock
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+lib-cov
+
+# Coverage directory used by tools like istanbul
+coverage
+*.lcov
+
+# nyc test coverage
+.nyc_output
+
+# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
+.grunt
+
+# Bower dependency directory (https://bower.io/)
+bower_components
+
+# node-waf configuration
+.lock-wscript
+
+# Compiled binary addons (https://nodejs.org/api/addons.html)
+build/Release
+
+# Dependency directories
+node_modules/
+jspm_packages/
+
+# Snowpack dependency directory (https://snowpack.dev/)
+web_modules/
+
+# TypeScript cache
+*.tsbuildinfo
+
+# Optional npm cache directory
+.npm
+
+# Optional eslint cache
+.eslintcache
+
+# Microbundle cache
+.rpt2_cache/
+.rts2_cache_cjs/
+.rts2_cache_es/
+.rts2_cache_umd/
+
+# Optional REPL history
+.node_repl_history
+
+# Output of 'npm pack'
+*.tgz
+
+# Yarn Integrity file
+.yarn-integrity
+
+# dotenv environment variables file
+.env
+.env.test
+.env.production
+
+# parcel-bundler cache (https://parceljs.org/)
+.cache
+.parcel-cache
+
+# Next.js build output
+.next
+out
+
+# Nuxt.js build / generate output
+.nuxt
+dist
+
+# Gatsby files
+.cache/
+# Comment in the public line in if your project uses Gatsby and not Next.js
+# https://nextjs.org/blog/next-9-1#public-directory-support
+# public
+
+# vuepress build output
+.vuepress/dist
+
+# Serverless directories
+.serverless/
+
+# FuseBox cache
+.fusebox/
+
+# DynamoDB Local files
+.dynamodb/
+
+# TernJS port file
+.tern-port
+
+# Stores VSCode versions used for testing VSCode extensions
+.vscode-test
+
+# yarn v2
+.yarn/cache
+.yarn/unplugged
+.yarn/build-state.yml
+.yarn/install-state.gz
+.pnp.*
diff --git a/lobby/Dockerfile b/lobby/Dockerfile
new file mode 100644
index 0000000..31acebc
--- /dev/null
+++ b/lobby/Dockerfile
@@ -0,0 +1,11 @@
+FROM node:16
+
+WORKDIR /usr/src/app
+
+COPY package*.json ./
+
+RUN npm install
+
+COPY . .
+
+CMD [ "node", "run.js" ]
diff --git a/lobby/README.md b/lobby/README.md
new file mode 100644
index 0000000..c1e1b0e
--- /dev/null
+++ b/lobby/README.md
@@ -0,0 +1,11 @@
+# ScummVM Lobby Server
+
+Lobby server code for Backyard Football and Backyard Baseball 2001.
+
+This server is the same one being used for [Backyard Sports Online](https://backyardsports.online/), which is
+now provided by the ScummVM project.
+
+An archive of the original code can be located here:
+https://github.com/Backyard-Sports-Online/server
+
+Follow the README.md in the base directory to get it up and running.
diff --git a/lobby/config.yaml b/lobby/config.yaml
new file mode 100644
index 0000000..1a3d393
--- /dev/null
+++ b/lobby/config.yaml
@@ -0,0 +1,11 @@
+# Process cores to fork the network processes
+cores: 1
+
+network:
+  host: 0.0.0.0
+  port: 9130
+
+  # TLS certificate.  Set the path to accept
+  # TLS connections, this MUST be set in production.
+  # key: /path/to/private_key.pem
+  # cert: /path/to/certificate.crt
diff --git a/lobby/database/Redis.js b/lobby/database/Redis.js
new file mode 100644
index 0000000..ed94d67
--- /dev/null
+++ b/lobby/database/Redis.js
@@ -0,0 +1,236 @@
+"use strict";
+
+const createLogger = require('logging').default;
+const ioredis = require("ioredis")
+const Areas = require('../global/Areas.js');
+
+class Redis {
+    constructor(config) {
+        this.logger = createLogger("Redis");
+        this.redis = new ioredis(config);
+
+        this.redis.on("ready", async () => {
+            this.logger.info("Connected");
+            if (database != this) {
+                // Use a different database so the dev accounts won't overlap.
+                await this.redis.select(1);
+            }
+            if (process.env.FIRST_WORKER) {
+                // Initalization and cache cleanup goes here.
+                if (database == this) {
+                    this.logger.info("Using Redis as default database.")
+                    if (!await this.redis.exists("byonline:globals:userId")) {
+                        this.logger.info("Creating userId sequence...");
+                        await this.redis.set("byonline:globals:userId", 0);
+                    }
+
+                    // Clear out the user list in every area.
+                    const keys = [];
+                    for (const areaId of Object.keys(Areas.Areas)) {
+                        keys.push(`byonline:areas:football:${areaId}`,
+                                  `byonline:areas:baseball:${areaId}`);
+                    }
+                    this.redis.del(keys);
+                } else {
+                    // Flush the entire cache and start off clean.
+                    await this.redis.flushdb();
+                }
+            }
+        });
+    }
+
+    async getUserById(userId, game) {
+        const response = await this.redis.hgetall(`byonline:users:${userId}`);
+        if (Object.keys(response).length == 0) {
+            this.logger.warn(`User ${userId} not found in Redis!`);
+            return {};
+        }
+	let stats;
+        if (database == this) {
+            stats = (game == 'football' ? response['f_stats'] : response['b_stats']);
+        } else {
+            stats = response['stats'];
+        }
+        return {
+            'id': Number(userId),
+            'user': response['user'],
+            'icon': Number(response['icon']),
+            'stats': stats
+            .split(',').map(Number),
+            'game': response['game'],
+            'area': Number(response['area']),
+            'inGame': Number(response['inGame']),
+            'phone': Number(response['phone']),
+            'opponent': Number(response['opponent'])
+        }
+    }
+
+    async getUserByName(username) {
+        const userId = await this.redis.hget("byonline:users:nameToId", username);
+        if (userId) {
+            return await this.getUserById(userId);
+        }
+        return undefined;
+    }
+
+    async addUser(userId, user, game) {
+        // Add some server specific keys.
+        user.game = game;
+        user.area = 0;
+        user.phone = 0;
+        user.opponent = 0;
+        user.inGame = 0;
+
+        await this.redis.hmset(`byonline:users:${userId}`, user);
+        await this.redis.hset("byonline:users:nameToId", user['user'].toUpperCase(), userId);
+    }
+
+    async getUser(username, password, game) {
+        if (database != this) {
+            this.logger.warn("Redis isn't set as default database, calling getUser shouldn't be possible");
+            return {error: 1, message: "Internal error."};
+        }
+        let user = await this.getUserByName(username);
+        if (user) {
+            await this.addUser(user.id, user, game)
+            return user;
+        } else {
+            const userId = await this.redis.incr("byonline:globals:userId")
+            user = {
+                'user': username,
+                'icon': 0,
+                'f_stats': Array(42).fill(0),
+                'b_stats': Array(29).fill(0),
+            }
+            this.addUser(userId, user, game);
+            user['id'] = userId;
+            return user;
+        }
+    }
+
+    async getUserWithToken(token, game) {
+        return {error: 1, message: "Redis does not support token logins."};
+    }
+
+    async removeUser(userId, game) {
+        // We don't want to remove users from a dev database.
+        if (database != this) {
+            // We need the name to clear the nameToId from.
+            const user = await this.getUserById(userId, game);
+            if (Object.keys(user).length == 0)
+                return;
+
+            await this.redis.del(`byonline:users:${userId}`);
+            await this.redis.hdel('byonline:users:nameToId', user.user.toUpperCase());
+        } else {
+            await this.redis.hmset(`byonline:users:${userId}`, {
+                'game': '',
+                'area': 0,
+                'phone': 0,
+                'opponent': 0
+            });
+        }
+
+        await this.removeOngoingResults(userId, game);
+    }
+
+    async setIcon(userId, icon) {
+        await this.redis.hset(`byonline:users:${userId}`, 'icon', icon);
+    }
+
+    async getUserIdsInArea(areaId, game) {
+        return await this.redis.lrange(`byonline:areas:${game}:${areaId}`, 0, -1)
+        .then((users) => {
+            return users.map(Number);
+        });
+    }
+
+    async getUsersInArea(areaId, game) {
+        const usersList = await this.getUserIdsInArea(areaId, game);
+        const users = [];
+        for (const userId of usersList) {
+            const user = await this.getUserById(userId);
+            // If the user doesn't exist or in a game, skip them out.
+            if (Object.keys(user).length == 0 || user.inGame)
+                continue;
+            users.push(user);
+        }
+        return users;
+    }
+
+    async addUserToArea(userId, areaId, game) {
+        if (await this.redis.lpos(`byonline:areas:${game}:${areaId}`, userId) == null) {
+            await this.redis.rpush(`byonline:areas:${game}:${areaId}`, userId);
+            await this.redis.hset(`byonline:users:${userId}`, {area: areaId});
+        }
+        // This calls sendUsersInArea
+        await this.setPhoneStatus(userId, areaId, game, 0);
+        // TODO: Only send games playing to this specific user.
+        await this.sendGamesPlayingInArea(areaId, game);
+    }
+
+    async removeUserFromArea(userId, areaId, game) {
+        await this.redis.lrem(`byonline:areas:${game}:${areaId}`, 0, userId);
+        await this.redis.hset(`byonline:users:${userId}`, {area: 0});
+        await this.sendUsersInArea(areaId, game);
+        await this.sendGamesPlayingInArea(areaId, game);
+
+    }
+
+    async sendUsersInArea(areaId, game) {
+        const users = await this.getUsersInArea(areaId, game);
+        process.send({cmd: 'update_players_list',
+                      area: areaId,
+                      game: game,
+                      users: users
+        });
+    }
+
+    async setPhoneStatus(userId, areaId, game, phoneStatus) {
+        await this.redis.hset(`byonline:users:${userId}`, {phone: phoneStatus});
+        // Update the clients in area.
+        await this.sendUsersInArea(areaId, game);
+    }
+
+    async setInGame(userId, inGame) {
+        await this.redis.hset(`byonline:users:${userId}`, {inGame: inGame});
+    }
+
+    async sendGamesPlayingInArea(areaId, game) {
+        const usersList = await this.getUserIdsInArea(areaId, game);
+        let gamesPlaying = 0;
+        for (const userId of usersList) {
+            const user = await this.getUserById(userId);
+            if (user.inGame) {
+                gamesPlaying += .5;
+            }
+        }
+        process.send({cmd: "update_games_playing",
+                      area: areaId,
+                      game: game,
+                      games: Math.floor(gamesPlaying)
+        });
+    }
+
+    async setOngoingResults(userId, game, ongoingResults) {
+        await this.redis.hmset(`byonline:ongoingResults:${game}:${userId}`, ongoingResults);
+    }
+
+    async getOngoingResults(userId, game) {
+        const ongoingResults = await this.redis.hgetall(`byonline:ongoingResults:${game}:${userId}`);
+        return ongoingResults;
+    }
+
+    async removeOngoingResults(userId, game) {
+        const resultsKey = `byonline:ongoingResults:${game}:${userId}`;
+        if (await this.redis.exists(resultsKey)) {
+            await this.redis.del(resultsKey);
+        }
+    }
+
+    async getTeam(userId, game) {
+        return {error: 1, message: "Redis API does not support teams"};
+    }
+}
+
+module.exports = Redis;
diff --git a/lobby/database/WebAPI.js b/lobby/database/WebAPI.js
new file mode 100644
index 0000000..10553de
--- /dev/null
+++ b/lobby/database/WebAPI.js
@@ -0,0 +1,57 @@
+"use strict";
+const createLogger = require('logging').default;
+const bent = require('bent');
+
+class WebAPI {
+    constructor(config) {
+        this.logger = createLogger('WebAPI');
+
+        this.token = config['token'] || "";
+
+        const endpoint = config['endpoint'];
+        this.get = bent(endpoint, 'GET', 'json', 200);
+        this.post = bent(endpoint, 'POST', 'json', 200);
+    }
+
+    async getUser(username, password, game) {
+        // TODO: Replace with /login
+        const user = await this.post('/new_login', {token: this.token,
+                                                    user: username,
+                                                    pass: password,
+                                                    game: game});
+        if (user.error) {
+            return user;
+        }
+        // Store the user into the Redis cache
+        redis.addUser(user.id, {user: user.user,
+                                icon: user.icon,
+                                stats: user.stats}, game);
+        return user;
+    }
+
+    async setIcon(userId, icon) {
+        const response = await this.post('/set_icon', {token: this.token,
+                                                      userId: userId,
+                                                      icon: icon});
+        if (response.error) {
+            this.logger.error("Failed to set icon!", { response });
+            return;
+        }
+
+        // Set the icon in the Redis cache.
+        redis.setIcon(userId, icon);
+    }
+
+    async getTeam(userId, game) {
+        const response = await this.post('/get_team', {token: this.token,
+                                                       userId: userId,
+                                                       game: game});
+        if (response.error) {
+            this.logger.error("Failed to get team!", { response });
+        }
+
+        return response;
+    }
+}
+
+module.exports = WebAPI;
diff --git a/lobby/discord/Discord.js b/lobby/discord/Discord.js
new file mode 100644
index 0000000..90a0e19
--- /dev/null
+++ b/lobby/discord/Discord.js
@@ -0,0 +1,179 @@
+"use strict";
+
+const createLogger = require('logging').default;
+const { table, getBorderCharacters } = require('table');
+const { Client, Intents, MessageEmbed } = require('discord.js');
+const { Areas, Groups } = require('../global/Areas.js');
+
+class Discord {
+  constructor(config) {
+    this.logger = createLogger("Discord");
+
+    this.sentOffline = false;
+
+    this.scoreboardTableConfig = {
+      border: getBorderCharacters('ramac'),
+      drawHorizontalLine: (lineIndex, rowCount) => {return lineIndex <= 1 || lineIndex % 2 == 1}
+    };
+
+    this.client = new Client({intents: [Intents.FLAGS.GUILDS]});
+    this.client.once('ready', async () => {
+      this.logger.info("Connected.");
+      this.channel = await this.client.channels.fetch(config['channel']);
+      let messages = await this.channel.messages.fetch();
+      messages = messages.filter(m => m.author.id === config['client']);
+      if (messages.first())
+        this.lastMessageId = messages.first().id;
+      this.logger.info("Now sending population.");
+      await this.sendPopulation();
+
+      // Set a timeout
+      this.populationTimer = setTimeout(async () => {
+        await this.sendPopulation();
+        this.populationTimer.refresh();
+      }, 30000);
+
+    });
+
+    this.client.login(config['token']);
+  }
+
+  async sendPopulation() {
+    let usersOnline = 0;
+    let gamesPlaying = 0;
+
+    let baseballUsers = '';
+    let footballUsers = '';
+
+    const userIds = Object.values(await redis.redis.hgetall("byonline:users:nameToId")).map(Number);
+    let ongoingScoresByHome = {};
+    let inGameUserIdsToNames = {};  // Storing so we don't have to call redis for these again later
+    for (const userId of userIds) {
+      const user = await redis.getUserById(userId);
+      if (Object.keys(user).length == 0 || !user.game)
+        // Not logged in.
+        continue;
+
+      usersOnline++;
+      if (user.inGame) {
+        gamesPlaying += .5;
+
+        const ongoingResultsStrings = await redis.getOngoingResults(userId, user.game);
+        const ongoingResults = Object.fromEntries(
+          Object.entries(ongoingResultsStrings).map(([k, stat]) => [k, Number(stat)])
+        );
+        if (ongoingResults) {
+          inGameUserIdsToNames[userId] = user.user;
+          if ((ongoingResults.opponentId in ongoingScoresByHome) && (ongoingScoresByHome[ongoingResults.opponentId]["awayId"] == userId)) {
+            // This is the away team; we already have the home team's score for this game
+            ongoingScoresByHome[ongoingResults.opponentId]["awayScore"] = ongoingResults.runs;
+            ongoingScoresByHome[ongoingResults.opponentId]["awayHits"] = ongoingResults.hits;
+            ongoingScoresByHome[ongoingResults.opponentId]["completedInnings"] = ongoingResults.completedInnings;
+          } else if ((userId in ongoingScoresByHome) && ("awayScore" in ongoingScoresByHome[userId])) {
+            // We already have the away team's score for this game. This must be the home team
+            ongoingScoresByHome[userId]["homeScore"] = ongoingResults.runs;
+            ongoingScoresByHome[userId]["homeHits"] = ongoingResults.hits;
+            ongoingScoresByHome[userId]["completedInnings"] = ongoingResults.completedInnings;
+          } else if (ongoingResults.isHome == 1) {
+            // We don't have either team's score yet and this is the home team. Let's add it
+            ongoingScoresByHome[userId] = {
+              "awayId": ongoingResults.opponentId,
+              "homeScore": ongoingResults.runs,
+              "homeHits": ongoingResults.hits,
+              "completedInnings": ongoingResults.completedInnings,
+            };
+          } else if (ongoingResults.isHome == 0) {
+            // We don't have either team's score yet and this is the away team. Let's add it
+            ongoingScoresByHome[ongoingResults.opponentId] = {
+              "awayId": userId,
+              "awayScore": ongoingResults.runs,
+              "awayHits": ongoingResults.hits,
+              "completedInnings": ongoingResults.completedInnings,
+            };
+          }
+        }
+
+      }
+
+      let area = "(Online)";
+      let groupName = "";
+      if (user.area) {
+        const groups = Object.values(Groups);
+        if (groups[0].includes(user.area))
+          groupName = "Easy Street";
+        else if (groups[1].includes(user.area))
+          groupName = "Mediumville";
+        else if (groups[2].includes(user.area))
+          groupName = "Toughy Town";
+
+        area = `${user.inGame ? '(In-Game) ' : ''}(${Areas[user.area]}, ${groupName})`;
+      }
+      if (user.game == "baseball") {
+        baseballUsers += `${user.user} ${area}\n`;
+      } else {
+        footballUsers += `${user.user} ${area}\n`;
+      }
+    }
+
+    const embed = new MessageEmbed()
+      .setTitle('Server Population:')
+      .setFooter("Updates every 30 seconds.")
+      .setColor("GREY")
+      .setTimestamp();
+
+    if (!usersOnline)
+      embed.setDescription("No one is currently online. :(");
+    else {
+      embed.setDescription(`Total Population: ${usersOnline}\nGames Currently Playing: ${Math.floor(gamesPlaying)}`);
+      if (baseballUsers) {
+        let baseballScoresData = [];
+        for (const homeId in ongoingScoresByHome) {
+          baseballScoresData.push(
+            [
+              inGameUserIdsToNames[ongoingScoresByHome[homeId]["awayId"]],
+              ongoingScoresByHome[homeId]["awayScore"],
+              ongoingScoresByHome[homeId]["awayHits"],
+              ongoingScoresByHome[homeId]["completedInnings"] + 1,
+            ],
+            [
+              inGameUserIdsToNames[homeId],
+              ongoingScoresByHome[homeId]["homeScore"],
+              ongoingScoresByHome[homeId]["homeHits"],
+              "",
+            ]
+          )
+        }
+
+        embed.addField("Backyard Baseball 2001", baseballUsers);
+        if (baseballScoresData.length > 0) {
+          const baseballScoreboardText = table(
+            [[ '', 'R', 'H', 'Inn' ]].concat(baseballScoresData),
+            this.scoreboardTableConfig
+          );
+          embed.addField(
+            "Backyard Baseball 2001 Scoreboard", "```" + baseballScoreboardText + "```"
+          );
+        }
+      }
+
+      if (footballUsers)
+        embed.addField("Backyard Football", footballUsers);
+    }
+
+    if ((!usersOnline && !this.sentOffline) || usersOnline) {
+      if (this.lastMessageId) {
+        const message = await this.channel.messages.fetch(this.lastMessageId)
+        await message.edit({ embeds: [embed] });
+      } else {
+        const message = await this.channel.send({ embeds: [embed] });
+        this.lastMessageId = message.id;
+      }
+      if (!usersOnline)
+        this.sentOffline = true;
+      else
+        this.sentOffline = false;
+    }
+  }
+}
+
+module.exports = Discord;
diff --git a/lobby/global/Areas.js b/lobby/global/Areas.js
new file mode 100644
index 0000000..465022d
--- /dev/null
+++ b/lobby/global/Areas.js
@@ -0,0 +1,32 @@
+"use strict";
+
+const Areas = {
+    // Easy Street
+    8: "Wilderness",
+    9: "Mountain Aire",
+    // Mediumville
+    16: "Dogwood",
+    17: "Brookline",
+    18: "Talula Lake",
+    19: "Prince Rupert",
+    // Toughy Town
+    24: "Vespucci Park",
+    25: "Capitol Hill",
+    26: "Lewis Ave.",
+
+    33: "Baseball 2001 Lobby"
+};
+
+const Groups = {
+    // Easy Street
+    '-248': [8, 9],
+    // Mediumville
+    '-240': [16, 17, 18, 19],
+    // Toughy Town
+    '-232': [24, 25, 26]
+};
+
+module.exports = {
+    Areas: Areas,
+    Groups: Groups
+};
diff --git a/lobby/global/EventLogger.js b/lobby/global/EventLogger.js
new file mode 100644
index 0000000..72624e2
--- /dev/null
+++ b/lobby/global/EventLogger.js
@@ -0,0 +1,22 @@
+"use strict";
+const createLogger = require('logging').default;
+const logger = createLogger('Event');
+
+const logEvent = (eventName, client, version, additionalData) => {
+    const timestamp = new Date().toISOString();
+    const eventDesc = Object.assign(
+        {
+            eventName: eventName,
+            timestamp: timestamp,
+            user: client.userId,
+            version: version || client.version,
+            game: client.game,
+        },
+        additionalData,
+    );
+    logger.info(JSON.stringify(eventDesc));
+};
+
+module.exports = {
+    logEvent: logEvent
+};
diff --git a/lobby/global/Stats.js b/lobby/global/Stats.js
new file mode 100644
index 0000000..eaf2f36
--- /dev/null
+++ b/lobby/global/Stats.js
@@ -0,0 +1,35 @@
+"use strict";
+
+const ResultsMappers = {
+    // These take an array that the game sends to `/game_results`
+    // and return an ongoing results object that can be stored in redis
+    "baseball": (resultsFields, isHome, opponentId) => {
+        const ongoingResults = {
+            winning: resultsFields[0],
+            runs: resultsFields[1],
+            atBats: resultsFields[2],
+            hits: resultsFields[3],
+            homeRuns: resultsFields[4],
+            longestHomeRun: resultsFields[5],
+            singles: resultsFields[6],
+            doubles: resultsFields[7],
+            triples: resultsFields[8],
+            steals: resultsFields[9],
+            strikeouts: resultsFields[10],
+            walks: resultsFields[11],
+            disconnect: resultsFields[12],
+            completedInnings: resultsFields[13],
+            isHome: isHome,
+            opponentId: opponentId,
+        };
+        return ongoingResults;
+    },
+    // TODO: Football
+    "football": (resultsFields, isHome, opponentId) => {
+        return {"not_yet_supported": 1}
+    }
+};
+
+module.exports = {
+    ResultsMappers: ResultsMappers,
+}; 
\ No newline at end of file
diff --git a/lobby/net/AreaMessages.js b/lobby/net/AreaMessages.js
new file mode 100644
index 0000000..754265f
--- /dev/null
+++ b/lobby/net/AreaMessages.js
@@ -0,0 +1,135 @@
+"use strict";
+const createLogger = require('logging').default;
+const logger = createLogger('AreaMessages');
+
+const Areas = require('../global/Areas.js');
+const logEvent = require('../global/EventLogger.js').logEvent;
+
+server.handleMessage('get_population', async (client, args) => {
+    const areaId = args.area;
+    if (areaId === undefined) {
+        logger.warn('Got get_population message without area id!  Ignoring.');
+        return;
+    }
+
+    let population = 0;
+    if (areaId in Areas.Groups) {
+        for (const area of Areas.Groups[areaId]) {
+            const users = await redis.getUserIdsInArea(area, client.game);
+            population += users.length;
+        }
+    } else {
+        population = (await redis.getUserIdsInArea(areaId, client.game)).length;
+    }
+    client.send('population_resp', {area: areaId, population: population});
+
+});
+
+server.handleMessage('enter_area', async (client, args) => {
+    const areaId = args.area;
+    logEvent('enter_area', client, args.version, {'area': areaId});
+    if (areaId === undefined) {
+        logger.warn('Got enter_area message without area id!  Ignoring.');
+        return;
+    }
+    if (areaId == 33) {
+        // HACK
+        return;
+    }
+    client.areaId = areaId;
+    await redis.addUserToArea(client.userId, areaId, client.game);
+});
+
+server.handleMessage('leave_area', async (client, args) => {
+    logEvent('leave_area', client, args.version, {'area': client.areaId});
+    if (!client.areaId) {
+        // this.logger.error("Got leave_area without being in an area!");
+        return;
+    }
+    const oldAreaId = client.areaId;
+    client.areaId = 0;
+    client.sliceStart = 0;
+    client.sliceEnd = 0;
+    await redis.removeUserFromArea(client.userId, oldAreaId, client.game);
+});
+
+server.handleMessage('get_players', async (client, args) => {
+    const start = args.start;
+    const end = args.end + 1;
+
+    if (!client.areaId) {
+        logger.warn("Got get_players without being in an area!");
+        return;
+    }
+
+    client.sliceStart = start;
+    client.sliceEnd = end;
+
+    const users = await redis.getUsersInArea(client.areaId, client.game);
+
+    const players = [];
+    for (const user of users) {
+        if (user.id == client.userId) {
+            // Don't add ourselves in.
+            continue;
+        }
+        players.push([user.user, user.id, user.icon, user.stats[0], user.stats[1], user.stats[2], user.phone, user.opponent]);
+    }
+    client.send('players_list', {players: players.slice(client.sliceStart, client.sliceEnd)});
+
+});
+
+process.on('update_players_list', (args) => {
+    const areaId = args.area;
+    const game = args.game;
+    const users = args.users;
+
+    for (const client of server.connections) {
+        if (client.areaId == areaId && client.game == game) {
+            const players = [];
+            for (const user of users) {
+                if (user.id == client.userId) {
+                    // Don't add ourselves in.
+                    continue;
+                }
+                players.push([user.user, user.id, user.icon, user.stats[0], user.stats[1], user.stats[2], user.phone, user.opponent]);
+            }
+            client.send('players_list', {players: players.slice(client.sliceStart, client.sliceEnd)});
+        }
+    }
+});
+
+server.handleMessage('game_started', async (client, args) => {
+    const playerId = args.user;
+    logEvent('game_started', client, args.version, {'area': client.areaId, 'opponent': playerId});
+
+    await redis.setInGame(client.userId, 1);
+    await redis.setInGame(playerId, 1);
+
+    await redis.sendUsersInArea(client.areaId, client.game);
+    await redis.sendGamesPlayingInArea(client.areaId, client.game);
+
+    await redis.removeOngoingResults(client.userId, client.game);  // Just in case ongoing results didn't get removed after a previous game
+    await redis.removeOngoingResults(playerId, client.game);
+});
+
+server.handleMessage('game_finished', async (client, args) => {
+    logEvent('game_finished', client, args.version, {'area': client.areaId, 'opponent': client.opponentId});
+    await redis.setInGame(client.userId, 0);
+    await redis.sendGamesPlayingInArea(client.areaId, client.game);
+
+    await redis.removeOngoingResults(client.opponentId, client.game);
+    await redis.removeOngoingResults(client.userId, client.game);
+});
+
+process.on('update_games_playing', async (args) => {
+    const areaId = args.area;
+    const game = args.game;
+    const gamesPlaying = args.games;
+
+    for (const client of server.connections) {
+        if (client.areaId == areaId && client.game == game) {
+            client.send('games_playing', {games: gamesPlaying});
+        }
+    }
+});
diff --git a/lobby/net/ChallengeMessages.js b/lobby/net/ChallengeMessages.js
new file mode 100644
index 0000000..2f156d8
--- /dev/null
+++ b/lobby/net/ChallengeMessages.js
@@ -0,0 +1,375 @@
+"use strict";
+const createLogger = require('logging').default;
+const logger = createLogger('ChallengeMessages');
+
+const logEvent = require('../global/EventLogger.js').logEvent;
+
+// Hack for baseball
+const busyTimeouts = {};
+
+server.handleMessage('set_phone_status', async (client, args) => {
+    const status = args.status;
+    if (status === undefined) {
+        return;
+    }
+    if (!client.areaId) {
+        logger.warn('Attempted to set phone status without being in an area.');
+        return;
+    }
+
+    await redis.setPhoneStatus(client.userId, client.areaId, client.game, status);
+});
+
+server.handleMessage('challenge_player', async (client, args) => {
+    const challengeUserId = args.user;
+    const stadium = args.stadium;
+    logEvent('challenge_player', client, args.version, {'area': client.areaId, 'opponent': challengeUserId, 'stadium': stadium});
+
+    if (challengeUserId === undefined) {
+        logger.error("Missing user argument on challenge_player!");
+        return;
+    } else if (stadium === undefined) {
+        logger.error("Missing stadium argument for challenge_player!");
+        return;
+    } else if (client.areaId == 0) {
+        logger.error(`Got challenge_player but I'm (${client.userId}) not in an area!`);
+        return;
+    }
+
+    // Check if the opponent is in our area.
+    const users = await redis.getUserIdsInArea(client.areaId, client.game);
+    if (!users.includes(challengeUserId)) {
+        logger.error(`Got challenge_player but our player (${challengeUserId}) isn't in area (${client.areaId})!`);
+        return;
+    }
+
+    if (client.userId in busyTimeouts) {
+        clearTimeout(busyTimeouts[client.userId]);
+    }
+
+    process.send({cmd: "receive_challenge", user: challengeUserId,
+                                            opponent: client.userId,
+                                            stadium: stadium});
+});
+
+process.on('receive_challenge', async (args) => {
+    const userId = args.user;
+    const opponentId = args.opponent;
+    const stadium = args.stadium;
+
+    for (const client of server.connections) {
+        if (client.userId == userId) {
+            if (client.areaId == 0) {
+                logger.error(`Got receive_challenge from server but I'm (${client.userId}) not in an area!`);
+                return;
+            } else if (stadium === undefined) {
+                logger.error("Missing stadium argument for receive_challenge!");
+                return;
+            }
+            // Check if the opponent is in our area.
+            const users = await redis.getUserIdsInArea(client.areaId, client.game);
+            if (!users.includes(opponentId)) {
+                logger.error(`Got receive_challenge but our player (${opponentId}) isn't in area (${client.areaId})!`);
+                return;
+            }
+
+            const userData = await redis.getUserById(opponentId);
+            if (Object.keys(userData).length == 0) {
+                logger.error(`Got receive_challenge but our player (${opponentId}) doesn't exist!`);
+                return;
+            }
+            client.send("receive_challenge", {user: opponentId, stadium: stadium, name: userData.user.toUpperCase()});
+            return;
+        }
+    }
+});
+
+server.handleMessage('challenge_timeout', async (client, args) => {
+  const challengeUserId = args.user;
+  logEvent('challenge_timeout', client, args.version, {'area': client.areaId, 'opponent': challengeUserId});
+  if (challengeUserId === undefined) {
+      logger.error("Missing user argument on challenge_timeout!");
+      return;
+  } else if (client.areaId == 0) {
+      logger.error(`Got challenge_timeout but I'm (${client.userId}) not in an area!`);
+      return;
+  }
+
+  // Check if the opponent is in our area.
+  const users = await redis.getUserIdsInArea(client.areaId, client.game);
+  if (!users.includes(challengeUserId)) {
+      logger.error(`Got challenge_timeout but our player (${challengeUserId}) isn't in area (${client.areaId})!`);
+      return;
+  }
+
+  process.send({cmd: "challenge_timeout", user: challengeUserId,
+                                          opponent: client.userId});
+});
+
+process.on('challenge_timeout', async (args) => {
+    const userId = args.user;
+    const opponentId = args.opponent;
+
+    for (const client of server.connections) {
+        if (client.userId == userId) {
+            if (client.areaId == 0) {
+                logger.error(`Got challenge_timeout from server but I'm (${client.userId}) not in an area!`);
+                return;
+            }
+
+            // Check if the opponent is in our area.
+            const users = await redis.getUserIdsInArea(client.areaId, client.game);
+            if (!users.includes(opponentId)) {
+                logger.error(`Got challenge_timeout but our player (${opponentId}) isn't in area (${client.areaId})!`);
+                return;
+            }
+            client.send("decline_challenge", {not_responding: 1});
+            return;
+        }
+    }
+});
+
+server.handleMessage('receiver_busy', async (client,args) => {
+    const userId = args.user;
+
+    if (userId === undefined) {
+        logger.error("Missing user argument on receiver_busy!");
+        return;
+    } else if (client.areaId == 0) {
+        logger.error(`Got receiver_busy but I'm (${client.userId}) not in an area!`);
+        return;
+    }
+
+    const users = await redis.getUserIdsInArea(client.areaId, client.game);
+    if (!users.includes(userId)) {
+        logger.error(`Got receiver_busy but our player (${userId}) isn't in area (${client.areaId})!`);
+        return;
+    }
+
+    process.send({cmd: 'receiver_busy', user: userId,
+                                        opponent: client.userId});
+});
+
+process.on('receiver_busy', async (args) => {
+    const userId = args.user;
+    const opponentId = args.opponent;
+
+    for (const client of server.connections) {
+        if (client.userId == userId) {
+            if (client.areaId == 0) {
+                logger.error(`Got receiver_busy but I'm (${client.userId}) not in an area!`);
+                return;
+            }
+            // Check if the opponent is in our area.
+            const users = await redis.getUserIdsInArea(client.areaId, client.game);
+            if (!users.includes(opponentId)) {
+                logger.error(`Got receiver_busy but our player (${opponentId}) isn't in area (${client.areaId})!`);
+                return;
+            }
+
+            client.send("receiver_busy");
+
+            // HACK: In baseball, the game does not automatically hang up
+            // the phone and return to the users list.  We have to send a
+            // decline_challenge message to get back there.
+            // TODO: Possibly make this a client-sided hack?
+            if (client.game == "baseball") {
+                busyTimeouts[client.userId] = setTimeout((client) => {
+                    client.send("decline_challenge", {not_responding: 1});
+                }, 7000, client);
+            }
+            return;
+        }
+    }
+});
+
+server.handleMessage('considering_challenge', async (client, args) => {
+    const userId = args.user;
+
+    if (userId === undefined) {
+        logger.error("Missing user argument on considering_challenge!");
+        return;
+    } else if (client.areaId == 0) {
+        logger.error(`Got considering_challenge but I'm (${client.userId}) not in an area!`);
+        return;
+    }
+
+    // Check if the opponent is in our area.
+    const users = await redis.getUserIdsInArea(client.areaId, client.game);
+    if (!users.includes(userId)) {
+        logger.error(`Got considering_challenge but our player (${userId}) isn't in area (${client.areaId})!`);
+        return;
+    }
+
+    client.opponentId = userId;
+    process.send({cmd: 'considering_challenge', user: userId,
+                                                opponent: client.userId});
+});
+
+process.on('considering_challenge', async (args) => {
+    const userId = args.user;
+    const opponentId = args.opponent;
+
+    for (const client of server.connections) {
+        if (client.userId == userId) {
+            if (client.areaId == 0) {
+                logger.error(`Got considering_challenge but I'm (${client.userId}) not in an area!`);
+                return;
+            }
+            // Check if the opponent is in our area.
+            const users = await redis.getUserIdsInArea(client.areaId, client.game);
+            if (!users.includes(opponentId)) {
+                logger.error(`Got considering_challenge but our player (${opponentId}) isn't in area (${client.areaId})!`);
+                return;
+            }
+
+            client.opponentId = opponentId;
+            client.send("considering_challenge");
+            return;
+        }
+    }
+});
+
+server.handleMessage('counter_challenge', async (client, args) => {
+    const stadium = args.stadium;
+    logEvent('counter_challenge', client, args.version, {'area': client.areaId, 'opponent': client.opponentId, 'stadium': stadium});
+
+    if (stadium === undefined) {
+        logger.error("Got counter_challenge but stadium is not defined!");
+        return;
+    }
+    else if (client.areaId == 0) {
+        logger.error(`Got counter_challenge but I'm (${client.userId}) not in an area!`);
+        return;
+    }
+
+    // Check if the opponent is in our area.
+    const users = await redis.getUserIdsInArea(client.areaId, client.game);
+    if (!users.includes(client.opponentId)) {
+        logger.error(`Got counter_challenge but our player (${client.opponentId}) isn't in area (${client.areaId})!`);
+        return;
+    }
+
+    process.send({cmd: 'counter_challenge', user: client.opponentId,
+                                            stadium: stadium});
+});
+
+process.on('counter_challenge', async (args) => {
+    const userId = args.user;
+    const stadium = args.stadium;
+
+    for (const client of server.connections) {
+        if (client.userId == userId) {
+            if (client.areaId == 0) {
+                logger.error(`Got considering_challenge but I'm (${client.userId}) not in an area!`);
+                return;
+            }
+            // Check if the opponent is in our area.
+            const users = await redis.getUserIdsInArea(client.areaId, client.game);
+            if (!users.includes(client.opponentId)) {
+                logger.error(`Got considering_challenge but our player (${client.opponentId}) isn't in area (${client.areaId})!`);
+                return;
+            }
+            client.send('counter_challenge', {stadium: stadium});
+            return;
+        }
+    }
+});
+
+server.handleMessage('decline_challenge', async (client, args) => {
+    const challengeUserId = args.user;
+    logEvent('decline_challenge', client, args.version, {'area': client.areaId, 'opponent': challengeUserId});
+
+    if (challengeUserId === undefined) {
+        logger.error("Missing user argument on decline_challenge!");
+        return;
+    } else if (client.areaId == 0) {
+        logger.error(`Got decline_challenge but I'm (${client.userId}) not in an area!`);
+        return;
+    }
+
+    // Check if the opponent is in our area.
+    const users = await redis.getUserIdsInArea(client.areaId, client.game);
+    if (!users.includes(challengeUserId)) {
+        logger.error(`Got decline_challenge but our player (${challengeUserId}) isn't in area (${client.areaId})!`);
+        return;
+    }
+
+    client.opponentId = 0;
+    process.send({cmd: 'decline_challenge', user: challengeUserId,
+                                            opponent: client.userId,
+                                            not_responding: 0});
+});
+
+process.on('decline_challenge', async (args) => {
+    const userId = args.user;
+    const opponentId = args.opponent;
+    const notResponding = args.not_responding;
+
+    for (const client of server.connections) {
+        if (client.userId == userId) {
+            if (client.areaId == 0) {
+                logger.error(`Got decline_challenge but I'm (${client.userId}) not in an area!`);
+                return;
+            }
+            // Check if the opponent is in our area.
+            const users = await redis.getUserIdsInArea(client.areaId, client.game);
+            if (!users.includes(opponentId)) {
+                logger.error(`Got decline_challenge but our player (${opponentId}) isn't in area (${client.areaId})!`);
+                return;
+            }
+
+            client.opponentId = 0;
+            client.send("decline_challenge", {not_responding: notResponding});
+            return;
+        }
+    }
+});
+
+server.handleMessage('accept_challenge', async (client, args) => {
+    const challengeUserId = args.user;
+    logEvent('accept_challenge', client, args.version, {'area': client.areaId, 'opponent': challengeUserId});
+
+    if (challengeUserId === undefined) {
+        logger.error("Missing user argument on accept_challenge!");
+        return;
+    } else if (client.areaId == 0) {
+        logger.error(`Got accept_challenge but I'm (${client.userId}) not in an area!`);
+        return;
+    }
+
+    // Check if the opponent is in our area.
+    const users = await redis.getUserIdsInArea(client.areaId, client.game);
+    if (!users.includes(challengeUserId)) {
+        logger.error(`Got accept_challenge but our player (${challengeUserId}) isn't in area (${client.areaId})!`);
+        return;
+    }
+
+    client.opponentId = challengeUserId;
+    process.send({cmd: 'accept_challenge', user: challengeUserId,
+                                           opponent: client.userId});
+});
+
+process.on('accept_challenge', async (args) => {
+    const userId = args.user;
+    const opponentId = args.opponent;
+
+    for (const client of server.connections) {
+        if (client.userId == userId) {
+            if (client.areaId == 0) {
+                logger.error(`Got accept_challenge but I'm (${client.userId}) not in an area!`);
+                return;
+            }
+            // Check if the opponent is in our area.
+            const users = await redis.getUserIdsInArea(client.areaId, client.game);
+            if (!users.includes(opponentId)) {
+                logger.error(`Got accept_challenge but our player (${opponentId}) isn't in area (${client.areaId})!`);
+                return;
+            }
+
+            client.opponentId = opponentId;
+            client.send("accept_challenge");
+            return;
+        }
+    }
+});
diff --git a/lobby/net/DatabaseMessages.js b/lobby/net/DatabaseMessages.js
new file mode 100644
index 0000000..b55e7aa
--- /dev/null
+++ b/lobby/net/DatabaseMessages.js
@@ -0,0 +1,177 @@
+"use strict";
+const createLogger = require('logging').default;
+const logger = createLogger('DatabaseMessages');
+
+const Areas = require('../global/Areas.js');
+const Stats = require('../global/Stats.js');
+const logEvent = require('../global/EventLogger.js').logEvent;
+
+server.handleMessage("login", async (client, args) => {
+    const username = args.user;
+    const password = args.pass;
+    const game = args.game;
+    const version = args.version;
+
+    if (username === undefined) {
+        client.kick("Missing username parameter!");
+        return;
+    } else if (password === undefined) {
+        client.kick("Missing password parameter!");
+        return;
+    } else if (game === undefined) {
+        client.kick("Missing game parameter!");
+        return;
+    } else if (version == undefined) {
+        client.kick("Missing version paremeter!");
+        return;
+    }
+
+    const games = ["football", "baseball"];
+    if (!games.includes(game)) {
+        client.kick("Game not supported.");
+        return;
+    }
+    client.game = game;
+    client.version = version;
+
+    const user = await database.getUser(username, password, game);
+    logEvent('login', client, args.version, {'user': user.id, 'username': user.user, 'game': game});
+    if (user.error) {
+        client.send("login_resp", {error_code: user.error,
+                                   id: 0,
+                                   response: user.message});
+        return;
+    }
+
+    // Kick the other clients out if they're logged in
+    // as the same user.
+    process.send({cmd: 'kick',
+                  userId: user.id,
+                  type: 901,
+                  reason: "You have been disconnected because someone else just logged in using your account on another computer."});
+
+    // We finish setting up the login details at the end of the event loop
+    // to prevent ourselves from getting kicked out after logging in.
+    setTimeout(() => {
+        client.userId = user.id;
+        client.send("login_resp", {error_code: 0,
+                                   id: user.id,
+                                   response: "All ok"});
+
+    }, 50);
+});
+
+server.handleMessage('get_profile', async (client, args) => {
+    let userId = args.user_id;
+    if (userId === undefined) {
+        // Must be self.
+        userId = client.userId;
+    }
+    const user = await redis.getUserById(userId, client.game);
+    if (Object.keys(user).length == 0)
+        return;
+
+    const profile = [user.icon].concat(user.stats);
+    client.send("profile_info", {profile: profile});
+});
+
+server.handleMessage('set_icon', async (client, args) => {
+    const icon = args.icon;
+    logEvent('set_icon', client, args.version, {'icon': icon});
+
+    if (client.userId == 0) {
+        client.kick("Attempting to set icon without logging in first.");
+        return;
+    } else if (icon === undefined) {
+        logger.warn("Got set_icon with missing icon!  Ignoring.");
+        return;
+    }
+
+    await database.setIcon(client.userId, icon);
+});
+
+server.handleMessage('locate_player', async (client, args) => {
+    const username = args.user;
+    if (client.userId == 0) {
+        client.kick("Attempting to locate player without logging in first.");
+        return;
+    } else if (username == undefined) {
+        logger.warn("Got locate_user without username set.  Ignoring.");
+        return;
+    }
+
+    const response = {code: 0,
+                      areaId: 0,
+                      area: ""};
+
+    const user = await redis.getUserByName(username);
+    if (!user || !user.game || user.game != client.game) {
+        // Player not logged in or in the different game
+        client.send("locate_resp", response);
+        return
+    }
+
+    if (!user.area) {
+        // Logged in but not in an area.
+        response.code = 4;
+    } else if (user.inGame) {
+        response.code = 2;
+    } else {
+        response.code = 1;
+        response.areaId = user.area;
+        response.area = Areas.Areas[user.area] || `Unknown area (${user.area})`;
+    }
+
+    client.send("locate_resp", response);
+});
+
+server.handleMessage("game_results", async (client, args) => {
+    const resultsUserId = args.user;
+    const reportingUserId = client.userId;
+    let isHome;
+    let opponentId;
+    // The home team always reports the game results, so we can use that
+    // to tell whether the results are for the home or away team.
+    // TODO: Verify that this is true for football (it is for baseball)
+    if (reportingUserId == resultsUserId) {
+        isHome = 1;
+        opponentId = client.opponentId;
+    } else {
+        isHome = 0;
+        opponentId = client.userId;
+    }
+    const resultsFields = args.fields;
+    const ongoingResults = Stats.ResultsMappers[client.game](
+        resultsFields, isHome, opponentId
+    );
+    logEvent('game_results', client, args.version, {'results': ongoingResults, 'rawResults': resultsFields});
+
+    await redis.setOngoingResults(resultsUserId, client.game, ongoingResults);
+});
+
+server.handleMessage('get_teams', async (client, args) => {
+    const userId = client.userId;
+    const opponentId = args.opponent_id;
+
+    const game = client.game;
+
+    const userTeamResponse = await database.getTeam(userId, game);
+    const opponentTeamResponse = await database.getTeam(opponentId, game);
+
+    logEvent('get_teams', client, args.version, {'userTeam': userTeamResponse, 'opponentTeam': opponentTeamResponse});
+
+    let userMessages = [];
+    if (userTeamResponse.error) {
+        userMessages.push("User: " + userTeamResponse.message);
+    }
+    if (opponentTeamResponse.error) {
+        userMessages.push("Opponent: " + opponentTeamResponse.message);
+    }
+    if (userMessages.length > 0) {
+        const errorMessage = userMessages.join(" ");
+        client.send("teams", {error: 1, message: errorMessage, user: [], opponent: []});
+    } else {
+        const teams = {error: 0, message: "", user: userTeamResponse.team, opponent: opponentTeamResponse.team};
+        client.send("teams", teams);
+    }
+});
diff --git a/lobby/net/NetworkConnection.js b/lobby/net/NetworkConnection.js
new file mode 100644
index 0000000..30d6632
--- /dev/null
+++ b/lobby/net/NetworkConnection.js
@@ -0,0 +1,97 @@
+"use strict";
+const createLogger = require('logging').default;
+
+class NetworkConnection {
+    constructor(socket) {
+        this.socket = socket;
+        this.logger = createLogger(socket.remoteAddress + ':' + socket.remotePort)
+        this.terminated = false;
+        this.buffer = '';
+
+        this.userId = 0;
+        this.game = '';
+        this.version = '';
+
+        this.areaId = 0;
+
+        this.sliceStart = 0;
+        this.sliceEnd = 0;
+
+        this.opponentId = 0;
+
+        this.receivedHeartbeat = true;
+        this.heartbeatTimer = setTimeout(() => {
+            if (this.terminated) return;
+            if (this.receivedHeartbeat) {
+                this.send("heartbeat");
+                this.receivedHeartbeat = false;
+                this.heartbeatTimer.refresh();
+            } else {
+                this.kick(1, "Heartbeat timeout.");
+            }
+        }, 30 * 1000);
+
+        this.socket.on('close', (hadError) => {
+            if (this.terminated) return;
+            this.logger.debug("Connection closed");
+            server.handleDisconnect(this, true);
+        });
+
+        this.socket.on('error', (error) => {
+            if (this.terminated) return;
+            this.logger.error(`Error on connection ${this.socket.remoteAddress}:${this.socket.remotePort}.`, error);
+            // 'close' event emits after.
+        });
+
+        this.socket.on('data', (data) => {
+            if (this.terminated) return;
+            this.buffer += data;
+            if (this.buffer.includes('\n')) {
+                const messages = this.buffer.split('\n');
+                // Reset the buffer
+                this.buffer = messages.pop();
+
+                for (const message of messages) {
+                    let json;
+                    try {
+                        json = JSON.parse(message)
+                    } catch (e) {
+                        this.logger.error("Received trunciated data!")
+                        continue;
+                    }
+
+                    const command = json.cmd;
+                    if (!command) {
+                        this.logger.warn("Received data without command value!")
+                        continue;
+                    }
+                    delete json.cmd;
+                    this.logger.debug(`Received "${command}" message:`, json);
+
+                    if (command in server.messages) {
+                        server.messages[command](this, json);
+                    } else {
+                        this.logger.error("Got unknown message:", {command});
+                    }
+                }
+            }
+        });
+    }
+
+    send(command, object = {}) {
+        if (this.terminated) return;
+        object.cmd = command;
+        const json = JSON.stringify(object);
+        this.logger.debug("Writing to connection:", json);
+        this.socket.write(json + "\n");
+    }
+
+    kick(type, reason) {
+        if (this.terminated) return;
+        this.logger.warn("Kicking:", reason);
+        this.send("disconnect", {"type": type, "message": reason});
+        server.handleDisconnect(this);
+    }
+}
+
+module.exports = NetworkConnection;
diff --git a/lobby/net/NetworkListener.js b/lobby/net/NetworkListener.js
new file mode 100644
index 0000000..f019365
--- /dev/null
+++ b/lobby/net/NetworkListener.js
@@ -0,0 +1,113 @@
+"use strict";
+const createLogger = require('logging').default;
+const net = require('net');
+const tls = require('tls');
+const fs = require('fs');
+
+const NetworkConnection = require('./NetworkConnection');
+
+class NetworkListener {
+    constructor(config) {
+        this.logger = createLogger('NetworkListener');
+        this.config = config;
+        this.connections = new Set();
+
+        // message: function
+        // Add new messages with server.handleMessage.
+        this.messages = {
+            'heartbeat': (client, args) => {
+                client.receivedHeartbeat = true;
+                client.heartbeatTimer.refresh();
+            },
+            'disconnect': async (client, args) => {
+                await this.handleDisconnect(client);
+            }
+        };
+
+        const host = this.config['host'];
+        const port = Number(this.config['port']);
+        const keyPath = this.config['key'];
+        const certPath = this.config['cert'];
+
+        if (keyPath && certPath) {
+            const readCerts = (keyPath, certPath) => {
+                return {
+                    key: fs.readFileSync(keyPath),
+                    cert: fs.readFileSync(certPath)
+                }
+            }
+            this.server = tls.createServer(readCerts(keyPath, certPath), (socket) => {
+                this.logger.debug('Got incoming connection from ' + socket.remoteAddress + ':' + socket.remotePort);
+                socket.setEncoding('utf-8');
+                this.connections.add(new NetworkConnection(socket))
+            });
+
+            // Watch the cert path for updates, if it does, re-read the latest
+            // certs, active connections will not be interrupted.
+            let certUpdateTimeout;
+            fs.watch(keyPath, () => {
+                this.logger.info("TLS Certificates updated, Reading in 1 second...");
+                clearTimeout(certUpdateTimeout);
+                certUpdateTimeout = setTimeout(() => {
+                    this.server.setSecureContext(readCerts(keyPath, certPath));
+                    this.logger.info("Updated secure context.");
+            }, 1000);
+            });
+
+            this.server.listen(port, host, () => {
+                this.logger.info('Now listening for TLS connections on ' + host + ':' + port);
+            });
+        } else {
+            this.logger.warn("Creating raw TCP server, DO NOT USE THIS IN PRODUCTION!!!")
+            this.server = net.createServer((socket) => {
+                this.logger.debug('Got incoming connection from ' + socket.remoteAddress + ':' + socket.remotePort);
+                socket.setEncoding('utf-8');
+                this.connections.add(new NetworkConnection(socket))
+            });
+
+            this.server.listen(port, host, () => {
+                this.logger.info('Now listening for TCP connections on ' + host + ':' + port);
+            });
+        }
+
+        process.on("kick", (args) => {
+            const userId = args.userId;
+            const type = args.type;
+            const reason = args.reason;
+            if (!userId) {
+                this.logger.warn("Received kick message without user id!  Ignoring.");
+                return;
+            }
+            for (const client of this.connections) {
+                if (client.userId == userId) {
+                    client.kick(type, reason);
+                    break;
+                }
+            }
+        });
+
+    }
+
+    handleMessage(message, func) {
+        this.messages[message] = func;
+    }
+
+    async handleDisconnect(client, lost = false) {
+        if (client.terminated) return;
+        client.terminated = true;
+        clearTimeout(client.heartbeatTimer);
+
+        if (client.userId && client.areaId) {
+            await redis.removeUserFromArea(client.userId, client.areaId, client.game);
+        }
+        if (client.userId) {
+            await redis.removeUser(client.userId, client.game);
+        }
+
+        this.connections.delete(client);
+        client.socket.end();
+    }
+
+}
+
+module.exports = NetworkListener;
diff --git a/lobby/net/SessionMessages.js b/lobby/net/SessionMessages.js
new file mode 100644
index 0000000..ccd5b74
--- /dev/null
+++ b/lobby/net/SessionMessages.js
@@ -0,0 +1,103 @@
+"use strict";
+const createLogger = require('logging').default;
+const logger = createLogger('SessionMessages');
+
+const logEvent = require('../global/EventLogger.js').logEvent;
+
+server.handleMessage('send_session', async (client, args) => {
+    const userId = args.user;
+    const sessionId = args.session;
+    logEvent('send_session', client, args.version, {'session': sessionId, 'opponent': userId});
+
+    if (userId === undefined) {
+        logger.error("Missing user argument on send_session!");
+        return;
+    } else if (client.areaId == 0) {
+        logger.error(`Got send_session but I'm (${client.userId}) not in an area!`);
+        return;
+    }
+
+    // Check if the opponent is in our area.
+    const users = await redis.getUserIdsInArea(client.areaId, client.game);
+    if (!users.includes(userId)) {
+        logger.error(`Got send_session but our player (${userId}) isn't in area (${client.areaId})!`);
+        return;
+    }
+
+    process.send({cmd: 'game_session', user: userId,
+                                       opponent: client.userId,
+                                       session: sessionId});
+});
+
+process.on('game_session', async (args) => {
+    const userId = args.user;
+    const opponentId = args.opponent;
+    const sessionId = args.session;
+
+    for (const client of server.connections) {
+        if (client.userId == userId) {
+            if (client.areaId == 0) {
+                logger.error(`Got game_session but I'm (${client.userId}) not in an area!`);
+                return;
+            }
+            // Check if the opponent is in our area.
+            const users = await redis.getUserIdsInArea(client.areaId, client.game);
+            if (!users.includes(opponentId)) {
+                logger.error(`Got game_session but our player (${opponentId}) isn't in area (${client.areaId})!`);
+                return;
+            }
+
+            client.send("game_session", {session: sessionId});
+            return;
+        }
+    }
+});
+
+server.handleMessage('send_relay', async (client, args) => {
+    const userId = args.user;
+    const relayId = args.relay;
+    logEvent('send_relay', client, args.version, {'relay': relayId, 'opponent': userId});
+
+    if (userId === undefined) {
+        logger.error("Missing user argument on send_relay!");
+        return;
+    } else if (client.areaId == 0) {
+        logger.error(`Got send_relay but I'm (${client.userId}) not in an area!`);
+        return;
+    }
+
+    // Check if the opponent is in our area.
+    const users = await redis.getUserIdsInArea(client.areaId, client.game);
+    if (!users.includes(userId)) {
+        logger.error(`Got send_relay but our player (${userId}) isn't in area (${client.areaId})!`);
+        return;
+    }
+
+    process.send({cmd: 'game_relay', user: userId,
+                                     opponent: client.userId,
+                                     relay: relayId});
+});
+
+process.on('game_relay', async (args) => {
+    const userId = args.user;
+    const opponentId = args.opponent;
+    const relayId = args.relay;
+
+    for (const client of server.connections) {
+        if (client.userId == userId) {
+            if (client.areaId == 0) {
+                logger.error(`Got game_session but I'm (${client.userId}) not in an area!`);
+                return;
+            }
+            // Check if the opponent is in our area.
+            const users = await redis.getUserIdsInArea(client.areaId, client.game);
+            if (!users.includes(opponentId)) {
+                logger.error(`Got game_session but our player (${opponentId}) isn't in area (${client.areaId})!`);
+                return;
+            }
+
+            client.send("game_relay", {relay: relayId});
+            return;
+        }
+    }
+});
diff --git a/lobby/package-lock.json b/lobby/package-lock.json
new file mode 100644
index 0000000..f031d60
--- /dev/null
+++ b/lobby/package-lock.json
@@ -0,0 +1,1283 @@
+{
+  "name": "scummvm-multiplayer-lobby",
+  "version": "1.0.0",
+  "lockfileVersion": 2,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "scummvm-multiplayer-lobby",
+      "version": "1.0.0",
+      "license": "MIT",
+      "dependencies": {
+        "bent": "^7.3.12",
+        "ioredis": "^4.27.7",
+        "js-yaml": "^4.1.0",
+        "logging": "^3.3.0"
+      },
+      "optionalDependencies": {
+        "discord.js": "^13.1.0",
+        "table": "^6.8.0"
+      }
+    },
+    "node_modules/@discordjs/builders": {
+      "version": "0.5.0",
+      "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-0.5.0.tgz",
+      "integrity": "sha512-HP5y4Rqw68o61Qv4qM5tVmDbWi4mdTFftqIOGRo33SNPpLJ1Ga3KEIR2ibKofkmsoQhEpLmopD1AZDs3cKpHuw==",
+      "optional": true,
+      "dependencies": {
+        "@sindresorhus/is": "^4.0.1",
+        "discord-api-types": "^0.22.0",
+        "ow": "^0.27.0",
+        "ts-mixer": "^6.0.0",
+        "tslib": "^2.3.0"
+      },
+      "engines": {
+        "node": ">=14.0.0",
+        "npm": ">=7.0.0"
+      }
+    },
+    "node_modules/@discordjs/collection": {
+      "version": "0.2.1",
+      "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-0.2.1.tgz",
+      "integrity": "sha512-vhxqzzM8gkomw0TYRF3tgx7SwElzUlXT/Aa41O7mOcyN6wIJfj5JmDWaO5XGKsGSsNx7F3i5oIlrucCCWV1Nog==",
+      "optional": true,
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
+    "node_modules/@discordjs/form-data": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/@discordjs/form-data/-/form-data-3.0.1.tgz",
+      "integrity": "sha512-ZfFsbgEXW71Rw/6EtBdrP5VxBJy4dthyC0tpQKGKmYFImlmmrykO14Za+BiIVduwjte0jXEBlhSKf0MWbFp9Eg==",
+      "optional": true,
+      "dependencies": {
+        "asynckit": "^0.4.0",
+        "combined-stream": "^1.0.8",
+        "mime-types": "^2.1.12"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/@sapphire/async-queue": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.1.4.tgz",
+      "integrity": "sha512-fFrlF/uWpGOX5djw5Mu2Hnnrunao75WGey0sP0J3jnhmrJ5TAPzHYOmytD5iN/+pMxS+f+u/gezqHa9tPhRHEA==",
+      "optional": true,
+      "engines": {
+        "node": ">=14",
+        "npm": ">=6"
+      }
+    },
+    "node_modules/@sindresorhus/is": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.2.0.tgz",
+      "integrity": "sha512-VkE3KLBmJwcCaVARtQpfuKcKv8gcBmUubrfHGF84dXuuW6jgsRYxPtzcIhPyK9WAPpRt2/xY6zkD9MnRaJzSyw==",
+      "optional": true,
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sindresorhus/is?sponsor=1"
+      }
+    },
+    "node_modules/@types/node": {
+      "version": "16.10.1",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-16.10.1.tgz",
+      "integrity": "sha512-4/Z9DMPKFexZj/Gn3LylFgamNKHm4K3QDi0gz9B26Uk0c8izYf97B5fxfpspMNkWlFupblKM/nV8+NA9Ffvr+w==",
+      "optional": true
+    },
+    "node_modules/@types/ws": {
+      "version": "7.4.7",
+      "resolved": "https://registry.npmjs.org/@types/ws/-/ws-7.4.7.tgz",
+      "integrity": "sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==",
+      "optional": true,
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
+    "node_modules/ajv": {
+      "version": "8.10.0",
+      "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.10.0.tgz",
+      "integrity": "sha512-bzqAEZOjkrUMl2afH8dknrq5KEk2SrwdBROR+vH1EKVQTqaUbJVPdc/gEdggTMM0Se+s+Ja4ju4TlNcStKl2Hw==",
+      "optional": true,
+      "dependencies": {
+        "fast-deep-equal": "^3.1.1",
+        "json-schema-traverse": "^1.0.0",
+        "require-from-string": "^2.0.2",
+        "uri-js": "^4.2.2"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/epoberezkin"
+      }
+    },
+    "node_modules/ansi-regex": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+      "optional": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/ansi-styles": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+      "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+      "dependencies": {
+        "color-convert": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+      }
+    },
+    "node_modules/argparse": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+      "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
+    },
+    "node_modules/astral-regex": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz",
+      "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==",
+      "optional": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/asynckit": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+      "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=",
+      "optional": true
+    },
+    "node_modules/bent": {
+      "version": "7.3.12",
+      "resolved": "https://registry.npmjs.org/bent/-/bent-7.3.12.tgz",
+      "integrity": "sha512-T3yrKnVGB63zRuoco/7Ybl7BwwGZR0lceoVG5XmQyMIH9s19SV5m+a8qam4if0zQuAmOQTyPTPmsQBdAorGK3w==",
+      "dependencies": {
+        "bytesish": "^0.4.1",
+        "caseless": "~0.12.0",
+        "is-stream": "^2.0.0"
+      }
+    },
+    "node_modules/bytesish": {
+      "version": "0.4.4",
+      "resolved": "https://registry.npmjs.org/bytesish/-/bytesish-0.4.4.tgz",
+      "integrity": "sha512-i4uu6M4zuMUiyfZN4RU2+i9+peJh//pXhd9x1oSe1LBkZ3LEbCoygu8W0bXTukU1Jme2txKuotpCZRaC3FLxcQ=="
+    },
+    "node_modules/callsites": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+      "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+      "optional": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/caseless": {
+      "version": "0.12.0",
+      "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
+      "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw="
+    },
+    "node_modules/chalk": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+      "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+      "dependencies": {
+        "ansi-styles": "^4.1.0",
+        "supports-color": "^7.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/chalk?sponsor=1"
+      }
+    },
+    "node_modules/cluster-key-slot": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.0.tgz",
+      "integrity": "sha512-2Nii8p3RwAPiFwsnZvukotvow2rIHM+yQ6ZcBXGHdniadkYGZYiGmkHJIbZPIV9nfv7m/U1IPMVVcAhoWFeklw==",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/color-convert": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+      "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+      "dependencies": {
+        "color-name": "~1.1.4"
+      },
+      "engines": {
+        "node": ">=7.0.0"
+      }
+    },
+    "node_modules/color-name": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+      "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
+    },
+    "node_modules/combined-stream": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+      "optional": true,
+      "dependencies": {
+        "delayed-stream": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/debug": {
+      "version": "4.3.2",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz",
+      "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==",
+      "dependencies": {
+        "ms": "2.1.2"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/delayed-stream": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+      "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=",
+      "optional": true,
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/denque": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/denque/-/denque-1.5.0.tgz",
+      "integrity": "sha512-CYiCSgIF1p6EUByQPlGkKnP1M9g0ZV3qMIrqMqZqdwazygIA/YP2vrbcyl1h/WppKJTdl1F85cXIle+394iDAQ==",
+      "engines": {
+        "node": ">=0.10"
+      }
+    },
+    "node_modules/discord-api-types": {
+      "version": "0.22.0",
+      "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.22.0.tgz",
+      "integrity": "sha512-l8yD/2zRbZItUQpy7ZxBJwaLX/Bs2TGaCthRppk8Sw24LOIWg12t9JEreezPoYD0SQcC2htNNo27kYEpYW/Srg==",
+      "optional": true,
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/discord.js": {
+      "version": "13.1.0",
+      "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-13.1.0.tgz",
+      "integrity": "sha512-gxO4CXKdHpqA+WKG+f5RNnd3srTDj5uFJHgOathksDE90YNq/Qijkd2WlMgTTMS6AJoEnHxI7G9eDQHCuZ+xDA==",
+      "optional": true,
+      "dependencies": {
+        "@discordjs/builders": "^0.5.0",
+        "@discordjs/collection": "^0.2.1",
+        "@discordjs/form-data": "^3.0.1",
+        "@sapphire/async-queue": "^1.1.4",
+        "@types/ws": "^7.4.7",
+        "discord-api-types": "^0.22.0",
+        "node-fetch": "^2.6.1",
+        "ws": "^7.5.1"
+      },
+      "engines": {
+        "node": ">=16.6.0",
+        "npm": ">=7.0.0"
+      }
+    },
+    "node_modules/dot-prop": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz",
+      "integrity": "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==",
+      "optional": true,
+      "dependencies": {
+        "is-obj": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/emoji-regex": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+      "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+      "optional": true
+    },
+    "node_modules/esutils": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+      "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/fast-deep-equal": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+      "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+      "optional": true
+    },
+    "node_modules/has-flag": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+      "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/ioredis": {
+      "version": "4.27.7",
+      "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-4.27.7.tgz",
+      "integrity": "sha512-lqvFFmUyGIHlrNyDvBoakzy1+ioJzNyoP6CP97GWtdTjWq9IOAnv6l0HUTsqhvd/z9etGgtrDHZ4kWCMAwNkug==",
+      "dependencies": {
+        "cluster-key-slot": "^1.1.0",
+        "debug": "^4.3.1",
+        "denque": "^1.1.0",
+        "lodash.defaults": "^4.2.0",
+        "lodash.flatten": "^4.4.0",
+        "lodash.isarguments": "^3.1.0",
+        "p-map": "^2.1.0",
+        "redis-commands": "1.7.0",
+        "redis-errors": "^1.2.0",
+        "redis-parser": "^3.0.0",
+        "standard-as-callback": "^2.1.0"
+      },
+      "engines": {
+        "node": ">=6"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/ioredis"
+      }
+    },
+    "node_modules/is-fullwidth-code-point": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+      "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+      "optional": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/is-obj": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz",
+      "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==",
+      "optional": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/is-stream": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
+      "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/js-yaml": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
+      "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+      "dependencies": {
+        "argparse": "^2.0.1"
+      },
+      "bin": {
+        "js-yaml": "bin/js-yaml.js"
+      }
+    },
+    "node_modules/json-schema-traverse": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+      "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+      "optional": true
+    },
+    "node_modules/lodash.defaults": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
+      "integrity": "sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw="
+    },
+    "node_modules/lodash.flatten": {
+      "version": "4.4.0",
+      "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz",
+      "integrity": "sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8="
+    },
+    "node_modules/lodash.isarguments": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
+      "integrity": "sha1-L1c9hcaiQon/AGY7SRwdM4/zRYo="
+    },
+    "node_modules/lodash.isequal": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
+      "integrity": "sha1-QVxEePK8wwEgwizhDtMib30+GOA=",
+      "optional": true
+    },
+    "node_modules/lodash.truncate": {
+      "version": "4.4.2",
+      "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz",
+      "integrity": "sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM=",
+      "optional": true
+    },
+    "node_modules/logging": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmjs.org/logging/-/logging-3.3.0.tgz",
+      "integrity": "sha512-Hnmu3KlGTbXMVS7ONjBpnjjiF9cBlK5qsmj77sOcqRkNpvO9ouUGPKe2PmBCWWYpKAbxb96b08cYEv4hiBk3lQ==",
+      "dependencies": {
+        "chalk": "^4.1.0",
+        "debug": "^4.3.1",
+        "nicely-format": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 4"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.49.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.49.0.tgz",
+      "integrity": "sha512-CIc8j9URtOVApSFCQIF+VBkX1RwXp/oMMOrqdyXSBXq5RWNEsRfyj1kiRnQgmNXmHxPoFIxOroKA3zcU9P+nAA==",
+      "optional": true,
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "2.1.32",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.32.tgz",
+      "integrity": "sha512-hJGaVS4G4c9TSMYh2n6SQAGrC4RnfU+daP8G7cSCmaqNjiOoUY0VHCMS42pxnQmVF1GWwFhbHWn3RIxCqTmZ9A==",
+      "optional": true,
+      "dependencies": {
+        "mime-db": "1.49.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+      "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
+    },
+    "node_modules/nicely-format": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/nicely-format/-/nicely-format-1.1.0.tgz",
+      "integrity": "sha1-bDUT0fOAd9Ze2XIecWqOGRe6J7Y=",
+      "dependencies": {
+        "ansi-styles": "^2.2.1",
+        "esutils": "^2.0.2"
+      }
+    },
+    "node_modules/nicely-format/node_modules/ansi-styles": {
+      "version": "2.2.1",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz",
+      "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/node-fetch": {
+      "version": "2.6.7",
+      "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz",
+      "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==",
+      "optional": true,
+      "dependencies": {
+        "whatwg-url": "^5.0.0"
+      },
+      "engines": {
+        "node": "4.x || >=6.0.0"
+      },
+      "peerDependencies": {
+        "encoding": "^0.1.0"
+      },
+      "peerDependenciesMeta": {
+        "encoding": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/ow": {
+      "version": "0.27.0",
+      "resolved": "https://registry.npmjs.org/ow/-/ow-0.27.0.tgz",
+      "integrity": "sha512-SGnrGUbhn4VaUGdU0EJLMwZWSupPmF46hnTRII7aCLCrqixTAC5eKo8kI4/XXf1eaaI8YEVT+3FeGNJI9himAQ==",
+      "optional": true,
+      "dependencies": {
+        "@sindresorhus/is": "^4.0.1",
+        "callsites": "^3.1.0",
+        "dot-prop": "^6.0.1",
+        "lodash.isequal": "^4.5.0",
+        "type-fest": "^1.2.1",
+        "vali-date": "^1.0.0"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/p-map": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz",
+      "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/punycode": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
+      "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==",
+      "optional": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/redis-commands": {
+      "version": "1.7.0",
+      "resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.7.0.tgz",
+      "integrity": "sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ=="
+    },
+    "node_modules/redis-errors": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
+      "integrity": "sha1-62LSrbFeTq9GEMBK/hUpOEJQq60=",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/redis-parser": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
+      "integrity": "sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ=",
+      "dependencies": {
+        "redis-errors": "^1.0.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/require-from-string": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+      "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+      "optional": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/slice-ansi": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz",
+      "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==",
+      "optional": true,
+      "dependencies": {
+        "ansi-styles": "^4.0.0",
+        "astral-regex": "^2.0.0",
+        "is-fullwidth-code-point": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/slice-ansi?sponsor=1"
+      }
+    },
+    "node_modules/standard-as-callback": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
+      "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="
+    },
+    "node_modules/string-width": {
+      "version": "4.2.3",
+      "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+      "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+      "optional": true,
+      "dependencies": {
+        "emoji-regex": "^8.0.0",
+        "is-fullwidth-code-point": "^3.0.0",
+        "strip-ansi": "^6.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/strip-ansi": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+      "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+      "optional": true,
+      "dependencies": {
+        "ansi-regex": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/supports-color": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+      "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+      "dependencies": {
+        "has-flag": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/table": {
+      "version": "6.8.0",
+      "resolved": "https://registry.npmjs.org/table/-/table-6.8.0.tgz",
+      "integrity": "sha512-s/fitrbVeEyHKFa7mFdkuQMWlH1Wgw/yEXMt5xACT4ZpzWFluehAxRtUUQKPuWhaLAWhFcVx6w3oC8VKaUfPGA==",
+      "optional": true,
+      "dependencies": {
+        "ajv": "^8.0.1",
+        "lodash.truncate": "^4.4.2",
+        "slice-ansi": "^4.0.0",
+        "string-width": "^4.2.3",
+        "strip-ansi": "^6.0.1"
+      },
+      "engines": {
+        "node": ">=10.0.0"
+      }
+    },
+    "node_modules/tr46": {
+      "version": "0.0.3",
+      "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
+      "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=",
+      "optional": true
+    },
+    "node_modules/ts-mixer": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.0.tgz",
+      "integrity": "sha512-nXIb1fvdY5CBSrDIblLn73NW0qRDk5yJ0Sk1qPBF560OdJfQp9jhl+0tzcY09OZ9U+6GpeoI9RjwoIKFIoB9MQ==",
+      "optional": true
+    },
+    "node_modules/tslib": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+      "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==",
+      "optional": true
+    },
+    "node_modules/type-fest": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz",
+      "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==",
+      "optional": true,
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/uri-js": {
+      "version": "4.4.1",
+      "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+      "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+      "optional": true,
+      "dependencies": {
+        "punycode": "^2.1.0"
+      }
+    },
+    "node_modules/vali-date": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/vali-date/-/vali-date-1.0.0.tgz",
+      "integrity": "sha1-G5BKWWCfsyjvB4E4Qgk09rhnCaY=",
+      "optional": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/webidl-conversions": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
+      "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=",
+      "optional": true
+    },
+    "node_modules/whatwg-url": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
+      "integrity": "sha1-lmRU6HZUYuN2RNNib2dCzotwll0=",
+      "optional": true,
+      "dependencies": {
+        "tr46": "~0.0.3",
+        "webidl-conversions": "^3.0.0"
+      }
+    },
+    "node_modules/ws": {
+      "version": "7.5.5",
+      "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.5.tgz",
+      "integrity": "sha512-BAkMFcAzl8as1G/hArkxOxq3G7pjUqQ3gzYbLL0/5zNkph70e+lCoxBGnm6AW1+/aiNeV4fnKqZ8m4GZewmH2w==",
+      "optional": true,
+      "engines": {
+        "node": ">=8.3.0"
+      },
+      "peerDependencies": {
+        "bufferutil": "^4.0.1",
+        "utf-8-validate": "^5.0.2"
+      },
+      "peerDependenciesMeta": {
+        "bufferutil": {
+          "optional": true
+        },
+        "utf-8-validate": {
+          "optional": true
+        }
+      }
+    }
+  },
+  "dependencies": {
+    "@discordjs/builders": {
+      "version": "0.5.0",
+      "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-0.5.0.tgz",
+      "integrity": "sha512-HP5y4Rqw68o61Qv4qM5tVmDbWi4mdTFftqIOGRo33SNPpLJ1Ga3KEIR2ibKofkmsoQhEpLmopD1AZDs3cKpHuw==",
+      "optional": true,
+      "requires": {
+        "@sindresorhus/is": "^4.0.1",
+        "discord-api-types": "^0.22.0",
+        "ow": "^0.27.0",
+        "ts-mixer": "^6.0.0",
+        "tslib": "^2.3.0"
+      }
+    },
+    "@discordjs/collection": {
+      "version": "0.2.1",
+      "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-0.2.1.tgz",
+      "integrity": "sha512-vhxqzzM8gkomw0TYRF3tgx7SwElzUlXT/Aa41O7mOcyN6wIJfj5JmDWaO5XGKsGSsNx7F3i5oIlrucCCWV1Nog==",
+      "optional": true
+    },
+    "@discordjs/form-data": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/@discordjs/form-data/-/form-data-3.0.1.tgz",
+      "integrity": "sha512-ZfFsbgEXW71Rw/6EtBdrP5VxBJy4dthyC0tpQKGKmYFImlmmrykO14Za+BiIVduwjte0jXEBlhSKf0MWbFp9Eg==",
+      "optional": true,
+      "requires": {
+        "asynckit": "^0.4.0",
+        "combined-stream": "^1.0.8",
+        "mime-types": "^2.1.12"
+      }
+    },
+    "@sapphire/async-queue": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.1.4.tgz",
+      "integrity": "sha512-fFrlF/uWpGOX5djw5Mu2Hnnrunao75WGey0sP0J3jnhmrJ5TAPzHYOmytD5iN/+pMxS+f+u/gezqHa9tPhRHEA==",
+      "optional": true
+    },
+    "@sindresorhus/is": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.2.0.tgz",
+      "integrity": "sha512-VkE3KLBmJwcCaVARtQpfuKcKv8gcBmUubrfHGF84dXuuW6jgsRYxPtzcIhPyK9WAPpRt2/xY6zkD9MnRaJzSyw==",
+      "optional": true
+    },
+    "@types/node": {
+      "version": "16.10.1",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-16.10.1.tgz",
+      "integrity": "sha512-4/Z9DMPKFexZj/Gn3LylFgamNKHm4K3QDi0gz9B26Uk0c8izYf97B5fxfpspMNkWlFupblKM/nV8+NA9Ffvr+w==",
+      "optional": true
+    },
+    "@types/ws": {
+      "version": "7.4.7",
+      "resolved": "https://registry.npmjs.org/@types/ws/-/ws-7.4.7.tgz",
+      "integrity": "sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==",
+      "optional": true,
+      "requires": {
+        "@types/node": "*"
+      }
+    },
+    "ajv": {
+      "version": "8.10.0",
+      "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.10.0.tgz",
+      "integrity": "sha512-bzqAEZOjkrUMl2afH8dknrq5KEk2SrwdBROR+vH1EKVQTqaUbJVPdc/gEdggTMM0Se+s+Ja4ju4TlNcStKl2Hw==",
+      "optional": true,
+      "requires": {
+        "fast-deep-equal": "^3.1.1",
+        "json-schema-traverse": "^1.0.0",
+        "require-from-string": "^2.0.2",
+        "uri-js": "^4.2.2"
+      }
+    },
+    "ansi-regex": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+      "optional": true
+    },
+    "ansi-styles": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+      "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+      "requires": {
+        "color-convert": "^2.0.1"
+      }
+    },
+    "argparse": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+      "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
+    },
+    "astral-regex": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz",
+      "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==",
+      "optional": true
+    },
+    "asynckit": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+      "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=",
+      "optional": true
+    },
+    "bent": {
+      "version": "7.3.12",
+      "resolved": "https://registry.npmjs.org/bent/-/bent-7.3.12.tgz",
+      "integrity": "sha512-T3yrKnVGB63zRuoco/7Ybl7BwwGZR0lceoVG5XmQyMIH9s19SV5m+a8qam4if0zQuAmOQTyPTPmsQBdAorGK3w==",
+      "requires": {
+        "bytesish": "^0.4.1",
+        "caseless": "~0.12.0",
+        "is-stream": "^2.0.0"
+      }
+    },
+    "bytesish": {
+      "version": "0.4.4",
+      "resolved": "https://registry.npmjs.org/bytesish/-/bytesish-0.4.4.tgz",
+      "integrity": "sha512-i4uu6M4zuMUiyfZN4RU2+i9+peJh//pXhd9x1oSe1LBkZ3LEbCoygu8W0bXTukU1Jme2txKuotpCZRaC3FLxcQ=="
+    },
+    "callsites": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+      "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+      "optional": true
+    },
+    "caseless": {
+      "version": "0.12.0",
+      "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
+      "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw="
+    },
+    "chalk": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+      "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+      "requires": {
+        "ansi-styles": "^4.1.0",
+        "supports-color": "^7.1.0"
+      }
+    },
+    "cluster-key-slot": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.0.tgz",
+      "integrity": "sha512-2Nii8p3RwAPiFwsnZvukotvow2rIHM+yQ6ZcBXGHdniadkYGZYiGmkHJIbZPIV9nfv7m/U1IPMVVcAhoWFeklw=="
+    },
+    "color-convert": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+      "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+      "requires": {
+        "color-name": "~1.1.4"
+      }
+    },
+    "color-name": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+      "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
+    },
+    "combined-stream": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+      "optional": true,
+      "requires": {
+        "delayed-stream": "~1.0.0"
+      }
+    },
+    "debug": {
+      "version": "4.3.2",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz",
+      "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==",
+      "requires": {
+        "ms": "2.1.2"
+      }
+    },
+    "delayed-stream": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+      "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=",
+      "optional": true
+    },
+    "denque": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/denque/-/denque-1.5.0.tgz",
+      "integrity": "sha512-CYiCSgIF1p6EUByQPlGkKnP1M9g0ZV3qMIrqMqZqdwazygIA/YP2vrbcyl1h/WppKJTdl1F85cXIle+394iDAQ=="
+    },
+    "discord-api-types": {
+      "version": "0.22.0",
+      "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.22.0.tgz",
+      "integrity": "sha512-l8yD/2zRbZItUQpy7ZxBJwaLX/Bs2TGaCthRppk8Sw24LOIWg12t9JEreezPoYD0SQcC2htNNo27kYEpYW/Srg==",
+      "optional": true
+    },
+    "discord.js": {
+      "version": "13.1.0",
+      "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-13.1.0.tgz",
+      "integrity": "sha512-gxO4CXKdHpqA+WKG+f5RNnd3srTDj5uFJHgOathksDE90YNq/Qijkd2WlMgTTMS6AJoEnHxI7G9eDQHCuZ+xDA==",
+      "optional": true,
+      "requires": {
+        "@discordjs/builders": "^0.5.0",
+        "@discordjs/collection": "^0.2.1",
+        "@discordjs/form-data": "^3.0.1",
+        "@sapphire/async-queue": "^1.1.4",
+        "@types/ws": "^7.4.7",
+        "discord-api-types": "^0.22.0",
+        "node-fetch": "^2.6.1",
+        "ws": "^7.5.1"
+      }
+    },
+    "dot-prop": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz",
+      "integrity": "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==",
+      "optional": true,
+      "requires": {
+        "is-obj": "^2.0.0"
+      }
+    },
+    "emoji-regex": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+      "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+      "optional": true
+    },
+    "esutils": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+      "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="
+    },
+    "fast-deep-equal": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+      "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+      "optional": true
+    },
+    "has-flag": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+      "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="
+    },
+    "ioredis": {
+      "version": "4.27.7",
+      "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-4.27.7.tgz",
+      "integrity": "sha512-lqvFFmUyGIHlrNyDvBoakzy1+ioJzNyoP6CP97GWtdTjWq9IOAnv6l0HUTsqhvd/z9etGgtrDHZ4kWCMAwNkug==",
+      "requires": {
+        "cluster-key-slot": "^1.1.0",
+        "debug": "^4.3.1",
+        "denque": "^1.1.0",
+        "lodash.defaults": "^4.2.0",
+        "lodash.flatten": "^4.4.0",
+        "lodash.isarguments": "^3.1.0",
+        "p-map": "^2.1.0",
+        "redis-commands": "1.7.0",
+        "redis-errors": "^1.2.0",
+        "redis-parser": "^3.0.0",
+        "standard-as-callback": "^2.1.0"
+      }
+    },
+    "is-fullwidth-code-point": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+      "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+      "optional": true
+    },
+    "is-obj": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz",
+      "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==",
+      "optional": true
+    },
+    "is-stream": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
+      "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="
+    },
+    "js-yaml": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
+      "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+      "requires": {
+        "argparse": "^2.0.1"
+      }
+    },
+    "json-schema-traverse": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+      "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+      "optional": true
+    },
+    "lodash.defaults": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
+      "integrity": "sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw="
+    },
+    "lodash.flatten": {
+      "version": "4.4.0",
+      "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz",
+      "integrity": "sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8="
+    },
+    "lodash.isarguments": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
+      "integrity": "sha1-L1c9hcaiQon/AGY7SRwdM4/zRYo="
+    },
+    "lodash.isequal": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
+      "integrity": "sha1-QVxEePK8wwEgwizhDtMib30+GOA=",
+      "optional": true
+    },
+    "lodash.truncate": {
+      "version": "4.4.2",
+      "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz",
+      "integrity": "sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM=",
+      "optional": true
+    },
+    "logging": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmjs.org/logging/-/logging-3.3.0.tgz",
+      "integrity": "sha512-Hnmu3KlGTbXMVS7ONjBpnjjiF9cBlK5qsmj77sOcqRkNpvO9ouUGPKe2PmBCWWYpKAbxb96b08cYEv4hiBk3lQ==",
+      "requires": {
+        "chalk": "^4.1.0",
+        "debug": "^4.3.1",
+        "nicely-format": "^1.1.0"
+      }
+    },
+    "mime-db": {
+      "version": "1.49.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.49.0.tgz",
+      "integrity": "sha512-CIc8j9URtOVApSFCQIF+VBkX1RwXp/oMMOrqdyXSBXq5RWNEsRfyj1kiRnQgmNXmHxPoFIxOroKA3zcU9P+nAA==",
+      "optional": true
+    },
+    "mime-types": {
+      "version": "2.1.32",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.32.tgz",
+      "integrity": "sha512-hJGaVS4G4c9TSMYh2n6SQAGrC4RnfU+daP8G7cSCmaqNjiOoUY0VHCMS42pxnQmVF1GWwFhbHWn3RIxCqTmZ9A==",
+      "optional": true,
+      "requires": {
+        "mime-db": "1.49.0"
+      }
+    },
+    "ms": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+      "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
+    },
+    "nicely-format": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/nicely-format/-/nicely-format-1.1.0.tgz",
+      "integrity": "sha1-bDUT0fOAd9Ze2XIecWqOGRe6J7Y=",
+      "requires": {
+        "ansi-styles": "^2.2.1",
+        "esutils": "^2.0.2"
+      },
+      "dependencies": {
+        "ansi-styles": {
+          "version": "2.2.1",
+          "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz",
+          "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4="
+        }
+      }
+    },
+    "node-fetch": {
+      "version": "2.6.7",
+      "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz",
+      "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==",
+      "optional": true,
+      "requires": {
+        "whatwg-url": "^5.0.0"
+      }
+    },
+    "ow": {
+      "version": "0.27.0",
+      "resolved": "https://registry.npmjs.org/ow/-/ow-0.27.0.tgz",
+      "integrity": "sha512-SGnrGUbhn4VaUGdU0EJLMwZWSupPmF46hnTRII7aCLCrqixTAC5eKo8kI4/XXf1eaaI8YEVT+3FeGNJI9himAQ==",
+      "optional": true,
+      "requires": {
+        "@sindresorhus/is": "^4.0.1",
+        "callsites": "^3.1.0",
+        "dot-prop": "^6.0.1",
+        "lodash.isequal": "^4.5.0",
+        "type-fest": "^1.2.1",
+        "vali-date": "^1.0.0"
+      }
+    },
+    "p-map": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz",
+      "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw=="
+    },
+    "punycode": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
+      "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==",
+      "optional": true
+    },
+    "redis-commands": {
+      "version": "1.7.0",
+      "resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.7.0.tgz",
+      "integrity": "sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ=="
+    },
+    "redis-errors": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
+      "integrity": "sha1-62LSrbFeTq9GEMBK/hUpOEJQq60="
+    },
+    "redis-parser": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
+      "integrity": "sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ=",
+      "requires": {
+        "redis-errors": "^1.0.0"
+      }
+    },
+    "require-from-string": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+      "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+      "optional": true
+    },
+    "slice-ansi": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz",
+      "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==",
+      "optional": true,
+      "requires": {
+        "ansi-styles": "^4.0.0",
+        "astral-regex": "^2.0.0",
+        "is-fullwidth-code-point": "^3.0.0"
+      }
+    },
+    "standard-as-callback": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
+      "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="
+    },
+    "string-width": {
+      "version": "4.2.3",
+      "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+      "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+      "optional": true,
+      "requires": {
+        "emoji-regex": "^8.0.0",
+        "is-fullwidth-code-point": "^3.0.0",
+        "strip-ansi": "^6.0.1"
+      }
+    },
+    "strip-ansi": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+      "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+      "optional": true,
+      "requires": {
+        "ansi-regex": "^5.0.1"
+      }
+    },
+    "supports-color": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+      "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+      "requires": {
+        "has-flag": "^4.0.0"
+      }
+    },
+    "table": {
+      "version": "6.8.0",
+      "resolved": "https://registry.npmjs.org/table/-/table-6.8.0.tgz",
+      "integrity": "sha512-s/fitrbVeEyHKFa7mFdkuQMWlH1Wgw/yEXMt5xACT4ZpzWFluehAxRtUUQKPuWhaLAWhFcVx6w3oC8VKaUfPGA==",
+      "optional": true,
+      "requires": {
+        "ajv": "^8.0.1",
+        "lodash.truncate": "^4.4.2",
+        "slice-ansi": "^4.0.0",
+        "string-width": "^4.2.3",
+        "strip-ansi": "^6.0.1"
+      }
+    },
+    "tr46": {
+      "version": "0.0.3",
+      "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
+      "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=",
+      "optional": true
+    },
+    "ts-mixer": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.0.tgz",
+      "integrity": "sha512-nXIb1fvdY5CBSrDIblLn73NW0qRDk5yJ0Sk1qPBF560OdJfQp9jhl+0tzcY09OZ9U+6GpeoI9RjwoIKFIoB9MQ==",
+      "optional": true
+    },
+    "tslib": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+      "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==",
+      "optional": true
+    },
+    "type-fest": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz",
+      "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==",
+      "optional": true
+    },
+    "uri-js": {
+      "version": "4.4.1",
+      "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+      "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+      "optional": true,
+      "requires": {
+        "punycode": "^2.1.0"
+      }
+    },
+    "vali-date": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/vali-date/-/vali-date-1.0.0.tgz",
+      "integrity": "sha1-G5BKWWCfsyjvB4E4Qgk09rhnCaY=",
+      "optional": true
+    },
+    "webidl-conversions": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
+      "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=",
+      "optional": true
+    },
+    "whatwg-url": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
+      "integrity": "sha1-lmRU6HZUYuN2RNNib2dCzotwll0=",
+      "optional": true,
+      "requires": {
+        "tr46": "~0.0.3",
+        "webidl-conversions": "^3.0.0"
+      }
+    },
+    "ws": {
+      "version": "7.5.5",
+      "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.5.tgz",
+      "integrity": "sha512-BAkMFcAzl8as1G/hArkxOxq3G7pjUqQ3gzYbLL0/5zNkph70e+lCoxBGnm6AW1+/aiNeV4fnKqZ8m4GZewmH2w==",
+      "optional": true,
+      "requires": {}
+    }
+  }
+}
diff --git a/lobby/package.json b/lobby/package.json
new file mode 100644
index 0000000..d168f7b
--- /dev/null
+++ b/lobby/package.json
@@ -0,0 +1,29 @@
+{
+  "name": "scummvm-multiplayer-lobby",
+  "version": "1.0.0",
+  "description": "Lobby server code for ScummVM Multiplayer",
+  "main": "run.js",
+  "scripts": {
+    "test": "echo \"Error: no test specified\" && exit 1"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/scummvm/scummvm-sites.git"
+  },
+  "author": "ScummVM (Backyard Sports Online)",
+  "license": "MIT",
+  "bugs": {
+    "url": "https://github.com/scummvm/scummvm-sites/issues"
+  },
+  "homepage": "https://github.com/scummvm/scummvm-sites#readme",
+  "dependencies": {
+    "bent": "^7.3.12",
+    "ioredis": "^4.27.7",
+    "js-yaml": "^4.1.0",
+    "logging": "^3.3.0"
+  },
+  "optionalDependencies": {
+    "discord.js": "^13.1.0",
+    "table": "^6.8.0"
+  }
+}
diff --git a/lobby/run.js b/lobby/run.js
new file mode 100644
index 0000000..f47675e
--- /dev/null
+++ b/lobby/run.js
@@ -0,0 +1,72 @@
+"use strict"
+
+const yaml         = require('js-yaml');
+const fs           = require('fs');
+const createLogger = require('logging').default;
+const cluster      = require('cluster');
+
+// Read the configuration files.
+const config = yaml.load(fs.readFileSync('config.yaml'));
+
+// Get credentials and such from environment variables
+const credentials = {
+    web: {
+        endpoint: process.env.WEB_ENDPOINT,
+        token: process.env.WEB_TOKEN,
+    },
+    redis: {
+        host: process.env.REDIS_HOST,
+        port: process.env.REDIS_PORT,
+    },
+    discord: {
+        client: process.env.DISCORD_CLIENT,
+        token: process.env.DISCORD_TOKEN,
+        channel: process.env.DISCORD_CHANNEL,
+    }
+};
+
+if (cluster.isMaster) {
+    // Fork out workers
+    for (let i = 0; i < (config['cores'] || 1); i++) {
+        const env = {};
+        if (i == 0) {
+            env.FIRST_WORKER = true;
+        }
+        const worker = cluster.fork(env);
+        worker.on('message', (message) => {
+            for (const id in cluster.workers) {
+                cluster.workers[id].send(message);
+            }
+        });
+    }
+} else {
+    // Worker
+    const NetworkListener = require('./net/NetworkListener');
+    const Redis = require('./database/Redis');
+    const Discord = require('./discord/Discord');
+    global.server = new NetworkListener(config['network'])
+    global.redis = new Redis(credentials.redis);
+    if (process.env.DATABASE == 'web') {
+        const WebAPI = require('./database/WebAPI');
+        global.database = new WebAPI(credentials.web);
+    } else {
+        global.database = global.redis;
+    }
+
+    // Load message functions
+    require('./net/DatabaseMessages.js');
+    require('./net/AreaMessages.js');
+    require('./net/ChallengeMessages.js');
+    require('./net/SessionMessages.js');
+
+    if (process.env.FIRST_WORKER && credentials.discord.client) {
+      global.discord = new Discord(credentials['discord']);
+    }
+
+    // Handle messages from other processes.
+    process.on('message', (message) => {
+        const cmd = message.cmd;
+        delete message.cmd;
+        process.emit(cmd, message);
+    });
+}


Commit: c375eb861ff29e7f18f121660c2ff7a0bfd75afb
    https://github.com/scummvm/scummvm-sites/commit/c375eb861ff29e7f18f121660c2ff7a0bfd75afb
Author: Little Cat (toontownlittlecat at gmail.com)
Date: 2023-03-29T01:16:01+02:00

Commit Message:
MULTIPLAYER: LOBBY: Send session server address.

Changed paths:
    lobby/config.yaml
    lobby/net/DatabaseMessages.js
    lobby/net/NetworkListener.js


diff --git a/lobby/config.yaml b/lobby/config.yaml
index 1a3d393..c53de91 100644
--- a/lobby/config.yaml
+++ b/lobby/config.yaml
@@ -9,3 +9,7 @@ network:
   # TLS connections, this MUST be set in production.
   # key: /path/to/private_key.pem
   # cert: /path/to/certificate.crt
+
+  # This sets the session server that the game will connect to
+  # to host and join their sessions.
+  session_server: "127.0.0.1:9120"
\ No newline at end of file
diff --git a/lobby/net/DatabaseMessages.js b/lobby/net/DatabaseMessages.js
index b55e7aa..7437ca5 100644
--- a/lobby/net/DatabaseMessages.js
+++ b/lobby/net/DatabaseMessages.js
@@ -54,8 +54,10 @@ server.handleMessage("login", async (client, args) => {
     // to prevent ourselves from getting kicked out after logging in.
     setTimeout(() => {
         client.userId = user.id;
+        console.log(server.sessionServer);
         client.send("login_resp", {error_code: 0,
                                    id: user.id,
+                                   sessionServer: server.sessionServer,
                                    response: "All ok"});
 
     }, 50);
diff --git a/lobby/net/NetworkListener.js b/lobby/net/NetworkListener.js
index f019365..bd02a0e 100644
--- a/lobby/net/NetworkListener.js
+++ b/lobby/net/NetworkListener.js
@@ -24,6 +24,9 @@ class NetworkListener {
             }
         };
 
+        // Store session server address
+        this.sessionServer = this.config['session_server'] || '127.0.0.1:9120';
+
         const host = this.config['host'];
         const port = Number(this.config['port']);
         const keyPath = this.config['key'];


Commit: ad9bbd832db278bfdd84e321ad95b57cc2ef4dd2
    https://github.com/scummvm/scummvm-sites/commit/ad9bbd832db278bfdd84e321ad95b57cc2ef4dd2
Author: Little Cat (toontownlittlecat at gmail.com)
Date: 2023-03-29T01:16:01+02:00

Commit Message:
MULTIPLAYER: LOBBY: Rename login endpoint.

Changed paths:
    lobby/database/WebAPI.js
    lobby/net/DatabaseMessages.js


diff --git a/lobby/database/WebAPI.js b/lobby/database/WebAPI.js
index 10553de..07fdd44 100644
--- a/lobby/database/WebAPI.js
+++ b/lobby/database/WebAPI.js
@@ -14,11 +14,10 @@ class WebAPI {
     }
 
     async getUser(username, password, game) {
-        // TODO: Replace with /login
-        const user = await this.post('/new_login', {token: this.token,
-                                                    user: username,
-                                                    pass: password,
-                                                    game: game});
+        const user = await this.post('/login', {token: this.token,
+                                                user: username,
+                                                pass: password,
+                                                game: game});
         if (user.error) {
             return user;
         }
diff --git a/lobby/net/DatabaseMessages.js b/lobby/net/DatabaseMessages.js
index 7437ca5..1a388da 100644
--- a/lobby/net/DatabaseMessages.js
+++ b/lobby/net/DatabaseMessages.js
@@ -39,6 +39,7 @@ server.handleMessage("login", async (client, args) => {
     if (user.error) {
         client.send("login_resp", {error_code: user.error,
                                    id: 0,
+                                   sessionServer: "",
                                    response: user.message});
         return;
     }
@@ -54,7 +55,6 @@ server.handleMessage("login", async (client, args) => {
     // to prevent ourselves from getting kicked out after logging in.
     setTimeout(() => {
         client.userId = user.id;
-        console.log(server.sessionServer);
         client.send("login_resp", {error_code: 0,
                                    id: user.id,
                                    sessionServer: server.sessionServer,


Commit: 08e75d131ae2e401b35c96280b4ef02682760c4e
    https://github.com/scummvm/scummvm-sites/commit/08e75d131ae2e401b35c96280b4ef02682760c4e
Author: Little Cat (toontownlittlecat at gmail.com)
Date: 2023-03-29T01:16:01+02:00

Commit Message:
MULTIPLAYER: LOBBY: Removed relay messages.

The client and the session server now does the relaying work.

Changed paths:
    lobby/net/SessionMessages.js


diff --git a/lobby/net/SessionMessages.js b/lobby/net/SessionMessages.js
index ccd5b74..5142fe4 100644
--- a/lobby/net/SessionMessages.js
+++ b/lobby/net/SessionMessages.js
@@ -52,52 +52,3 @@ process.on('game_session', async (args) => {
         }
     }
 });
-
-server.handleMessage('send_relay', async (client, args) => {
-    const userId = args.user;
-    const relayId = args.relay;
-    logEvent('send_relay', client, args.version, {'relay': relayId, 'opponent': userId});
-
-    if (userId === undefined) {
-        logger.error("Missing user argument on send_relay!");
-        return;
-    } else if (client.areaId == 0) {
-        logger.error(`Got send_relay but I'm (${client.userId}) not in an area!`);
-        return;
-    }
-
-    // Check if the opponent is in our area.
-    const users = await redis.getUserIdsInArea(client.areaId, client.game);
-    if (!users.includes(userId)) {
-        logger.error(`Got send_relay but our player (${userId}) isn't in area (${client.areaId})!`);
-        return;
-    }
-
-    process.send({cmd: 'game_relay', user: userId,
-                                     opponent: client.userId,
-                                     relay: relayId});
-});
-
-process.on('game_relay', async (args) => {
-    const userId = args.user;
-    const opponentId = args.opponent;
-    const relayId = args.relay;
-
-    for (const client of server.connections) {
-        if (client.userId == userId) {
-            if (client.areaId == 0) {
-                logger.error(`Got game_session but I'm (${client.userId}) not in an area!`);
-                return;
-            }
-            // Check if the opponent is in our area.
-            const users = await redis.getUserIdsInArea(client.areaId, client.game);
-            if (!users.includes(opponentId)) {
-                logger.error(`Got game_session but our player (${opponentId}) isn't in area (${client.areaId})!`);
-                return;
-            }
-
-            client.send("game_relay", {relay: relayId});
-            return;
-        }
-    }
-});


Commit: da987f4117df9970813ebc03da2b286cd20fd72d
    https://github.com/scummvm/scummvm-sites/commit/da987f4117df9970813ebc03da2b286cd20fd72d
Author: Little Cat (toontownlittlecat at gmail.com)
Date: 2023-03-29T01:16:01+02:00

Commit Message:
MULTIPLAYER: Improve relay setup through Redis.

Changed paths:
    main.py


diff --git a/main.py b/main.py
index 3e74cf4..e497d9d 100644
--- a/main.py
+++ b/main.py
@@ -45,7 +45,8 @@ for game in get_full_game_names():
 		# Clear out the sessions
 		logging.info(f"Clearing out {game} sessions")
 		for session_id in range(redis.llen(f"{game}:sessions")):
-			redis.delete(f"{game}.session:{session_id}")
+			redis.delete(f"{game}:session:{session_id}")
+			redis.delete(f"{game}:relay:{session_id}")
 		redis.delete(f"{game}:sessions")
 		redis.delete(f"{game}:sessionByAddress")
 
@@ -90,23 +91,33 @@ def relay_data(data, sent_peer):
 	type_of_send = data.get("to")
 	send_type_param = data.get("toparam")
 
-	session_id = relays.get(sent_peer)
+	session_id = int(redis.hget(f"relays:{str(sent_peer.address)}", "session"))
 	if not session_id:
 		return
 	
-	peers_by_user_id = session_to_relay_user_ids.get(session_id)
-	if not peers_by_user_id:
-		logging.warning(f"relay_data: Missing peers on session_to_relay_user_ids[{session_id}]!")
+	game = redis.hget(f"relays:{str(sent_peer.address)}", "game")
+	relay_users = redis.hgetall(f"{game}:relay:{session_id}")
+	if not relay_users:
+		logging.warning(f"relay_data: Missing users on {game}:relay:{session_id}!")
 		return
 
-	user_id_by_peers = {v: k for k, v in peers_by_user_id.items()}
+	logging.debug(f"relay_data: Players of \"{game}\" session {session_id}:")
+	for user_id, address in relay_users.items():
+		logging.debug(f"relay_data:  - {user_id}: {address}")
+	
+	peers_by_user_id = {}
+	for user_id, address in relay_users.items():
+		peer = get_peer_by_address(address)
+		if not peer:
+			logging.warning(f"relay_data: Peer for {address} does not exist!")
+			continue
+		peers_by_user_id[int(user_id)] = peer
 	
-	logging.debug(f"relay_data: Players of session {session_id}:")
-	for user_id, peer in peers_by_user_id.items():
-		logging.debug(f"relay_data:  - {user_id}: {str(peer.address)}")
+	user_id_by_peers = {v: k for k, v in peers_by_user_id.items()}
 	
 	if user_id_by_peers.get(sent_peer) != 1:
-		# To make things easier, just send all non-host data to the host.
+		# To make things easier, just send all non-host data to the host, so it can
+		# transfer data to peers that are connected directly to the host.
 		# It'll send it back to us if it actually needs to be relayed somewhere.
 		host_peer = peers_by_user_id.get(1)
 		if not host_peer:
@@ -151,25 +162,30 @@ def relay_data(data, sent_peer):
 		send(peer, data)
 	
 def remove_user_from_relay(peer):
-	session_id = relays.get(peer)
+	session_id = redis.hget(f"relays:{str(peer.address)}", "session")
 	if not session_id:
 		return
 	
-	del relays[peer]
+	game = redis.hget(f"relays:{str(peer.address)}", "game")
+	redis.delete(f"relays:{str(peer.address)}")
 	
-	peers_by_user_id = session_to_relay_user_ids.get(session_id)
-	if not peers_by_user_id:
+	address_by_user_id = redis.hgetall(f"{game}:relay:{session_id}")
+	if not address_by_user_id:
 		return
 
-	user_id_by_peers = {v: k for k, v in peers_by_user_id.items()}
-	user_id = user_id_by_peers.get(peer)
+	user_id_by_address = {v: k for k, v in address_by_user_id.items()}
+	user_id = user_id_by_address.get(str(peer.address))
 	if not user_id:
 		return
 	
-	del session_to_relay_user_ids[session_id][user_id]
+	redis.hdel(f"{game}:relay:{session_id}", user_id)
 
 	# Send the remove_user request to the host.
-	host_peer = peers_by_user_id.get(1)
+	host_address = address_by_user_id.get(1)
+	if not host_address:
+		return
+
+	host_peer = get_peer_by_address(host_address)
 	if not host_peer:
 		return
 
@@ -185,9 +201,6 @@ signal.signal(signal.SIGTERM, exit)
 # SIGINT: Ctrl+C KeyboardInterrupt
 signal.signal(signal.SIGINT, exit)
 
-relays = {} # peer: sessionId
-session_to_relay_user_ids = {} # sessionId: {userId: peer}
-
 while do_loop:
 	# Main event loop
 	event = host.service(1000)
@@ -204,8 +217,6 @@ while do_loop:
 				redis.hdel(f"{game}:sessionByAddress", str(event.peer.address))
 
 			# Cleanup Relays (if any):
-				if session_id in session_to_relay_user_ids:
-					del session_to_relay_user_ids[session_id]
 			remove_user_from_relay(event.peer)
 
 
@@ -279,11 +290,11 @@ while do_loop:
 		elif command == "join_session":
 			session_id = data.get("id")
 
-			if not (redis.exists(f"{game}:session:{id}")):
+			if not (redis.exists(f"{game}:session:{session_id}")):
 				logging.warning(f"Session {game}:{session_id} not found")
 				continue
 
-			address = redis.hget(f"{game}:session:{id}", "address")
+			address = redis.hget(f"{game}:session:{session_id}", "address")
 			peer = get_peer_by_address(address)
 			if not peer:
 				continue
@@ -293,20 +304,24 @@ while do_loop:
 		elif command == "start_relay":
 			session_id = data.get("session")
 			
-			if not (redis.exists(f"{game}:session:{id}")):
+			if not redis.exists(f"{game}:session:{session_id}"):
 				logging.warning(f"Session {game}:{session_id} not found")
 				continue
 
-			address = redis.hget(f"{game}:session:{id}", "address")
+			# Get peer of the session host
+			address = redis.hget(f"{game}:session:{session_id}", "address")
 			peer = get_peer_by_address(address)
 			if not peer:
 				continue
+			
+			if redis.exists(f"{game}:relay:{session_id}"):
+				logging.warning(f"Relay for {game}:{session_id} already exists!")
+				continue
 
-			if session_id not in session_to_relay_user_ids:
-				# The host peer is usually always has the userId of 1:
-				session_to_relay_user_ids[session_id] = {1: peer}
-			if peer not in relays:
-				relays[peer] = session_id
+			# Store new relay with the host (which usually always has the
+			# userId of 1).
+			redis.hset(f"{game}:relay:{session_id}", 1, str(peer.address))
+			redis.hset(f"relays:{str(peer.address)}", mapping={"game": game, "session": session_id})
 			
 			# Send the add_user request to the host (with joiner's address for context):
 			send(peer, {"cmd": "add_user_for_relay", "address": str(event.peer.address)})
@@ -318,18 +333,25 @@ while do_loop:
 			if not session_id:
 				logging.warning(f"Could not find session for address {str(event.peer.address)}!")
 				continue
-			if session_id not in session_to_relay_user_ids:
-				logging.warning(f"Session ID {session_id} not found in session2RelayUserIds!")
+
+			if not redis.exists(f"{game}:relay:{session_id}"):
+				logging.warning(f"{game}:relay:{session_id} does not exist!")
 				continue
-			if user_id in session_to_relay_user_ids[session_id]:
-				logging.warning(f"Duplicate user ID {user_id} in session2RelayUserIds[{session_id}]!")
+			
+			if redis.hexists(f"{game}:relay:{session_id}", user_id):
+				logging.warning(f"Duplicate User ID {user_id} in {game}:relay:{session_id}!")
 				continue
+
 			peer = get_peer_by_address(address)
 			if not peer:
 				logging.warning(f"Could not find peer for address: {address}!")
 				continue
+			
+			if redis.exists(f"relays:{str(peer.address)}"):
+				logging.warning(f"Peer {str(peer.address)} is already in a relay!")
+				continue
 
-			session_to_relay_user_ids[session_id][user_id] = peer
-			relays[peer] = session_id
+			redis.hset(f"{game}:relay:{session_id}", user_id, str(peer.address))
+			redis.hset(f"relays:{str(peer.address)}", mapping={"game": game, "session": session_id})
 			# Send the response back to the peer:
 			send(peer, {"cmd": "add_user_resp", "id": user_id})


Commit: b0c13bda75082d5c84a4f7481390c75946675fed
    https://github.com/scummvm/scummvm-sites/commit/b0c13bda75082d5c84a4f7481390c75946675fed
Author: Little Cat (toontownlittlecat at gmail.com)
Date: 2023-03-29T01:16:01+02:00

Commit Message:
MULTIPLAYER: Accept and show max players.

Changed paths:
    main.py
    web/main.py
    web/templates/game.html


diff --git a/main.py b/main.py
index e497d9d..7a6e6e2 100644
--- a/main.py
+++ b/main.py
@@ -71,12 +71,13 @@ def get_session_by_address(game: str, address: str):
 		return redis.hgetall(f"{game}:session:{session_id}")
 	return None
 
-def create_session(name: str, address: str):
+def create_session(name: str, maxplayers:int, address: str):
 	# Get our new session ID
 	session_id = redis.incr(f"{game}:counter")
 	# Create and store our new session
 	redis.hset(f"{game}:session:{session_id}", 
-		mapping={"name": name, "players": 0, "address": str(event.peer.address)})
+		mapping={"name": name, "players": 0, "maxplayers": maxplayers,
+				"address": str(event.peer.address)})
 	# Add session to sessions list
 	redis.rpush(f"{game}:sessions", session_id)
 
@@ -264,8 +265,9 @@ while do_loop:
 		
 		if command == "host_session":
 			name = data.get("name")
+			maxplayers = data.get("maxplayers")
 
-			session_id = create_session(name, event.peer.address)
+			session_id = create_session(name, maxplayers, event.peer.address)
 			send(event.peer, {"cmd": "host_session_resp", "id": session_id})
 		
 		elif command == "update_players":
diff --git a/web/main.py b/web/main.py
index 79c261a..e3254ba 100644
--- a/web/main.py
+++ b/web/main.py
@@ -38,7 +38,8 @@ async def get_sessions(game: str, version: str = None):
 	for id in session_ids:
 		session = await redis.hgetall(f"{key}:session:{id}")
 		sessions.append({"id": int(id), "version": version, "name": session["name"],
-						"players": int(session["players"]), "address": str(session["address"])})
+						"players": int(session["players"]), "maxplayers": int(session["maxplayers"]),
+						"address": str(session["address"])})
 	return sessions
 
 @app.get('/{game}')
diff --git a/web/templates/game.html b/web/templates/game.html
index e349504..ae73cc3 100644
--- a/web/templates/game.html
+++ b/web/templates/game.html
@@ -20,8 +20,10 @@
                 <dl>
                 {% for session in sessions -%}
                     <dt>{{session["name"]}}</li>
-                    <dd>- Players: {{session["players"]}}/4</dd>
-                    <dd>- Version: {{session["version"]}}</dd>
+                    <dd>- Players: {{session["players"]}}/{{session["maxplayers"]}}</dd>
+                    {% if session["version"] -%}
+                        <dd>- Version: {{session["version"]}}</dd>
+                    {% endif -%}
                 {% endfor -%}
                 </dl>
                 {% endif -%}


Commit: 0eb4eebe281257fc2cad52754ddf0a371a5321fc
    https://github.com/scummvm/scummvm-sites/commit/0eb4eebe281257fc2cad52754ddf0a371a5321fc
Author: Little Cat (toontownlittlecat at gmail.com)
Date: 2023-03-29T01:16:01+02:00

Commit Message:
README: Update repo link

Changed paths:
    README.md


diff --git a/README.md b/README.md
index b927000..2a85efc 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@ Both the session and web servers requires Redis to be installed.  This is needed
 
 Clone this repo and checkout to the multiplayer branch:
 ```
-git clone https://github.com/LittleToonCat/scummvm-sites.git
+git clone https://github.com/scummvm/scummvm-sites.git
 
 git checkout multiplayer
 ```
@@ -60,4 +60,4 @@ docker-compose build
 docker-compose up
 ```
 
-This will build Docker images for all three servers and starts a container for them simultaneously alongside with Redis.
\ No newline at end of file
+This will build Docker images for all three servers and starts a container for them simultaneously alongside with Redis.


Commit: 85b476f3468178c783d7ddc6fa1dada137884e9a
    https://github.com/scummvm/scummvm-sites/commit/85b476f3468178c783d7ddc6fa1dada137884e9a
Author: Little Cat (toontownlittlecat at gmail.com)
Date: 2023-03-29T01:16:01+02:00

Commit Message:
MULTIPLAYER: Format Python code.

Changed paths:
    main.py
    net_defines.py
    web/config.py
    web/main.py


diff --git a/main.py b/main.py
index 7a6e6e2..f3fd45f 100644
--- a/main.py
+++ b/main.py
@@ -9,351 +9,404 @@ from net_defines import *
 
 # Games to accept:
 GAMES = [
-	"football", # Backyard Football (1999)
-	"baseball2001", # Backyard Baseball 2001
-	"football2002", # Backyard Football 2002 
-	"moonbase", # Moonbase Commander (v1.0/v1.1/Demo)
+    "football",  # Backyard Football (1999)
+    "baseball2001",  # Backyard Baseball 2001
+    "football2002",  # Backyard Football 2002
+    "moonbase",  # Moonbase Commander (v1.0/v1.1/Demo)
 ]
 
 # Version variants to accept for a specific game.
 # If none exist but game exist in the GAMES list,
 # that means there's only one game version.
-VERSIONS = {
-	"moonbase": ["1.0", "1.1", "Demo"]
-}
-
-def get_full_game_names():
-	games = []
-	for game in GAMES:
-		versions = VERSIONS.get(game)
-		if versions:
-			for version in versions:
-				games.append(f"{game}:{version}")
-		else:
-			games.append(game)
-	return games
-
-if os.environ.get("DEBUG"):
-	logging.basicConfig(level=logging.DEBUG)
-
-redis = redis_package.Redis(os.environ.get("REDIS_HOST", "127.0.0.1"),
-							retry_on_timeout=True, decode_responses=True)
-for game in get_full_game_names():
-	# Reset session counter
-	redis.set(f"{game}:counter", 0)
-	if redis.exists(f"{game}:sessions"):
-		# Clear out the sessions
-		logging.info(f"Clearing out {game} sessions")
-		for session_id in range(redis.llen(f"{game}:sessions")):
-			redis.delete(f"{game}:session:{session_id}")
-			redis.delete(f"{game}:relay:{session_id}")
-		redis.delete(f"{game}:sessions")
-		redis.delete(f"{game}:sessionByAddress")
-
-# Create our host to listen connections from.  4095 is the maxinum amount.
-host = enet.Host(enet.Address(b"0.0.0.0", 9120), peerCount=4095, channelLimit=1)
-print("Listening for messages in port 9120", flush=True)
-
-def send(peer, data: dict):
-	logging.debug(f"{peer.address}: OUT: {data}")
-	data = json.dumps(data, separators=(',', ':')).encode()
-	peer.send(0, enet.Packet(data, enet.PACKET_FLAG_RELIABLE))
-
-def get_peer_by_address(address: str):
-	for peer in host.peers:
-		if str(peer.address) == address:
-			return peer
-	return None
-
-def get_session_by_address(game: str, address: str):
-	session_id = redis.hget(f"{game}:sessionByAddress", str(address))
-	if session_id:
-		return redis.hgetall(f"{game}:session:{session_id}")
-	return None
-
-def create_session(name: str, maxplayers:int, address: str):
-	# Get our new session ID
-	session_id = redis.incr(f"{game}:counter")
-	# Create and store our new session
-	redis.hset(f"{game}:session:{session_id}", 
-		mapping={"name": name, "players": 0, "maxplayers": maxplayers,
-				"address": str(event.peer.address)})
-	# Add session to sessions list
-	redis.rpush(f"{game}:sessions", session_id)
-
-	# Store to address to session hash
-	redis.hset(f"{game}:sessionByAddress", str(address), session_id)
-
-	logging.debug(f"{address}: NEW SESSION: \"{name}\"")
-	return session_id
-
-def relay_data(data, sent_peer):
-	from_user = data.get("from")
-	type_of_send = data.get("to")
-	send_type_param = data.get("toparam")
-
-	session_id = int(redis.hget(f"relays:{str(sent_peer.address)}", "session"))
-	if not session_id:
-		return
-	
-	game = redis.hget(f"relays:{str(sent_peer.address)}", "game")
-	relay_users = redis.hgetall(f"{game}:relay:{session_id}")
-	if not relay_users:
-		logging.warning(f"relay_data: Missing users on {game}:relay:{session_id}!")
-		return
-
-	logging.debug(f"relay_data: Players of \"{game}\" session {session_id}:")
-	for user_id, address in relay_users.items():
-		logging.debug(f"relay_data:  - {user_id}: {address}")
-	
-	peers_by_user_id = {}
-	for user_id, address in relay_users.items():
-		peer = get_peer_by_address(address)
-		if not peer:
-			logging.warning(f"relay_data: Peer for {address} does not exist!")
-			continue
-		peers_by_user_id[int(user_id)] = peer
-	
-	user_id_by_peers = {v: k for k, v in peers_by_user_id.items()}
-	
-	if user_id_by_peers.get(sent_peer) != 1:
-		# To make things easier, just send all non-host data to the host, so it can
-		# transfer data to peers that are connected directly to the host.
-		# It'll send it back to us if it actually needs to be relayed somewhere.
-		host_peer = peers_by_user_id.get(1)
-		if not host_peer:
-			logging.warning("relay_data: Host user (1) is missing!")
-			return
-		logging.debug(f"relay_data: Relaying data from user {user_id_by_peers.get(sent_peer)} to host (1).")
-		send(host_peer, data)
-		return
-	
-	peers_to_send = set()
-	if type_of_send == PN_SENDTYPE_INDIVIDUAL:
-		peer = peers_by_user_id.get(send_type_param)
-		if not peer:
-			logging.warning(f"relay_data: user {send_type_param} not in relay, Host does not know, something might be wrong.")
-			return
-		logging.debug(f"relay_data: Relaying data to user {send_type_param}")
-		peers_to_send.add(peer)
-	elif type_of_send == PN_SENDTYPE_GROUP:
-		logging.warning("STUB: PN_SENDTYPE_GROUP")
-		return
-	elif type_of_send == PN_SENDTYPE_HOST:
-		# Chances are that the host is user_id 1.
-		peer = peers_by_user_id.get(1)
-		if not peer:
-			return
-		logging.debug(f"relay_data: Relaying data to host (user 1)")
-		peers_to_send.add(peer)
-	elif type_of_send == PN_SENDTYPE_ALL:
-		# Send to all peers
-		for peer in peers_by_user_id.values():
-			peers_to_send.add(peer)
-	
-		logging.debug(f"relay_data: Relaying data to all peers: {str(list(peers_by_user_id.keys()))}")
-	else:
-		logging.warning(f"relay_data: Unknown type of send: {type_of_send}")
-	
-	# Remove self from set.
-	if sent_peer in peers_to_send:
-		peers_to_send.remove(sent_peer)
-
-	for peer in peers_to_send:
-		send(peer, data)
-	
-def remove_user_from_relay(peer):
-	session_id = redis.hget(f"relays:{str(peer.address)}", "session")
-	if not session_id:
-		return
-	
-	game = redis.hget(f"relays:{str(peer.address)}", "game")
-	redis.delete(f"relays:{str(peer.address)}")
-	
-	address_by_user_id = redis.hgetall(f"{game}:relay:{session_id}")
-	if not address_by_user_id:
-		return
-
-	user_id_by_address = {v: k for k, v in address_by_user_id.items()}
-	user_id = user_id_by_address.get(str(peer.address))
-	if not user_id:
-		return
-	
-	redis.hdel(f"{game}:relay:{session_id}", user_id)
-
-	# Send the remove_user request to the host.
-	host_address = address_by_user_id.get(1)
-	if not host_address:
-		return
-
-	host_peer = get_peer_by_address(host_address)
-	if not host_peer:
-		return
-
-	send(host_peer, {"cmd": "remove_user", "id": user_id})
-
-do_loop = True
-def exit(*args):
-	global do_loop
-	do_loop = False
-
-# For Docker, they grace stop with SIGTERM
-signal.signal(signal.SIGTERM, exit)
-# SIGINT: Ctrl+C KeyboardInterrupt
-signal.signal(signal.SIGINT, exit)
-
-while do_loop:
-	# Main event loop
-	event = host.service(1000)
-	if event.type == enet.EVENT_TYPE_CONNECT:
-		logging.debug(f"{event.peer.address}: CONNECT")
-	elif event.type == enet.EVENT_TYPE_DISCONNECT:
-		logging.debug(f"{event.peer.address}: DISCONNECT")
-		# Close out sessions relating to the address
-		for game in get_full_game_names():
-			session_id = redis.hget(f"{game}:sessionByAddress", str(event.peer.address))
-			if session_id:
-				redis.delete(f"{game}:session:{session_id}")
-				redis.lrem(f"{game}:sessions", 0, session_id)
-				redis.hdel(f"{game}:sessionByAddress", str(event.peer.address))
-
-			# Cleanup Relays (if any):
-			remove_user_from_relay(event.peer)
-
-
-	elif event.type == enet.EVENT_TYPE_RECEIVE:
-		logging.debug(f"{event.peer.address}: IN: {event.packet.data}")
-		try:
-			data = json.loads(event.packet.data)
-		except:
-			logging.warning(f"{event.peer.address}: Received non-JSON data.", event.packet.data.decode())
-			continue
-		command = data.get("cmd")
-
-		if command == "game":
-			relay_data(data, event.peer)
-			continue
-		elif command == "remove_user":
-			remove_user_from_relay(event.peer)
-			continue
-		
-		game = data.get("game")
-		version = data.get("version")
-		if not command:
-			logging.warning(f"{event.peer.address}: Command missing")
-			continue
-		if not game:
-			logging.warning(f"{event.peer.address}: Game missing")
-			continue
-		
-		if game not in GAMES:
-			logging.warning(f"Game \"{game}\" not supported.")
-			continue
-		
-		versions = VERSIONS.get(game)
-		if versions:
-			if not version:
-				logging.warning(f"{event.peer.address}: Version missing")
-				continue
-			
-			if version not in version:
-				logging.warning(f"Game \"{game}\" with version \"{version}\" not supported.")
-				continue
-
-			# Update the game to contain the version
-			game = f"{game}:{version}"
-		
-		if command == "host_session":
-			name = data.get("name")
-			maxplayers = data.get("maxplayers")
-
-			session_id = create_session(name, maxplayers, event.peer.address)
-			send(event.peer, {"cmd": "host_session_resp", "id": session_id})
-		
-		elif command == "update_players":
-			players = data.get("players")
-
-			session_id = redis.hget(f"{game}:sessionByAddress", str(event.peer.address))
-			if session_id:
-				redis.hset(f"{game}:session:{session_id}", "players", players)
-
-		elif command == "get_sessions":
-			sessions = []
-
-			num_sessions = redis.llen(f"{game}:sessions")
-			session_ids = redis.lrange(f"{game}:sessions", 0, num_sessions)
-			for id in session_ids:
-				session = redis.hgetall(f"{game}:session:{id}")
-				sessions.append({"id": int(id), "name": session["name"],
-								 "players": int(session["players"]), "address": str(session["address"])})
-			
-			send(event.peer, {"cmd": "get_sessions_resp",
-							  "address": str(event.peer.address), "sessions": sessions})
-		elif command == "join_session":
-			session_id = data.get("id")
-
-			if not (redis.exists(f"{game}:session:{session_id}")):
-				logging.warning(f"Session {game}:{session_id} not found")
-				continue
-
-			address = redis.hget(f"{game}:session:{session_id}", "address")
-			peer = get_peer_by_address(address)
-			if not peer:
-				continue
-			
-			# Send the joiner's address to the hoster for hole-punching
-			send(peer, {"cmd": "joining_session", "address": str(event.peer.address)})
-		elif command == "start_relay":
-			session_id = data.get("session")
-			
-			if not redis.exists(f"{game}:session:{session_id}"):
-				logging.warning(f"Session {game}:{session_id} not found")
-				continue
-
-			# Get peer of the session host
-			address = redis.hget(f"{game}:session:{session_id}", "address")
-			peer = get_peer_by_address(address)
-			if not peer:
-				continue
-			
-			if redis.exists(f"{game}:relay:{session_id}"):
-				logging.warning(f"Relay for {game}:{session_id} already exists!")
-				continue
-
-			# Store new relay with the host (which usually always has the
-			# userId of 1).
-			redis.hset(f"{game}:relay:{session_id}", 1, str(peer.address))
-			redis.hset(f"relays:{str(peer.address)}", mapping={"game": game, "session": session_id})
-			
-			# Send the add_user request to the host (with joiner's address for context):
-			send(peer, {"cmd": "add_user_for_relay", "address": str(event.peer.address)})
-		elif command == "add_user_resp":
-			address = data.get("address")
-			user_id = data.get("id")
-
-			session_id = int(redis.hget(f"{game}:sessionByAddress", str(event.peer.address)))
-			if not session_id:
-				logging.warning(f"Could not find session for address {str(event.peer.address)}!")
-				continue
-
-			if not redis.exists(f"{game}:relay:{session_id}"):
-				logging.warning(f"{game}:relay:{session_id} does not exist!")
-				continue
-			
-			if redis.hexists(f"{game}:relay:{session_id}", user_id):
-				logging.warning(f"Duplicate User ID {user_id} in {game}:relay:{session_id}!")
-				continue
-
-			peer = get_peer_by_address(address)
-			if not peer:
-				logging.warning(f"Could not find peer for address: {address}!")
-				continue
-			
-			if redis.exists(f"relays:{str(peer.address)}"):
-				logging.warning(f"Peer {str(peer.address)} is already in a relay!")
-				continue
-
-			redis.hset(f"{game}:relay:{session_id}", user_id, str(peer.address))
-			redis.hset(f"relays:{str(peer.address)}", mapping={"game": game, "session": session_id})
-			# Send the response back to the peer:
-			send(peer, {"cmd": "add_user_resp", "id": user_id})
+VERSIONS = {"moonbase": ["1.0", "1.1", "Demo"]}
+
+if __name__ == "__main__":
+
+    def get_full_game_names():
+        games = []
+        for game in GAMES:
+            versions = VERSIONS.get(game)
+            if versions:
+                for version in versions:
+                    games.append(f"{game}:{version}")
+            else:
+                games.append(game)
+        return games
+
+    if os.environ.get("DEBUG"):
+        logging.basicConfig(level=logging.DEBUG)
+
+    redis = redis_package.Redis(
+        os.environ.get("REDIS_HOST", "127.0.0.1"),
+        retry_on_timeout=True,
+        decode_responses=True,
+    )
+    for game in get_full_game_names():
+        # Reset session counter
+        redis.set(f"{game}:counter", 0)
+        if redis.exists(f"{game}:sessions"):
+            # Clear out the sessions
+            logging.info(f"Clearing out {game} sessions")
+            for session_id in range(redis.llen(f"{game}:sessions")):
+                redis.delete(f"{game}:session:{session_id}")
+                redis.delete(f"{game}:relay:{session_id}")
+            redis.delete(f"{game}:sessions")
+            redis.delete(f"{game}:sessionByAddress")
+
+    # Create our host to listen connections from.  4095 is the maxinum amount.
+    host = enet.Host(enet.Address(b"0.0.0.0", 9120), peerCount=4095, channelLimit=1)
+    print("Listening for messages in port 9120", flush=True)
+
+    def send(peer, data: dict):
+        logging.debug(f"{peer.address}: OUT: {data}")
+        data = json.dumps(data, separators=(",", ":")).encode()
+        peer.send(0, enet.Packet(data, enet.PACKET_FLAG_RELIABLE))
+
+    def get_peer_by_address(address: str):
+        for peer in host.peers:
+            if str(peer.address) == address:
+                return peer
+        return None
+
+    def get_session_by_address(game: str, address: str):
+        session_id = redis.hget(f"{game}:sessionByAddress", str(address))
+        if session_id:
+            return redis.hgetall(f"{game}:session:{session_id}")
+        return None
+
+    def create_session(name: str, maxplayers: int, address: str):
+        # Get our new session ID
+        session_id = redis.incr(f"{game}:counter")
+        # Create and store our new session
+        redis.hset(
+            f"{game}:session:{session_id}",
+            mapping={
+                "name": name,
+                "players": 0,
+                "maxplayers": maxplayers,
+                "address": str(event.peer.address),
+            },
+        )
+        # Add session to sessions list
+        redis.rpush(f"{game}:sessions", session_id)
+
+        # Store to address to session hash
+        redis.hset(f"{game}:sessionByAddress", str(address), session_id)
+
+        logging.debug(f'{address}: NEW SESSION: "{name}"')
+        return session_id
+
+    def relay_data(data, sent_peer):
+        from_user = data.get("from")
+        type_of_send = data.get("to")
+        send_type_param = data.get("toparam")
+
+        session_id = int(redis.hget(f"relays:{str(sent_peer.address)}", "session"))
+        if not session_id:
+            return
+
+        game = redis.hget(f"relays:{str(sent_peer.address)}", "game")
+        relay_users = redis.hgetall(f"{game}:relay:{session_id}")
+        if not relay_users:
+            logging.warning(f"relay_data: Missing users on {game}:relay:{session_id}!")
+            return
+
+        logging.debug(f'relay_data: Players of "{game}" session {session_id}:')
+        for user_id, address in relay_users.items():
+            logging.debug(f"relay_data:  - {user_id}: {address}")
+
+        peers_by_user_id = {}
+        for user_id, address in relay_users.items():
+            peer = get_peer_by_address(address)
+            if not peer:
+                logging.warning(f"relay_data: Peer for {address} does not exist!")
+                continue
+            peers_by_user_id[int(user_id)] = peer
+
+        user_id_by_peers = {v: k for k, v in peers_by_user_id.items()}
+
+        if user_id_by_peers.get(sent_peer) != 1:
+            # To make things easier, just send all non-host data to the host, so it can
+            # transfer data to peers that are connected directly to the host.
+            # It'll send it back to us if it actually needs to be relayed somewhere.
+            host_peer = peers_by_user_id.get(1)
+            if not host_peer:
+                logging.warning("relay_data: Host user (1) is missing!")
+                return
+            logging.debug(
+                f"relay_data: Relaying data from user {user_id_by_peers.get(sent_peer)} to host (1)."
+            )
+            send(host_peer, data)
+            return
+
+        peers_to_send = set()
+        if type_of_send == PN_SENDTYPE_INDIVIDUAL:
+            peer = peers_by_user_id.get(send_type_param)
+            if not peer:
+                logging.warning(
+                    f"relay_data: user {send_type_param} not in relay, Host does not know, something might be wrong."
+                )
+                return
+            logging.debug(f"relay_data: Relaying data to user {send_type_param}")
+            peers_to_send.add(peer)
+        elif type_of_send == PN_SENDTYPE_GROUP:
+            logging.warning("STUB: PN_SENDTYPE_GROUP")
+            return
+        elif type_of_send == PN_SENDTYPE_HOST:
+            # Chances are that the host is user_id 1.
+            peer = peers_by_user_id.get(1)
+            if not peer:
+                return
+            logging.debug(f"relay_data: Relaying data to host (user 1)")
+            peers_to_send.add(peer)
+        elif type_of_send == PN_SENDTYPE_ALL:
+            # Send to all peers
+            for peer in peers_by_user_id.values():
+                peers_to_send.add(peer)
+
+            logging.debug(
+                f"relay_data: Relaying data to all peers: {str(list(peers_by_user_id.keys()))}"
+            )
+        else:
+            logging.warning(f"relay_data: Unknown type of send: {type_of_send}")
+
+        # Remove self from set.
+        if sent_peer in peers_to_send:
+            peers_to_send.remove(sent_peer)
+
+        for peer in peers_to_send:
+            send(peer, data)
+
+    def remove_user_from_relay(peer):
+        session_id = redis.hget(f"relays:{str(peer.address)}", "session")
+        if not session_id:
+            return
+
+        game = redis.hget(f"relays:{str(peer.address)}", "game")
+        redis.delete(f"relays:{str(peer.address)}")
+
+        address_by_user_id = redis.hgetall(f"{game}:relay:{session_id}")
+        if not address_by_user_id:
+            return
+
+        user_id_by_address = {v: k for k, v in address_by_user_id.items()}
+        user_id = user_id_by_address.get(str(peer.address))
+        if not user_id:
+            return
+
+        redis.hdel(f"{game}:relay:{session_id}", user_id)
+
+        # Send the remove_user request to the host.
+        host_address = address_by_user_id.get(1)
+        if not host_address:
+            return
+
+        host_peer = get_peer_by_address(host_address)
+        if not host_peer:
+            return
+
+        send(host_peer, {"cmd": "remove_user", "id": user_id})
+
+    do_loop = True
+
+    def exit(*args):
+        global do_loop
+        do_loop = False
+
+    # For Docker, they grace stop with SIGTERM
+    signal.signal(signal.SIGTERM, exit)
+    # SIGINT: Ctrl+C KeyboardInterrupt
+    signal.signal(signal.SIGINT, exit)
+
+    while do_loop:
+        # Main event loop
+        event = host.service(1000)
+        if event.type == enet.EVENT_TYPE_CONNECT:
+            logging.debug(f"{event.peer.address}: CONNECT")
+        elif event.type == enet.EVENT_TYPE_DISCONNECT:
+            logging.debug(f"{event.peer.address}: DISCONNECT")
+            # Close out sessions relating to the address
+            for game in get_full_game_names():
+                session_id = redis.hget(
+                    f"{game}:sessionByAddress", str(event.peer.address)
+                )
+                if session_id:
+                    redis.delete(f"{game}:session:{session_id}")
+                    redis.lrem(f"{game}:sessions", 0, session_id)
+                    redis.hdel(f"{game}:sessionByAddress", str(event.peer.address))
+
+                # Cleanup Relays (if any):
+                remove_user_from_relay(event.peer)
+
+        elif event.type == enet.EVENT_TYPE_RECEIVE:
+            logging.debug(f"{event.peer.address}: IN: {event.packet.data}")
+            try:
+                data = json.loads(event.packet.data)
+            except:
+                logging.warning(
+                    f"{event.peer.address}: Received non-JSON data.",
+                    event.packet.data.decode(),
+                )
+                continue
+            command = data.get("cmd")
+
+            if command == "game":
+                relay_data(data, event.peer)
+                continue
+            elif command == "remove_user":
+                remove_user_from_relay(event.peer)
+                continue
+
+            game = data.get("game")
+            version = data.get("version")
+            if not command:
+                logging.warning(f"{event.peer.address}: Command missing")
+                continue
+            if not game:
+                logging.warning(f"{event.peer.address}: Game missing")
+                continue
+
+            if game not in GAMES:
+                logging.warning(f'Game "{game}" not supported.')
+                continue
+
+            versions = VERSIONS.get(game)
+            if versions:
+                if not version:
+                    logging.warning(f"{event.peer.address}: Version missing")
+                    continue
+
+                if version not in version:
+                    logging.warning(
+                        f'Game "{game}" with version "{version}" not supported.'
+                    )
+                    continue
+
+                # Update the game to contain the version
+                game = f"{game}:{version}"
+
+            if command == "host_session":
+                name = data.get("name")
+                maxplayers = data.get("maxplayers")
+
+                session_id = create_session(name, maxplayers, event.peer.address)
+                send(event.peer, {"cmd": "host_session_resp", "id": session_id})
+
+            elif command == "update_players":
+                players = data.get("players")
+
+                session_id = redis.hget(
+                    f"{game}:sessionByAddress", str(event.peer.address)
+                )
+                if session_id:
+                    redis.hset(f"{game}:session:{session_id}", "players", players)
+
+            elif command == "get_sessions":
+                sessions = []
+
+                num_sessions = redis.llen(f"{game}:sessions")
+                session_ids = redis.lrange(f"{game}:sessions", 0, num_sessions)
+                for id in session_ids:
+                    session = redis.hgetall(f"{game}:session:{id}")
+                    sessions.append(
+                        {
+                            "id": int(id),
+                            "name": session["name"],
+                            "players": int(session["players"]),
+                            "address": str(session["address"]),
+                        }
+                    )
+
+                send(
+                    event.peer,
+                    {
+                        "cmd": "get_sessions_resp",
+                        "address": str(event.peer.address),
+                        "sessions": sessions,
+                    },
+                )
+            elif command == "join_session":
+                session_id = data.get("id")
+
+                if not (redis.exists(f"{game}:session:{session_id}")):
+                    logging.warning(f"Session {game}:{session_id} not found")
+                    continue
+
+                address = redis.hget(f"{game}:session:{session_id}", "address")
+                peer = get_peer_by_address(address)
+                if not peer:
+                    continue
+
+                # Send the joiner's address to the hoster for hole-punching
+                send(
+                    peer, {"cmd": "joining_session", "address": str(event.peer.address)}
+                )
+            elif command == "start_relay":
+                session_id = data.get("session")
+
+                if not redis.exists(f"{game}:session:{session_id}"):
+                    logging.warning(f"Session {game}:{session_id} not found")
+                    continue
+
+                # Get peer of the session host
+                address = redis.hget(f"{game}:session:{session_id}", "address")
+                peer = get_peer_by_address(address)
+                if not peer:
+                    continue
+
+                if redis.exists(f"{game}:relay:{session_id}"):
+                    logging.warning(f"Relay for {game}:{session_id} already exists!")
+                    continue
+
+                # Store new relay with the host (which usually always has the
+                # userId of 1).
+                redis.hset(f"{game}:relay:{session_id}", 1, str(peer.address))
+                redis.hset(
+                    f"relays:{str(peer.address)}",
+                    mapping={"game": game, "session": session_id},
+                )
+
+                # Send the add_user request to the host (with joiner's address for context):
+                send(
+                    peer,
+                    {"cmd": "add_user_for_relay", "address": str(event.peer.address)},
+                )
+            elif command == "add_user_resp":
+                address = data.get("address")
+                user_id = data.get("id")
+
+                session_id = int(
+                    redis.hget(f"{game}:sessionByAddress", str(event.peer.address))
+                )
+                if not session_id:
+                    logging.warning(
+                        f"Could not find session for address {str(event.peer.address)}!"
+                    )
+                    continue
+
+                if not redis.exists(f"{game}:relay:{session_id}"):
+                    logging.warning(f"{game}:relay:{session_id} does not exist!")
+                    continue
+
+                if redis.hexists(f"{game}:relay:{session_id}", user_id):
+                    logging.warning(
+                        f"Duplicate User ID {user_id} in {game}:relay:{session_id}!"
+                    )
+                    continue
+
+                peer = get_peer_by_address(address)
+                if not peer:
+                    logging.warning(f"Could not find peer for address: {address}!")
+                    continue
+
+                if redis.exists(f"relays:{str(peer.address)}"):
+                    logging.warning(f"Peer {str(peer.address)} is already in a relay!")
+                    continue
+
+                redis.hset(f"{game}:relay:{session_id}", user_id, str(peer.address))
+                redis.hset(
+                    f"relays:{str(peer.address)}",
+                    mapping={"game": game, "session": session_id},
+                )
+                # Send the response back to the peer:
+                send(peer, {"cmd": "add_user_resp", "id": user_id})
diff --git a/net_defines.py b/net_defines.py
index 367622e..9bc11b2 100644
--- a/net_defines.py
+++ b/net_defines.py
@@ -1,8 +1,8 @@
 # Copied from engines/scumm/he/moonbase/net_defines.h
 
 # These are used for relaying.
-PN_PRIORITY_HIGH = 			0x00000001
-PN_SENDTYPE_INDIVIDUAL =	1
-PN_SENDTYPE_GROUP =			2
-PN_SENDTYPE_HOST =			3
-PN_SENDTYPE_ALL =			4
\ No newline at end of file
+PN_PRIORITY_HIGH = 0x00000001
+PN_SENDTYPE_INDIVIDUAL = 1
+PN_SENDTYPE_GROUP = 2
+PN_SENDTYPE_HOST = 3
+PN_SENDTYPE_ALL = 4
diff --git a/web/config.py b/web/config.py
index c4e81a6..4e23a6a 100644
--- a/web/config.py
+++ b/web/config.py
@@ -1,23 +1,21 @@
 # Games to accept:
 GAMES = [
-	"football", # Backyard Football (1999)
-	"baseball2001", # Backyard Baseball 2001
-	"football2002", # Backyard Football 2002 
-	"moonbase", # Moonbase Commander (v1.0/v1.1/Demo)
+    "football",  # Backyard Football (1999)
+    "baseball2001",  # Backyard Baseball 2001
+    "football2002",  # Backyard Football 2002
+    "moonbase",  # Moonbase Commander (v1.0/v1.1/Demo)
 ]
 
 # Full names of accepted games.  Make sure that they
 # match with the GAMES list above
 NAMES = {
-	"football": "Backyard Football",
-	"baseball2001": "Backyard Baseball 2001",
-	"football2002": "Backyard Football 2002",
-	"moonbase": "Moonbase Commander"
+    "football": "Backyard Football",
+    "baseball2001": "Backyard Baseball 2001",
+    "football2002": "Backyard Football 2002",
+    "moonbase": "Moonbase Commander",
 }
 
 # Version variants to accept for a specific game.
 # If none exist but game exist in the GAMES list,
 # that means there's only one game version.
-VERSIONS = {
-	"moonbase": ["1.0", "1.1", "Demo"]
-}
\ No newline at end of file
+VERSIONS = {"moonbase": ["1.0", "1.1", "Demo"]}
diff --git a/web/main.py b/web/main.py
index e3254ba..47427de 100644
--- a/web/main.py
+++ b/web/main.py
@@ -10,61 +10,89 @@ import aioredis
 
 from config import *
 
-routes = (
-	Mount('/static', StaticFiles(directory='static'), name='static'),
-)
+# NOTE: Top-level code for this file is not under a __name__ == "__main__" case
+# because it requires an ASGI web server such as Uvicorn to run the code.
+
+routes = (Mount("/static", StaticFiles(directory="static"), name="static"),)
 
-app = FastAPI(debug=os.environ.get('DEBUG', False), routes=routes, title="ScummVM Multiplayer")
+app = FastAPI(
+    debug=os.environ.get("DEBUG", False), routes=routes, title="ScummVM Multiplayer"
+)
 
 templates = Jinja2Templates(directory="templates")
 
-redis = aioredis.from_url(os.environ.get("REDIS_URL", "redis://localhost:6379"),
-						  retry_on_timeout=True, decode_responses=True)
+redis = aioredis.from_url(
+    os.environ.get("REDIS_URL", "redis://localhost:6379"),
+    retry_on_timeout=True,
+    decode_responses=True,
+)
+
+
+ at app.get("/")
+def index(request: Request, format: str = "html"):
+    if format == "json":
+        return {"games": NAMES}
+    return templates.TemplateResponse(
+        "index.html", {"request": request, "games": GAMES, "names": NAMES}
+    )
 
- at app.get('/')
-def index(request: Request, format: str = 'html'):
-	if format == 'json':
-		return {"games": NAMES}
-	return templates.TemplateResponse("index.html", {'request': request, 'games': GAMES, 'names': NAMES})
 
 async def get_sessions(game: str, version: str = None):
-	sessions = []
-	key = game
-	if version:
-		key += f":{version}"
-
-	num_sessions = await redis.llen(f"{key}:sessions")
-	session_ids = await redis.lrange(f"{key}:sessions", 0, num_sessions)
-	for id in session_ids:
-		session = await redis.hgetall(f"{key}:session:{id}")
-		sessions.append({"id": int(id), "version": version, "name": session["name"],
-						"players": int(session["players"]), "maxplayers": int(session["maxplayers"]),
-						"address": str(session["address"])})
-	return sessions
-
- at app.get('/{game}')
-async def game_page(request: Request, game: str, version: str = None, format: str = 'html'):
-	sessions = []
-	error = None
-	if game in GAMES:
-		versions = VERSIONS.get(game)
-		if versions:
-			version_request = version
-			if version_request and version_request in versions:
-				# Get sessions for a specific version
-				sessions = await get_sessions(game, version_request)
-			else:
-				# Get sessions for all versions
-				for version in versions:
-					sessions += await get_sessions(game, version)
-		else:
-			# No version variants
-			sessions = await get_sessions(game)
-	else:
-		error = f"Not supported game: \"{game}\""
-
-	if format == 'json':
-		if error:
-			return {"error": error}
-		return {"sessions": sessions}
-	return templates.TemplateResponse("game.html", {'request': request, 'name': NAMES.get(game, game), 'sessions': sessions, 'error': error})
+    sessions = []
+    key = game
+    if version:
+        key += f":{version}"
+
+    num_sessions = await redis.llen(f"{key}:sessions")
+    session_ids = await redis.lrange(f"{key}:sessions", 0, num_sessions)
+    for id in session_ids:
+        session = await redis.hgetall(f"{key}:session:{id}")
+        sessions.append(
+            {
+                "id": int(id),
+                "version": version,
+                "name": session["name"],
+                "players": int(session["players"]),
+                "maxplayers": int(session["maxplayers"]),
+                "address": str(session["address"]),
+            }
+        )
+    return sessions
+
+
+ at app.get("/{game}")
+async def game_page(
+    request: Request, game: str, version: str = None, format: str = "html"
+):
+    sessions = []
+    error = None
+    if game in GAMES:
+        versions = VERSIONS.get(game)
+        if versions:
+            version_request = version
+            if version_request and version_request in versions:
+                # Get sessions for a specific version
+                sessions = await get_sessions(game, version_request)
+            else:
+                # Get sessions for all versions
+                for version in versions:
+                    sessions += await get_sessions(game, version)
+        else:
+            # No version variants
+            sessions = await get_sessions(game)
+    else:
+        error = f'Not supported game: "{game}"'
+
+    if format == "json":
+        if error:
+            return {"error": error}
+        return {"sessions": sessions}
+    return templates.TemplateResponse(
+        "game.html",
+        {
+            "request": request,
+            "name": NAMES.get(game, game),
+            "sessions": sessions,
+            "error": error,
+        },
+    )


Commit: e1a7a482a9d6e197b2c4fe532bd4cf4442c15381
    https://github.com/scummvm/scummvm-sites/commit/e1a7a482a9d6e197b2c4fe532bd4cf4442c15381
Author: Little Cat (toontownlittlecat at gmail.com)
Date: 2023-03-29T01:16:01+02:00

Commit Message:
MULTIPLAYER: Add logo and adjust paths to it.

Changed paths:
  A web/static/scummvm_logo.png
    web/templates/game.html
    web/templates/index.html


diff --git a/web/static/scummvm_logo.png b/web/static/scummvm_logo.png
new file mode 100644
index 0000000..dc8f39f
Binary files /dev/null and b/web/static/scummvm_logo.png differ
diff --git a/web/templates/game.html b/web/templates/game.html
index ae73cc3..159fb8a 100644
--- a/web/templates/game.html
+++ b/web/templates/game.html
@@ -8,7 +8,7 @@
     <body>
         <div class="container">
             <div class="header">
-                <img src="https://scummvm.org/images/scummvm_logo.png"/>
+                <img src="/static/scummvm_logo.png"/>
             </div>
             <div class="content">
             {% if error -%}
@@ -31,4 +31,4 @@
             </div>
         </div>
     </body>
-</html>
\ No newline at end of file
+</html>
diff --git a/web/templates/index.html b/web/templates/index.html
index a306038..6b3b5bb 100644
--- a/web/templates/index.html
+++ b/web/templates/index.html
@@ -8,7 +8,7 @@
     <body>
         <div class="container">
             <div class="header">
-                <img src="https://scummvm.org/images/scummvm_logo.png"/>
+                <img src="/static/scummvm_logo.png"/>
             </div>
             <div class="content">
                 <p>Multiplayer Lobbies</p>
@@ -23,4 +23,4 @@
             </div>
         </div>
     </body>
-</html>
\ No newline at end of file
+</html>


Commit: 47a3ac5a40838e23c09512d14c26e0bfe097dd08
    https://github.com/scummvm/scummvm-sites/commit/47a3ac5a40838e23c09512d14c26e0bfe097dd08
Author: Little Cat (toontownlittlecat at gmail.com)
Date: 2023-03-29T01:16:01+02:00

Commit Message:
MULTIPLAYER: Add missing end line.

Changed paths:
    web/static/style.css


diff --git a/web/static/style.css b/web/static/style.css
index e9379e1..0aeaec0 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -83,7 +83,7 @@ a.link > b {
     outline: none;
     font-size: 15px;
   }
-  
+
   /* Add a background color to the button if it is clicked on (add the .active class with JS), and when you move the mouse over it (hover) */
   .active, .collapsible:hover {
     background-color: #cccccc;
@@ -93,4 +93,4 @@ a.link > b {
   a.link > b {
     font-size: 10pt;
   }
-}
\ No newline at end of file
+}


Commit: a59fb4347c8ed5a4f9e50aeb94c79bcd77e23b72
    https://github.com/scummvm/scummvm-sites/commit/a59fb4347c8ed5a4f9e50aeb94c79bcd77e23b72
Author: shkupfer (shkupfer at ncsu.edu)
Date: 2023-03-29T01:16:01+02:00

Commit Message:
MULTIPLAYER: Replace aioredis with redis, update README

Changed paths:
    README.md
    web/main.py
    web/requirements.txt


diff --git a/README.md b/README.md
index 2a85efc..cc46116 100644
--- a/README.md
+++ b/README.md
@@ -2,10 +2,7 @@
 
 This project contains code for hosting online multiplayer lobbies for compatable Humongous Entertainment games.
 
-## Getting Started
-### Installing
-Both the session and web servers requires Redis to be installed.  This is needed for both servers to share session data to each other.
-
+# Local Development
 Clone this repo and checkout to the multiplayer branch:
 ```
 git clone https://github.com/scummvm/scummvm-sites.git
@@ -13,51 +10,58 @@ git clone https://github.com/scummvm/scummvm-sites.git
 git checkout multiplayer
 ```
 
-To start the session server, create a new virtual envrionment and install the requirements.
+## With Docker
+The session, lobby and web server can be run within Docker via docker-compose.
 ```
-python3 -m venv .env
-source .env/bin/activate
-
-python3 -m pip install -r requirements.txt
+docker-compose build
+docker-compose up
 ```
 
-Backyard Football and Backyard Baseball 2001 needs a lobby server to play online.  You will need
-[Node.js](https://nodejs.org/en/) installed to run it.
+This will build Docker images for all three servers and starts a container for them simultaneously alongside with Redis.
 
-If you're planning to run the web server, install the requirements located in the web directory.
+## Without Docker
+### Redis
+Both the session and web servers use Redis.  It is needed for both servers to share session data to each other.  To install Redis, you can follow [the instructions on their website](https://redis.io/docs/getting-started/installation/). Then, start up a Redis instance by running
 ```
-python3 -m pip install -r web/requirements.txt
+redis-server
 ```
+### Session server
+To start the session server, first create a new virtual envrionment and install its requirements.
+```
+python3 -m venv .env
+source .env/bin/activate
 
-### Running
-To run the session server, simply run the main.py script
+python3 -m pip install -r requirements.txt
+```
+Then, run it with the main.py script:
 ```
 python3 main.py
 ```
 It should listen for connections on port 9120.  Remember to configure ScummVM to connect to localhost or whatever address your server is running in.
 
+### Lobby server
+Backyard Football and Backyard Baseball 2001 need a lobby server to play online.  You will need
+[Node.js](https://nodejs.org/en/) installed to run it.
+
 To start the lobby server, go to the `lobby` directory and install the dependencies:
 ```
 cd lobby
 npm install
 ```
-
 After that's done, you can simply run the `run.js` file.
 ```
 node run.js
 ```
 
-Running a web server is unnecessary if you just want your server to host sessions, but if you want to, you can start one up by using uvicorn.
+### Web server
+Running the web server isn't necessary if you just want your server to host sessions, but if you want to you can run it. First, go to the `web` directory and install the requirements there.
 ```
 cd web
-uvicorn main:app --reload
+python3 -m venv .env
+source .env/bin/activate
+python3 -m pip install -r requirements.txt
 ```
-
-## Docker
-The session, lobby and web server can be run within Docker via docker-compose.
+And then start the server with uvicorn
 ```
-docker-compose build
-docker-compose up
+uvicorn main:app --reload
 ```
-
-This will build Docker images for all three servers and starts a container for them simultaneously alongside with Redis.
diff --git a/web/main.py b/web/main.py
index 47427de..a22e880 100644
--- a/web/main.py
+++ b/web/main.py
@@ -6,7 +6,7 @@ from fastapi.staticfiles import StaticFiles
 from fastapi.requests import Request
 from fastapi.routing import Mount
 
-import aioredis
+from redis import asyncio as aioredis
 
 from config import *
 
diff --git a/web/requirements.txt b/web/requirements.txt
index 3af1006..2a839be 100644
--- a/web/requirements.txt
+++ b/web/requirements.txt
@@ -1,4 +1,4 @@
 fastapi==0.89.1
-aioredis==2.0.1
+redis==4.5.3
 uvicorn==0.20.0
 Jinja2==3.1.2
\ No newline at end of file


Commit: 8190b98cbc9ed3bfef98796a757e3f4570076203
    https://github.com/scummvm/scummvm-sites/commit/8190b98cbc9ed3bfef98796a757e3f4570076203
Author: shkupfer (shkupfer at ncsu.edu)
Date: 2023-03-29T01:16:01+02:00

Commit Message:
MULTIPLAYER: Make redis start before server

Changed paths:
    docker-compose.yml


diff --git a/docker-compose.yml b/docker-compose.yml
index 837f6a7..b73ca90 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -6,6 +6,8 @@ services:
       - REDIS_HOST=redis
     ports:
       - "9120:9120/udp"
+    depends_on:
+      - redis
   lobby:
     build: ./lobby/
     environment:


Commit: d002097a1b007af970baf1cdc0c20544576624e0
    https://github.com/scummvm/scummvm-sites/commit/d002097a1b007af970baf1cdc0c20544576624e0
Author: shkupfer (shkupfer at ncsu.edu)
Date: 2023-03-29T01:16:01+02:00

Commit Message:
MULTIPLAYER: Base web image on python:3.9

Changed paths:
  A web/gunicorn_conf.py
    web/Dockerfile
    web/requirements.txt


diff --git a/web/Dockerfile b/web/Dockerfile
index 52eb120..7fbc68a 100644
--- a/web/Dockerfile
+++ b/web/Dockerfile
@@ -1,7 +1,11 @@
-FROM tiangolo/uvicorn-gunicorn-fastapi:python3.9
+FROM python:3.9
 
-COPY ./requirements.txt /app/requirements.txt
+ENV PYTHONPATH=/app
+WORKDIR /app
 
+COPY ./requirements.txt /app/requirements.txt
 RUN pip install --no-cache-dir --upgrade -r /app/requirements.txt
 
-COPY ./ /app
\ No newline at end of file
+COPY ./ /app
+
+CMD gunicorn -k uvicorn.workers.UvicornWorker -c gunicorn_conf.py main:app
\ No newline at end of file
diff --git a/web/gunicorn_conf.py b/web/gunicorn_conf.py
new file mode 100644
index 0000000..33330e3
--- /dev/null
+++ b/web/gunicorn_conf.py
@@ -0,0 +1,48 @@
+# Inspired by https://github.com/tiangolo/uvicorn-gunicorn-docker/blob/58ce0895f8c38b895e84f7ddb2128d66748b437c/docker-images/gunicorn_conf.py
+import multiprocessing
+import os
+
+workers_per_core_str = os.getenv("WORKERS_PER_CORE", "1")
+max_workers_str = os.getenv("MAX_WORKERS")
+use_max_workers = None
+if max_workers_str:
+    use_max_workers = int(max_workers_str)
+web_concurrency_str = os.getenv("WEB_CONCURRENCY", None)
+
+host = os.getenv("HOST", "0.0.0.0")
+port = os.getenv("PORT", "80")
+bind_env = os.getenv("BIND", None)
+use_loglevel = os.getenv("LOG_LEVEL", "info")
+if bind_env:
+    use_bind = bind_env
+else:
+    use_bind = f"{host}:{port}"
+
+cores = multiprocessing.cpu_count()
+workers_per_core = float(workers_per_core_str)
+default_web_concurrency = workers_per_core * cores
+if web_concurrency_str:
+    web_concurrency = int(web_concurrency_str)
+    assert web_concurrency > 0
+else:
+    web_concurrency = max(int(default_web_concurrency), 2)
+    if use_max_workers:
+        web_concurrency = min(web_concurrency, use_max_workers)
+accesslog_var = os.getenv("ACCESS_LOG", "-")
+use_accesslog = accesslog_var or None
+errorlog_var = os.getenv("ERROR_LOG", "-")
+use_errorlog = errorlog_var or None
+graceful_timeout_str = os.getenv("GRACEFUL_TIMEOUT", "120")
+timeout_str = os.getenv("TIMEOUT", "120")
+keepalive_str = os.getenv("KEEP_ALIVE", "5")
+
+# Gunicorn config variables
+loglevel = use_loglevel
+workers = web_concurrency
+bind = use_bind
+errorlog = use_errorlog
+worker_tmp_dir = "/dev/shm"
+accesslog = use_accesslog
+graceful_timeout = int(graceful_timeout_str)
+timeout = int(timeout_str)
+keepalive = int(keepalive_str)
diff --git a/web/requirements.txt b/web/requirements.txt
index 2a839be..4e5c8a4 100644
--- a/web/requirements.txt
+++ b/web/requirements.txt
@@ -1,4 +1,5 @@
 fastapi==0.89.1
 redis==4.5.3
-uvicorn==0.20.0
+gunicorn==20.1.0
+uvicorn[standard]==0.21.1
 Jinja2==3.1.2
\ No newline at end of file


Commit: 08b6a47f2f9335b596c965f2f85547b22f3539b7
    https://github.com/scummvm/scummvm-sites/commit/08b6a47f2f9335b596c965f2f85547b22f3539b7
Author: Little Cat (toontownlittlecat at gmail.com)
Date: 2023-03-29T01:16:01+02:00

Commit Message:
MULTIPLAYER: Add GPLv3 license and file headers.

Changed paths:
  A LICENSE
    lobby/database/Redis.js
    lobby/database/WebAPI.js
    lobby/discord/Discord.js
    lobby/global/Areas.js
    lobby/global/EventLogger.js
    lobby/global/Stats.js
    lobby/net/AreaMessages.js
    lobby/net/ChallengeMessages.js
    lobby/net/DatabaseMessages.js
    lobby/net/NetworkConnection.js
    lobby/net/NetworkListener.js
    lobby/net/SessionMessages.js
    lobby/run.js
    main.py
    net_defines.py
    web/main.py


diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..f288702
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,674 @@
+                    GNU GENERAL PUBLIC LICENSE
+                       Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+                            Preamble
+
+  The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+  The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works.  By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.  We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors.  You can apply it to
+your programs, too.
+
+  When we speak of free software, we are referring to freedom, not
+price.  Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+  To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights.  Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+  For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received.  You must make sure that they, too, receive
+or can get the source code.  And you must show them these terms so they
+know their rights.
+
+  Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+  For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software.  For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+  Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so.  This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software.  The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable.  Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products.  If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+  Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary.  To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+  The precise terms and conditions for copying, distribution and
+modification follow.
+
+                       TERMS AND CONDITIONS
+
+  0. Definitions.
+
+  "This License" refers to version 3 of the GNU General Public License.
+
+  "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+  "The Program" refers to any copyrightable work licensed under this
+License.  Each licensee is addressed as "you".  "Licensees" and
+"recipients" may be individuals or organizations.
+
+  To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy.  The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+  A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+  To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy.  Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+  To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies.  Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+  An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License.  If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+  1. Source Code.
+
+  The "source code" for a work means the preferred form of the work
+for making modifications to it.  "Object code" means any non-source
+form of a work.
+
+  A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+  The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form.  A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+  The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities.  However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work.  For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+  The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+  The Corresponding Source for a work in source code form is that
+same work.
+
+  2. Basic Permissions.
+
+  All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met.  This License explicitly affirms your unlimited
+permission to run the unmodified Program.  The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work.  This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+  You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force.  You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright.  Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+  Conveying under any other circumstances is permitted solely under
+the conditions stated below.  Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+  3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+  No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+  When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+  4. Conveying Verbatim Copies.
+
+  You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+  You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+  5. Conveying Modified Source Versions.
+
+  You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+    a) The work must carry prominent notices stating that you modified
+    it, and giving a relevant date.
+
+    b) The work must carry prominent notices stating that it is
+    released under this License and any conditions added under section
+    7.  This requirement modifies the requirement in section 4 to
+    "keep intact all notices".
+
+    c) You must license the entire work, as a whole, under this
+    License to anyone who comes into possession of a copy.  This
+    License will therefore apply, along with any applicable section 7
+    additional terms, to the whole of the work, and all its parts,
+    regardless of how they are packaged.  This License gives no
+    permission to license the work in any other way, but it does not
+    invalidate such permission if you have separately received it.
+
+    d) If the work has interactive user interfaces, each must display
+    Appropriate Legal Notices; however, if the Program has interactive
+    interfaces that do not display Appropriate Legal Notices, your
+    work need not make them do so.
+
+  A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit.  Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+  6. Conveying Non-Source Forms.
+
+  You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+    a) Convey the object code in, or embodied in, a physical product
+    (including a physical distribution medium), accompanied by the
+    Corresponding Source fixed on a durable physical medium
+    customarily used for software interchange.
+
+    b) Convey the object code in, or embodied in, a physical product
+    (including a physical distribution medium), accompanied by a
+    written offer, valid for at least three years and valid for as
+    long as you offer spare parts or customer support for that product
+    model, to give anyone who possesses the object code either (1) a
+    copy of the Corresponding Source for all the software in the
+    product that is covered by this License, on a durable physical
+    medium customarily used for software interchange, for a price no
+    more than your reasonable cost of physically performing this
+    conveying of source, or (2) access to copy the
+    Corresponding Source from a network server at no charge.
+
+    c) Convey individual copies of the object code with a copy of the
+    written offer to provide the Corresponding Source.  This
+    alternative is allowed only occasionally and noncommercially, and
+    only if you received the object code with such an offer, in accord
+    with subsection 6b.
+
+    d) Convey the object code by offering access from a designated
+    place (gratis or for a charge), and offer equivalent access to the
+    Corresponding Source in the same way through the same place at no
+    further charge.  You need not require recipients to copy the
+    Corresponding Source along with the object code.  If the place to
+    copy the object code is a network server, the Corresponding Source
+    may be on a different server (operated by you or a third party)
+    that supports equivalent copying facilities, provided you maintain
+    clear directions next to the object code saying where to find the
+    Corresponding Source.  Regardless of what server hosts the
+    Corresponding Source, you remain obligated to ensure that it is
+    available for as long as needed to satisfy these requirements.
+
+    e) Convey the object code using peer-to-peer transmission, provided
+    you inform other peers where the object code and Corresponding
+    Source of the work are being offered to the general public at no
+    charge under subsection 6d.
+
+  A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+  A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling.  In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage.  For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product.  A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+  "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source.  The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+  If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information.  But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+  The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed.  Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+  Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+  7. Additional Terms.
+
+  "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law.  If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+  When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it.  (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.)  You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+  Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+    a) Disclaiming warranty or limiting liability differently from the
+    terms of sections 15 and 16 of this License; or
+
+    b) Requiring preservation of specified reasonable legal notices or
+    author attributions in that material or in the Appropriate Legal
+    Notices displayed by works containing it; or
+
+    c) Prohibiting misrepresentation of the origin of that material, or
+    requiring that modified versions of such material be marked in
+    reasonable ways as different from the original version; or
+
+    d) Limiting the use for publicity purposes of names of licensors or
+    authors of the material; or
+
+    e) Declining to grant rights under trademark law for use of some
+    trade names, trademarks, or service marks; or
+
+    f) Requiring indemnification of licensors and authors of that
+    material by anyone who conveys the material (or modified versions of
+    it) with contractual assumptions of liability to the recipient, for
+    any liability that these contractual assumptions directly impose on
+    those licensors and authors.
+
+  All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10.  If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term.  If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+  If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+  Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+  8. Termination.
+
+  You may not propagate or modify a covered work except as expressly
+provided under this License.  Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+  However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+  Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+  Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License.  If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+  9. Acceptance Not Required for Having Copies.
+
+  You are not required to accept this License in order to receive or
+run a copy of the Program.  Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance.  However,
+nothing other than this License grants you permission to propagate or
+modify any covered work.  These actions infringe copyright if you do
+not accept this License.  Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+  10. Automatic Licensing of Downstream Recipients.
+
+  Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License.  You are not responsible
+for enforcing compliance by third parties with this License.
+
+  An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations.  If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+  You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License.  For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+  11. Patents.
+
+  A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based.  The
+work thus licensed is called the contributor's "contributor version".
+
+  A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version.  For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+  Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+  In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement).  To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+  If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients.  "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+  If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+  A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License.  You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+  Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+  12. No Surrender of Others' Freedom.
+
+  If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License.  If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all.  For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+  13. Use with the GNU Affero General Public License.
+
+  Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work.  The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+  14. Revised Versions of this License.
+
+  The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time.  Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+  Each version is given a distinguishing version number.  If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation.  If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+  If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+  Later license versions may give you additional or different
+permissions.  However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+  15. Disclaimer of Warranty.
+
+  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+  16. Limitation of Liability.
+
+  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+  17. Interpretation of Sections 15 and 16.
+
+  If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+                     END OF TERMS AND CONDITIONS
+
+            How to Apply These Terms to Your New Programs
+
+  If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+  To do so, attach the following notices to the program.  It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+    <one line to give the program's name and a brief idea of what it does.>
+    Copyright (C) <year>  <name of author>
+
+    This program is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with this program.  If not, see <https://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+  If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+    <program>  Copyright (C) <year>  <name of author>
+    This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+    This is free software, and you are welcome to redistribute it
+    under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License.  Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+  You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+<https://www.gnu.org/licenses/>.
+
+  The GNU General Public License does not permit incorporating your program
+into proprietary programs.  If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library.  If this is what you want to do, use the GNU Lesser General
+Public License instead of this License.  But first, please read
+<https://www.gnu.org/licenses/why-not-lgpl.html>.
diff --git a/lobby/database/Redis.js b/lobby/database/Redis.js
index ed94d67..ee85f94 100644
--- a/lobby/database/Redis.js
+++ b/lobby/database/Redis.js
@@ -1,3 +1,24 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
 "use strict";
 
 const createLogger = require('logging').default;
diff --git a/lobby/database/WebAPI.js b/lobby/database/WebAPI.js
index 07fdd44..3dda8e5 100644
--- a/lobby/database/WebAPI.js
+++ b/lobby/database/WebAPI.js
@@ -1,3 +1,24 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
 "use strict";
 const createLogger = require('logging').default;
 const bent = require('bent');
diff --git a/lobby/discord/Discord.js b/lobby/discord/Discord.js
index 90a0e19..d6366ba 100644
--- a/lobby/discord/Discord.js
+++ b/lobby/discord/Discord.js
@@ -1,3 +1,24 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
 "use strict";
 
 const createLogger = require('logging').default;
diff --git a/lobby/global/Areas.js b/lobby/global/Areas.js
index 465022d..0f55c05 100644
--- a/lobby/global/Areas.js
+++ b/lobby/global/Areas.js
@@ -1,3 +1,24 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
 "use strict";
 
 const Areas = {
diff --git a/lobby/global/EventLogger.js b/lobby/global/EventLogger.js
index 72624e2..3576b5d 100644
--- a/lobby/global/EventLogger.js
+++ b/lobby/global/EventLogger.js
@@ -1,3 +1,24 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
 "use strict";
 const createLogger = require('logging').default;
 const logger = createLogger('Event');
diff --git a/lobby/global/Stats.js b/lobby/global/Stats.js
index eaf2f36..5444eca 100644
--- a/lobby/global/Stats.js
+++ b/lobby/global/Stats.js
@@ -1,3 +1,24 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
 "use strict";
 
 const ResultsMappers = {
@@ -32,4 +53,4 @@ const ResultsMappers = {
 
 module.exports = {
     ResultsMappers: ResultsMappers,
-}; 
\ No newline at end of file
+};
diff --git a/lobby/net/AreaMessages.js b/lobby/net/AreaMessages.js
index 754265f..bcc9391 100644
--- a/lobby/net/AreaMessages.js
+++ b/lobby/net/AreaMessages.js
@@ -1,3 +1,24 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
 "use strict";
 const createLogger = require('logging').default;
 const logger = createLogger('AreaMessages');
diff --git a/lobby/net/ChallengeMessages.js b/lobby/net/ChallengeMessages.js
index 2f156d8..4d1fa62 100644
--- a/lobby/net/ChallengeMessages.js
+++ b/lobby/net/ChallengeMessages.js
@@ -1,3 +1,24 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
 "use strict";
 const createLogger = require('logging').default;
 const logger = createLogger('ChallengeMessages');
diff --git a/lobby/net/DatabaseMessages.js b/lobby/net/DatabaseMessages.js
index 1a388da..5c140ec 100644
--- a/lobby/net/DatabaseMessages.js
+++ b/lobby/net/DatabaseMessages.js
@@ -1,3 +1,24 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
 "use strict";
 const createLogger = require('logging').default;
 const logger = createLogger('DatabaseMessages');
diff --git a/lobby/net/NetworkConnection.js b/lobby/net/NetworkConnection.js
index 30d6632..12b2b1d 100644
--- a/lobby/net/NetworkConnection.js
+++ b/lobby/net/NetworkConnection.js
@@ -1,3 +1,24 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
 "use strict";
 const createLogger = require('logging').default;
 
diff --git a/lobby/net/NetworkListener.js b/lobby/net/NetworkListener.js
index bd02a0e..417086f 100644
--- a/lobby/net/NetworkListener.js
+++ b/lobby/net/NetworkListener.js
@@ -1,3 +1,24 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
 "use strict";
 const createLogger = require('logging').default;
 const net = require('net');
diff --git a/lobby/net/SessionMessages.js b/lobby/net/SessionMessages.js
index 5142fe4..dac0fb5 100644
--- a/lobby/net/SessionMessages.js
+++ b/lobby/net/SessionMessages.js
@@ -1,3 +1,24 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
 "use strict";
 const createLogger = require('logging').default;
 const logger = createLogger('SessionMessages');
diff --git a/lobby/run.js b/lobby/run.js
index f47675e..89ae487 100644
--- a/lobby/run.js
+++ b/lobby/run.js
@@ -1,3 +1,24 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
 "use strict"
 
 const yaml         = require('js-yaml');
diff --git a/main.py b/main.py
index f3fd45f..db154a3 100644
--- a/main.py
+++ b/main.py
@@ -1,3 +1,22 @@
+# ScummVM - Graphic Adventure Engine
+#
+# ScummVM is the legal property of its developers, whose names
+# are too numerous to list here. Please refer to the COPYRIGHT
+# file distributed with this source distribution.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
 import json
 import logging
 import os
diff --git a/net_defines.py b/net_defines.py
index 9bc11b2..fcf4a00 100644
--- a/net_defines.py
+++ b/net_defines.py
@@ -1,3 +1,22 @@
+# ScummVM - Graphic Adventure Engine
+#
+# ScummVM is the legal property of its developers, whose names
+# are too numerous to list here. Please refer to the COPYRIGHT
+# file distributed with this source distribution.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
 # Copied from engines/scumm/he/moonbase/net_defines.h
 
 # These are used for relaying.
diff --git a/web/main.py b/web/main.py
index a22e880..90c9677 100644
--- a/web/main.py
+++ b/web/main.py
@@ -1,3 +1,22 @@
+# ScummVM - Graphic Adventure Engine
+#
+# ScummVM is the legal property of its developers, whose names
+# are too numerous to list here. Please refer to the COPYRIGHT
+# file distributed with this source distribution.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
 import os
 
 from fastapi import FastAPI


Commit: 8c88e8770dd51893c0e7dd4bdd4f95387a49fa98
    https://github.com/scummvm/scummvm-sites/commit/8c88e8770dd51893c0e7dd4bdd4f95387a49fa98
Author: Little Cat (toontownlittlecat at gmail.com)
Date: 2023-03-29T01:16:01+02:00

Commit Message:
MULTIPLAYER: Sanity check game data packets.

Changed paths:
    main.py
    net_defines.py


diff --git a/main.py b/main.py
index db154a3..c79428d 100644
--- a/main.py
+++ b/main.py
@@ -119,9 +119,48 @@ if __name__ == "__main__":
         from_user = data.get("from")
         type_of_send = data.get("to")
         send_type_param = data.get("toparam")
+        packet_type = data.get("type")
+        packet_data = data.get("data")
+
+        if None in (from_user, type_of_send, send_type_param, packet_type, packet_data):
+            logging.warning(
+                f"relay_data: Got malformed game data from {str(sent_peer.address)}: {data}"
+            )
+            return
+
+        # Check the packet received to see if it contains the proper data.
+        if packet_type in (
+            PACKETTYPE_REMOTESTARTSCRIPT,
+            PACKETTYPE_REMOTESTARTSCRIPTRETURN,
+            PACKETTYPE_REMOTESTARTSCRIPTRESULT,
+        ):
+            params = packet_data.get("params")
+            if not params or not isinstance(params, list):
+                logging.warning(
+                    f"relay_data: Missing params in a remote start script packet from {str(sent_peer.address)}: {data}"
+                )
+                return
+        elif packet_type == PACKETTYPE_REMOTESENDSCUMMARRAY:
+            dim1start = packet_data.get("dim1start")
+            dim1end = packet_data.get("dim1end")
+            dim2start = packet_data.get("dim2start")
+            dim2end = packet_data.get("dim2end")
+            atype = packet_data.get("type")
+
+            if not all(
+                isinstance(i, int)
+                for i in (dim1start, dim1end, dim2start, dim2end, atype)
+            ):
+                logging.warning(
+                    f"relay_data: Malformed SCUMM array data from {str(sent_peer.address)}: {data}"
+                )
+                return
 
         session_id = int(redis.hget(f"relays:{str(sent_peer.address)}", "session"))
         if not session_id:
+            logging.warning(
+                f"relay_data: Could not find session id for peer: {str(sent_peer.address)}"
+            )
             return
 
         game = redis.hget(f"relays:{str(sent_peer.address)}", "game")
diff --git a/net_defines.py b/net_defines.py
index fcf4a00..74ce2e1 100644
--- a/net_defines.py
+++ b/net_defines.py
@@ -25,3 +25,9 @@ PN_SENDTYPE_INDIVIDUAL = 1
 PN_SENDTYPE_GROUP = 2
 PN_SENDTYPE_HOST = 3
 PN_SENDTYPE_ALL = 4
+
+# These are used for sanity checking.
+PACKETTYPE_REMOTESTARTSCRIPT = 1
+PACKETTYPE_REMOTESTARTSCRIPTRETURN = 2
+PACKETTYPE_REMOTESTARTSCRIPTRESULT = 3
+PACKETTYPE_REMOTESENDSCUMMARRAY = 4




More information about the Scummvm-git-logs mailing list