Compare commits

..

2 commits

Author SHA1 Message Date
David Négrier 8e07fc6513 Adding default Jitsi in env.template 2020-09-16 17:09:34 +02:00
David Négrier c4a0f2f1a4 Fixing URL for room in Artilery 2020-09-16 16:59:09 +02:00
1195 changed files with 22765 additions and 88767 deletions

View file

@ -1,2 +0,0 @@
**/node_modules/**
**/Dockerfile

View file

@ -1,31 +1,2 @@
DEBUG_MODE=false
JITSI_URL=meet.jit.si
# If your Jitsi environment has authentication set up, you MUST set JITSI_PRIVATE_MODE to "true" and you MUST pass a SECRET_JITSI_KEY to generate the JWT secret
JITSI_PRIVATE_MODE=false
JITSI_ISS=
SECRET_JITSI_KEY=
ADMIN_API_TOKEN=123
START_ROOM_URL=/_/global/maps.workadventure.localhost/starter/map.json
# If your Turn server is configured to use the Turn REST API, you should put the shared auth secret here.
# If you are using Coturn, this is the value of the "static-auth-secret" parameter in your coturn config file.
# Keep empty if you are sharing hard coded / clear text credentials.
TURN_STATIC_AUTH_SECRET=
DISABLE_NOTIFICATIONS=true
SKIP_RENDER_OPTIMIZATIONS=false
# The email address used by Let's encrypt to send renewal warnings (compulsory)
ACME_EMAIL=
MAX_PER_GROUP=4
MAX_USERNAME_LENGTH=8
OPID_CLIENT_ID=
OPID_CLIENT_SECRET=
OPID_CLIENT_ISSUER=
OPID_CLIENT_REDIRECT_URL=
OPID_LOGIN_SCREEN_PROVIDER=http://pusher.workadventure.localhost/login-screen
OPID_PROFILE_SCREEN_PROVIDER=
DISABLE_ANONYMOUS=
# If you want to have a contact page in your menu, you MUST set CONTACT_URL to the URL of the page that you want
CONTACT_URL=

View file

@ -1,13 +1,7 @@
name: Build, push and deploy Docker image
on:
push:
branches: [master, develop]
release:
types: [created]
pull_request:
types: [ labeled, synchronize ]
- push
# Enables BuildKit
env:
@ -16,7 +10,7 @@ env:
jobs:
build-front:
if: ${{ github.event_name == 'push' || github.event_name == 'release' || github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'deploy') }}
runs-on: ubuntu-latest
steps:
@ -26,21 +20,21 @@ jobs:
# Create a slugified value of the branch
- uses: rlespinasse/github-slug-action@3.1.0
- uses: rlespinasse/github-slug-action@master
- name: "Build and push front image"
uses: docker/build-push-action@v1
with:
dockerfile: front/Dockerfile
path: ./
path: front/
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
repository: thecodingmachine/workadventure-front
tags: ${{ github.event_name == 'pull_request' && env.GITHUB_HEAD_REF_SLUG || env.GITHUB_REF_SLUG }}
tags: ${{ env.GITHUB_REF_SLUG }}
add_git_labels: true
build-back:
if: ${{ github.event_name == 'push' || github.event_name == 'release' || github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'deploy') }}
runs-on: ubuntu-latest
steps:
@ -49,21 +43,21 @@ jobs:
uses: actions/checkout@v2
# Create a slugified value of the branch
- uses: rlespinasse/github-slug-action@3.1.0
- uses: rlespinasse/github-slug-action@master
- name: "Build and push back image"
uses: docker/build-push-action@v1
with:
dockerfile: back/Dockerfile
path: ./
path: back/
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
repository: thecodingmachine/workadventure-back
tags: ${{ github.event_name == 'pull_request' && env.GITHUB_HEAD_REF_SLUG || env.GITHUB_REF_SLUG }}
tags: ${{ env.GITHUB_REF_SLUG }}
add_git_labels: true
build-pusher:
if: ${{ github.event_name == 'push' || github.event_name == 'release' || github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'deploy') }}
build-website:
runs-on: ubuntu-latest
steps:
@ -72,44 +66,21 @@ jobs:
uses: actions/checkout@v2
# Create a slugified value of the branch
- uses: rlespinasse/github-slug-action@3.1.0
- uses: rlespinasse/github-slug-action@master
- name: "Build and push back image"
uses: docker/build-push-action@v1
with:
dockerfile: pusher/Dockerfile
path: ./
dockerfile: website/Dockerfile
path: website/
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
repository: thecodingmachine/workadventure-pusher
tags: ${{ github.event_name == 'pull_request' && env.GITHUB_HEAD_REF_SLUG || env.GITHUB_REF_SLUG }}
add_git_labels: true
build-uploader:
if: ${{ github.event_name == 'push' || github.event_name == 'release' || github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'deploy') }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
# Create a slugified value of the branch
- uses: rlespinasse/github-slug-action@3.1.0
- name: "Build and push back image"
uses: docker/build-push-action@v1
with:
dockerfile: uploader/Dockerfile
path: ./
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
repository: thecodingmachine/workadventure-uploader
tags: ${{ github.event_name == 'pull_request' && env.GITHUB_HEAD_REF_SLUG || env.GITHUB_REF_SLUG }}
repository: thecodingmachine/workadventure-website
tags: ${{ env.GITHUB_REF_SLUG }}
add_git_labels: true
build-maps:
if: ${{ github.event_name == 'push' || github.event_name == 'release' || github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'deploy') }}
runs-on: ubuntu-latest
steps:
@ -119,7 +90,7 @@ jobs:
# Create a slugified value of the branch
- uses: rlespinasse/github-slug-action@3.1.0
- uses: rlespinasse/github-slug-action@master
- name: "Build and push front image"
uses: docker/build-push-action@v1
@ -129,76 +100,63 @@ jobs:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
repository: thecodingmachine/workadventure-maps
tags: ${{ github.event_name == 'pull_request' && env.GITHUB_HEAD_REF_SLUG || env.GITHUB_REF_SLUG }}
tags: ${{ env.GITHUB_REF_SLUG }}
add_git_labels: true
deeploy:
needs:
- build-front
- build-back
- build-pusher
- build-maps
- build-uploader
runs-on: ubuntu-latest
if: ${{ github.event_name == 'push' || github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'deploy') }}
steps:
- name: Checkout
uses: actions/checkout@v2
# Create a slugified value of the branch
- uses: rlespinasse/github-slug-action@3.1.0
- name: Write certificate
run: echo "${CERTS_PRIVATE_KEY}" > secret.key && chmod 0600 secret.key
env:
CERTS_PRIVATE_KEY: ${{ secrets.CERTS_PRIVATE_KEY }}
- name: Download certificate
run: mkdir secrets && scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i secret.key ubuntu@cert.workadventu.re:./config/live/workadventu.re/* secrets/
- name: Create namespace
uses: steebchen/kubectl@v1.0.0
env:
KUBE_CONFIG_DATA: ${{ secrets.KUBE_CONFIG_FILE_BASE64 }}
with:
args: create namespace workadventure-${{ github.event_name == 'pull_request' && env.GITHUB_HEAD_REF_SLUG || env.GITHUB_REF_SLUG }}
continue-on-error: true
- name: Delete old certificates in namespace
uses: steebchen/kubectl@v1.0.0
env:
KUBE_CONFIG_DATA: ${{ secrets.KUBE_CONFIG_FILE_BASE64 }}
with:
args: -n workadventure-${{ github.event_name == 'pull_request' && env.GITHUB_HEAD_REF_SLUG || env.GITHUB_REF_SLUG }} delete secret certificate-tls
continue-on-error: true
- name: Install certificates in namespace
uses: steebchen/kubectl@v1.0.0
env:
KUBE_CONFIG_DATA: ${{ secrets.KUBE_CONFIG_FILE_BASE64 }}
with:
args: -n workadventure-${{ github.event_name == 'pull_request' && env.GITHUB_HEAD_REF_SLUG || env.GITHUB_REF_SLUG }} create secret tls certificate-tls --key="secrets/privkey.pem" --cert="secrets/fullchain.pem"
- uses: rlespinasse/github-slug-action@1.1.0
- name: Deploy
uses: thecodingmachine/deeployer-action@master
uses: thecodingmachine/deeployer@master
env:
KUBE_CONFIG_FILE: ${{ secrets.KUBE_CONFIG_FILE }}
ADMIN_API_TOKEN: ${{ secrets.ADMIN_API_TOKEN }}
JITSI_ISS: ${{ secrets.JITSI_ISS }}
JITSI_URL: ${{ secrets.JITSI_URL }}
SECRET_JITSI_KEY: ${{ secrets.SECRET_JITSI_KEY }}
TURN_STATIC_AUTH_SECRET: ${{ secrets.TURN_STATIC_AUTH_SECRET }}
DEPLOY_REF: ${{ github.event_name == 'pull_request' && env.GITHUB_HEAD_REF_SLUG || env.GITHUB_REF_SLUG }}
POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }}
POSTHOG_URL: ${{ secrets.POSTHOG_URL }}
with:
namespace: workadventure-${{ github.event_name == 'pull_request' && env.GITHUB_HEAD_REF_SLUG || env.GITHUB_REF_SLUG }}
namespace: workadventure-${{ env.GITHUB_REF_SLUG }}
- name: Add a comment in PR
uses: unsplash/comment-on-pr@v1.2.0
if: ${{ github.event_name == 'pull_request' }}
if: ${{ env.GITHUB_REF_SLUG != 'master' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
msg: "Environment deployed at https://play-${{ env.GITHUB_HEAD_REF_SLUG }}.test.workadventu.re \nTests available at https://maps-${{ env.GITHUB_HEAD_REF_SLUG }}.test.workadventu.re/tests"
msg: Environment deployed at https://${{ env.GITHUB_REF_SLUG }}.workadventure.test.thecodingmachine.com
check_for_duplicate_msg: true
- name: Run Cypress tests
uses: cypress-io/github-action@v1
if: ${{ env.GITHUB_REF_SLUG != 'master' }}
env:
CYPRESS_BASE_URL: https://play.${{ env.GITHUB_REF_SLUG }}.workadventure.test.thecodingmachine.com
with:
env: host=play.${{ env.GITHUB_REF_SLUG }}.workadventure.test.thecodingmachine.com,port=80
spec: cypress/integration/spec.js
wait-on: https://play.${{ env.GITHUB_REF_SLUG }}.workadventure.test.thecodingmachine.com
working-directory: e2e
- name: Run Cypress tests in prod
uses: cypress-io/github-action@v1
if: ${{ env.GITHUB_REF_SLUG == 'master' }}
env:
CYPRESS_BASE_URL: https://play.workadventu.re
with:
env: host=play.workadventu.re
spec: cypress/integration/spec.js
wait-on: https://workadventu.re
working-directory: e2e
- name: "Upload the screenshot on test failure"
uses: actions/upload-artifact@v1
if: failure()
with:
name: "screenshot"
path: "./e2e/cypress/screenshots/spec.js/WorkAdventureGame -- loads (failed).png"

View file

@ -1,8 +1,7 @@
name: Cleanup images and environments
on:
pull_request:
types: [ closed ]
- delete
# Enables BuildKit
env:
@ -15,12 +14,13 @@ jobs:
steps:
# Create a slugified value of the branch
- uses: rlespinasse/github-slug-action@3.1.0
- uses: rlespinasse/github-slug-action@1.1.0
- name: Cleanup
continue-on-error: true
uses: thecodingmachine/deeployer-cleanup-action@master
env:
KUBE_CONFIG_FILE: ${{ secrets.KUBE_CONFIG_FILE }}
with:
namespace: workadventure-${{ env.GITHUB_HEAD_REF_SLUG }}
# FIXME: we are not using ${{ env.GITHUB_REF_SLUG }} that resolves to master BUT! we are not using a slugified namespace
# so complex namespace names will not be treated correctly
namespace: workadventure-${{ github.event.ref }}

View file

@ -1,71 +0,0 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
push:
branches: [ develop ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ develop ]
schedule:
- cron: '24 17 * * 0'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'javascript' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
# Learn more:
# https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
steps:
- name: Checkout repository
uses: actions/checkout@v2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v1
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1

View file

@ -3,13 +3,11 @@
name: "Continuous Integration"
on:
push:
branches:
- master
- develop
pull_request:
- "pull_request"
- "push"
jobs:
continuous-integration-front:
name: "Continuous Integration Front"
@ -22,103 +20,26 @@ jobs:
- name: "Setup NodeJS"
uses: actions/setup-node@v1
with:
node-version: '14.x'
- name: Install Protoc
uses: arduino/setup-protoc@v1
with:
version: '3.x'
node-version: '12.x'
- name: "Install dependencies"
run: yarn install
working-directory: "front"
- name: "Install messages dependencies"
run: yarn install
working-directory: "messages"
- name: "Build proto messages"
run: yarn run ts-proto && yarn run copy-to-front-ts-proto && yarn run json-copy-to-front
working-directory: "messages"
- name: "Create index.html"
run: ./templater.sh
working-directory: "front"
- name: "Generate i18n files"
run: yarn run typesafe-i18n
working-directory: "front"
- name: "Build"
run: yarn run build
env:
PUSHER_URL: "//localhost:8080"
ADMIN_URL: "//localhost:80"
working-directory: "front"
- name: "Svelte check"
run: yarn run svelte-check
API_URL: "localhost:8080"
working-directory: "front"
- name: "Lint"
run: yarn run lint
working-directory: "front"
- name: "Pretty"
run: yarn run pretty-check
working-directory: "front"
- name: "Jasmine"
run: yarn test
working-directory: "front"
continuous-integration-pusher:
name: "Continuous Integration Pusher"
runs-on: "ubuntu-latest"
steps:
- name: "Checkout"
uses: "actions/checkout@v2.0.0"
- name: "Setup NodeJS"
uses: actions/setup-node@v1
with:
node-version: '14.x'
- name: Install Protoc
uses: arduino/setup-protoc@v1
with:
version: '3.x'
- name: "Install dependencies"
run: yarn install
working-directory: "pusher"
- name: "Install messages dependencies"
run: yarn install
working-directory: "messages"
- name: "Build proto messages"
run: yarn run proto && yarn run copy-to-pusher && yarn run json-copy-to-pusher
working-directory: "messages"
- name: "Build"
run: yarn run tsc
working-directory: "pusher"
- name: "Lint"
run: yarn run lint
working-directory: "pusher"
- name: "Jasmine"
run: yarn test
working-directory: "pusher"
- name: "Prettier"
run: yarn run pretty-check
working-directory: "pusher"
continuous-integration-back:
name: "Continuous Integration Back"
@ -133,23 +54,10 @@ jobs:
with:
node-version: '12.x'
- name: Install Protoc
uses: arduino/setup-protoc@v1
with:
version: '3.x'
- name: "Install dependencies"
run: yarn install
working-directory: "back"
- name: "Install messages dependencies"
run: yarn install
working-directory: "messages"
- name: "Build proto messages"
run: yarn run proto && yarn run copy-to-back
working-directory: "messages"
- name: "Build"
run: yarn run tsc
working-directory: "back"
@ -162,7 +70,3 @@ jobs:
run: yarn test
working-directory: "back"
- name: "Prettier"
run: yarn run pretty-check
working-directory: "back"

View file

@ -1,126 +0,0 @@
# https://help.github.com/en/categories/automating-your-workflow-with-github-actions
name: "End to end tests"
on:
push:
branches:
- master
- develop
pull_request:
jobs:
start-runner:
if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false)
name: Start self-hosted EC2 runner
runs-on: ubuntu-latest
outputs:
label: ${{ steps.start-ec2-runner.outputs.label }}
ec2-instance-id: ${{ steps.start-ec2-runner.outputs.ec2-instance-id }}
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ secrets.AWS_REGION }}
- name: Start EC2 runner
id: start-ec2-runner
uses: machulav/ec2-github-runner@v2
with:
mode: start
github-token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }}
ec2-image-id: ami-094dbcc53250a2480
ec2-instance-type: m5.2xlarge
subnet-id: subnet-0ac40025f559df1bc
security-group-id: sg-0e36e96e3b8ed2d64
#iam-role-name: my-role-name # optional, requires additional permissions
#aws-resource-tags: > # optional, requires additional permissions
# [
# {"Key": "Name", "Value": "ec2-github-runner"},
# {"Key": "GitHubRepository", "Value": "${{ github.repository }}"}
# ]
end-to-end-tests:
name: "End-to-end testcafe tests"
needs: start-runner # required to start the main job when the runner is ready
runs-on: ${{ needs.start-runner.outputs.label }} # run the job on the newly created runner
steps:
- name: "Checkout"
uses: "actions/checkout@v2.0.0"
- name: "Setup NodeJS"
uses: actions/setup-node@v1
with:
node-version: '14.x'
- name: "Install dependencies"
run: npm install
working-directory: "tests"
- name: "Setup .env file"
run: cp .env.template .env
- name: "Edit ownership of file for test cases"
run: sudo chown 1000:1000 -R .
- name: "Start environment"
run: LIVE_RELOAD=0 docker-compose up -d
- name: "Wait for environment to build (and downloading testcafe image)"
run: (docker-compose -f docker-compose.testcafe.yml build &) && docker-compose logs -f --tail=0 front | grep -q "Compiled successfully"
# - name: "temp debug: display logs"
# run: docker-compose logs
#
# - name: "Wait for back start"
# run: docker-compose logs -f back | grep -q "WorkAdventure HTTP API starting on port"
#
# - name: "Wait for pusher start"
# run: docker-compose logs -f pusher | grep -q "WorkAdventure starting on port"
- name: "Run tests"
run: PROJECT_DIR=$(pwd) docker-compose -f docker-compose.testcafe.yml up --exit-code-from testcafe
- name: Upload failed tests
if: ${{ failure() }}
uses: actions/upload-artifact@v2
with:
name: my-artifact
path: './tests/screenshots/'
- name: Display state
if: ${{ failure() }}
run: docker-compose ps
- name: Display logs
if: ${{ failure() }}
run: docker-compose logs
stop-runner:
name: Stop self-hosted EC2 runner
needs:
- start-runner # required to get output from the start-runner job
- end-to-end-tests # required to wait when the main job is done
runs-on: ubuntu-latest
if: ${{ always() }} # required to stop the runner even if the error happened in the previous jobs
steps:
- name: Configure AWS credentials
if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false)
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ secrets.AWS_REGION }}
- name: Stop EC2 runner
if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false)
uses: machulav/ec2-github-runner@v2
with:
mode: stop
github-token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }}
label: ${{ needs.start-runner.outputs.label }}
ec2-instance-id: ${{ needs.start-runner.outputs.ec2-instance-id }}

View file

@ -1,73 +0,0 @@
name: Push @workadventure/iframe-api-typings to NPM
on:
release:
types: [created]
push:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
# Setup .npmrc file to publish to npm
- uses: actions/setup-node@v2
with:
node-version: '14.x'
registry-url: 'https://registry.npmjs.org'
- name: Replace version number
run: 'sed -i "s#VERSION_PLACEHOLDER#${GITHUB_REF/refs\/tags\//}#g" package.json'
working-directory: "front/packages/iframe-api-typings"
- name: Debug package.json
run: cat package.json
working-directory: "front/packages/iframe-api-typings"
- name: Install Protoc
uses: arduino/setup-protoc@v1
with:
version: '3.x'
- name: "Install dependencies"
run: yarn install
working-directory: "front"
- name: "Install messages dependencies"
run: yarn install
working-directory: "messages"
- name: "Build proto messages"
run: yarn run ts-proto && yarn run copy-to-front-ts-proto && yarn run json-copy-to-front
working-directory: "messages"
- name: "Create index.html"
run: ./templater.sh
working-directory: "front"
- name: "Generate i18n files"
run: yarn run typesafe-i18n
working-directory: "front"
- name: "Build"
run: yarn run build-typings
env:
PUSHER_URL: "//localhost:8080"
ADMIN_URL: "//localhost:80"
working-directory: "front"
# We build the front to generate the typings of iframe_api, then we copy those typings in a separate package.
- name: Copy typings to package dir
run: cp front/dist/src/iframe_api.d.ts front/packages/iframe-api-typings/iframe_api.d.ts
- name: Copy typings to package dir (2)
run: cp -R front/dist/src/Api front/packages/iframe-api-typings/Api
- name: Install dependencies in package
run: yarn install
working-directory: "front/packages/iframe-api-typings"
- name: Publish package
run: yarn publish
working-directory: "front/packages/iframe-api-typings"
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
if: ${{ github.event_name == 'release' }}

7
.gitignore vendored
View file

@ -3,9 +3,4 @@
.vagrant
Vagrantfile
docker-compose.override.yaml
*.DS_Store
maps/yarn.lock
maps/dist/computer.js
maps/dist/computer.js.map
node_modules
_
*.DS_Store

1
.husky/.gitignore vendored
View file

@ -1 +0,0 @@
_

View file

@ -1,19 +0,0 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
(
cd messages || exit
yarn run precommit
)
(
cd front || exit
yarn run precommit
)
(
cd pusher || exit
yarn run precommit
)
(
cd back || exit
yarn run precommit
)

View file

@ -1,166 +0,0 @@
## Version develop
### Updates
- Added multi Co-Website management
### Bugfix
- Moving a discussion over a user will now add this user to the discussion
- Being in a silent zone new forces mediaConstraints to false (#1508)
- Fixes for the emote menu (#1501)
- Fixing chat message attributed to wrong user (#1507 #1528)
## Version 1.5.0
### Updates
- Added support for login with OpenID Connect
- New scripting library available to extend WorkAdventure: see [Scripting API Extra](https://github.com/workadventure/scripting-api-extra/)
- New menu design!
- New `openTab` property (#1419)
- Possible integration with Posthog (#1458)
### Bugfix
- Fixing layers flattened several times (#1427 @Lurkars)
- Fixing CSS of video elements
- Chat now scrolls to bottom when opened (#1450)
- Fixing silent zone not respected when exiting from Jitsi (#1456)
- Fixing "yarn install" failing because of missing rights on some Docker installs (#1457)
- Fixing audio not shut down when exiting a room (#1459)
### Misc
- Finished migrating "Build your map" documentation into the "/docs" directory of this repository (#1417 #1385)
- Refactoring documentation (dedicated page for variables) (#1414)
- Front container code is now completely linted (#1413)
## Version 1.4.15
### Updates
- New scripting API features :
- Use `WA.ui.registerMenuCommand(commandDescriptor: string, options: MenuOptions): Menu` to add a custom menu or an iframe to the menu.
- New `jitsiWidth` parameter to set the width of Jitsi and Cowebsite (#1398 @tabascoeye)
- Refactored the way videos are displayed to better cope for vertical videos (on mobile)
- Fixing reconnection issues after 5 minutes of an inactive tab on Google Chrome
- Changes performed in `WA.room.setPropertyLayer` now have a real-time impact (#1395)
### Bugfixes
- Fixing streams in bubbles sometimes improperly muted when there are more than 2 people in the bubble (#1400 #1402)
- Properly displaying carriage returns in popups (#1388)
- `WA.state` now answers correctly to "in" keyword (#1393)
- Variables can now be nested in group layers (#1406)
## Version 1.4.14
### Updates
- New scripting API features :
- Use `WA.room.loadTileset(url: string) : Promise<number>` to load a tileset from a JSON file.
- Rewrote the way authentification works: the auth jwt token can now contains an email instead of an uuid
- Added an OpenId login flow than can be plugged to any OIDC provider.
- You can send a message to all rooms of your world from the console global message (user with tag admin only).
## Version 1.4.11
### Updates
- Added the ability to have animated tiles in maps #1216 #1217
- Enabled outlines on actionable item again (they were disabled when migrating to Phaser 3.50) #1218
- Enabled outlines on player names (when the mouse hovers on a player you can interact with) #1219
- Migrated the admin console to Svelte, and redesigned the console #1211
- Layer properties (like `exitUrl`, `silent`, etc...) can now also used in tile properties #1210 (@jonnytest1)
- New scripting API features :
- Use `WA.onInit(): Promise<void>` to wait for scripting API initialization
- Use `WA.room.showLayer(): void` to show a layer
- Use `WA.room.hideLayer(): void` to hide a layer
- Use `WA.room.setProperty() : void` to add, delete or change existing property of a layer
- Use `WA.player.onPlayerMove(): void` to track the movement of the current player
- Use `WA.player.id: string|undefined` to get the ID of the current player
- Use `WA.player.name: string` to get the name of the current player
- Use `WA.player.tags: string[]` to get the tags of the current player
- Use `WA.room.id: string` to get the ID of the room
- Use `WA.room.mapURL: string` to get the URL of the map
- Use `WA.room.mapURL: string` to get the URL of the map
- Use `WA.room.getMap(): Promise<ITiledMap>` to get the JSON map file
- Use `WA.room.setTiles(): void` to add, delete or change an array of tiles
- Use `WA.ui.registerMenuCommand(): void` to add a custom menu
- Use `WA.state.loadVariable(key: string): unknown` to retrieve a variable
- Use `WA.state.saveVariable(key: string, value: unknown): Promise<void>` to set a variable (across the room, for all users)
- Use `WA.state.onVariableChange(key: string): Observable<unknown>` to track a variable
- Use `WA.state.[any variable]: unknown` to access directly any variable (this is a shortcut to using `WA.state.loadVariable` and `WA.state.saveVariable`)
- Users blocking now relies on UUID rather than ID. A blocked user that leaves a room and comes back will stay blocked.
- The text chat was redesigned to be prettier and to use more features :
- The chat is now persistent between discussions and always accessible
- The chat now tracks incoming and outcoming users in your conversation
- The chat allows your to see the visit card of users
- You can close the chat window with the escape key
- Added a 'Enable notifications' button in the menu.
- The exchange format between Pusher and Admin servers has changed. If you have your own implementation of an admin server, these endpoints signatures have changed:
- `/api/map`: now accepts a complete room URL instead of organization/world/room slugs
- `/api/ban`: new endpoint to report users
- as a side effect, the "routing" is now completely stored on the admin side, so by implementing your own admin server, you can develop completely custom routing
## Version 1.4.3 - 1.4.4 - 1.4.5
## Bugfixes
- Fixing the generation of @workadventure/iframe-api-typings
## Version 1.4.2
## Updates
- A script in an iframe opened by another script can use the IFrame API.
## Version 1.4.1
### Bugfixes
- Loading errors after the preload stage should not crash the game anymore
## Version 1.4.0
### BREAKING CHANGES
- Scripting API:
- Changed function names: `restorePlayerControl` => `restorePlayerControls`, `disablePlayerControl` => `disablePlayerControls`.
Please keep in mind that the scripting API is still experimental. Some breaking changes can occur in it until we mark it as stable.
### Updates
- Added the emote feature to WorkAdventure. (@Kharhamel, @Tabascoeye)
- The emote menu can be opened by clicking on your character.
- Clicking on one of its element will close the menu and play an emote above your character.
- This emote can be seen by other players.
- Player names were improved. (@Kharhamel)
- We now create a GameObject.Text instead of GameObject.BitmapText
- now use the 'Press Start 2P' font family and added an outline
- As a result, we can now allow non-standard letters like french accents or chinese characters!
- Added the contact card feature. (@Kharhamel)
- Click on another player to see its contact info.
- Premium-only feature unfortunately. I need to find a way to make it available for all.
- If no contact data is found (either because the user is anonymous or because no admin backend), display an error card.
- Mobile support has been improved
- WorkAdventure automatically sets the zoom level based on the viewport size to ensure a sensible size of the map is visible, whatever the viewport used
- Mouse wheel support to zoom in / out
- Pinch support on mobile to zoom in / out
- Improved virtual joystick size (adapts to the zoom level)
- Redesigned intermediate scenes
- Redesigned Select Companion scene
- Redesigned Enter Your Name scene
- Added a new `DISPLAY_TERMS_OF_USE` environment variable to trigger the display of terms of use
- New scripting API features:
- Use `WA.loadSound(): Sound` to load / play / stop a sound
### Bug Fixes
- Pinch gesture does no longer move the character
## Version 1.3.0
### New Features
* Maps can now contain "group" layers (layers that contain other layers) - #899 #779 (@Lurkars @moufmouf)
### Updates
### Bug Fixes

View file

@ -1,113 +0,0 @@
# Contributing to WorkAdventure
Are you looking to help on WorkAdventure? Awesome, feel welcome and read the following sections in order to know how to
ask questions and how to work on something.
## Contributions we are seeking
We love to receive contributions from our community — you!
There are many ways to contribute, from writing tutorials or blog posts, improving the documentation,
submitting bug reports and feature requests or writing code which can be incorporated into WorkAdventure itself.
## Contributing external resources
You can share your work on maps / articles / videos related to WorkAdventure on our [awesome-workadventure](https://github.com/workadventure/awesome-workadventure) list.
## Developer documentation
Documentation targeted at developers can be found in the [`/docs/dev`](docs/dev/)
## Using the issue tracker
First things first: **Do NOT report security vulnerabilities in public issues!**.
Please read the [security guide](SECURITY.md) to learn who to do a security disclosure to the WorkAdventure core team.
You can use [GitHub issue tracker](https://github.com/thecodingmachine/workadventure/issues) to:
- File bug reports
- Ask for feature requests
If you have more general questions, a good place to ask is [our Discord server](https://discord.gg/YGtngdh9gt).
Finally, you can come and talk to the WorkAdventure core team... on WorkAdventure, of course! [Our offices are here](https://play.staging.workadventu.re/@/tcm/workadventure/wa-village).
## Pull requests
Good pull requests - patches, improvements, new features - are a fantastic help. They should remain focused in scope
and avoid containing unrelated commits.
Please ask first before embarking on any significant pull request (e.g. implementing features, refactoring code),
otherwise you risk spending a lot of time working on something that the project's developers might not want to merge
into the project.
You can ask us on [Discord](https://discord.gg/YGtngdh9gt) or in the [GitHub issues](https://github.com/thecodingmachine/workadventure/issues).
### Linting your code
Before committing, be sure to install the "Prettier" precommit hook that will reformat your code to our coding style.
In order to enable the "Prettier" precommit hook, at the root of the project, run:
```console
$ yarn install
$ yarn run prepare
```
If you don't have the precommit hook installed (or if you committed code before installing the precommit hook), you will need
to run code linting manually:
```console
$ docker-compose exec front yarn run pretty
$ docker-compose exec pusher yarn run pretty
$ docker-compose exec back yarn run pretty
```
### Providing tests
WorkAdventure is based on a video game engine (Phaser), and video games are not the easiest programs to unit test.
Nevertheless, if your code can be unit tested, please provide a unit test (we use Jasmine), or an end-to-end test (we use Testcafe).
If you are providing a new feature, you should setup a test map in the `maps/tests` directory. The test map should contain
some description text describing how to test the feature.
* if the features is meant to be manually tested, you should modify the `maps/tests/index.html` file to add a reference
to your newly created test map
* if the features can be automatically tested, please provide a testcafe test
#### Running testcafe tests
End-to-end tests are available in the "/tests" directory.
To run these tests locally:
```console
$ LIVE_RELOAD=0 docker-compose up -d
$ cd tests
$ npm install
$ npm run test
```
Note: If your tests fail on a Javascript error in "sockjs", this is due to the
Webpack live reload. The Webpack live reload feature is conflicting with testcafe. This is why we recommend starting
WorkAdventure with the `LIVE_RELOAD=0` environment variable.
End-to-end tests can take a while to run. To run only one test, use:
```console
$ npm run test -- tests/[name of the test file].ts
```
You can also run the tests inside a container (but you will not have visual feedbacks on your test, so we recommend using
the local tests).
```console
$ LIVE_RELOAD=0 docker-compose up -d
# Wait 2-3 minutes for the environment to start, then:
$ PROJECT_DIR=$(pwd) docker-compose -f docker-compose.testcafe.yml up
```
### A bad wording or a missing language
If you notice a translation error or missing language you can help us by following the [how to translate](docs/dev/how-to-translate.md) documentation.

BIN
README-INTRO.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

View file

@ -1,49 +1,41 @@
![](https://github.com/thecodingmachine/workadventure/workflows/Continuous%20Integration/badge.svg) [![Discord](https://img.shields.io/discord/821338762134290432?label=Discord)](https://discord.gg/YGtngdh9gt)
![](https://github.com/thecodingmachine/workadventure/workflows/Continuous%20Integration/badge.svg)
![WorkAdventure logo](README-LOGO.svg)
![WorkAdventure office image](README-MAP.png)
![WorkAdventure landscape image](README-INTRO.jpg)
Live demo [here](https://play.workadventu.re/@/tcm/workadventure/wa-village).
Demo here : [https://workadventu.re/](https://workadventu.re/).
# WorkAdventure
# Work Adventure
WorkAdventure is a web-based collaborative workspace presented in the form of a
## Work in progress
Work Adventure is a web-based collaborative workspace for small to medium teams (2-100 people) presented in the form of a
16-bit video game.
In WorkAdventure you can move around your office and talk to your colleagues (using a video-chat system, triggered when you approach someone).
In Work Adventure, you can move around your office and talk to your colleagues (using a video-chat feature that is
triggered when you move next to a colleague).
See more features for your virtual office: https://workadventu.re/virtual-office
## Community resources
Check out resources developed by the WorkAdventure community at [awesome-workadventure](https://github.com/workadventure/awesome-workadventure)
## Setting up a development environment
## Getting started
Install Docker.
Run:
```
cp .env.template .env
docker-compose up -d
docker-compose up
```
The environment will start.
You should now be able to browse to http://play.workadventure.localhost/ and see the application.
You can view the dashboard at http://workadventure.localhost:8080/
You should now be able to browse to http://workadventure.localhost/ and see the application.
Note: on some OSes, you will need to add this line to your `/etc/hosts` file:
**/etc/hosts**
```
127.0.0.1 workadventure.localhost
workadventure.localhost 127.0.0.1
```
Note: If on the first run you get a page with "network error". Try to ``docker-compose stop`` , then ``docker-compose start``.
Note 2: If you are still getting "network error". Make sure you are authorizing the self-signed certificate by entering https://pusher.workadventure.localhost and accepting them.
### MacOS developers, your environment with Vagrant
If you are using MacOS, you can increase Docker performance using Vagrant. If you want more explanations, you can read [this medium article](https://medium.com/better-programming/vagrant-to-increase-docker-performance-with-macos-25b354b0c65c).
@ -109,7 +101,5 @@ Vagrant destroy
* `Vagrant halt`: stop your VM Vagrant.
* `Vagrant destroy`: delete your VM Vagrant.
## Setting up a production environment
The way you set up your production environment will highly depend on your servers.
We provide a production ready `docker-compose` file that you can use as a good starting point in the [contrib/docker](https://github.com/thecodingmachine/workadventure/tree/master/contrib/docker) directory.
## Features developed
You have more details of features developed in back [README.md](./back/README.md).

View file

@ -1,20 +0,0 @@
# Security Policy
## Reporting a Vulnerability
First things first: **Do NOT report security vulnerabilities in public issues!**
Please disclose responsibly by sending
a mail at security@workadventu.re (you can also ping us in the GitHub issues, but please, no details in the issues!)
We will assess the issue as soon as possible on a best-effort basis and will give you an estimate for when we have a fix
and release available for an eventual public disclosure.
We do not have a bug bounty program.
## Supported Versions
We only apply security patches on the latest tagged release and on the `master` and `develop` branches
Unless specified otherwise, do not expect us to fix security issues on past releases. We are only maintaining one release:
the latest one, which is online at https://play.workadventu.re.

View file

@ -2,7 +2,7 @@
# -*- mode: ruby -*-
# vi: set ft=ruby :
# Box / OS
VAGRANT_BOX = 'bento/ubuntu-20.04'
VAGRANT_BOX = 'bento/ubuntu-19.10'
# VM User — 'vagrant' by default
VM_USER = 'vagrant'
@ -58,7 +58,7 @@ Vagrant.configure(2) do |config|
apt-get update -y
apt-get install -y git
apt-get install -y apt-transport-https
apt-get install -y ca-certificates
apt-get install -y build-essential
apt-get install -y curl
apt-get install -y gnupg-agent
apt-get install -y software-properties-common
@ -66,8 +66,8 @@ Vagrant.configure(2) do |config|
apt-key fingerprint 0EBFCD88
add-apt-repository \
"deb [arch=amd64] https://download.docker.com/linux/ubuntu \
$(lsb_release -cs) \
stable"
$(lsb_release -cs) \
stable"
apt-get update -y
apt-get install -y docker-ce docker-ce-cli containerd.io
curl -L "https://github.com/docker/compose/releases/download/1.25.4/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose

View file

@ -25,7 +25,6 @@
],
"rules": {
"no-unused-vars": "off",
"@typescript-eslint/no-explicit-any": "error",
"no-throw-literal": "error"
"@typescript-eslint/no-explicit-any": "error"
}
}

View file

@ -1 +0,0 @@
src/Messages/generated

View file

@ -1,4 +0,0 @@
{
"printWidth": 120,
"tabWidth": 4
}

View file

@ -1,26 +1,9 @@
# protobuf build
FROM node:14.18.2-buster-slim@sha256:20bedf0c09de887379e59a41c04284974f5fb529cf0e13aab613473ce298da3d as builder
WORKDIR /usr/src
COPY messages .
RUN yarn install && yarn proto
FROM thecodingmachine/nodejs:12
# typescript build
FROM node:14.18.2-buster-slim@sha256:20bedf0c09de887379e59a41c04284974f5fb529cf0e13aab613473ce298da3d as builder2
WORKDIR /usr/src
COPY back/yarn.lock back/package.json ./
COPY --chown=docker:docker . .
RUN yarn install
COPY back .
COPY --from=builder /usr/src/generated src/Messages/generated
ENV NODE_ENV=production
RUN yarn run tsc
# final production image
FROM node:14.18.2-buster-slim@sha256:20bedf0c09de887379e59a41c04284974f5fb529cf0e13aab613473ce298da3d
WORKDIR /usr/src
COPY back/yarn.lock back/package.json ./
COPY --from=builder2 /usr/src/dist /usr/src/dist
ENV NODE_ENV=production
RUN yarn install --production
USER node
CMD ["yarn", "run", "runprod"]
CMD ["yarn", "run", "prod"]

61
back/README.md Normal file
View file

@ -0,0 +1,61 @@
# Back Features
## Login
To start your game, you must authenticate on the server back.
When you are authenticated, the back server return token and room starting.
```
POST => /login
Params :
email: email of user.
```
## Join a room
When a user is connected, the user can join a room.
So you must send emit `join-room` with information user:
```
Socket.io => 'join-room'
userId: user id of gamer
roomId: room id when user enter in game
position: {
x: position x on map
y: position y on map
}
```
All data users are stocked on socket client.
## Send position user
When user move on the map, you can share new position on back with event `user-position`.
The information sent:
```
Socket.io => 'user-position'
userId: user id of gamer
roomId: room id when user enter in game
position: {
x: position x on map
y: position y on map
}
```
All data users are updated on socket client.
## Receive positions of all users
The application sends position of all users in each room in every few 10 milliseconds.
The data will pushed on event `user-position`:
```
Socket.io => 'user-position'
[
{
userId: user id of gamer
roomId: room id when user enter in game
position: {
x: position x on map
y: position y on map
}
},
...
]
```
[<<< back](../README.md)

View file

@ -5,16 +5,12 @@
"main": "index.js",
"scripts": {
"tsc": "tsc",
"dev": "ts-node-dev --respawn ./server.ts",
"prod": "tsc && node --max-old-space-size=4096 ./dist/server.js",
"runprod": "node --max-old-space-size=4096 ./dist/server.js",
"dev": "ts-node-dev --respawn --transpileOnly ./server.ts",
"prod": "tsc && node ./dist/server.js",
"profile": "tsc && node --prof ./dist/server.js",
"test": "ts-node node_modules/jasmine/bin/jasmine --config=jasmine.json",
"lint": "DEBUG= node_modules/.bin/eslint src/ . --ext .ts",
"fix": "DEBUG= node_modules/.bin/eslint --fix src/ . --ext .ts",
"precommit": "lint-staged",
"pretty": "yarn prettier --write 'src/**/*.{ts,tsx}'",
"pretty-check": "yarn prettier --check 'src/**/*.{ts,tsx}'"
"lint": "node_modules/.bin/eslint src/ . --ext .ts",
"fix": "node_modules/.bin/eslint --fix src/ . --ext .ts"
},
"repository": {
"type": "git",
@ -40,46 +36,28 @@
},
"homepage": "https://github.com/thecodingmachine/workadventure#readme",
"dependencies": {
"@workadventure/tiled-map-type-guard": "^1.0.3",
"axios": "^0.21.2",
"busboy": "^0.3.1",
"circular-json": "^0.5.9",
"debug": "^4.3.1",
"@types/express": "^4.17.4",
"@types/http-status-codes": "^1.2.0",
"@types/jsonwebtoken": "^8.3.8",
"@types/socket.io": "^2.1.4",
"@types/uuidv4": "^5.0.0",
"body-parser": "^1.19.0",
"express": "^4.17.1",
"generic-type-guard": "^3.2.0",
"google-protobuf": "^3.13.0",
"grpc": "^1.24.4",
"ipaddr.js": "^2.0.1",
"http-status-codes": "^1.4.0",
"jsonwebtoken": "^8.5.1",
"mkdirp": "^1.0.4",
"prom-client": "^12.0.0",
"query-string": "^6.13.3",
"redis": "^3.1.2",
"uWebSockets.js": "uNetworking/uWebSockets.js#v18.5.0",
"socket.io": "^2.3.0",
"systeminformation": "^4.26.5",
"ts-node-dev": "^1.0.0-pre.44",
"typescript": "^3.8.3",
"uuidv4": "^6.0.7"
},
"devDependencies": {
"@types/busboy": "^0.2.3",
"@types/circular-json": "^0.4.0",
"@types/debug": "^4.1.5",
"@types/google-protobuf": "^3.7.3",
"@types/http-status-codes": "^1.2.0",
"@types/jasmine": "^3.5.10",
"@types/jsonwebtoken": "^8.3.8",
"@types/mkdirp": "^1.0.1",
"@types/redis": "^2.8.31",
"@types/uuidv4": "^5.0.0",
"@typescript-eslint/eslint-plugin": "^5.8.0",
"@typescript-eslint/parser": "^5.8.0",
"eslint": "^8.5.0",
"jasmine": "^3.5.0",
"lint-staged": "^11.0.0",
"prettier": "^2.3.1",
"ts-node-dev": "^1.1.8",
"typescript": "^4.5.4"
},
"lint-staged": {
"*.ts": [
"prettier --write"
]
"@typescript-eslint/eslint-plugin": "^2.26.0",
"@typescript-eslint/parser": "^2.26.0",
"eslint": "^6.8.0",
"jasmine": "^3.5.0"
}
}

148
back/position-test.js Normal file
View file

@ -0,0 +1,148 @@
// Constants
let MIN_DISTANCE = 12;
let MAX_PER_GROUP = 3;
let NB_USERS = 10;
// Utils
let rand = function(min, max) {
min = Math.ceil(min);
max = Math.floor(max);
return Math.floor(Math.random() * (max - min + 1)) + min;
};
let compareDistances = function(distA, distB) {
if (distA.distance < distB.distance) {
return -1;
}
if (distA.distance > distB.distance) {
return 1;
}
return 0;
};
let computeDistance = function (user1, user2) {
return Math.sqrt(Math.pow(user2.X - user1.X, 2) + Math.pow(user2.Y - user1.Y, 2));
};
// Test Data
let users = [];
for(let i = 1; i <= NB_USERS; i++) {
let user = {};
user.id = rand(0,99999);
user.X = rand(0, 40);
user.Y = rand(0, 40);
users.push(user);
}
// Compute distance between each user
let getDistanceOfEachUser = function(users) {
let i = 0;
let distances = [];
users.forEach(function(user1, key1) {
users.forEach(function(user2, key2) {
if(key1 < key2) {
let distanceObj = {};
distanceObj.distance = computeDistance(user1, user2);
distanceObj.first = user1;
distanceObj.second = user2;
distances[i] = distanceObj;
i++;
}
});
});
return distances;
};
// Organise groups
let createGroups = function(distances) {
let i = 0;
let groups = [];
let alreadyInAGroup = [];
for(let j = 0; j < distances.length; j++) {
let dist = distances[j];
if(dist.distance <= MIN_DISTANCE) {
if(typeof groups[i] === 'undefined') {
groups[i] = [];
}
if(groups[i].indexOf(dist.first) === -1 && typeof alreadyInAGroup[dist.first.id] === 'undefined') {
if(groups[i].length > 1) {
// if group is not empty we check current user can be added in the group according to its distance to the others already in it
for(let l = 0; l < groups[i].length; l++) {
let userTotest = groups[i][l];
if(computeDistance(dist.first, userTotest) <= MIN_DISTANCE) {
groups[i].push(dist.first);
alreadyInAGroup[dist.first.id] = true;
break;
}
}
} else {
groups[i].push(dist.first);
alreadyInAGroup[dist.first.id] = true;
}
}
if(groups[i].length === MAX_PER_GROUP) {
i++; // on créé un nouveau groupe
if(i > (NB_USERS / MAX_PER_GROUP)) {
console.log('There is no room left for user ID : ' + dist.second.id + ' !');
break;
}
continue;
}
if(groups[i].indexOf(dist.second) === -1 && typeof alreadyInAGroup[dist.second.id] === 'undefined') {
if(groups[i].length > 1) {
// if group is not empty we check current user can be added in the group according to its distance to the others already in it
for(let l = 0; l < groups[i].length; l++) {
let userTotest = groups[i][l];
if(computeDistance(dist.second, userTotest) <= MIN_DISTANCE) {
groups[i].push(dist.second);
alreadyInAGroup[dist.second.id] = true;
break;
}
}
} else {
groups[i].push(dist.second);
alreadyInAGroup[dist.second.id] = true;
}
}
}
}
return groups;
};
let distances = getDistanceOfEachUser(users);
// ordonner par distance pour prioriser l'association en groupe des utilisateurs les plus proches
distances.sort(compareDistances);
let groups = createGroups(distances);
// Compute distance between each user of a already existing group
let checkGroupDistance = function(groups) {
for(let i = 0; i < groups.length; i++) {
let group = groups[i];
group.forEach(function(user1, key1) {
group.forEach(function(user2, key2) {
if(key1 < key2) {
let distance = computeDistance(user1, user2);
if(distance > MIN_DISTANCE) {
// TODO : message a user1 et user2
}
}
});
});
}
};
console.log(users);
console.log(distances);
console.log(groups);

View file

@ -1,15 +1,3 @@
// lib/server.ts
import App from "./src/App";
import grpc from "grpc";
import { roomManager } from "./src/RoomManager";
import { IRoomManagerServer, RoomManagerService } from "./src/Messages/generated/messages_grpc_pb";
import { HTTP_PORT, GRPC_PORT } from "./src/Enum/EnvironmentVariable";
App.listen(HTTP_PORT, () => console.log(`WorkAdventure HTTP API starting on port %d!`, HTTP_PORT));
const server = new grpc.Server();
server.addService<IRoomManagerServer>(RoomManagerService, roomManager);
server.bind(`0.0.0.0:${GRPC_PORT}`, grpc.ServerCredentials.createInsecure());
server.start();
console.log("WorkAdventure HTTP/2 API starting on port %d!", GRPC_PORT);
App.listen(8080, () => console.log(`Example app listening on port 8080!`))

View file

@ -1,19 +1,55 @@
// lib/app.ts
import { PrometheusController } from "./Controller/PrometheusController";
import { DebugController } from "./Controller/DebugController";
import { App as uwsApp } from "./Server/sifrr.server";
import {IoSocketController} from "./Controller/IoSocketController"; //TODO fix import by "_Controller/..."
import {AuthenticateController} from "./Controller/AuthenticateController"; //TODO fix import by "_Controller/..."
import express from "express";
import {Application, Request, Response} from 'express';
import bodyParser = require('body-parser');
import * as http from "http";
import {MapController} from "./Controller/MapController";
import {PrometheusController} from "./Controller/PrometheusController";
class App {
public app: uwsApp;
public app: Application;
public server: http.Server;
public ioSocketController: IoSocketController;
public authenticateController: AuthenticateController;
public mapController: MapController;
public prometheusController: PrometheusController;
private debugController: DebugController;
constructor() {
this.app = new uwsApp();
this.app = express();
this.prometheusController = new PrometheusController(this.app);
this.debugController = new DebugController(this.app);
//config server http
this.server = http.createServer(this.app);
this.config();
this.crossOrigin();
//TODO add middleware with access token to secure api
//create socket controllers
this.ioSocketController = new IoSocketController(this.server);
this.authenticateController = new AuthenticateController(this.app);
this.mapController = new MapController(this.app);
this.prometheusController = new PrometheusController(this.app, this.ioSocketController);
}
// TODO add session user
private config(): void {
this.app.use(bodyParser.json());
this.app.use(bodyParser.urlencoded({extended: false}));
}
private crossOrigin(){
this.app.use((req: Request, res: Response, next) => {
res.setHeader("Access-Control-Allow-Origin", "*"); // update to match the domain you will make the request from
// Request methods you wish to allow
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, PATCH, DELETE');
// Request headers you wish to allow
res.setHeader("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
next();
});
}
}
export default new App().app;
export default new App().server;

View file

@ -0,0 +1,40 @@
import {Application, Request, Response} from "express";
import Jwt from "jsonwebtoken";
import {BAD_REQUEST, OK} from "http-status-codes";
import {SECRET_KEY, URL_ROOM_STARTED} from "../Enum/EnvironmentVariable"; //TODO fix import by "_Enum/..."
import { uuid } from 'uuidv4';
export interface TokenInterface {
name: string,
userId: string
}
export class AuthenticateController {
App : Application;
constructor(App : Application) {
this.App = App;
this.login();
}
//permit to login on application. Return token to connect on Websocket IO.
login(){
// For now, let's completely forget the /login route.
this.App.post("/login", (req: Request, res: Response) => {
const param = req.body;
/*if(!param.name){
return res.status(BAD_REQUEST).send({
message: "email parameter is empty"
});
}*/
//TODO check user email for The Coding Machine game
const userId = uuid();
const token = Jwt.sign({name: param.name, userId: userId} as TokenInterface, SECRET_KEY, {expiresIn: '24h'});
return res.status(OK).send({
token: token,
mapUrlStart: URL_ROOM_STARTED,
userId: userId,
});
});
}
}

View file

@ -1,9 +0,0 @@
import { HttpResponse } from "uWebSockets.js";
export class BaseController {
protected addCorsHeaders(res: HttpResponse): void {
res.writeHeader("access-control-allow-headers", "Origin, X-Requested-With, Content-Type, Accept");
res.writeHeader("access-control-allow-methods", "GET, POST, OPTIONS, PUT, PATCH, DELETE");
res.writeHeader("access-control-allow-origin", "*");
}
}

View file

@ -1,68 +0,0 @@
import { ADMIN_API_TOKEN } from "../Enum/EnvironmentVariable";
import { stringify } from "circular-json";
import { HttpRequest, HttpResponse } from "uWebSockets.js";
import { parse } from "query-string";
import { App } from "../Server/sifrr.server";
import { socketManager } from "../Services/SocketManager";
export class DebugController {
constructor(private App: App) {
this.getDump();
}
getDump() {
this.App.get("/dump", (res: HttpResponse, req: HttpRequest) => {
(async () => {
const query = parse(req.getQuery());
if (ADMIN_API_TOKEN === "") {
return res.writeStatus("401 Unauthorized").end("No token configured!");
}
if (query.token !== ADMIN_API_TOKEN) {
return res.writeStatus("401 Unauthorized").end("Invalid token sent!");
}
return res
.writeStatus("200 OK")
.writeHeader("Content-Type", "application/json")
.end(
stringify(
await Promise.all(socketManager.getWorlds().values()),
(key: unknown, value: unknown) => {
if (key === "listeners") {
return "Listeners";
}
if (key === "socket") {
return "Socket";
}
if (key === "batchedMessages") {
return "BatchedMessages";
}
if (value instanceof Map) {
const obj: { [key: string | number]: unknown } = {};
for (const [mapKey, mapValue] of value.entries()) {
if (typeof mapKey === "number" || typeof mapKey === "string") {
obj[mapKey] = mapValue;
}
}
return obj;
} else if (value instanceof Set) {
const obj: Array<unknown> = [];
for (const [setKey, setValue] of value.entries()) {
obj.push(setValue);
}
return obj;
} else {
return value;
}
}
)
);
})().catch((e) => {
console.error(e);
res.writeStatus("500");
res.end("An error occurred");
});
});
}
}

View file

@ -0,0 +1,630 @@
import socketIO = require('socket.io');
import {Socket} from "socket.io";
import * as http from "http";
import {MessageUserPosition, Point} from "../Model/Websocket/MessageUserPosition"; //TODO fix import by "_Model/.."
import {ExSocketInterface} from "../Model/Websocket/ExSocketInterface"; //TODO fix import by "_Model/.."
import Jwt, {JsonWebTokenError} from "jsonwebtoken";
import {SECRET_KEY, MINIMUM_DISTANCE, GROUP_RADIUS, ALLOW_ARTILLERY} from "../Enum/EnvironmentVariable"; //TODO fix import by "_Enum/..."
import {World} from "../Model/World";
import {Group} from "../Model/Group";
import {User} from "../Model/User";
import {isSetPlayerDetailsMessage,} from "../Model/Websocket/SetPlayerDetailsMessage";
import {MessageUserJoined} from "../Model/Websocket/MessageUserJoined";
import {MessageUserMoved} from "../Model/Websocket/MessageUserMoved";
import si from "systeminformation";
import {Gauge} from "prom-client";
import {TokenInterface} from "../Controller/AuthenticateController";
import {isJoinRoomMessageInterface} from "../Model/Websocket/JoinRoomMessage";
import {isPointInterface, PointInterface} from "../Model/Websocket/PointInterface";
import {isWebRtcSignalMessageInterface} from "../Model/Websocket/WebRtcSignalMessage";
import {UserInGroupInterface} from "../Model/Websocket/UserInGroupInterface";
import {isItemEventMessageInterface} from "../Model/Websocket/ItemEventMessage";
import {uuid} from 'uuidv4';
import {isUserMovesInterface} from "../Model/Websocket/UserMovesMessage";
import {isViewport} from "../Model/Websocket/ViewportMessage";
import {GroupUpdateInterface} from "_Model/Websocket/GroupUpdateInterface";
import {Movable} from "../Model/Movable";
enum SockerIoEvent {
CONNECTION = "connection",
DISCONNECT = "disconnect",
JOIN_ROOM = "join-room", // bi-directional
USER_POSITION = "user-position", // From client to server
USER_MOVED = "user-moved", // From server to client
USER_LEFT = "user-left", // From server to client
WEBRTC_SIGNAL = "webrtc-signal",
WEBRTC_SCREEN_SHARING_SIGNAL = "webrtc-screen-sharing-signal",
WEBRTC_START = "webrtc-start",
WEBRTC_DISCONNECT = "webrtc-disconect",
MESSAGE_ERROR = "message-error",
GROUP_CREATE_UPDATE = "group-create-update",
GROUP_DELETE = "group-delete",
SET_PLAYER_DETAILS = "set-player-details",
ITEM_EVENT = 'item-event',
SET_SILENT = "set_silent", // Set or unset the silent mode for this user.
SET_VIEWPORT = "set-viewport",
BATCH = "batch",
}
function emitInBatch(socket: ExSocketInterface, event: string | symbol, payload: unknown): void {
socket.batchedMessages.push({ event, payload});
if (socket.batchTimeout === null) {
socket.batchTimeout = setTimeout(() => {
socket.emit(SockerIoEvent.BATCH, socket.batchedMessages);
socket.batchedMessages = [];
socket.batchTimeout = null;
}, 100);
}
}
export class IoSocketController {
public readonly Io: socketIO.Server;
private Worlds: Map<string, World> = new Map<string, World>();
private sockets: Map<string, ExSocketInterface> = new Map<string, ExSocketInterface>();
private nbClientsGauge: Gauge<string>;
private nbClientsPerRoomGauge: Gauge<string>;
constructor(server: http.Server) {
this.Io = socketIO(server);
this.nbClientsGauge = new Gauge({
name: 'workadventure_nb_sockets',
help: 'Number of connected sockets',
labelNames: [ ]
});
this.nbClientsPerRoomGauge = new Gauge({
name: 'workadventure_nb_clients_per_room',
help: 'Number of clients per room',
labelNames: [ 'room' ]
});
// Authentication with token. it will be decoded and stored in the socket.
// Completely commented for now, as we do not use the "/login" route at all.
this.Io.use((socket: Socket, next) => {
console.log(socket.handshake.query.token);
if (!socket.handshake.query || !socket.handshake.query.token) {
console.error('An authentication error happened, a user tried to connect without a token.');
return next(new Error('Authentication error'));
}
if(socket.handshake.query.token === 'test'){
if (ALLOW_ARTILLERY) {
(socket as ExSocketInterface).token = socket.handshake.query.token;
(socket as ExSocketInterface).userId = uuid();
(socket as ExSocketInterface).isArtillery = true;
console.log((socket as ExSocketInterface).userId);
next();
return;
} else {
console.warn("In order to perform a load-testing test on this environment, you must set the ALLOW_ARTILLERY environment variable to 'true'");
next();
}
}
(socket as ExSocketInterface).isArtillery = false;
if(this.searchClientByToken(socket.handshake.query.token)){
console.error('An authentication error happened, a user tried to connect while its token is already connected.');
return next(new Error('Authentication error'));
}
Jwt.verify(socket.handshake.query.token, SECRET_KEY, (err: JsonWebTokenError, tokenDecoded: object) => {
if (err) {
console.error('An authentication error happened, invalid JsonWebToken.', err);
return next(new Error('Authentication error'));
}
if (!this.isValidToken(tokenDecoded)) {
return next(new Error('Authentication error, invalid token structure'));
}
(socket as ExSocketInterface).token = socket.handshake.query.token;
(socket as ExSocketInterface).userId = tokenDecoded.userId;
next();
});
});
this.ioConnection();
}
private isValidToken(token: object): token is TokenInterface {
if (typeof((token as TokenInterface).userId) !== 'string') {
return false;
}
if (typeof((token as TokenInterface).name) !== 'string') {
return false;
}
return true;
}
/**
*
* @param token
*/
searchClientByToken(token: string): ExSocketInterface | null {
const clients: ExSocketInterface[] = Object.values(this.Io.sockets.sockets) as ExSocketInterface[];
for (let i = 0; i < clients.length; i++) {
const client = clients[i];
if (client.token !== token) {
continue
}
return client;
}
return null;
}
ioConnection() {
this.Io.on(SockerIoEvent.CONNECTION, (socket: Socket) => {
const client : ExSocketInterface = socket as ExSocketInterface;
client.batchedMessages = [];
client.batchTimeout = null;
client.emitInBatch = (event: string | symbol, payload: unknown): void => {
emitInBatch(client, event, payload);
}
this.sockets.set(client.userId, client);
// Let's log server load when a user joins
const srvSockets = this.Io.sockets.sockets;
this.nbClientsGauge.inc();
console.log(new Date().toISOString() + ' A user joined (', Object.keys(srvSockets).length, ' connected users)');
si.currentLoad().then(data => console.log(' Current load: ', data.avgload));
si.currentLoad().then(data => console.log(' CPU: ', data.currentload, '%'));
// End log server load
/*join-rom event permit to join one room.
message :
userId : user identification
roomId: room identification
position: position of user in map
x: user x position on map
y: user y position on map
*/
socket.on(SockerIoEvent.JOIN_ROOM, (message: unknown, answerFn): void => {
console.log(SockerIoEvent.JOIN_ROOM, message);
try {
if (!isJoinRoomMessageInterface(message)) {
socket.emit(SockerIoEvent.MESSAGE_ERROR, {message: 'Invalid JOIN_ROOM message.'});
console.warn('Invalid JOIN_ROOM message received: ', message);
return;
}
const roomId = message.roomId;
const Client = (socket as ExSocketInterface);
if (Client.roomId === roomId) {
return;
}
//leave previous room
this.leaveRoom(Client);
//join new previous room
const world = this.joinRoom(Client, roomId, message.position);
const things = world.setViewport(Client, message.viewport);
const listOfUsers: Array<MessageUserPosition> = [];
const listOfGroups: Array<GroupUpdateInterface> = [];
for (const thing of things) {
if (thing instanceof User) {
const player: ExSocketInterface|undefined = this.sockets.get(thing.id);
if (player === undefined) {
console.warn('Something went wrong. The World contains a user "'+thing.id+"' but this user does not exist in the sockets list!");
continue;
}
listOfUsers.push(new MessageUserPosition(thing.id, player.name, player.characterLayers, player.position));
} else if (thing instanceof Group) {
listOfGroups.push({
groupId: thing.getId(),
position: thing.getPosition(),
});
} else {
console.error("Unexpected type for Movable returned by setViewport");
}
}
const listOfItems: {[itemId: string]: unknown} = {};
for (const [itemId, item] of world.getItemsState().entries()) {
listOfItems[itemId] = item;
}
//console.warn('ANSWER PLAYER POSITIONS', listOfUsers);
if (answerFn === undefined && ALLOW_ARTILLERY === true) {
// For some reason, answerFn can be undefined if we use Artillery (?)
return;
}
answerFn({
users: listOfUsers,
groups: listOfGroups,
items: listOfItems
});
} catch (e) {
console.error('An error occurred on "join_room" event');
console.error(e);
}
});
socket.on(SockerIoEvent.SET_VIEWPORT, (message: unknown): void => {
try {
//console.log('SET_VIEWPORT')
if (!isViewport(message)) {
socket.emit(SockerIoEvent.MESSAGE_ERROR, {message: 'Invalid SET_VIEWPORT message.'});
console.warn('Invalid SET_VIEWPORT message received: ', message);
return;
}
const Client = (socket as ExSocketInterface);
Client.viewport = message;
const world = this.Worlds.get(Client.roomId);
if (!world) {
console.error("In SET_VIEWPORT, could not find world with id '", Client.roomId, "'");
return;
}
world.setViewport(Client, Client.viewport);
} catch (e) {
console.error('An error occurred on "SET_VIEWPORT" event');
console.error(e);
}
});
socket.on(SockerIoEvent.USER_POSITION, (userMovesMessage: unknown): void => {
//console.log(SockerIoEvent.USER_POSITION, userMovesMessage);
try {
if (!isUserMovesInterface(userMovesMessage)) {
socket.emit(SockerIoEvent.MESSAGE_ERROR, {message: 'Invalid USER_POSITION message.'});
console.warn('Invalid USER_POSITION message received: ', userMovesMessage);
return;
}
const Client = (socket as ExSocketInterface);
// sending to all clients in room except sender
Client.position = userMovesMessage.position;
Client.viewport = userMovesMessage.viewport;
// update position in the world
const world = this.Worlds.get(Client.roomId);
if (!world) {
console.error("In USER_POSITION, could not find world with id '", Client.roomId, "'");
return;
}
world.updatePosition(Client, Client.position);
world.setViewport(Client, Client.viewport);
} catch (e) {
console.error('An error occurred on "user_position" event');
console.error(e);
}
});
socket.on(SockerIoEvent.WEBRTC_SIGNAL, (data: unknown) => {
this.emitVideo((socket as ExSocketInterface), data);
});
socket.on(SockerIoEvent.WEBRTC_SCREEN_SHARING_SIGNAL, (data: unknown) => {
this.emitScreenSharing((socket as ExSocketInterface), data);
});
socket.on(SockerIoEvent.DISCONNECT, () => {
const Client = (socket as ExSocketInterface);
try {
//leave room
this.leaveRoom(Client);
//leave webrtc room
//socket.leave(Client.webRtcRoomId);
//delete all socket information
delete Client.webRtcRoomId;
delete Client.roomId;
delete Client.token;
delete Client.position;
} catch (e) {
console.error('An error occurred on "disconnect"');
console.error(e);
}
this.sockets.delete(Client.userId);
// Let's log server load when a user leaves
const srvSockets = this.Io.sockets.sockets;
this.nbClientsGauge.dec();
console.log('A user left (', Object.keys(srvSockets).length, ' connected users)');
si.currentLoad().then(data => console.log('Current load: ', data.avgload));
si.currentLoad().then(data => console.log('CPU: ', data.currentload, '%'));
// End log server load
});
// Let's send the user id to the user
socket.on(SockerIoEvent.SET_PLAYER_DETAILS, (playerDetails: unknown, answerFn) => {
console.log(SockerIoEvent.SET_PLAYER_DETAILS, playerDetails);
if (!isSetPlayerDetailsMessage(playerDetails)) {
socket.emit(SockerIoEvent.MESSAGE_ERROR, {message: 'Invalid SET_PLAYER_DETAILS message.'});
console.warn('Invalid SET_PLAYER_DETAILS message received: ', playerDetails);
return;
}
const Client = (socket as ExSocketInterface);
Client.name = playerDetails.name;
Client.characterLayers = playerDetails.characterLayers;
// Artillery fails when receiving an acknowledgement that is not a JSON object
if (!Client.isArtillery) {
answerFn(Client.userId);
}
});
socket.on(SockerIoEvent.SET_SILENT, (silent: unknown) => {
console.log(SockerIoEvent.SET_SILENT, silent);
if (typeof silent !== "boolean") {
socket.emit(SockerIoEvent.MESSAGE_ERROR, {message: 'Invalid SET_SILENT message.'});
console.warn('Invalid SET_SILENT message received: ', silent);
return;
}
try {
const Client = (socket as ExSocketInterface);
// update position in the world
const world = this.Worlds.get(Client.roomId);
if (!world) {
console.error("In SET_SILENT, could not find world with id '", Client.roomId, "'");
return;
}
world.setSilent(Client, silent);
} catch (e) {
console.error('An error occurred on "SET_SILENT"');
console.error(e);
}
});
socket.on(SockerIoEvent.ITEM_EVENT, (itemEvent: unknown) => {
if (!isItemEventMessageInterface(itemEvent)) {
socket.emit(SockerIoEvent.MESSAGE_ERROR, {message: 'Invalid ITEM_EVENT message.'});
console.warn('Invalid ITEM_EVENT message received: ', itemEvent);
return;
}
try {
const Client = (socket as ExSocketInterface);
socket.to(Client.roomId).emit(SockerIoEvent.ITEM_EVENT, itemEvent);
const world = this.Worlds.get(Client.roomId);
if (!world) {
console.error("Could not find world with id '", Client.roomId, "'");
return;
}
world.setItemState(itemEvent.itemId, itemEvent.state);
} catch (e) {
console.error('An error occurred on "item_event"');
console.error(e);
}
});
});
}
emitVideo(socket: ExSocketInterface, data: unknown){
if (!isWebRtcSignalMessageInterface(data)) {
socket.emit(SockerIoEvent.MESSAGE_ERROR, {message: 'Invalid WEBRTC_SIGNAL message.'});
console.warn('Invalid WEBRTC_SIGNAL message received: ', data);
return;
}
//send only at user
const client = this.sockets.get(data.receiverId);
if (client === undefined) {
console.warn("While exchanging a WebRTC signal: client with id ", data.receiverId, " does not exist. This might be a race condition.");
return;
}
return client.emit(SockerIoEvent.WEBRTC_SIGNAL, {
userId: socket.userId,
signal: data.signal
});
}
emitScreenSharing(socket: ExSocketInterface, data: unknown){
if (!isWebRtcSignalMessageInterface(data)) {
socket.emit(SockerIoEvent.MESSAGE_ERROR, {message: 'Invalid WEBRTC_SCREEN_SHARING message.'});
console.warn('Invalid WEBRTC_SCREEN_SHARING message received: ', data);
return;
}
//send only at user
const client = this.sockets.get(data.receiverId);
if (client === undefined) {
console.warn("While exchanging a WEBRTC_SCREEN_SHARING signal: client with id ", data.receiverId, " does not exist. This might be a race condition.");
return;
}
return client.emit(SockerIoEvent.WEBRTC_SCREEN_SHARING_SIGNAL, {
userId: socket.userId,
signal: data.signal
});
}
searchClientByIdOrFail(userId: string): ExSocketInterface {
const client: ExSocketInterface|undefined = this.sockets.get(userId);
if (client === undefined) {
throw new Error("Could not find user with id " + userId);
}
return client;
}
leaveRoom(Client : ExSocketInterface){
// leave previous room and world
if(Client.roomId){
try {
//user leave previous world
const world: World | undefined = this.Worlds.get(Client.roomId);
if (world) {
world.leave(Client);
if (world.isEmpty()) {
this.Worlds.delete(Client.roomId);
}
}
//user leave previous room
Client.leave(Client.roomId);
} finally {
this.nbClientsPerRoomGauge.dec({ room: Client.roomId });
delete Client.roomId;
}
}
}
private joinRoom(Client : ExSocketInterface, roomId: string, position: PointInterface): World {
//join user in room
Client.join(roomId);
this.nbClientsPerRoomGauge.inc({ room: roomId });
Client.roomId = roomId;
Client.position = position;
//check and create new world for a room
let world = this.Worlds.get(roomId)
if(world === undefined){
world = new World((user1: string, group: Group) => {
this.connectedUser(user1, group);
}, (user1: string, group: Group) => {
this.disConnectedUser(user1, group);
}, MINIMUM_DISTANCE, GROUP_RADIUS, (thing: Movable, listener: User) => {
const clientListener = this.searchClientByIdOrFail(listener.id);
if (thing instanceof User) {
const clientUser = this.searchClientByIdOrFail(thing.id);
const messageUserJoined = new MessageUserJoined(clientUser.userId, clientUser.name, clientUser.characterLayers, clientUser.position);
clientListener.emit(SockerIoEvent.JOIN_ROOM, messageUserJoined);
} else if (thing instanceof Group) {
clientListener.emit(SockerIoEvent.GROUP_CREATE_UPDATE, {
position: thing.getPosition(),
groupId: thing.getId()
} as GroupUpdateInterface);
} else {
console.error('Unexpected type for Movable.');
}
}, (thing: Movable, position, listener) => {
const clientListener = this.searchClientByIdOrFail(listener.id);
if (thing instanceof User) {
const clientUser = this.searchClientByIdOrFail(thing.id);
clientListener.emitInBatch(SockerIoEvent.USER_MOVED, new MessageUserMoved(clientUser.userId, clientUser.position));
//console.log("Sending USER_MOVED event");
} else if (thing instanceof Group) {
clientListener.emit(SockerIoEvent.GROUP_CREATE_UPDATE, {
position: thing.getPosition(),
groupId: thing.getId()
} as GroupUpdateInterface);
} else {
console.error('Unexpected type for Movable.');
}
}, (thing: Movable, listener) => {
const clientListener = this.searchClientByIdOrFail(listener.id);
if (thing instanceof User) {
const clientUser = this.searchClientByIdOrFail(thing.id);
clientListener.emit(SockerIoEvent.USER_LEFT, clientUser.userId);
//console.log("Sending USER_LEFT event");
} else if (thing instanceof Group) {
clientListener.emit(SockerIoEvent.GROUP_DELETE, thing.getId());
} else {
console.error('Unexpected type for Movable.');
}
});
this.Worlds.set(roomId, world);
}
// Dispatch groups position to newly connected user
world.getGroups().forEach((group: Group) => {
Client.emit(SockerIoEvent.GROUP_CREATE_UPDATE, {
position: group.getPosition(),
groupId: group.getId()
} as GroupUpdateInterface);
});
//join world
world.join(Client, Client.position);
return world;
}
/**
*
* @param socket
* @param roomId
*/
joinWebRtcRoom(socket: ExSocketInterface, roomId: string) {
if (socket.webRtcRoomId === roomId) {
return;
}
socket.join(roomId);
socket.webRtcRoomId = roomId;
//if two persons in room share
if (this.Io.sockets.adapter.rooms[roomId].length < 2 /*|| this.Io.sockets.adapter.rooms[roomId].length >= 4*/) {
return;
}
// TODO: scanning all sockets is maybe not the most efficient
const clients: Array<ExSocketInterface> = (Object.values(this.Io.sockets.sockets) as Array<ExSocketInterface>)
.filter((client: ExSocketInterface) => client.webRtcRoomId && client.webRtcRoomId === roomId);
//send start at one client to initialise offer webrtc
//send all users in room to create PeerConnection in front
clients.forEach((client: ExSocketInterface, index: number) => {
const peerClients = clients.reduce((tabs: Array<UserInGroupInterface>, clientId: ExSocketInterface, indexClientId: number) => {
if (!clientId.userId || clientId.userId === client.userId) {
return tabs;
}
tabs.push({
userId: clientId.userId,
name: clientId.name,
initiator: index <= indexClientId
});
return tabs;
}, []);
client.emit(SockerIoEvent.WEBRTC_START, {clients: peerClients, roomId: roomId});
});
}
/** permit to share user position
** users position will send in event 'user-position'
** The data sent is an array with information for each user :
[
{
userId: <string>,
roomId: <string>,
position: {
x : <number>,
y : <number>,
direction: <string>
}
},
...
]
**/
//connected user
connectedUser(userId: string, group: Group) {
/*let Client = this.sockets.get(userId);
if (Client === undefined) {
return;
}*/
const Client = this.searchClientByIdOrFail(userId);
this.joinWebRtcRoom(Client, group.getId());
}
//disconnect user
disConnectedUser(userId: string, group: Group) {
const Client = this.searchClientByIdOrFail(userId);
Client.to(group.getId()).emit(SockerIoEvent.WEBRTC_DISCONNECT, {
userId: userId
});
// Most of the time, sending a disconnect event to one of the players is enough (the player will close the connection
// which will be shut for the other player).
// However! In the rare case where the WebRTC connection is not yet established, if we close the connection on one of the player,
// the other player will try connecting until a timeout happens (during this time, the connection icon will be displayed for nothing).
// So we also send the disconnect event to the other player.
for (const user of group.getUsers()) {
Client.emit(SockerIoEvent.WEBRTC_DISCONNECT, {
userId: user.id
});
}
//disconnect webrtc room
if(!Client.webRtcRoomId){
return;
}
Client.leave(Client.webRtcRoomId);
delete Client.webRtcRoomId;
}
}

View file

@ -0,0 +1,29 @@
import express from "express";
import {Application, Request, Response} from "express";
import {OK} from "http-status-codes";
import {URL_ROOM_STARTED} from "../Enum/EnvironmentVariable";
export class MapController {
App: Application;
constructor(App: Application) {
this.App = App;
this.getStartMap();
this.assetMaps();
}
assetMaps() {
this.App.use('/map/files', express.static('src/Assets/Maps'));
}
// Returns a map mapping map name to file name of the map
getStartMap() {
this.App.get("/start-map", (req: Request, res: Response) => {
const url = req.headers.host?.replace('api.', 'maps.') + URL_ROOM_STARTED;
res.status(OK).send({
mapUrlStart: url,
startInstance: "global"
});
});
}
}

View file

@ -1,23 +1,20 @@
import { App } from "../Server/sifrr.server";
import { HttpRequest, HttpResponse } from "uWebSockets.js";
import { register, collectDefaultMetrics } from "prom-client";
import {Application, Request, Response} from "express";
import {IoSocketController} from "_Controller/IoSocketController";
const register = require('prom-client').register;
const collectDefaultMetrics = require('prom-client').collectDefaultMetrics;
export class PrometheusController {
constructor(private App: App) {
constructor(private App: Application, private ioSocketController: IoSocketController) {
collectDefaultMetrics({
timeout: 10000,
gcDurationBuckets: [0.001, 0.01, 0.1, 1, 2, 5], // These are the default buckets.
});
this.App.get("/metrics", this.metrics.bind(this));
this.App.get("/metrics.json", this.metricsAsJSON.bind(this));
}
private metrics(res: HttpResponse, req: HttpRequest): void {
res.writeHeader("Content-Type", register.contentType);
private metrics(req: Request, res: Response): void {
res.set('Content-Type', register.contentType);
res.end(register.metrics());
}
private metricsAsJSON(res: HttpResponse, req: HttpRequest): void {
res.writeHeader('Content-Type', 'application/json');
res.end(JSON.stringify(register.getMetricsAsJSON()));
}
}

View file

@ -1,31 +1,13 @@
const SECRET_KEY = process.env.SECRET_KEY || "THECODINGMACHINE_SECRET_KEY";
const URL_ROOM_STARTED = "/Floor0/floor0.json";
const MINIMUM_DISTANCE = process.env.MINIMUM_DISTANCE ? Number(process.env.MINIMUM_DISTANCE) : 64;
const GROUP_RADIUS = process.env.GROUP_RADIUS ? Number(process.env.GROUP_RADIUS) : 48;
const ALLOW_ARTILLERY = process.env.ALLOW_ARTILLERY ? process.env.ALLOW_ARTILLERY == "true" : false;
const ADMIN_API_URL = process.env.ADMIN_API_URL || "";
const ADMIN_API_TOKEN = process.env.ADMIN_API_TOKEN || "";
const CPU_OVERHEAT_THRESHOLD = Number(process.env.CPU_OVERHEAT_THRESHOLD) || 80;
const JITSI_URL: string | undefined = process.env.JITSI_URL === "" ? undefined : process.env.JITSI_URL;
const JITSI_ISS = process.env.JITSI_ISS || "";
const SECRET_JITSI_KEY = process.env.SECRET_JITSI_KEY || "";
const HTTP_PORT = parseInt(process.env.HTTP_PORT || "8080") || 8080;
const GRPC_PORT = parseInt(process.env.GRPC_PORT || "50051") || 50051;
export const TURN_STATIC_AUTH_SECRET = process.env.TURN_STATIC_AUTH_SECRET || "";
export const MAX_PER_GROUP = parseInt(process.env.MAX_PER_GROUP || "4");
export const REDIS_HOST = process.env.REDIS_HOST || undefined;
export const REDIS_PORT = parseInt(process.env.REDIS_PORT || "6379") || 6379;
export const REDIS_PASSWORD = process.env.REDIS_PASSWORD || undefined;
export const STORE_VARIABLES_FOR_LOCAL_MAPS = process.env.STORE_VARIABLES_FOR_LOCAL_MAPS === "true";
const ALLOW_ARTILLERY = process.env.ALLOW_ARTILLERY ? process.env.ALLOW_ARTILLERY == 'true' : false;
export {
SECRET_KEY,
URL_ROOM_STARTED,
MINIMUM_DISTANCE,
ADMIN_API_URL,
ADMIN_API_TOKEN,
HTTP_PORT,
GRPC_PORT,
GROUP_RADIUS,
ALLOW_ARTILLERY,
CPU_OVERHEAT_THRESHOLD,
JITSI_URL,
JITSI_ISS,
SECRET_JITSI_KEY,
};
ALLOW_ARTILLERY
}

View file

@ -1 +0,0 @@
/generated/

View file

@ -1,34 +0,0 @@
import {
ServerToAdminClientMessage,
UserJoinedRoomMessage,
UserLeftRoomMessage,
} from "../Messages/generated/messages_pb";
import { AdminSocket } from "../RoomManager";
export class Admin {
public constructor(private readonly socket: AdminSocket) {}
public sendUserJoin(uuid: string, name: string, ip: string): void {
const serverToAdminClientMessage = new ServerToAdminClientMessage();
const userJoinedRoomMessage = new UserJoinedRoomMessage();
userJoinedRoomMessage.setUuid(uuid);
userJoinedRoomMessage.setName(name);
userJoinedRoomMessage.setIpaddress(ip);
serverToAdminClientMessage.setUserjoinedroom(userJoinedRoomMessage);
this.socket.write(serverToAdminClientMessage);
}
public sendUserLeft(uuid: string /*, name: string, ip: string*/): void {
const serverToAdminClientMessage = new ServerToAdminClientMessage();
const userLeftRoomMessage = new UserLeftRoomMessage();
userLeftRoomMessage.setUuid(uuid);
serverToAdminClientMessage.setUserleftroom(userLeftRoomMessage);
this.socket.write(serverToAdminClientMessage);
}
}

View file

@ -0,0 +1,7 @@
import {MessageUserPosition} from "../Model/Websocket/MessageUserPosition";
export interface Distance {
distance: number,
first: MessageUserPosition,
second: MessageUserPosition,
}

View file

@ -1,657 +0,0 @@
import { PointInterface } from "./Websocket/PointInterface";
import { Group } from "./Group";
import { User, UserSocket } from "./User";
import { PositionInterface } from "_Model/PositionInterface";
import {
EmoteCallback,
EntersCallback,
LeavesCallback,
MovesCallback,
PlayerDetailsUpdatedCallback,
} from "_Model/Zone";
import { PositionNotifier } from "./PositionNotifier";
import { Movable } from "_Model/Movable";
import {
BatchToPusherMessage,
BatchToPusherRoomMessage,
EmoteEventMessage,
ErrorMessage,
JoinRoomMessage,
SetPlayerDetailsMessage,
SubToPusherRoomMessage,
VariableMessage,
VariableWithTagMessage,
ServerToClientMessage,
} from "../Messages/generated/messages_pb";
import { ProtobufUtils } from "../Model/Websocket/ProtobufUtils";
import { RoomSocket, ZoneSocket } from "src/RoomManager";
import { Admin } from "../Model/Admin";
import { adminApi } from "../Services/AdminApi";
import { isMapDetailsData, MapDetailsData } from "../Services/AdminApi/MapDetailsData";
import { ITiledMap } from "@workadventure/tiled-map-type-guard/dist";
import { mapFetcher } from "../Services/MapFetcher";
import { VariablesManager } from "../Services/VariablesManager";
import { ADMIN_API_URL } from "../Enum/EnvironmentVariable";
import { LocalUrlError } from "../Services/LocalUrlError";
import { emitErrorOnRoomSocket } from "../Services/MessageHelpers";
import { VariableError } from "../Services/VariableError";
import { isRoomRedirect } from "../Services/AdminApi/RoomRedirect";
export type ConnectCallback = (user: User, group: Group) => void;
export type DisconnectCallback = (user: User, group: Group) => void;
export class GameRoom {
// Users, sorted by ID
private readonly users = new Map<number, User>();
private readonly usersByUuid = new Map<string, User>();
private readonly groups = new Set<Group>();
private readonly admins = new Set<Admin>();
private itemsState = new Map<number, unknown>();
private readonly positionNotifier: PositionNotifier;
private versionNumber: number = 1;
private nextUserId: number = 1;
private roomListeners: Set<RoomSocket> = new Set<RoomSocket>();
private constructor(
public readonly roomUrl: string,
private mapUrl: string,
private readonly connectCallback: ConnectCallback,
private readonly disconnectCallback: DisconnectCallback,
private readonly minDistance: number,
private readonly groupRadius: number,
onEnters: EntersCallback,
onMoves: MovesCallback,
onLeaves: LeavesCallback,
onEmote: EmoteCallback,
onPlayerDetailsUpdated: PlayerDetailsUpdatedCallback
) {
// A zone is 10 sprites wide.
this.positionNotifier = new PositionNotifier(
320,
320,
onEnters,
onMoves,
onLeaves,
onEmote,
onPlayerDetailsUpdated
);
}
public static async create(
roomUrl: string,
connectCallback: ConnectCallback,
disconnectCallback: DisconnectCallback,
minDistance: number,
groupRadius: number,
onEnters: EntersCallback,
onMoves: MovesCallback,
onLeaves: LeavesCallback,
onEmote: EmoteCallback,
onPlayerDetailsUpdated: PlayerDetailsUpdatedCallback
): Promise<GameRoom> {
const mapDetails = await GameRoom.getMapDetails(roomUrl);
const gameRoom = new GameRoom(
roomUrl,
mapDetails.mapUrl,
connectCallback,
disconnectCallback,
minDistance,
groupRadius,
onEnters,
onMoves,
onLeaves,
onEmote,
onPlayerDetailsUpdated
);
return gameRoom;
}
public getUsers(): Map<number, User> {
return this.users;
}
public getUserByUuid(uuid: string): User | undefined {
return this.usersByUuid.get(uuid);
}
public getUserById(id: number): User | undefined {
return this.users.get(id);
}
public getUsersByUuid(uuid: string): User[] {
const userList: User[] = [];
for (const user of this.users.values()) {
if (user.uuid === uuid) {
userList.push(user);
}
}
return userList;
}
public join(socket: UserSocket, joinRoomMessage: JoinRoomMessage): User {
const positionMessage = joinRoomMessage.getPositionmessage();
if (positionMessage === undefined) {
throw new Error("Missing position message");
}
const position = ProtobufUtils.toPointInterface(positionMessage);
const user = new User(
this.nextUserId,
joinRoomMessage.getUseruuid(),
joinRoomMessage.getIpaddress(),
position,
false,
this.positionNotifier,
socket,
joinRoomMessage.getTagList(),
joinRoomMessage.getVisitcardurl(),
joinRoomMessage.getName(),
ProtobufUtils.toCharacterLayerObjects(joinRoomMessage.getCharacterlayerList()),
joinRoomMessage.getCompanion()
);
this.nextUserId++;
this.users.set(user.id, user);
this.usersByUuid.set(user.uuid, user);
this.updateUserGroup(user);
// Notify admins
for (const admin of this.admins) {
admin.sendUserJoin(user.uuid, user.name, user.IPAddress);
}
return user;
}
public leave(user: User) {
const userObj = this.users.get(user.id);
if (userObj === undefined) {
console.warn("User ", user.id, "does not belong to this game room! It should!");
}
if (userObj !== undefined && typeof userObj.group !== "undefined") {
this.leaveGroup(userObj);
}
if (user.hasFollowers()) {
user.stopLeading();
}
if (user.following) {
user.following.delFollower(user);
}
this.users.delete(user.id);
this.usersByUuid.delete(user.uuid);
if (userObj !== undefined) {
this.positionNotifier.leave(userObj);
}
// Notify admins
for (const admin of this.admins) {
admin.sendUserLeft(user.uuid /*, user.name, user.IPAddress*/);
}
}
public isEmpty(): boolean {
return this.users.size === 0 && this.admins.size === 0;
}
public updatePosition(user: User, userPosition: PointInterface): void {
user.setPosition(userPosition);
this.updateUserGroup(user);
}
updatePlayerDetails(user: User, playerDetailsMessage: SetPlayerDetailsMessage) {
if (playerDetailsMessage.getRemoveoutlinecolor()) {
user.outlineColor = undefined;
} else {
user.outlineColor = playerDetailsMessage.getOutlinecolor();
}
}
private updateUserGroup(user: User): void {
if (user.silent) {
return;
}
const group = user.group;
const closestItem: User | Group | null = this.searchClosestAvailableUserOrGroup(user);
if (group === undefined) {
// If the user is not part of a group:
// should he join a group?
// If the user is moving, don't try to join
if (user.getPosition().moving) {
return;
}
if (closestItem !== null) {
if (closestItem instanceof Group) {
// Let's join the group!
closestItem.join(user);
closestItem.setOutOfBounds(false);
} else {
const closestUser: User = closestItem;
const group: Group = new Group(
this.roomUrl,
[user, closestUser],
this.groupRadius,
this.connectCallback,
this.disconnectCallback,
this.positionNotifier
);
this.groups.add(group);
}
}
} else {
let hasKickOutSomeone = false;
let followingMembers: User[] = [];
const previewNewGroupPosition = group.previewGroupPosition();
if (!previewNewGroupPosition) {
this.leaveGroup(user);
return;
}
if (user.hasFollowers() || user.following) {
followingMembers = user.hasFollowers()
? group.getUsers().filter((currentUser) => currentUser.following === user)
: group.getUsers().filter((currentUser) => currentUser.following === user.following);
// If all group members are part of the same follow group
if (group.getUsers().length - 1 === followingMembers.length) {
let isOutOfBounds = false;
// If a follower is far away from the leader, "outOfBounds" is set to true
for (const member of followingMembers) {
const distance = GameRoom.computeDistanceBetweenPositions(
member.getPosition(),
previewNewGroupPosition
);
if (distance > this.groupRadius) {
isOutOfBounds = true;
break;
}
}
group.setOutOfBounds(isOutOfBounds);
}
}
// Check if the moving user has kicked out another user
for (const headMember of group.getGroupHeads()) {
if (!headMember.group) {
this.leaveGroup(headMember);
continue;
}
const headPosition = headMember.getPosition();
const distance = GameRoom.computeDistanceBetweenPositions(headPosition, previewNewGroupPosition);
if (distance > this.groupRadius) {
hasKickOutSomeone = true;
break;
}
}
/**
* If the current moving user has kicked another user from the radius,
* the moving user leaves the group because he is too far away.
*/
const userDistance = GameRoom.computeDistanceBetweenPositions(user.getPosition(), previewNewGroupPosition);
if (hasKickOutSomeone && userDistance > this.groupRadius) {
if (user.hasFollowers() && group.getUsers().length === 3 && followingMembers.length === 1) {
const other = group
.getUsers()
.find((currentUser) => !currentUser.hasFollowers() && !currentUser.following);
if (other) {
this.leaveGroup(other);
}
} else if (user.hasFollowers()) {
this.leaveGroup(user);
for (const member of followingMembers) {
this.leaveGroup(member);
}
// Re-create a group with the followers
const newGroup: Group = new Group(
this.roomUrl,
[user, ...followingMembers],
this.groupRadius,
this.connectCallback,
this.disconnectCallback,
this.positionNotifier
);
this.groups.add(newGroup);
} else {
this.leaveGroup(user);
}
}
}
user.group?.updatePosition();
user.group?.searchForNearbyUsers();
}
public sendToOthersInGroupIncludingUser(user: User, message: ServerToClientMessage): void {
user.group?.getUsers().forEach((currentUser: User) => {
if (currentUser.id !== user.id) {
currentUser.socket.write(message);
}
});
}
setSilent(user: User, silent: boolean) {
if (user.silent === silent) {
return;
}
user.silent = silent;
if (silent && user.group !== undefined) {
this.leaveGroup(user);
}
if (!silent) {
// If we are back to life, let's trigger a position update to see if we can join some group.
this.updatePosition(user, user.getPosition());
}
}
/**
* Makes a user leave a group and closes and destroy the group if the group contains only one remaining person.
*
* @param user
*/
private leaveGroup(user: User): void {
const group = user.group;
if (group === undefined) {
throw new Error("The user is part of no group");
}
group.leave(user);
if (group.isEmpty()) {
group.destroy();
if (!this.groups.has(group)) {
throw new Error(`Could not find group ${group.getId()} referenced by user ${user.id} in World.`);
}
this.groups.delete(group);
//todo: is the group garbage collected?
} else {
group.updatePosition();
//this.positionNotifier.updatePosition(group, group.getPosition(), oldPosition);
}
}
/**
* Looks for the closest user that is:
* - close enough (distance <= minDistance)
* - not in a group
* - not silent
* OR
* - close enough to a group (distance <= groupRadius)
*/
private searchClosestAvailableUserOrGroup(user: User): User | Group | null {
let minimumDistanceFound: number = Math.max(this.minDistance, this.groupRadius);
let matchingItem: User | Group | null = null;
this.users.forEach((currentUser, userId) => {
// Let's only check users that are not part of a group
if (typeof currentUser.group !== "undefined") {
return;
}
if (currentUser === user) {
return;
}
if (currentUser.silent) {
return;
}
const distance = GameRoom.computeDistance(user, currentUser); // compute distance between peers.
if (distance <= minimumDistanceFound && distance <= this.minDistance) {
minimumDistanceFound = distance;
matchingItem = currentUser;
}
});
this.groups.forEach((group: Group) => {
if (group.isFull()) {
return;
}
const distance = GameRoom.computeDistanceBetweenPositions(user.getPosition(), group.getPosition());
if (distance <= minimumDistanceFound && distance <= this.groupRadius) {
minimumDistanceFound = distance;
matchingItem = group;
}
});
return matchingItem;
}
public static computeDistance(user1: User, user2: User): number {
const user1Position = user1.getPosition();
const user2Position = user2.getPosition();
return Math.sqrt(
Math.pow(user2Position.x - user1Position.x, 2) + Math.pow(user2Position.y - user1Position.y, 2)
);
}
public static computeDistanceBetweenPositions(position1: PositionInterface, position2: PositionInterface): number {
return Math.sqrt(Math.pow(position2.x - position1.x, 2) + Math.pow(position2.y - position1.y, 2));
}
public setItemState(itemId: number, state: unknown) {
this.itemsState.set(itemId, state);
}
public getItemsState(): Map<number, unknown> {
return this.itemsState;
}
public async setVariable(name: string, value: string, user: User): Promise<void> {
// First, let's check if "user" is allowed to modify the variable.
const variableManager = await this.getVariableManager();
try {
const readableBy = variableManager.setVariable(name, value, user);
// If the variable was not changed, let's not dispatch anything.
if (readableBy === false) {
return;
}
// TODO: should we batch those every 100ms?
const variableMessage = new VariableWithTagMessage();
variableMessage.setName(name);
variableMessage.setValue(value);
if (readableBy) {
variableMessage.setReadableby(readableBy);
}
const subMessage = new SubToPusherRoomMessage();
subMessage.setVariablemessage(variableMessage);
const batchMessage = new BatchToPusherRoomMessage();
batchMessage.addPayload(subMessage);
// Dispatch the message on the room listeners
for (const socket of this.roomListeners) {
socket.write(batchMessage);
}
} catch (e) {
if (e instanceof VariableError) {
// Ok, we have an error setting a variable. Either the user is trying to hack the map... or the map
// is not up to date. So let's try to reload the map from scratch.
if (this.variableManagerLastLoad === undefined) {
throw e;
}
const lastLoaded = new Date().getTime() - this.variableManagerLastLoad.getTime();
if (lastLoaded < 10000) {
console.log(
'An error occurred while setting the "' +
name +
"\" variable. But we tried to reload the map less than 10 seconds ago, so let's fail."
);
// Do not try to reload if we tried to reload less than 10 seconds ago.
throw e;
}
// Reset the variable manager
this.variableManagerPromise = undefined;
this.mapPromise = undefined;
console.log(
'An error occurred while setting the "' + name + "\" variable. Let's reload the map and try again"
);
// Try to set the variable again!
await this.setVariable(name, value, user);
} else {
throw e;
}
}
}
public addZoneListener(call: ZoneSocket, x: number, y: number): Set<Movable> {
return this.positionNotifier.addZoneListener(call, x, y);
}
public removeZoneListener(call: ZoneSocket, x: number, y: number): void {
return this.positionNotifier.removeZoneListener(call, x, y);
}
public adminJoin(admin: Admin): void {
this.admins.add(admin);
// Let's send all connected users
for (const user of this.users.values()) {
admin.sendUserJoin(user.uuid, user.name, user.IPAddress);
}
}
public adminLeave(admin: Admin): void {
this.admins.delete(admin);
}
public incrementVersion(): number {
this.versionNumber++;
return this.versionNumber;
}
public emitEmoteEvent(user: User, emoteEventMessage: EmoteEventMessage) {
this.positionNotifier.emitEmoteEvent(user, emoteEventMessage);
}
public addRoomListener(socket: RoomSocket) {
this.roomListeners.add(socket);
}
public removeRoomListener(socket: RoomSocket) {
this.roomListeners.delete(socket);
}
/**
* Connects to the admin server to fetch map details.
* If there is no admin server, the map details are generated by analysing the map URL (that must be in the form: /_/instance/map_url)
*/
private static async getMapDetails(roomUrl: string): Promise<MapDetailsData> {
if (!ADMIN_API_URL) {
const roomUrlObj = new URL(roomUrl);
const match = /\/_\/[^/]+\/(.+)/.exec(roomUrlObj.pathname);
if (!match) {
console.error("Unexpected room URL", roomUrl);
throw new Error('Unexpected room URL "' + roomUrl + '"');
}
const mapUrl = roomUrlObj.protocol + "//" + match[1];
return {
mapUrl,
policy_type: 1,
textures: [],
tags: [],
};
}
const result = await adminApi.fetchMapDetails(roomUrl);
if (isRoomRedirect(result)) {
console.error("Unexpected room redirect received while querying map details", result);
throw new Error("Unexpected room redirect received while querying map details");
}
return result;
}
private mapPromise: Promise<ITiledMap> | undefined;
/**
* Returns a promise to the map file.
* @throws LocalUrlError if the map we are trying to load is hosted on a local network
* @throws Error
*/
private getMap(): Promise<ITiledMap> {
if (!this.mapPromise) {
this.mapPromise = mapFetcher.fetchMap(this.mapUrl);
}
return this.mapPromise;
}
private variableManagerPromise: Promise<VariablesManager> | undefined;
private variableManagerLastLoad: Date | undefined;
private getVariableManager(): Promise<VariablesManager> {
if (!this.variableManagerPromise) {
this.variableManagerLastLoad = new Date();
this.variableManagerPromise = this.getMap()
.then((map) => {
const variablesManager = new VariablesManager(this.roomUrl, map);
return variablesManager.init();
})
.catch((e) => {
if (e instanceof LocalUrlError) {
// If we are trying to load a local URL, we are probably in test mode.
// In this case, let's bypass the server-side checks completely.
// Note: we run this message inside a setTimeout so that the room listeners can have time to connect.
setTimeout(() => {
for (const roomListener of this.roomListeners) {
emitErrorOnRoomSocket(
roomListener,
"You are loading a local map. If you use the scripting API in this map, please be aware that server-side checks and variable persistence is disabled."
);
}
}, 1000);
const variablesManager = new VariablesManager(this.roomUrl, null);
return variablesManager.init();
} else {
// An error occurred while loading the map
// Right now, let's bypass the error. In the future, we should make sure the user is aware of that
// and that he/she will act on it to fix the problem.
// Note: we run this message inside a setTimeout so that the room listeners can have time to connect.
setTimeout(() => {
for (const roomListener of this.roomListeners) {
emitErrorOnRoomSocket(
roomListener,
"Your map does not seem accessible from the WorkAdventure servers. Is it behind a firewall or a proxy? Your map should be accessible from the WorkAdventure servers. If you use the scripting API in this map, please be aware that server-side checks and variable persistence is disabled."
);
}
}, 1000);
const variablesManager = new VariablesManager(this.roomUrl, null);
return variablesManager.init();
}
});
}
return this.variableManagerPromise;
}
public async getVariablesForTags(tags: string[]): Promise<Map<string, string>> {
const variablesManager = await this.getVariableManager();
return variablesManager.getVariablesForTags(tags);
}
}

View file

@ -1,51 +1,34 @@
import { ConnectCallback, DisconnectCallback, GameRoom } from "./GameRoom";
import { World, ConnectCallback, DisconnectCallback } from "./World";
import { User } from "./User";
import { PositionInterface } from "_Model/PositionInterface";
import { Movable } from "_Model/Movable";
import { PositionNotifier } from "_Model/PositionNotifier";
import { MAX_PER_GROUP } from "../Enum/EnvironmentVariable";
import type { Zone } from "../Model/Zone";
import {PositionInterface} from "_Model/PositionInterface";
import {uuid} from "uuidv4";
import {Movable} from "_Model/Movable";
export class Group implements Movable {
private static nextId: number = 1;
static readonly MAX_PER_GROUP = 4;
private id: number;
private id: string;
private users: Set<User>;
private x!: number;
private y!: number;
private wasDestroyed: boolean = false;
private roomId: string;
private currentZone: Zone | null = null;
/**
* When outOfBounds = true, a user if out of the bounds of the group BUT still considered inside it (because we are in following mode)
*/
private outOfBounds = false;
private connectCallback: ConnectCallback;
private disconnectCallback: DisconnectCallback;
constructor(
roomId: string,
users: User[],
private groupRadius: number,
private connectCallback: ConnectCallback,
private disconnectCallback: DisconnectCallback,
private positionNotifier: PositionNotifier
) {
this.roomId = roomId;
constructor(users: User[], connectCallback: ConnectCallback, disconnectCallback: DisconnectCallback) {
this.users = new Set<User>();
this.id = Group.nextId;
Group.nextId++;
this.connectCallback = connectCallback;
this.disconnectCallback = disconnectCallback;
this.id = uuid();
users.forEach((user: User) => {
this.join(user);
});
this.updatePosition();
}
getUsers(): User[] {
return Array.from(this.users.values());
}
getId(): number {
getId() : string{
return this.id;
}
@ -53,162 +36,57 @@ export class Group implements Movable {
* Returns the barycenter of all users (i.e. the center of the group)
*/
getPosition(): PositionInterface {
let x = 0;
let y = 0;
// Let's compute the barycenter of all users.
this.users.forEach((user: User) => {
x += user.position.x;
y += user.position.y;
});
x /= this.users.size;
y /= this.users.size;
return {
x: this.x,
y: this.y,
x,
y
};
}
/**
* Returns the list of users of the group, ignoring any "followers".
* Useful to compute the position of the group if a follower is "trapped" far away from the the leader.
*/
getGroupHeads(): User[] {
return Array.from(this.users).filter((user) => user.group?.leader === user || !user.following);
}
/**
* Preview the position of the group but don't update it
*/
previewGroupPosition(): { x: number; y: number } | undefined {
const users = this.getGroupHeads();
let x = 0;
let y = 0;
if (users.length === 0) {
return undefined;
}
users.forEach((user: User) => {
const position = user.getPosition();
x += position.x;
y += position.y;
});
x /= users.length;
y /= users.length;
return { x, y };
}
/**
* Computes the barycenter of all users (i.e. the center of the group)
*/
updatePosition(): void {
const oldX = this.x;
const oldY = this.y;
// Let's compute the barycenter of all users.
const newPosition = this.previewGroupPosition();
if (!newPosition) {
return;
}
const { x, y } = newPosition;
this.x = x;
this.y = y;
if (this.outOfBounds) {
return;
}
if (oldX === undefined) {
this.currentZone = this.positionNotifier.enter(this);
} else {
this.currentZone = this.positionNotifier.updatePosition(this, { x, y }, { x: oldX, y: oldY });
}
}
searchForNearbyUsers(): void {
if (!this.currentZone) return;
for (const user of this.positionNotifier.getAllUsersInSquareAroundZone(this.currentZone)) {
// Todo: Merge two groups with a leader
if (user.group || this.isFull()) return; //we ignore users that are already in a group.
const distance = GameRoom.computeDistanceBetweenPositions(user.getPosition(), this.getPosition());
if (distance < this.groupRadius) {
this.join(user);
this.setOutOfBounds(false);
this.updatePosition();
}
}
}
isFull(): boolean {
return this.users.size >= MAX_PER_GROUP;
return this.users.size >= Group.MAX_PER_GROUP;
}
isEmpty(): boolean {
return this.users.size <= 1;
}
join(user: User): void {
join(user: User): void
{
// Broadcast on the right event
this.connectCallback(user, this);
this.connectCallback(user.id, this);
this.users.add(user);
user.group = this;
}
leave(user: User): void {
leave(user: User): void
{
const success = this.users.delete(user);
if (success === false) {
throw new Error(`Could not find user ${user.id} in the group ${this.id}`);
throw new Error("Could not find user "+user.id+" in the group "+this.id);
}
user.group = undefined;
if (this.users.size !== 0) {
this.updatePosition();
}
// Broadcast on the right event
this.disconnectCallback(user, this);
this.disconnectCallback(user.id, this);
}
/**
* Let's kick everybody out.
* Usually used when there is only one user left.
*/
destroy(): void {
if (!this.outOfBounds) {
this.positionNotifier.leave(this);
}
destroy(): void
{
for (const user of this.users) {
this.leave(user);
}
this.wasDestroyed = true;
}
get getSize() {
return this.users.size;
}
/**
* A group can have at most one person leading the way in it.
*/
get leader(): User | undefined {
for (const user of this.users) {
if (user.hasFollowers()) {
return user;
}
}
return undefined;
}
setOutOfBounds(outOfBounds: boolean): void {
if (this.outOfBounds === true && outOfBounds === false) {
this.positionNotifier.enter(this);
this.outOfBounds = false;
} else if (this.outOfBounds === false && outOfBounds === true) {
this.positionNotifier.leave(this);
this.outOfBounds = true;
}
}
get getOutOfBounds() {
return this.outOfBounds;
}
}

View file

@ -1,8 +1,8 @@
import { PositionInterface } from "_Model/PositionInterface";
import {PositionInterface} from "_Model/PositionInterface";
/**
* A physical object that can be placed into a Zone
*/
export interface Movable {
getPosition(): PositionInterface;
getPosition(): PositionInterface
}

View file

@ -1,4 +1,4 @@
export interface PositionInterface {
x: number;
y: number;
x: number,
y: number
}

View file

@ -8,67 +8,73 @@
* The PositionNotifier is important for performance. It allows us to send the position of players only to a restricted
* number of players around the current player.
*/
import {
EmoteCallback,
EntersCallback,
LeavesCallback,
MovesCallback,
PlayerDetailsUpdatedCallback,
Zone,
} from "./Zone";
import { Movable } from "_Model/Movable";
import { PositionInterface } from "_Model/PositionInterface";
import { ZoneSocket } from "../RoomManager";
import { User } from "../Model/User";
import { EmoteEventMessage, SetPlayerDetailsMessage } from "../Messages/generated/messages_pb";
import {EntersCallback, LeavesCallback, MovesCallback, Zone} from "./Zone";
import {PointInterface} from "_Model/Websocket/PointInterface";
import {User} from "_Model/User";
import {ViewportInterface} from "_Model/Websocket/ViewportMessage";
import {Movable} from "_Model/Movable";
import {PositionInterface} from "_Model/PositionInterface";
interface ZoneDescriptor {
i: number;
j: number;
}
export function* getNearbyDescriptorsMatrix(middleZoneDescriptor: ZoneDescriptor): Generator<ZoneDescriptor> {
for (let n = 0; n < 9; n++) {
const i = middleZoneDescriptor.i + ((n % 3) - 1);
const j = middleZoneDescriptor.j + (Math.floor(n / 3) - 1);
if (i >= 0 && j >= 0) {
yield { i, j };
}
}
}
export class PositionNotifier {
// TODO: we need a way to clean the zones if no one is in the zone and no one listening (to free memory!)
// TODO: we need a way to clean the zones if noone is in the zone and noone listening (to free memory!)
private zones: Zone[][] = [];
constructor(
private zoneWidth: number,
private zoneHeight: number,
private onUserEnters: EntersCallback,
private onUserMoves: MovesCallback,
private onUserLeaves: LeavesCallback,
private onEmote: EmoteCallback,
private onPlayerDetailsUpdated: PlayerDetailsUpdatedCallback
) {}
constructor(private zoneWidth: number, private zoneHeight: number, private onUserEnters: EntersCallback, private onUserMoves: MovesCallback, private onUserLeaves: LeavesCallback) {
}
private getZoneDescriptorFromCoordinates(x: number, y: number): ZoneDescriptor {
return {
i: Math.floor(x / this.zoneWidth),
j: Math.floor(y / this.zoneHeight),
};
}
}
public enter(thing: Movable): Zone {
const position = thing.getPosition();
const zoneDesc = this.getZoneDescriptorFromCoordinates(position.x, position.y);
const zone = this.getZone(zoneDesc.i, zoneDesc.j);
zone.enter(thing, null, position);
return zone;
/**
* Sets the viewport coordinates.
* Returns the list of new users to add
*/
public setViewport(user: User, viewport: ViewportInterface): Movable[] {
if (viewport.left > viewport.right || viewport.top > viewport.bottom) {
console.warn('Invalid viewport received: ', viewport);
return [];
}
const oldZones = user.listenedZones;
const newZones = new Set<Zone>();
const topLeftDesc = this.getZoneDescriptorFromCoordinates(viewport.left, viewport.top);
const bottomRightDesc = this.getZoneDescriptorFromCoordinates(viewport.right, viewport.bottom);
for (let j = topLeftDesc.j; j <= bottomRightDesc.j; j++) {
for (let i = topLeftDesc.i; i <= bottomRightDesc.i; i++) {
newZones.add(this.getZone(i, j));
}
}
const addedZones = [...newZones].filter(x => !oldZones.has(x));
const removedZones = [...oldZones].filter(x => !newZones.has(x));
let things: Movable[] = [];
for (const zone of addedZones) {
zone.startListening(user);
things = things.concat(Array.from(zone.getThings()))
}
for (const zone of removedZones) {
zone.stopListening(user);
}
return things;
}
public updatePosition(thing: Movable, newPosition: PositionInterface, oldPosition: PositionInterface): Zone {
public updatePosition(thing: Movable, newPosition: PositionInterface, oldPosition: PositionInterface): void {
// Did we change zone?
const oldZoneDesc = this.getZoneDescriptorFromCoordinates(oldPosition.x, oldPosition.y);
const newZoneDesc = this.getZoneDescriptorFromCoordinates(newPosition.x, newPosition.y);
@ -82,11 +88,9 @@ export class PositionNotifier {
// Enter new zone
newZone.enter(thing, oldZone, newPosition);
return newZone;
} else {
const zone = this.getZone(oldZoneDesc.i, oldZoneDesc.j);
zone.move(thing, newPosition);
return zone;
}
}
@ -97,6 +101,13 @@ export class PositionNotifier {
oldZone.leave(thing, null);
}
public removeViewport(user: User): void {
// Also, let's stop listening on viewports
for (const zone of user.listenedZones) {
zone.stopListening(user);
}
}
private getZone(i: number, j: number): Zone {
let zoneRow = this.zones[j];
if (zoneRow === undefined) {
@ -106,53 +117,9 @@ export class PositionNotifier {
let zone = this.zones[j][i];
if (zone === undefined) {
zone = new Zone(
this.onUserEnters,
this.onUserMoves,
this.onUserLeaves,
this.onEmote,
this.onPlayerDetailsUpdated,
i,
j
);
zone = new Zone(this.onUserEnters, this.onUserMoves, this.onUserLeaves);
this.zones[j][i] = zone;
}
return zone;
}
public addZoneListener(call: ZoneSocket, x: number, y: number): Set<Movable> {
const zone = this.getZone(x, y);
zone.addListener(call);
return zone.getThings();
}
public removeZoneListener(call: ZoneSocket, x: number, y: number): void {
const zone = this.getZone(x, y);
zone.removeListener(call);
}
public emitEmoteEvent(user: User, emoteEventMessage: EmoteEventMessage) {
const zoneDesc = this.getZoneDescriptorFromCoordinates(user.getPosition().x, user.getPosition().y);
const zone = this.getZone(zoneDesc.i, zoneDesc.j);
zone.emitEmoteEvent(emoteEventMessage);
}
public *getAllUsersInSquareAroundZone(zone: Zone): Generator<User> {
const zoneDescriptor = this.getZoneDescriptorFromCoordinates(zone.x, zone.y);
for (const d of getNearbyDescriptorsMatrix(zoneDescriptor)) {
const zone = this.getZone(d.i, d.j);
for (const thing of zone.getThings()) {
if (thing instanceof User) {
yield thing;
}
}
}
}
public updatePlayerDetails(user: User, playerDetails: SetPlayerDetailsMessage) {
const position = user.getPosition();
const zoneDesc = this.getZoneDescriptorFromCoordinates(position.x, position.y);
const zone = this.getZone(zoneDesc.i, zoneDesc.j);
zone.updatePlayerDetails(user, playerDetails);
}
}

View file

@ -1,130 +1,23 @@
import { Group } from "./Group";
import { PointInterface } from "./Websocket/PointInterface";
import { Zone } from "_Model/Zone";
import { Movable } from "_Model/Movable";
import { PositionNotifier } from "_Model/PositionNotifier";
import { ServerDuplexStream } from "grpc";
import {
BatchMessage,
CompanionMessage,
FollowAbortMessage,
FollowConfirmationMessage,
PusherToBackMessage,
ServerToClientMessage,
SetPlayerDetailsMessage,
SubMessage,
} from "../Messages/generated/messages_pb";
import { CharacterLayer } from "_Model/Websocket/CharacterLayer";
export type UserSocket = ServerDuplexStream<PusherToBackMessage, ServerToClientMessage>;
import {Zone} from "_Model/Zone";
import {Movable} from "_Model/Movable";
import {PositionInterface} from "_Model/PositionInterface";
export class User implements Movable {
public listenedZones: Set<Zone>;
public group?: Group;
private _following: User | undefined;
private followedBy: Set<User> = new Set<User>();
public constructor(
public id: number,
public readonly uuid: string,
public readonly IPAddress: string,
private position: PointInterface,
public id: string,
public position: PointInterface,
public silent: boolean,
private positionNotifier: PositionNotifier,
public readonly socket: UserSocket,
public readonly tags: string[],
public readonly visitCardUrl: string | null,
public readonly name: string,
public readonly characterLayers: CharacterLayer[],
public readonly companion?: CompanionMessage,
private _outlineColor?: number | undefined
) {
this.listenedZones = new Set<Zone>();
this.positionNotifier.enter(this);
}
public getPosition(): PointInterface {
public getPosition(): PositionInterface {
return this.position;
}
public setPosition(position: PointInterface): void {
const oldPosition = this.position;
this.position = position;
this.positionNotifier.updatePosition(this, position, oldPosition);
}
public addFollower(follower: User): void {
this.followedBy.add(follower);
follower._following = this;
const message = new FollowConfirmationMessage();
message.setFollower(follower.id);
message.setLeader(this.id);
const clientMessage = new ServerToClientMessage();
clientMessage.setFollowconfirmationmessage(message);
this.socket.write(clientMessage);
}
public delFollower(follower: User): void {
this.followedBy.delete(follower);
follower._following = undefined;
const message = new FollowAbortMessage();
message.setFollower(follower.id);
message.setLeader(this.id);
const clientMessage = new ServerToClientMessage();
clientMessage.setFollowabortmessage(message);
this.socket.write(clientMessage);
follower.socket.write(clientMessage);
}
public hasFollowers(): boolean {
return this.followedBy.size !== 0;
}
get following(): User | undefined {
return this._following;
}
public stopLeading(): void {
for (const follower of this.followedBy) {
this.delFollower(follower);
}
}
private batchedMessages: BatchMessage = new BatchMessage();
private batchTimeout: NodeJS.Timeout | null = null;
public emitInBatch(payload: SubMessage): void {
this.batchedMessages.addPayload(payload);
if (this.batchTimeout === null) {
this.batchTimeout = setTimeout(() => {
/*if (socket.disconnecting) {
return;
}*/
const serverToClientMessage = new ServerToClientMessage();
serverToClientMessage.setBatchmessage(this.batchedMessages);
this.socket.write(serverToClientMessage);
this.batchedMessages = new BatchMessage();
this.batchTimeout = null;
}, 100);
}
}
public set outlineColor(value: number | undefined) {
this._outlineColor = value;
const playerDetails = new SetPlayerDetailsMessage();
if (value === undefined) {
playerDetails.setRemoveoutlinecolor(true);
} else {
playerDetails.setOutlinecolor(value);
}
this.positionNotifier.updatePlayerDetails(this, playerDetails);
}
}

View file

@ -1,4 +0,0 @@
export interface CharacterLayer {
name: string;
url: string | undefined;
}

View file

@ -0,0 +1,23 @@
import {Socket} from "socket.io";
import {PointInterface} from "./PointInterface";
import {Identificable} from "./Identificable";
import {TokenInterface} from "../../Controller/AuthenticateController";
import {ViewportInterface} from "_Model/Websocket/ViewportMessage";
export interface ExSocketInterface extends Socket, Identificable {
token: string;
roomId: string;
webRtcRoomId: string;
userId: string;
name: string;
characterLayers: string[];
position: PointInterface;
viewport: ViewportInterface;
isArtillery: boolean; // Whether this socket is opened by Artillery for load testing (hack)
/**
* Pushes an event that will be sent in the next batch of events
*/
emitInBatch: (event: string | symbol, payload: unknown) => void;
batchedMessages: Array<{ event: string | symbol, payload: unknown }>;
batchTimeout: NodeJS.Timeout|null;
}

View file

@ -0,0 +1,6 @@
import {PositionInterface} from "_Model/PositionInterface";
export interface GroupUpdateInterface {
position: PositionInterface,
groupId: string,
}

View file

@ -1,3 +1,3 @@
export interface Identificable {
userId: number;
userId: string;
}

View file

@ -1,11 +1,10 @@
import * as tg from "generic-type-guard";
export const isItemEventMessageInterface = new tg.IsInterface()
.withProperties({
export const isItemEventMessageInterface =
new tg.IsInterface().withProperties({
itemId: tg.isNumber,
event: tg.isString,
state: tg.isUnknown,
parameters: tg.isUnknown,
})
.get();
}).get();
export type ItemEventMessageInterface = tg.GuardedType<typeof isItemEventMessageInterface>;

View file

@ -0,0 +1,11 @@
import * as tg from "generic-type-guard";
import {isPointInterface} from "./PointInterface";
import {isViewport} from "./ViewportMessage";
export const isJoinRoomMessageInterface =
new tg.IsInterface().withProperties({
roomId: tg.isString,
position: isPointInterface,
viewport: isViewport
}).get();
export type JoinRoomMessageInterface = tg.GuardedType<typeof isJoinRoomMessageInterface>;

View file

@ -0,0 +1,6 @@
import {PointInterface} from "_Model/Websocket/PointInterface";
export class MessageUserJoined {
constructor(public userId: string, public name: string, public characterLayers: string[], public position: PointInterface) {
}
}

View file

@ -0,0 +1,6 @@
import {PointInterface} from "./PointInterface";
export class MessageUserMoved {
constructor(public userId: string, public position: PointInterface) {
}
}

View file

@ -1,10 +1,11 @@
import { PointInterface } from "./PointInterface";
import {PointInterface} from "./PointInterface";
export class Point implements PointInterface {
constructor(
public x: number,
public y: number,
public direction: string = "none",
public moving: boolean = false
) {}
export class Point implements PointInterface{
constructor(public x : number, public y : number, public direction : string = "none", public moving : boolean = false) {
}
}
export class MessageUserPosition {
constructor(public userId: string, public name: string, public characterLayers: string[], public position: PointInterface) {
}
}

View file

@ -7,12 +7,11 @@ import * as tg from "generic-type-guard";
readonly moving: boolean;
}*/
export const isPointInterface = new tg.IsInterface()
.withProperties({
export const isPointInterface =
new tg.IsInterface().withProperties({
x: tg.isNumber,
y: tg.isNumber,
direction: tg.isString,
moving: tg.isBoolean,
})
.get();
moving: tg.isBoolean
}).get();
export type PointInterface = tg.GuardedType<typeof isPointInterface>;

View file

@ -1,117 +0,0 @@
import { PointInterface } from "./PointInterface";
import {
CharacterLayerMessage,
ItemEventMessage,
PointMessage,
PositionMessage,
} from "../../Messages/generated/messages_pb";
import { CharacterLayer } from "_Model/Websocket/CharacterLayer";
import Direction = PositionMessage.Direction;
import { ItemEventMessageInterface } from "_Model/Websocket/ItemEventMessage";
import { PositionInterface } from "_Model/PositionInterface";
export class ProtobufUtils {
public static toPositionMessage(point: PointInterface): PositionMessage {
let direction: Direction;
switch (point.direction) {
case "up":
direction = Direction.UP;
break;
case "down":
direction = Direction.DOWN;
break;
case "left":
direction = Direction.LEFT;
break;
case "right":
direction = Direction.RIGHT;
break;
default:
throw new Error("unexpected direction");
}
const position = new PositionMessage();
position.setX(point.x);
position.setY(point.y);
position.setMoving(point.moving);
position.setDirection(direction);
return position;
}
public static toPointInterface(position: PositionMessage): PointInterface {
let direction: string;
switch (position.getDirection()) {
case Direction.UP:
direction = "up";
break;
case Direction.DOWN:
direction = "down";
break;
case Direction.LEFT:
direction = "left";
break;
case Direction.RIGHT:
direction = "right";
break;
default:
throw new Error("Unexpected direction");
}
// sending to all clients in room except sender
return {
x: position.getX(),
y: position.getY(),
direction,
moving: position.getMoving(),
};
}
public static toPointMessage(point: PositionInterface): PointMessage {
const position = new PointMessage();
position.setX(Math.floor(point.x));
position.setY(Math.floor(point.y));
return position;
}
public static toItemEvent(itemEventMessage: ItemEventMessage): ItemEventMessageInterface {
return {
itemId: itemEventMessage.getItemid(),
event: itemEventMessage.getEvent(),
parameters: JSON.parse(itemEventMessage.getParametersjson()),
state: JSON.parse(itemEventMessage.getStatejson()),
};
}
public static toItemEventProtobuf(itemEvent: ItemEventMessageInterface): ItemEventMessage {
const itemEventMessage = new ItemEventMessage();
itemEventMessage.setItemid(itemEvent.itemId);
itemEventMessage.setEvent(itemEvent.event);
itemEventMessage.setParametersjson(JSON.stringify(itemEvent.parameters));
itemEventMessage.setStatejson(JSON.stringify(itemEvent.state));
return itemEventMessage;
}
public static toCharacterLayerMessages(characterLayers: CharacterLayer[]): CharacterLayerMessage[] {
return characterLayers.map(function (characterLayer): CharacterLayerMessage {
const message = new CharacterLayerMessage();
message.setName(characterLayer.name);
if (characterLayer.url) {
message.setUrl(characterLayer.url);
}
return message;
});
}
public static toCharacterLayerObjects(characterLayers: CharacterLayerMessage[]): CharacterLayer[] {
return characterLayers.map(function (characterLayer): CharacterLayer {
const url = characterLayer.getUrl();
return {
name: characterLayer.getName(),
url: url ? url : undefined,
};
});
}
}

View file

@ -0,0 +1,8 @@
import * as tg from "generic-type-guard";
export const isSetPlayerDetailsMessage =
new tg.IsInterface().withProperties({
name: tg.isString,
characterLayers: tg.isArray(tg.isString)
}).get();
export type SetPlayerDetailsMessage = tg.GuardedType<typeof isSetPlayerDetailsMessage>;

View file

@ -0,0 +1,5 @@
export interface UserInGroupInterface {
userId: string,
name: string,
initiator: boolean
}

View file

@ -0,0 +1,11 @@
import * as tg from "generic-type-guard";
import {isPointInterface} from "./PointInterface";
import {isViewport} from "./ViewportMessage";
export const isUserMovesInterface =
new tg.IsInterface().withProperties({
position: isPointInterface,
viewport: isViewport,
}).get();
export type UserMovesInterface = tg.GuardedType<typeof isUserMovesInterface>;

View file

@ -1,11 +1,10 @@
import * as tg from "generic-type-guard";
export const isViewport = new tg.IsInterface()
.withProperties({
export const isViewport =
new tg.IsInterface().withProperties({
left: tg.isNumber,
top: tg.isNumber,
right: tg.isNumber,
bottom: tg.isNumber,
})
.get();
}).get();
export type ViewportInterface = tg.GuardedType<typeof isViewport>;

View file

@ -0,0 +1,18 @@
import * as tg from "generic-type-guard";
export const isSignalData =
new tg.IsInterface().withProperties({
type: tg.isOptional(tg.isString)
}).get();
export const isWebRtcSignalMessageInterface =
new tg.IsInterface().withProperties({
receiverId: tg.isString,
signal: isSignalData
}).get();
export const isWebRtcScreenSharingStartMessageInterface =
new tg.IsInterface().withProperties({
userId: tg.isString,
roomId: tg.isString
}).get();
export type WebRtcSignalMessageInterface = tg.GuardedType<typeof isWebRtcSignalMessageInterface>;

343
back/src/Model/World.ts Normal file
View file

@ -0,0 +1,343 @@
import {MessageUserPosition, Point} from "./Websocket/MessageUserPosition";
import {PointInterface} from "./Websocket/PointInterface";
import {Group} from "./Group";
import {Distance} from "./Distance";
import {User} from "./User";
import {ExSocketInterface} from "_Model/Websocket/ExSocketInterface";
import {PositionInterface} from "_Model/PositionInterface";
import {Identificable} from "_Model/Websocket/Identificable";
import {EntersCallback, LeavesCallback, MovesCallback, Zone} from "_Model/Zone";
import {PositionNotifier} from "./PositionNotifier";
import {ViewportInterface} from "_Model/Websocket/ViewportMessage";
import {Movable} from "_Model/Movable";
export type ConnectCallback = (user: string, group: Group) => void;
export type DisconnectCallback = (user: string, group: Group) => void;
export class World {
private readonly minDistance: number;
private readonly groupRadius: number;
// Users, sorted by ID
private readonly users: Map<string, User>;
private readonly groups: Set<Group>;
private readonly connectCallback: ConnectCallback;
private readonly disconnectCallback: DisconnectCallback;
private itemsState: Map<number, unknown> = new Map<number, unknown>();
private readonly positionNotifier: PositionNotifier;
constructor(connectCallback: ConnectCallback,
disconnectCallback: DisconnectCallback,
minDistance: number,
groupRadius: number,
onEnters: EntersCallback,
onMoves: MovesCallback,
onLeaves: LeavesCallback)
{
this.users = new Map<string, User>();
this.groups = new Set<Group>();
this.connectCallback = connectCallback;
this.disconnectCallback = disconnectCallback;
this.minDistance = minDistance;
this.groupRadius = groupRadius;
// A zone is 10 sprites wide.
this.positionNotifier = new PositionNotifier(320, 320, onEnters, onMoves, onLeaves);
}
public getGroups(): Group[] {
return Array.from(this.groups.values());
}
public getUsers(): Map<string, User> {
return this.users;
}
public join(socket : Identificable, userPosition: PointInterface): void {
this.users.set(socket.userId, new User(socket.userId, userPosition, false));
// Let's call update position to trigger the join / leave room
this.updatePosition(socket, userPosition);
}
public leave(user : Identificable){
const userObj = this.users.get(user.userId);
if (userObj === undefined) {
console.warn('User ', user.userId, 'does not belong to world! It should!');
}
if (userObj !== undefined && typeof userObj.group !== 'undefined') {
this.leaveGroup(userObj);
}
this.users.delete(user.userId);
if (userObj !== undefined) {
this.positionNotifier.leave(userObj);
this.positionNotifier.removeViewport(userObj);
}
}
public isEmpty(): boolean {
return this.users.size === 0;
}
public updatePosition(socket : Identificable, userPosition: PointInterface): void {
const user = this.users.get(socket.userId);
if(typeof user === 'undefined') {
return;
}
this.positionNotifier.updatePosition(user, userPosition, user.position);
const oldGroupPosition = user.group?.getPosition();
user.position = userPosition;
if (user.silent) {
return;
}
if (user.group === undefined) {
// If the user is not part of a group:
// should he join a group?
const closestItem: User|Group|null = this.searchClosestAvailableUserOrGroup(user);
if (closestItem !== null) {
if (closestItem instanceof Group) {
// Let's join the group!
closestItem.join(user);
} else {
const closestUser : User = closestItem;
const group: Group = new Group([
user,
closestUser
], this.connectCallback, this.disconnectCallback);
this.groups.add(group);
}
}
} else {
// If the user is part of a group:
// should he leave the group?
const distance = World.computeDistanceBetweenPositions(user.position, user.group.getPosition());
if (distance > this.groupRadius) {
this.leaveGroup(user);
}
}
// At the very end, if the user is part of a group, let's call the callback to update group position
if (typeof user.group !== 'undefined') {
this.positionNotifier.updatePosition(user.group, user.group.getPosition(), oldGroupPosition ? oldGroupPosition : user.group.getPosition());
}
}
setSilent(socket: Identificable, silent: boolean) {
const user = this.users.get(socket.userId);
if(typeof user === 'undefined') {
console.warn('In setSilent, could not find user with ID "'+socket.userId+'" in world.');
return;
}
if (user.silent === silent) {
return;
}
user.silent = silent;
if (silent && user.group !== undefined) {
this.leaveGroup(user);
}
if (!silent) {
// If we are back to life, let's trigger a position update to see if we can join some group.
this.updatePosition(socket, user.position);
}
}
/**
* Makes a user leave a group and closes and destroy the group if the group contains only one remaining person.
*
* @param user
*/
private leaveGroup(user: User): void {
const group = user.group;
if (typeof group === 'undefined') {
throw new Error("The user is part of no group");
}
group.leave(user);
if (group.isEmpty()) {
this.positionNotifier.leave(group);
group.destroy();
if (!this.groups.has(group)) {
throw new Error("Could not find group "+group.getId()+" referenced by user "+user.id+" in World.");
}
this.groups.delete(group);
} else {
this.positionNotifier.updatePosition(group, group.getPosition(), group.getPosition());
}
}
/**
* Looks for the closest user that is:
* - close enough (distance <= minDistance)
* - not in a group
* - not silent
* OR
* - close enough to a group (distance <= groupRadius)
*/
private searchClosestAvailableUserOrGroup(user: User): User|Group|null
{
let minimumDistanceFound: number = Math.max(this.minDistance, this.groupRadius);
let matchingItem: User | Group | null = null;
this.users.forEach((currentUser, userId) => {
// Let's only check users that are not part of a group
if (typeof currentUser.group !== 'undefined') {
return;
}
if(currentUser === user) {
return;
}
if (currentUser.silent) {
return;
}
const distance = World.computeDistance(user, currentUser); // compute distance between peers.
if(distance <= minimumDistanceFound && distance <= this.minDistance) {
minimumDistanceFound = distance;
matchingItem = currentUser;
}
/*if (typeof currentUser.group === 'undefined' || !currentUser.group.isFull()) {
// We found a user we can bind to.
return;
}*/
/*
if(context.groups.length > 0) {
context.groups.forEach(group => {
if(group.isPartOfGroup(userPosition)) { // Is the user in a group ?
if(group.isStillIn(userPosition)) { // Is the user leaving the group ? (is the user at more than max distance of each player)
// Should we split the group? (is each player reachable from the current player?)
// This is needed if
// A <==> B <==> C <===> D
// becomes A <==> B <=====> C <> D
// If C moves right, the distance between B and C is too great and we must form 2 groups
}
} else {
// If the user is in no group
// Is there someone in a group close enough and with room in the group ?
}
});
} else {
// Aucun groupe n'existe donc je stock les users assez proches de moi
let dist: Distance = {
distance: distance,
first: userPosition,
second: user // TODO: convertir en messageUserPosition
}
usersToBeGroupedWith.push(dist);
}
*/
});
this.groups.forEach((group: Group) => {
if (group.isFull()) {
return;
}
const distance = World.computeDistanceBetweenPositions(user.position, group.getPosition());
if(distance <= minimumDistanceFound && distance <= this.groupRadius) {
minimumDistanceFound = distance;
matchingItem = group;
}
});
return matchingItem;
}
public static computeDistance(user1: User, user2: User): number
{
return Math.sqrt(Math.pow(user2.position.x - user1.position.x, 2) + Math.pow(user2.position.y - user1.position.y, 2));
}
public static computeDistanceBetweenPositions(position1: PositionInterface, position2: PositionInterface): number
{
return Math.sqrt(Math.pow(position2.x - position1.x, 2) + Math.pow(position2.y - position1.y, 2));
}
public setItemState(itemId: number, state: unknown) {
this.itemsState.set(itemId, state);
}
public getItemsState(): Map<number, unknown> {
return this.itemsState;
}
/*getDistancesBetweenGroupUsers(group: Group): Distance[]
{
let i = 0;
let users = group.getUsers();
let distances: Distance[] = [];
users.forEach(function(user1, key1) {
users.forEach(function(user2, key2) {
if(key1 < key2) {
distances[i] = {
distance: World.computeDistance(user1, user2),
first: user1,
second: user2
};
i++;
}
});
});
distances.sort(World.compareDistances);
return distances;
}
filterGroup(distances: Distance[], group: Group): void
{
let users = group.getUsers();
let usersToRemove = false;
let groupTmp: MessageUserPosition[] = [];
distances.forEach(dist => {
if(dist.distance <= World.MIN_DISTANCE) {
let users = [dist.first];
let usersbis = [dist.second]
groupTmp.push(dist.first);
groupTmp.push(dist.second);
} else {
usersToRemove = true;
}
});
if(usersToRemove) {
// Detecte le ou les users qui se sont fait sortir du groupe
let difference = users.filter(x => !groupTmp.includes(x));
// TODO : Notify users un difference that they have left the group
}
let newgroup = new Group(groupTmp);
this.groups.push(newgroup);
}
private static compareDistances(distA: Distance, distB: Distance): number
{
if (distA.distance < distB.distance) {
return -1;
}
if (distA.distance > distB.distance) {
return 1;
}
return 0;
}*/
setViewport(socket : Identificable, viewport: ViewportInterface): Movable[] {
const user = this.users.get(socket.userId);
if(typeof user === 'undefined') {
console.warn('In setViewport, could not find user with ID "'+socket.userId+'" in world.');
return [];
}
return this.positionNotifier.setViewport(user, viewport);
}
}

View file

@ -1,120 +1,96 @@
import { User } from "./User";
import { PositionInterface } from "_Model/PositionInterface";
import { Movable } from "./Movable";
import { Group } from "./Group";
import { ZoneSocket } from "../RoomManager";
import {
EmoteEventMessage,
SetPlayerDetailsMessage,
PlayerDetailsUpdatedMessage,
} from "../Messages/generated/messages_pb";
import {User} from "./User";
import {PositionInterface} from "_Model/PositionInterface";
import {Movable} from "_Model/Movable";
export type EntersCallback = (thing: Movable, fromZone: Zone | null, listener: ZoneSocket) => void;
export type MovesCallback = (thing: Movable, position: PositionInterface, listener: ZoneSocket) => void;
export type LeavesCallback = (thing: Movable, newZone: Zone | null, listener: ZoneSocket) => void;
export type EmoteCallback = (emoteEventMessage: EmoteEventMessage, listener: ZoneSocket) => void;
export type PlayerDetailsUpdatedCallback = (
playerDetailsUpdatedMessage: PlayerDetailsUpdatedMessage,
listener: ZoneSocket
) => void;
export type EntersCallback = (thing: Movable, listener: User) => void;
export type MovesCallback = (thing: Movable, position: PositionInterface, listener: User) => void;
export type LeavesCallback = (thing: Movable, listener: User) => void;
export class Zone {
private things: Set<Movable> = new Set<Movable>();
private listeners: Set<ZoneSocket> = new Set<ZoneSocket>();
private listeners: Set<User> = new Set<User>();
constructor(
private onEnters: EntersCallback,
private onMoves: MovesCallback,
private onLeaves: LeavesCallback,
private onEmote: EmoteCallback,
private onPlayerDetailsUpdated: PlayerDetailsUpdatedCallback,
public readonly x: number,
public readonly y: number
) {}
constructor(private onEnters: EntersCallback, private onMoves: MovesCallback, private onLeaves: LeavesCallback) {
}
/**
* A user/thing leaves the zone
*/
public leave(thing: Movable, newZone: Zone | null) {
const result = this.things.delete(thing);
if (!result) {
if (thing instanceof User) {
throw new Error(`Could not find user in zone ${thing.id}`);
}
if (thing instanceof Group) {
throw new Error(
`Could not find group ${thing.getId()} in zone (${this.x},${this.y}). Position of group: (${
thing.getPosition().x
},${thing.getPosition().y})`
);
}
}
public leave(thing: Movable, newZone: Zone|null) {
this.things.delete(thing);
this.notifyLeft(thing, newZone);
}
/**
* Notify listeners of this zone that this user/thing left
*/
private notifyLeft(thing: Movable, newZone: Zone | null) {
private notifyLeft(thing: Movable, newZone: Zone|null) {
for (const listener of this.listeners) {
this.onLeaves(thing, newZone, listener);
if (listener !== thing && (newZone === null || !listener.listenedZones.has(newZone))) {
this.onLeaves(thing, listener);
}
}
}
public enter(thing: Movable, oldZone: Zone | null, position: PositionInterface) {
public enter(thing: Movable, oldZone: Zone|null, position: PositionInterface) {
this.things.add(thing);
this.notifyEnter(thing, oldZone, position);
this.notifyUserEnter(thing, oldZone, position);
}
/**
* Notify listeners of this zone that this user entered
*/
private notifyEnter(thing: Movable, oldZone: Zone | null, position: PositionInterface) {
private notifyUserEnter(thing: Movable, oldZone: Zone|null, position: PositionInterface) {
for (const listener of this.listeners) {
this.onEnters(thing, oldZone, listener);
if (listener === thing) {
continue;
}
if (oldZone === null || !listener.listenedZones.has(oldZone)) {
this.onEnters(thing, listener);
} else {
this.onMoves(thing, position, listener);
}
}
}
public move(thing: Movable, position: PositionInterface) {
if (!this.things.has(thing)) {
this.things.add(thing);
this.notifyEnter(thing, null, position);
const foo = this.things;
this.notifyUserEnter(thing, null, position);
return;
}
for (const listener of this.listeners) {
//if (listener !== thing) {
this.onMoves(thing, position, listener);
//}
if (listener !== thing) {
this.onMoves(thing,position, listener);
}
}
}
public startListening(listener: User): void {
for (const thing of this.things) {
if (thing !== listener) {
this.onEnters(thing, listener);
}
}
this.listeners.add(listener);
listener.listenedZones.add(this);
}
public stopListening(listener: User): void {
for (const thing of this.things) {
if (thing !== listener) {
this.onLeaves(thing, listener);
}
}
this.listeners.delete(listener);
listener.listenedZones.delete(this);
}
public getThings(): Set<Movable> {
return this.things;
}
public addListener(socket: ZoneSocket): void {
this.listeners.add(socket);
// TODO: here, we should trigger in some way the sending of current items
}
public removeListener(socket: ZoneSocket): void {
this.listeners.delete(socket);
}
public emitEmoteEvent(emoteEventMessage: EmoteEventMessage) {
for (const listener of this.listeners) {
this.onEmote(emoteEventMessage, listener);
}
}
public updatePlayerDetails(user: User, playerDetails: SetPlayerDetailsMessage) {
const playerDetailsUpdatedMessage = new PlayerDetailsUpdatedMessage();
playerDetailsUpdatedMessage.setUserid(user.id);
playerDetailsUpdatedMessage.setDetails(playerDetails);
for (const listener of this.listeners) {
this.onPlayerDetailsUpdated(playerDetailsUpdatedMessage, listener);
}
}
}

View file

@ -1,324 +0,0 @@
import { IRoomManagerServer } from "./Messages/generated/messages_grpc_pb";
import {
AdminGlobalMessage,
AdminMessage,
AdminPusherToBackMessage,
AdminRoomMessage,
BanMessage,
BanUserMessage,
BatchToPusherMessage,
BatchToPusherRoomMessage,
EmotePromptMessage,
FollowRequestMessage,
FollowConfirmationMessage,
FollowAbortMessage,
EmptyMessage,
ItemEventMessage,
JoinRoomMessage,
PlayGlobalMessage,
PusherToBackMessage,
QueryJitsiJwtMessage,
RefreshRoomPromptMessage,
RoomMessage,
SendUserMessage,
ServerToAdminClientMessage,
SetPlayerDetailsMessage,
SilentMessage,
UserMovesMessage,
VariableMessage,
WebRtcSignalToServerMessage,
WorldFullWarningToRoomMessage,
ZoneMessage,
} from "./Messages/generated/messages_pb";
import { sendUnaryData, ServerDuplexStream, ServerUnaryCall, ServerWritableStream } from "grpc";
import { socketManager } from "./Services/SocketManager";
import { emitError, emitErrorOnRoomSocket, emitErrorOnZoneSocket } from "./Services/MessageHelpers";
import { User, UserSocket } from "./Model/User";
import { GameRoom } from "./Model/GameRoom";
import Debug from "debug";
import { Admin } from "./Model/Admin";
const debug = Debug("roommanager");
export type AdminSocket = ServerDuplexStream<AdminPusherToBackMessage, ServerToAdminClientMessage>;
export type ZoneSocket = ServerWritableStream<ZoneMessage, BatchToPusherMessage>;
export type RoomSocket = ServerWritableStream<RoomMessage, BatchToPusherRoomMessage>;
const roomManager: IRoomManagerServer = {
joinRoom: (call: UserSocket): void => {
console.log("joinRoom called");
let room: GameRoom | null = null;
let user: User | null = null;
call.on("data", (message: PusherToBackMessage) => {
(async () => {
try {
if (room === null || user === null) {
if (message.hasJoinroommessage()) {
socketManager
.handleJoinRoom(call, message.getJoinroommessage() as JoinRoomMessage)
.then(({ room: gameRoom, user: myUser }) => {
if (call.writable) {
room = gameRoom;
user = myUser;
} else {
//Connection may have been closed before the init was finished, so we have to manually disconnect the user.
socketManager.leaveRoom(gameRoom, myUser);
}
})
.catch((e) => emitError(call, e));
} else {
throw new Error("The first message sent MUST be of type JoinRoomMessage");
}
} else {
if (message.hasJoinroommessage()) {
throw new Error("Cannot call JoinRoomMessage twice!");
} else if (message.hasUsermovesmessage()) {
socketManager.handleUserMovesMessage(
room,
user,
message.getUsermovesmessage() as UserMovesMessage
);
} else if (message.hasSilentmessage()) {
socketManager.handleSilentMessage(room, user, message.getSilentmessage() as SilentMessage);
} else if (message.hasItemeventmessage()) {
socketManager.handleItemEvent(
room,
user,
message.getItemeventmessage() as ItemEventMessage
);
} else if (message.hasVariablemessage()) {
await socketManager.handleVariableEvent(
room,
user,
message.getVariablemessage() as VariableMessage
);
} else if (message.hasWebrtcsignaltoservermessage()) {
socketManager.emitVideo(
room,
user,
message.getWebrtcsignaltoservermessage() as WebRtcSignalToServerMessage
);
} else if (message.hasWebrtcscreensharingsignaltoservermessage()) {
socketManager.emitScreenSharing(
room,
user,
message.getWebrtcscreensharingsignaltoservermessage() as WebRtcSignalToServerMessage
);
} else if (message.hasQueryjitsijwtmessage()) {
socketManager.handleQueryJitsiJwtMessage(
user,
message.getQueryjitsijwtmessage() as QueryJitsiJwtMessage
);
} else if (message.hasEmotepromptmessage()) {
socketManager.handleEmoteEventMessage(
room,
user,
message.getEmotepromptmessage() as EmotePromptMessage
);
} else if (message.hasFollowrequestmessage()) {
socketManager.handleFollowRequestMessage(
room,
user,
message.getFollowrequestmessage() as FollowRequestMessage
);
} else if (message.hasFollowconfirmationmessage()) {
socketManager.handleFollowConfirmationMessage(
room,
user,
message.getFollowconfirmationmessage() as FollowConfirmationMessage
);
} else if (message.hasFollowabortmessage()) {
socketManager.handleFollowAbortMessage(
room,
user,
message.getFollowabortmessage() as FollowAbortMessage
);
} else if (message.hasSendusermessage()) {
const sendUserMessage = message.getSendusermessage();
socketManager.handleSendUserMessage(user, sendUserMessage as SendUserMessage);
} else if (message.hasBanusermessage()) {
const banUserMessage = message.getBanusermessage();
socketManager.handlerBanUserMessage(room, user, banUserMessage as BanUserMessage);
} else if (message.hasSetplayerdetailsmessage()) {
const setPlayerDetailsMessage = message.getSetplayerdetailsmessage();
socketManager.handleSetPlayerDetails(
room,
user,
setPlayerDetailsMessage as SetPlayerDetailsMessage
);
} else {
throw new Error("Unhandled message type");
}
}
} catch (e) {
console.error(e);
emitError(call, e);
call.end();
}
})().catch((e) => console.error(e));
});
call.on("end", () => {
debug("joinRoom ended");
if (user !== null && room !== null) {
socketManager.leaveRoom(room, user);
}
call.end();
room = null;
user = null;
});
call.on("error", (err: Error) => {
console.error("An error occurred in joinRoom stream:", err);
});
},
listenZone(call: ZoneSocket): void {
debug("listenZone called");
const zoneMessage = call.request;
socketManager
.addZoneListener(call, zoneMessage.getRoomid(), zoneMessage.getX(), zoneMessage.getY())
.catch((e) => {
emitErrorOnZoneSocket(call, e);
});
call.on("cancelled", () => {
debug("listenZone cancelled");
socketManager
.removeZoneListener(call, zoneMessage.getRoomid(), zoneMessage.getX(), zoneMessage.getY())
.catch((e) => console.error(e));
call.end();
});
call.on("close", () => {
debug("listenZone connection closed");
socketManager
.removeZoneListener(call, zoneMessage.getRoomid(), zoneMessage.getX(), zoneMessage.getY())
.catch((e) => console.error(e));
}).on("error", (e) => {
console.error("An error occurred in listenZone stream:", e);
socketManager
.removeZoneListener(call, zoneMessage.getRoomid(), zoneMessage.getX(), zoneMessage.getY())
.catch((e) => console.error(e));
call.end();
});
},
listenRoom(call: RoomSocket): void {
debug("listenRoom called");
const roomMessage = call.request;
socketManager.addRoomListener(call, roomMessage.getRoomid()).catch((e) => {
emitErrorOnRoomSocket(call, e);
});
call.on("cancelled", () => {
debug("listenRoom cancelled");
socketManager.removeRoomListener(call, roomMessage.getRoomid()).catch((e) => console.error(e));
call.end();
});
call.on("close", () => {
debug("listenRoom connection closed");
socketManager.removeRoomListener(call, roomMessage.getRoomid()).catch((e) => console.error(e));
}).on("error", (e) => {
console.error("An error occurred in listenRoom stream:", e);
socketManager.removeRoomListener(call, roomMessage.getRoomid()).catch((e) => console.error(e));
call.end();
});
},
adminRoom(call: AdminSocket): void {
console.log("adminRoom called");
const admin = new Admin(call);
let room: GameRoom | null = null;
call.on("data", (message: AdminPusherToBackMessage) => {
try {
if (room === null) {
if (message.hasSubscribetoroom()) {
const roomId = message.getSubscribetoroom();
socketManager
.handleJoinAdminRoom(admin, roomId)
.then((gameRoom: GameRoom) => {
room = gameRoom;
})
.catch((e) => console.error(e));
} else {
throw new Error("The first message sent MUST be of type JoinRoomMessage");
}
}
} catch (e) {
emitError(call, e);
call.end();
}
});
call.on("end", () => {
debug("joinRoom ended");
if (room !== null) {
socketManager.leaveAdminRoom(room, admin);
}
call.end();
room = null;
});
call.on("error", (err: Error) => {
console.error("An error occurred in joinAdminRoom stream:", err);
});
},
sendAdminMessage(call: ServerUnaryCall<AdminMessage>, callback: sendUnaryData<EmptyMessage>): void {
socketManager
.sendAdminMessage(
call.request.getRoomid(),
call.request.getRecipientuuid(),
call.request.getMessage(),
call.request.getType()
)
.catch((e) => console.error(e));
callback(null, new EmptyMessage());
},
sendGlobalAdminMessage(call: ServerUnaryCall<AdminGlobalMessage>, callback: sendUnaryData<EmptyMessage>): void {
throw new Error("Not implemented yet");
// TODO
callback(null, new EmptyMessage());
},
ban(call: ServerUnaryCall<BanMessage>, callback: sendUnaryData<EmptyMessage>): void {
// FIXME Work in progress
socketManager
.banUser(call.request.getRoomid(), call.request.getRecipientuuid(), call.request.getMessage())
.catch((e) => console.error(e));
callback(null, new EmptyMessage());
},
sendAdminMessageToRoom(call: ServerUnaryCall<AdminRoomMessage>, callback: sendUnaryData<EmptyMessage>): void {
// FIXME: we could improve return message by returning a Success|ErrorMessage message
socketManager
.sendAdminRoomMessage(call.request.getRoomid(), call.request.getMessage(), call.request.getType())
.catch((e) => console.error(e));
callback(null, new EmptyMessage());
},
sendWorldFullWarningToRoom(
call: ServerUnaryCall<WorldFullWarningToRoomMessage>,
callback: sendUnaryData<EmptyMessage>
): void {
// FIXME: we could improve return message by returning a Success|ErrorMessage message
socketManager.dispatchWorldFullWarning(call.request.getRoomid()).catch((e) => console.error(e));
callback(null, new EmptyMessage());
},
sendRefreshRoomPrompt(
call: ServerUnaryCall<RefreshRoomPromptMessage>,
callback: sendUnaryData<EmptyMessage>
): void {
// FIXME: we could improve return message by returning a Success|ErrorMessage message
socketManager.dispatchRoomRefresh(call.request.getRoomid()).catch((e) => console.error(e));
callback(null, new EmptyMessage());
},
};
export { roomManager };

View file

@ -1,13 +0,0 @@
import { App as _App, AppOptions } from "uWebSockets.js";
import BaseApp from "./baseapp";
import { extend } from "./utils";
import { UwsApp } from "./types";
class App extends (<UwsApp>_App) {
constructor(options: AppOptions = {}) {
super(options); // eslint-disable-line constructor-super
extend(this, new BaseApp());
}
}
export default App;

View file

@ -1,111 +0,0 @@
/* eslint-disable */
import { Readable } from "stream";
import { us_listen_socket_close, TemplatedApp, HttpResponse, HttpRequest } from "uWebSockets.js";
import formData from "./formdata";
import { stob } from "./utils";
import { Handler } from "./types";
import { join } from "path";
const contTypes = ["application/x-www-form-urlencoded", "multipart/form-data"];
const noOp = () => true;
const handleBody = (res: HttpResponse, req: HttpRequest) => {
const contType = req.getHeader("content-type");
res.bodyStream = function () {
const stream = new Readable();
stream._read = noOp; // eslint-disable-line @typescript-eslint/unbound-method
this.onData((ab: ArrayBuffer, isLast: boolean) => {
// uint and then slicing is bit faster than slice and then uint
stream.push(new Uint8Array(ab.slice((ab as any).byteOffset, ab.byteLength))); // eslint-disable-line @typescript-eslint/no-explicit-any
if (isLast) {
stream.push(null);
}
});
return stream;
};
res.body = () => stob(res.bodyStream());
if (contType.includes("application/json")) res.json = async () => JSON.parse(await res.body());
if (contTypes.map((t) => contType.includes(t)).includes(true)) res.formData = formData.bind(res, contType);
};
class BaseApp {
_sockets = new Map();
ws!: TemplatedApp["ws"];
get!: TemplatedApp["get"];
_post!: TemplatedApp["post"];
_put!: TemplatedApp["put"];
_patch!: TemplatedApp["patch"];
_listen!: TemplatedApp["listen"];
post(pattern: string, handler: Handler) {
if (typeof handler !== "function") throw Error(`handler should be a function, given ${typeof handler}.`);
this._post(pattern, (res, req) => {
handleBody(res, req);
handler(res, req);
});
return this;
}
put(pattern: string, handler: Handler) {
if (typeof handler !== "function") throw Error(`handler should be a function, given ${typeof handler}.`);
this._put(pattern, (res, req) => {
handleBody(res, req);
handler(res, req);
});
return this;
}
patch(pattern: string, handler: Handler) {
if (typeof handler !== "function") throw Error(`handler should be a function, given ${typeof handler}.`);
this._patch(pattern, (res, req) => {
handleBody(res, req);
handler(res, req);
});
return this;
}
listen(h: string | number, p: Function | number = noOp, cb?: Function) {
if (typeof p === "number" && typeof h === "string") {
this._listen(h, p, (socket) => {
this._sockets.set(p, socket);
if (cb === undefined) {
throw new Error("cb undefined");
}
cb(socket);
});
} else if (typeof h === "number" && typeof p === "function") {
this._listen(h, (socket) => {
this._sockets.set(h, socket);
p(socket);
});
} else {
throw Error("Argument types: (host: string, port: number, cb?: Function) | (port: number, cb?: Function)");
}
return this;
}
close(port: null | number = null) {
if (port) {
this._sockets.has(port) && us_listen_socket_close(this._sockets.get(port));
this._sockets.delete(port);
} else {
this._sockets.forEach((app) => {
us_listen_socket_close(app);
});
this._sockets.clear();
}
return this;
}
}
export default BaseApp;

View file

@ -1,101 +0,0 @@
/* eslint-disable */
import { createWriteStream } from "fs";
import { join, dirname } from "path";
import Busboy from "busboy";
import mkdirp from "mkdirp";
function formData(
contType: string,
options: busboy.BusboyConfig & {
abortOnLimit?: boolean;
tmpDir?: string;
onFile?: (
fieldname: string,
file: NodeJS.ReadableStream,
filename: string,
encoding: string,
mimetype: string
) => string;
onField?: (fieldname: string, value: any) => void; // eslint-disable-line @typescript-eslint/no-explicit-any
filename?: (oldName: string) => string;
} = {}
) {
console.log("Enter form data");
options.headers = {
"content-type": contType,
};
return new Promise((resolve, reject) => {
const busb = new Busboy(options);
const ret = {};
this.bodyStream().pipe(busb);
busb.on("limit", () => {
if (options.abortOnLimit) {
reject(Error("limit"));
}
});
busb.on("file", function (fieldname, file, filename, encoding, mimetype) {
const value: { filePath: string | undefined; filename: string; encoding: string; mimetype: string } = {
filename,
encoding,
mimetype,
filePath: undefined,
};
if (typeof options.tmpDir === "string") {
if (typeof options.filename === "function") filename = options.filename(filename);
const fileToSave = join(options.tmpDir, filename);
mkdirp(dirname(fileToSave));
file.pipe(createWriteStream(fileToSave));
value.filePath = fileToSave;
}
if (typeof options.onFile === "function") {
value.filePath = options.onFile(fieldname, file, filename, encoding, mimetype) || value.filePath;
}
setRetValue(ret, fieldname, value);
});
busb.on("field", function (fieldname, value) {
if (typeof options.onField === "function") options.onField(fieldname, value);
setRetValue(ret, fieldname, value);
});
busb.on("finish", function () {
resolve(ret);
});
busb.on("error", reject);
});
}
function setRetValue(
ret: { [x: string]: any }, // eslint-disable-line @typescript-eslint/no-explicit-any
fieldname: string,
value: { filename: string; encoding: string; mimetype: string; filePath?: string } | any // eslint-disable-line @typescript-eslint/no-explicit-any
) {
if (fieldname.endsWith("[]")) {
fieldname = fieldname.slice(0, fieldname.length - 2);
if (Array.isArray(ret[fieldname])) {
ret[fieldname].push(value);
} else {
ret[fieldname] = [value];
}
} else {
if (Array.isArray(ret[fieldname])) {
ret[fieldname].push(value);
} else if (ret[fieldname]) {
ret[fieldname] = [ret[fieldname], value];
} else {
ret[fieldname] = value;
}
}
}
export default formData;

View file

@ -1,13 +0,0 @@
import { SSLApp as _SSLApp, AppOptions } from "uWebSockets.js";
import BaseApp from "./baseapp";
import { extend } from "./utils";
import { UwsApp } from "./types";
class SSLApp extends (<UwsApp>_SSLApp) {
constructor(options: AppOptions) {
super(options); // eslint-disable-line constructor-super
extend(this, new BaseApp());
}
}
export default SSLApp;

View file

@ -1,11 +0,0 @@
import { AppOptions, TemplatedApp, HttpResponse, HttpRequest } from "uWebSockets.js";
export type UwsApp = {
(options: AppOptions): TemplatedApp;
new (options: AppOptions): TemplatedApp;
prototype: TemplatedApp;
};
export type Handler = (res: HttpResponse, req: HttpRequest) => void;
export {};

View file

@ -1,38 +0,0 @@
/* eslint-disable */
import { ReadStream } from "fs";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function extend(who: any, from: any, overwrite = true) {
const ownProps = Object.getOwnPropertyNames(Object.getPrototypeOf(from)).concat(Object.keys(from));
ownProps.forEach((prop) => {
if (prop === "constructor" || from[prop] === undefined) return;
if (who[prop] && overwrite) {
who[`_${prop}`] = who[prop];
}
if (typeof from[prop] === "function") who[prop] = from[prop].bind(who);
else who[prop] = from[prop];
});
}
function stob(stream: ReadStream): Promise<Buffer> {
return new Promise((resolve) => {
const buffers: Buffer[] = [];
stream.on("data", buffers.push.bind(buffers));
stream.on("end", () => {
switch (buffers.length) {
case 0:
resolve(Buffer.allocUnsafe(0));
break;
case 1:
resolve(buffers[0]);
break;
default:
resolve(Buffer.concat(buffers));
}
});
});
}
export { extend, stob };

View file

@ -1,19 +0,0 @@
import { parse } from "query-string";
import { HttpRequest } from "uWebSockets.js";
import App from "./server/app";
import SSLApp from "./server/sslapp";
import * as types from "./server/types";
const getQuery = (req: HttpRequest) => {
return parse(req.getQuery());
};
export { App, SSLApp, getQuery };
export * from "./server/types";
export default {
App,
SSLApp,
getQuery,
...types,
};

View file

@ -1,30 +0,0 @@
import { ADMIN_API_TOKEN, ADMIN_API_URL } from "../Enum/EnvironmentVariable";
import Axios from "axios";
import { isMapDetailsData, MapDetailsData } from "./AdminApi/MapDetailsData";
import { isRoomRedirect, RoomRedirect } from "./AdminApi/RoomRedirect";
class AdminApi {
async fetchMapDetails(playUri: string): Promise<MapDetailsData | RoomRedirect> {
if (!ADMIN_API_URL) {
return Promise.reject(new Error("No admin backoffice set!"));
}
const params: { playUri: string } = {
playUri,
};
const res = await Axios.get(ADMIN_API_URL + "/api/map", {
headers: { Authorization: `${ADMIN_API_TOKEN}` },
params,
});
if (!isMapDetailsData(res.data) && !isRoomRedirect(res.data)) {
console.error("Unexpected answer from the /api/map admin endpoint.", res.data);
throw new Error("Unexpected answer from the /api/map admin endpoint.");
}
return res.data;
}
}
export const adminApi = new AdminApi();

View file

@ -1,11 +0,0 @@
import * as tg from "generic-type-guard";
export const isCharacterTexture = new tg.IsInterface()
.withProperties({
id: tg.isNumber,
level: tg.isNumber,
url: tg.isString,
rights: tg.isString,
})
.get();
export type CharacterTexture = tg.GuardedType<typeof isCharacterTexture>;

View file

@ -1,21 +0,0 @@
import * as tg from "generic-type-guard";
import { isCharacterTexture } from "./CharacterTexture";
import { isAny, isNumber } from "generic-type-guard";
/*const isNumericEnum =
<T extends { [n: number]: string }>(vs: T) =>
(v: any): v is T =>
typeof v === "number" && v in vs;*/
export const isMapDetailsData = new tg.IsInterface()
.withProperties({
mapUrl: tg.isString,
policy_type: isNumber, //isNumericEnum(GameRoomPolicyTypes),
tags: tg.isArray(tg.isString),
textures: tg.isArray(isCharacterTexture),
})
.withOptionalProperties({
roomSlug: tg.isUnion(tg.isString, tg.isNull), // deprecated
})
.get();
export type MapDetailsData = tg.GuardedType<typeof isMapDetailsData>;

View file

@ -1,8 +0,0 @@
import * as tg from "generic-type-guard";
export const isRoomRedirect = new tg.IsInterface()
.withProperties({
redirectUrl: tg.isString,
})
.get();
export type RoomRedirect = tg.GuardedType<typeof isRoomRedirect>;

View file

@ -1,3 +0,0 @@
export const arrayIntersect = (array1: string[], array2: string[]): boolean => {
return array1.filter((value) => array2.includes(value)).length > 0;
};

View file

@ -1,32 +0,0 @@
import { EventEmitter } from "events";
const clientJoinEvent = "clientJoin";
const clientLeaveEvent = "clientLeave";
class ClientEventsEmitter extends EventEmitter {
emitClientJoin(clientUUid: string, roomId: string): void {
this.emit(clientJoinEvent, clientUUid, roomId);
}
emitClientLeave(clientUUid: string, roomId: string): void {
this.emit(clientLeaveEvent, clientUUid, roomId);
}
registerToClientJoin(callback: (clientUUid: string, roomId: string) => void): void {
this.on(clientJoinEvent, callback);
}
registerToClientLeave(callback: (clientUUid: string, roomId: string) => void): void {
this.on(clientLeaveEvent, callback);
}
unregisterFromClientJoin(callback: (clientUUid: string, roomId: string) => void): void {
this.removeListener(clientJoinEvent, callback);
}
unregisterFromClientLeave(callback: (clientUUid: string, roomId: string) => void): void {
this.removeListener(clientLeaveEvent, callback);
}
}
export const clientEventsEmitter = new ClientEventsEmitter();

View file

@ -1,55 +0,0 @@
import { CPU_OVERHEAT_THRESHOLD } from "../Enum/EnvironmentVariable";
function secNSec2ms(secNSec: Array<number> | number) {
if (Array.isArray(secNSec)) {
return secNSec[0] * 1000 + secNSec[1] / 1000000;
}
return secNSec / 1000;
}
class CpuTracker {
private cpuPercent: number = 0;
private overHeating: boolean = false;
constructor() {
let time = process.hrtime.bigint();
let usage = process.cpuUsage();
setInterval(() => {
const elapTime = process.hrtime.bigint();
const elapUsage = process.cpuUsage(usage);
usage = process.cpuUsage();
const elapTimeMS = elapTime - time;
const elapUserMS = secNSec2ms(elapUsage.user);
const elapSystMS = secNSec2ms(elapUsage.system);
this.cpuPercent = Math.round(((100 * (elapUserMS + elapSystMS)) / Number(elapTimeMS)) * 1000000);
time = elapTime;
if (!this.overHeating && this.cpuPercent > CPU_OVERHEAT_THRESHOLD) {
this.overHeating = true;
console.warn('CPU high threshold alert. Going in "overheat" mode');
} else if (this.overHeating && this.cpuPercent <= CPU_OVERHEAT_THRESHOLD) {
this.overHeating = false;
console.log('CPU is back to normal. Canceling "overheat" mode');
}
/*console.log('elapsed time ms: ', elapTimeMS)
console.log('elapsed user ms: ', elapUserMS)
console.log('elapsed system ms:', elapSystMS)
console.log('cpu percent: ', this.cpuPercent)*/
}, 100);
}
public getCpuPercent(): number {
return this.cpuPercent;
}
public isOverHeating(): boolean {
return this.overHeating;
}
}
const cpuTracker = new CpuTracker();
export { cpuTracker };

View file

@ -1,57 +0,0 @@
import { Counter, Gauge } from "prom-client";
//this class should manage all the custom metrics used by prometheus
class GaugeManager {
private nbClientsGauge: Gauge<string>;
private nbClientsPerRoomGauge: Gauge<string>;
private nbGroupsPerRoomGauge: Gauge<string>;
private nbGroupsPerRoomCounter: Counter<string>;
private nbRoomsGauge: Gauge<string>;
constructor() {
this.nbRoomsGauge = new Gauge({
name: "workadventure_nb_rooms",
help: "Number of active rooms",
});
this.nbClientsGauge = new Gauge({
name: "workadventure_nb_sockets",
help: "Number of connected sockets",
labelNames: [],
});
this.nbClientsPerRoomGauge = new Gauge({
name: "workadventure_nb_clients_per_room",
help: "Number of clients per room",
labelNames: ["room"],
});
this.nbGroupsPerRoomCounter = new Counter({
name: "workadventure_counter_groups_per_room",
help: "Counter of groups per room",
labelNames: ["room"],
});
this.nbGroupsPerRoomGauge = new Gauge({
name: "workadventure_nb_groups_per_room",
help: "Number of groups per room",
labelNames: ["room"],
});
}
incNbRoomGauge(): void {
this.nbRoomsGauge.inc();
}
decNbRoomGauge(): void {
this.nbRoomsGauge.dec();
}
incNbClientPerRoomGauge(roomId: string): void {
this.nbClientsGauge.inc();
this.nbClientsPerRoomGauge.inc({ room: roomId });
}
decNbClientPerRoomGauge(roomId: string): void {
this.nbClientsGauge.dec();
this.nbClientsPerRoomGauge.dec({ room: roomId });
}
}
export const gaugeManager = new GaugeManager();

View file

@ -1 +0,0 @@
export class LocalUrlError extends Error {}

View file

@ -1,70 +0,0 @@
import Axios from "axios";
import ipaddr from "ipaddr.js";
import { Resolver } from "dns";
import { promisify } from "util";
import { LocalUrlError } from "./LocalUrlError";
import { ITiledMap } from "@workadventure/tiled-map-type-guard";
import { isTiledMap } from "@workadventure/tiled-map-type-guard/dist";
import { STORE_VARIABLES_FOR_LOCAL_MAPS } from "../Enum/EnvironmentVariable";
class MapFetcher {
async fetchMap(mapUrl: string): Promise<ITiledMap> {
// Before trying to make the query, let's verify the map is actually on the open internet (and not a local test map)
if ((await this.isLocalUrl(mapUrl)) && !STORE_VARIABLES_FOR_LOCAL_MAPS) {
throw new LocalUrlError('URL for map "' + mapUrl + '" targets a local map');
}
// Note: mapUrl is provided by the client. A possible attack vector would be to use a rogue DNS server that
// returns local URLs. Alas, Axios cannot pin a URL to a given IP. So "isLocalUrl" and Axios.get could potentially
// target to different servers (and one could trick Axios.get into loading resources on the internal network
// despite isLocalUrl checking that.
// We can deem this problem not that important because:
// - We make sure we are only passing "GET" requests
// - The result of the query is never displayed to the end user
const res = await Axios.get(mapUrl, {
maxContentLength: 50 * 1024 * 1024, // Max content length: 50MB. Maps should not be bigger
timeout: 10000, // Timeout after 10 seconds
});
if (!isTiledMap(res.data)) {
//TODO fixme
//throw new Error("Invalid map format for map " + mapUrl);
console.error("Invalid map format for map " + mapUrl);
}
/* eslint-disable-next-line @typescript-eslint/no-unsafe-return */
return res.data;
}
/**
* Returns true if the domain name is localhost of *.localhost
* Returns true if the domain name resolves to an IP address that is "private" (like 10.x.x.x or 192.168.x.x)
*
* @private
*/
async isLocalUrl(url: string): Promise<boolean> {
const urlObj = new URL(url);
if (urlObj.hostname === "localhost" || urlObj.hostname.endsWith(".localhost")) {
return true;
}
let addresses = [];
if (!ipaddr.isValid(urlObj.hostname)) {
const resolver = new Resolver();
addresses = await promisify(resolver.resolve).bind(resolver)(urlObj.hostname);
} else {
addresses = [urlObj.hostname];
}
for (const address of addresses) {
const addr = ipaddr.parse(address);
if (addr.range() !== "unicast") {
return true;
}
}
return false;
}
}
export const mapFetcher = new MapFetcher();

View file

@ -1,74 +0,0 @@
import {
BatchMessage,
BatchToPusherMessage,
BatchToPusherRoomMessage,
ErrorMessage,
ServerToClientMessage,
SubToPusherMessage,
SubToPusherRoomMessage,
} from "../Messages/generated/messages_pb";
import { UserSocket } from "_Model/User";
import { RoomSocket, ZoneSocket } from "../RoomManager";
function getMessageFromError(error: unknown): string {
if (error instanceof Error) {
return error.message;
} else if (typeof error === "string") {
return error;
} else {
return "Unknown error";
}
}
export function emitError(Client: UserSocket, error: unknown): void {
const message = getMessageFromError(error);
const errorMessage = new ErrorMessage();
errorMessage.setMessage(message);
const serverToClientMessage = new ServerToClientMessage();
serverToClientMessage.setErrormessage(errorMessage);
//if (!Client.disconnecting) {
Client.write(serverToClientMessage);
//}
console.warn(message);
}
export function emitErrorOnRoomSocket(Client: RoomSocket, error: unknown): void {
console.error(error);
const message = getMessageFromError(error);
const errorMessage = new ErrorMessage();
errorMessage.setMessage(message);
const subToPusherRoomMessage = new SubToPusherRoomMessage();
subToPusherRoomMessage.setErrormessage(errorMessage);
const batchToPusherMessage = new BatchToPusherRoomMessage();
batchToPusherMessage.addPayload(subToPusherRoomMessage);
//if (!Client.disconnecting) {
Client.write(batchToPusherMessage);
//}
console.warn(message);
}
export function emitErrorOnZoneSocket(Client: ZoneSocket, error: unknown): void {
console.error(error);
const message = getMessageFromError(error);
const errorMessage = new ErrorMessage();
errorMessage.setMessage(message);
const subToPusherMessage = new SubToPusherMessage();
subToPusherMessage.setErrormessage(errorMessage);
const batchToPusherMessage = new BatchToPusherMessage();
batchToPusherMessage.addPayload(subToPusherMessage);
//if (!Client.disconnecting) {
Client.write(batchToPusherMessage);
//}
console.warn(message);
}

View file

@ -1,23 +0,0 @@
import { ClientOpts, createClient, RedisClient } from "redis";
import { REDIS_HOST, REDIS_PASSWORD, REDIS_PORT } from "../Enum/EnvironmentVariable";
let redisClient: RedisClient | null = null;
if (REDIS_HOST !== undefined) {
const config: ClientOpts = {
host: REDIS_HOST,
port: REDIS_PORT,
};
if (REDIS_PASSWORD) {
config.password = REDIS_PASSWORD;
}
redisClient = createClient(config);
redisClient.on("error", (err) => {
console.error("Error connecting to Redis:", err);
});
}
export { redisClient };

View file

@ -1,43 +0,0 @@
import { promisify } from "util";
import { RedisClient } from "redis";
import { VariablesRepositoryInterface } from "./VariablesRepositoryInterface";
/**
* Class in charge of saving/loading variables from the data store
*/
export class RedisVariablesRepository implements VariablesRepositoryInterface {
private readonly hgetall: OmitThisParameter<(arg1: string) => Promise<{ [p: string]: string }>>;
private readonly hset: OmitThisParameter<(arg1: [string, ...string[]]) => Promise<number>>;
private readonly hdel: OmitThisParameter<(arg1: string, arg2: string) => Promise<number>>;
constructor(private redisClient: RedisClient) {
/* eslint-disable @typescript-eslint/unbound-method */
this.hgetall = promisify(redisClient.hgetall).bind(redisClient);
this.hset = promisify(redisClient.hset).bind(redisClient);
this.hdel = promisify(redisClient.hdel).bind(redisClient);
/* eslint-enable @typescript-eslint/unbound-method */
}
/**
* Load all variables for a room.
*
* Note: in Redis, variables are stored in a hashmap and the key is the roomUrl
*/
async loadVariables(roomUrl: string): Promise<{ [key: string]: string }> {
return this.hgetall(roomUrl);
}
async saveVariable(roomUrl: string, key: string, value: string): Promise<number> {
// The value is passed to JSON.stringify client side. If value is "undefined", JSON.stringify returns "undefined"
// which is translated to empty string when fetching the value in the pusher.
// Therefore, empty string server side == undefined client side.
if (value === "") {
return this.hdel(roomUrl, key);
}
// TODO: SLOW WRITING EVERY 2 SECONDS WITH A TIMEOUT
// @ts-ignore See https://stackoverflow.com/questions/63539317/how-do-i-use-hmset-with-node-promisify
return this.hset(roomUrl, key, value);
}
}

View file

@ -1,14 +0,0 @@
import { RedisVariablesRepository } from "./RedisVariablesRepository";
import { redisClient } from "../RedisClient";
import { VoidVariablesRepository } from "./VoidVariablesRepository";
import { VariablesRepositoryInterface } from "./VariablesRepositoryInterface";
let variablesRepository: VariablesRepositoryInterface;
if (!redisClient) {
console.warn("WARNING: Redis isnot configured. No variables will be persisted.");
variablesRepository = new VoidVariablesRepository();
} else {
variablesRepository = new RedisVariablesRepository(redisClient);
}
export { variablesRepository };

View file

@ -1,10 +0,0 @@
export interface VariablesRepositoryInterface {
/**
* Load all variables for a room.
*
* Note: in Redis, variables are stored in a hashmap and the key is the roomUrl
*/
loadVariables(roomUrl: string): Promise<{ [key: string]: string }>;
saveVariable(roomUrl: string, key: string, value: string): Promise<number>;
}

View file

@ -1,14 +0,0 @@
import { VariablesRepositoryInterface } from "./VariablesRepositoryInterface";
/**
* Mock class in charge of NOT saving/loading variables from the data store
*/
export class VoidVariablesRepository implements VariablesRepositoryInterface {
loadVariables(roomUrl: string): Promise<{ [key: string]: string }> {
return Promise.resolve({});
}
saveVariable(roomUrl: string, key: string, value: string): Promise<number> {
return Promise.resolve(0);
}
}

View file

@ -1,874 +0,0 @@
import { GameRoom } from "../Model/GameRoom";
import {
ItemEventMessage,
ItemStateMessage,
PlayGlobalMessage,
PointMessage,
RoomJoinedMessage,
ServerToClientMessage,
SilentMessage,
SubMessage,
UserMovedMessage,
UserMovesMessage,
WebRtcDisconnectMessage,
WebRtcSignalToClientMessage,
WebRtcSignalToServerMessage,
WebRtcStartMessage,
QueryJitsiJwtMessage,
SendJitsiJwtMessage,
SendUserMessage,
JoinRoomMessage,
Zone as ProtoZone,
BatchToPusherMessage,
SubToPusherMessage,
UserJoinedZoneMessage,
GroupUpdateZoneMessage,
GroupLeftZoneMessage,
WorldFullWarningMessage,
UserLeftZoneMessage,
EmoteEventMessage,
BanUserMessage,
RefreshRoomMessage,
EmotePromptMessage,
FollowRequestMessage,
FollowConfirmationMessage,
FollowAbortMessage,
VariableMessage,
BatchToPusherRoomMessage,
SubToPusherRoomMessage,
SetPlayerDetailsMessage,
PlayerDetailsUpdatedMessage,
} from "../Messages/generated/messages_pb";
import { User, UserSocket } from "../Model/User";
import { ProtobufUtils } from "../Model/Websocket/ProtobufUtils";
import { Group } from "../Model/Group";
import { cpuTracker } from "./CpuTracker";
import {
GROUP_RADIUS,
JITSI_ISS,
MINIMUM_DISTANCE,
SECRET_JITSI_KEY,
TURN_STATIC_AUTH_SECRET,
} from "../Enum/EnvironmentVariable";
import { Movable } from "../Model/Movable";
import { PositionInterface } from "../Model/PositionInterface";
import Jwt from "jsonwebtoken";
import { JITSI_URL } from "../Enum/EnvironmentVariable";
import { clientEventsEmitter } from "./ClientEventsEmitter";
import { gaugeManager } from "./GaugeManager";
import { RoomSocket, ZoneSocket } from "../RoomManager";
import { Zone } from "_Model/Zone";
import Debug from "debug";
import { Admin } from "_Model/Admin";
import crypto from "crypto";
const debug = Debug("sockermanager");
function emitZoneMessage(subMessage: SubToPusherMessage, socket: ZoneSocket): void {
// TODO: should we batch those every 100ms?
const batchMessage = new BatchToPusherMessage();
batchMessage.addPayload(subMessage);
socket.write(batchMessage);
}
export class SocketManager {
//private rooms = new Map<string, GameRoom>();
// List of rooms in process of loading.
private roomsPromises = new Map<string, PromiseLike<GameRoom>>();
constructor() {
clientEventsEmitter.registerToClientJoin((clientUUid: string, roomId: string) => {
gaugeManager.incNbClientPerRoomGauge(roomId);
});
clientEventsEmitter.registerToClientLeave((clientUUid: string, roomId: string) => {
gaugeManager.decNbClientPerRoomGauge(roomId);
});
}
public async handleJoinRoom(
socket: UserSocket,
joinRoomMessage: JoinRoomMessage
): Promise<{ room: GameRoom; user: User }> {
//join new previous room
const { room, user } = await this.joinRoom(socket, joinRoomMessage);
if (!socket.writable) {
console.warn("Socket was aborted");
return {
room,
user,
};
}
const roomJoinedMessage = new RoomJoinedMessage();
roomJoinedMessage.setTagList(joinRoomMessage.getTagList());
roomJoinedMessage.setUserroomtoken(joinRoomMessage.getUserroomtoken());
for (const [itemId, item] of room.getItemsState().entries()) {
const itemStateMessage = new ItemStateMessage();
itemStateMessage.setItemid(itemId);
itemStateMessage.setStatejson(JSON.stringify(item));
roomJoinedMessage.addItem(itemStateMessage);
}
const variables = await room.getVariablesForTags(user.tags);
for (const [name, value] of variables.entries()) {
const variableMessage = new VariableMessage();
variableMessage.setName(name);
variableMessage.setValue(value);
roomJoinedMessage.addVariable(variableMessage);
}
roomJoinedMessage.setCurrentuserid(user.id);
const serverToClientMessage = new ServerToClientMessage();
serverToClientMessage.setRoomjoinedmessage(roomJoinedMessage);
socket.write(serverToClientMessage);
return {
room,
user,
};
}
handleUserMovesMessage(room: GameRoom, user: User, userMovesMessage: UserMovesMessage) {
const userMoves = userMovesMessage.toObject();
const position = userMovesMessage.getPosition();
// If CPU is high, let's drop messages of users moving (we will only dispatch the final position)
if (cpuTracker.isOverHeating() && userMoves.position?.moving === true) {
return;
}
if (position === undefined) {
throw new Error("Position not found in message");
}
const viewport = userMoves.viewport;
if (viewport === undefined) {
throw new Error("Viewport not found in message");
}
// update position in the world
room.updatePosition(user, ProtobufUtils.toPointInterface(position));
//room.setViewport(client, client.viewport);
}
handleSetPlayerDetails(room: GameRoom, user: User, playerDetailsMessage: SetPlayerDetailsMessage) {
room.updatePlayerDetails(user, playerDetailsMessage);
}
handleSilentMessage(room: GameRoom, user: User, silentMessage: SilentMessage) {
room.setSilent(user, silentMessage.getSilent());
}
handleItemEvent(room: GameRoom, user: User, itemEventMessage: ItemEventMessage) {
const itemEvent = ProtobufUtils.toItemEvent(itemEventMessage);
const subMessage = new SubMessage();
subMessage.setItemeventmessage(itemEventMessage);
// Let's send the event without using the SocketIO room.
// TODO: move this in the GameRoom class.
for (const user of room.getUsers().values()) {
user.emitInBatch(subMessage);
}
room.setItemState(itemEvent.itemId, itemEvent.state);
}
handleVariableEvent(room: GameRoom, user: User, variableMessage: VariableMessage): Promise<void> {
return room.setVariable(variableMessage.getName(), variableMessage.getValue(), user);
}
emitVideo(room: GameRoom, user: User, data: WebRtcSignalToServerMessage): void {
//send only at user
const remoteUser = room.getUsers().get(data.getReceiverid());
if (remoteUser === undefined) {
console.warn(
"While exchanging a WebRTC signal: client with id ",
data.getReceiverid(),
" does not exist. This might be a race condition."
);
return;
}
const webrtcSignalToClient = new WebRtcSignalToClientMessage();
webrtcSignalToClient.setUserid(user.id);
webrtcSignalToClient.setSignal(data.getSignal());
// TODO: only compute credentials if data.signal.type === "offer"
if (TURN_STATIC_AUTH_SECRET !== "") {
const { username, password } = this.getTURNCredentials(user.id.toString(), TURN_STATIC_AUTH_SECRET);
webrtcSignalToClient.setWebrtcusername(username);
webrtcSignalToClient.setWebrtcpassword(password);
}
const serverToClientMessage = new ServerToClientMessage();
serverToClientMessage.setWebrtcsignaltoclientmessage(webrtcSignalToClient);
//if (!client.disconnecting) {
remoteUser.socket.write(serverToClientMessage);
//}
}
emitScreenSharing(room: GameRoom, user: User, data: WebRtcSignalToServerMessage): void {
//send only at user
const remoteUser = room.getUsers().get(data.getReceiverid());
if (remoteUser === undefined) {
console.warn(
"While exchanging a WEBRTC_SCREEN_SHARING signal: client with id ",
data.getReceiverid(),
" does not exist. This might be a race condition."
);
return;
}
const webrtcSignalToClient = new WebRtcSignalToClientMessage();
webrtcSignalToClient.setUserid(user.id);
webrtcSignalToClient.setSignal(data.getSignal());
// TODO: only compute credentials if data.signal.type === "offer"
if (TURN_STATIC_AUTH_SECRET !== "") {
const { username, password } = this.getTURNCredentials(user.id.toString(), TURN_STATIC_AUTH_SECRET);
webrtcSignalToClient.setWebrtcusername(username);
webrtcSignalToClient.setWebrtcpassword(password);
}
const serverToClientMessage = new ServerToClientMessage();
serverToClientMessage.setWebrtcscreensharingsignaltoclientmessage(webrtcSignalToClient);
//if (!client.disconnecting) {
remoteUser.socket.write(serverToClientMessage);
//}
}
leaveRoom(room: GameRoom, user: User) {
// leave previous room and world
try {
//user leave previous world
room.leave(user);
if (room.isEmpty()) {
this.roomsPromises.delete(room.roomUrl);
gaugeManager.decNbRoomGauge();
debug('Room is empty. Deleting room "%s"', room.roomUrl);
}
} finally {
clientEventsEmitter.emitClientLeave(user.uuid, room.roomUrl);
console.log("A user left");
}
}
async getOrCreateRoom(roomId: string): Promise<GameRoom> {
//check and create new room
let roomPromise = this.roomsPromises.get(roomId);
if (roomPromise === undefined) {
roomPromise = GameRoom.create(
roomId,
(user: User, group: Group) => this.joinWebRtcRoom(user, group),
(user: User, group: Group) => this.disConnectedUser(user, group),
MINIMUM_DISTANCE,
GROUP_RADIUS,
(thing: Movable, fromZone: Zone | null, listener: ZoneSocket) =>
this.onZoneEnter(thing, fromZone, listener),
(thing: Movable, position: PositionInterface, listener: ZoneSocket) =>
this.onClientMove(thing, position, listener),
(thing: Movable, newZone: Zone | null, listener: ZoneSocket) =>
this.onClientLeave(thing, newZone, listener),
(emoteEventMessage: EmoteEventMessage, listener: ZoneSocket) =>
this.onEmote(emoteEventMessage, listener),
(playerDetailsUpdatedMessage: PlayerDetailsUpdatedMessage, listener: ZoneSocket) =>
this.onPlayerDetailsUpdated(playerDetailsUpdatedMessage, listener)
)
.then((gameRoom) => {
gaugeManager.incNbRoomGauge();
return gameRoom;
})
.catch((e) => {
this.roomsPromises.delete(roomId);
throw e;
});
this.roomsPromises.set(roomId, roomPromise);
}
return roomPromise;
}
private async joinRoom(
socket: UserSocket,
joinRoomMessage: JoinRoomMessage
): Promise<{ room: GameRoom; user: User }> {
const roomId = joinRoomMessage.getRoomid();
const room = await socketManager.getOrCreateRoom(roomId);
//join world
const user = room.join(socket, joinRoomMessage);
clientEventsEmitter.emitClientJoin(user.uuid, roomId);
console.log(new Date().toISOString() + " A user joined");
return { room, user };
}
private onZoneEnter(thing: Movable, fromZone: Zone | null, listener: ZoneSocket) {
if (thing instanceof User) {
const userJoinedZoneMessage = new UserJoinedZoneMessage();
if (!Number.isInteger(thing.id)) {
throw new Error(`clientUser.userId is not an integer ${thing.id}`);
}
userJoinedZoneMessage.setUserid(thing.id);
userJoinedZoneMessage.setUseruuid(thing.uuid);
userJoinedZoneMessage.setName(thing.name);
userJoinedZoneMessage.setCharacterlayersList(ProtobufUtils.toCharacterLayerMessages(thing.characterLayers));
userJoinedZoneMessage.setPosition(ProtobufUtils.toPositionMessage(thing.getPosition()));
userJoinedZoneMessage.setFromzone(this.toProtoZone(fromZone));
if (thing.visitCardUrl) {
userJoinedZoneMessage.setVisitcardurl(thing.visitCardUrl);
}
userJoinedZoneMessage.setCompanion(thing.companion);
if (thing.outlineColor === undefined) {
userJoinedZoneMessage.setHasoutline(false);
} else {
userJoinedZoneMessage.setHasoutline(true);
userJoinedZoneMessage.setOutlinecolor(thing.outlineColor);
}
const subMessage = new SubToPusherMessage();
subMessage.setUserjoinedzonemessage(userJoinedZoneMessage);
emitZoneMessage(subMessage, listener);
//listener.emitInBatch(subMessage);
} else if (thing instanceof Group) {
this.emitCreateUpdateGroupEvent(listener, fromZone, thing);
} else {
console.error("Unexpected type for Movable.");
}
}
private onClientMove(thing: Movable, position: PositionInterface, listener: ZoneSocket): void {
if (thing instanceof User) {
const userMovedMessage = new UserMovedMessage();
userMovedMessage.setUserid(thing.id);
userMovedMessage.setPosition(ProtobufUtils.toPositionMessage(thing.getPosition()));
const subMessage = new SubToPusherMessage();
subMessage.setUsermovedmessage(userMovedMessage);
emitZoneMessage(subMessage, listener);
//listener.emitInBatch(subMessage);
//console.log("Sending USER_MOVED event");
} else if (thing instanceof Group) {
this.emitCreateUpdateGroupEvent(listener, null, thing);
} else {
console.error("Unexpected type for Movable.");
}
}
private onClientLeave(thing: Movable, newZone: Zone | null, listener: ZoneSocket) {
if (thing instanceof User) {
this.emitUserLeftEvent(listener, thing.id, newZone);
} else if (thing instanceof Group) {
this.emitDeleteGroupEvent(listener, thing.getId(), newZone);
} else {
console.error("Unexpected type for Movable.");
}
}
private onEmote(emoteEventMessage: EmoteEventMessage, client: ZoneSocket) {
const subMessage = new SubToPusherMessage();
subMessage.setEmoteeventmessage(emoteEventMessage);
emitZoneMessage(subMessage, client);
}
private onPlayerDetailsUpdated(playerDetailsUpdatedMessage: PlayerDetailsUpdatedMessage, client: ZoneSocket) {
const subMessage = new SubToPusherMessage();
subMessage.setPlayerdetailsupdatedmessage(playerDetailsUpdatedMessage);
emitZoneMessage(subMessage, client);
}
private emitCreateUpdateGroupEvent(client: ZoneSocket, fromZone: Zone | null, group: Group): void {
const position = group.getPosition();
const pointMessage = new PointMessage();
pointMessage.setX(Math.floor(position.x));
pointMessage.setY(Math.floor(position.y));
const groupUpdateMessage = new GroupUpdateZoneMessage();
groupUpdateMessage.setGroupid(group.getId());
groupUpdateMessage.setPosition(pointMessage);
groupUpdateMessage.setGroupsize(group.getSize);
groupUpdateMessage.setFromzone(this.toProtoZone(fromZone));
const subMessage = new SubToPusherMessage();
subMessage.setGroupupdatezonemessage(groupUpdateMessage);
emitZoneMessage(subMessage, client);
//client.emitInBatch(subMessage);
}
private emitDeleteGroupEvent(client: ZoneSocket, groupId: number, newZone: Zone | null): void {
const groupDeleteMessage = new GroupLeftZoneMessage();
groupDeleteMessage.setGroupid(groupId);
groupDeleteMessage.setTozone(this.toProtoZone(newZone));
const subMessage = new SubToPusherMessage();
subMessage.setGroupleftzonemessage(groupDeleteMessage);
emitZoneMessage(subMessage, client);
//user.emitInBatch(subMessage);
}
private emitUserLeftEvent(client: ZoneSocket, userId: number, newZone: Zone | null): void {
const userLeftMessage = new UserLeftZoneMessage();
userLeftMessage.setUserid(userId);
userLeftMessage.setTozone(this.toProtoZone(newZone));
const subMessage = new SubToPusherMessage();
subMessage.setUserleftzonemessage(userLeftMessage);
emitZoneMessage(subMessage, client);
}
private toProtoZone(zone: Zone | null): ProtoZone | undefined {
if (zone !== null) {
const zoneMessage = new ProtoZone();
zoneMessage.setX(zone.x);
zoneMessage.setY(zone.y);
return zoneMessage;
}
return undefined;
}
private joinWebRtcRoom(user: User, group: Group) {
for (const otherUser of group.getUsers()) {
if (user === otherUser) {
continue;
}
// Let's send 2 messages: one to the user joining the group and one to the other user
const webrtcStartMessage1 = new WebRtcStartMessage();
webrtcStartMessage1.setUserid(otherUser.id);
webrtcStartMessage1.setInitiator(true);
if (TURN_STATIC_AUTH_SECRET !== "") {
const { username, password } = this.getTURNCredentials(
otherUser.id.toString(),
TURN_STATIC_AUTH_SECRET
);
webrtcStartMessage1.setWebrtcusername(username);
webrtcStartMessage1.setWebrtcpassword(password);
}
const serverToClientMessage1 = new ServerToClientMessage();
serverToClientMessage1.setWebrtcstartmessage(webrtcStartMessage1);
user.socket.write(serverToClientMessage1);
const webrtcStartMessage2 = new WebRtcStartMessage();
webrtcStartMessage2.setUserid(user.id);
webrtcStartMessage2.setInitiator(false);
if (TURN_STATIC_AUTH_SECRET !== "") {
const { username, password } = this.getTURNCredentials(user.id.toString(), TURN_STATIC_AUTH_SECRET);
webrtcStartMessage2.setWebrtcusername(username);
webrtcStartMessage2.setWebrtcpassword(password);
}
const serverToClientMessage2 = new ServerToClientMessage();
serverToClientMessage2.setWebrtcstartmessage(webrtcStartMessage2);
otherUser.socket.write(serverToClientMessage2);
}
}
/**
* Computes a unique user/password for the TURN server, using a shared secret between the WorkAdventure API server
* and the Coturn server.
* The Coturn server should be initialized with parameters: `--use-auth-secret --static-auth-secret=MySecretKey`
*/
private getTURNCredentials(name: string, secret: string): { username: string; password: string } {
const unixTimeStamp = Math.floor(Date.now() / 1000) + 4 * 3600; // this credential would be valid for the next 4 hours
const username = [unixTimeStamp, name].join(":");
const hmac = crypto.createHmac("sha1", secret);
hmac.setEncoding("base64");
hmac.write(username);
hmac.end();
const password = hmac.read() as string;
return {
username: username,
password: password,
};
}
//disconnect user
private disConnectedUser(user: User, group: Group) {
// Most of the time, sending a disconnect event to one of the players is enough (the player will close the connection
// which will be shut for the other player).
// However! In the rare case where the WebRTC connection is not yet established, if we close the connection on one of the player,
// the other player will try connecting until a timeout happens (during this time, the connection icon will be displayed for nothing).
// So we also send the disconnect event to the other player.
for (const otherUser of group.getUsers()) {
if (user === otherUser) {
continue;
}
const webrtcDisconnectMessage1 = new WebRtcDisconnectMessage();
webrtcDisconnectMessage1.setUserid(user.id);
const serverToClientMessage1 = new ServerToClientMessage();
serverToClientMessage1.setWebrtcdisconnectmessage(webrtcDisconnectMessage1);
//if (!otherUser.socket.disconnecting) {
otherUser.socket.write(serverToClientMessage1);
//}
const webrtcDisconnectMessage2 = new WebRtcDisconnectMessage();
webrtcDisconnectMessage2.setUserid(otherUser.id);
const serverToClientMessage2 = new ServerToClientMessage();
serverToClientMessage2.setWebrtcdisconnectmessage(webrtcDisconnectMessage2);
//if (!user.socket.disconnecting) {
user.socket.write(serverToClientMessage2);
//}
}
}
public getWorlds(): Map<string, PromiseLike<GameRoom>> {
return this.roomsPromises;
}
public handleQueryJitsiJwtMessage(user: User, queryJitsiJwtMessage: QueryJitsiJwtMessage) {
const room = queryJitsiJwtMessage.getJitsiroom();
const tag = queryJitsiJwtMessage.getTag(); // FIXME: this is not secure. We should load the JSON for the current room and check rights associated to room instead.
if (SECRET_JITSI_KEY === "") {
throw new Error("You must set the SECRET_JITSI_KEY key to the secret to generate JWT tokens for Jitsi.");
}
// Let's see if the current client has
const isAdmin = user.tags.includes(tag);
const jwt = Jwt.sign(
{
aud: "jitsi",
iss: JITSI_ISS,
sub: JITSI_URL,
room: room,
moderator: isAdmin,
},
SECRET_JITSI_KEY,
{
expiresIn: "1d",
algorithm: "HS256",
header: {
alg: "HS256",
typ: "JWT",
},
}
);
const sendJitsiJwtMessage = new SendJitsiJwtMessage();
sendJitsiJwtMessage.setJitsiroom(room);
sendJitsiJwtMessage.setJwt(jwt);
const serverToClientMessage = new ServerToClientMessage();
serverToClientMessage.setSendjitsijwtmessage(sendJitsiJwtMessage);
user.socket.write(serverToClientMessage);
}
public handleSendUserMessage(user: User, sendUserMessageToSend: SendUserMessage) {
const sendUserMessage = new SendUserMessage();
sendUserMessage.setMessage(sendUserMessageToSend.getMessage());
sendUserMessage.setType(sendUserMessageToSend.getType());
const serverToClientMessage = new ServerToClientMessage();
serverToClientMessage.setSendusermessage(sendUserMessage);
user.socket.write(serverToClientMessage);
}
public handlerBanUserMessage(room: GameRoom, user: User, banUserMessageToSend: BanUserMessage) {
const banUserMessage = new BanUserMessage();
banUserMessage.setMessage(banUserMessageToSend.getMessage());
banUserMessage.setType(banUserMessageToSend.getType());
const serverToClientMessage = new ServerToClientMessage();
serverToClientMessage.setSendusermessage(banUserMessage);
user.socket.write(serverToClientMessage);
setTimeout(() => {
// Let's leave the room now.
room.leave(user);
// Let's close the connection when the user is banned.
user.socket.end();
}, 10000);
}
public async addZoneListener(call: ZoneSocket, roomId: string, x: number, y: number): Promise<void> {
const room = await this.roomsPromises.get(roomId);
if (!room) {
throw new Error("In addZoneListener, could not find room with id '" + roomId + "'");
}
const things = room.addZoneListener(call, x, y);
const batchMessage = new BatchToPusherMessage();
for (const thing of things) {
if (thing instanceof User) {
const userJoinedMessage = new UserJoinedZoneMessage();
userJoinedMessage.setUserid(thing.id);
userJoinedMessage.setUseruuid(thing.uuid);
userJoinedMessage.setName(thing.name);
userJoinedMessage.setCharacterlayersList(ProtobufUtils.toCharacterLayerMessages(thing.characterLayers));
userJoinedMessage.setPosition(ProtobufUtils.toPositionMessage(thing.getPosition()));
if (thing.visitCardUrl) {
userJoinedMessage.setVisitcardurl(thing.visitCardUrl);
}
userJoinedMessage.setCompanion(thing.companion);
const subMessage = new SubToPusherMessage();
subMessage.setUserjoinedzonemessage(userJoinedMessage);
batchMessage.addPayload(subMessage);
} else if (thing instanceof Group) {
const groupUpdateMessage = new GroupUpdateZoneMessage();
groupUpdateMessage.setGroupid(thing.getId());
groupUpdateMessage.setPosition(ProtobufUtils.toPointMessage(thing.getPosition()));
const subMessage = new SubToPusherMessage();
subMessage.setGroupupdatezonemessage(groupUpdateMessage);
batchMessage.addPayload(subMessage);
} else {
console.error("Unexpected type for Movable returned by setViewport");
}
}
call.write(batchMessage);
}
async removeZoneListener(call: ZoneSocket, roomId: string, x: number, y: number): Promise<void> {
const room = await this.roomsPromises.get(roomId);
if (!room) {
throw new Error("In removeZoneListener, could not find room with id '" + roomId + "'");
}
room.removeZoneListener(call, x, y);
}
async addRoomListener(call: RoomSocket, roomId: string) {
const room = await this.getOrCreateRoom(roomId);
if (!room) {
throw new Error("In addRoomListener, could not find room with id '" + roomId + "'");
}
room.addRoomListener(call);
const batchMessage = new BatchToPusherRoomMessage();
call.write(batchMessage);
}
async removeRoomListener(call: RoomSocket, roomId: string) {
const room = await this.roomsPromises.get(roomId);
if (!room) {
throw new Error("In removeRoomListener, could not find room with id '" + roomId + "'");
}
room.removeRoomListener(call);
}
public async handleJoinAdminRoom(admin: Admin, roomId: string): Promise<GameRoom> {
const room = await socketManager.getOrCreateRoom(roomId);
room.adminJoin(admin);
return room;
}
public leaveAdminRoom(room: GameRoom, admin: Admin) {
room.adminLeave(admin);
if (room.isEmpty()) {
this.roomsPromises.delete(room.roomUrl);
gaugeManager.decNbRoomGauge();
debug('Room is empty. Deleting room "%s"', room.roomUrl);
}
}
public async sendAdminMessage(roomId: string, recipientUuid: string, message: string, type: string): Promise<void> {
const room = await this.roomsPromises.get(roomId);
if (!room) {
console.error(
"In sendAdminMessage, could not find room with id '" +
roomId +
"'. Maybe the room was closed a few milliseconds ago and there was a race condition?"
);
return;
}
const recipients = room.getUsersByUuid(recipientUuid);
if (recipients.length === 0) {
console.error(
"In sendAdminMessage, could not find user with id '" +
recipientUuid +
"'. Maybe the user left the room a few milliseconds ago and there was a race condition?"
);
return;
}
for (const recipient of recipients) {
const sendUserMessage = new SendUserMessage();
sendUserMessage.setMessage(message);
sendUserMessage.setType(type);
const serverToClientMessage = new ServerToClientMessage();
serverToClientMessage.setSendusermessage(sendUserMessage);
recipient.socket.write(serverToClientMessage);
}
}
public async banUser(roomId: string, recipientUuid: string, message: string): Promise<void> {
const room = await this.roomsPromises.get(roomId);
if (!room) {
console.error(
"In banUser, could not find room with id '" +
roomId +
"'. Maybe the room was closed a few milliseconds ago and there was a race condition?"
);
return;
}
const recipients = room.getUsersByUuid(recipientUuid);
if (recipients.length === 0) {
console.error(
"In banUser, could not find user with id '" +
recipientUuid +
"'. Maybe the user left the room a few milliseconds ago and there was a race condition?"
);
return;
}
for (const recipient of recipients) {
// Let's leave the room now.
room.leave(recipient);
const banUserMessage = new BanUserMessage();
banUserMessage.setMessage(message);
banUserMessage.setType("banned");
const serverToClientMessage = new ServerToClientMessage();
serverToClientMessage.setBanusermessage(banUserMessage);
// Let's close the connection when the user is banned.
recipient.socket.write(serverToClientMessage);
recipient.socket.end();
}
}
async sendAdminRoomMessage(roomId: string, message: string, type: string) {
const room = await this.roomsPromises.get(roomId);
if (!room) {
//todo: this should cause the http call to return a 500
console.error(
"In sendAdminRoomMessage, could not find room with id '" +
roomId +
"'. Maybe the room was closed a few milliseconds ago and there was a race condition?"
);
return;
}
room.getUsers().forEach((recipient) => {
const sendUserMessage = new SendUserMessage();
sendUserMessage.setMessage(message);
sendUserMessage.setType(type);
const clientMessage = new ServerToClientMessage();
clientMessage.setSendusermessage(sendUserMessage);
recipient.socket.write(clientMessage);
});
}
async dispatchWorldFullWarning(roomId: string): Promise<void> {
const room = await this.roomsPromises.get(roomId);
if (!room) {
//todo: this should cause the http call to return a 500
console.error(
"In dispatchWorldFullWarning, could not find room with id '" +
roomId +
"'. Maybe the room was closed a few milliseconds ago and there was a race condition?"
);
return;
}
room.getUsers().forEach((recipient) => {
const worldFullMessage = new WorldFullWarningMessage();
const clientMessage = new ServerToClientMessage();
clientMessage.setWorldfullwarningmessage(worldFullMessage);
recipient.socket.write(clientMessage);
});
}
async dispatchRoomRefresh(roomId: string): Promise<void> {
const room = await this.roomsPromises.get(roomId);
if (!room) {
return;
}
const versionNumber = room.incrementVersion();
room.getUsers().forEach((recipient) => {
const worldFullMessage = new RefreshRoomMessage();
worldFullMessage.setRoomid(roomId);
worldFullMessage.setVersionnumber(versionNumber);
const clientMessage = new ServerToClientMessage();
clientMessage.setRefreshroommessage(worldFullMessage);
recipient.socket.write(clientMessage);
});
}
handleEmoteEventMessage(room: GameRoom, user: User, emotePromptMessage: EmotePromptMessage) {
const emoteEventMessage = new EmoteEventMessage();
emoteEventMessage.setEmote(emotePromptMessage.getEmote());
emoteEventMessage.setActoruserid(user.id);
room.emitEmoteEvent(user, emoteEventMessage);
}
handleFollowRequestMessage(room: GameRoom, user: User, message: FollowRequestMessage) {
const clientMessage = new ServerToClientMessage();
clientMessage.setFollowrequestmessage(message);
room.sendToOthersInGroupIncludingUser(user, clientMessage);
}
handleFollowConfirmationMessage(room: GameRoom, user: User, message: FollowConfirmationMessage) {
const leader = room.getUserById(message.getLeader());
if (!leader) {
const message = `Could not follow user "{message.getLeader()}" in room "{room.roomUrl}".`;
console.info(message, "Maybe the user just left.");
return;
}
// By security, we look at the group leader. If the group leader is NOT the leader in the message,
// everybody should stop following the group leader (to avoid having 2 group leaders)
if (user?.group?.leader && user?.group?.leader !== leader) {
user?.group?.leader?.stopLeading();
}
leader.addFollower(user);
}
handleFollowAbortMessage(room: GameRoom, user: User, message: FollowAbortMessage) {
if (user.id === message.getLeader()) {
user?.group?.leader?.stopLeading();
} else {
// Forward message
const leader = room.getUserById(message.getLeader());
leader?.delFollower(user);
}
}
}
export const socketManager = new SocketManager();

View file

@ -1,9 +0,0 @@
/**
* Errors related to variable handling.
*/
export class VariableError extends Error {
constructor(message: string) {
super(message);
Object.setPrototypeOf(this, VariableError.prototype);
}
}

View file

@ -1,229 +0,0 @@
/**
* Handles variables shared between the scripting API and the server.
*/
import { ITiledMap, ITiledMapLayer, ITiledMapObject } from "@workadventure/tiled-map-type-guard/dist";
import { User } from "_Model/User";
import { variablesRepository } from "./Repository/VariablesRepository";
import { redisClient } from "./RedisClient";
import { VariableError } from "./VariableError";
interface Variable {
defaultValue?: string;
persist?: boolean;
readableBy?: string;
writableBy?: string;
}
export class VariablesManager {
/**
* The actual values of the variables for the current room
*/
private _variables = new Map<string, string>();
/**
* The list of variables that are allowed
*/
private variableObjects: Map<string, Variable> | undefined;
/**
* @param map The map can be "null" if it is hosted on a private network. In this case, we assume this is a test setup and bypass any server-side checks.
*/
constructor(private roomUrl: string, private map: ITiledMap | null) {
// We initialize the list of variable object at room start. The objects cannot be edited later
// (otherwise, this would cause a security issue if the scripting API can edit this list of objects)
if (map) {
this.variableObjects = VariablesManager.findVariablesInMap(map);
// Let's initialize default values
for (const [name, variableObject] of this.variableObjects.entries()) {
if (variableObject.defaultValue !== undefined) {
this._variables.set(name, variableObject.defaultValue);
}
}
}
}
/**
* Let's load data from the Redis backend.
*/
public async init(): Promise<VariablesManager> {
if (!this.shouldPersist()) {
return this;
}
const variables = await variablesRepository.loadVariables(this.roomUrl);
for (const key in variables) {
// Let's only set variables if they are in the map (if the map has changed, maybe stored variables do not exist anymore)
if (this.variableObjects) {
const variableObject = this.variableObjects.get(key);
if (variableObject === undefined) {
continue;
}
if (!variableObject.persist) {
continue;
}
}
this._variables.set(key, variables[key]);
}
return this;
}
/**
* Returns true if saving should be enabled, and false otherwise.
*
* Saving is enabled if REDIS_HOST is set
* unless we are editing a local map
* unless we are in dev mode in which case it is ok to save
*
* @private
*/
private shouldPersist(): boolean {
return redisClient !== null && (this.map !== null || process.env.NODE_ENV === "development");
}
private static findVariablesInMap(map: ITiledMap): Map<string, Variable> {
const objects = new Map<string, Variable>();
for (const layer of map.layers) {
this.recursiveFindVariablesInLayer(layer, objects);
}
return objects;
}
private static recursiveFindVariablesInLayer(layer: ITiledMapLayer, objects: Map<string, Variable>): void {
if (layer.type === "objectgroup") {
for (const object of layer.objects) {
if (object.type === "variable") {
if (object.template) {
console.warn(
'Warning, a variable object is using a Tiled "template". WorkAdventure does not support objects generated from Tiled templates.'
);
continue;
}
// We store a copy of the object (to make it immutable)
objects.set(object.name as string, this.iTiledObjectToVariable(object));
}
}
} else if (layer.type === "group") {
for (const innerLayer of layer.layers as ITiledMapLayer[]) {
this.recursiveFindVariablesInLayer(innerLayer, objects);
}
}
}
private static iTiledObjectToVariable(object: ITiledMapObject): Variable {
const variable: Variable = {};
if (object.properties) {
for (const property of object.properties) {
const value = property.value as unknown;
switch (property.name) {
case "default":
variable.defaultValue = JSON.stringify(value);
break;
case "persist":
if (typeof value !== "boolean") {
throw new Error('The persist property of variable "' + object.name + '" must be a boolean');
}
variable.persist = value;
break;
case "writableBy":
if (typeof value !== "string") {
throw new Error(
'The writableBy property of variable "' + object.name + '" must be a string'
);
}
if (value) {
variable.writableBy = value;
}
break;
case "readableBy":
if (typeof value !== "string") {
throw new Error(
'The readableBy property of variable "' + object.name + '" must be a string'
);
}
if (value) {
variable.readableBy = value;
}
break;
}
}
}
return variable;
}
/**
* Sets the variable.
*
* Returns who is allowed to read the variable (the readableby property) or "undefined" if anyone can read it.
* Also, returns "false" if the variable was not modified (because we set it to the value it already has)
*
* @param name
* @param value
* @param user
*/
setVariable(name: string, value: string, user: User): string | undefined | false {
let readableBy: string | undefined;
let variableObject: Variable | undefined;
if (this.variableObjects) {
variableObject = this.variableObjects.get(name);
if (variableObject === undefined) {
throw new VariableError(
'Trying to set a variable "' + name + '" that is not defined as an object in the map.'
);
}
if (variableObject.writableBy && !user.tags.includes(variableObject.writableBy)) {
throw new VariableError(
'Trying to set a variable "' +
name +
'". User "' +
user.name +
'" does not have sufficient permission. Required tag: "' +
variableObject.writableBy +
'". User tags: ' +
user.tags.join(", ") +
"."
);
}
readableBy = variableObject.readableBy;
}
// If the value is not modified, return false
if (this._variables.get(name) === value) {
return false;
}
this._variables.set(name, value);
if (variableObject !== undefined && variableObject.persist) {
variablesRepository
.saveVariable(this.roomUrl, name, value)
.catch((e) => console.error("Error while saving variable in Redis:", e));
}
return readableBy;
}
public getVariablesForTags(tags: string[]): Map<string, string> {
if (this.variableObjects === undefined) {
return this._variables;
}
const readableVariables = new Map<string, string>();
for (const [key, value] of this._variables.entries()) {
const variableObject = this.variableObjects.get(key);
if (variableObject === undefined) {
throw new Error('Unexpected variable "' + key + '" found has no associated variableObject.');
}
if (!variableObject.readableBy || tags.includes(variableObject.readableBy)) {
readableVariables.set(key, value);
}
}
return readableVariables;
}
}

View file

@ -1,14 +0,0 @@
import {arrayIntersect} from "../src/Services/ArrayHelper";
describe("RoomIdentifier", () => {
it("should return true on intersect", () => {
expect(arrayIntersect(['admin', 'user'], ['admin', 'superAdmin'])).toBe(true);
});
it("should be reflexive", () => {
expect(arrayIntersect(['admin', 'superAdmin'], ['admin', 'user'])).toBe(true);
});
it("should return false on non intersect", () => {
expect(arrayIntersect(['admin', 'user'], ['superAdmin'])).toBe(false);
});
})

View file

@ -1,148 +0,0 @@
import "jasmine";
import { ConnectCallback, DisconnectCallback, GameRoom } from "../src/Model/GameRoom";
import { Point } from "../src/Model/Websocket/MessageUserPosition";
import { Group } from "../src/Model/Group";
import { User, UserSocket } from "_Model/User";
import { JoinRoomMessage, PositionMessage } from "../src/Messages/generated/messages_pb";
import Direction = PositionMessage.Direction;
import { EmoteCallback } from "_Model/Zone";
function createMockUser(userId: number): User {
return {
userId,
} as unknown as User;
}
function createMockUserSocket(): UserSocket {
return {} as unknown as UserSocket;
}
function createJoinRoomMessage(uuid: string, x: number, y: number): JoinRoomMessage {
const positionMessage = new PositionMessage();
positionMessage.setX(x);
positionMessage.setY(y);
positionMessage.setDirection(Direction.DOWN);
positionMessage.setMoving(false);
const joinRoomMessage = new JoinRoomMessage();
joinRoomMessage.setUseruuid("1");
joinRoomMessage.setIpaddress("10.0.0.2");
joinRoomMessage.setName("foo");
joinRoomMessage.setRoomid("_/global/test.json");
joinRoomMessage.setPositionmessage(positionMessage);
return joinRoomMessage;
}
const emote: EmoteCallback = (emoteEventMessage, listener): void => {};
describe("GameRoom", () => {
it("should connect user1 and user2", async () => {
let connectCalledNumber: number = 0;
const connect: ConnectCallback = (user: User, group: Group): void => {
connectCalledNumber++;
};
const disconnect: DisconnectCallback = (user: User, group: Group): void => {};
const world = await GameRoom.create(
"https://play.workadventu.re/_/global/localhost/test.json",
connect,
disconnect,
160,
160,
() => {},
() => {},
() => {},
emote,
() => {}
);
const user1 = world.join(createMockUserSocket(), createJoinRoomMessage("1", 100, 100));
const user2 = world.join(createMockUserSocket(), createJoinRoomMessage("2", 500, 100));
world.updatePosition(user2, new Point(261, 100));
expect(connectCalledNumber).toBe(0);
world.updatePosition(user2, new Point(101, 100));
expect(connectCalledNumber).toBe(2);
world.updatePosition(user2, new Point(102, 100));
expect(connectCalledNumber).toBe(2);
});
it("should connect 3 users", async () => {
let connectCalled: boolean = false;
const connect: ConnectCallback = (user: User, group: Group): void => {
connectCalled = true;
};
const disconnect: DisconnectCallback = (user: User, group: Group): void => {};
const world = await GameRoom.create(
"https://play.workadventu.re/_/global/localhost/test.json",
connect,
disconnect,
160,
160,
() => {},
() => {},
() => {},
emote,
() => {}
);
const user1 = world.join(createMockUserSocket(), createJoinRoomMessage("1", 100, 100));
const user2 = world.join(createMockUserSocket(), createJoinRoomMessage("2", 200, 100));
expect(connectCalled).toBe(true);
connectCalled = false;
// baz joins at the outer limit of the group
const user3 = world.join(createMockUserSocket(), createJoinRoomMessage("2", 311, 100));
expect(connectCalled).toBe(false);
world.updatePosition(user3, new Point(309, 100));
expect(connectCalled).toBe(true);
});
it("should disconnect user1 and user2", async () => {
let connectCalled: boolean = false;
let disconnectCallNumber: number = 0;
const connect: ConnectCallback = (user: User, group: Group): void => {
connectCalled = true;
};
const disconnect: DisconnectCallback = (user: User, group: Group): void => {
disconnectCallNumber++;
};
const world = await GameRoom.create(
"https://play.workadventu.re/_/global/localhost/test.json",
connect,
disconnect,
160,
160,
() => {},
() => {},
() => {},
emote,
() => {}
);
const user1 = world.join(createMockUserSocket(), createJoinRoomMessage("1", 100, 100));
const user2 = world.join(createMockUserSocket(), createJoinRoomMessage("2", 259, 100));
expect(connectCalled).toBe(true);
expect(disconnectCallNumber).toBe(0);
world.updatePosition(user2, new Point(100 + 160 + 160 + 1, 100));
expect(disconnectCallNumber).toBe(2);
world.updatePosition(user2, new Point(262, 100));
expect(disconnectCallNumber).toBe(2);
});
});

View file

@ -1,32 +0,0 @@
import { arrayIntersect } from "../src/Services/ArrayHelper";
import { mapFetcher } from "../src/Services/MapFetcher";
describe("MapFetcher", () => {
it("should return true on localhost ending URLs", async () => {
expect(await mapFetcher.isLocalUrl("https://localhost")).toBeTrue();
expect(await mapFetcher.isLocalUrl("https://foo.localhost")).toBeTrue();
});
it("should return true on DNS resolving to a local domain", async () => {
expect(await mapFetcher.isLocalUrl("https://127.0.0.1.nip.io")).toBeTrue();
});
it("should return true on an IP resolving to a local domain", async () => {
expect(await mapFetcher.isLocalUrl("https://127.0.0.1")).toBeTrue();
expect(await mapFetcher.isLocalUrl("https://192.168.0.1")).toBeTrue();
});
it("should return false on an IP resolving to a global domain", async () => {
expect(await mapFetcher.isLocalUrl("https://51.12.42.42")).toBeFalse();
});
it("should return false on an DNS resolving to a global domain", async () => {
expect(await mapFetcher.isLocalUrl("https://maps.workadventu.re")).toBeFalse();
});
it("should throw error on invalid domain", async () => {
await expectAsync(
mapFetcher.isLocalUrl("https://this.domain.name.doesnotexistfoobgjkgfdjkgldf.com")
).toBeRejected();
});
});

View file

@ -1,10 +1,22 @@
import "jasmine";
import { PositionNotifier } from "../src/Model/PositionNotifier";
import { User, UserSocket } from "../src/Model/User";
import { Zone } from "_Model/Zone";
import { Movable } from "_Model/Movable";
import { PositionInterface } from "_Model/PositionInterface";
import { ZoneSocket } from "../src/RoomManager";
import {World, ConnectCallback, DisconnectCallback } from "../src/Model/World";
import {Point} from "../src/Model/Websocket/MessageUserPosition";
import { Group } from "../src/Model/Group";
import {PositionNotifier} from "../src/Model/PositionNotifier";
import {User} from "../src/Model/User";
import {PointInterface} from "../src/Model/Websocket/PointInterface";
import {Zone} from "_Model/Zone";
import {Movable} from "_Model/Movable";
import {PositionInterface} from "_Model/PositionInterface";
function move(user: User, x: number, y: number, positionNotifier: PositionNotifier): void {
positionNotifier.updatePosition(user, {
x,
y
}, user.position);
user.position.x = x;
user.position.y = y;
}
describe("PositionNotifier", () => {
it("should receive notifications when player moves", () => {
@ -12,86 +24,50 @@ describe("PositionNotifier", () => {
let moveTriggered = false;
let leaveTriggered = false;
const positionNotifier = new PositionNotifier(
300,
300,
(thing: Movable) => {
enterTriggered = true;
},
(thing: Movable, position: PositionInterface) => {
moveTriggered = true;
},
(thing: Movable) => {
leaveTriggered = true;
},
() => {},
() => {}
);
const positionNotifier = new PositionNotifier(300, 300, (thing: Movable) => {
enterTriggered = true;
}, (thing: Movable, position: PositionInterface) => {
moveTriggered = true;
}, (thing: Movable) => {
leaveTriggered = true;
});
const user1 = new User(
1,
"test",
"10.0.0.2",
{
x: 500,
y: 500,
moving: false,
direction: "down",
},
false,
positionNotifier,
{} as UserSocket,
[],
null,
"foo",
[]
);
const user1 = new User("1", {
x: 500,
y: 500,
moving: false,
direction: 'down'
}, false);
const user2 = new User(
2,
"test",
"10.0.0.2",
{
x: -9999,
y: -9999,
moving: false,
direction: "down",
},
false,
positionNotifier,
{} as UserSocket,
[],
null,
"foo",
[]
);
const user2 = new User("2", {
x: -9999,
y: -9999,
moving: false,
direction: 'down'
}, false);
positionNotifier.addZoneListener({} as ZoneSocket, 0, 0);
positionNotifier.addZoneListener({} as ZoneSocket, 0, 1);
positionNotifier.addZoneListener({} as ZoneSocket, 1, 1);
positionNotifier.addZoneListener({} as ZoneSocket, 1, 0);
/*positionNotifier.setViewport(user1, {
positionNotifier.setViewport(user1, {
left: 200,
right: 600,
top: 100,
bottom: 500
});*/
});
user2.setPosition({ x: 500, y: 500, direction: "down", moving: false });
move(user2, 500, 500, positionNotifier);
expect(enterTriggered).toBe(true);
expect(moveTriggered).toBe(false);
enterTriggered = false;
// Move inside the zone
user2.setPosition({ x: 501, y: 500, direction: "down", moving: false });
move(user2, 501, 500, positionNotifier);
expect(enterTriggered).toBe(false);
expect(moveTriggered).toBe(true);
moveTriggered = false;
// Move out of the zone in a zone that we don't track
user2.setPosition({ x: 901, y: 500, direction: "down", moving: false });
move(user2, 901, 500, positionNotifier);
expect(enterTriggered).toBe(false);
expect(moveTriggered).toBe(false);
@ -99,15 +75,22 @@ describe("PositionNotifier", () => {
leaveTriggered = false;
// Move back in
user2.setPosition({ x: 500, y: 500, direction: "down", moving: false });
move(user2, 500, 500, positionNotifier);
expect(enterTriggered).toBe(true);
expect(moveTriggered).toBe(false);
expect(leaveTriggered).toBe(false);
enterTriggered = false;
// Move out of the zone in a zone that we do track
move(user2, 200, 500, positionNotifier);
expect(enterTriggered).toBe(false);
expect(moveTriggered).toBe(true);
expect(leaveTriggered).toBe(false);
moveTriggered = false;
// Leave the room
positionNotifier.leave(user2);
//positionNotifier.removeViewport(user2);
positionNotifier.removeViewport(user2);
expect(enterTriggered).toBe(false);
expect(moveTriggered).toBe(false);
expect(leaveTriggered).toBe(true);
@ -119,128 +102,80 @@ describe("PositionNotifier", () => {
let moveTriggered = false;
let leaveTriggered = false;
const positionNotifier = new PositionNotifier(
300,
300,
(thing: Movable, fromZone: Zone | null) => {
enterTriggered = true;
},
(thing: Movable, position: PositionInterface) => {
moveTriggered = true;
},
(thing: Movable) => {
leaveTriggered = true;
},
() => {},
() => {}
);
const positionNotifier = new PositionNotifier(300, 300, (thing: Movable) => {
enterTriggered = true;
}, (thing: Movable, position: PositionInterface) => {
moveTriggered = true;
}, (thing: Movable) => {
leaveTriggered = true;
});
const user1 = new User(
1,
"test",
"10.0.0.2",
{
x: 500,
y: 500,
moving: false,
direction: "down",
},
false,
positionNotifier,
{} as UserSocket,
[],
null,
"foo",
[]
);
const user1 = new User("1", {
x: 500,
y: 500,
moving: false,
direction: 'down'
}, false);
const user2 = new User(
2,
"test",
"10.0.0.2",
{
x: 0,
y: 0,
moving: false,
direction: "down",
},
false,
positionNotifier,
{} as UserSocket,
[],
null,
"foo",
[]
);
const user2 = new User("2", {
x: -9999,
y: -9999,
moving: false,
direction: 'down'
}, false);
const listener = {} as ZoneSocket;
positionNotifier.addZoneListener(listener, 0, 0);
positionNotifier.addZoneListener(listener, 0, 1);
positionNotifier.addZoneListener(listener, 1, 1);
positionNotifier.addZoneListener(listener, 1, 0);
/*let newUsers = positionNotifier.setViewport(user1, {
let newUsers = positionNotifier.setViewport(user1, {
left: 200,
right: 600,
top: 100,
bottom: 500
});*/
positionNotifier.enter(user1);
positionNotifier.enter(user2);
});
//expect(newUsers.length).toBe(2);
expect(enterTriggered).toBe(true);
enterTriggered = false;
expect(newUsers.length).toBe(0);
//positionNotifier.updatePosition(user2, {x:500, y:500}, {x:0, y: 0})
user2.setPosition({ x: 500, y: 500, direction: "down", moving: false });
move(user2, 500, 500, positionNotifier);
expect(enterTriggered).toBe(true);
expect(moveTriggered).toBe(false);
expect(leaveTriggered).toBe(true);
enterTriggered = false;
leaveTriggered = false;
// Add a listener, but the user in not in this zone.
positionNotifier.addZoneListener(listener, 10, 10);
/*positionNotifier.setViewport(user1, {
// Move the viewport but the user stays inside.
positionNotifier.setViewport(user1, {
left: 201,
right: 601,
top: 100,
bottom: 500
});*/
});
expect(enterTriggered).toBe(false);
expect(moveTriggered).toBe(false);
expect(leaveTriggered).toBe(false);
// Stop listening to zone
positionNotifier.removeZoneListener(listener, 1, 1);
// Move the viewport out of the user.
/*positionNotifier.setViewport(user1, {
positionNotifier.setViewport(user1, {
left: 901,
right: 1001,
top: 100,
bottom: 500
});*/
});
expect(enterTriggered).toBe(false);
expect(moveTriggered).toBe(false);
expect(leaveTriggered).toBe(false);
expect(leaveTriggered).toBe(true);
leaveTriggered = false;
// Move the viewport back on the user.
positionNotifier.addZoneListener(listener, 1, 1);
/*newUsers = positionNotifier.setViewport(user1, {
newUsers = positionNotifier.setViewport(user1, {
left: 200,
right: 600,
top: 100,
bottom: 500
});*/
});
expect(enterTriggered).toBe(false);
expect(enterTriggered).toBe(true);
expect(moveTriggered).toBe(false);
expect(leaveTriggered).toBe(false);
enterTriggered = false;
//expect(newUsers.length).toBe(2);
expect(newUsers.length).toBe(1);
});
});
})

89
back/tests/WorldTest.ts Normal file
View file

@ -0,0 +1,89 @@
import "jasmine";
import {World, ConnectCallback, DisconnectCallback } from "../src/Model/World";
import {Point} from "../src/Model/Websocket/MessageUserPosition";
import { Group } from "../src/Model/Group";
describe("World", () => {
it("should connect user1 and user2", () => {
let connectCalledNumber: number = 0;
const connect: ConnectCallback = (user: string, group: Group): void => {
connectCalledNumber++;
}
const disconnect: DisconnectCallback = (user: string, group: Group): void => {
}
const world = new World(connect, disconnect, 160, 160, () => {}, () => {}, () => {});
world.join({ userId: "foo" }, new Point(100, 100));
world.join({ userId: "bar" }, new Point(500, 100));
world.updatePosition({ userId: "bar" }, new Point(261, 100));
expect(connectCalledNumber).toBe(0);
world.updatePosition({ userId: "bar" }, new Point(101, 100));
expect(connectCalledNumber).toBe(2);
world.updatePosition({ userId: "bar" }, new Point(102, 100));
expect(connectCalledNumber).toBe(2);
});
it("should connect 3 users", () => {
let connectCalled: boolean = false;
const connect: ConnectCallback = (user: string, group: Group): void => {
connectCalled = true;
}
const disconnect: DisconnectCallback = (user: string, group: Group): void => {
}
const world = new World(connect, disconnect, 160, 160, () => {}, () => {}, () => {});
world.join({ userId: "foo" }, new Point(100, 100));
world.join({ userId: "bar" }, new Point(200, 100));
expect(connectCalled).toBe(true);
connectCalled = false;
// baz joins at the outer limit of the group
world.join({ userId: "baz" }, new Point(311, 100));
expect(connectCalled).toBe(false);
world.updatePosition({ userId: "baz" }, new Point(309, 100));
expect(connectCalled).toBe(true);
});
it("should disconnect user1 and user2", () => {
let connectCalled: boolean = false;
let disconnectCallNumber: number = 0;
const connect: ConnectCallback = (user: string, group: Group): void => {
connectCalled = true;
}
const disconnect: DisconnectCallback = (user: string, group: Group): void => {
disconnectCallNumber++;
}
const world = new World(connect, disconnect, 160, 160, () => {}, () => {}, () => {});
world.join({ userId: "foo" }, new Point(100, 100));
world.join({ userId: "bar" }, new Point(259, 100));
expect(connectCalled).toBe(true);
expect(disconnectCallNumber).toBe(0);
world.updatePosition({ userId: "bar" }, new Point(100+160+160+1, 100));
expect(disconnectCallNumber).toBe(2);
world.updatePosition({ userId: "bar" }, new Point(262, 100));
expect(disconnectCallNumber).toBe(2);
});
})

View file

@ -1,67 +0,0 @@
import "jasmine";
import { getNearbyDescriptorsMatrix } from "../src/Model/PositionNotifier";
describe("getNearbyDescriptorsMatrix", () => {
it("should create a matrix of coordinates in a square around the parameter", () => {
const matrix = [];
for (const d of getNearbyDescriptorsMatrix({ i: 1, j: 1 })) {
matrix.push(d);
}
expect(matrix).toEqual([
{ i: 0, j: 0 },
{ i: 1, j: 0 },
{ i: 2, j: 0 },
{ i: 0, j: 1 },
{ i: 1, j: 1 },
{ i: 2, j: 1 },
{ i: 0, j: 2 },
{ i: 1, j: 2 },
{ i: 2, j: 2 },
]);
});
it("should create a matrix of coordinates in a square around the parameter bis", () => {
const matrix = [];
for (const d of getNearbyDescriptorsMatrix({ i: 8, j: 3 })) {
matrix.push(d);
}
expect(matrix).toEqual([
{ i: 7, j: 2 },
{ i: 8, j: 2 },
{ i: 9, j: 2 },
{ i: 7, j: 3 },
{ i: 8, j: 3 },
{ i: 9, j: 3 },
{ i: 7, j: 4 },
{ i: 8, j: 4 },
{ i: 9, j: 4 },
]);
});
it("should not create a matrix with negative coordinates", () => {
const matrix = [];
for (const d of getNearbyDescriptorsMatrix({ i: 0, j: 0 })) {
matrix.push(d);
}
expect(matrix).toEqual([
{ i: 0, j: 0 },
{ i: 1, j: 0 },
{ i: 0, j: 1 },
{ i: 1, j: 1 },
]);
});
/*it("should not create a matrix with coordinates bigger than its dimmensions", () => {
const matrix = getNearbyDescriptorsMatrix({i: 4, j: 4}, 5, 5);
expect(matrix).toEqual([
{i: 3,j: 3},
{i: 4,j: 3},
{i: 3,j: 4},
{i: 4,j: 4},
])
});*/
});

View file

@ -3,11 +3,11 @@
"experimentalDecorators": true,
/* Basic Options */
// "incremental": true, /* Enable incremental compilation */
"target": "ES2019", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */
"target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */
"downlevelIteration": true,
"module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
// "lib": [], /* Specify library files to be included in the compilation. */
"allowJs": true, /* Allow javascript files to be compiled. */
// "allowJs": true, /* Allow javascript files to be compiled. */
// "checkJs": true, /* Report errors in .js files. */
// "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
// "declaration": true, /* Generates corresponding '.d.ts' file. */
@ -31,7 +31,7 @@
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
// "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
"noImplicitThis": false, /* Raise error on 'this' expressions with an implied 'any' type. */ // Disabled because of sifrr server that is monkey patching HttpResponse
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
// "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
/* Additional Checks */

File diff suppressed because it is too large Load diff

View file

@ -1,3 +0,0 @@
/node_modules/
/artillery_output.html
/artillery_output.json

View file

@ -0,0 +1,17 @@
#!/bin/bash
npm run start &
pid1=$!
npm run start:nooutput &
pid2=$!
npm run start:nooutput &
pid3=$!
npm run start:nooutput &
pid4=$!
wait $pid1
wait $pid2
wait $pid3
wait $pid4

Some files were not shown because too many files have changed in this diff Show more