diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index a3ab9864..00000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,108 +0,0 @@ -version: 2.1 - -jobs: - client-suse-qt5-notests: - docker: - - image: kdeorg/ci-suse-qt515 - steps: - - run: - name: Install KArchive - command: zypper -n install karchive-devel - - checkout - - run: - name: Cmake - command: mkdir build && cd build && cmake .. -DBUILD_ALL=OFF -DBUILD_CLIENT=ON -DQT_MAJOR_VERSION=5 - - run: - name: Make - command: cd build && make cavoke_client - client-suse-qt6-notests: - docker: - - image: kdeorg/ci-suse-qt62 - steps: - - checkout - - run: - name: Install KArchive - command: chmod +x .circleci/suse-qt6-install-karchive.sh && .circleci/suse-qt6-install-karchive.sh - - run: - name: Cmake - command: mkdir build && cd build && cmake .. -DBUILD_ALL=OFF -DBUILD_CLIENT=ON -DQT_MAJOR_VERSION=6 # QT_MAJOR_VERSION=6 by default, so not necessary - - run: - name: Make - command: cd build && make cavoke_client - server-docker-healthcheck: - docker: - - image: ghcr.io/cavoke-project/cavoke_ci:drogon # TODO: use circleci orbs - auth: - username: $GHCR_USERNAME - password: $GHCR_PASSWORD - steps: - - checkout - - run: - name: Cmake - command: mkdir build && cd build && cmake .. -DBUILD_ALL=OFF -DBUILD_SERVER=ON - - run: - name: Make - command: cd build && make cavoke_server - - run: - name: Simple healthcheck - command: chmod +x .circleci/server-test-health.py && .circleci/server-test-health.py ./build/server/cavoke_server - games-docker: - docker: - - image: ghcr.io/cavoke-project/cavoke_ci:drogon # TODO: use circleci orbs - auth: - username: $GHCR_USERNAME - password: $GHCR_PASSWORD - steps: - - checkout - - run: - name: Cmake - command: mkdir build && cd build && cmake .. -DBUILD_ALL=OFF -DBUILD_GAMES=ON - - run: - name: Make - command: cd build && make all - clangformat-master: - docker: - - image: ubuntu - steps: - - run: - name: Install Clangformat - command: apt update && apt install clang-format -y - - checkout - - run: - name: Clangformat - command: for file in $(find . -iname '*.cpp' -or -iname '*.c' -or -iname '*.h' -and -not -iname 'doctest.h' ); do diff -u <(cat "$file") <(clang-format "$file") || exit 1; done - clangtidy-master: - docker: - - image: ubuntu - steps: - - run: - name: Install Clang-tidy - command: apt update && apt install clang-tidy-12 -y - - checkout - - run: - name: Clang-tidy - command: clang-tidy-12 -extra-arg=-Iinclude -extra-arg=-Idoctest $(find . -path '*/CMakeFiles/*' -prune -or \( -iname '*.cpp' -and -not -iname '*_test.cpp' -or -iname '*.h' -and -not -iname 'doctest.h' \) -print) - cppcheck-master: - docker: - - image: ubuntu - steps: - - run: - name: Install Cppcheck - command: apt update && apt install cppcheck -y - - checkout - - run: - name: Cppcheck - command: cppcheck --language=c++ -DSOME_DEFINE_TO_FIX_CONFIG --enable=all --suppress=*:doctest.h --suppress=unusedFunction --error-exitcode=1 --inline-suppr $(find . -iname '*.cpp') - -workflows: - app: - jobs: - - client-suse-qt5-notests - - client-suse-qt6-notests - - server-docker-healthcheck - - games-docker - codestyle: - jobs: - - clangformat-master -# - clangtidy-master - - cppcheck-master \ No newline at end of file diff --git a/.circleci/suse-qt6-install-karchive.sh b/.circleci/suse-qt6-install-karchive.sh deleted file mode 100644 index 59eb2063..00000000 --- a/.circleci/suse-qt6-install-karchive.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env sh -pushd $HOME -# Install ECM -zypper -n install extra-cmake-modules -# Prepare KArchive -git clone --branch v5.90.0 https://github.com/KDE/karchive # FIXME: don't use specific version: currently ECM conflicts -mkdir karchive/build -cd karchive -# Build -cd build -cmake .. -DCMAKE_BUILD_TYPE=debug -DCMAKE_INSTALL_PREFIX=/usr/local -DQT_MAJOR_VERSION=6 -make -make install -popd diff --git a/.craft.ini b/.craft.ini new file mode 100644 index 00000000..90cb2fb2 --- /dev/null +++ b/.craft.ini @@ -0,0 +1,62 @@ +[General] +Command = craft +Branch = master +ShallowClone = True + +# Variables defined here override the default value +# The variable names are casesensitive +[Variables] +#Values need to be overwritten to create a chache +UseCache = True +CreateCache = True + +[GeneralSettings] +Version/ConfigVersion = 6 + +Packager/Destination=${Variables:WORKSPACE}/binaries +Paths/Python = C:/Python36 +Paths/Python27 = C:/Python27 +Paths/DownloadDir = ${Variables:Root}/downloads +ShortPath/Enabled = False +Compile/BuildType = Release +ContinuousIntegration/Enabled = True +Packager/PackageDebugSymbols = False + +Packager/CacheDir = ${Variables:WORKSPACE}/cache +Packager/UseCache = ${Variables:UseCache} +Packager/CreateCache = ${Variables:CreateCache} + +[windows-msvc2019_64-cl] +General/ABI = windows-msvc2019_64-cl + +[windows-msvc2019_64-cl-debug] +General/ABI = windows-msvc2019_64-cl +Compile/BuildType = Debug + +[windows-msvc2019_32-cl] +General/ABI = windows-msvc2019_32-cl + +[macos-64-clang] +General/ABI = macos-64-clang +# Packager/PackageType = MacPkgPackager + +[macos-64-clang-debug] +General/ABI = macos-64-clang +Compile/BuildType = Debug + +[linux-64-gcc] +General/ABI = linux-64-gcc + +[linux-64-gcc-BlueprintSettings] +libs/dbus.ignored = False +libs/qt5/qtbase.withDBus = True +dev-utils/7zip.useCentosBasedBuild=True + +[linux-64-gcc-debug] +General/ABI = linux-64-gcc +Compile/BuildType = Debug + +[linux-64-gcc-debug-BlueprintSettings] +libs/dbus.ignored = False +libs/qt5/qtbase.withDBus = True +dev-utils/7zip.useCentosBasedBuild=True diff --git a/.craft.shelf b/.craft.shelf new file mode 100644 index 00000000..1b7ce862 --- /dev/null +++ b/.craft.shelf @@ -0,0 +1,3 @@ +[General] +version = 2 +blueprintrepositories = https://github.com/cavoke-project/craft-blueprints-cavoke.git|master|;https://invent.kde.org/packaging/craft-blueprints-kde.git|master| \ No newline at end of file diff --git a/.github/assets/cavoke-demo.gif b/.github/assets/cavoke-demo.gif new file mode 100644 index 00000000..48a5bdc6 Binary files /dev/null and b/.github/assets/cavoke-demo.gif differ diff --git a/.github/files/ci_server_config.json b/.github/files/ci_server_config.json new file mode 100644 index 00000000..41415339 --- /dev/null +++ b/.github/files/ci_server_config.json @@ -0,0 +1,151 @@ +/* This is a JSON format configuration file + */ +{ + //custom_config: custom configuration for users. This object can be acquired by the app().getCustomConfig() method. + "custom_config": { + "storage": { + "games_directory": "local_server/games", + "logic_name": "logic", + "zip_name": "client.zip", + "config_name": "config.json" + } + }, + "db_clients": [ + { + //name: Name of the client,'default' by default + "name": "default", + //rdbms: Server type, postgresql,mysql or sqlite3, "postgresql" by default + "rdbms": "postgresql", + //filename: Sqlite3 db file name + //"filename":"", + //host: Server address,localhost by default + "host": "127.0.0.1", + //port: Server port, 5432 by default + "port": 5432, + //dbname: Database name + "dbname": "cavoke", + //user: 'postgres' by default + "user": "postgres_user", + //passwd: '' by default + "passwd": "postgres_password", + //is_fast: false by default, if it is true, the client is faster but user can't call + //any synchronous interface of it. + "is_fast": false, + //client_encoding: The character set used by the client. it is empty string by default which + //means use the default character set. + //"client_encoding": "", + //number_of_connections: 1 by default, if the 'is_fast' is true, the number is the number of + //connections per IO thread, otherwise it is the total number of all connections. + "number_of_connections": 1, + //timeout: -1.0 by default, in seconds, the timeout for executing a SQL query. + //zero or negative value means no timeout. + "timeout": -1.0 + } + ], + "app": { + //number_of_threads: The number of IO threads, 1 by default, if the value is set to 0, the number of threads + //is the number of CPU cores + "number_of_threads": 1, + //enable_session: False by default + "enable_session": false, + "session_timeout": 0, + //max_connections: maximum number of connections, 100000 by default + "max_connections": 100000, + //max_connections_per_ip: maximum number of connections per client, 0 by default which means no limit + "max_connections_per_ip": 0, + //Load_dynamic_views: False by default, when set to true, drogon + //compiles and loads dynamically "CSP View Files" in directories defined + //by "dynamic_views_path" + "load_dynamic_views": false, + //dynamic_views_path: If the path isn't prefixed with /, ./ or ../, + //it is relative path of document_root path + "dynamic_views_path": [ + "./views" + ], + //dynamic_views_output_path: Default by an empty string which means the output path of source + //files is the path where the csp files locate. If the path isn't prefixed with /, it is relative + //path of the current working directory. + "dynamic_views_output_path": "", + //enable_unicode_escaping_in_json: true by default, enable unicode escaping in json. + "enable_unicode_escaping_in_json": true, + //float_precision_in_json: set precision of float number in json. + "float_precision_in_json": { + //precision: 0 by default, 0 means use the default precision of the jsoncpp lib. + "precision": 0, + //precision_type: must be "significant" or "decimal", defaults to "significant" that means + //setting max number of significant digits in string, "decimal" means setting max number of + //digits after "." in string + "precision_type": "significant" + }, + //log: Set log output, drogon output logs to stdout by default + "log": { + //log_path: Log file path,empty by default,in which case,logs are output to the stdout + "log_path": "./", + //logfile_base_name: Log file base name,empty by default which means drogon names logfile as + //drogon.log ... + "logfile_base_name": "cavoke-server", + //log_size_limit: 100000000 bytes by default, + //When the log file size reaches "log_size_limit", the log file is switched. + "log_size_limit": 100000000, + //log_level: "DEBUG" by default,options:"TRACE","DEBUG","INFO","WARN" + //The TRACE level is only valid when built in DEBUG mode. + "log_level": "DEBUG" + }, + //run_as_daemon: False by default + "run_as_daemon": false, + //handle_sig_term: True by default + "handle_sig_term": true, + //relaunch_on_error: False by default, if true, the program will be restarted by the parent after exiting; + "relaunch_on_error": false, + //use_sendfile: True by default, if true, the program + //uses sendfile() system-call to send static files to clients; + "use_sendfile": true, + //use_gzip: True by default, use gzip to compress the response body's content; + "use_gzip": true, + //use_brotli: False by default, use brotli to compress the response body's content; + "use_brotli": false, + //static_files_cache_time: 5 (seconds) by default, the time in which the static file response is cached, + //0 means cache forever, the negative value means no cache + "static_files_cache_time": 5, + //idle_connection_timeout: Defaults to 60 seconds, the lifetime + //of the connection without read or write + "idle_connection_timeout": 60, + //server_header_field: Set the 'Server' header field in each response sent by drogon, + //empty string by default with which the 'Server' header field is set to "Server: drogon/version string\r\n" + "server_header_field": "", + //enable_server_header: Set true to force drogon to add a 'Server' header to each HTTP response. The default + //value is true. + "enable_server_header": true, + //enable_date_header: Set true to force drogon to add a 'Date' header to each HTTP response. The default + //value is true. + "enable_date_header": true, + //keepalive_requests: Set the maximum number of requests that can be served through one keep-alive connection. + //After the maximum number of requests are made, the connection is closed. + //The default value of 0 means no limit. + "keepalive_requests": 0, + //pipelining_requests: Set the maximum number of unhandled requests that can be cached in pipelining buffer. + //After the maximum number of requests are made, the connection is closed. + //The default value of 0 means no limit. + "pipelining_requests": 0, + //gzip_static: If it is set to true, when the client requests a static file, drogon first finds the compressed + //file with the extension ".gz" in the same path and send the compressed file to the client. + //The default value of gzip_static is true. + "gzip_static": true, + //br_static: If it is set to true, when the client requests a static file, drogon first finds the compressed + //file with the extension ".br" in the same path and send the compressed file to the client. + //The default value of br_static is true. + "br_static": true, + //client_max_body_size: Set the maximum body size of HTTP requests received by drogon. The default value is "1M". + //One can set it to "1024", "1k", "10M", "1G", etc. Setting it to "" means no limit. + "client_max_body_size": "1M", + //max_memory_body_size: Set the maximum body size in memory of HTTP requests received by drogon. The default value is "64K" bytes. + //If the body size of an HTTP request exceeds this limit, the body is stored to a temporary file for processing. + //Setting it to "" means no limit. + "client_max_memory_body_size": "64K", + //client_max_websocket_message_size: Set the maximum size of messages sent by WebSocket client. The default value is "128K". + //One can set it to "1024", "1k", "10M", "1G", etc. Setting it to "" means no limit. + "client_max_websocket_message_size": "128K", + //reuse_port: Defaults to false, users can run multiple processes listening on the same port at the same time. + "reuse_port": false + } +} diff --git a/.github/scripts/.craft.ps1 b/.github/scripts/.craft.ps1 new file mode 100644 index 00000000..8925247f --- /dev/null +++ b/.github/scripts/.craft.ps1 @@ -0,0 +1,13 @@ +if ($IsWindows) { + $python = (Get-Command py).Source +} else { + $python = (Get-Command python3).Source +} +$command = @("${env:HOME}/craft/CraftMaster/CraftMaster/CraftMaster.py", + "--config", "${env:GITHUB_WORKSPACE}/.craft.ini", + "--target", "${env:CRAFT_TARGET}", + "--variables", "WORKSPACE=${env:HOME}/craft") + $args + +Write-Host "Exec: ${python} ${command}" + +& $python @command \ No newline at end of file diff --git a/.github/scripts/diff-clangformat-12.sh b/.github/scripts/diff-clangformat-12.sh new file mode 100644 index 00000000..08fb2202 --- /dev/null +++ b/.github/scripts/diff-clangformat-12.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +diff -u --from-file "$1" <(clang-format-12 "$1") && exit 0 || exit 1 diff --git a/.circleci/server-test-health.py b/.github/scripts/server-test-health.py similarity index 76% rename from .circleci/server-test-health.py rename to .github/scripts/server-test-health.py index 254efcd8..493299d0 100644 --- a/.circleci/server-test-health.py +++ b/.github/scripts/server-test-health.py @@ -14,13 +14,13 @@ def check_eq(expected, actual): def main(): - _, *server_cmd = sys.argv - assert server_cmd, 'Expected usage: ./server-test-health.py ' + server_cmds = sys.argv[1:] + assert server_cmds, 'Expected usage: ./server-test-health.py ' port = 8080 - print(f'Booting server... at {server_cmd}', flush=True) + print(f'Booting server... at {server_cmds[0]}', flush=True) - server = subprocess.Popen(args=[*server_cmd, '-p', str(port)]) + server = subprocess.Popen(args=server_cmds + ['-p', str(port)]) def kill_server(): try: server.wait(timeout=0.1) diff --git a/.github/workflows/app.yml b/.github/workflows/app.yml new file mode 100644 index 00000000..1a0f0c5b --- /dev/null +++ b/.github/workflows/app.yml @@ -0,0 +1,14 @@ +name: Cavoke +on: + push +jobs: + tests: + uses: ./.github/workflows/tests.yml + bundle-client: + needs: [tests] + uses: ./.github/workflows/bundle-client-craft.yml + deploy-server-develop: + if: github.ref == 'refs/heads/develop' + needs: [tests] + uses: ./.github/workflows/deploy-server-develop.yml + secrets: inherit diff --git a/.github/workflows/bundle-client-craft.yml b/.github/workflows/bundle-client-craft.yml new file mode 100644 index 00000000..ce4dd53d --- /dev/null +++ b/.github/workflows/bundle-client-craft.yml @@ -0,0 +1,113 @@ +# Originally from: https://raw.githubusercontent.com/owncloud/client/9ef42e4faa56367d99e31b5cc3e5f91da9b865f7/.github/workflows/main.yml + +name: Bundle Client +on: workflow_call + +defaults: + run: + shell: pwsh + +jobs: + build: + strategy: + matrix: + include: + - name: Windows 64-bit cl + target: windows-msvc2019_64-cl + os: windows-latest + fetch-depth: 0 + container: + - name: macOS 64-bit clang + target: macos-64-clang + os: macos-latest + fetch-depth: 1 + container: + - name: CentOS 7 64-bit gcc + target: linux-64-gcc + os: ubuntu-latest + container: kdeorg/centos7-craft + fetch-depth: 1 + fail-fast: false + + name: ${{ matrix.name }} + + runs-on: ${{ matrix.os }} + + env: + CRAFT_TARGET: ${{ matrix.target }} + + container: ${{ matrix.container }} + + steps: + - name: Check out source code + uses: actions/checkout@v2 + with: + submodules: recursive + + - name: Restore cache + uses: actions/cache@v2 + with: + path: ~/craft/cache + key: ${{ runner.os }}-${{ matrix.target }}-v3 + + - name: Clone CraftMaster + run: git clone --depth=1 https://invent.kde.org/kde/craftmaster.git "$env:HOME/craft/CraftMaster/CraftMaster" + + - name: Craft setup + run: | + New-Item -Path ~/cache -ItemType Directory -ErrorAction SilentlyContinue + & "${env:GITHUB_WORKSPACE}/.github/scripts/.craft.ps1" --setup + - name: Craft unshelve + run: | + & "${env:GITHUB_WORKSPACE}/.github/scripts/.craft.ps1" -c --unshelve "${env:GITHUB_WORKSPACE}/.craft.shelf" + - name: Prepare + run: | + if ($IsWindows) { + & "${env:GITHUB_WORKSPACE}/.github/scripts/.craft.ps1" -c dev-utils/nsis + } elseif($IsLinux) { + & "${env:GITHUB_WORKSPACE}/.github/scripts/.craft.ps1" -c dev-utils/linuxdeploy + & "${env:GITHUB_WORKSPACE}/.github/scripts/.craft.ps1" -c --set enableLibcloudproviders=true cavoke + } + - name: Install dependencies + run: | + & "${env:GITHUB_WORKSPACE}/.github/scripts/.craft.ps1" -c --install-deps cavoke + - name: Build + run: | + & "${env:GITHUB_WORKSPACE}/.github/scripts/.craft.ps1" -c --no-cache --src-dir "${env:GITHUB_WORKSPACE}" cavoke + - name: Package + run: | + & "${env:GITHUB_WORKSPACE}/.github/scripts/.craft.ps1" -c --no-cache --src-dir "${env:GITHUB_WORKSPACE}" --package cavoke + - name: Prepare artifacts + run: | + New-Item -ItemType Directory "${env:GITHUB_WORKSPACE}/binaries/" -ErrorAction SilentlyContinue + Copy-Item "$env:HOME/craft/binaries/*" "${env:GITHUB_WORKSPACE}/binaries/" + & "${env:GITHUB_WORKSPACE}/.github/scripts/.craft.ps1" -c --shelve "${env:GITHUB_WORKSPACE}/.craft.shelf" + Copy-Item "${env:GITHUB_WORKSPACE}/.craft.shelf" "${env:GITHUB_WORKSPACE}/binaries/" + - name: Upload artifacts + uses: actions/upload-artifact@v2 + with: + name: ${{ matrix.name }} + path: ${{ github.workspace }}/binaries/* + create-release: + name: Create Release + needs: [ build ] + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') + steps: + - name: Download all workflow run artifacts + uses: actions/download-artifact@v3 + - name: Rename files + run: | + find -name '*.exe' -exec mv '{}' './cavoke-${{ github.ref_name }}-windows.exe' ';' + find -name '*.dmg' -exec mv '{}' './cavoke-${{ github.ref_name }}-macos.dmg' ';' + find -name '*.AppImage' -exec mv '{}' './cavoke-${{ github.ref_name }}-linux.AppImage' ';' + - name: GitHub Release + uses: softprops/action-gh-release@v1 + with: + name: Release ${{ github.ref_name }} + draft: true + files: | + cavoke-*-windows.exe + cavoke-*-macos.dmg + cavoke-*-linux.AppImage + fail_on_unmatched_files: true diff --git a/.github/workflows/deploy-server-develop.yml b/.github/workflows/deploy-server-develop.yml new file mode 100644 index 00000000..62f0c2fc --- /dev/null +++ b/.github/workflows/deploy-server-develop.yml @@ -0,0 +1,54 @@ +name: Deploy server (development version) +on: workflow_call +jobs: + server-publish-image-develop: + runs-on: ubuntu-latest + # environment: develop + steps: + - uses: actions/checkout@v2 + with: + submodules: recursive + - name: Login to GitHub Container Registry + uses: docker/login-action@v1 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push + uses: docker/build-push-action@v2 + with: + file: server.Dockerfile + push: true + tags: ghcr.io/cavoke-project/cavoke-server:latest # TODO: different tag for different branches + server-deploy-cloud-run-develop: + runs-on: ubuntu-latest + environment: + name: develop + url: https://develop.api.cavoke.wlko.me + concurrency: develop + needs: [ server-publish-image-develop ] + steps: + - uses: actions/checkout@v2 + with: + submodules: recursive + - name: Setup Cloud SDK + uses: google-github-actions/setup-gcloud@v0 + with: + project_id: ${{ env.PROJECT_ID }} + service_account_key: ${{ secrets.GCP_SA_KEY }} + export_default_credentials: true + - name: 'Deploy to Cloud Run' + run: | + gcloud components install beta && \ + gcloud beta run deploy cavoke-server-develop \ + --execution-environment gen2 \ + --source server/.gcp \ + --allow-unauthenticated \ + --service-account ${{ env.SERVICE_ACCOUNT }} \ + --region=${{ env.REGION }} \ + --update-env-vars BUCKET=${{ env.BUCKET_NAME }} +env: + PROJECT_ID: waleko-personal + SERVICE_ACCOUNT: fs-identity + BUCKET_NAME: cavoke-test-1 + REGION: europe-north1 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..1aa84b7e --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,129 @@ +name: Run Tests +on: + workflow_call +jobs: + clang-format: + name: ClangFormat 12 + runs-on: ubuntu-latest + container: ghcr.io/cavoke-project/cavoke-ci:codeql + timeout-minutes: 1 + steps: + - uses: actions/checkout@v2 + # do not check submodules + - run: clang-format-12 --version + - run: chmod +x .github/scripts/diff-clangformat-12.sh && find . \( -iname '*.cpp' -or -iname '*.c' -or -iname '*.h' \) -and -not -path './server/models/*' | xargs -n1 .github/scripts/diff-clangformat-12.sh + cppcheck: + name: Cppcheck + runs-on: ubuntu-latest + container: ghcr.io/cavoke-project/cavoke-ci:codeql + timeout-minutes: 1 + steps: + - uses: actions/checkout@v2 + # do not check submodules + - run: cppcheck --version + - run: cppcheck --language=c++ -DSOME_DEFINE_TO_FIX_CONFIG --enable=all --suppress=*:doctest.h --suppress=unusedFunction --error-exitcode=1 --inline-suppr $(find . -iname '*.cpp' -and -not -path './server/models/*') + client-notests-qt5-suse: + runs-on: ubuntu-latest + container: ghcr.io/cavoke-project/cavoke-ci:client-qt5-suse + steps: + - uses: actions/checkout@v2 + with: + submodules: recursive + - name: Cmake + run: mkdir build && cd build && cmake .. -DBUILD_ALL=OFF -DBUILD_CLIENT=ON -DQT_MAJOR_VERSION=5 + - name: Make + run: cd build && make cavoke_client + client-notests-qt6-suse: + runs-on: ubuntu-latest + container: ghcr.io/cavoke-project/cavoke-ci:client-qt6-suse + steps: + - uses: actions/checkout@v2 + with: + submodules: recursive + - name: Cmake + run: mkdir build && cd build && cmake .. -DBUILD_ALL=OFF -DBUILD_CLIENT=ON -DQT_MAJOR_VERSION=6 # QT_MAJOR_VERSION=6 by default, so not necessary + - name: Make + run: cd build && make cavoke_client + server-compiles-with-submodules: + runs-on: ubuntu-latest + steps: + - run: sudo apt-get update && sudo apt-get install -y openssl libssl-dev libjsoncpp-dev uuid-dev zlib1g-dev libc-ares-dev postgresql-server-dev-all libboost-all-dev + - uses: actions/checkout@v2 + with: + submodules: recursive + - name: Cmake + run: mkdir build && cd build && cmake .. -DBUILD_ALL=OFF -DBUILD_SERVER=ON + - name: Make + run: cd build && make cavoke_server + server-healthcheck: + runs-on: ubuntu-latest + container: ghcr.io/cavoke-project/cavoke-ci:server + services: + postgres: + image: postgres:latest + env: + POSTGRES_DB: cavoke + POSTGRES_PASSWORD: postgres_password + POSTGRES_PORT: 5432 + POSTGRES_USER: postgres_user + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - uses: actions/checkout@v2 + with: + submodules: recursive + - name: Install PostgreSQL client + run: | + apt-get update + apt-get install --yes postgresql-client + - name: Populate the database + run: psql -h postgres -d cavoke -U postgres_user -f server/db/schema.sql + env: + PGPASSWORD: postgres_password + - name: Cmake + run: mkdir build && cd build && cmake .. -DBUILD_ALL=OFF -DBUILD_SERVER=ON -DUSE_EXTERNAL_DROGON=ON -DUSE_EXTERNAL_NLOHMANN=ON -DUSE_EXTERNAL_JWT=ON + - name: Make + run: cd build && make cavoke_server + - name: Healthcheck + run: chmod +x .github/scripts/server-test-health.py && .github/scripts/server-test-health.py ./build/server/cavoke_server -c .github/files/ci_server_config.json + server-tests: + runs-on: ubuntu-latest + container: ghcr.io/cavoke-project/cavoke-ci:server + steps: + - uses: actions/checkout@v2 + with: + submodules: recursive + - name: Cmake + run: mkdir build && cd build && cmake .. -DBUILD_ALL=OFF -DBUILD_SERVER=ON -DUSE_EXTERNAL_DROGON=ON -DUSE_EXTERNAL_NLOHMANN=ON -DUSE_EXTERNAL_JWT=ON + - name: Make + run: cd build && make cavoke_server_test + - name: Run Tests + run: ./build/server/test/cavoke_server_test + games-notests: + runs-on: ubuntu-latest + container: ghcr.io/cavoke-project/cavoke-ci:server + steps: + - uses: actions/checkout@v2 + with: + submodules: recursive + - name: Cmake + run: mkdir build && cd build && cmake .. -DBUILD_ALL=OFF -DBUILD_GAMES=ON -DUSE_EXTERNAL_DROGON=ON -DUSE_EXTERNAL_NLOHMANN=ON -DUSE_EXTERNAL_JWT=ON + - name: Make + run: cd build && make all + server-docker-compose-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + with: + submodules: recursive + - name: Start up the server with Docker Compose + run: docker-compose up -d + - name: Healthcheck + run: test $(curl -s localhost:8080/health) = "OK" + - name: Check that some games are available + run: curl -s localhost:8080/games/list | jq -e 'length >= 1' diff --git a/.gitignore b/.gitignore index 0ad14463..91f47cd5 100644 --- a/.gitignore +++ b/.gitignore @@ -172,4 +172,10 @@ fabric.properties .idea/ +# Local game files local_server/ + +# Drogon logs +*.log +/games/codenames/client/client.zip +uploads/ \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..203384c8 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,21 @@ +[submodule "third_party/drogon"] + path = third_party/drogon + url = https://github.com/drogonframework/drogon.git +[submodule "third_party/json"] + path = third_party/json + url = https://github.com/nlohmann/json.git +[submodule "third_party/qtkeychain"] + path = third_party/qtkeychain + url = https://github.com/frankosterfeld/qtkeychain.git +[submodule "third_party/jwt"] + path = third_party/jwt + url = https://github.com/Thalhammer/jwt-cpp.git +[submodule "third_party/extra-cmake-modules"] + path = third_party/extra-cmake-modules + url = https://github.com/kde/extra-cmake-modules +[submodule "third_party/karchive"] + path = third_party/karchive + url = https://github.com/kde/karchive +[submodule "third_party/zlib"] + path = third_party/zlib + url = https://github.com/madler/zlib diff --git a/.gitpod.Dockerfile b/.gitpod.Dockerfile new file mode 100644 index 00000000..54c16038 --- /dev/null +++ b/.gitpod.Dockerfile @@ -0,0 +1,10 @@ +FROM gitpod/workspace-full + +RUN sudo apt-get update -yqq \ + && sudo apt-get install -yqq --no-install-recommends software-properties-common \ + openssl libssl-dev libjsoncpp-dev uuid-dev zlib1g-dev libc-ares-dev\ + postgresql-server-dev-all \ + libboost-all-dev python3-pip \ + qtbase5-dev qtdeclarative5-dev libqt5networkauth5-dev\ + libkf5archive-dev \ + && sudo rm -rf /var/lib/apt/lists/* diff --git a/.gitpod.yml b/.gitpod.yml new file mode 100644 index 00000000..fa0c2fa6 --- /dev/null +++ b/.gitpod.yml @@ -0,0 +1,25 @@ +image: + file: .gitpod.Dockerfile + +tasks: + - name: Database + before: docker build . -f db.Dockerfile -t cavoke_db + command: docker run -t -p 5432:5432 -e POSTGRES_USER=postgres_user -e POSTGRES_PASSWORD=postgres_password cavoke_db + - name: Cmake + init: mkdir -p build-cmake && cmake . -B build-cmake -DQT_MAJOR_VERSION=5 + +vscode: + extensions: + - ms-azuretools.vscode-docker + - timonwong.shellcheck +# - ms-vscode.cmake-tools + - vscode.cpp + +github: + prebuilds: + master: true + branches: true + pullRequests: true + pullRequestsFromForks: true + addCheck: true + addComment: true \ No newline at end of file diff --git a/.postman/collections/Cavoke REST API_950ad167-dd3a-4311-a8a5-3099ffd73b61.json b/.postman/collections/Cavoke REST API_950ad167-dd3a-4311-a8a5-3099ffd73b61.json deleted file mode 100644 index f7fc4fc4..00000000 --- a/.postman/collections/Cavoke REST API_950ad167-dd3a-4311-a8a5-3099ffd73b61.json +++ /dev/null @@ -1,225 +0,0 @@ -{ - "info": { - "_postman_id": "2b66a0c3-22a1-481a-83c6-68178c907d8a", - "name": "Cavoke REST API", - "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" - }, - "item": [ - { - "name": "Simple health check", - "id": "db43eafc-7c04-4bd0-81c3-9d775eee7020", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{baseUrl}}/health", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "health" - ] - } - }, - "response": [] - }, - { - "name": "List available games to play", - "id": "47f80185-289c-418b-971d-7397c1e1c2aa", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{baseUrl}}/games/list", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "games", - "list" - ] - } - }, - "response": [] - }, - { - "name": "Get GameInfo of specific game", - "id": "53aa843d-ce79-4ffd-9c93-8a89a08738b5", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{baseUrl}}/games/:game_id/get_config", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "games", - ":game_id", - "get_config" - ], - "variable": [ - { - "id": "2788ef7c-a640-4c9f-a4cf-b157a7460bfe", - "key": "game_id", - "value": "deserunt voluptate culpa", - "description": "(Required) String id of the game to get" - } - ] - } - }, - "response": [] - }, - { - "name": "Download QML client zip of a game", - "id": "5b5e67c5-ab56-4966-81dc-a703521bced0", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{baseUrl}}/games/:game_id/get_client", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "games", - ":game_id", - "get_client" - ], - "variable": [ - { - "id": "3cc760b2-83e2-4d0d-883d-5ab622b69967", - "key": "game_id", - "value": "deserunt voluptate culpa", - "description": "(Required) String id of the game to get" - } - ] - } - }, - "response": [ - { - "id": "669d21ec-5471-4b7e-80a4-cccded3ee4b4", - "name": "Client zip file of an existing game", - "originalRequest": { - "method": "GET", - "header": [], - "url": { - "raw": "{{baseUrl}}/games/:game_id/get_client", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "games", - ":game_id", - "get_client" - ], - "variable": [ - { - "key": "game_id", - "value": "deserunt voluptate culpa", - "description": "(Required) String id of the game to get" - } - ] - } - }, - "status": "OK", - "code": 200, - "_postman_previewlanguage": "Text", - "header": [ - { - "key": "Content-Type", - "value": "application/octet-stream" - } - ], - "cookie": [], - "responseTime": null, - "body": "qui labore" - }, - { - "id": "49473878-b93d-40ef-9f6d-34658fbebfaa", - "name": "No such game", - "originalRequest": { - "method": "GET", - "header": [], - "url": { - "raw": "{{baseUrl}}/games/:game_id/get_client", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "games", - ":game_id", - "get_client" - ], - "variable": [ - { - "key": "game_id", - "value": "deserunt voluptate culpa", - "description": "(Required) String id of the game to get" - } - ] - } - }, - "status": "Not Found", - "code": 404, - "_postman_previewlanguage": "Text", - "header": [ - { - "key": "Content-Type", - "value": "text/plain" - } - ], - "cookie": [], - "responseTime": null, - "body": "" - }, - { - "id": "a239fae8-9480-4921-a2b2-080413f7cb85", - "name": "Unexpected error", - "originalRequest": { - "method": "GET", - "header": [], - "url": { - "raw": "{{baseUrl}}/games/:game_id/get_client", - "host": [ - "{{baseUrl}}" - ], - "path": [ - "games", - ":game_id", - "get_client" - ], - "variable": [ - { - "key": "game_id", - "value": "deserunt voluptate culpa", - "description": "(Required) String id of the game to get" - } - ] - } - }, - "status": "Internal Server Error", - "code": 500, - "_postman_previewlanguage": "Text", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "cookie": [], - "responseTime": null, - "body": "{\n \"code\": -33611818,\n \"message\": \"Excepteur ut nulla\"\n}" - } - ] - } - ], - "variable": [ - { - "id": "f7afe901-2417-48e1-8d98-b7e71978434e", - "key": "baseUrl", - "value": "localhost:8080", - "type": "string" - } - ] -} \ No newline at end of file diff --git a/.postman/schemas/schema.yaml b/.postman/schemas/schema.yaml deleted file mode 100644 index c7f098eb..00000000 --- a/.postman/schemas/schema.yaml +++ /dev/null @@ -1,290 +0,0 @@ -openapi: 3.0.0 -info: - version: '0.0.3' - title: 'cavoke' -servers: - - url: 'localhost:8080' -paths: - /health: - get: - summary: 'Simple health check' - operationId: health - responses: - '200': - description: 'OK' - content: - text/plain: - schema: - type: string - example: OK - - /games/list: - get: - summary: 'List available games to play' - operationId: listGames - responses: - '200': - description: 'List of available games as metadata' - content: - application/json: - schema: - type: array - items: - $ref: "#/components/schemas/GameInfo" - default: - description: Unexpected error - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - /games/{game_id}/get_config: - get: - summary: 'Get GameInfo of specific game' - operationId: configGame - parameters: - - in: path - name: game_id - schema: - $ref: '#/components/schemas/GameId' - required: true - description: 'String id of the game to get' - example: tictactoe - responses: - '200': - description: 'Config of an existing game' - content: - application/json: - schema: - $ref: "#/components/schemas/GameInfo" - '404': - description: 'No such game' - default: - description: Unexpected error - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - /games/{game_id}/get_client: - get: - summary: 'Download QML client zip of a game' - operationId: downloadClient - parameters: - - in: path - name: game_id - schema: - $ref: '#/components/schemas/GameId' - required: true - description: 'String id of the game to get' - example: 'tictactoe' - responses: - '200': - description: 'Client zip file of an existing game' - content: - application/octet-stream: - schema: - type: string - format: binary - '404': - description: 'No such game' - default: - description: Unexpected error - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - /sessions/create: - post: - summary: 'Create a game from requested game' - operationId: createSession - parameters: - - in: query - name: game_id - schema: - $ref: '#/components/schemas/GameId' - required: true - description: 'String id of the game to get' - example: tictactoe - - in: query - name: user_id - schema: - $ref: '#/components/schemas/UserId' - required: true - description: 'User id' - example: c84d0d6e-547a-4e3a-92ba-5a6ff6c3d51f - responses: - '200': - description: 'Created successfully' - content: - application/json: - schema: - type: object - properties: - game_id: - $ref: '#/components/schemas/GameId' - session_id: - $ref: '#/components/schemas/SessionId' - '400': - description: 'Bad request' - '404': - description: 'No game with such game id' - default: - description: Unexpected error - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - - /play/{session_id}/send_move: - post: - summary: 'Send a move from QML game' - operationId: sendMove - parameters: - - in: path - name: session_id - schema: - $ref: '#/components/schemas/SessionId' - required: true - example: 83896dd5-6f03-4805-8cf1-03ce6bd6077f - - in: query - name: user_id - schema: - $ref: '#/components/schemas/UserId' - required: true - description: 'User id' - example: c84d0d6e-547a-4e3a-92ba-5a6ff6c3d51f - requestBody: - description: Game move data - content: - application/json: - schema: - $ref: '#/components/schemas/GameMove' - responses: - '200': - description: 'Move accepted' - - '404': - description: 'No such session / no such user' - default: - description: Unexpected error - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - /play/{session_id}/get_update: - get: - summary: 'Try get an update from QML game' - operationId: getUpdate - tags: - - play - parameters: - - in: path - name: session_id - schema: - type: string - format: uuid - required: true - description: 'UUID of a game session (room to game pair)' - - in: query - name: user_id - schema: - $ref: '#/components/schemas/UserId' - required: true - description: 'User id' - example: c84d0d6e-547a-4e3a-92ba-5a6ff6c3d51f - - in: query - name: offset - schema: - type: integer - format: int32 - example: 32 - description: 'Only get updates after this id (by default 0, so returns all updates throught the game)' - responses: - '200': - description: 'List updates (in an ascending order of ids)' - content: - application/json: - schema: - type: array - items: - $ref: "#/components/schemas/GameUpdate" - '404': - description: 'No such session / no such user' - default: - description: Unexpected error - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - -components: - schemas: - GameId: - type: string - example: tictactoe - GameInfo: - type: object - required: - - id - - display_name - - description - - players_num - properties: - id: - $ref: '#/components/schemas/GameId' - display_name: - type: string - description: - type: string - players_num: - type: integer - format: int32 - Error: - type: object - required: - - code - - message - properties: - code: - type: integer - format: int32 - example: 31 - message: - type: string - example: 'Something went wrong' - GameMove: - type: object - properties: - move: - type: string - description: 'Any information about the move supplied by QML. May be any string (e.g. JSON)' - required: - - user_id - - move - GameUpdate: - type: object - properties: - user_id: - type: string - format: uuid - example: c84d0d6e-547a-4e3a-92ba-5a6ff6c3d51f - update: - type: string - description: "Any information about the update supplied by app's logic. May be any string (e.g. JSON)" - example: '{"action": "x_move", x: 0, y: 2}' - id: - type: integer - format: int32 - example: 1 - required: - - user_id - - update - SessionId: - type: string - format: uuid - example: 83896dd5-6f03-4805-8cf1-03ce6bd6077f - securitySchemes: - BasicAuth: - type: http - scheme: basic -# security: -# - BasicAuth: [] \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt index fdba859f..6a62bea7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,12 +1,75 @@ cmake_minimum_required(VERSION 3.10) project(cavoke) +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + option(BUILD_ALL "Build all subprojects (usually disabled for ci)" ON) option(BUILD_SERVER "Enable building server" OFF) option(BUILD_CLIENT "Enable building client" OFF) option(BUILD_GAMES "Enable building games" OFF) +# Add local drogon +if (BUILD_ALL OR BUILD_SERVER OR BUILD_GAMES) + option(USE_EXTERNAL_DROGON "Whether to use external drogon" OFF) + if (USE_EXTERNAL_DROGON) + message("Using external Drogon...") + find_package(Drogon 1.7.3 REQUIRED) + else () + message("Using Drogon from submodules...") + if (NOT EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/third_party/drogon/CMakeLists.txt) + message(SEND_ERROR "Unable to add a dependency locally!\nHave you tried running: git submodule update --init --recursive") + endif () + add_subdirectory(third_party/drogon) + endif () + + option(USE_EXTERNAL_JWT "Whether to use external jwt-cpp" OFF) + if (USE_EXTERNAL_JWT) + message("Using external jwt-cpp...") + # https://github.com/Thalhammer/jwt-cpp JWT authentication + find_package(jwt-cpp REQUIRED) + else () + message("Using JWT from submodules...") + if (NOT EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/third_party/jwt/CMakeLists.txt) + message(SEND_ERROR "Unable to add a dependency locally!\nHave you tried running: git submodule update --init --recursive") + endif () + # HACK: workaround for a windows bug https://github.com/kazuho/picojson/issues/141 + set(JWT_BUILD_EXAMPLES OFF) + set(JWT_DISABLE_PICOJSON ON) + add_subdirectory(third_party/jwt) + endif () +endif () + +# Add local nlohmann +if (BUILD_ALL OR BUILD_SERVER OR BUILD_GAMES) + option(USE_EXTERNAL_NLOHMANN "Whether to use external nlohmann_json" OFF) + if (USE_EXTERNAL_NLOHMANN) + message("Using external Nlohmann...") + # https://github.com/nlohmann/json/releases/tag/v3.9.0 for convenience macros + find_package(nlohmann_json 3.9.0 REQUIRED) + else () + message("Using Nlohmann from submodules...") + if (NOT EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/third_party/json/CMakeLists.txt) + message(SEND_ERROR "Unable to add a dependency locally!\nHave you tried running: git submodule update --init --recursive") + endif () + add_subdirectory(third_party/json) + endif () +endif () + +## Add local qtkeychain +#if (BUILD_ALL OR BUILD_CLIENT) +# message("Using Qtkeychain from submodules...") +# if (NOT EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/third_party/qtkeychain/CMakeLists.txt) +# message(SEND_ERROR "Unable to add a dependency locally!\nHave you tried running: git submodule update --init --recursive") +# endif() +# if (QT_MAJOR_VERSION EQUAL 6) +# set(BUILD_WITH_QT6 ON) +# endif () +# add_subdirectory(third_party/qtkeychain) +#endif() + if (BUILD_ALL OR BUILD_SERVER) add_subdirectory(server) endif () @@ -16,3 +79,4 @@ endif () if (BUILD_ALL OR BUILD_GAMES) add_subdirectory(games) endif () + diff --git a/CNAME b/CNAME new file mode 100644 index 00000000..a888d9b7 --- /dev/null +++ b/CNAME @@ -0,0 +1 @@ +cavoke.wlko.me \ No newline at end of file diff --git a/GameLogicProtocol.md b/GameLogicProtocol.md index 82d45ac1..bcff9e4a 100644 --- a/GameLogicProtocol.md +++ b/GameLogicProtocol.md @@ -4,7 +4,7 @@ ### GameState Состояние игры, возвращаемое игровой логикой. -```json lines +```jsonc lines { // закончена ли игра "is_terminal": bool, @@ -25,7 +25,7 @@ ### GameMove Ход игрока, передаваемый игровой логике -```json lines +```jsonc lines { // номер игрока (в рамках данной партии) // если равно -1 - инициализация новой сессии (остальные поля не имеют значения) diff --git a/README.md b/README.md index 95928ec0..be75bddf 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,277 @@ - - - -

+

+
+ Cavoke +
Cavoke +

-

Платформа для создания и проведения сетевых пошаговых настольных игр

+

A Platform for creating and hosting multiplayer turn-based board games

+ +

+ + GitHub Workflow Status + + + + GitHub deployments + + + Docker available + + + OpenAPI status + + + + License + +

+

+ + GitHub release (latest by date including pre-releases) + + +Browse available games + + +

+ +

+ Overview • + How To Use • + Download • + Credits • + License +

+ + +![screenshot](https://raw.githubusercontent.com/cavoke-project/cavoke/develop/.github/assets/cavoke-demo.gif) + +## Overview + +### Problem formulation + +We believe that developing a desktop multiplayer game is always a great adventure that has many wonderful opportunities +to learn something new along the way. +However, in our opinion this journey, is often riddled with unnecessary challenges of implementing mechanisms that are +common for many games. This includes: + +- Developing a **client application** with many UI components for game creation process +- **Networking**, which in itself includes + - **Synchronizing the game state** among clients + - Handling network issues on client's side + - SSL and Security +- **Game state persistence** +- **Game move validation** +- **Role choosing** (e.g. White/Black in Chess) +- Managing **game sessions** and **shareable invites* for friends to play with you +- **Game and User statistics** (win rate, total time spent, etc.) +- User **Authentication and Authorization** +- Game versioning +- Hosting server logic and scalability + +To make these obstacles easier to avoid, we developed a framework for developing multiplayer turn-based board games +that comes with built-in solutions to solve all the above-mentioned problems. + +### Our solution +#### Developers side + +Our platform provides an easy way for developers to create a desktop game. A developer should provide only two +components: + +- **Server logic component** that verifies one's move correctness and updates the game state +- **Client game component**. A [QML](https://doc.qt.io/qt-6/qmlapplications.html) application that communicates with the + main Cavoke Client application and sends player's moves through this gateway. It also receives updates from other + players' moves to update its user interface. + +
+ Example Cavoke Game Development Story (Tic-Tac-Toe) + +Let's see how a simple Tic-Tac-Toe game using our Cavoke platform may look like. See files for this +example [here](./games/tictactoe) and [here](./client/tictactoe-files) +. + +We start off with the client-side. We create a simple QML application and design our game UI. Now it's time to connect +our QML component to the main cavoke Qt application: + +```qml +... +import "content/interactions.js" as Interact + +Rectangle { + // BEGIN cavoke section + Connections { + target: cavoke + + function onReceiveUpdate(jsonUpdate) { // <-- Signal that handles incoming game states + console.log("Received: " + jsonUpdate); + Interact.processResponse(jsonUpdate); + } + } + // END cavoke section + + id: game +... +``` + +And in the `interactions.js`: + +```js +function processResponse(response) { // <-- Processes the received state from the server + let res = JSON.parse(response) + updateBoard(res["state"]); // Parses the game state UI. + // Here res["state"] may look something like "XOO OX X " +} + +function sendMove(moveString) { + let move = {} + move.move = "X" + moveString + cavoke.getMoveFromQml(JSON.stringify(move)) // <-- Sends data to the server via Qt +} + +function updateBoard(boardString) { + for (let i = 0; i < 9; ++i) { + board.children[i].state = boardString[i]; + } +} + +// ... +``` + +Developers may decide for themselves how their states will look. They may use json, plain-string, base64. It just has to +be representable as a string. For example, the developers of this game decided to encode the game state as a json +with `state` field that is 9 characters long representing the tictactoe board, i.e. `XOO OX X ` would represent the +following field. + +``` +XOO + OX + X +``` + +> :information_source: Notice that QML game component doesn't include any networking, session management, users +> authentication, etc. All of this is handled by the main cavoke client executable. Developers can focus solely on their +> game. +> That's it with the client side! All the other bits and bobs around QML component have to do with UI and not +> administrating the game process. + +Now let's see what awaits us on the server side. + +A server component must be able to handle 3 requests. + +- `VALIDATE` – Check if received game configuration can be used to start it (e.g. minimum players count reached, at + least 1 player in every team, etc.) +- `INIT` – Create a game session using given configuration. Generates game state for this session. Now players can play. +- `MOVE` – Process user's move (figure move in Chess, new X/O in Tic-Tac-Toe, etc.) + +> :warning: Server components also have to be *stateless*, because they may be shut down and restarted at any point +> without notice. This means that all game session information must be stored in the game state that is passed onto the +> server component with every `MOVE` request. + +So for Tic-Tac-Toe we have something like this: + +```cpp +bool validate_settings( // <-- Validates game configuration + const json &settings, + const std::vector &occupied_positions, + const std::function &message_callback) { + if (occupied_positions.size() != 2) { + message_callback("Not enough players"); + return false; + } + if (!settings.contains("board_size")) { + message_callback("No board_size property"); + return false; + } + if (settings["board_size"].get() != 3) { + message_callback("Only board_size=3 is supported"); + return false; + } + return true; +} +// ... +GameState init_state(const json &settings, // <-- Creates a game session + const std::vector &occupied_positions) { + int board_size = settings["board_size"]; + std::string board(board_size * board_size, ' '); + return GameState{false, board, {board, board}, {}}; +} +// ... +GameState apply_move(GameMove &new_move) { /// <-- Handles move request + std::string &board = new_move.global_state; + char player = (new_move.player_id == 0 ? 'X' : 'O'); + if (player != current_player(board)) { + return {false, board, {board, board}, {}}; + } + int position = extract_position(new_move.move); + if (!is_valid_move(board, position)) { + return {false, board, {board, board}, {}}; + } + board[position] = player; + bool win = winner(board); + std::vector winners; + if (win) { + winners.push_back(new_move.player_id); + } + return {win, board, {board, board}, winners}; +} +``` + +> :information_source: You can read more about all the game logic protocol [here](./GameLogicProtocol.md) +
+ +#### Players side +Futhermore, we provide many features in our cavoke client for the users. These include: +- **Cross platform**: Available on Windows, macOS and Linux +- **User Authentication** using Email-Password or SSO via Google or GitHub +- **Catalog of available cavoke games** +- **Rooms** with shareable invites for your friends, so you can play multiple games without the need to change the app +- **User's statistics** for every game +- **Developer mode** to test QML components locally + +## How To Use + +Please see the instructions for how to clone and build server and client components in its subdirectories. + +- [Server](./server) +- [Client](./client) + +## Download + +You can [download](https://github.com/cavoke-project/cavoke/releases/) the latest installable version of Cavoke Client +for Windows, macOS and Linux. + +## Credits + +This software uses the following open source projects: + +- [Qt](https://www.qt.io/) +- [Drogon](https://github.com/drogonframework/drogon) +- [Circle Icons](https://www.iconfinder.com/iconsets/circle-icons-1) by Nick Roach +- [Boost](https://www.boost.org/) +- [KArchive](https://github.com/KDE/karchive) +- [KDE Craft](https://github.com/KDE/Craft) +- [ECM](https://github.com/KDE/extra-cmake-modules) +- [jwt-cpp](https://github.com/Thalhammer/jwt-cpp) +- [qtkeychain](https://github.com/frankosterfeld/qtkeychain) +- [nlohmann/json](https://github.com/nlohmann/json) +- [Quassel](https://github.com/quassel/quassel) -[![CircleCI Build Status](https://img.shields.io/circleci/build/github/cavoke-project/cavoke?style=flat-square)](https://app.circleci.com/pipelines/github/cavoke-project/cavoke?filter=all) -[![GitHub top language](https://img.shields.io/github/languages/top/cavoke-project/cavoke?logo=github&style=flat-square)](https://github.com/cavoke-project/cavoke) -[![license](https://img.shields.io/github/license/cavoke-project/cavoke?style=flat-square)](./LICENSE) +Credit to [Mudlet](https://github.com/Mudlet/Mudlet) and [Markdownify](https://github.com/amitmerchant1990/electron-markdownify) for the README inspiration. -## Что это -Cavoke – платформа для создания и проведения сетевых пошаговых настольных игр: позволяет разработчику не думать о создании комнат, подключении игроков, передаче данных от сервера к клиентам. Состоит из сервера и игрового клиента (отвечает за запуск интерфейсов игр). +## License -Разработчик игры регистрирует свой игровой продукт на нашей платформе, предоставляет компоненты с серверной и игровой логикой. Игрок, посредством приложения Cavoke клиента, получает доступ к зарегистрированным в системе играм и может играть в них один и по сети. Cavoke клиент при запуске игры запрашивает компонент выбранной игры с сервера, загружает и запускает его на компьютере игрока, предоставляя им доступ к игровому опыту. +[MIT](./LICENSE) - +--- -## Подмодули -- [client](/client) – Десктопный клиент на Qt -- [server](/server) – Сервер на Drogon, запускающий логику игр -- [games](/games) – Реализованные игры +> [@MarkTheHopeful](https://github.com//MarkTheHopeful)  ·  +> [@waleko](https://github.com/waleko)  ·  +> [@petrtsv](https://github.com/petrtsv) diff --git a/_config.yml b/_config.yml new file mode 100644 index 00000000..3083ae5e --- /dev/null +++ b/_config.yml @@ -0,0 +1,2 @@ +theme: jekyll-theme-cayman +exclude: third_party/ diff --git a/games/cavoke.cpp b/cavoke-dev-lib/cavoke.cpp similarity index 100% rename from games/cavoke.cpp rename to cavoke-dev-lib/cavoke.cpp diff --git a/games/cavoke.h b/cavoke-dev-lib/cavoke.h similarity index 100% rename from games/cavoke.h rename to cavoke-dev-lib/cavoke.h diff --git a/cavoke-dev-server-lib/cavoke.cpp b/cavoke-dev-server-lib/cavoke.cpp new file mode 100644 index 00000000..0a1b0cf2 --- /dev/null +++ b/cavoke-dev-server-lib/cavoke.cpp @@ -0,0 +1,79 @@ +#include +#include +#include +#include +#include "controllers/logic_controller.h" + +namespace cavoke::logic_server { + +void run(const std::string &ip, uint16_t port) { + auto &app = drogon::app(); + + app.addListener(ip, port); + + // print db and listeners on start + app.registerBeginningAdvice([]() { + auto listeners = drogon::app().getListeners(); + std::string ips = + listeners.empty() + ? "" + : boost::join( + listeners | boost::adaptors::transformed( + [](const trantor::InetAddress &e) { + return e.toIpPort(); + }), + ", "); + std::cout << "Listening at " << ips << "... " << std::endl; + }); + + app.registerPostHandlingAdvice([](const drogon::HttpRequestPtr &req, + const drogon::HttpResponsePtr &resp) { + // cors header for every response + resp->addHeader("Access-Control-Allow-Origin", "*"); + }); + + auto logic_controller = std::make_shared(); + + app.registerController(logic_controller); + + app.run(); +} + +} // namespace cavoke::logic_server + +namespace po = boost::program_options; + +int main(int argc, char *argv[]) { + po::options_description desc("Allowed options"); + auto add_desc_options = desc.add_options(); + add_desc_options("help,h", "Print help"); + add_desc_options("ip,i", po::value()->default_value("0.0.0.0"), + "IP address for connection"); + add_desc_options("port,p", po::value()->default_value(9090), + "TCP/IP port number for connection"); + + po::variables_map vm; + po::store(po::parse_command_line(argc, argv, desc), vm); + po::notify(vm); + + if (vm.count("help")) { + std::cout << desc << "\n"; + return 0; + } + + // defaults + std::string ip = "0.0.0.0"; + uint16_t port = 9090; + + if (vm.count("ip")) { + ip = vm["ip"].as(); + } + + if (vm.count("port")) { + port = vm["port"].as(); + } + + cavoke::logic_server::run(ip, port); + + return 0; +} diff --git a/cavoke-dev-server-lib/cavoke.h b/cavoke-dev-server-lib/cavoke.h new file mode 100644 index 00000000..bbae56e3 --- /dev/null +++ b/cavoke-dev-server-lib/cavoke.h @@ -0,0 +1,64 @@ +#ifndef CAVOKE_CAVOKE_H +#define CAVOKE_CAVOKE_H + +#include +#include +#include +#include + +// TODO: logging +namespace cavoke { +using json = nlohmann::json; + +struct GameState { // NOLINT(cppcoreguidelines-pro-type-member-init) + bool is_terminal; + std::string global_state; + std::vector players_state; + std::vector winners; +}; +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(GameState, + is_terminal, + global_state, + players_state, + winners) + +struct GameMove { // NOLINT(cppcoreguidelines-pro-type-member-init) + int player_id; + std::string move; + std::string global_state; +}; +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(GameMove, player_id, move, global_state) + +struct InitSettings { + json settings; + std::vector occupied_positions; +}; +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(InitSettings, settings, occupied_positions); + +struct ValidationResult { + bool success; + std::string message; +}; + +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(ValidationResult, success, message); + +// called every time admin changes settings or new player arrives +// returns true if game can be started with current settings; false otherwise +// use message_callback() to display message to the players +bool validate_settings( + const json &settings, + const std::vector &occupied_positions, + const std::function &message_callback); + +// called once when the game session is started +// generates initial state +// settings have passed the validation +GameState init_state(const json &settings, + const std::vector &occupied_positions); + +// applies move to the state +GameState apply_move(GameMove &new_move); + +} // namespace cavoke + +#endif // CAVOKE_CAVOKE_H diff --git a/cavoke-dev-server-lib/controllers/logic_controller.cpp b/cavoke-dev-server-lib/controllers/logic_controller.cpp new file mode 100644 index 00000000..7c10a37d --- /dev/null +++ b/cavoke-dev-server-lib/controllers/logic_controller.cpp @@ -0,0 +1,70 @@ +#include "logic_controller.h" +#include +#include "../cavoke.h" +#include "../utils.h" + +namespace cavoke::logic_server::controllers { + +using json = nlohmann::json; + +void LogicController::validate( + const drogon::HttpRequestPtr &req, + std::function &&callback) { + std::cout << "GOT REQUEST " << req->getBody() << std::endl; + try { + InitSettings settings = json::parse(req->getBody()); + ValidationResult result; + + result.success = cavoke::validate_settings( + settings.settings, settings.occupied_positions, + [&result](const std::string &message) { + result.message = message; + }); + + std::cout << "SENT RESPONSE " << json(result).dump() << std::endl; + callback(newNlohmannJsonResponse(result)); + } catch (const json::parse_error &) { + return CALLBACK_STATUS_CODE(k400BadRequest); + } catch (const json::out_of_range &) { + return CALLBACK_STATUS_CODE(k400BadRequest); + } +} + +void LogicController::init_state( + const drogon::HttpRequestPtr &req, + std::function &&callback) { + std::cout << "GOT REQUEST " << req->getBody() << std::endl; + try { + InitSettings settings = json::parse(req->getBody()); + + GameState result = + cavoke::init_state(settings.settings, settings.occupied_positions); + + std::cout << "SENT RESPONSE " << json(result).dump() << std::endl; + callback(newNlohmannJsonResponse(result)); + } catch (const json::parse_error &) { + return CALLBACK_STATUS_CODE(k400BadRequest); + } catch (const json::out_of_range &) { + return CALLBACK_STATUS_CODE(k400BadRequest); + } +} + +void LogicController::apply_move( + const drogon::HttpRequestPtr &req, + std::function &&callback) { + std::cout << "GOT REQUEST " << req->getBody() << std::endl; + try { + GameMove move = json::parse(req->getBody()); + + GameState result = cavoke::apply_move(move); + + std::cout << "SENT RESPONSE " << json(result).dump() << std::endl; + callback(newNlohmannJsonResponse(result)); + } catch (const json::parse_error &) { + return CALLBACK_STATUS_CODE(k400BadRequest); + } catch (const json::out_of_range &) { + return CALLBACK_STATUS_CODE(k400BadRequest); + } +} + +} // namespace cavoke::logic_server::controllers diff --git a/cavoke-dev-server-lib/controllers/logic_controller.h b/cavoke-dev-server-lib/controllers/logic_controller.h new file mode 100644 index 00000000..7ac948af --- /dev/null +++ b/cavoke-dev-server-lib/controllers/logic_controller.h @@ -0,0 +1,32 @@ +#ifndef CAVOKE_STATE_CONTROLLER_H +#define CAVOKE_STATE_CONTROLLER_H + +#include + +namespace cavoke::logic_server::controllers { + +class LogicController : public drogon::HttpController { +public: + METHOD_LIST_BEGIN + ADD_METHOD_TO(LogicController::validate, "/validate", drogon::Post); + ADD_METHOD_TO(LogicController::init_state, "/init_state", drogon::Post); + ADD_METHOD_TO(LogicController::apply_move, "/apply_move", drogon::Post); + METHOD_LIST_END + +protected: + void validate( + const drogon::HttpRequestPtr &req, + std::function &&callback); + + void init_state( + const drogon::HttpRequestPtr &req, + std::function &&callback); + + void apply_move( + const drogon::HttpRequestPtr &req, + std::function &&callback); +}; + +} // namespace cavoke::logic_server::controllers + +#endif // CAVOKE_STATE_CONTROLLER_H diff --git a/cavoke-dev-server-lib/utils.h b/cavoke-dev-server-lib/utils.h new file mode 100644 index 00000000..5879ba74 --- /dev/null +++ b/cavoke-dev-server-lib/utils.h @@ -0,0 +1,38 @@ +#ifndef CAVOKE_UTILS_H +#define CAVOKE_UTILS_H + +#include +#include +#include +#include + +namespace cavoke::logic_server::controllers { + +/** + * Creates a drogon response from serializable object. + * + * Preferably pass the object itself, not the json object, + * because `nlohmann::json{configs}` adds extra square brackets to objects + * see https://github.com/cavoke-project/cavoke/issues/22. Avoid curly braces. + */ +inline drogon::HttpResponsePtr newNlohmannJsonResponse( + const nlohmann::json &obj) { + auto res = drogon::HttpResponse::newHttpResponse(); + res->setContentTypeCode(drogon::CT_APPLICATION_JSON); + res->setBody(obj.dump()); + return res; +} + +/// Creates blank http response with given status code +inline drogon::HttpResponsePtr newStatusCodeResponse( + const drogon::HttpStatusCode &status_code) { + auto res = drogon::HttpResponse::newHttpResponse(); + res->setStatusCode(status_code); + return res; +} + +#define CALLBACK_STATUS_CODE(code) \ + callback(newStatusCodeResponse(::drogon::code)) +} // namespace cavoke::logic_server::controllers + +#endif // CAVOKE_UTILS_H diff --git a/client/AuthenticationManager.cpp b/client/AuthenticationManager.cpp new file mode 100644 index 00000000..b2ce3578 --- /dev/null +++ b/client/AuthenticationManager.cpp @@ -0,0 +1,142 @@ +#include "AuthenticationManager.h" +#include +#include +#include +#include + +//#if defined(INCLUDE_OWN_QT_KEYCHAIN) +//#include "keychain.h" +//#else +//#include +//#endif + +void cavoke::auth::AuthenticationManager::init() { + oauth2.setAuthorizationUrl(QUrl(authorizationUrl)); + oauth2.setAccessTokenUrl(QUrl(accessTokenUrl)); + oauth2.setClientIdentifier(clientId); + oauth2.setScope(scope); + + connect(&oauth2, &QOAuth2AuthorizationCodeFlow::statusChanged, + [=](QAbstractOAuth::Status status) { + if (status == QAbstractOAuth::Status::Granted) { + qDebug() << "Now authenticated!!"; + writeSecurePassword(refresh_token_profile, + oauth2.refreshToken()); + if (oauth2.token().isEmpty()) { + qWarning() << "Authentication completed successfully, " + "but token is empty!! Forcing a relogin"; + relogin(); + } + emit authenticated(); + } else if (status == QAbstractOAuth::Status::NotAuthenticated) { + qWarning() << "Unauthenticated"; + } + }); + oauth2.setModifyParametersFunction( + [&](QAbstractOAuth::Stage stage, auto *parameters) { + if (stage == QAbstractOAuth::Stage::RequestingAuthorization) + parameters->insert("audience", audience); + }); + connect(&oauth2, &QOAuth2AuthorizationCodeFlow::authorizeWithBrowser, this, + [&](const QUrl &url) { + // logout link with redirect to authorization + QUrl route{logoutUrl}; + route.setQuery(QUrlQuery{ + {"returnTo", QUrl::toPercentEncoding(url.toEncoded())}}); + QDesktopServices::openUrl(route); + }); + readSecurePassword(refresh_token_profile, + [&](const QString &refresh_token) { + qDebug() << "Loaded refresh token from Keychain!"; + oauth2.setRefreshToken(refresh_token); + oauth2.refreshAccessToken(); + }); +} +bool cavoke::auth::AuthenticationManager::checkAuthStatus() { + return !oauth2.token().isEmpty(); +} +void cavoke::auth::AuthenticationManager::writeSecurePassword( + const QString &profile, + const QString &pass) { + // FIXME: move to qkeychain + settings.setValue(profile, pass); + // auto *job = new QKeychain::WritePasswordJob("cavoke_keychain"); + // job->setAutoDelete(false); + // job->setInsecureFallback(false); + // job->setKey(profile); + // job->setTextData(pass); + // connect(job, &QKeychain::WritePasswordJob::finished, this, + // [](QKeychain::Job *job) { + // if (job->error()) { + // qWarning() << job->errorString(); + // } + // job->deleteLater(); + // }); + // job->start(); +} +template +void cavoke::auth::AuthenticationManager::readSecurePassword( + const QString &profile, + L callback) { + // FIXME: move to qkeychain + callback(settings.value(profile).toString()); + // auto *job = new QKeychain::ReadPasswordJob("cavoke_keychain"); + // job->setAutoDelete(false); + // job->setInsecureFallback(false); + // job->setKey(profile); + // connect(job, &QKeychain::ReadPasswordJob::finished, this, + // [=](QKeychain::Job *job) { + // if (job->error()) { + // qWarning() << job->errorString(); + // } + // auto readJob = dynamic_cast(job); callback(readJob->textData()); + // job->deleteLater(); + // }); + // job->start(); +} +void cavoke::auth::AuthenticationManager::deleteSecurePassword( + const QString &profile) { + // FIXME: move to qkeychain + settings.setValue(profile, ""); + // auto *job = new QKeychain::DeletePasswordJob("cavoke_keychain"); + // job->setAutoDelete(false); + // job->setInsecureFallback(false); + // job->setKey(profile); + // job->setProperty("profile", profile); + // connect(job, &QKeychain::WritePasswordJob::finished, this, + // [](QKeychain::Job *job) { + // if (job->error()) { + // qWarning() << job->errorString(); + // } + // job->deleteLater(); + // }); + // job->start(); +} + +void cavoke::auth::AuthenticationManager::relogin() { + deleteSecurePassword(refresh_token_profile); + oauth2.setRefreshToken(""); + // Immediately asks user to relogin + // Terrible solution, couldn't find anything better + // At least he can decline, so fine.... + oauth2.grant(); +} + +const QString cavoke::auth::AuthenticationManager::authorizationUrl = + "https://cavoke.eu.auth0.com/authorize"; +const QString cavoke::auth::AuthenticationManager::accessTokenUrl = + "https://cavoke.eu.auth0.com/oauth/token"; +const QString cavoke::auth::AuthenticationManager::logoutUrl = + "https://cavoke.eu.auth0.com/v2/logout"; +const QString cavoke::auth::AuthenticationManager::clientId = + "yxkEiSikGF6JSaFwIikeLQlUNAUUR0ak"; +const QString cavoke::auth::AuthenticationManager::scope = + "identity sessions profile users offline_access"; +const QString cavoke::auth::AuthenticationManager::audience = + "https://develop.api.cavoke.wlko.me"; // NOTE: should not equal to HOST, as + // this must be registered as API + // endpoint. Basically don't change + // this. +const QString cavoke::auth::AuthenticationManager::refresh_token_profile = + "cavoke_profiles_refresh"; \ No newline at end of file diff --git a/client/AuthenticationManager.h b/client/AuthenticationManager.h new file mode 100644 index 00000000..558cb48d --- /dev/null +++ b/client/AuthenticationManager.h @@ -0,0 +1,45 @@ +#ifndef CAVOKE_AUTHENTICATIONMANAGER_H +#define CAVOKE_AUTHENTICATIONMANAGER_H + +#include +#include + +namespace cavoke::auth { +/// Manages all Oauth2 related things. Including code flow itself and storing +/// refresh tokens. +struct AuthenticationManager : public QObject { + Q_OBJECT +public: + AuthenticationManager() = default; + QOAuth2AuthorizationCodeFlow oauth2; + /// Singleton wrapper + static AuthenticationManager &getInstance() { + static AuthenticationManager obj; + return obj; + } + bool checkAuthStatus(); + void init(); + void relogin(); +signals: + void authenticated(); + +private: + void writeSecurePassword(const QString &profile, const QString &pass); + template + void readSecurePassword(const QString &profile, L callback); + void deleteSecurePassword(const QString &profile); + + QSettings settings{this}; // FIXME: move to qtkeychain + + const static QString authorizationUrl; + const static QString accessTokenUrl; + const static QString logoutUrl; + const static QString clientId; + const static QString scope; + const static QString audience; + + const static QString refresh_token_profile; +}; +} // namespace cavoke::auth + +#endif // CAVOKE_AUTHENTICATIONMANAGER_H diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt index 69b3749d..0bec39f4 100644 --- a/client/CMakeLists.txt +++ b/client/CMakeLists.txt @@ -1,4 +1,4 @@ -cmake_minimum_required(VERSION 3.10) +cmake_minimum_required(VERSION 3.16) project(cavoke_client) set(QT_MAJOR_VERSION 6 CACHE STRING "Qt Major Version (e.g. Qt5/Qt6)") @@ -6,9 +6,18 @@ set(QT_MAJOR_VERSION 6 CACHE STRING "Qt Major Version (e.g. Qt5/Qt6)") set(CMAKE_CXX_STANDARD 17) set(CMAKE_INCLUDE_CURRENT_DIR ON) -find_package(Qt${QT_MAJOR_VERSION}Widgets REQUIRED) -find_package(Qt${QT_MAJOR_VERSION}Quick REQUIRED) -find_package(Qt${QT_MAJOR_VERSION}Network REQUIRED) +find_package(ECM 5.90.0 REQUIRED CONFIG) +set(CMAKE_MODULE_PATH ${CMAKE_MODULE_PATH} ${ECM_MODULE_PATH} ) + +include(FeatureSummary) +include(ECMAddAppIcon) +include(ECMQtDeclareLoggingCategory) +include(ECMInstallIcons) +include(ECMSetupVersion) +include(KDEInstallDirs) +include(KDECMakeSettings) + +find_package(Qt${QT_MAJOR_VERSION} REQUIRED COMPONENTS Widgets Quick Network NetworkAuth) set(CMAKE_AUTOMOC ON) set(CMAKE_AUTOUIC ON) @@ -17,32 +26,49 @@ set(CMAKE_AUTORCC ON) include_directories(${CMAKE_BINARY_DIR} controllers models - views) + views + # ../third_party/qtkeychain + ) # https://api.kde.org/frameworks-api/frameworks-apidocs/frameworks/karchive/html/index.html find_package(KF5Archive REQUIRED) #add_compile_definitions(MOCK) +add_compile_definitions(REAL) + +aux_source_directory(views VIEWS_DIR) +aux_source_directory(entities ENTITIES_DIR) add_executable(cavoke_client main.cpp controllers/cavokeclientcontroller.cpp models/cavokeclientmodel.cpp models/cavokeqmlgamemodel.cpp - views/testwindowview.cpp - views/startview.cpp - views/joingameview.cpp - views/creategameview.cpp - views/settingsview.cpp - views/gameslistview.cpp - views/protoroomview.cpp + ${VIEWS_DIR} + ${ENTITIES_DIR} cache_manager.cpp network_manager.cpp - gameinfo.cpp - sessioninfo.cpp - ) + resources/resources.qrc + AuthenticationManager.cpp) + +target_link_libraries(cavoke_client PRIVATE Qt${QT_MAJOR_VERSION}::Widgets) +target_link_libraries(cavoke_client PRIVATE Qt${QT_MAJOR_VERSION}::Quick) +target_link_libraries(cavoke_client PRIVATE Qt${QT_MAJOR_VERSION}::Network) +target_link_libraries(cavoke_client PRIVATE Qt${QT_MAJOR_VERSION}::NetworkAuth) +target_link_libraries(cavoke_client PRIVATE KF5::Archive) + +install(TARGETS ${PROJECT_NAME} ${KDE_INSTALL_TARGETS_DEFAULT_ARGS}) +configure_file(${CMAKE_SOURCE_DIR}/client/cavoke.desktop + ${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME}.desktop) +install(FILES ${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME}.desktop DESTINATION ${KDE_INSTALL_APPDIR} ) +file(GLOB_RECURSE ICON_FILES "${CMAKE_CURRENT_SOURCE_DIR}/resources/packaging/*-apps-cavoke.png") + +# Add icon files to the application's source files to have CMake bundle them in the executable. +ecm_add_app_icon(ICONS_SOURCES ICONS ${ICON_FILES}) +target_sources(${PROJECT_NAME} PRIVATE ${ICONS_SOURCES}) -target_link_libraries(cavoke_client Qt${QT_MAJOR_VERSION}::Widgets) -target_link_libraries(cavoke_client Qt${QT_MAJOR_VERSION}::Quick) -target_link_libraries(cavoke_client Qt${QT_MAJOR_VERSION}::Network) -target_link_libraries(cavoke_client KF5::Archive) +ecm_install_icons( + ICONS ${ICON_FILES} + DESTINATION ${KDE_INSTALL_ICONDIR} + THEME hicolor +) diff --git a/client/cavoke.desktop b/client/cavoke.desktop new file mode 100644 index 00000000..e0d87da6 --- /dev/null +++ b/client/cavoke.desktop @@ -0,0 +1,11 @@ +[Desktop Entry] +Categories=Game; +Type=Application +Exec=cavoke_client +Name=Cavoke Client +Comment=An online board games platform +Icon=cavoke +GenericName=Online board games platform +Keywords=cavoke;games; +Terminal=false +StartupWMClass=cavoke_client diff --git a/client/controllers/cavokeclientcontroller.cpp b/client/controllers/cavokeclientcontroller.cpp index 1195c25f..75578b25 100644 --- a/client/controllers/cavokeclientcontroller.cpp +++ b/client/controllers/cavokeclientcontroller.cpp @@ -1,4 +1,5 @@ #include "cavokeclientcontroller.h" +#include CavokeClientController::CavokeClientController(QObject *parent) : QObject{parent}, @@ -7,8 +8,11 @@ CavokeClientController::CavokeClientController(QObject *parent) testWindowView{}, startView{}, joinGameView{}, + statisticsView{}, settingsView{}, - protoRoomView{} { + roomView{}, + sessionView{}, + settings{} { // startQml connection connect(&model, SIGNAL(startQmlApplication(CavokeQmlGameModel *)), this, SLOT(startQmlApplication(CavokeQmlGameModel *))); @@ -24,12 +28,13 @@ CavokeClientController::CavokeClientController(QObject *parent) SLOT(showStartView())); connect(&settingsView, SIGNAL(shownStartView()), this, SLOT(showStartView())); - connect(&protoRoomView, SIGNAL(shownStartView()), this, + connect(&statisticsView, SIGNAL(shownStartView()), this, SLOT(showStartView())); + connect(&roomView, SIGNAL(shownStartView()), this, SLOT(showStartView())); // Main navigation buttons from startView connect(&startView, SIGNAL(shownTestWindowView()), this, - SLOT(showTestWindowView())); + SLOT(showTestWindowView())); // Not displayed connect(&startView, SIGNAL(shownJoinGameView()), this, SLOT(showJoinGameView())); connect(&startView, SIGNAL(shownCreateGameView()), this, @@ -38,6 +43,8 @@ CavokeClientController::CavokeClientController(QObject *parent) SLOT(showGamesListView())); connect(&startView, SIGNAL(shownSettingsView()), this, SLOT(showSettingsView())); + connect(&startView, SIGNAL(shownStatisticsView()), this, + SLOT(showStatisticsView())); // startView Exit button connect(&startView, SIGNAL(clickedExitButton()), this, @@ -50,43 +57,47 @@ CavokeClientController::CavokeClientController(QObject *parent) &networkManager, SLOT(getHealth())); // createGameView actions - connect(&createGameView, SIGNAL(currentIndexChanged(int)), &model, - SLOT(receivedGameIndexChange(int))); - connect(&model, SIGNAL(updateSelectedGame(GameInfo)), &createGameView, - SLOT(gotNewSelectedGame(GameInfo))); - - connect(&createGameView, SIGNAL(startedCreateGameRoutine(int)), this, - SLOT(createGameStart(int))); - connect(this, SIGNAL(createGameDownloaded()), this, - SLOT(createGameSendRequest())); - - // both Join and Create gameView actions - connect(&networkManager, SIGNAL(gotSessionInfo(SessionInfo)), this, - SLOT(gotSessionInfo(SessionInfo))); + connect(&createGameView, SIGNAL(startedCreateGameRoutine(QString)), this, + SLOT(createGameStart(QString))); - // joinGameView workflow + // joinGameView actions connect(&joinGameView, SIGNAL(joinedGame(QString)), this, SLOT(joinGameStart(QString))); - connect(this, SIGNAL(joinGameDownloaded()), this, - SLOT(creatingJoiningGameDone())); // gamesListView actions connect(&gamesListView, SIGNAL(currentIndexChanged(int)), &model, SLOT(receivedGameIndexChangeInList(int))); + connect(&gamesListView, SIGNAL(requestGameStatistics(QString)), + &networkManager, SLOT(getGameStatistics(QString))); connect(&model, SIGNAL(updateSelectedGameInList(GameInfo)), &gamesListView, SLOT(gotNewSelectedGame(GameInfo))); + connect(&networkManager, SIGNAL(gotGameStatistics(GameStatistics)), + &gamesListView, SLOT(gotNewGameStatistics(GameStatistics))); connect(&gamesListView, SIGNAL(requestedDownloadGame(int)), &model, SLOT(gotIndexToDownload(int))); + connect(this, SIGNAL(clearScreens()), &gamesListView, SLOT(displayEmpty())); + + // session/room/game-Info processing actions + connect(&networkManager, SIGNAL(gotSessionInfo(SessionInfo)), this, + SLOT(gotSessionInfo(SessionInfo))); + connect(&networkManager, SIGNAL(gotGameInfo(GameInfo)), this, + SLOT(gotCurrentGameInfo(GameInfo))); + connect(this, SIGNAL(setGameName(QString)), &sessionView, + SLOT(updateGameName(QString))); + connect(&networkManager, SIGNAL(gotRoomInfo(RoomInfo)), this, + SLOT(gotRoomInfo(RoomInfo))); // Download games list from server workflow connect(this, SIGNAL(loadGamesList()), &networkManager, SLOT(getGamesList())); connect(&networkManager, SIGNAL(finalizedGamesList(QJsonArray)), &model, SLOT(updateGamesList(QJsonArray))); - connect(&model, SIGNAL(gamesListUpdated(std::vector)), - &createGameView, SLOT(gotGamesListUpdate(std::vector))); connect(&model, SIGNAL(gamesListUpdated(std::vector)), &gamesListView, SLOT(gotGamesListUpdate(std::vector))); + connect(&model, SIGNAL(gamesListUpdated(std::vector)), + &statisticsView, SLOT(gotGamesListUpdate(std::vector))); + connect(&model, SIGNAL(gamesListUpdated(std::vector)), &roomView, + SLOT(gotGamesListUpdate(std::vector))); // Download and unpack game workflow connect(&model, SIGNAL(downloadGame(QString)), &networkManager, @@ -94,19 +105,107 @@ CavokeClientController::CavokeClientController(QObject *parent) connect(&networkManager, SIGNAL(downloadedGameFile(QFile *, QString)), this, SLOT(unpackDownloadedQml(QFile *, QString))); - // protoRoomView actions - connect(&protoRoomView, SIGNAL(joinedCreatedGame(QString)), this, - SLOT(startQmlByGameId(QString))); + // roomView actions + connect(&roomView, SIGNAL(leftRoom()), this, SLOT(leftRoom())); + connect(&roomView, SIGNAL(createdSession(QString)), this, + SLOT(createSessionStart(QString))); + connect(&roomView, SIGNAL(joinedSession(QString)), this, + SLOT(joinSessionStart(QString))); + connect(&roomView, SIGNAL(requestedSessionUpdate(QString)), &networkManager, + SLOT(getSessionInfo(QString))); + + // sessionView actions + connect(&sessionView, SIGNAL(joinedCreatedGame()), this, + SLOT(startLoadedQml())); + connect(&networkManager, SIGNAL(gotValidationResult(ValidationResult)), + &sessionView, SLOT(updateValidationResult(ValidationResult))); + connect(&sessionView, SIGNAL(createdGame()), &networkManager, + SLOT(startSession())); + connect(this, SIGNAL(createdAvailableRolesList(std::vector)), + &sessionView, SLOT(gotRolesListUpdate(std::vector))); + connect(&sessionView, SIGNAL(newRoleChosen(int)), &networkManager, + SLOT(changeRoleInSession(int))); + connect(&sessionView, SIGNAL(leftSession()), this, SLOT(leftSession())); + connect(&sessionView, SIGNAL(shownRoomView()), this, SLOT(showRoomView())); + + // settingsView actions + connect(this, SIGNAL(initSettingsValues(QString, QString)), &settingsView, + SLOT(initStartValues(QString, QString))); + connect(&settingsView, SIGNAL(updatedSettings(QString, QString)), this, + SLOT(updateSettings(QString, QString))); + connect(&networkManager, SIGNAL(gotDisplayName(QString)), &settingsView, + SLOT(updateDisplayName(QString))); + + // statisticsView actions + connect(&networkManager, SIGNAL(gotUserStatistics(UserStatistics)), + &statisticsView, SLOT(gotUserStatisticsUpdate(UserStatistics))); + connect(&statisticsView, SIGNAL(requestedRefresh()), &networkManager, + SLOT(getMyUserStatistics())); + connect(&networkManager, SIGNAL(gotUserGameStatistics(UserGameStatistics)), + &statisticsView, + SLOT(gotUserGameStatisticsUpdate(UserGameStatistics))); + connect(&statisticsView, SIGNAL(statisticsGameChanged(QString)), + &networkManager, SLOT(getMyUserGameStatistics(QString))); + + defaultSettingsInitialization(); + + // oauth reply handler + auto replyHandler = new QOAuthHttpServerReplyHandler(1337, this); + auto &auth = cavoke::auth::AuthenticationManager::getInstance(); + auth.oauth2.setReplyHandler(replyHandler); + + // on new authentication update my user id and display name + connect(&auth, SIGNAL(authenticated()), &networkManager, SLOT(getMe())); + connect(&auth, SIGNAL(authenticated()), &networkManager, + SLOT(getMyUserStatistics())); + // initialize auth in a separate thread + QTimer::singleShot(0, [&]() { auth.init(); }); + + showStartView(); +} - startView.show(); +void CavokeClientController::defaultSettingsInitialization() { + settings.setValue(PLAYER_NICKNAME, + settings.value(PLAYER_NICKNAME, DEFAULT_NICKNAME)); + settings.setValue( + NETWORK_HOST, + settings.value(NETWORK_HOST, NetworkManager::DEFAULT_HOST)); + + networkManager.changeHost( + QUrl::fromUserInput(settings.value(NETWORK_HOST).toString())); + + if (cavoke::auth::AuthenticationManager::getInstance().checkAuthStatus()) { + networkManager.getMe(); + } - emit loadGamesList(); + emit initSettingsValues(settings.value(PLAYER_NICKNAME).toString(), + settings.value(NETWORK_HOST).toString()); } void CavokeClientController::showTestWindowView() { testWindowView.show(); } +void CavokeClientController::leftSession(bool real_leave) { + if (real_leave) { + networkManager.leaveSession(); + } + networkManager.stopGamePolling(); + networkManager.stopSessionPolling(); + networkManager.stopValidationPolling(); + qmlDownloadStatus = QMLDownloadStatus::NOT_STARTED; + hostGuestStatus = HostGuestStatus::NOT_IN; + currentGameInfo = GameInfo(); + currentSessionInfo = SessionInfo(); +} + +void CavokeClientController::leftRoom() { + networkManager.stopRoomPolling(); + networkManager.leaveRoom(); + roomHostGuestStatus = HostGuestStatus::NOT_IN; + currentRoomInfo = RoomInfo(); +} + void CavokeClientController::showStartView() { startView.show(); } @@ -123,6 +222,21 @@ void CavokeClientController::showCreateGameView() { createGameView.show(); } +void CavokeClientController::showRoomView() { + displacement = UserDisplacement::ROOM; + roomView.show(); +} + +void CavokeClientController::showSessionView() { + displacement = UserDisplacement::SESSION; + sessionView.show(); +} + +void CavokeClientController::showStatisticsView() { + statisticsView.requestUpdates(); + statisticsView.show(); +} + void CavokeClientController::showSettingsView() { settingsView.show(); } @@ -152,6 +266,8 @@ void CavokeClientController::exitApplication() { joinGameView.close(); gamesListView.close(); createGameView.close(); + roomView.close(); + sessionView.close(); settingsView.close(); startView.close(); } @@ -165,13 +281,23 @@ void CavokeClientController::startQmlByGameId(const QString &gameId) { connect(&networkManager, SIGNAL(gotGameUpdate(QString)), currentQmlGameModel, SLOT(getUpdateFromNetwork(QString))); connect(currentQmlGameModel, SIGNAL(closingQml()), this, SLOT(stopQml())); - networkManager.startPolling(); + networkManager.startGamePolling(); +} + +void CavokeClientController::startLoadedQml() { + displacement = UserDisplacement::GAME; + networkManager.stopSessionPolling(); + networkManager.stopValidationPolling(); + networkManager.stopRoomPolling(); + startQmlByGameId(currentGameInfo.id); } void CavokeClientController::stopQml() { - startView.show(); + networkManager.stopGamePolling(); + networkManager.startRoomPolling(); + leftSession(false); // Without actual `leave` request, due to issue #171 + showRoomView(); currentQmlGameModel->deleteLater(); - networkManager.stopPolling(); } void CavokeClientController::unpackDownloadedQml(QFile *file, @@ -179,72 +305,161 @@ void CavokeClientController::unpackDownloadedQml(QFile *file, cache_manager::save_zip_to_cache(file, gameId); qDebug() << "UnpackDownloadedQml Finished"; file->deleteLater(); - if (status == CreateJoinControllerStatus::CREATING) { - emit createGameDownloaded(); - } else if (status == CreateJoinControllerStatus::JOINING) { - emit joinGameDownloaded(); - } -} -void CavokeClientController::createGameStart(int gameIndex) { - qDebug() << "Now we are creating game with index: " << gameIndex; - - status = CreateJoinControllerStatus::CREATING; - protoRoomView.prepareJoinCreate(false); + qmlDownloadStatus = QMLDownloadStatus::DOWNLOADED; +} - currentGameId = model.getGameIdByIndex(gameIndex); +void CavokeClientController::createGameStart(const QString &roomName) { + qDebug() << "Now we are creating room with name:" << roomName; createGameView.close(); - protoRoomView.show(); - - downloadCurrentGame(); + showRoomView(); + networkManager.createRoom(roomName); } void CavokeClientController::joinGameStart(const QString &inviteCode) { - qDebug() << "Now we are joinGameStart with inviteCode: " << inviteCode; - - status = CreateJoinControllerStatus::JOINING; - protoRoomView.prepareJoinCreate(true); - - protoRoomView.updateStatus(ProtoRoomView::CreatingGameStatus::REQUESTED); + qDebug() << "Now we are joining room with inviteCode:" << inviteCode; joinGameView.close(); - protoRoomView.show(); + showRoomView(); + networkManager.joinRoom(inviteCode); +} - networkManager.joinSession(inviteCode); +void CavokeClientController::createSessionStart(const QString &gameId) { + qDebug() << "Now we are creating session with gameId:" << gameId; + + roomView.close(); + showSessionView(); + networkManager.roomCreateSession(gameId); } -void CavokeClientController::downloadCurrentGame() { - qDebug() << "Now we are downloading game: " << currentGameId; +void CavokeClientController::joinSessionStart(const QString &sessionId) { + qDebug() << "Now we are joining session with sessionId:" << sessionId; - protoRoomView.updateStatus(ProtoRoomView::CreatingGameStatus::DOWNLOAD); - protoRoomView.updateGameName(currentGameId); - model.gotGameIdToDownload(currentGameId); + roomView.close(); + showSessionView(); + networkManager.joinSession(sessionId); } -void CavokeClientController::createGameSendRequest() { - qDebug() << "Now we are createGameSendRequest with id: " << currentGameId; +void CavokeClientController::gotRoomInfo(const RoomInfo &roomInfo) { + qDebug() << "Got room info"; + + if (displacement != UserDisplacement::ROOM) { + return; // We are not in a room, actually + } - networkManager.createSession(currentGameId); - protoRoomView.updateStatus(ProtoRoomView::CreatingGameStatus::REQUESTED); + currentRoomInfo = roomInfo; + + if (roomHostGuestStatus == HostGuestStatus::NOT_IN) { + networkManager.startRoomPolling(); + } + + if (currentRoomInfo.isHost && + roomHostGuestStatus != HostGuestStatus::HOST) { + becomeRoomHost(); + } else if (!currentRoomInfo.isHost && + roomHostGuestStatus != HostGuestStatus::GUEST) { + becomeRoomGuest(); + } + + roomView.updateRoomInfo(currentRoomInfo); } void CavokeClientController::gotSessionInfo(const SessionInfo &sessionInfo) { qDebug() << "Now we got session info"; - protoRoomView.updateInviteCode(sessionInfo.invite_code); + if (displacement == UserDisplacement::ROOM) { + roomView.updateSessionInfo(sessionInfo); + return; + } + + if (displacement != UserDisplacement::SESSION) { + return; // We are not in a session, actually + } + + currentSessionInfo = sessionInfo; + + if (hostGuestStatus == HostGuestStatus::NOT_IN) { + networkManager.startSessionPolling(); + } + + if (currentSessionInfo.isHost && hostGuestStatus != HostGuestStatus::HOST) { + becomeHost(); + } else if (!currentSessionInfo.isHost && + hostGuestStatus != HostGuestStatus::GUEST) { + becomeGuest(); + } + + if (qmlDownloadStatus == QMLDownloadStatus::NOT_STARTED) { + networkManager.getGamesClient(currentSessionInfo.game_id); + qmlDownloadStatus = QMLDownloadStatus::DOWNLOADING; + sessionView.updateStatus(SessionView::CreatingGameStatus::DOWNLOAD); + } + + sessionView.updateSessionInfo(currentSessionInfo); + + if (currentGameInfo.players_num == 0) { + networkManager.getGamesConfig(currentSessionInfo.game_id); + } else if (qmlDownloadStatus == QMLDownloadStatus::DOWNLOADED) { + sessionView.updateStatus(SessionView::CreatingGameStatus::DONE); + collectListOfAvailableRoles(); + } +} - if (status == CreateJoinControllerStatus::CREATING) { - creatingJoiningGameDone(); - } else if (status == CreateJoinControllerStatus::JOINING) { - currentGameId = sessionInfo.game_id; - protoRoomView.updateGameName(currentGameId); - downloadCurrentGame(); +void CavokeClientController::gotCurrentGameInfo(const GameInfo &gameInfo) { + currentGameInfo = gameInfo; + emit setGameName(currentGameInfo.display_name); +} + +void CavokeClientController::updateSettings(const QString &displayName, + const QString &host) { + settings.setValue(PLAYER_NICKNAME, displayName); + settings.setValue(NETWORK_HOST, host); + emit clearScreens(); + networkManager.changeHost(QUrl::fromUserInput(host)); + networkManager.changeName(displayName); +} +void CavokeClientController::collectListOfAvailableRoles() { + std::vector isFree(currentGameInfo.players_num, true); + int ourRole = -1; + QString userId = networkManager.getUserId(); + for (const auto &player : currentSessionInfo.players) { + isFree[player.player_id] = false; + if (player.user.user_id == userId) { + ourRole = player.player_id; + } } + if (ourRole == -1) { + return; + // I guess it is the bug when we have already left the session but + // still + // somehow made a request to get info about session + } + std::vector availableRoles; + availableRoles.emplace_back(currentGameInfo.role_names[ourRole], + ourRole); // Now first. + for (int i = 0; i < currentGameInfo.players_num; ++i) { + if (isFree[i]) { + availableRoles.emplace_back(currentGameInfo.role_names[i], i); + } + } + emit createdAvailableRolesList(availableRoles); } -void CavokeClientController::creatingJoiningGameDone() { - qDebug() << "Now creating/joining game preparations are done"; +void CavokeClientController::becomeHost() { + hostGuestStatus = HostGuestStatus::HOST; + networkManager.startValidationPolling(); +} + +void CavokeClientController::becomeGuest() { + hostGuestStatus = HostGuestStatus::GUEST; + networkManager.stopValidationPolling(); +} + +void CavokeClientController::becomeRoomHost() { + roomHostGuestStatus = HostGuestStatus::HOST; +} - protoRoomView.updateStatus(ProtoRoomView::CreatingGameStatus::DONE); +void CavokeClientController::becomeRoomGuest() { + roomHostGuestStatus = HostGuestStatus::GUEST; } diff --git a/client/controllers/cavokeclientcontroller.h b/client/controllers/cavokeclientcontroller.h index bbc67996..65caedb6 100644 --- a/client/controllers/cavokeclientcontroller.h +++ b/client/controllers/cavokeclientcontroller.h @@ -1,21 +1,25 @@ #ifndef CAVOKECLIENTCONTROLLER_H #define CAVOKECLIENTCONTROLLER_H -#include -#include #include #include +#include "cache_manager.h" #include "cavokeclientmodel.h" -#include "creategameview.h" -#include "joingameview.h" #include "network_manager.h" -#include "protoroomview.h" -#include "settingsview.h" -#include "startview.h" -#include "testwindowview.h" +#include "views/creategameview.h" +#include "views/gameslistview.h" +#include "views/joingameview.h" +#include "views/roomview.h" +#include "views/sessionview.h" +#include "views/settingsview.h" +#include "views/startview.h" +#include "views/statisticsview.h" +#include "views/testwindowview.h" class CavokeClientController : public QObject { - enum class CreateJoinControllerStatus { NOTHING, CREATING, JOINING }; + enum class UserDisplacement { NOWHERE, ROOM, SESSION, GAME }; + enum class QMLDownloadStatus { NOT_STARTED, DOWNLOADING, DOWNLOADED }; + enum class HostGuestStatus { NOT_IN, HOST, GUEST }; Q_OBJECT public: explicit CavokeClientController(QObject *parent = nullptr); @@ -26,27 +30,46 @@ public slots: void showJoinGameView(); void showGamesListView(); void showCreateGameView(); + void showStatisticsView(); void showSettingsView(); + void showRoomView(); + void showSessionView(); + void updateSettings(const QString &displayName, const QString &host); signals: void loadGamesList(); - void createGameDownloaded(); - void joinGameDownloaded(); + void setGameName(const QString &gameName); + void initSettingsValues(const QString &nickname, const QString &host); + void clearScreens(); + void createdAvailableRolesList(const std::vector &availableRoles); private slots: void startQmlApplication(CavokeQmlGameModel *); void exitApplication(); void startQmlByGameId(const QString &gameId); + void startLoadedQml(); void stopQml(); void unpackDownloadedQml(QFile *file, const QString &gameId); - void createGameStart(int gameIndex); + void createGameStart(const QString &roomName); void joinGameStart(const QString &inviteCode); - void downloadCurrentGame(); - void creatingJoiningGameDone(); - void createGameSendRequest(); + void createSessionStart(const QString &gameId); + void joinSessionStart(const QString &sessionId); + void gotCurrentGameInfo(const GameInfo &gameInfo); + void gotRoomInfo(const RoomInfo &roomInfo); void gotSessionInfo(const SessionInfo &sessionInfo); + void collectListOfAvailableRoles(); + void becomeHost(); + void becomeGuest(); + void becomeRoomHost(); + void becomeRoomGuest(); + void leftSession(bool real_leave = true); + void leftRoom(); private: + void defaultSettingsInitialization(); + const static inline QString PLAYER_NICKNAME{"player/nickname"}; + const static inline QString NETWORK_HOST{"network/host"}; + const static inline QString DEFAULT_NICKNAME{"Guest"}; NetworkManager networkManager; CavokeClientModel model; TestWindowView testWindowView; @@ -54,11 +77,19 @@ private slots: JoinGameView joinGameView; CreateGameView createGameView; GamesListView gamesListView; + StatisticsView statisticsView; SettingsView settingsView; - ProtoRoomView protoRoomView; + RoomView roomView; + SessionView sessionView; CavokeQmlGameModel *currentQmlGameModel = nullptr; - CreateJoinControllerStatus status; - QString currentGameId; + RoomInfo currentRoomInfo; + SessionInfo currentSessionInfo; + GameInfo currentGameInfo; + UserDisplacement displacement = UserDisplacement::NOWHERE; + QMLDownloadStatus qmlDownloadStatus = QMLDownloadStatus::NOT_STARTED; + HostGuestStatus hostGuestStatus = HostGuestStatus::NOT_IN; + HostGuestStatus roomHostGuestStatus = HostGuestStatus::NOT_IN; + QSettings settings; }; #endif // CAVOKECLIENTCONTROLLER_H diff --git a/client/entities/gameinfo.cpp b/client/entities/gameinfo.cpp new file mode 100644 index 00000000..7c109551 --- /dev/null +++ b/client/entities/gameinfo.cpp @@ -0,0 +1,41 @@ +#include "gameinfo.h" +#include +GameInfo::GameInfo() = default; +GameInfo::GameInfo(QString _id, + QString _display_name, + QString _description, + int _players_num, + QVector _role_names) + : id(std::move(_id)), + display_name(std::move(_display_name)), + description(std::move(_description)), + players_num(_players_num), + role_names(std::move(_role_names)) { +} +void GameInfo::read(const QJsonObject &json) { + if (json.contains(ID) && json[ID].isString()) + id = json[ID].toString(); + + if (json.contains(DISPLAY_NAME) && json[DISPLAY_NAME].isString()) + display_name = json[DISPLAY_NAME].toString(); + + if (json.contains(DESCRIPTION) && json[DESCRIPTION].isString()) + description = json[DESCRIPTION].toString(); + + if (json.contains(PLAYERS_NUM) && json[PLAYERS_NUM].isDouble()) + players_num = json[PLAYERS_NUM].toInt(); + + if (json.contains(ROLE_NAMES) && json[ROLE_NAMES].isArray()) { + auto objects = json[ROLE_NAMES].toArray(); + std::transform(objects.begin(), objects.end(), + std::back_inserter(role_names), + [](const QJsonValueRef &obj) { return obj.toString(); }); + } +} +void GameInfo::write(QJsonObject &json) const { + json[ID] = id; + json[DISPLAY_NAME] = display_name; + json[DESCRIPTION] = description; + json[PLAYERS_NUM] = players_num; + // Actually, write is not used +} diff --git a/client/gameinfo.h b/client/entities/gameinfo.h similarity index 52% rename from client/gameinfo.h rename to client/entities/gameinfo.h index e5c577d3..b42758f8 100644 --- a/client/gameinfo.h +++ b/client/entities/gameinfo.h @@ -1,6 +1,7 @@ #ifndef CAVOKE_CLIENT_GAMEINFO_H #define CAVOKE_CLIENT_GAMEINFO_H +#include #include #include struct GameInfo { @@ -9,7 +10,8 @@ struct GameInfo { GameInfo(QString _id, QString _display_name, QString _description, - int _players_num); + int _players_num, + QVector _role_names); void read(const QJsonObject &json); void write(QJsonObject &json) const; @@ -18,6 +20,14 @@ struct GameInfo { QString display_name; QString description; int players_num = 0; + QVector role_names; + +private: + static inline const QString ID = "id"; + static inline const QString DISPLAY_NAME = "display_name"; + static inline const QString DESCRIPTION = "description"; + static inline const QString PLAYERS_NUM = "players_num"; + static inline const QString ROLE_NAMES = "role_names"; }; #endif // CAVOKE_CLIENT_GAMEINFO_H diff --git a/client/entities/gamestatistics.cpp b/client/entities/gamestatistics.cpp new file mode 100644 index 00000000..84072ac3 --- /dev/null +++ b/client/entities/gamestatistics.cpp @@ -0,0 +1,36 @@ +#include "gamestatistics.h" + +GameStatistics::GameStatistics() = default; + +GameStatistics::GameStatistics(int _average_duration_sec, + int _average_players_count, + int _total_time_played_sec, + int _total_games_played) + : average_duration_sec(_average_duration_sec), + average_players_count(_average_players_count), + total_time_played_sec(_total_time_played_sec), + total_games_played(_total_games_played) { +} + +void GameStatistics::read(const QJsonObject &json) { + if (json.contains(AVERAGE_DURATION_SEC) && + json[AVERAGE_DURATION_SEC].isDouble()) { + average_duration_sec = json[AVERAGE_DURATION_SEC].toInt(); + } + if (json.contains(AVERAGE_PLAYERS_COUNT) && + json[AVERAGE_PLAYERS_COUNT].isDouble()) { + average_players_count = json[AVERAGE_PLAYERS_COUNT].toInt(); + } + if (json.contains(TOTAL_TIME_PLAYED_SEC) && + json[TOTAL_TIME_PLAYED_SEC].isDouble()) { + total_time_played_sec = json[TOTAL_TIME_PLAYED_SEC].toInt(); + } + if (json.contains(TOTAL_GAMES_PLAYED) && + json[TOTAL_GAMES_PLAYED].isDouble()) { + total_games_played = json[TOTAL_GAMES_PLAYED].toInt(); + } +} + +void GameStatistics::write(QJsonObject &json) { + assert(false); // Should not be used +} diff --git a/client/entities/gamestatistics.h b/client/entities/gamestatistics.h new file mode 100644 index 00000000..36e4a3bc --- /dev/null +++ b/client/entities/gamestatistics.h @@ -0,0 +1,29 @@ +#ifndef CAVOKE_GAMESTATISTICS_H +#define CAVOKE_GAMESTATISTICS_H + +#include +#include +struct GameStatistics { +public: + GameStatistics(); + GameStatistics(int _average_duration_sec, + int _average_players_count, + int _total_time_played_sec, + int _total_games_played); + + void read(const QJsonObject &json); + static void write(QJsonObject &json); + + int average_duration_sec = 0; + int average_players_count = 0; + int total_time_played_sec = 0; + int total_games_played = 0; + +private: + static inline const QString AVERAGE_DURATION_SEC = "average_duration_sec"; + static inline const QString AVERAGE_PLAYERS_COUNT = "average_players_count"; + static inline const QString TOTAL_TIME_PLAYED_SEC = "total_time_played_sec"; + static inline const QString TOTAL_GAMES_PLAYED = "total_games_played"; +}; + +#endif // CAVOKE_USERSTATISTICS_H diff --git a/client/entities/player.cpp b/client/entities/player.cpp new file mode 100644 index 00000000..e29e354e --- /dev/null +++ b/client/entities/player.cpp @@ -0,0 +1,22 @@ +#include "player.h" + +Player::Player() = default; + +Player::Player(int _player_id, User _user) + : player_id(_player_id), user(std::move(_user)) { +} + +void Player::read(const QJsonObject &json) { + if (json.contains(PLAYER_ID) && + json[PLAYER_ID].isDouble()) { // FIXME: wat? + player_id = json[PLAYER_ID].toInt(); + } + if (json.contains(USER) && json[USER].isObject()) { + user = User(); + user.read(json[USER].toObject()); + } +} + +void Player::write(QJsonObject &json) { + assert(false); // Should not be used +} diff --git a/client/entities/player.h b/client/entities/player.h new file mode 100644 index 00000000..02f21b69 --- /dev/null +++ b/client/entities/player.h @@ -0,0 +1,23 @@ +#ifndef CAVOKE_PLAYER_H +#define CAVOKE_PLAYER_H + +#include +#include +#include "user.h" +struct Player { +public: + Player(); + Player(int _player_id, User _user); + + void read(const QJsonObject &json); + static void write(QJsonObject &json); + + int player_id{}; + User user; + +private: + static inline const QString PLAYER_ID = "player_id"; + static inline const QString USER = "user"; +}; + +#endif // CAVOKE_PLAYER_H diff --git a/client/entities/role.cpp b/client/entities/role.cpp new file mode 100644 index 00000000..0038f37a --- /dev/null +++ b/client/entities/role.cpp @@ -0,0 +1,4 @@ +#include "role.h" + +Role::Role(QString _name, int _id) : name(std::move(_name)), id(_id) { +} diff --git a/client/entities/role.h b/client/entities/role.h new file mode 100644 index 00000000..f669eb5e --- /dev/null +++ b/client/entities/role.h @@ -0,0 +1,12 @@ +#ifndef CAVOKE_ROLE_H +#define CAVOKE_ROLE_H + +#include +struct Role { + QString name = ""; + int id = 0; + + Role(QString _name, int _id); +}; + +#endif // CAVOKE_ROLE_H diff --git a/client/entities/roominfo.cpp b/client/entities/roominfo.cpp new file mode 100644 index 00000000..38c23c6a --- /dev/null +++ b/client/entities/roominfo.cpp @@ -0,0 +1,54 @@ +#include "roominfo.h" +#include +RoomInfo::RoomInfo() = default; +RoomInfo::RoomInfo(QString _room_id, + QString _display_name, + QString _invite_code, + QString _session_id, + QString _host_id, + QVector _members, + SessionInfo _session) + : room_id(std::move(_room_id)), + display_name(std::move(_display_name)), + invite_code(std::move(_invite_code)), + session_id(std::move(_session_id)), + host_id(std::move(_host_id)), + members(std::move(_members)), + session(std::move(_session)) { +} +void RoomInfo::read(const QJsonObject &json) { + if (json.contains(ROOM_ID) && json[ROOM_ID].isString()) { + room_id = json[ROOM_ID].toString(); + } + + if (json.contains(DISPLAY_NAME) && json[DISPLAY_NAME].isString()) { + display_name = json[DISPLAY_NAME].toString(); + } + + if (json.contains(INVITE_CODE) && json[INVITE_CODE].isString()) { + invite_code = json[INVITE_CODE].toString(); + } + + if (json.contains(SESSION_ID) && json[SESSION_ID].isString()) { + session_id = json[SESSION_ID].toString(); + } + + if (json.contains(HOST_ID) && json[HOST_ID].isString()) { + host_id = json[HOST_ID].toString(); + } + + if (json.contains(MEMBERS) && json[MEMBERS].isArray()) { + for (auto obj : json[MEMBERS].toArray()) { + members.push_back(User()); + members.back().read(obj.toObject()); + } + } + + if (json.contains(SESSION) && json[SESSION].isObject()) { + session.read(json[SESSION].toObject()); + isSessionAlive = true; + } +} +void RoomInfo::write(QJsonObject &json) const { + assert(false); // Should not be used +} diff --git a/client/entities/roominfo.h b/client/entities/roominfo.h new file mode 100644 index 00000000..e1569a76 --- /dev/null +++ b/client/entities/roominfo.h @@ -0,0 +1,44 @@ +#ifndef CAVOKE_ROOMINFO_H +#define CAVOKE_ROOMINFO_H + +#include +#include +#include +#include +#include "sessioninfo.h" +#include "user.h" +struct RoomInfo { +public: + RoomInfo(); + RoomInfo(QString _room_id, + QString _display_name, + QString _invite_code, + QString _session_id, + QString _host_id, + QVector _members, + SessionInfo _session); + + void read(const QJsonObject &json); + void write(QJsonObject &json) const; + + QString room_id; + QString display_name; + QString invite_code; + QString session_id; + QString host_id; + QVector members; + SessionInfo session{}; + bool isSessionAlive = false; + bool isHost = false; + +private: + static inline const QString ROOM_ID = "room_id"; + static inline const QString DISPLAY_NAME = "display_name"; + static inline const QString INVITE_CODE = "invite_code"; + static inline const QString SESSION_ID = "session_id"; + static inline const QString HOST_ID = "host_id"; + static inline const QString MEMBERS = "members"; + static inline const QString SESSION = "session"; +}; + +#endif // CAVOKE_ROOMINFO_H diff --git a/client/entities/sessioninfo.cpp b/client/entities/sessioninfo.cpp new file mode 100644 index 00000000..0567735c --- /dev/null +++ b/client/entities/sessioninfo.cpp @@ -0,0 +1,51 @@ +#include "sessioninfo.h" +#include +SessionInfo::SessionInfo() = default; +SessionInfo::SessionInfo(QString _session_id, + QString _game_id, + SessionInfo::Status _status, + QString _host_id, + QVector _players) + : session_id(std::move(_session_id)), + game_id(std::move(_game_id)), + status(_status), + host_id(std::move(_host_id)), + players(std::move(_players)) { +} +void SessionInfo::read(const QJsonObject &json) { + if (json.contains(SESSION_ID) && json[SESSION_ID].isString()) { + session_id = json[SESSION_ID].toString(); + } + + if (json.contains(GAME_ID) && json[GAME_ID].isString()) { + game_id = json[GAME_ID].toString(); + } + + if (json.contains(STATUS) && json[STATUS].isDouble()) { + switch (json[STATUS].toInt()) { + case 0: + status = Status::NOT_STARTED; + break; + case 1: + status = Status::RUNNING; + break; + case 2: + status = Status::FINISHED; + break; + } + } + + if (json.contains(HOST_ID) && json[HOST_ID].isString()) { + host_id = json[HOST_ID].toString(); + } + + if (json.contains(PLAYERS) && json[PLAYERS].isArray()) { + for (auto obj : json[PLAYERS].toArray()) { + players.push_back(Player()); + players.back().read(obj.toObject()); + } + } +} +void SessionInfo::write(QJsonObject &json) const { + assert(false); // Should not be used +} diff --git a/client/entities/sessioninfo.h b/client/entities/sessioninfo.h new file mode 100644 index 00000000..80f2eb3a --- /dev/null +++ b/client/entities/sessioninfo.h @@ -0,0 +1,38 @@ +#ifndef CAVOKE_SESSIONINFO_H +#define CAVOKE_SESSIONINFO_H + +#include +#include +#include +#include +#include "player.h" +struct SessionInfo { + enum class Status { NOT_STARTED, RUNNING, FINISHED }; + +public: + SessionInfo(); + SessionInfo(QString _session_id, + QString _game_id, + SessionInfo::Status _status, + QString _host_id, + QVector _players); + + void read(const QJsonObject &json); + void write(QJsonObject &json) const; + + QString session_id; + QString game_id; + SessionInfo::Status status = Status::FINISHED; + QString host_id; + QVector players; + bool isHost = false; + +private: + static inline const QString SESSION_ID = "session_id"; + static inline const QString GAME_ID = "game_id"; + static inline const QString STATUS = "status"; + static inline const QString HOST_ID = "host_id"; + static inline const QString PLAYERS = "players"; +}; + +#endif // CAVOKE_SESSIONINFO_H diff --git a/client/entities/user.cpp b/client/entities/user.cpp new file mode 100644 index 00000000..b3a0d998 --- /dev/null +++ b/client/entities/user.cpp @@ -0,0 +1,20 @@ +#include "user.h" + +User::User() = default; + +User::User(QString _display_name, QString _user_id) + : display_name(std::move(_display_name)), user_id(std::move(_user_id)) { +} + +void User::read(const QJsonObject &json) { + if (json.contains(DISPLAY_NAME) && json[DISPLAY_NAME].isString()) { + display_name = json[DISPLAY_NAME].toString(); + } + if (json.contains(USER_ID) && json[USER_ID].isString()) { + user_id = json[USER_ID].toString(); + } +} + +void User::write(QJsonObject &json) { + assert(false); // Should not be used +} diff --git a/client/entities/user.h b/client/entities/user.h new file mode 100644 index 00000000..b57b0960 --- /dev/null +++ b/client/entities/user.h @@ -0,0 +1,22 @@ +#ifndef CAVOKE_USER_H +#define CAVOKE_USER_H + +#include +#include +struct User { +public: + User(); + User(QString _display_name, QString _user_id); + + void read(const QJsonObject &json); + static void write(QJsonObject &json); + + QString display_name; + QString user_id; + +private: + static inline const QString DISPLAY_NAME = "display_name"; + static inline const QString USER_ID = "user_id"; +}; + +#endif // CAVOKE_USER_H diff --git a/client/entities/usergamestatistics.cpp b/client/entities/usergamestatistics.cpp new file mode 100644 index 00000000..ff60f61e --- /dev/null +++ b/client/entities/usergamestatistics.cpp @@ -0,0 +1,33 @@ +#include "usergamestatistics.h" +#include + +UserGameStatistics::UserGameStatistics() = default; + +UserGameStatistics::UserGameStatistics(QString _game_id, + int _time_played_sec, + int _games_played, + double _win_rate) + : game_id(std::move(_game_id)), + time_played_sec(_time_played_sec), + games_played(_games_played), + win_rate(_win_rate) { +} + +void UserGameStatistics::read(const QJsonObject &json) { + if (json.contains(GAME_ID) && json[GAME_ID].isString()) { + game_id = json[GAME_ID].toString(); + } + if (json.contains(TIME_PLAYED_SEC) && json[TIME_PLAYED_SEC].isDouble()) { + time_played_sec = json[TIME_PLAYED_SEC].toInt(); + } + if (json.contains(GAMES_PLAYED) && json[GAMES_PLAYED].isDouble()) { + games_played = json[GAMES_PLAYED].toInt(); + } + if (json.contains(WIN_RATE) && json[WIN_RATE].isDouble()) { + win_rate = json[WIN_RATE].toDouble(); + } +} + +void UserGameStatistics::write(QJsonObject &json) { + assert(false); // Should not be used +} diff --git a/client/entities/usergamestatistics.h b/client/entities/usergamestatistics.h new file mode 100644 index 00000000..936f67e1 --- /dev/null +++ b/client/entities/usergamestatistics.h @@ -0,0 +1,29 @@ +#ifndef CAVOKE_USERGAMESTATISTICS_H +#define CAVOKE_USERGAMESTATISTICS_H + +#include +#include +struct UserGameStatistics { +public: + UserGameStatistics(); + UserGameStatistics(QString _game_id, + int _time_played_sec, + int _games_played, + double _win_rate); + + void read(const QJsonObject &json); + static void write(QJsonObject &json); + + QString game_id; + int time_played_sec = 0; + int games_played = 0; + double win_rate = 0; + +private: + static inline const QString GAME_ID = "game_id"; + static inline const QString TIME_PLAYED_SEC = "time_played_sec"; + static inline const QString GAMES_PLAYED = "games_played"; + static inline const QString WIN_RATE = "win_rate"; +}; + +#endif // CAVOKE_USERGAMESTATISTICS_H diff --git a/client/entities/userstatistics.cpp b/client/entities/userstatistics.cpp new file mode 100644 index 00000000..91a4ffe0 --- /dev/null +++ b/client/entities/userstatistics.cpp @@ -0,0 +1,24 @@ +#include "userstatistics.h" + +UserStatistics::UserStatistics() = default; + +UserStatistics::UserStatistics(int _total_time_played_sec, + int _total_games_played) + : total_time_played_sec(_total_time_played_sec), + total_games_played(_total_games_played) { +} + +void UserStatistics::read(const QJsonObject &json) { + if (json.contains(TOTAL_TIME_PLAYED_SEC) && + json[TOTAL_TIME_PLAYED_SEC].isDouble()) { + total_time_played_sec = json[TOTAL_TIME_PLAYED_SEC].toInt(); + } + if (json.contains(TOTAL_GAMES_PLAYED) && + json[TOTAL_GAMES_PLAYED].isDouble()) { + total_games_played = json[TOTAL_GAMES_PLAYED].toInt(); + } +} + +void UserStatistics::write(QJsonObject &json) { + assert(false); // Should not be used +} diff --git a/client/entities/userstatistics.h b/client/entities/userstatistics.h new file mode 100644 index 00000000..84dcd5a7 --- /dev/null +++ b/client/entities/userstatistics.h @@ -0,0 +1,22 @@ +#ifndef CAVOKE_USERSTATISTICS_H +#define CAVOKE_USERSTATISTICS_H + +#include +#include +struct UserStatistics { +public: + UserStatistics(); + UserStatistics(int _total_time_played_sec, int _total_games_played); + + void read(const QJsonObject &json); + static void write(QJsonObject &json); + + int total_time_played_sec = 0; + int total_games_played = 0; + +private: + static inline const QString TOTAL_TIME_PLAYED_SEC = "total_time_played_sec"; + static inline const QString TOTAL_GAMES_PLAYED = "total_games_played"; +}; + +#endif // CAVOKE_USERSTATISTICS_H diff --git a/client/entities/validationresult.cpp b/client/entities/validationresult.cpp new file mode 100644 index 00000000..7529dbdf --- /dev/null +++ b/client/entities/validationresult.cpp @@ -0,0 +1,25 @@ +#include "validationresult.h" +#include +ValidationResult::ValidationResult() = default; +ValidationResult::ValidationResult(bool _success, QString _message) + : success(_success), message(std::move(_message)) { +} + +void ValidationResult::read(const QJsonObject &json) { + if (json.contains(SUCCESS) && json[SUCCESS].isBool()) { + success = json[SUCCESS].toBool(); + } + if (success) { + message = "Now you can start the game!"; + } + if (!success && json.contains(MESSAGE) && json[MESSAGE].isString()) { + message = json[MESSAGE].toString(); + } +} + +void ValidationResult::write(QJsonObject &json) const { + json[SUCCESS] = success; + if (!success) { + json[MESSAGE] = message; + } +} diff --git a/client/entities/validationresult.h b/client/entities/validationresult.h new file mode 100644 index 00000000..20f0f1c6 --- /dev/null +++ b/client/entities/validationresult.h @@ -0,0 +1,22 @@ +#ifndef CAVOKE_VALIDATIONRESULT_H +#define CAVOKE_VALIDATIONRESULT_H + +#include +#include +struct ValidationResult { +public: + ValidationResult(); + ValidationResult(bool _success, QString _message); + + void read(const QJsonObject &json); + void write(QJsonObject &json) const; + + bool success = false; + QString message = "Empty result"; + +private: + static inline const QString SUCCESS = "success"; + static inline const QString MESSAGE = "message"; +}; + +#endif // CAVOKE_VALIDATIONRESULT_H diff --git a/client/gameinfo.cpp b/client/gameinfo.cpp deleted file mode 100644 index 31965622..00000000 --- a/client/gameinfo.cpp +++ /dev/null @@ -1,31 +0,0 @@ -#include "gameinfo.h" -#include -GameInfo::GameInfo() = default; -GameInfo::GameInfo(QString _id, - QString _display_name, - QString _description, - int _players_num) - : id(std::move(_id)), - display_name(std::move(_display_name)), - description(std::move(_description)), - players_num(_players_num) { -} -void GameInfo::read(const QJsonObject &json) { - if (json.contains("id") && json["id"].isString()) - id = json["id"].toString(); - - if (json.contains("display_name") && json["display_name"].isString()) - display_name = json["display_name"].toString(); - - if (json.contains("description") && json["description"].isString()) - description = json["description"].toString(); - - if (json.contains("players_num") && json["players_num"].isDouble()) - players_num = json["players_num"].toInt(); -} -void GameInfo::write(QJsonObject &json) const { - json["id"] = id; - json["display_name"] = display_name; - json["description"] = description; - json["players_num"] = players_num; -} diff --git a/client/main.cpp b/client/main.cpp index 80fea92b..94417f3f 100644 --- a/client/main.cpp +++ b/client/main.cpp @@ -3,6 +3,9 @@ int main(int argc, char *argv[]) { QApplication a(argc, argv); + QCoreApplication::setOrganizationName("Cavoke"); + QCoreApplication::setOrganizationDomain("cavoke.wlko.me"); + QCoreApplication::setApplicationName("Cavoke"); CavokeClientController c(&a); return a.exec(); } diff --git a/client/models/cavokeclientmodel.cpp b/client/models/cavokeclientmodel.cpp index 9a22ffd1..46584fd7 100644 --- a/client/models/cavokeclientmodel.cpp +++ b/client/models/cavokeclientmodel.cpp @@ -16,13 +16,15 @@ void CavokeClientModel::loadQmlGame(const QString &gameId) { } void CavokeClientModel::updateGamesList(const QJsonArray &newGamesList) { std::vector got_from; - for (auto obj : newGamesList) { - got_from.emplace_back(GameInfo()); - got_from.back().read(obj.toObject()); - } - gamesList = got_from; - - if (!gamesList.empty()) { + std::transform(newGamesList.begin(), newGamesList.end(), + std::back_inserter(got_from), [](const QJsonValue &obj) { + GameInfo game_info; + game_info.read(obj.toObject()); + return game_info; + }); + gamesList = std::move(got_from); + + if (!newGamesList.empty()) { qDebug() << "First In Model: " << gamesList[0].id; } emit gamesListUpdated(gamesList); @@ -39,8 +41,8 @@ void CavokeClientModel::gotIndexToDownload(int index) { gotGameIdToDownload(gamesList[index].id); } -QString CavokeClientModel::getGameIdByIndex(int index) { - return gamesList[index].id; +GameInfo CavokeClientModel::getGameByIndex(int index) { + return gamesList[index]; } void CavokeClientModel::gotGameIdToDownload(const QString &gameId) { diff --git a/client/models/cavokeclientmodel.h b/client/models/cavokeclientmodel.h index c559b443..cb4a5838 100644 --- a/client/models/cavokeclientmodel.h +++ b/client/models/cavokeclientmodel.h @@ -3,14 +3,14 @@ #include #include "cavokeqmlgamemodel.h" -#include "gameinfo.h" +#include "entities/gameinfo.h" class CavokeClientModel : public QObject { Q_OBJECT public: explicit CavokeClientModel(QObject *parent = nullptr); - QString getGameIdByIndex(int index); // FIXME: oh no, cringe + GameInfo getGameByIndex(int index); // FIXME: oh no, cringe public slots: void loadQmlGame(const QString &gameId); void updateGamesList(const QJsonArray &newGamesList); diff --git a/client/network_manager.cpp b/client/network_manager.cpp index 1107f123..19fbd0cf 100644 --- a/client/network_manager.cpp +++ b/client/network_manager.cpp @@ -1,14 +1,28 @@ #include "network_manager.h" -NetworkManager::NetworkManager(QObject *parent) : manager(parent) { - pollingTimer = new QTimer(this); - pollingTimer->setInterval(500); - pollingTimer->callOnTimeout([this]() { getUpdate(); }); - userId = QUuid::createUuid(); +NetworkManager::NetworkManager(QObject *parent) + : manager{this}, + oauth2{&cavoke::auth::AuthenticationManager::getInstance().oauth2} { + gamePollingTimer = new QTimer(this); + gamePollingTimer->setInterval(500); + gamePollingTimer->callOnTimeout([this]() { getPlayState(); }); + sessionPollingTimer = new QTimer(this); + sessionPollingTimer->setInterval(500); + sessionPollingTimer->callOnTimeout([this]() { getSessionInfo(); }); + validationPollingTimer = new QTimer(this); + validationPollingTimer->setInterval(500); + validationPollingTimer->callOnTimeout([this]() { validateSession(); }); + roomPollingTimer = new QTimer(this); + roomPollingTimer->setInterval(500); + roomPollingTimer->callOnTimeout([this]() { getRoomInfo(); }); + // generate randomly for local server mode + queryUserId = QUuid::createUuid().toString(QUuid::WithoutBraces); + + oauth2->setNetworkAccessManager(&manager); } void NetworkManager::getHealth() { - auto reply = manager.get(QNetworkRequest(HOST.resolved(HEALTH))); + auto reply = oauth2->get(HOST.resolved(HEALTH)); connect(reply, &QNetworkReply::finished, this, [reply, this]() { gotHealth(reply); }); } @@ -21,7 +35,7 @@ void NetworkManager::gotHealth(QNetworkReply *reply) { } void NetworkManager::getGamesList() { - auto reply = manager.get(QNetworkRequest(HOST.resolved(GAMES_LIST))); + auto reply = oauth2->get(HOST.resolved(GAMES_LIST)); connect(reply, &QNetworkReply::finished, this, [reply, this]() { gotGamesList(reply); }); } @@ -38,24 +52,32 @@ void NetworkManager::gotGamesList(QNetworkReply *reply) { void NetworkManager::getGamesConfig(const QString &gameId) { QUrl route = HOST.resolved(GAMES).resolved(gameId + "/").resolved(GET_CONFIG); - auto request = QNetworkRequest(route); - auto reply = manager.get(request); + route.setQuery({{"user_id", getUserId()}}); + auto reply = oauth2->get(route); connect(reply, &QNetworkReply::finished, this, [reply, this]() { gotGamesConfig(reply); }); } void NetworkManager::gotGamesConfig(QNetworkReply *reply) { - qDebug() << "Got Games Config:"; - qDebug() << reply->readAll(); - qDebug() << "Not implemented yet!!"; + if (reply->error()) { + qDebug() << reply->errorString(); + return; + } + QByteArray answer = reply->readAll(); + reply->close(); + reply->deleteLater(); + qDebug() << answer; + GameInfo gameInfo; + gameInfo.read(QJsonDocument::fromJson(answer).object()); + emit gotGameInfo(gameInfo); } void NetworkManager::getGamesClient(const QString &gameId) { QUrl route = HOST.resolved(GAMES).resolved(gameId + "/").resolved(GET_CLIENT); + route.setQuery({{"user_id", getUserId()}}); qDebug() << route.toString(); - auto request = QNetworkRequest(route); - auto reply = manager.get(request); + auto reply = oauth2->get(route); connect(reply, &QNetworkReply::finished, this, [reply, gameId, this]() { gotGamesClient(reply, gameId); }); } @@ -79,90 +101,334 @@ void NetworkManager::gotGamesClient(QNetworkReply *reply, reply->deleteLater(); } -void NetworkManager::createSession(const QString &gameId) { - QUrl route = HOST.resolved(SESSIONS_CREATE); - route.setQuery({{"game_id", gameId}, - {"user_id", userId.toString(QUuid::WithoutBraces)}}); +void NetworkManager::sendMove(const QString &jsonMove) { + QUrl route = + HOST.resolved(PLAY).resolved(sessionId + "/").resolved(SEND_MOVE); + route.setQuery({{"user_id", getUserId()}}); qDebug() << route.toString(); - auto request = QNetworkRequest(route); - request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, - "application/json"); - auto reply = manager.post(request, "{}"); + auto reply = oauth2->post(route, jsonMove.toUtf8()); connect(reply, &QNetworkReply::finished, this, - [reply, this]() { gotSession(reply); }); + [reply, this]() { gotPostResponse(reply); }); +} + +void NetworkManager::gotPostResponse(QNetworkReply *reply) { + QByteArray answer = reply->readAll(); + reply->close(); + reply->deleteLater(); + qDebug() << "Got some post response: " << answer; } -void NetworkManager::joinSession(const QString &inviteCode) { - QUrl route = HOST.resolved(SESSIONS_JOIN); - route.setQuery({{"user_id", userId.toString(QUuid::WithoutBraces)}, - {"invite_code", inviteCode}}); +void NetworkManager::getPlayState() { + QUrl route = + HOST.resolved(PLAY).resolved(sessionId + "/").resolved(GET_STATE); + route.setQuery({{"user_id", getUserId()}}); + qDebug() << route.toString(); + auto reply = oauth2->get(route); + connect(reply, &QNetworkReply::finished, this, + [reply, this]() { gotPlayState(reply); }); +} + +void NetworkManager::gotPlayState(QNetworkReply *reply) { + if (reply->error()) { + qDebug() << reply->errorString(); + return; + } + QString answer = reply->readAll(); + reply->close(); + reply->deleteLater(); + qDebug() << answer; + emit gotGameUpdate(answer); +} + +void NetworkManager::joinSession(const QString &sessionId) { + QUrl route = + HOST.resolved(SESSIONS).resolved(sessionId + "/").resolved(JOIN); + route.setQuery({{"user_id", getUserId()}}); qDebug() << route.toString(); - auto request = QNetworkRequest(route); - request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, - "application/json"); - auto reply = manager.post(request, "{}"); + auto reply = oauth2->post(route, "{}"); connect(reply, &QNetworkReply::finished, this, [reply, this]() { gotSession(reply); }); } void NetworkManager::gotSession(QNetworkReply *reply) { QByteArray answer = reply->readAll(); + if (reply->error()) { + qDebug() << "Session ERROR:" << reply->errorString(); + } reply->close(); reply->deleteLater(); - qDebug() << answer; + qDebug() << "Got session: " << answer; SessionInfo sessionInfo; sessionInfo.read(QJsonDocument::fromJson(answer).object()); sessionId = sessionInfo.session_id; + sessionInfo.isHost = + sessionInfo.host_id == + queryUserId; // using explicitly query user_id, as in prod mode it is + // returned from the server + emit gotSessionInfo(sessionInfo); } -void NetworkManager::sendMove(const QString &jsonMove) { +void NetworkManager::validateSession() { QUrl route = - HOST.resolved(PLAY).resolved(sessionId + "/").resolved(SEND_MOVE); - route.setQuery({{"user_id", userId.toString(QUuid::WithoutBraces)}}); + HOST.resolved(SESSIONS).resolved(sessionId + "/").resolved(VALIDATE); + route.setQuery({{"user_id", getUserId()}}); + qDebug() << route.toString(); + auto reply = oauth2->post(route, "{}"); + connect(reply, &QNetworkReply::finished, this, + [reply, this]() { gotValidatedSession(reply); }); +} + +void NetworkManager::gotValidatedSession(QNetworkReply *reply) { + QByteArray answer = reply->readAll(); + reply->close(); + reply->deleteLater(); + // qDebug() << answer; + + ValidationResult validationResult; + validationResult.read(QJsonDocument::fromJson(answer).object()); + + emit gotValidationResult(validationResult); +} + +void NetworkManager::getSessionInfo() { + QUrl route = + HOST.resolved(SESSIONS).resolved(sessionId + "/").resolved(GET_INFO); + route.setQuery({{"user_id", getUserId()}}); + qDebug() << route.toString(); + auto reply = oauth2->get(route); + connect(reply, &QNetworkReply::finished, this, + [reply, this]() { gotSession(reply); }); +} + +void NetworkManager::getSessionInfo(const QString &other_sessionId) { + QUrl route = HOST.resolved(SESSIONS) + .resolved(other_sessionId + "/") + .resolved(GET_INFO); + route.setQuery({{"user_id", getUserId()}}); qDebug() << route.toString(); - auto request = QNetworkRequest(route); - request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, - "application/json"); - auto reply = manager.post(request, jsonMove.toUtf8()); + auto reply = oauth2->get(route); + connect(reply, &QNetworkReply::finished, this, + [reply, this]() { gotSession(reply); }); +} + +void NetworkManager::startSession() { + QUrl route = + HOST.resolved(SESSIONS).resolved(sessionId + "/").resolved(START); + route.setQuery({{"user_id", getUserId()}}); + qDebug() << route.toString(); + auto reply = oauth2->post(route, "{}"); connect(reply, &QNetworkReply::finished, this, [reply, this]() { gotPostResponse(reply); }); } -void NetworkManager::gotPostResponse(QNetworkReply *reply) { +void NetworkManager::leaveSession() { + QUrl route = + HOST.resolved(SESSIONS).resolved(sessionId + "/").resolved(LEAVE); + route.setQuery({{"user_id", getUserId()}}); + qDebug() << route.toString(); + auto reply = oauth2->post(route, "{}"); + connect(reply, &QNetworkReply::finished, this, + [reply, this]() { gotPostResponse(reply); }); +} + +void NetworkManager::changeRoleInSession(int newRole) { + QUrl route = + HOST.resolved(SESSIONS).resolved(sessionId + "/").resolved(CHANGE_ROLE); + route.setQuery( + {{"user_id", getUserId()}, {"new_role", QString::number(newRole)}}); + qDebug() << route.toString(); + auto reply = oauth2->post(route, "{}"); + connect(reply, &QNetworkReply::finished, this, + [reply, this]() { gotPostResponse(reply); }); +} + +void NetworkManager::getRoomInfo() { + QUrl route = HOST.resolved(ROOMS).resolved(roomId + "/").resolved(GET_INFO); + route.setQuery({{"user_id", getUserId()}}); + qDebug() << route.toString(); + auto reply = oauth2->get(route); + connect(reply, &QNetworkReply::finished, this, + [reply, this]() { gotRoom(reply); }); +} + +void NetworkManager::gotRoom(QNetworkReply *reply) { QByteArray answer = reply->readAll(); reply->close(); reply->deleteLater(); - qDebug() << "Got some post response: " << answer; + qDebug() << "Got room: " << answer; + + RoomInfo roomInfo; + roomInfo.read(QJsonDocument::fromJson(answer).object()); + roomId = roomInfo.room_id; + roomInfo.isHost = roomInfo.host_id == queryUserId; + + emit gotRoomInfo(roomInfo); } -void NetworkManager::getUpdate() { +void NetworkManager::createRoom(const QString &display_name) { + QUrl route = HOST.resolved(ROOMS_CREATE); + route.setQuery({{"display_name", display_name}, {"user_id", getUserId()}}); + qDebug() << route.toString(); + auto reply = oauth2->post(route, "{}"); + connect(reply, &QNetworkReply::finished, this, + [reply, this]() { gotRoom(reply); }); +} + +void NetworkManager::joinRoom(const QString &inviteCode) { + QUrl route = HOST.resolved(ROOMS_JOIN); + route.setQuery({{"user_id", getUserId()}, {"invite_code", inviteCode}}); + qDebug() << route.toString(); + auto reply = oauth2->post(route, "{}"); + connect(reply, &QNetworkReply::finished, this, + [reply, this]() { gotRoom(reply); }); +} + +void NetworkManager::leaveRoom() { + QUrl route = HOST.resolved(ROOMS).resolved(roomId + "/").resolved(LEAVE); + route.setQuery({{"user_id", getUserId()}}); + qDebug() << route.toString(); + auto reply = oauth2->post(route, "{}"); + connect(reply, &QNetworkReply::finished, this, + [reply, this]() { gotPostResponse(reply); }); +} + +void NetworkManager::roomCreateSession(const QString &game_id) { QUrl route = - HOST.resolved(PLAY).resolved(sessionId + "/").resolved(GET_STATE); - route.setQuery({{"user_id", userId.toString(QUuid::WithoutBraces)}}); + HOST.resolved(ROOMS).resolved(roomId + "/").resolved(CREATE_SESSION); + route.setQuery({{"user_id", getUserId()}, {"game_id", game_id}}); + auto reply = oauth2->post(route, "{}"); + connect(reply, &QNetworkReply::finished, this, + [reply, this]() { gotSession(reply); }); +} + +void NetworkManager::getMe() { + QUrl route = HOST.resolved(PROFILE).resolved(GET_ME); + route.setQuery({{"user_id", getUserId()}}); qDebug() << route.toString(); - auto request = QNetworkRequest(route); - auto reply = manager.get(request); + auto reply = oauth2->get(route); connect(reply, &QNetworkReply::finished, this, - [reply, this]() { gotUpdate(reply); }); + [reply, this]() { gotMyself(reply); }); } -void NetworkManager::gotUpdate(QNetworkReply *reply) { - if (reply->error()) { - qDebug() << reply->errorString(); - return; - } - QString answer = reply->readAll(); +void NetworkManager::gotMyself(QNetworkReply *reply) { + QByteArray answer = reply->readAll(); reply->close(); reply->deleteLater(); - qDebug() << answer; - emit gotGameUpdate(answer); + qDebug() << "Got my profile: " << answer; + + User userInfo; + userInfo.read(QJsonDocument::fromJson(answer).object()); + // update my user id used in query params + queryUserId = userInfo.user_id; + emit gotDisplayName(userInfo.display_name); +} + +void NetworkManager::changeName(const QString &new_name) { + QUrl route = HOST.resolved(PROFILE).resolved(CHANGE_NAME); + route.setQuery({{"user_id", getUserId()}, {"new_name", new_name}}); + qDebug() << route.toString(); + auto reply = oauth2->post(route); + connect(reply, &QNetworkReply::finished, this, + [reply, this]() { gotPostResponse(reply); }); } -void NetworkManager::startPolling() { - pollingTimer->start(); + +void NetworkManager::getMyUserStatistics() { + QUrl route = HOST.resolved(PROFILE).resolved(MY_USER_STATISTICS); + route.setQuery({{"user_id", getUserId()}}); + qDebug() << route.toString(); + auto reply = oauth2->get(route); + connect(reply, &QNetworkReply::finished, this, + [reply, this]() { gotUserStatistics(reply); }); } -void NetworkManager::stopPolling() { - pollingTimer->stop(); +void NetworkManager::gotUserStatistics(QNetworkReply *reply) { + QByteArray answer = reply->readAll(); + reply->close(); + reply->deleteLater(); + qDebug() << "Got my user statistics: " << answer; + + UserStatistics userStatistics; + userStatistics.read(QJsonDocument::fromJson(answer).object()); + emit gotUserStatistics(userStatistics); +} + +void NetworkManager::getMyUserGameStatistics(const QString &gameId) { + QUrl route = HOST.resolved(PROFILE) + .resolved(MY_USER_GAME_STATISTICS) + .resolved(gameId); + route.setQuery({{"user_id", getUserId()}}); + qDebug() << route.toString(); + auto reply = oauth2->get(route); + connect(reply, &QNetworkReply::finished, this, + [reply, this]() { gotUserGameStatistics(reply); }); +} + +void NetworkManager::gotUserGameStatistics(QNetworkReply *reply) { + QByteArray answer = reply->readAll(); + reply->close(); + reply->deleteLater(); + qDebug() << "Got my user game statistics: " << answer; + + UserGameStatistics userGameStatistics; + userGameStatistics.read(QJsonDocument::fromJson(answer).object()); + emit gotUserGameStatistics(userGameStatistics); +} + +void NetworkManager::getGameStatistics(const QString &gameId) { + QUrl route = HOST.resolved(STATISTICS_GAME).resolved(gameId); + route.setQuery({{"user_id", getUserId()}}); + qDebug() << route.toString(); + auto reply = oauth2->get(route); + connect(reply, &QNetworkReply::finished, this, + [reply, this]() { gotGameStatistics(reply); }); +} + +void NetworkManager::gotGameStatistics(QNetworkReply *reply) { + QByteArray answer = reply->readAll(); + reply->close(); + reply->deleteLater(); + qDebug() << "Got game statistics: " << answer; + + GameStatistics gameStatistics; + gameStatistics.read(QJsonDocument::fromJson(answer).object()); + emit gotGameStatistics(gameStatistics); +} + +void NetworkManager::startGamePolling() { + gamePollingTimer->start(); +} + +void NetworkManager::stopGamePolling() { + gamePollingTimer->stop(); +} + +void NetworkManager::startSessionPolling() { + sessionPollingTimer->start(); +} +void NetworkManager::stopSessionPolling() { + sessionPollingTimer->stop(); +} +void NetworkManager::startValidationPolling() { + validationPollingTimer->start(); +} + +void NetworkManager::stopValidationPolling() { + validationPollingTimer->stop(); +} + +void NetworkManager::startRoomPolling() { + roomPollingTimer->start(); +} + +void NetworkManager::stopRoomPolling() { + roomPollingTimer->stop(); +} +void NetworkManager::changeHost(const QUrl &newHost) { + HOST = newHost; + getGamesList(); +} +QString NetworkManager::getUserId() { + return queryUserId; } diff --git a/client/network_manager.h b/client/network_manager.h index 7db099fa..f96ed554 100644 --- a/client/network_manager.h +++ b/client/network_manager.h @@ -1,71 +1,147 @@ #ifndef CAVOKE_CLIENT_NETWORK_MANAGER_H #define CAVOKE_CLIENT_NETWORK_MANAGER_H +#include #include +#include #include #include #include #include #include #include -#include #include -#include "sessioninfo.h" +#include "AuthenticationManager.h" +#include "entities/gameinfo.h" +#include "entities/gamestatistics.h" +#include "entities/roominfo.h" +#include "entities/sessioninfo.h" +#include "entities/usergamestatistics.h" +#include "entities/userstatistics.h" +#include "entities/validationresult.h" + struct NetworkManager : public QObject { Q_OBJECT public: + const static inline QUrl DEFAULT_HOST{"https://develop.api.cavoke.wlko.me"}; explicit NetworkManager(QObject *parent = nullptr); + void changeHost(const QUrl &newHost); + QString getUserId(); public slots: void getHealth(); + void getGamesList(); void getGamesConfig(const QString &gameId); void getGamesClient(const QString &gameId); - void createSession(const QString &gameId); - void joinSession(const QString &inviteCode); + void sendMove(const QString &jsonMove); - void getUpdate(); + void getPlayState(); + + void joinSession(const QString &sessionId); + void validateSession(); + void getSessionInfo(); + void getSessionInfo(const QString &sessionId); + void startSession(); + void leaveSession(); + void changeRoleInSession(int newRole); + + void getRoomInfo(); + void createRoom(const QString &display_name); + void joinRoom(const QString &invite_code); + void leaveRoom(); + void roomCreateSession(const QString &game_id); + + void getMe(); + void changeName(const QString &new_name); - void startPolling(); - void stopPolling(); + void getMyUserStatistics(); + void getMyUserGameStatistics(const QString &gameId); + void getGameStatistics(const QString &gameId); + + void startGamePolling(); + void stopGamePolling(); + void startSessionPolling(); + void stopSessionPolling(); + void startValidationPolling(); + void stopValidationPolling(); + void startRoomPolling(); + void stopRoomPolling(); signals: void finalizedGamesList(QJsonArray list); void gotGameUpdate(const QString &jsonField); void downloadedGameFile(QFile *file, const QString &gameId); + void gotGameInfo(const GameInfo &gameInfo); void gotSessionInfo(const SessionInfo &sessionInfo); + void gotValidationResult(const ValidationResult &validationResult); + void gotDisplayName(const QString &displayName); + void gotUserStatistics(const UserStatistics &userStatistics); + void gotUserGameStatistics(const UserGameStatistics &userGameStatistics); + void gotGameStatistics(const GameStatistics &gameStatistics); + void gotRoomInfo(const RoomInfo &roomInfo); private slots: void gotHealth(QNetworkReply *reply); void gotGamesList(QNetworkReply *reply); void gotGamesConfig(QNetworkReply *reply); void gotSession(QNetworkReply *reply); + void gotValidatedSession(QNetworkReply *reply); void gotPostResponse(QNetworkReply *reply); - void gotUpdate(QNetworkReply *reply); + void gotPlayState(QNetworkReply *reply); void gotGamesClient(QNetworkReply *reply, const QString &gameId); + void gotMyself(QNetworkReply *reply); + void gotUserStatistics(QNetworkReply *reply); + void gotUserGameStatistics(QNetworkReply *reply); + void gotGameStatistics(QNetworkReply *reply); + void gotRoom(QNetworkReply *reply); private: + QOAuth2AuthorizationCodeFlow *oauth2; QNetworkAccessManager manager; - QTimer *pollingTimer = nullptr; + QTimer *gamePollingTimer = nullptr; + QTimer *sessionPollingTimer = nullptr; + QTimer *validationPollingTimer = nullptr; + QTimer *roomPollingTimer = nullptr; QString sessionId; - QUuid userId; - const static inline QUrl HOST{ -#ifdef MOCK - "https://764bbfca-c45a-46fc-9c79-11d9094b9ba8.mock.pstmn.io/" -#else - "http://127.0.0.1:8080/" -#endif - }; - const static inline QUrl HEALTH{"health"}; // FIXME: move to routes module + QString roomId; + /// `user_id` query param. + /// For local servers (without jwt) is generated randomly through QUuid. + /// For prod servers (with jwt) is acquired from the server (through + /// `get_me`). + QString queryUserId; + QUrl HOST{DEFAULT_HOST}; + const static inline QUrl HEALTH{"health"}; const static inline QUrl GAMES_LIST{"games/list"}; const static inline QUrl GAMES{"games/"}; const static inline QUrl GET_CONFIG{"get_config"}; const static inline QUrl GET_CLIENT{"get_client"}; - const static inline QUrl SESSIONS_CREATE{"sessions/create"}; - const static inline QUrl SESSIONS_JOIN{"sessions/join"}; + // Rooms activity + const static inline QUrl ROOMS{"rooms/"}; + const static inline QUrl GET_INFO{"get_info"}; + const static inline QUrl ROOMS_CREATE{"rooms/create"}; + const static inline QUrl ROOMS_JOIN{"rooms/join"}; + const static inline QUrl LEAVE{"leave"}; + const static inline QUrl CREATE_SESSION{"create_session"}; + + const static inline QUrl JOIN{"join"}; + const static inline QUrl SESSIONS{"sessions/"}; + const static inline QUrl VALIDATE{"validate"}; + const static inline QUrl START{"start"}; + const static inline QUrl CHANGE_ROLE{"change_role"}; + const static inline QUrl PLAY{"play/"}; const static inline QUrl SEND_MOVE{"send_move"}; const static inline QUrl GET_STATE{"get_state"}; + + const static inline QUrl PROFILE{"profile/"}; + const static inline QUrl GET_ME{"get_me"}; + const static inline QUrl CHANGE_NAME{"change_name"}; + const static inline QUrl MY_USER_STATISTICS{"my_user_statistics"}; + const static inline QUrl MY_USER_GAME_STATISTICS{ + "my_user_game_statistics/"}; + + const static inline QUrl STATISTICS_GAME{"statistics/game/"}; }; #endif // CAVOKE_CLIENT_NETWORK_MANAGER_H diff --git a/client/resource.qrc b/client/resource.qrc deleted file mode 100644 index e12eccc8..00000000 --- a/client/resource.qrc +++ /dev/null @@ -1,11 +0,0 @@ - - - tictactoe-files/tic-tac-toe.qml - tictactoe-files/content/Button.qml - tictactoe-files/content/TicTac.qml - tictactoe-files/content/interactions.js - tictactoe-files/content/pics/board.png - tictactoe-files/content/pics/o.png - tictactoe-files/content/pics/x.png - - diff --git a/client/resources/images/cavoke_64.png b/client/resources/images/cavoke_64.png new file mode 100644 index 00000000..91dd65eb Binary files /dev/null and b/client/resources/images/cavoke_64.png differ diff --git a/client/resources/images/info_48.png b/client/resources/images/info_48.png new file mode 100644 index 00000000..39dc9ce2 Binary files /dev/null and b/client/resources/images/info_48.png differ diff --git a/client/resources/packaging/128-apps-cavoke.png b/client/resources/packaging/128-apps-cavoke.png new file mode 100644 index 00000000..c1bf0ab0 Binary files /dev/null and b/client/resources/packaging/128-apps-cavoke.png differ diff --git a/client/resources/packaging/32-apps-cavoke.png b/client/resources/packaging/32-apps-cavoke.png new file mode 100644 index 00000000..625b0cf3 Binary files /dev/null and b/client/resources/packaging/32-apps-cavoke.png differ diff --git a/client/resources/packaging/64-apps-cavoke.png b/client/resources/packaging/64-apps-cavoke.png new file mode 100644 index 00000000..91dd65eb Binary files /dev/null and b/client/resources/packaging/64-apps-cavoke.png differ diff --git a/client/resources/packaging/cavoke.ico b/client/resources/packaging/cavoke.ico new file mode 100644 index 00000000..d55aab26 Binary files /dev/null and b/client/resources/packaging/cavoke.ico differ diff --git a/client/resources/resources.qrc b/client/resources/resources.qrc new file mode 100644 index 00000000..0b0e7747 --- /dev/null +++ b/client/resources/resources.qrc @@ -0,0 +1,6 @@ + + + images/cavoke_64.png + images/info_48.png + + diff --git a/client/sessioninfo.cpp b/client/sessioninfo.cpp deleted file mode 100644 index 09961c89..00000000 --- a/client/sessioninfo.cpp +++ /dev/null @@ -1,28 +0,0 @@ -#include "sessioninfo.h" -#include -SessionInfo::SessionInfo() = default; -SessionInfo::SessionInfo(QString _game_id, - QString _session_id, - QString _invite_code) - : game_id(std::move(_game_id)), - session_id(std::move(_session_id)), - invite_code(std::move(_invite_code)) { -} -void SessionInfo::read(const QJsonObject &json) { - if (json.contains(GAME_ID) && json[GAME_ID].isString()) { - game_id = json[GAME_ID].toString(); - } - - if (json.contains(SESSION_ID) && json[SESSION_ID].isString()) { - session_id = json[SESSION_ID].toString(); - } - - if (json.contains(INVITE_CODE) && json[INVITE_CODE].isString()) { - invite_code = json[INVITE_CODE].toString(); - } -} -void SessionInfo::write(QJsonObject &json) const { - json[GAME_ID] = game_id; - json[SESSION_ID] = session_id; - json[INVITE_CODE] = invite_code; -} diff --git a/client/sessioninfo.h b/client/sessioninfo.h deleted file mode 100644 index 572dffa8..00000000 --- a/client/sessioninfo.h +++ /dev/null @@ -1,24 +0,0 @@ -#ifndef CAVOKE_SESSIONINFO_H -#define CAVOKE_SESSIONINFO_H - -#include -#include -struct SessionInfo { -public: - SessionInfo(); - SessionInfo(QString _game_id, QString _session_id, QString _invite_code); - - void read(const QJsonObject &json); - void write(QJsonObject &json) const; - - QString game_id; - QString session_id; - QString invite_code; - -private: - static inline const QString GAME_ID = "game_id"; - static inline const QString SESSION_ID = "session_id"; - static inline const QString INVITE_CODE = "invite_code"; -}; - -#endif // CAVOKE_SESSIONINFO_H diff --git a/client/tictactoe-files/content/pics/x.png b/client/tictactoe-files/content/pics/x.png index 19506ee7..be59260a 100644 Binary files a/client/tictactoe-files/content/pics/x.png and b/client/tictactoe-files/content/pics/x.png differ diff --git a/client/tictactoe-files/tic-tac-toe.qml b/client/tictactoe-files/tic-tac-toe.qml index a0d325be..57f3efe5 100644 --- a/client/tictactoe-files/tic-tac-toe.qml +++ b/client/tictactoe-files/tic-tac-toe.qml @@ -88,19 +88,6 @@ Rectangle { Row { spacing: 4 anchors.horizontalCenter: parent.horizontalCenter - - Button { - text: "Hard" - onClicked: { cavoke.getMoveFromQml("D 1.0"); } - } - Button { - text: "Moderate" - onClicked: { cavoke.getMoveFromQml("D 0.8"); } - } - Button { - text: "Easy" - onClicked: { cavoke.getMoveFromQml("D 0.2"); } - } } } diff --git a/client/views/authdialog.cpp b/client/views/authdialog.cpp new file mode 100644 index 00000000..cb9696d3 --- /dev/null +++ b/client/views/authdialog.cpp @@ -0,0 +1,37 @@ +#include "authdialog.h" +#include "ui_authdialog.h" + +AuthDialog::AuthDialog(QWidget *parent) + : ui(new Ui::AuthDialog), QDialog(parent) { + ui->setupUi(this); +} + +AuthDialog::~AuthDialog() { + delete ui; +} + +void AuthDialog::on_acceptButton_clicked() { + // connect authenticated status with dialog's successful closing + connect(authManager, &cavoke::auth::AuthenticationManager::authenticated, + this, &AuthDialog::accept); + // authorize through browser + authManager->oauth2.grant(); +} + +void AuthDialog::on_declineButton_clicked() { + qWarning() << "User declined authorization! Unable to proceed."; + QDialog::reject(); +} + +/// Auth guard for methods that require authentication (e.g. session creation) +/// @returns `true` if authenticated successfully, `false` otherwise +bool AuthDialog::verifyAuth(QWidget *parent) { + auto *authManager = &cavoke::auth::AuthenticationManager::getInstance(); + // check if user already logged in + if (authManager->checkAuthStatus()) { + qDebug() << "Already authenticated"; + return true; + } + // otherwise launch the dialog and ask the user to log in + return AuthDialog(parent).exec() == 1; +} diff --git a/client/views/authdialog.h b/client/views/authdialog.h new file mode 100644 index 00000000..c6a4fbdf --- /dev/null +++ b/client/views/authdialog.h @@ -0,0 +1,34 @@ +#ifndef AUTHDIALOG_H +#define AUTHDIALOG_H + +#include +#include +#include "AuthenticationManager.h" + +namespace Ui { +class AuthDialog; +} + +/// Dialog that asks users to log in in order to use methods that require +/// authentication. +class AuthDialog : public QDialog { + Q_OBJECT + +public: + explicit AuthDialog(QWidget *parent = nullptr); + ~AuthDialog(); + + static bool verifyAuth(QWidget *parent = nullptr); + +private slots: + void on_acceptButton_clicked(); + + void on_declineButton_clicked(); + +private: + Ui::AuthDialog *ui; + cavoke::auth::AuthenticationManager *authManager = + &cavoke::auth::AuthenticationManager::getInstance(); +}; + +#endif // AUTHDIALOG_H diff --git a/client/views/authdialog.ui b/client/views/authdialog.ui new file mode 100644 index 00000000..a6aa0ea9 --- /dev/null +++ b/client/views/authdialog.ui @@ -0,0 +1,55 @@ + + + AuthDialog + + + + 0 + 0 + 274 + 147 + + + + Sign in + + + true + + + + + + Sign in to complete this operation + + + Qt::AlignCenter + + + + + + + + + Authorize with browser + + + true + + + + + + + Cancel + + + + + + + + + + diff --git a/client/views/creategameview.cpp b/client/views/creategameview.cpp index ef990051..3a347c60 100644 --- a/client/views/creategameview.cpp +++ b/client/views/creategameview.cpp @@ -1,15 +1,10 @@ #include "creategameview.h" +#include "authdialog.h" #include "ui_creategameview.h" CreateGameView::CreateGameView(QWidget *parent) : QMainWindow(parent), ui(new Ui::CreateGameView) { ui->setupUi(this); - connect(ui->gamesListComboBox, SIGNAL(currentIndexChanged(int)), this, - SLOT(repeaterCurrentIndexChanged(int))); -} - -void CreateGameView::repeaterCurrentIndexChanged(int index) { - emit currentIndexChanged(index); } CreateGameView::~CreateGameView() { @@ -20,19 +15,13 @@ void CreateGameView::on_backButton_clicked() { this->close(); emit shownStartView(); } -void CreateGameView::gotGamesListUpdate( - const std::vector &newGamesList) { - for (const auto &gameInfo : newGamesList) { - ui->gamesListComboBox->addItem(gameInfo.display_name); - } -} -void CreateGameView::gotNewSelectedGame(const GameInfo &gameInfo) { - ui->gameNameLabel->setText(gameInfo.display_name); - ui->gameDescriptionTextBrowser->setText(gameInfo.description); - ui->playersAmountLabel->setText(QString::number(gameInfo.players_num)); - ui->createGameButton->setEnabled(true); -} + void CreateGameView::on_createGameButton_clicked() { - // this->close(); - emit startedCreateGameRoutine(ui->gamesListComboBox->currentIndex()); + if (!AuthDialog::verifyAuth(this)) { + return; + } + if (ui->roomNameEdit->text().isEmpty()) { + return; + } + emit startedCreateGameRoutine(ui->roomNameEdit->text()); } diff --git a/client/views/creategameview.h b/client/views/creategameview.h index 23d84230..9f8ce258 100644 --- a/client/views/creategameview.h +++ b/client/views/creategameview.h @@ -2,7 +2,6 @@ #define CAVOKE_CLIENT_CREATEGAMEVIEW_H #include -#include "gameinfo.h" namespace Ui { class CreateGameView; @@ -15,18 +14,13 @@ class CreateGameView : public QMainWindow { ~CreateGameView(); public slots: - void gotGamesListUpdate(const std::vector &newGamesList); - void gotNewSelectedGame(const GameInfo &gameInfo); - signals: void shownStartView(); - void currentIndexChanged(int index); - void startedCreateGameRoutine(int gameIndex); + void startedCreateGameRoutine(const QString &roomName); private slots: void on_backButton_clicked(); void on_createGameButton_clicked(); - void repeaterCurrentIndexChanged(int index); private: Ui::CreateGameView *ui; diff --git a/client/views/creategameview.ui b/client/views/creategameview.ui index 572d23bf..822dac4d 100644 --- a/client/views/creategameview.ui +++ b/client/views/creategameview.ui @@ -10,141 +10,196 @@ 600 + + + 800 + 600 + + + + + 800 + 600 + + - MainWindow + Cavoke - - - - 40 - 480 - 90 - 32 - - - - Back - - - + - 280 - 60 - 181 - 71 + 9 + 9 + 782 + 541 - - - 16 - - - - CREATE GAME - - - false + + Qt::AlignCenter - - - - - 220 - 190 - 291 - 41 - - - - - - - 70 - 200 - 131 - 31 - - - - Choose game to play - - - - - - 220 - 270 - 251 - 21 - - - - Game name - - - - - - 220 - 300 - 251 - 121 - - - - <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> -<html><head><meta name="qrichtext" content="1" /><meta charset="utf-8" /><style type="text/css"> -p, li { white-space: pre-wrap; } -</style></head><body style=" font-family:'Sans Serif'; font-size:9pt; font-weight:400; font-style:normal;"> -<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-family:'Cantarell'; font-size:10pt;">Game description</span></p></body></html> - - - - - - 520 - 280 - 111 - 21 - - - - Players allowed: - - - - - - 630 - 280 - 57 - 21 - - - - 0 - - - - - false - - - - 520 - 380 - 90 - 32 - - - - Create game - + + + + 1 + 4 + 61 + 31 + + + + + 0 + 0 + + + + Back + + + false + + + + + + 59 + 30 + 661 + 481 + + + + + 10 + + + 10 + + + + + 200 + + + 20 + + + 200 + + + 20 + + + + + true + + + + 0 + 0 + + + + Create room + + + + + + + + + Room name: + + + + + + + + 16 + + + + CREATE ROOM + + + false + + + Qt::AlignCenter + + + + + + + 10 + + + 10 + + + 10 + + + 10 + + + + + + 0 + 0 + + + + + 16 + + + + Room name + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + gridLayoutWidget + backButton @@ -153,7 +208,7 @@ p, li { white-space: pre-wrap; } 0 0 800 - 19 + 28 diff --git a/client/views/gameslistview.cpp b/client/views/gameslistview.cpp index 5a371f9e..40498910 100644 --- a/client/views/gameslistview.cpp +++ b/client/views/gameslistview.cpp @@ -4,12 +4,17 @@ GamesListView::GamesListView(QWidget *parent) : QMainWindow(parent), ui(new Ui::GamesListView) { ui->setupUi(this); - connect(ui->gamesListWidget, SIGNAL(currentRowChanged(int)), this, + connect(ui->gameNameComboBox, SIGNAL(currentIndexChanged(int)), this, SLOT(repeaterCurrentIndexChanged(int))); } void GamesListView::repeaterCurrentIndexChanged(int index) { + if (index == -1) { + displayEmpty(); + return; + } emit currentIndexChanged(index); + emit requestGameStatistics(ui->gameNameComboBox->currentData().toString()); } GamesListView::~GamesListView() { @@ -22,19 +27,34 @@ void GamesListView::on_backButton_clicked() { } void GamesListView::on_downloadQmlButton_clicked() { - emit requestedDownloadGame(ui->gamesListWidget->currentRow()); + emit requestedDownloadGame(ui->gameNameComboBox->currentIndex()); } void GamesListView::gotGamesListUpdate( const std::vector &newGamesList) { + ui->gameNameComboBox->clear(); for (const auto &gameInfo : newGamesList) { - ui->gamesListWidget->addItem(gameInfo.display_name); + ui->gameNameComboBox->addItem(gameInfo.display_name, gameInfo.id); } } void GamesListView::gotNewSelectedGame(const GameInfo &gameInfo) { - ui->gameNameLabel->setText(gameInfo.display_name); ui->gameDescriptionTextBrowser->setText(gameInfo.description); - ui->playersAmountLabel->setText(QString::number(gameInfo.players_num)); - // ui->createGameButton->setEnabled(true); + ui->playersAllowedLabel->setText(QString::number(gameInfo.players_num)); +} + +void GamesListView::gotNewGameStatistics(const GameStatistics &gameStatistics) { + ui->averageDurationLabel->setText( + QString::number(gameStatistics.average_duration_sec)); + ui->averagePlayersLabel->setText( + QString::number(gameStatistics.average_players_count)); + ui->totalTimePlayedLabel->setText( + QString::number(gameStatistics.total_time_played_sec)); + ui->totalGamesPlayedLabel->setText( + QString::number(gameStatistics.total_games_played)); +} + +void GamesListView::displayEmpty() { + ui->gameDescriptionTextBrowser->clear(); + ui->playersAllowedLabel->clear(); } diff --git a/client/views/gameslistview.h b/client/views/gameslistview.h index c9c54f3c..c4a22387 100644 --- a/client/views/gameslistview.h +++ b/client/views/gameslistview.h @@ -1,8 +1,9 @@ #ifndef CAVOKE_GAMESLISTVIEW_H #define CAVOKE_GAMESLISTVIEW_H -#include #include +#include "entities/gameinfo.h" +#include "entities/gamestatistics.h" namespace Ui { class GamesListView; @@ -17,10 +18,13 @@ class GamesListView : public QMainWindow { public slots: void gotGamesListUpdate(const std::vector &newGamesList); void gotNewSelectedGame(const GameInfo &gameInfo); + void displayEmpty(); + void gotNewGameStatistics(const GameStatistics &gameStatistics); signals: void shownStartView(); void currentIndexChanged(int index); + void requestGameStatistics(const QString &gameId); void requestedDownloadGame(int index); private slots: diff --git a/client/views/gameslistview.ui b/client/views/gameslistview.ui index a079b4b3..d629c03a 100644 --- a/client/views/gameslistview.ui +++ b/client/views/gameslistview.ui @@ -10,120 +10,223 @@ 600 + + + 800 + 600 + + + + + 800 + 600 + + - MainWindow + Cavoke - - - - 40 - 480 - 90 - 32 - - - - Back - - - - - - 250 - 60 - 241 - 41 - - - - ALL AVAILABLE GAMES ON SERVER - - - false - - - Qt::AlignCenter - - - - - - 145 - 121 - 371 - 321 - - - - + - 540 - 210 - 251 - 121 + 9 + 9 + 782 + 541 - - <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> + + + + + + + 1 + 4 + 61 + 31 + + + + Back + + + + + + 59 + 29 + 661 + 481 + + + + + 10 + + + 10 + + + + + + 0 + 0 + + + + + + + + 200 + + + 20 + + + 200 + + + 20 + + + + + + 0 + 0 + + + + Download QML + + + + + + + + + Players allowed: + + + + + + + Game name: + + + + + + + + 0 + 0 + + + + <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> <html><head><meta name="qrichtext" content="1" /><style type="text/css"> p, li { white-space: pre-wrap; } </style></head><body style=" font-family:'Cantarell'; font-size:10pt; font-weight:400; font-style:normal;"> <p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;">Game description</p></body></html> - - - - - - 550 - 130 - 221 - 21 - - - - Game name - - - - - - 550 - 170 - 111 - 21 - - - - Players allowed: - - - - - - 670 - 170 - 57 - 16 - - - - 0 - - - - - - 550 - 370 - 121 - 31 - - - - Download QML - + + + + + + + 0 + + + + + + + Average duration, seconds: + + + + + + + Game description: + + + + + + + Average players count: + + + + + + + + 16 + + + + Qt::LeftToRight + + + AVAILABLE GAMES ON SERVER + + + Qt::AlignCenter + + + + + + + Total time played, seconds: + + + + + + + Total games played: + + + + + + + 0 + + + + + + + 0 + + + + + + + 0 + + + + + + + 0 + + + + + diff --git a/client/views/joingameview.cpp b/client/views/joingameview.cpp index 7df5e378..c361855a 100644 --- a/client/views/joingameview.cpp +++ b/client/views/joingameview.cpp @@ -1,5 +1,5 @@ #include "joingameview.h" -#include +#include "authdialog.h" #include "ui_joingameview.h" JoinGameView::JoinGameView(QWidget *parent) @@ -18,6 +18,10 @@ void JoinGameView::on_backButton_clicked() { void JoinGameView::on_joinGameButton_clicked() { QString inviteCode = ui->inviteCodeInput->text(); + // verify user is authenticated + if (!AuthDialog::verifyAuth(this)) { + return; + } if (!inviteCode.isEmpty()) { emit joinedGame(inviteCode); } diff --git a/client/views/joingameview.ui b/client/views/joingameview.ui index 608cdb33..27e7ab6a 100644 --- a/client/views/joingameview.ui +++ b/client/views/joingameview.ui @@ -10,86 +10,191 @@ 600 + + + 800 + 600 + + + + + 800 + 600 + + - MainWindow + Cavoke - + - 40 - 480 - 90 - 32 + 9 + 9 + 782 + 541 - - Back - - - - - - 240 - 60 - 291 - 71 - - - - - 16 - - - - Qt::LeftToRight - - - JOIN GAME - - - Qt::AlignCenter - - - - - - 440 - 180 - 121 - 41 - - - - Join game - - - - - - 140 - 170 - 261 - 61 - - - - - 16 - - - - - - - - - + - - Invite code - + + + + 1 + 4 + 61 + 31 + + + + Back + + + + + + 59 + 29 + 661 + 481 + + + + + 10 + + + 10 + + + + + 10 + + + 10 + + + 10 + + + 10 + + + + + + 0 + 0 + + + + + 16 + + + + + + + + + + + + + Invite code + + + + + + + + + Invite code: + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + 200 + + + 20 + + + 200 + + + 20 + + + + + + 0 + 0 + + + + Join room + + + + + + + + + + 16 + + + + Qt::LeftToRight + + + JOIN ROOM + + + Qt::AlignCenter + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + @@ -98,7 +203,7 @@ 0 0 800 - 19 + 28 diff --git a/client/views/protoroomview.cpp b/client/views/protoroomview.cpp deleted file mode 100644 index 8d7d12c3..00000000 --- a/client/views/protoroomview.cpp +++ /dev/null @@ -1,51 +0,0 @@ -#include "protoroomview.h" -#include "ui_protoroomview.h" - -ProtoRoomView::ProtoRoomView(QWidget *parent) - : QMainWindow(parent), ui(new Ui::ProtoRoomView) { - ui->setupUi(this); -} - -ProtoRoomView::~ProtoRoomView() { - delete ui; -} - -void ProtoRoomView::updateStatus(CreatingGameStatus newStatus) { - ui->statusLabel->setText(STATUS.at(newStatus)); - ui->joinGameButton->setEnabled(newStatus == CreatingGameStatus::DONE); -} - -void ProtoRoomView::updateInviteCode(const QString &newInviteCode) { - ui->inviteCodeLabel->setText(newInviteCode); -} - -void ProtoRoomView::on_joinGameButton_clicked() { - this->close(); - emit joinedCreatedGame(gameName); -} -void ProtoRoomView::updateGameName(const QString &name) { - gameName = name; - ui->gameNameLabel->setText(gameName); -} -void ProtoRoomView::prepareJoinCreate(bool _isJoining) { - isJoining = _isJoining; - if (isJoining) { - ui->headerLabel->setText("Joining game session"); - ui->gameNameHLabel->show(); - ui->gameNameLabel->show(); - ui->inviteCodeHLabel->hide(); - ui->inviteCodeLabel->hide(); - } else { - ui->headerLabel->setText("Creating game session"); - ui->gameNameHLabel->hide(); - ui->gameNameLabel->hide(); - ui->inviteCodeHLabel->show(); - ui->inviteCodeLabel->show(); - } - updateInviteCode(""); -} - -void ProtoRoomView::on_backButton_clicked() { - this->close(); - emit shownStartView(); -} diff --git a/client/views/protoroomview.h b/client/views/protoroomview.h deleted file mode 100644 index a7cf5d29..00000000 --- a/client/views/protoroomview.h +++ /dev/null @@ -1,43 +0,0 @@ -#ifndef CAVOKE_CLIENT_MIDDLESCREENVIEW_H -#define CAVOKE_CLIENT_MIDDLESCREENVIEW_H - -#include - -namespace Ui { -class ProtoRoomView; -} - -class ProtoRoomView : public QMainWindow { - Q_OBJECT -public: - enum class CreatingGameStatus { UNKNOWN, DOWNLOAD, REQUESTED, DONE }; - explicit ProtoRoomView(QWidget *parent = nullptr); - ~ProtoRoomView(); - -public slots: - void updateStatus(CreatingGameStatus newStatus); - void updateInviteCode(const QString &newInviteCode); - void updateGameName(const QString &name); - void prepareJoinCreate(bool _isJoining); - -signals: - void joinedCreatedGame(const QString &appName); - void shownStartView(); - -private slots: - void on_joinGameButton_clicked(); - void on_backButton_clicked(); - -private: - Ui::ProtoRoomView *ui; - const std::map STATUS = { - {CreatingGameStatus::UNKNOWN, "Unknown"}, - {CreatingGameStatus::DOWNLOAD, "Downloading"}, - {CreatingGameStatus::REQUESTED, "Sending request"}, - {CreatingGameStatus::DONE, "Done"}, - }; - QString gameName; - bool isJoining = false; -}; - -#endif // CAVOKE_CLIENT_MIDDLESCREENVIEW_H diff --git a/client/views/protoroomview.ui b/client/views/protoroomview.ui deleted file mode 100644 index 3804fe69..00000000 --- a/client/views/protoroomview.ui +++ /dev/null @@ -1,179 +0,0 @@ - - - ProtoRoomView - - - - 0 - 0 - 800 - 600 - - - - MainWindow - - - - - - 180 - 110 - 231 - 61 - - - - Unknown - - - - - - 220 - 20 - 251 - 81 - - - - - 16 - - - - Creating game session - - - - - - 50 - 130 - 121 - 21 - - - - Current status: - - - - - - 50 - 240 - 91 - 16 - - - - Invite code: - - - - - - 180 - 220 - 231 - 51 - - - - - 14 - - - - false - - - false - - - Unknown - - - Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse - - - - - false - - - - 290 - 350 - 90 - 32 - - - - Join game - - - false - - - - - - 180 - 170 - 231 - 51 - - - - - 14 - - - - Unknown - - - - - - 50 - 190 - 91 - 16 - - - - Game name: - - - - - - 60 - 480 - 90 - 32 - - - - Back - - - - - - - 0 - 0 - 800 - 19 - - - - - - - - diff --git a/client/views/roomview.cpp b/client/views/roomview.cpp new file mode 100644 index 00000000..3f7f61b2 --- /dev/null +++ b/client/views/roomview.cpp @@ -0,0 +1,102 @@ +#include "roomview.h" +#include "ui_roomview.h" + +RoomView::RoomView(QWidget *parent) + : QMainWindow(parent), ui(new Ui::RoomView) { + ui->setupUi(this); +} + +RoomView::~RoomView() { + delete ui; +} + +void RoomView::updateStatus(CreatingSessionStatus newStatus) { + status = newStatus; + ui->waitForHostLabel->setText(STATUS.at(status)); + ui->joinSessionButton->setEnabled(status == + CreatingSessionStatus::NOW_YOU_CAN_JOIN); +} + +void RoomView::updateRoomInfo(const RoomInfo &roomInfo) { + sessionId = roomInfo.session_id; + is_host = roomInfo.isHost; + ui->roomNameLabel->setText(roomInfo.display_name); + ui->inviteCodeLabel->setText(roomInfo.invite_code); + ui->membersListWidget->clear(); + for (const auto &member : roomInfo.members) { + ui->membersListWidget->addItem(member.display_name); + } + + if (!sessionId.isEmpty()) { + requestedSessionUpdate(sessionId); + } else { + updateSessionInfo(SessionInfo()); + } +} + +void RoomView::updateSessionInfo(const SessionInfo &sessionInfo) { + if (!is_host) { + ui->sessionGameLabel->hide(); + ui->sessionGameComboBox->hide(); + ui->createSessionButton->hide(); + ui->joinSessionButton->show(); + ui->joinSessionButton->setEnabled(sessionInfo.status != + SessionInfo::Status::FINISHED); + updateStatus(sessionInfo.status != SessionInfo::Status::FINISHED + ? CreatingSessionStatus::NOW_YOU_CAN_JOIN + : CreatingSessionStatus::WAIT_FOR_THE_HOST); + return; + } + if (sessionInfo.status != SessionInfo::Status::FINISHED) { + ui->createSessionButton->hide(); + ui->joinSessionButton->show(); + ui->joinSessionButton->setEnabled(true); + ui->sessionGameLabel->hide(); + ui->sessionGameComboBox->hide(); + updateStatus(CreatingSessionStatus::NOW_YOU_CAN_JOIN); + } else { + ui->createSessionButton->show(); + ui->joinSessionButton->hide(); + ui->createSessionButton->setEnabled(true); + ui->sessionGameLabel->show(); + ui->sessionGameComboBox->show(); + updateStatus(CreatingSessionStatus::YOU_CAN_CREATE); + } +} + +void RoomView::gotGamesListUpdate(const std::vector &newGamesList) { + ui->sessionGameComboBox->clear(); + for (const auto &gameInfo : newGamesList) { + ui->sessionGameComboBox->addItem(gameInfo.display_name, gameInfo.id); + } + if (ui->sessionGameComboBox->count() > 0) { + ui->sessionGameComboBox->setCurrentIndex(0); + } else { + ui->sessionGameComboBox->setCurrentIndex(-1); + } +} + +void RoomView::clear() { + updateStatus(CreatingSessionStatus::UNKNOWN); + ui->roomNameLabel->setText(""); + ui->inviteCodeLabel->setText(""); + ui->membersListWidget->clear(); +} + +void RoomView::on_backButton_clicked() { + this->clear(); + this->close(); + emit leftRoom(); + emit shownStartView(); +} + +void RoomView::on_joinSessionButton_clicked() { + emit joinedSession(sessionId); +} + +void RoomView::on_createSessionButton_clicked() { + if (ui->sessionGameComboBox->currentData().toString().isEmpty()) { + return; // Probably no games at all + } + emit createdSession(ui->sessionGameComboBox->currentData().toString()); +} diff --git a/client/views/roomview.h b/client/views/roomview.h new file mode 100644 index 00000000..08000e82 --- /dev/null +++ b/client/views/roomview.h @@ -0,0 +1,61 @@ +#ifndef CAVOKE_CLIENT_ROOMVIEW_H +#define CAVOKE_CLIENT_ROOMVIEW_H + +#include +#include "entities/gameinfo.h" +#include "entities/role.h" +#include "entities/roominfo.h" +#include "entities/sessioninfo.h" +#include "entities/validationresult.h" + +namespace Ui { +class RoomView; +} + +class RoomView : public QMainWindow { + Q_OBJECT +public: + enum class CreatingSessionStatus { + UNKNOWN, + WAIT_FOR_THE_HOST, + NOW_YOU_CAN_JOIN, + YOU_CAN_CREATE + }; + explicit RoomView(QWidget *parent = nullptr); + ~RoomView(); + +public slots: + void updateRoomInfo(const RoomInfo &roomInfo); + void updateSessionInfo(const SessionInfo &sessionInfo); // Temporary + void clear(); + void gotGamesListUpdate(const std::vector &newGamesList); + +signals: + void createdSession(const QString &gameId); + void joinedSession(const QString &sessionId); + void requestedSessionUpdate(const QString &sessionId); + void shownStartView(); + void leftRoom(); + +private slots: + void updateStatus(CreatingSessionStatus newStatus); + void on_backButton_clicked(); + void on_joinSessionButton_clicked(); + void on_createSessionButton_clicked(); + +private: + Ui::RoomView *ui; + const std::map STATUS = { + {CreatingSessionStatus::UNKNOWN, "Unknown"}, + {CreatingSessionStatus::WAIT_FOR_THE_HOST, + "Wait for the host to create a session"}, + {CreatingSessionStatus::NOW_YOU_CAN_JOIN, + "Now you can join the session!"}, + {CreatingSessionStatus::YOU_CAN_CREATE, "You can create a session"}, + }; + QString sessionId; + CreatingSessionStatus status = CreatingSessionStatus::UNKNOWN; + bool is_host = false; // Wow, this is very cool +}; + +#endif // CAVOKE_CLIENT_ROOMVIEW_H diff --git a/client/views/roomview.ui b/client/views/roomview.ui new file mode 100644 index 00000000..aaa658ec --- /dev/null +++ b/client/views/roomview.ui @@ -0,0 +1,234 @@ + + + RoomView + + + + 0 + 0 + 800 + 600 + + + + + 800 + 600 + + + + + 800 + 600 + + + + Cavoke + + + + + + 9 + 9 + 782 + 541 + + + + + + + + + 1 + 4 + 61 + 31 + + + + Leave + + + + + + 59 + 29 + 661 + 481 + + + + + 10 + + + 10 + + + + + + 14 + + + + true + + + false + + + <invite_code> + + + Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse + + + + + + + + + + 200 + + + 20 + + + 200 + + + 20 + + + + + false + + + + 0 + 0 + + + + Join session + + + false + + + + + + + true + + + + 0 + 0 + + + + Create session + + + false + + + + + + + + + Invite code: + + + + + + + Wait for the host to create a session + + + Qt::AlignCenter + + + + + + + + 16 + + + + WAITING ROOM + + + Qt::AlignCenter + + + + + + + Current members: + + + + + + + Room name: + + + + + + + + + + Choose session game: + + + + + + + + 14 + + + + <room_name> + + + + + + + + + + + 0 + 0 + 800 + 28 + + + + + + + + diff --git a/client/views/sessionview.cpp b/client/views/sessionview.cpp new file mode 100644 index 00000000..c8d526b1 --- /dev/null +++ b/client/views/sessionview.cpp @@ -0,0 +1,99 @@ +#include "sessionview.h" +#include "ui_sessionview.h" + +SessionView::SessionView(QWidget *parent) + : QMainWindow(parent), ui(new Ui::SessionView) { + ui->setupUi(this); + connect(ui->roleComboBox, SIGNAL(currentIndexChanged(int)), this, + SLOT(repeaterCurrentIndexChanged(int))); +} + +SessionView::~SessionView() { + delete ui; +} + +void SessionView::repeaterCurrentIndexChanged(int index) { + if (index == -1 || ui->roleComboBox->itemData(index).toInt() == ourRole) { + // displayEmpty(); + return; + } + // qDebug() << "Chosen: " << ui->roleComboBox->itemText(index); + qDebug() << "Got new role index: " + << ui->roleComboBox->itemData(index).toInt(); + emit newRoleChosen(ui->roleComboBox->itemData(index).toInt()); +} + +void SessionView::updateStatus(CreatingGameStatus newStatus) { + ui->statusLabel->setText(STATUS.at(newStatus)); + ui->currentPlayersHLabel->setHidden(newStatus != CreatingGameStatus::DONE); + ui->playersListWidget->setHidden(newStatus != CreatingGameStatus::DONE); +} + +void SessionView::updateSessionInfo(const SessionInfo &sessionInfo) { + ui->playersListWidget->clear(); + for (const auto &player : sessionInfo.players) { + ui->playersListWidget->addItem(player.user.display_name); + } + if (sessionInfo.isHost) { + show_as_host(); + } else { + show_as_guest(); + } + if (sessionInfo.status == SessionInfo::Status::RUNNING) { + this->close(); + emit joinedCreatedGame(); + } +} + +void SessionView::updateValidationResult( + const ValidationResult &validationResult) { + ui->startGameButton->setEnabled(validationResult.success); + ui->joinErrorLabel->setText(validationResult.message); +} + +void SessionView::on_startGameButton_clicked() { + emit createdGame(); +} + +void SessionView::clear() { + ui->currentPlayersHLabel->hide(); + ui->playersListWidget->hide(); + + ui->gameNameLabel->setText("Unknown"); +} + +void SessionView::on_backButton_clicked() { + this->clear(); + this->close(); + qDebug() << "Pressed Back From Session"; + emit leftSession(); + emit shownRoomView(); +} +void SessionView::updateGameName(const QString &gameName) { + ui->gameNameLabel->setText(gameName); +} +void SessionView::gotRolesListUpdate(const std::vector &newRolesList) { + // Some bad way to check whether list actually updated + // TODO: implement it + + ourRole = newRolesList.front().id; + ui->roleComboBox->clear(); + for (const auto &roleIdAndName : newRolesList) { + ui->roleComboBox->addItem(roleIdAndName.name, roleIdAndName.id); + } + if (ui->roleComboBox->count() > 0) { + ui->roleComboBox->setCurrentIndex(0); + } else { + ui->roleComboBox->setCurrentIndex(-1); + } +} +void SessionView::show_as_host() { + ui->waitForHostLabel->hide(); + ui->joinErrorLabel->show(); + ui->startGameButton->show(); +} +void SessionView::show_as_guest() { + ui->waitForHostLabel->show(); + ui->joinErrorLabel->hide(); + ui->startGameButton->hide(); +} diff --git a/client/views/sessionview.h b/client/views/sessionview.h new file mode 100644 index 00000000..42b10d47 --- /dev/null +++ b/client/views/sessionview.h @@ -0,0 +1,53 @@ +#ifndef CAVOKE_CLIENT_SESSIONVIEW_H +#define CAVOKE_CLIENT_SESSIONVIEW_H + +#include +#include "entities/role.h" +#include "entities/sessioninfo.h" +#include "entities/validationresult.h" + +namespace Ui { +class SessionView; +} + +class SessionView : public QMainWindow { + Q_OBJECT +public: + enum class CreatingGameStatus { UNKNOWN, DOWNLOAD, REQUESTED, DONE }; + explicit SessionView(QWidget *parent = nullptr); + ~SessionView(); + +public slots: + void updateStatus(CreatingGameStatus newStatus); + void updateSessionInfo(const SessionInfo &sessionInfo); + void clear(); + void updateValidationResult(const ValidationResult &validationResult); + void updateGameName(const QString &gameName); + void gotRolesListUpdate(const std::vector &newRolesList); + +signals: + void createdGame(); + void joinedCreatedGame(); + void shownRoomView(); + void leftSession(); + void newRoleChosen(int roleId); + +private slots: + void on_startGameButton_clicked(); + void on_backButton_clicked(); + void repeaterCurrentIndexChanged(int index); + +private: + Ui::SessionView *ui; + void show_as_host(); + void show_as_guest(); + const std::map STATUS = { + {CreatingGameStatus::UNKNOWN, "Unknown"}, + {CreatingGameStatus::DOWNLOAD, "Downloading"}, + {CreatingGameStatus::REQUESTED, "Sending request"}, + {CreatingGameStatus::DONE, "Ready"}, + }; + int ourRole = -1; +}; + +#endif // CAVOKE_CLIENT_SESSIONVIEW_H diff --git a/client/views/sessionview.ui b/client/views/sessionview.ui new file mode 100644 index 00000000..cb0daebc --- /dev/null +++ b/client/views/sessionview.ui @@ -0,0 +1,213 @@ + + + SessionView + + + + 0 + 0 + 800 + 600 + + + + + 800 + 600 + + + + + 800 + 600 + + + + Cavoke + + + + + + 9 + 9 + 782 + 541 + + + + + + + + + 1 + 4 + 61 + 31 + + + + Leave + + + + + + 59 + 29 + 661 + 481 + + + + + 10 + + + 10 + + + + + + + + + 16 + + + + SESSION ROOM + + + Qt::AlignCenter + + + + + + + + + + Wait for the host to launch the game + + + Qt::AlignCenter + + + + + + + + + + + + + + Current players: + + + + + + + 200 + + + 20 + + + 200 + + + 20 + + + + + false + + + + 0 + 0 + + + + Start game + + + false + + + + + + + + + Current status: + + + + + + + Your role: + + + + + + + Game name: + + + + + + + + 14 + + + + <game_name> + + + + + + + + 14 + + + + <session_status> + + + + + + + + + + + 0 + 0 + 800 + 28 + + + + + + + + diff --git a/client/views/settingsview.cpp b/client/views/settingsview.cpp index 0044163e..71830a1a 100644 --- a/client/views/settingsview.cpp +++ b/client/views/settingsview.cpp @@ -1,4 +1,5 @@ #include "settingsview.h" +#include "AuthenticationManager.h" #include "ui_settingsview.h" SettingsView::SettingsView(QWidget *parent) @@ -10,7 +11,33 @@ SettingsView::~SettingsView() { delete ui; } +void SettingsView::initStartValues(const QString &displayName, + const QString &host) { + ui->nicknameInput->setText(displayName); + oldDisplayName = displayName; + ui->serverAddressInput->setText(host); +} + +void SettingsView::updateDisplayName(const QString &displayName) { + ui->nicknameInput->setText(displayName); + oldDisplayName = displayName; +} + void SettingsView::on_backButton_clicked() { this->close(); emit shownStartView(); } +void SettingsView::on_updateSettingsButton_clicked() { + if (ui->nicknameInput->text() != oldDisplayName) { + if (!AuthDialog::verifyAuth(this)) { + return; + } + } + emit updatedSettings(ui->nicknameInput->text(), + ui->serverAddressInput->text()); + this->close(); + emit shownStartView(); +} +void SettingsView::on_reloginButton_clicked() { + cavoke::auth::AuthenticationManager::getInstance().relogin(); +} diff --git a/client/views/settingsview.h b/client/views/settingsview.h index 74130d7b..dd3133b9 100644 --- a/client/views/settingsview.h +++ b/client/views/settingsview.h @@ -2,6 +2,7 @@ #define CAVOKE_CLIENT_SETTINGSVIEW_H #include +#include "authdialog.h" namespace Ui { class SettingsView; @@ -13,14 +14,22 @@ class SettingsView : public QMainWindow { explicit SettingsView(QWidget *parent = nullptr); ~SettingsView(); +public slots: + void initStartValues(const QString &displayName, const QString &host); + void updateDisplayName(const QString &displayName); + signals: void shownStartView(); + void updatedSettings(const QString &displayName, const QString &host); private slots: void on_backButton_clicked(); + void on_updateSettingsButton_clicked(); + void on_reloginButton_clicked(); private: Ui::SettingsView *ui; + QString oldDisplayName{}; }; #endif // CAVOKE_CLIENT_SETTINGSVIEW_H diff --git a/client/views/settingsview.ui b/client/views/settingsview.ui index 5a71a931..9414bfae 100644 --- a/client/views/settingsview.ui +++ b/client/views/settingsview.ui @@ -10,35 +10,180 @@ 600 + + + 800 + 600 + + + + + 800 + 600 + + - MainWindow + Cavoke - - - - 40 - 480 - 90 - 32 - - - - Back - - - + - 200 - 60 - 291 - 71 + 9 + 9 + 782 + 541 - - Settings. Empty. + + + + + + 1 + 4 + 61 + 31 + + + + Back + + + + + + 59 + 29 + 661 + 481 + + + + + 10 + + + 10 + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + Nickname: + + + + + + + + 16 + + + + SETTINGS + + + Qt::AlignCenter + + + + + + + + 0 + 0 + + + + + + + + Server address: + + + + + + + + 0 + 0 + + + + + + + + 200 + + + 20 + + + 200 + + + 20 + + + + + + 0 + 0 + + + + Update settings + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Change user + + + + + + + diff --git a/client/views/startview.cpp b/client/views/startview.cpp index a8b74ef9..5d16b37a 100644 --- a/client/views/startview.cpp +++ b/client/views/startview.cpp @@ -5,6 +5,7 @@ StartView::StartView(QWidget *parent) : QMainWindow(parent), ui(new Ui::StartView) { ui->setupUi(this); + ui->cavokeTestWindowButton->hide(); } StartView::~StartView() { @@ -31,6 +32,14 @@ void StartView::on_cavokeTestWindowButton_clicked() { emit shownTestWindowView(); } +void StartView::on_statisticsButton_clicked() { + if (!AuthDialog::verifyAuth(this)) { + return; + } + this->close(); + emit shownStatisticsView(); +} + void StartView::on_settingsButton_clicked() { this->close(); emit shownSettingsView(); diff --git a/client/views/startview.h b/client/views/startview.h index a9e791d0..de02fe73 100644 --- a/client/views/startview.h +++ b/client/views/startview.h @@ -20,6 +20,7 @@ class StartView : public QMainWindow { void shownCreateGameView(); void shownSettingsView(); void shownGamesListView(); + void shownStatisticsView(); void clickedExitButton(); private slots: @@ -27,6 +28,7 @@ private slots: void on_createGameButton_clicked(); void on_gamesListButton_clicked(); void on_cavokeTestWindowButton_clicked(); + void on_statisticsButton_clicked(); void on_settingsButton_clicked(); void on_exitButton_clicked(); diff --git a/client/views/startview.ui b/client/views/startview.ui index 20d74c69..1f84cbde 100644 --- a/client/views/startview.ui +++ b/client/views/startview.ui @@ -10,8 +10,20 @@ 600 + + + 800 + 600 + + + + + 800 + 600 + + - MainWindow + Cavoke @@ -29,21 +41,34 @@ 230 160 321 - 194 + 238 + + + + Qt::Vertical + + + + 20 + 40 + + + + - Join game + Join room - Create game + Create room @@ -57,27 +82,47 @@ - Cavoke test screen + Developer mode - - - Qt::Vertical - - - - 20 - 40 - + + + Statistics - + - + + + 6 + + + + + + 0 + 1 + + + + + + + + :/info:/info + + + + + + 0 + 3 + + Settings @@ -85,6 +130,12 @@ + + + 0 + 3 + + Exit @@ -92,6 +143,68 @@ + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + 230 + 30 + 321 + 91 + + + + + + + + 0 + 0 + + + + + + + :/cavoke + + + + + + + + 0 + 0 + + + + + 16 + + + + CAVOKE + + + Qt::AlignCenter + + + @@ -110,6 +223,8 @@ - + + + diff --git a/client/views/statisticsview.cpp b/client/views/statisticsview.cpp new file mode 100644 index 00000000..8c1b704a --- /dev/null +++ b/client/views/statisticsview.cpp @@ -0,0 +1,69 @@ +#include "statisticsview.h" +#include +#include "authdialog.h" +#include "ui_statisticsview.h" + +StatisticsView::StatisticsView(QWidget *parent) + : QMainWindow(parent), ui(new Ui::StatisticsView) { + ui->setupUi(this); + connect(ui->games_combobox, SIGNAL(currentIndexChanged(int)), this, + SLOT(repeaterCurrentIndexChanged(int))); +} + +void StatisticsView::repeaterCurrentIndexChanged(int index) { + if (index == -1) { + displayEmpty(); + return; + } + emit statisticsGameChanged(ui->games_combobox->itemData(index).toString()); +} + +void StatisticsView::displayEmpty() { + ui->time_played_label->setText("0"); + ui->games_played_label->setText("0"); + ui->win_rate_label->setText("0"); +} + +void StatisticsView::gotUserGameStatisticsUpdate( + const UserGameStatistics &userGameStatistics) { + ui->time_played_label->setText( + QString::number(userGameStatistics.time_played_sec)); + ui->games_played_label->setText( + QString::number(userGameStatistics.games_played)); + ui->win_rate_label->setText(QString::number(userGameStatistics.win_rate)); +} + +StatisticsView::~StatisticsView() { + delete ui; +} + +void StatisticsView::on_backButton_clicked() { + this->close(); + emit shownStartView(); +} + +void StatisticsView::gotGamesListUpdate( + const std::vector &newGamesList) { + ui->games_combobox->clear(); + for (const auto &gameInfo : newGamesList) { + ui->games_combobox->addItem(gameInfo.display_name, gameInfo.id); + } +} +void StatisticsView::gotUserStatisticsUpdate( + const UserStatistics &userStatistics) { + ui->total_time_played_label->setText( + QString::number(userStatistics.total_time_played_sec)); + ui->total_games_played_label->setText( + QString::number(userStatistics.total_games_played)); +} + +void StatisticsView::requestUpdates() { + emit requestedRefresh(); + emit statisticsGameChanged( + ui->games_combobox->itemData(ui->games_combobox->currentIndex()) + .toString()); +} + +void StatisticsView::on_refreshButton_clicked() { + requestUpdates(); +} diff --git a/client/views/statisticsview.h b/client/views/statisticsview.h new file mode 100644 index 00000000..c840d84d --- /dev/null +++ b/client/views/statisticsview.h @@ -0,0 +1,41 @@ +#ifndef CAVOKE_CLIENT_STATISTICSVIEW_H +#define CAVOKE_CLIENT_STATISTICSVIEW_H + +#include +#include "entities/gameinfo.h" +#include "entities/usergamestatistics.h" +#include "entities/userstatistics.h" + +namespace Ui { +class StatisticsView; +} + +class StatisticsView : public QMainWindow { + Q_OBJECT +public: + explicit StatisticsView(QWidget *parent = nullptr); + ~StatisticsView(); + +public slots: + void gotGamesListUpdate(const std::vector &newGamesList); + void gotUserStatisticsUpdate(const UserStatistics &userStatistics); + void gotUserGameStatisticsUpdate( + const UserGameStatistics &userGameStatistics); + void requestUpdates(); + +signals: + void shownStartView(); + void requestedRefresh(); + void statisticsGameChanged(const QString &gameId); + +private slots: + void on_backButton_clicked(); + void on_refreshButton_clicked(); + void displayEmpty(); + void repeaterCurrentIndexChanged(int index); + +private: + Ui::StatisticsView *ui; +}; + +#endif // CAVOKE_CLIENT_STATISTICSVIEW_H diff --git a/client/views/statisticsview.ui b/client/views/statisticsview.ui new file mode 100644 index 00000000..9eb62350 --- /dev/null +++ b/client/views/statisticsview.ui @@ -0,0 +1,222 @@ + + + StatisticsView + + + + 0 + 0 + 800 + 600 + + + + + 800 + 600 + + + + + 800 + 600 + + + + Cavoke + + + + + + 9 + 9 + 782 + 541 + + + + + + + + + 1 + 4 + 61 + 31 + + + + Back + + + + + + 59 + 29 + 661 + 481 + + + + + 10 + + + 10 + + + + + 0 + + + + + + + Games played: + + + + + + + Statistics for game: + + + + + + + Time played, seconds: + + + + + + + Total games played: + + + + + + + 0 + + + + + + + 0 + + + + + + + + 0 + 0 + + + + + + + + + 16 + + + + Qt::LeftToRight + + + STATISTICS + + + Qt::AlignCenter + + + + + + + 0 + + + + + + + 0 + + + + + + + Total tme played, seconds: + + + + + + + Win rate: + + + + + + + 200 + + + 20 + + + 200 + + + 20 + + + + + + 0 + 0 + + + + Refresh + + + + + + + + + + + + + 0 + 0 + 800 + 28 + + + + + + + + diff --git a/client/views/testwindowview.ui b/client/views/testwindowview.ui index 5c675ded..4dba215c 100644 --- a/client/views/testwindowview.ui +++ b/client/views/testwindowview.ui @@ -10,8 +10,20 @@ 600 + + + 800 + 600 + + + + + 800 + 600 + + - MainWindow + Cavoke diff --git a/db.Dockerfile b/db.Dockerfile new file mode 100644 index 00000000..7aee07ae --- /dev/null +++ b/db.Dockerfile @@ -0,0 +1,3 @@ +FROM postgres +ENV POSTGRES_DB cavoke +COPY ./server/db/schema.sql /docker-entrypoint-initdb.d/ \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..88f453ef --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +version: "3.9" + +services: + db: + build: + context: . + dockerfile: db.Dockerfile + environment: + - POSTGRES_DB=cavoke + - POSTGRES_USER=postgres_user + - POSTGRES_PASSWORD=postgres_password + cavoke_server: + build: + context: . + dockerfile: server.Dockerfile + command: -p 8080 -c /mnt/data/drogon_config.json + depends_on: + - db + volumes: + - ./example/server_data:/mnt/data + ports: + - "8080:8080" diff --git a/example/server_data/drogon_config.json b/example/server_data/drogon_config.json new file mode 100644 index 00000000..64c40cea --- /dev/null +++ b/example/server_data/drogon_config.json @@ -0,0 +1,137 @@ +/* This is a JSON format configuration file + */ +{ + //custom_config: custom configuration for users. This object can be acquired by the app().getCustomConfig() method. + "custom_config": { + "storage": { + "games_directory": "/mnt/data/games", + "logic_name": "logic", + "zip_name": "client.zip", + "config_name": "config.json" + } + }, + "db_clients": [ + { + //name: Name of the client,'default' by default + "name": "default", + //rdbms: Server type, postgresql,mysql or sqlite3, "postgresql" by default + "rdbms": "postgresql", + //filename: Sqlite3 db file name + //"filename":"", + //host: Server address,localhost by default + "host": "db", + //port: Server port, 5432 by default + "port": 5432, + //dbname: Database name + "dbname": "cavoke", + //user: 'postgres' by default + "user": "postgres_user", + //passwd: '' by default + "passwd": "postgres_password", + //is_fast: false by default, if it is true, the client is faster but user can't call + //any synchronous interface of it. + "is_fast": false, + //client_encoding: The character set used by the client. it is empty string by default which + //means use the default character set. + //"client_encoding": "", + //number_of_connections: 1 by default, if the 'is_fast' is true, the number is the number of + //connections per IO thread, otherwise it is the total number of all connections. + "number_of_connections": 20, + //timeout: -1.0 by default, in seconds, the timeout for executing a SQL query. + //zero or negative value means no timeout. + "timeout": -1.0 + } + ], + "app": { + //number_of_threads: The number of IO threads, 1 by default, if the value is set to 0, the number of threads + //is the number of CPU cores + "number_of_threads": 1, + //enable_session: False by default + "enable_session": false, + "session_timeout": 0, + //max_connections: maximum number of connections, 100000 by default + "max_connections": 100000, + //max_connections_per_ip: maximum number of connections per client, 0 by default which means no limit + "max_connections_per_ip": 0, + //Load_dynamic_views: False by default, when set to true, drogon + //compiles and loads dynamically "CSP View Files" in directories defined + //by "dynamic_views_path" + "load_dynamic_views": false, + //dynamic_views_path: If the path isn't prefixed with /, ./ or ../, + //it is relative path of document_root path + "dynamic_views_path": [ + "./views" + ], + //dynamic_views_output_path: Default by an empty string which means the output path of source + //files is the path where the csp files locate. If the path isn't prefixed with /, it is relative + //path of the current working directory. + "dynamic_views_output_path": "", + //enable_unicode_escaping_in_json: true by default, enable unicode escaping in json. + "enable_unicode_escaping_in_json": true, + //float_precision_in_json: set precision of float number in json. + "float_precision_in_json": { + //precision: 0 by default, 0 means use the default precision of the jsoncpp lib. + "precision": 0, + //precision_type: must be "significant" or "decimal", defaults to "significant" that means + //setting max number of significant digits in string, "decimal" means setting max number of + //digits after "." in string + "precision_type": "significant" + }, + //run_as_daemon: False by default + "run_as_daemon": false, + //handle_sig_term: True by default + "handle_sig_term": true, + //relaunch_on_error: False by default, if true, the program will be restarted by the parent after exiting; + "relaunch_on_error": false, + //use_sendfile: True by default, if true, the program + //uses sendfile() system-call to send static files to clients; + "use_sendfile": true, + //use_gzip: True by default, use gzip to compress the response body's content; + "use_gzip": true, + //use_brotli: False by default, use brotli to compress the response body's content; + "use_brotli": false, + //static_files_cache_time: 5 (seconds) by default, the time in which the static file response is cached, + //0 means cache forever, the negative value means no cache + "static_files_cache_time": 5, + //idle_connection_timeout: Defaults to 60 seconds, the lifetime + //of the connection without read or write + "idle_connection_timeout": 60, + //server_header_field: Set the 'Server' header field in each response sent by drogon, + //empty string by default with which the 'Server' header field is set to "Server: drogon/version string\r\n" + "server_header_field": "", + //enable_server_header: Set true to force drogon to add a 'Server' header to each HTTP response. The default + //value is true. + "enable_server_header": true, + //enable_date_header: Set true to force drogon to add a 'Date' header to each HTTP response. The default + //value is true. + "enable_date_header": true, + //keepalive_requests: Set the maximum number of requests that can be served through one keep-alive connection. + //After the maximum number of requests are made, the connection is closed. + //The default value of 0 means no limit. + "keepalive_requests": 0, + //pipelining_requests: Set the maximum number of unhandled requests that can be cached in pipelining buffer. + //After the maximum number of requests are made, the connection is closed. + //The default value of 0 means no limit. + "pipelining_requests": 0, + //gzip_static: If it is set to true, when the client requests a static file, drogon first finds the compressed + //file with the extension ".gz" in the same path and send the compressed file to the client. + //The default value of gzip_static is true. + "gzip_static": true, + //br_static: If it is set to true, when the client requests a static file, drogon first finds the compressed + //file with the extension ".br" in the same path and send the compressed file to the client. + //The default value of br_static is true. + "br_static": true, + //client_max_body_size: Set the maximum body size of HTTP requests received by drogon. The default value is "1M". + //One can set it to "1024", "1k", "10M", "1G", etc. Setting it to "" means no limit. + "client_max_body_size": "1M", + //max_memory_body_size: Set the maximum body size in memory of HTTP requests received by drogon. The default value is "64K" bytes. + //If the body size of an HTTP request exceeds this limit, the body is stored to a temporary file for processing. + //Setting it to "" means no limit. + "client_max_memory_body_size": "64K", + //client_max_websocket_message_size: Set the maximum size of messages sent by WebSocket client. The default value is "128K". + //One can set it to "1024", "1k", "10M", "1G", etc. Setting it to "" means no limit. + "client_max_websocket_message_size": "128K", + //reuse_port: Defaults to false, users can run multiple processes listening on the same port at the same time. + "reuse_port": false + } +} diff --git a/example/server_data/games/tictactoe/client.zip b/example/server_data/games/tictactoe/client.zip new file mode 100644 index 00000000..d8d27e31 Binary files /dev/null and b/example/server_data/games/tictactoe/client.zip differ diff --git a/example/server_data/games/tictactoe/config.json b/example/server_data/games/tictactoe/config.json new file mode 100644 index 00000000..cdea6e6d --- /dev/null +++ b/example/server_data/games/tictactoe/config.json @@ -0,0 +1,15 @@ +{ + "default_settings": { + "board_size": 3 + }, + "description": "Paper-and-pencil game for two players who take turns marking the spaces in a three-by-three grid with X or O", + "display_name": "Tic-tac-toe", + "id": "tictactoe", + "players_num": 2, + "role_names": [ + "Crosses", + "Noughts" + ], + "app_type": "LOCAL", + "url": "" +} diff --git a/example/server_data/games/tictactoe/logic b/example/server_data/games/tictactoe/logic new file mode 100644 index 00000000..eaf7d18c Binary files /dev/null and b/example/server_data/games/tictactoe/logic differ diff --git a/games/CMakeLists.txt b/games/CMakeLists.txt index 04d1c6e0..f25d07dc 100644 --- a/games/CMakeLists.txt +++ b/games/CMakeLists.txt @@ -1,10 +1,59 @@ cmake_minimum_required(VERSION 3.10) -project(tictactoe) +project(games) -find_package(nlohmann_json 3.9.0 REQUIRED) +set(CMAKE_CXX_STANDARD 17) + +set(CAVOKE_H_DIR ../cavoke-dev-lib CACHE PATH "Directory with cavoke.h library" FORCE) +set(CAVOKE_DEV_SERVER_H_DIR ../cavoke-dev-server-lib CACHE PATH "Directory with game dev server library" FORCE) +include_directories(${CAVOKE_H_DIR}) +include_directories(${CAVOKE_DEV_SERVER_H_DIR}) add_executable(tictactoe tictactoe/tictactoe.cpp - cavoke.cpp) + ${CAVOKE_H_DIR}/cavoke.cpp) + +add_executable(codenames + codenames/server/src/codenames.cpp + codenames/server/src/wordlists.cpp + ${CAVOKE_H_DIR}/cavoke.cpp codenames/server/src/model.cpp) + +add_executable(tictactoe_server + tictactoe/tictactoe.cpp + ${CAVOKE_DEV_SERVER_H_DIR}/cavoke.cpp + ${CAVOKE_DEV_SERVER_H_DIR}/controllers/logic_controller.cpp) + +add_executable(codenames_server + codenames/server/src/codenames.cpp + codenames/server/src/wordlists.cpp + codenames/server/src/model.cpp + ${CAVOKE_DEV_SERVER_H_DIR}/cavoke.cpp + ${CAVOKE_DEV_SERVER_H_DIR}/controllers/logic_controller.cpp) + +if (USE_EXTERNAL_NLOHMANN) + target_link_libraries(tictactoe PRIVATE nlohmann_json::nlohmann_json) + target_link_libraries(tictactoe_server PRIVATE nlohmann_json::nlohmann_json) + target_link_libraries(codenames PRIVATE nlohmann_json::nlohmann_json) + target_link_libraries(codenames_server PRIVATE nlohmann_json::nlohmann_json) +else () + target_link_libraries(tictactoe PRIVATE nlohmann_json) + target_link_libraries(tictactoe_server PRIVATE nlohmann_json) + target_link_libraries(codenames PRIVATE nlohmann_json) + target_link_libraries(codenames_server PRIVATE nlohmann_json) +endif () + +# Link Drogon +if (USE_EXTERNAL_DROGON) + target_link_libraries(tictactoe_server PRIVATE Drogon::Drogon) + target_link_libraries(codenames_server PRIVATE Drogon::Drogon) +else () + target_link_libraries(tictactoe_server PRIVATE drogon) + target_link_libraries(codenames_server PRIVATE drogon) +endif () + +# Link Boost +find_package(Boost 1.71 REQUIRED filesystem program_options) +include_directories(${Boost_INCLUDE_DIRS}) +link_directories(${Boost_LIBRARY_DIRS}) -target_link_libraries(tictactoe nlohmann_json::nlohmann_json) +target_link_libraries(tictactoe_server PRIVATE ${Boost_LIBRARIES} ${CMAKE_DL_LIBS}) +target_link_libraries(codenames_server PRIVATE ${Boost_LIBRARIES} ${CMAKE_DL_LIBS}) diff --git a/games/codenames/client/build.sh b/games/codenames/client/build.sh new file mode 100644 index 00000000..172313f0 --- /dev/null +++ b/games/codenames/client/build.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +mkdir -p .build/client +cp -r codenames-client/app.qml .build/client/app.qml +cp -r codenames-client/src .build/client/src +cd .build +zip -r ../client.zip client +cd .. +rm -r .build diff --git a/games/codenames/client/codenames-client/app.qml b/games/codenames/client/codenames-client/app.qml new file mode 100644 index 00000000..65d0d45a --- /dev/null +++ b/games/codenames/client/codenames-client/app.qml @@ -0,0 +1,15 @@ +import QtQuick 2.15 +import QtQuick.Window 2.15 +import "src/components" +import "src/screens" + +Rectangle { + width: 1280 + height: 768 + //anchors.fill: parent + visible: true + + Game { + anchors.fill: parent + } +} diff --git a/games/codenames/client/codenames-client/src/components/Card.qml b/games/codenames/client/codenames-client/src/components/Card.qml new file mode 100644 index 00000000..1dba1b56 --- /dev/null +++ b/games/codenames/client/codenames-client/src/components/Card.qml @@ -0,0 +1,72 @@ +import QtQuick 2.0 + +import "../interactions/interactions.js" as Interact +import "../style/colors.js" as Colors + +Item { + id: card; + + property string word: "" + property int state: 0 + property bool opened: false + property int player: 0 + property int card_index: 0 + width: 180 + height: 100 + + function getColor(state, opened, player) { + if (state === 0) { + if (player >= 2 || !opened) { + return Colors.BLUE_AGENT; + } + return Colors.BLUE_AGENT_OPENED; + } else if (state === 1) { + if (player >= 2 || !opened) { + return Colors.RED_AGENT; + } + return Colors.RED_AGENT_OPENED; + } else if (state === 2) { + return Colors.BLACK_CARD; + } else if (state === 3) { + if (player >= 2 || !opened) { + return Colors.NEUTRAL; + } + return Colors.NEUTRAL_OPENED; + } else { + return Colors.CLOSED; + } + } + + function getTextColor(state) { + return "white"; + } + + function getText(state, player, word) { + if (state === 4 || player === 0 || player === 1) { + return word; + } else { + return ""; + } + } + + Rectangle { + id: card_content + anchors.fill: parent +// radius: 10 + + color: getColor(card.state, card.opened, card.player); + + Text { + id: card_hint; + + anchors.centerIn: parent; + text: getText(card.state, card.player, card.word); + color: getTextColor(card.state); + } + + MouseArea { + anchors.fill: parent + onClicked: Interact.openCard(parent.parent.card_index); + } + } +} diff --git a/games/codenames/client/codenames-client/src/interactions/interactions.js b/games/codenames/client/codenames-client/src/interactions/interactions.js new file mode 100644 index 00000000..45b0d55c --- /dev/null +++ b/games/codenames/client/codenames-client/src/interactions/interactions.js @@ -0,0 +1,118 @@ +let previousState = ""; + +function sendMove(move) { + let moveObj = {} + moveObj.move = JSON.stringify(move); + cavoke.getMoveFromQml(JSON.stringify(moveObj)); +} + +function makeHint(hint, attempts = 1) { + if (!hint || !attempts || attempts <= 0) { + return; + } + console.log("Make hint", hint); + sendMove({type: "hint", hint: hint, attempts: attempts}); +} + +function openCard(index) { + console.log("Open card", index); + sendMove({type: "open", position: index}); +} + +function skip() { + console.log("Skip move"); + sendMove({type: "skip"}); +} + +function getRoleName(role) { + if (role === 0) { + return "blue captain"; + } else if (role === 1) { + return "red captain"; + } else if (role % 2 === 0) { + return "blue player"; + } else { + return "red player"; + } +} + +function isYourTurn(role, stage) { + if (stage === 0) { + return role === 0; + } else if (stage === 2) { + return role === 1; + } else if (stage === 1) { + return role === 2 || role === 4; + } else if (stage === 3) { + return role === 3 || role === 5; + } + + return false; +} + + +function updateInterface(model) { + let result = model.m_result; + + if (result === 1) { + resultsScreen.visible = true; + gameBoard.opacity = 0.33; + + blueWins.visible = true; + redWins.visible = false; + } else if (result === 2) { + resultsScreen.visible = true; + gameBoard.opacity = 0.33; + + blueWins.visible = false; + redWins.visible = true; + } else { + resultsScreen.visible = false; + gameBoard.opacity = 1; + } + + board.rows = model.m_height; + board.columns = model.m_width; + let stage = model.m_stage; + let player = model.role; + hintControls.visible = ((stage === 0 && player === 0) || (stage === 2 && player === 1)); + if (model.m_last_hint) { + currentHint.text = "\"" + model.m_last_hint + "\""; + } else { + currentHint.text = "none"; + } + currentAttempts.text = model.m_attempts_left.toString(); + yourRole.text = getRoleName(player); + + yourTurnLabel.visible = isYourTurn(player, model.m_stage); + skipControls.visible = yourTurnLabel.visible && (player >= 2); + + blueCardsLeft.text = model.m_blue_closed.toString(); + redCardsLeft.text = model.m_red_closed.toString(); + + for (let i = 0; i < model.m_height; ++i) { + for (let j = 0; j < model.m_width; ++j) { + if (board.children.length < (i * model.m_width + j) + 1) { + let component = Qt.createComponent("../components/Card.qml"); + component.createObject(board); + } + let card = board.children[(i * model.m_width + j)]; + card.word = model.m_words[(i * model.m_width + j)]; + card.state = model.m_card_states[(i * model.m_width + j)]; + card.player = player; + if (player <= 1) { + card.opened = model.m_opened[(i * model.m_width + j)]; + } + card.card_index = (i * model.m_width + j); + } + } +} + +function processUpdate(update_str) { + let update = JSON.parse(update_str); + let model = JSON.parse(update.state); + if (model !== previousState) { + previousState = model; + updateInterface(model); + } +} diff --git a/games/codenames/client/codenames-client/src/screens/Game.qml b/games/codenames/client/codenames-client/src/screens/Game.qml new file mode 100644 index 00000000..55b74238 --- /dev/null +++ b/games/codenames/client/codenames-client/src/screens/Game.qml @@ -0,0 +1,248 @@ +import QtQuick 2.0 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 + + +import "../components" +import "../interactions/interactions.js" as Interact +import "../style/colors.js" as Colors + +Rectangle { + // BEGIN cavoke section + Connections { + target: cavoke + + function onReceiveUpdate(jsonUpdate) { + console.log("Received: " + jsonUpdate); + Interact.processUpdate(jsonUpdate); + } + } + // END cavoke section + Item { + id: gameBoard; + visible: true; + anchors.fill: parent; + + Rectangle { + color: "#f0ffff"; + anchors.fill: parent; + Grid { + id: board + anchors.centerIn: parent + spacing: 5; + } + } + + ColumnLayout { + id: cardsLeftBlock; + + anchors.top: parent.top; + anchors.left: parent.left; + anchors.topMargin: 25; + anchors.leftMargin: 25; + + RowLayout { + anchors.left: parent.left; + Text { + id: blueCardsLeftLabel; + color: Colors.BLUE_AGENT; + text: "Blue cards left: " + anchors.left: parent.left; + } + Text { + id: blueCardsLeft; + color: Colors.BLUE_AGENT; + anchors.left: blueCardsLeftLabel.right; + } + } + + RowLayout { + anchors.topMargin: 5; + anchors.left: parent.left; + + Text { + id: redCardsLeftLabel; + color: Colors.RED_AGENT; + text: "Red cards left: " + anchors.left: parent.left; + } + Text { + id: redCardsLeft; + color: Colors.RED_AGENT; + anchors.left: redCardsLeftLabel.right; + } + } + + } + + RowLayout { + id: currentHintBlock; + + anchors.top: parent.top; + anchors.horizontalCenter: parent.horizontalCenter; + anchors.topMargin: 25; + Text { + id: currentHintLabel; + color: "black"; + text: "Current hint: " + anchors.left: parent.left; + } + Text { + id: currentHint; + color: "black"; + anchors.left: currentHintLabel.right; + } + Text { + id: currentAttemptsLabel; + color: "black"; + text: "attempts left: " + anchors.left: currentHint.right; + anchors.leftMargin: 50; + } + Text { + id: currentAttempts; + color: "black"; + anchors.left: currentAttemptsLabel.right; + } + + } + + Text { + id: yourTurnLabel; + color: "black"; + text: "Your turn!"; + anchors.right: yourRoleBlock.right; + anchors.top: yourRoleBlock.bottom; + anchors.topMargin: 5; + + visible: false; + } + + RowLayout { + id: yourRoleBlock; + + anchors.top: parent.top; + anchors.right: parent.right; + anchors.topMargin: 25; + anchors.rightMargin: 25; + Text { + id: yourRoleLabel; + color: "black"; + text: "Your role is: " + anchors.right: yourRole.left; + } + Text { + id: yourRole; + color: "black"; + anchors.right: parent.right; + } + + } + + RowLayout { + id: hintControls; + + anchors.bottom: parent.bottom; + anchors.horizontalCenter: parent.horizontalCenter; + anchors.bottomMargin: 25; + + Rectangle { + color: "white"; + border.width: 1; + border.color: "black"; + + width: 600; + height: 30; + TextInput { + anchors.fill: parent; + anchors.leftMargin: 3; + id: hintInput; + visible: true; + verticalAlignment: Qt.AlignVCenter; + font.capitalization: Font.AllLowercase + font.pointSize: 15; + maximumLength: 37; + } + } + + Rectangle { + color: "white"; + border.width: 1; + border.color: "black"; + + width: 75; + height: 30; + anchors.leftMargin: 5 + TextInput { + anchors.fill: parent; + anchors.leftMargin: 3; + id: attemptsInput; + visible: true; + verticalAlignment: Qt.AlignVCenter; + font.pointSize: 15; + validator: IntValidator {bottom: 1; top: 200} + } + } + + Button { + id: hintSubmit; + text: "Submit"; + onClicked: { + let hint = hintInput.text + let attempts = attemptsInput.text; + hintInput.text = ""; + attemptsInput.text = ""; + Interact.makeHint(hint, parseInt(attempts)); + } + width: 100; + height: 30; + anchors.leftMargin: 5 + } + } + + RowLayout { + id: skipControls; + + anchors.bottom: parent.bottom; + anchors.horizontalCenter: parent.horizontalCenter; + anchors.bottomMargin: 25; + Button { + id: skipButton; + text: "Skip"; + onClicked: { + Interact.skip(); + } + width: 100; + height: 30; + + } + } + } + Item { + id: resultsScreen; + visible: false; + anchors.centerIn: parent; + anchors.fill: parent; + Text { + id: blueWins; + visible: false; + text: "Blue team wins!"; + color: Colors.BLUE_AGENT; + font.pointSize: 64; + + anchors.centerIn: parent; + } + + Text { + id: redWins; + visible: false; + text: "Red team wins!"; + color: Colors.RED_AGENT; + font.pointSize: 64; + + anchors.centerIn: parent; + } + } + +// Component.onCompleted: drawBoard(5, 5, [], []) +} diff --git a/games/codenames/client/codenames-client/src/style/colors.js b/games/codenames/client/codenames-client/src/style/colors.js new file mode 100644 index 00000000..138b3f1e --- /dev/null +++ b/games/codenames/client/codenames-client/src/style/colors.js @@ -0,0 +1,12 @@ +const RED_AGENT = "#FF6B6B"; +const RED_AGENT_OPENED = "#A20000"; + +const BLUE_AGENT = "#4D96FF"; +const BLUE_AGENT_OPENED = "#003D95"; + +const NEUTRAL = "#6BCB77"; +const NEUTRAL_OPENED = "#24672C"; + +const BLACK_CARD = "#272838"; + +const CLOSED = "#5D536B"; diff --git a/games/codenames/config.json b/games/codenames/config.json new file mode 100644 index 00000000..5f81e6c7 --- /dev/null +++ b/games/codenames/config.json @@ -0,0 +1,19 @@ +{ + "id": "codenames", + "display_name": "Codenames", + "description": "Two teams compete by each having a \"spymaster\" give one-word clues that can point to multiple words on the board. The other players on the team attempt to guess their team's words while avoiding the words of the other team.", + "players_num": 6, + "role_names": [ + "blue captain", + "red captain", + "blue player", + "red player", + "blue player", + "red player" + ], + "default_settings": { + "board_h": 5, + "board_w": 5, + "word_list": "ru" + } +} diff --git a/games/codenames/server/assets/wordlists/en.txt b/games/codenames/server/assets/wordlists/en.txt new file mode 100644 index 00000000..76f8e66d --- /dev/null +++ b/games/codenames/server/assets/wordlists/en.txt @@ -0,0 +1,673 @@ +Acne +Acre +Addendum +Advertise +Aircraft +Aisle +Alligator +Alphabetize +America +Ankle +Apathy +Applause +Applesauc +Application +Archaeologist +Aristocrat +Arm +Armada +Asleep +Astronaut +Athlete +Atlantis +Aunt +Avocado +Baby-Sitter +Backbone +Bag +Baguette +Bald +Balloon +Banana +Banister +Baseball +Baseboards +Basketball +Bat +Battery +Beach +Beanstalk +Bedbug +Beer +Beethoven +Belt +Bib +Bicycle +Big +Bike +Billboard +Bird +Birthday +Bite +Blacksmith +Blanket +Bleach +Blimp +Blossom +Blueprint +Blunt +Blur +Boa +Boat +Bob +Bobsled +Body +Bomb +Bonnet +Book +Booth +Bowtie +Box +Boy +Brainstorm +Brand +Brave +Bride +Bridge +Broccoli +Broken +Broom +Bruise +Brunette +Bubble +Buddy +Buffalo +Bulb +Bunny +Bus +Buy +Cabin +Cafeteria +Cake +Calculator +Campsite +Can +Canada +Candle +Candy +Cape +Capitalism +Car +Cardboard +Cartography +Cat +Cd +Ceiling +Cell +Century +Chair +Chalk +Champion +Charger +Cheerleader +Chef +Chess +Chew +Chicken +Chime +China +Chocolate +Church +Circus +Clay +Cliff +Cloak +Clockwork +Clown +Clue +Coach +Coal +Coaster +Cog +Cold +College +Comfort +Computer +Cone +Constrictor +Continuum +Conversation +Cook +Coop +Cord +Corduroy +Cot +Cough +Cow +Cowboy +Crayon +Cream +Crisp +Criticize +Crow +Cruise +Crumb +Crust +Cuff +Curtain +Cuticle +Czar +Dad +Dart +Dawn +Day +Deep +Defect +Dent +Dentist +Desk +Dictionary +Dimple +Dirty +Dismantle +Ditch +Diver +Doctor +Dog +Doghouse +Doll +Dominoes +Door +Dot +Drain +Draw +Dream +Dress +Drink +Drip +Drums +Dryer +Duck +Dump +Dunk +Dust +Ear +Eat +Ebony +Elbow +Electricity +Elephant +Elevator +Elf +Elm +Engine +England +Ergonomic +Escalator +Eureka +Europe +Evolution +Extension +Eyebrow +Fan +Fancy +Fast +Feast +Fence +Feudalism +Fiddle +Figment +Finger +Fire +First +Fishing +Fix +Fizz +Flagpole +Flannel +Flashlight +Flock +Flotsam +Flower +Flu +Flush +Flutter +Fog +Foil +Football +Forehead +Forever +Fortnight +France +Freckle +Freight +Fringe +Frog +Frown +Gallop +Game +Garbage +Garden +Gasoline +Gem +Ginger +Gingerbread +Girl +Glasses +Goblin +Gold +Goodbye +Grandpa +Grape +Grass +Gratitude +Gray +Green +Guitar +Gum +Gumball +Hair +Half +Handle +Handwriting +Hang +Happy +Hat +Hatch +Headache +Heart +Hedge +Helicopter +Hem +Hide +Hill +Hockey +Homework +Honk +Hopscotch +Horse +Hose +Hot +House +Houseboat +Hug +Humidifier +Hungry +Hurdle +Hurt +Hut +Ice +Implode +Inn +Inquisition +Intern +Internet +Invitation +Ironic +Ivory +Ivy +Jade +Japan +Jeans +Jelly +Jet +Jig +Jog +Journal +Jump +Key +Killer +Kilogram +King +Kitchen +Kite +Knee +Kneel +Knife +Knight +Koala +Lace +Ladder +Ladybug +Lag +Landfill +Lap +Laugh +Laundry +Law +Lawn +Lawnmower +Leak +Leg +Letter +Level +Lifestyle +Ligament +Light +Lightsaber +Lime +Lion +Lizard +Log +Loiterer +Lollipop +Loveseat +Loyalty +Lunch +Lunchbox +Lyrics +Machine +Macho +Mailbox +Mammoth +Mark +Mars +Mascot +Mast +Matchstick +Mate +Mattress +Mess +Mexico +Midsummer +Mine +Mistake +Modern +Mold +Mom +Monday +Money +Monitor +Monster +Mooch +Moon +Mop +Moth +Motorcycle +Mountain +Mouse +Mower +Mud +Music +Mute +Nature +Negotiate +Neighbor +Nest +Neutron +Niece +Night +Nightmare +Nose +Oar +Observatory +Office +Oil +Old +Olympian +Opaque +Opener +Orbit +Organ +Organize +Outer +Outside +Ovation +Overture +Pail +Paint +Pajamas +Palace +Pants +Paper +Paper +Park +Parody +Party +Password +Pastry +Pawn +Pear +Pen +Pencil +Pendulum +Penis +Penny +Pepper +Personal +Philosopher +Phone +Photograph +Piano +Picnic +Pigpen +Pillow +Pilot +Pinch +Ping +Pinwheel +Pirate +Plaid +Plan +Plank +Plate +Platypus +Playground +Plow +Plumber +Pocket +Poem +Point +Pole +Pomp +Pong +Pool +Popsicle +Population +Portfolio +Positive +Post +Princess +Procrastinate +Protestant +Psychologist +Publisher +Punk +Puppet +Puppy +Push +Puzzle +Quarantine +Queen +Quicksand +Quiet +Race +Radio +Raft +Rag +Rainbow +Rainwater +Random +Ray +Recycle +Red +Regret +Reimbursement +Retaliate +Rib +Riddle +Rim +Rink +Roller +Room +Rose +Round +Roundabout +Rung +Runt +Rut +Sad +Safe +Salmon +Salt +Sandbox +Sandcastle +Sandwich +Sash +Satellite +Scar +Scared +School +Scoundrel +Scramble +Scuff +Seashell +Season +Sentence +Sequins +Set +Shaft +Shallow +Shampoo +Shark +Sheep +Sheets +Sheriff +Shipwreck +Shirt +Shoelace +Short +Shower +Shrink +Sick +Siesta +Silhouette +Singer +Sip +Skate +Skating +Ski +Slam +Sleep +Sling +Slow +Slump +Smith +Sneeze +Snow +Snuggle +Song +Space +Spare +Speakers +Spider +Spit +Sponge +Spool +Spoon +Spring +Sprinkler +Spy +Square +Squint +Stairs +Standing +Star +State +Stick +Stockholder +Stoplight +Stout +Stove +Stowaway +Straw +Stream +Streamline +Stripe +Student +Sun +Sunburn +Sushi +Swamp +Swarm +Sweater +Swimming +Swing +Tachometer +Talk +Taxi +Teacher +Teapot +Teenager +Telephone +Ten +Tennis +Thief +Think +Throne +Through +Thunder +Tide +Tiger +Time +Tinting +Tiptoe +Tiptop +Tired +Tissue +Toast +Toilet +Tool +Toothbrush +Tornado +Tournament +Tractor +Train +Trash +Treasure +Tree +Triangle +Trip +Truck +Tub +Tuba +Tutor +Television +Twang +Twig +Twitterpated +Type +Unemployed +Upgrade +Vest +Vision +Wag +Water +Watermelon +Wax +Wedding +Weed +Welder +Whatever +Wheelchair +Whiplash +Whisk +Whistle +White +Wig +Will +Windmill +Winter +Wish +Wolf +Wool +World +Worm +Wristwatch +Yardstick +Zamboni +Zen +Zero +Zipper +Zone +Zoo diff --git a/games/codenames/server/assets/wordlists/ru.txt b/games/codenames/server/assets/wordlists/ru.txt new file mode 100644 index 00000000..0d97f435 --- /dev/null +++ b/games/codenames/server/assets/wordlists/ru.txt @@ -0,0 +1,191 @@ +рука +друг +глаз +голова +ребёнок +сила +отец +проблема +голос +ночь +свет +душа +минута +язык +любовь +президент +стена +интерес +лес +игра +кровь +картина +доллар +музыка +писатель +самолёт +берег +песня +круг +поэт +сон +удар +линия +доктор +художник +волос +ветер +грудь +сад +камень +река +сфера +воля +снег +деревня +немец +победа +звезда +карман +кухня +зуб +актёр +чёрт +дед +чай +зима +студент +секунда +бабушка +трубка +газ +улыбка +май +остров +волна +птица +тень +ужас +декабрь +цветок +весна +трава +князь +рыбка +дождь +лоб +восток +тишина +подарок +царь +смех +стакан +экран +парк +страсть +формула +мост +лев +корень +буква +лёд +цифра +полоса +куст +кость +ручка +металл +поэзия +краска +мечта +почва +король +шанс +сумка +песок +сказка +хозяйка +дочка +танец +пенсия +пыль +москвич +корова +Париж +туфля +трамвай +кошка +поезд +щётка +Бразилия +тарелка +Лондон +лопата +слон +Китай +крыша +пират +ящик +Африка +посол +бутылка +замок +пилот +веер +пальма +мышь +луна +кровать +день +кит +градус +принцесса +спутник +батарея +няня +вишня +арбуз +заяц +лиса +крест +нож +Кремль +книга +торт +стул +клоун +шоколад +банан +конфетка +куб +туман +лягушка +мамонт +матрёшка +лимон +клубничка +муза +нос +бумага +радуга +рояль +салат +роза +шарф +паук +тигр +трактор +мельница +свадьба +водка +ключ +Техас +Испания +крокодил +медведь +верблюд +динозавр +леопард +панда +зебра +Венера diff --git a/games/codenames/server/src/codenames.cpp b/games/codenames/server/src/codenames.cpp new file mode 100644 index 00000000..b003a29b --- /dev/null +++ b/games/codenames/server/src/codenames.cpp @@ -0,0 +1,140 @@ +#include "cavoke.h" +#include "model.h" +#include "wordlists.h" + +const int MAX_PLAYERS = 6; +const std::vector BLUE_PLAYERS = {0, 2, 4}; // NOLINT(cert-err58-cpp) +const std::vector RED_PLAYERS = {1, 3, 5}; // NOLINT(cert-err58-cpp) + +// 0 - blue captain +// 1 - red captain +// 2, 4 - blue team +// 3, 5 - red team +// requires 0, 1, (2 or 4), (3 or 5) +bool cavoke::validate_settings( + const cavoke::json &settings, + const std::vector &occupied_positions, + const std::function &message_callback) { + if (!settings.contains("board_h") || !settings.contains("board_w") || + !settings.contains("word_list") || settings["board_h"].get() < 5 || + settings["board_w"].get() < 4) { + message_callback("Invalid config"); + return false; + } + + try { + codenames::WordList wl(settings["word_list"].get()); + } catch (const codenames::WordList::wordlist_not_found &e) { + message_callback(e.what()); + return false; + } + + auto occupied_end = occupied_positions.end(); + if (std::find(occupied_positions.begin(), occupied_positions.end(), 0) == + occupied_end) { + message_callback("No blue captain"); + return false; + } + + if (std::find(occupied_positions.begin(), occupied_positions.end(), 1) == + occupied_end) { + message_callback("No red captain"); + return false; + } + + if (std::find(occupied_positions.begin(), occupied_positions.end(), 2) == + occupied_end && + std::find(occupied_positions.begin(), occupied_positions.end(), 4) == + occupied_end) { + message_callback("No blue team"); + return false; + } + + if (std::find(occupied_positions.begin(), occupied_positions.end(), 3) == + occupied_end && + std::find(occupied_positions.begin(), occupied_positions.end(), 5) == + occupied_end) { + message_callback("No red team"); + return false; + } + + return true; +} + +cavoke::GameState model_to_game_state(const codenames::CodenamesModel &model) { + cavoke::GameState result; + result.is_terminal = + (model.result() != codenames::CodenamesModel::GAME_RESULT::IN_PROGRESS); + + if (result.is_terminal) { + if (model.result() == + codenames::CodenamesModel::GAME_RESULT::BLUE_WINS) { + result.winners = BLUE_PLAYERS; + } else { + result.winners = RED_PLAYERS; + } + } + + result.global_state = model.get_global_state(); + result.players_state.resize(MAX_PLAYERS); + + for (int i = 0; i < MAX_PLAYERS; ++i) { + result.players_state[i] = model.get_player_state(i); + } + + return result; +} + +cavoke::GameState cavoke::init_state( + const cavoke::json &settings, + const std::vector &occupied_positions) { + codenames::CodenamesModel model( + settings["board_h"].get(), settings["board_w"].get(), + codenames::WordList(settings["word_list"].get())); + + return model_to_game_state(model); +} + +bool is_right_player(const codenames::CodenamesModel &model, int player_id) { + return ((player_id == 2 || player_id == 4) && + model.stage() == + codenames::CodenamesModel::GAME_STAGE::BLUE_TEAM) || + ((player_id == 3 || player_id == 5) && + model.stage() == codenames::CodenamesModel::GAME_STAGE::RED_TEAM); +} + +bool is_right_captain(const codenames::CodenamesModel &model, int player_id) { + return (player_id == 0 && + model.stage() == + codenames::CodenamesModel::GAME_STAGE::BLUE_CAPTAIN) || + (player_id == 1 && + model.stage() == + codenames::CodenamesModel::GAME_STAGE::RED_CAPTAIN); +} + +cavoke::GameState cavoke::apply_move(cavoke::GameMove &new_move) { + codenames::CodenamesModel model = + nlohmann::json::parse(new_move.global_state); + auto command = nlohmann::json::parse(new_move.move); + auto type = command["type"].get(); + + if (type == "hint") { + auto hint = command["hint"].get(); + auto attempts = command["attempts"].get(); + + if (is_right_captain(model, new_move.player_id)) { + model.make_hint(hint, attempts); + } + } else if (type == "open") { + auto pos = command["position"].get(); + if (is_right_player(model, new_move.player_id)) { + model.open_card(pos); + } + } else if (type == "skip") { + if (is_right_player(model, new_move.player_id)) { + model.skip(); + } + } + + return model_to_game_state(model); +} diff --git a/games/codenames/server/src/model.cpp b/games/codenames/server/src/model.cpp new file mode 100644 index 00000000..055485e3 --- /dev/null +++ b/games/codenames/server/src/model.cpp @@ -0,0 +1,183 @@ +#include "model.h" +#include + +namespace codenames { + +CodenamesModel::GAME_STAGE CodenamesModel::stage() const { + return m_stage; +} + +CodenamesModel::GAME_RESULT CodenamesModel::result() const { + return m_result; +} + +CodenamesModel::CodenamesModel(int height_, + int width_, + const WordList &wordlist_) + : m_height(height_), + m_width(width_), + m_stage(GAME_STAGE::BLUE_CAPTAIN), + m_result(GAME_RESULT::IN_PROGRESS) { + int cards_cnt = height_ * width_; + m_opened = std::vector(cards_cnt, false); + m_words = wordlist_.sample(cards_cnt); + generate_cards(); +} + +void CodenamesModel::generate_cards() { + std::mt19937 rnd{std::random_device{}()}; + + int red_cnt = m_height * m_width / 3; + int blue_cnt = red_cnt + 1; + int black_cnt = m_height * m_width / 15; + + m_blue_closed = blue_cnt; + m_red_closed = red_cnt; + + m_card_states = + std::vector(m_height * m_width, CARD_STATE::NEUTRAL); + + for (int i = 0; i < red_cnt; ++i) { + size_t pos = rnd() % m_card_states.size(); + while (m_card_states[pos] != CARD_STATE::NEUTRAL) { + pos = rnd() % m_card_states.size(); + } + m_card_states[pos] = CARD_STATE::RED; + } + + for (int i = 0; i < blue_cnt; ++i) { + size_t pos = rnd() % m_card_states.size(); + while (m_card_states[pos] != CARD_STATE::NEUTRAL) { + pos = rnd() % m_card_states.size(); + } + m_card_states[pos] = CARD_STATE::BLUE; + } + + for (int i = 0; i < black_cnt; ++i) { + size_t pos = rnd() % m_card_states.size(); + while (m_card_states[pos] != CARD_STATE::NEUTRAL) { + pos = rnd() % m_card_states.size(); + } + m_card_states[pos] = CARD_STATE::BLACK; + } +} + +std::string CodenamesModel::get_global_state() const { + nlohmann::json state_json = *this; + return state_json.dump(); +} + +std::string CodenamesModel::get_player_state(int player) const { + nlohmann::json result = *this; + result["role"] = player; + if (player == 0 || player == 1) { + return result.dump(); + } + + result.erase("m_opened"); + + std::vector state(m_width * m_height); + + for (int i = 0; i < m_height * m_width; ++i) { + if (m_opened[i]) { + state[i] = m_card_states[i]; + } else { + state[i] = CARD_STATE::CLOSED; + } + } + + result["m_card_states"] = std::move(state); + + return result.dump(); +} + +void CodenamesModel::open_card(int pos) { + if (m_stage == GAME_STAGE::BLUE_CAPTAIN || + m_stage == GAME_STAGE::RED_CAPTAIN) { + throw invalid_move(); + } + + if (m_opened[pos]) { + return; + } + + m_opened[pos] = true; + m_attempts_left--; + + if (m_card_states[pos] == CARD_STATE::BLACK) { + m_last_hint = ""; + + if (m_stage == GAME_STAGE::BLUE_TEAM) { + m_result = GAME_RESULT::RED_WINS; + } else { + m_result = GAME_RESULT::BLUE_WINS; + } + m_stage = GAME_STAGE::FINISHED; + return; + + } else if (m_card_states[pos] == CARD_STATE::NEUTRAL || + m_attempts_left == 0 || + (m_card_states[pos] == CARD_STATE::BLUE && + m_stage == GAME_STAGE::RED_TEAM) || + (m_card_states[pos] == CARD_STATE::RED && + m_stage == GAME_STAGE::BLUE_TEAM)) { + m_last_hint = ""; + next_stage(); + } + + if (m_card_states[pos] == CARD_STATE::BLUE) { + m_blue_closed--; + } else if (m_card_states[pos] == CARD_STATE::RED) { + m_red_closed--; + } + + if (m_red_closed == 0) { + m_result = GAME_RESULT::RED_WINS; + m_stage = GAME_STAGE::FINISHED; + } else if (m_blue_closed == 0) { + m_result = GAME_RESULT::BLUE_WINS; + m_stage = GAME_STAGE::FINISHED; + } +} + +void CodenamesModel::make_hint(std::string hint, int attempts) { + if (m_stage != GAME_STAGE::BLUE_CAPTAIN && + m_stage != GAME_STAGE::RED_CAPTAIN) { + throw invalid_move(); + } + + m_last_hint = std::move(hint); + m_attempts_left = attempts + 1; + + next_stage(); +} + +void CodenamesModel::skip() { + if (m_stage == GAME_STAGE::BLUE_CAPTAIN || + m_stage == GAME_STAGE::RED_CAPTAIN) { + throw invalid_move(); + } + + m_attempts_left = 0; + m_last_hint = ""; + + next_stage(); +} + +void CodenamesModel::next_stage() { + if (m_stage == GAME_STAGE::BLUE_CAPTAIN) { + m_stage = GAME_STAGE::BLUE_TEAM; + } else if (m_stage == GAME_STAGE::BLUE_TEAM) { + m_stage = GAME_STAGE::RED_CAPTAIN; + } else if (m_stage == GAME_STAGE::RED_CAPTAIN) { + m_stage = GAME_STAGE::RED_TEAM; + } else if (m_stage == GAME_STAGE::RED_TEAM) { + m_stage = GAME_STAGE::BLUE_CAPTAIN; + } +} + +CodenamesModel::invalid_move::invalid_move() + : std::runtime_error("Invalid move") { +} + +} // namespace codenames diff --git a/games/codenames/server/src/model.h b/games/codenames/server/src/model.h new file mode 100644 index 00000000..26f7227f --- /dev/null +++ b/games/codenames/server/src/model.h @@ -0,0 +1,84 @@ +#ifndef CAVOKE_MODEL_H +#define CAVOKE_MODEL_H + +#include +#include +#include +#include +#include "wordlists.h" + +namespace codenames { + +class CodenamesModel { // NOLINT(cppcoreguidelines-pro-type-member-init) +public: + struct invalid_move : std::runtime_error { + invalid_move(); + }; + + enum class GAME_STAGE { + BLUE_CAPTAIN = 0, + BLUE_TEAM = 1, + RED_CAPTAIN = 2, + RED_TEAM = 3, + FINISHED = 4 + }; + + enum class CARD_STATE { + BLUE = 0, + RED = 1, + BLACK = 2, + NEUTRAL = 3, + CLOSED = 4 + }; + + enum class GAME_RESULT { IN_PROGRESS = 0, BLUE_WINS = 1, RED_WINS = 2 }; + +private: + void generate_cards(); + + GAME_STAGE m_stage; + std::string m_last_hint; + int m_attempts_left = 0; + std::vector m_words; + std::vector m_card_states; + std::vector m_opened; + int m_height; + int m_width; + int m_blue_closed = 0; + int m_red_closed = 0; + GAME_RESULT m_result; + + void next_stage(); + +public: + [[nodiscard]] GAME_STAGE stage() const; + [[nodiscard]] GAME_RESULT result() const; + + void open_card(int pos); + void make_hint(std::string hint, int attempts); + void skip(); + + [[nodiscard]] std::string get_player_state(int player) const; + [[nodiscard]] std::string get_global_state() const; + + // cppcheck-suppress unknownMacro + NLOHMANN_DEFINE_TYPE_INTRUSIVE(CodenamesModel, + m_stage, + m_last_hint, + m_attempts_left, + m_words, + m_card_states, + m_opened, + m_height, + m_width, + m_result, + m_blue_closed, + m_red_closed) + + CodenamesModel(int height_, int width_, const WordList &wordlist_); + CodenamesModel() = default; +}; + +} // namespace codenames + +#endif // CAVOKE_MODEL_H diff --git a/games/codenames/server/src/wordlists.cpp b/games/codenames/server/src/wordlists.cpp new file mode 100644 index 00000000..02dacdce --- /dev/null +++ b/games/codenames/server/src/wordlists.cpp @@ -0,0 +1,42 @@ +#include "wordlists.h" +#include +#include +#include +#include +#include + +namespace codenames { + +WordList::wordlist_not_found::wordlist_not_found(const std::string &wordlist) + : std::runtime_error("Wordlist \"" + wordlist + "\" not found") { +} + +WordList::WordList(const std::string &wordlist_name) + : m_rnd{std::random_device{}()} { + std::string file_path = + WORDLISTS_PATH + path_sep() + wordlist_name + ".txt"; + if (!std::filesystem::exists(file_path)) { + throw wordlist_not_found(wordlist_name); + } + std::string word; + std::ifstream input(file_path); + while (input >> word) { + m_words.push_back(word); + } +} + +const std::vector &WordList::all_words() const { + return m_words; +} + +int WordList::size() const { + return static_cast(m_words.size()); +} + +std::vector WordList::sample(int n) const { + std::vector result(n); + std::sample(m_words.begin(), m_words.end(), result.begin(), n, m_rnd); + return result; +} + +} // namespace codenames diff --git a/games/codenames/server/src/wordlists.h b/games/codenames/server/src/wordlists.h new file mode 100644 index 00000000..1f78d77c --- /dev/null +++ b/games/codenames/server/src/wordlists.h @@ -0,0 +1,37 @@ +#ifndef CAVOKE_WORDLISTS_H +#define CAVOKE_WORDLISTS_H + +#include +#include +#include +#include +#include + +namespace codenames { + +inline std::string path_sep() { + return {std::filesystem::path::preferred_separator}; +} + +// NOLINTNEXTLINE(cert-err58-cpp) +const std::string WORDLISTS_PATH = "assets" + path_sep() + "wordlists"; + +struct WordList { + explicit WordList(const std::string &wordlist_name); + + struct wordlist_not_found : std::runtime_error { + explicit wordlist_not_found(const std::string &wordlist); + }; + + [[nodiscard]] const std::vector &all_words() const; + [[nodiscard]] int size() const; + [[nodiscard]] std::vector sample(int n) const; + +private: + std::vector m_words; + mutable std::mt19937 m_rnd; +}; + +} // namespace codenames + +#endif // CAVOKE_WORDLISTS_H diff --git a/games/tictactoe/tictactoe.cpp b/games/tictactoe/tictactoe.cpp index db2fd9f7..9c877181 100644 --- a/games/tictactoe/tictactoe.cpp +++ b/games/tictactoe/tictactoe.cpp @@ -1,5 +1,5 @@ #include -#include "../cavoke.h" +#include "cavoke.h" namespace cavoke { @@ -30,7 +30,7 @@ int get_board_size(const std::string &board) { return static_cast(sqrt(static_cast(board.size()))); } -char current_player(std::string &board) { +char current_player(const std::string &board) { int xs_cnt = 0; int os_cnt = 0; for (int i = 0; i < board.size(); ++i) { @@ -44,7 +44,7 @@ char current_player(std::string &board) { return (xs_cnt == os_cnt ? 'X' : 'O'); } -int extract_position(std::string &move) { +int extract_position(const std::string &move) { std::stringstream to_split(move); char action; to_split >> action; @@ -53,10 +53,15 @@ int extract_position(std::string &move) { return position; }; -bool is_valid_move(std::string &board, int position) { +bool is_valid_move(const std::string &board, int position) { return position >= 0 && position < board.size() && board[position] == ' '; } +bool is_full(const std::string &board) { + return std::none_of(board.begin(), board.end(), + [](char c) { return c == ' '; }); +} + int coord_to_pos(int x, int y, int board_size) { return x * board_size + y; } @@ -158,12 +163,13 @@ GameState apply_move(GameMove &new_move) { board[position] = player; bool win = winner(board); + bool full = is_full(board); std::vector winners; if (win) { winners.push_back(new_move.player_id); } - return {win, board, {board, board}, winners}; + return {win || full, board, {board, board}, winners}; } } // namespace cavoke diff --git a/install-karchive.sh b/install-karchive.sh new file mode 100755 index 00000000..f9205924 --- /dev/null +++ b/install-karchive.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env sh +mkdir build-zlib +cmake -B build-zlib ./third_party/zlib +cmake --build build-zlib --target install +mkdir build-ECM +cmake -B build-ECM ./third_party/extra-cmake-modules +cmake --build build-ECM --target install +mkdir build-karchive +cmake -B build-karchive ./third_party/karchive +cmake --build build-karchive --target install \ No newline at end of file diff --git a/server.Dockerfile b/server.Dockerfile new file mode 100644 index 00000000..ddf4a509 --- /dev/null +++ b/server.Dockerfile @@ -0,0 +1,21 @@ +FROM ghcr.io/cavoke-project/cavoke-ci:server + +# Build arguments. Use `--build-arg` in docker run +ARG SERVER_PORT=8080 + +EXPOSE $SERVER_PORT + +# Cavoke server +ENV CAVOKE_ROOT="$IROOT/cavoke/" +ADD ./ $CAVOKE_ROOT +# add cavoke-dev-lib dependency +ADD cavoke-dev-lib/ $CAVOKE_ROOT +WORKDIR $CAVOKE_ROOT +# Install server +RUN mkdir -p build && \ + cmake . -B build -DBUILD_ALL=OFF -DBUILD_SERVER=ON -DUSE_EXTERNAL_DROGON=ON -DUSE_EXTERNAL_NLOHMANN=ON -DUSE_EXTERNAL_JWT=ON && \ + make -C build install +WORKDIR / +RUN rm -rf $CAVOKE_ROOT + +ENTRYPOINT ["/usr/local/bin/cavoke_server"] diff --git a/server/.gcp/Dockerfile b/server/.gcp/Dockerfile new file mode 100644 index 00000000..74d4ce4c --- /dev/null +++ b/server/.gcp/Dockerfile @@ -0,0 +1,38 @@ +FROM ghcr.io/cavoke-project/cavoke-server + +# Install system dependencies +RUN set -e; \ + apt-get update -y && apt-get install -y \ + tini \ + lsb-release \ + gpg-agent; \ + gcsFuseRepo=gcsfuse-`lsb_release -c -s`; \ + echo "deb http://packages.cloud.google.com/apt $gcsFuseRepo main" | \ + tee /etc/apt/sources.list.d/gcsfuse.list; \ + curl https://packages.cloud.google.com/apt/doc/apt-key.gpg | \ + apt-key add -; \ + apt-get update; \ + apt-get install -y gcsfuse \ + && apt-get clean + +# Drogon configuration basename for +ARG DROGON_CONFIG_BASENAME=drogon_config.json + +# Set fallback mount directory +ENV MNT_DIR /mnt/gcs +ENV DROGON_CONFIG_FILE $MNT_DIR/$DROGON_CONFIG_BASENAME + +# Copy local code to the container image. +ENV APP_HOME /app +WORKDIR $APP_HOME +COPY gcsfuse_run.sh ./ + +# Ensure the script is executable +RUN chmod +x /app/gcsfuse_run.sh + +# Use tini to manage zombie processes and signal forwarding +# https://github.com/krallin/tini +ENTRYPOINT ["/usr/bin/tini", "--"] + +# Pass the startup script as arguments to Tini +CMD ["/app/gcsfuse_run.sh"] \ No newline at end of file diff --git a/server/.gcp/gcsfuse_run.sh b/server/.gcp/gcsfuse_run.sh new file mode 100644 index 00000000..0f3dda09 --- /dev/null +++ b/server/.gcp/gcsfuse_run.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -eo pipefail + +# Create mount directory for service +mkdir -p "$MNT_DIR" + +echo "Mounting GCS Fuse." +gcsfuse --debug_gcs --debug_fuse --file-mode=777 "$BUCKET" "$MNT_DIR" +echo "Mounting completed." + +# Run the web service on container startup. +exec /usr/local/bin/cavoke_server -p "$PORT" -c "$DROGON_CONFIG_FILE" "$@" & + +# Exit immediately when one of the background processes terminate. +wait -n \ No newline at end of file diff --git a/server/CMakeLists.txt b/server/CMakeLists.txt index d240d42c..86fa7004 100644 --- a/server/CMakeLists.txt +++ b/server/CMakeLists.txt @@ -1,35 +1,85 @@ cmake_minimum_required(VERSION 3.10) project(cavoke_server) - set(CMAKE_CXX_STANDARD 17) -find_package(Drogon 1.7.3 REQUIRED) - -find_package(Boost 1.71 REQUIRED filesystem program_options) -include_directories(${Boost_INCLUDE_DIRS}) -link_directories(${Boost_LIBRARY_DIRS}) +set(CAVOKE_H_DIR ../cavoke-dev-lib CACHE PATH "Directory with cavoke.h library") +include_directories(${CAVOKE_H_DIR}) -# https://github.com/nlohmann/json/releases/tag/v3.9.0 for convenience macros -find_package(nlohmann_json 3.9.0 REQUIRED) +aux_source_directory(model SRC_DIR) +aux_source_directory(controllers CTL_SRC) +aux_source_directory(filters FILTER_SRC) +aux_source_directory(plugins PLUGIN_SRC) +aux_source_directory(sql-models MODEL_SRC) -include_directories(.) +# C++ namespace with sql models drogon ORM +add_compile_definitions(cavoke_orm=cavoke) -add_executable(cavoke_server +add_executable(${PROJECT_NAME} main.cpp - model/games_storage.cpp - model/game.cpp + cavoke_base_exception.cpp + ${FILTER_SRC} + ${PLUGIN_SRC} + ${MODEL_SRC} + model/games/games_storage.cpp + model/games/game.cpp controllers/games_controller.cpp controllers/health_controller.cpp - model/games_storage_config.cpp - model/game_state_storage.cpp + model/games/games_storage_config.cpp + model/logic/game_state_storage.cpp model/sessions/sessions_storage.cpp - model/game_logic_manager.cpp + model/logic/game_logic_manager.cpp controllers/state_controller.cpp controllers/sessions_controller.cpp - model/auth/authentication_manager.cpp + controllers/users_controller.cpp model/sessions/game_session.cpp - cavoke_base_exception.cpp + model/statistics/statistics_manager.cpp + controllers/statistics_controller.cpp + model/rooms/rooms_storage.cpp + controllers/rooms_controller.cpp + controllers/gamesubmissions_controller.cpp ) -target_link_libraries(cavoke_server Drogon::Drogon) -target_link_libraries(cavoke_server ${Boost_LIBRARIES} ${CMAKE_DL_LIBS}) -target_link_libraries(cavoke_server nlohmann_json::nlohmann_json) +install(TARGETS ${PROJECT_NAME}) + +find_package(Boost 1.71 REQUIRED filesystem program_options) +include_directories(${Boost_INCLUDE_DIRS}) +link_directories(${Boost_LIBRARY_DIRS}) + +# Link Drogon +if (USE_EXTERNAL_DROGON) + target_link_libraries(${PROJECT_NAME} PRIVATE Drogon::Drogon) +else () + target_link_libraries(${PROJECT_NAME} PRIVATE drogon) +endif () + +# Link nlohmann +if (USE_EXTERNAL_NLOHMANN) + target_link_libraries(${PROJECT_NAME} PRIVATE nlohmann_json::nlohmann_json) +else () + target_link_libraries(${PROJECT_NAME} PRIVATE nlohmann_json) +endif () + +# Link jwt +if (USE_EXTERNAL_NLOHMANN) + target_link_libraries(${PROJECT_NAME} PRIVATE jwt-cpp::jwt-cpp) +else () + target_link_libraries(${PROJECT_NAME} PRIVATE jwt-cpp) +endif () + + +target_link_libraries(cavoke_server PRIVATE ${Boost_LIBRARIES} ${CMAKE_DL_LIBS}) + +drogon_create_views(${PROJECT_NAME} ${CMAKE_CURRENT_SOURCE_DIR}/views + ${CMAKE_CURRENT_BINARY_DIR}) +# use the following line to create views with namespaces. +# drogon_create_views(${PROJECT_NAME} ${CMAKE_CURRENT_SOURCE_DIR}/views +# ${CMAKE_CURRENT_BINARY_DIR} TRUE) + +target_include_directories(${PROJECT_NAME} + PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +# ############################################################################## +# uncomment the following line for dynamically loading views +# set_property(TARGET ${PROJECT_NAME} PROPERTY ENABLE_EXPORTS ON) + +# ############################################################################## + +add_subdirectory(test) diff --git a/server/README.md b/server/README.md index 0098d2d8..9cedd249 100644 --- a/server/README.md +++ b/server/README.md @@ -1,17 +1,133 @@ # Серверный компонент Cavoke ## О сервере + TODO ## Как запустить -### Установка -Для работы сервера нужно установить несколько библиотек: + +### Docker + +Проще всего сервер запустить внутри docker контейнера. + +#### Docker Compose + +Для локальной разработки в одну команду можно запустить сервер через docker-compose. + +```console +cavoke@ubuntu:~$ docker-compose up -d +cavoke@ubuntu:~$ curl -s localhost:8080/games/list +[{"display_name":"Tic-tac-toe","id":"tictactoe", ... }] +``` + +Для этого можно воспользоваться актуальным +image [`ghcr.io/cavoke-project/cavoke-server`](ghcr.io/cavoke-project/cavoke-server). + +#### Пример с mount игр + +```console +cavoke@ubuntu:~$ find . -type f +./local_games/tictactoe/client.zip +./local_games/tictactoe/logic +./local_games/tictactoe/config.json +cavoke@ubuntu:~$ docker run -v `pwd`/local_games:/mnt/games -p 8080:8080 -d ghcr.io/cavoke-project/cavoke-server -g /mnt/games +cavoke@ubuntu:~$ curl -s localhost:8080/games/list +[{"display_name":"Tic-tac-toe","id":"tictactoe", ... }] +``` + + + +Подробное описание всех опций запуска см. в `docker run -t ghcr.io/cavoke-project/cavoke-server --help` + +### Собрать самому + +#### На своей машине + +Для сборки необходимо установить [boost](https://www.boost.org/) не старее 1.71, а также несколько пакетов для +серверного фреймворка. + +Например, на ubuntu все зависимости можно загрузить с помощью `apt`. + +```console +cavoke@ubuntu:~$ apt install libboost-all-dev \ + openssl libssl-dev libjsoncpp-dev uuid-dev zlib1g-dev libc-ares-dev \ + postgresql-server-dev-all +``` + +После этого можно будет собрать проект с помощью `cmake`. + +```console +cavoke@ubuntu:~$ mkdir -p build-cmake && cmake . -B build-cmake +``` + +> :information_source: Для сборки, используя локальные библиотеки, может понадобиться +> прописать `git submodule update --init --recursive` для загрузки подмодулей. + +#### Установка библиотек извне + +Вообще же на сервере используются следующие библиотеки. Альтернативно можно установить каждую из них отдельно, +следуя инструкциям в их документации. + - [drogon](https://github.com/drogonframework/drogon) не старее 1.7.3 - [nlohmann/json](https://github.com/nlohmann/json) не старее 3.9.0 - [boost](https://www.boost.org/) не старее 1.71 -#### Собрать самому -На своей машине первые две библиотеки проще собрать самому. В nlohmann вообще отсутствуют внешние зависимости. В drogon же их много, поэтому сборка не на linux может быть увлекательным процессом. +После этого, чтобы указать установку библиотеки cmake, стоит использовать флаг. Например, `-DUSE_EXTERNAL_DROGON=ON`. + +Итого команда для запуска `cmake` может выглядеть так: + +```console +cavoke@ubuntu:~$ mkdir -p build-cmake && cmake . -B build-cmake -DUSE_EXTERNAL_DROGON=ON -DUSE_EXTERNAL_NLOHMANN=ON +``` + +Более подробно смотрите в [CMakeLists.txt](./CMakeLists.txt) + +#### Gitpod + +Альтернативно, можно попробовать разрабатывать в удаленной среде. Для этого подключён сервис Gitpod, с помощью которого +можно в браузере запустить VS Code, в котором уже будут добавлены все зависимости, а работать он будет в облаке. + +[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/cavoke-project/cavoke) + +### SQL + +Для обработки большинства запросов сервера требуется база данных [Postgresql](https://www.postgresql.org/). + +После того как сервер postgres запущен, создайте необходимые таблицы с помощью [скрипта sql](db/schema.sql). Например, +на linux: + +```bash +PGPASSWORD="postgres_password" +psql -h postgres -d cavoke -U postgres_user -f server/db/schema.sql +``` + +После этого нужно обновить ваш конфигурационный файл Drogon ([шаблон конфигурационного файла](./example_config.json)), +дополнив его информацией о подключении к базе данных. + +Для того чтобы передать серверу конфигурационный файл, запустите его с ключом `-c`. Например: + +```console +cavoke@ubuntu:~$ cavoke_server -c my_config.json +Booting server... at /home/cavoke/build/server/cavoke_server +Server configuration loaded from: 'my_config.json' +Initialize models... +Initialize controllers... +Database connection: host=127.0.0.1 port=5432 dbname=cavoke user=postgres_user