diff --git a/.ci/Arch/Dockerfile b/.ci/Arch/Dockerfile index 36cf5c4ae..b08e568f3 100644 --- a/.ci/Arch/Dockerfile +++ b/.ci/Arch/Dockerfile @@ -8,10 +8,13 @@ RUN pacman --sync --refresh --sysupgrade --needed --noconfirm \ gtest \ mariadb-libs \ ninja \ + openssl \ protobuf \ qt6-base \ + qt6-declarative \ qt6-imageformats \ qt6-multimedia \ + qt6-shadertools \ qt6-svg \ qt6-tools \ qt6-translations \ diff --git a/.ci/Debian12/Dockerfile b/.ci/Debian12/Dockerfile index 202405b84..e3df94ab5 100644 --- a/.ci/Debian12/Dockerfile +++ b/.ci/Debian12/Dockerfile @@ -15,12 +15,15 @@ RUN apt-get update && \ libprotobuf-dev \ libqt6multimedia6 \ libqt6sql6-mysql \ + libssl-dev \ ninja-build \ protobuf-compiler \ qt6-image-formats-plugins \ qt6-l10n-tools \ qt6-multimedia-dev \ + qt6-declarative-dev \ qt6-svg-dev \ + qt6-shadertools-dev \ qt6-tools-dev \ qt6-tools-dev-tools \ qt6-websockets-dev \ diff --git a/.ci/Debian13/Dockerfile b/.ci/Debian13/Dockerfile index d7ab6ac86..60e490c98 100644 --- a/.ci/Debian13/Dockerfile +++ b/.ci/Debian13/Dockerfile @@ -16,12 +16,15 @@ RUN apt-get update && \ libprotobuf-dev \ libqt6multimedia6 \ libqt6sql6-mysql \ + libssl-dev \ ninja-build \ protobuf-compiler \ qt6-image-formats-plugins \ qt6-l10n-tools \ qt6-multimedia-dev \ + qt6-declarative-dev \ qt6-svg-dev \ + qt6-shadertools-dev \ qt6-tools-dev \ qt6-tools-dev-tools \ qt6-websockets-dev \ diff --git a/.ci/Fedora43/Dockerfile b/.ci/Fedora43/Dockerfile index 27570cf99..4005bbf67 100644 --- a/.ci/Fedora43/Dockerfile +++ b/.ci/Fedora43/Dockerfile @@ -7,8 +7,9 @@ RUN dnf install -y \ git \ mariadb-devel \ ninja-build \ + openssl-devel \ protobuf-devel \ - qt6-{qttools,qtsvg,qtmultimedia,qtwebsockets}-devel \ + qt6-{qtdeclarative,qtshadertools,qttools,qtsvg,qtmultimedia,qtwebsockets}-devel \ qt6-qtimageformats \ rpm-build \ xz-devel \ diff --git a/.ci/Fedora44/Dockerfile b/.ci/Fedora44/Dockerfile index e6c8da7f3..e0224cdc6 100644 --- a/.ci/Fedora44/Dockerfile +++ b/.ci/Fedora44/Dockerfile @@ -7,8 +7,9 @@ RUN dnf install -y \ git \ mariadb-devel \ ninja-build \ + openssl-devel \ protobuf-devel \ - qt6-{qttools,qtsvg,qtmultimedia,qtwebsockets}-devel \ + qt6-{qtdeclarative,qtshadertools,qttools,qtsvg,qtmultimedia,qtwebsockets}-devel \ qt6-qtimageformats \ rpm-build \ xz-devel \ diff --git a/.ci/Servatrice_Debian12/Dockerfile b/.ci/Servatrice_Debian12/Dockerfile index 21f6a036e..321aa7c0f 100644 --- a/.ci/Servatrice_Debian12/Dockerfile +++ b/.ci/Servatrice_Debian12/Dockerfile @@ -12,6 +12,7 @@ RUN apt-get update && \ libmariadb-dev-compat \ libprotobuf-dev \ libqt6sql6-mysql \ + libssl-dev \ ninja-build \ protobuf-compiler \ qt6-tools-dev \ diff --git a/.ci/Ubuntu24.04/Dockerfile b/.ci/Ubuntu24.04/Dockerfile index 809b2e43a..10adc5e64 100644 --- a/.ci/Ubuntu24.04/Dockerfile +++ b/.ci/Ubuntu24.04/Dockerfile @@ -15,12 +15,15 @@ RUN apt-get update && \ libprotobuf-dev \ libqt6multimedia6 \ libqt6sql6-mysql \ + libssl-dev \ ninja-build \ protobuf-compiler \ qt6-image-formats-plugins \ qt6-l10n-tools \ qt6-multimedia-dev \ + qt6-declarative-dev \ qt6-svg-dev \ + qt6-shadertools-dev \ qt6-tools-dev \ qt6-tools-dev-tools \ qt6-websockets-dev \ diff --git a/.ci/Ubuntu26.04/Dockerfile b/.ci/Ubuntu26.04/Dockerfile index 7b0cd389f..1b6cf825f 100644 --- a/.ci/Ubuntu26.04/Dockerfile +++ b/.ci/Ubuntu26.04/Dockerfile @@ -16,12 +16,15 @@ RUN apt-get update && \ libprotobuf-dev \ libqt6multimedia6 \ libqt6sql6-mysql \ + libssl-dev \ ninja-build \ protobuf-compiler \ qt6-image-formats-plugins \ qt6-l10n-tools \ qt6-multimedia-dev \ + qt6-declarative-dev \ qt6-svg-dev \ + qt6-shadertools-dev \ qt6-tools-dev \ qt6-tools-dev-tools \ qt6-websockets-dev \ diff --git a/.ci/compile.sh b/.ci/compile.sh index ee846897b..bd8c900c8 100755 --- a/.ci/compile.sh +++ b/.ci/compile.sh @@ -149,6 +149,9 @@ if [[ $MAKE_TEST ]]; then fi if [[ $USE_CCACHE ]]; then flags+=("-DUSE_CCACHE=1") + # PCH-aware caching is required or ccache refuses to cache any TU that + # consumes a precompiled header, silently recompiling everything on every run. + ccache --set-config sloppiness=pch_defines,time_macros if [[ $CCACHE_SIZE ]]; then # note, this setting persists after running the script ccache --max-size "$CCACHE_SIZE" @@ -159,6 +162,7 @@ if [[ $PACKAGE_TYPE ]]; then fi if [[ $USE_VCPKG ]]; then flags+=("-DUSE_VCPKG=1") + flags+=("-DVCPKG_INSTALL_OPTIONS=--x-abi-tools-use-exact-versions") fi # Add cmake --build flags diff --git a/.ci/release_template.md b/.ci/release_template.md index ac78a193a..23b475150 100644 --- a/.ci/release_template.md +++ b/.ci/release_template.md @@ -12,9 +12,9 @@ Available pre-compiled binaries for installation: • Windows 10+ macOS - • macOS 15+ Sequoia Apple M - • macOS 14+ Sonoma Apple M - • macOS 13+ Ventura Intel + • macOS 15+ Sequoia + • macOS 14+ Sonoma + • macOS 13+ Ventura (x86) LinuxUbuntu 26.04 LTS Resolute Racoon @@ -24,10 +24,10 @@ Available pre-compiled binaries for installation: • Fedora 44Fedora 43 -We are also packaged in Arch Linux's official extra repository, courtesy of @FFY00. -General Linux support is available via a flatpak package at Flathub! + General Linux support is available via a flatpak package hosted at Flathub (x86 & ARM)! + Thanks to courtesy of @FFY00, the app is also available in Arch Linux's official extra repository. -We provide a Docker image for "Servatrice" in GHCR. You can docker pull it or use our Docker Compose files! + We maintain a Docker image for "Servatrice" in GHCR (x86 & ARM). You can docker pull it or use our Docker Compose files! diff --git a/.codacy.yml b/.codacy.yml deleted file mode 100644 index f9482da2f..000000000 --- a/.codacy.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -exclude_paths: - - '**/translations/*.ts' - -# codacy config documentation: https://support.codacy.com/hc/en-us/articles/115002130625-Codacy-Configuration-File diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 56ad64283..589cae1d8 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -334,7 +334,7 @@ the tr() call, also you can add an extra string as a hint for translators: QString message = tr("Everyone draws %n cards", "english hint for translators", amount); ``` See [Qt's wiki on translations]( -https://doc.qt.io/qt-5/i18n-source-translation.html#handling-plurals) +https://doc.qt.io/qt-6/i18n-source-translation.html#handling-plurals) If you're about to propose a change that adds or modifies any translatable string in the code, you don't need to take care of adding the new strings to diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000..75fbc59f1 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,95 @@ +# GitHub Docs on Code Scanning: +# https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning +# https://docs.github.com/en/code-security/how-tos/find-and-fix-code-vulnerabilities/manage-your-configuration +# https://docs.github.com/en/code-security/reference/code-scanning/workflow-configuration-options +# +# CodeQL Docs: +# https://codeql.github.com/docs/ + +name: CodeQL + +permissions: + contents: read + security-events: write # needed to post results + +on: + push: + branches: + - master + pull_request: + +# Cancel earlier, unfinished runs of this workflow on the same branch +concurrency: + group: "${{ github.workflow }} @ ${{ github.ref_name }}" + cancel-in-progress: true + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + include: + # https://codeql.github.com/docs/codeql-overview/supported-languages-and-frameworks/ + - language: cpp + build-mode: manual + - language: actions + build-mode: none + + steps: + - name: "Checkout repository" + uses: actions/checkout@v7 + + - name: "Initialize CodeQL" + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + # https://docs.github.com/en/code-security/reference/code-scanning/codeql/codeql-queries/c-cpp-built-in-queries + # https://docs.github.com/en/code-security/reference/code-scanning/codeql/codeql-queries/actions-built-in-queries + queries: security-extended + dependency-caching: true + + - name: "[C++] Install dependencies" + if: matrix.language == 'cpp' && matrix.build-mode == 'manual' + shell: bash + env: + DEBIAN_FRONTEND: noninteractive + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + cmake \ + g++ \ + libgl-dev \ + liblzma-dev \ + libmariadb-dev-compat \ + libprotobuf-dev \ + ninja-build \ + protobuf-compiler \ + qt6-declarative-dev \ + qt6-multimedia-dev \ + qt6-shadertools-dev \ + qt6-svg-dev \ + qt6-tools-dev \ + qt6-tools-dev-tools \ + qt6-websockets-dev + +# Minimize dependency install +# Add ccache usage for faster compilation, (install ccache dep, actions/cache step + append DUSE_CCACHE=1 in cmake config, CCACHE env values) + + - name: "[C++] Configure CMake" + if: matrix.language == 'cpp' && matrix.build-mode == 'manual' + shell: bash + run: cmake -S . -B build -G Ninja -DWITH_SERVER=1 -DCMAKE_BUILD_TYPE=Release + + - name: "[C++] Build application" + if: matrix.language == 'cpp' && matrix.build-mode == 'manual' + shell: bash + run: cmake --build build + + - name: "Perform CodeQL Analysis" + uses: github/codeql-action/analyze@v4 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 3227cb8a7..4a83b260e 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -3,7 +3,7 @@ name: Build Desktop permissions: actions: write # needed to delete entries in GHA cache (update ccache) attestations: write # needed to persist the attestation. - contents: write + contents: write # needed for e.g. vcpkg dependency graph updates id-token: write # needed for signing certificate in attestation on: @@ -34,7 +34,7 @@ on: - 'vcpkg.json' - 'vcpkg' # needed to match submodule bumps (gitlink) -# Cancel earlier, unfinished runs of this workflow on the same branch (unless on release) +# Cancel earlier, unfinished runs of this workflow on the same branch (unless on tag --> release) concurrency: group: "${{ github.workflow }} @ ${{ github.ref_name }}" cancel-in-progress: ${{ github.ref_type != 'tag' }} @@ -42,7 +42,7 @@ concurrency: jobs: configure: name: Configure - runs-on: ubuntu-slim + runs-on: ubuntu-slim # https://github.com/actions/runner-images/blob/main/images/ubuntu-slim/ubuntu-slim-Readme.md outputs: tag: ${{ steps.configure.outputs.tag }} sha: ${{ steps.configure.outputs.sha }} @@ -146,13 +146,13 @@ jobs: name: ${{ matrix.distro }} ${{ matrix.version }} needs: configure - runs-on: ubuntu-latest + runs-on: ubuntu-latest # https://github.com/actions/runner-images continue-on-error: ${{ matrix.allow-failure == 'yes' }} timeout-minutes: 70 env: CACHE: ${{ github.workspace }}/.cache/${{ matrix.distro }}${{ matrix.version }} # directory for caching docker image and ccache CCACHE_EVICTION_AGE: 7d - CCACHE_SIZE: 550M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy + CCACHE_SIZE: 600M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy CMAKE_GENERATOR: 'Ninja' NAME: ${{ matrix.distro }}${{ matrix.version }} @@ -161,14 +161,11 @@ jobs: uses: actions/checkout@v7 - name: "Restore compiler cache (ccache)" - id: ccache_restore - uses: actions/cache/restore@v5 - env: - BRANCH_NAME: ${{ github.head_ref || github.ref_name }} + id: restore_ccache + uses: actions/cache/restore@v6 with: - key: ccache-${{ matrix.distro }}${{ matrix.version }}-${{ env.BRANCH_NAME }} + key: ccache-${{ matrix.distro }}${{ matrix.version }} path: ${{ env.CACHE }} - restore-keys: ccache-${{ matrix.distro }}${{ matrix.version }}- - name: "Build ${{ matrix.distro }} ${{ matrix.version }} Docker image" shell: bash @@ -179,8 +176,12 @@ jobs: shell: bash run: | source .ci/docker.sh - RUN --server --debug --test --ccache "$CCACHE_SIZE" \ - --cmake-generator "$CMAKE_GENERATOR" + args=() + [[ $GITHUB_REF == "refs/heads/master" ]] && args+=(--evict-ccache "$CCACHE_EVICTION_AGE") + args+=(--ccache "$CCACHE_SIZE") + args+=(--cmake-generator "$CMAKE_GENERATOR") + + RUN --server --debug --test "${args[@]}" - name: "Build release package" id: build @@ -203,10 +204,10 @@ jobs: # Delete used cache to emulate a ccache update. See https://github.com/actions/cache/issues/342 - name: "Delete remote compiler cache (ccache)" - if: github.ref == 'refs/heads/master' && steps.ccache_restore.outputs.cache-hit + if: github.ref == 'refs/heads/master' && steps.restore_ccache.outputs.cache-hit continue-on-error: true env: - CACHE_PRIMARY_KEY: ${{ steps.ccache_restore.outputs.cache-primary-key }} + CACHE_PRIMARY_KEY: ${{ steps.restore_ccache.outputs.cache-primary-key }} GH_TOKEN: ${{ github.token }} run: | if gh cache delete --repo "$GITHUB_REPOSITORY" "$CACHE_PRIMARY_KEY"; then @@ -215,9 +216,9 @@ jobs: - name: "Save updated compiler cache (ccache)" if: github.ref == 'refs/heads/master' - uses: actions/cache/save@v5 + uses: actions/cache/save@v6 with: - key: ${{ steps.ccache_restore.outputs.cache-primary-key }} + key: ${{ steps.restore_ccache.outputs.cache-primary-key }} path: ${{ env.CACHE }} - name: "Upload artifact" @@ -262,79 +263,77 @@ jobs: matrix: include: - os: macOS - target: 13 - runner: macos-15-intel + target: 13 # EOL 2025-09-15 + runner: macos-15-intel # https://github.com/actions/runner-images/blob/main/images/macos/macos-15-Readme.md + # macos-26-intel is very slow and fails in CPack during DMG config if not increasing Finder timeout ccache_eviction_age: 7d cmake_generator: Ninja make_package: 1 override_target: 13 package_suffix: "-macOS13_Intel" - qt_version: 6.11.0 - qt_arch: clang_64 - qt_modules: qtimageformats qtmultimedia qtwebsockets + qt_version: 6.11.1 + qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools soc: Intel type: Release use_ccache: 1 - xcode: "16.4" + xcode: "26.3" - os: macOS - target: 14 - runner: macos-14 + target: 14 # EOL 2026-?? + runner: macos-26 # https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md ccache_eviction_age: 7d cmake_generator: Ninja make_package: 1 + override_target: 14 package_suffix: "-macOS14" - qt_version: 6.11.0 - qt_arch: clang_64 - qt_modules: qtimageformats qtmultimedia qtwebsockets + qt_version: 6.11.1 + qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools soc: Apple type: Release use_ccache: 1 - xcode: "15.4" + xcode: "26.6" - os: macOS target: 15 - runner: macos-15 + runner: macos-26 # https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md ccache_eviction_age: 7d cmake_generator: Ninja make_package: 1 + override_target: 15 package_suffix: "-macOS15" - qt_version: 6.11.0 - qt_arch: clang_64 - qt_modules: qtimageformats qtmultimedia qtwebsockets + qt_version: 6.11.1 + qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools soc: Apple type: Release use_ccache: 1 - xcode: "16.4" + xcode: "26.6" - os: macOS - target: 15 - runner: macos-15 + target: 26 + runner: macos-26 # https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md ccache_eviction_age: 7d cmake_generator: Ninja - qt_version: 6.11.0 - qt_arch: clang_64 - qt_modules: qtimageformats qtmultimedia qtwebsockets + qt_version: 6.11.1 + qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools soc: Apple type: Debug use_ccache: 1 - xcode: "16.4" + xcode: "26.6" - os: Windows target: 10 - runner: windows-2025 + runner: windows-2025 # https://github.com/actions/runner-images/blob/main/images/windows/Windows2025-VS2026-Readme.md cmake_generator: "Visual Studio 18 2026" cmake_generator_platform: x64 make_package: 1 package_suffix: "-Win10" - qt_version: 6.11.0 - qt_arch: win64_msvc2022_64 - qt_modules: qtimageformats qtmultimedia qtwebsockets + qt_version: 6.11.1 + qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools type: Release name: ${{ matrix.os }} ${{ matrix.target }}${{ matrix.soc == 'Intel' && ' Intel' || '' }}${{ matrix.type == 'Debug' && ' Debug' || '' }} @@ -343,7 +342,7 @@ jobs: timeout-minutes: 100 env: CCACHE_DIR: ${{ github.workspace }}/.cache/ - CCACHE_SIZE: 550M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy + CCACHE_SIZE: 600M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy steps: - name: "Checkout" @@ -353,7 +352,6 @@ jobs: - name: "[Windows] Add msbuild to PATH" if: matrix.os == 'Windows' - id: add-msbuild uses: microsoft/setup-msbuild@v3 with: msbuild-architecture: x64 @@ -364,14 +362,11 @@ jobs: - name: "[macOS] Restore compiler cache (ccache)" if: matrix.os == 'macOS' && matrix.use_ccache == 1 - id: ccache_restore - uses: actions/cache/restore@v5 - env: - BRANCH_NAME: ${{ github.head_ref || github.ref_name }} + id: restore_ccache + uses: actions/cache/restore@v6 with: - key: ccache-${{ matrix.runner }}-${{ matrix.soc }}-${{ matrix.type }}-${{ env.BRANCH_NAME }} + key: ccache-${{ matrix.runner }}_${{ matrix.override_target }}-Xcode${{ matrix.xcode }} path: ${{ env.CCACHE_DIR }} - restore-keys: ccache-${{ matrix.runner }}-${{ matrix.soc }}-${{ matrix.type }}- - name: "Install aqtinstall" run: pipx install aqtinstall @@ -387,9 +382,9 @@ jobs: - name: "[macOS] Restore thin Qt ${{ steps.resolve_qt_version.outputs.version }} libraries" if: matrix.os == 'macOS' id: restore_qt - uses: actions/cache/restore@v5 + uses: actions/cache/restore@v6 with: - key: thin-qt-macos-${{ matrix.soc }}-${{ steps.resolve_qt_version.outputs.version }} + key: Qt-${{ steps.resolve_qt_version.outputs.version }}-macOS-${{ matrix.soc }}-${{ matrix.qt_modules }}-thin path: ${{ github.workspace }}/Qt # Using jurplel/install-qt-action to install Qt without using brew @@ -398,7 +393,6 @@ jobs: if: matrix.os == 'macOS' && steps.restore_qt.outputs.cache-hit != 'true' uses: jurplel/install-qt-action@v4 with: - arch: ${{ matrix.qt_arch }} cache: false dir: ${{ github.workspace }} modules: ${{ matrix.qt_modules }} @@ -410,17 +404,17 @@ jobs: - name: "[macOS] Cache thin Qt libraries" if: matrix.os == 'macOS' && steps.restore_qt.outputs.cache-hit != 'true' - uses: actions/cache/save@v5 + uses: actions/cache/save@v6 with: - key: thin-qt-macos-${{ matrix.soc }}-${{ steps.resolve_qt_version.outputs.version }} + key: ${{ steps.restore_qt.outputs.cache-primary-key }} path: ${{ github.workspace }}/Qt - name: "[Windows] Install Qt ${{ matrix.qt_version }}" if: matrix.os == 'Windows' uses: jurplel/install-qt-action@v4 with: - arch: ${{ matrix.qt_arch }} cache: true + cache-key-prefix: Qt modules: ${{ matrix.qt_modules }} version: ${{ steps.resolve_qt_version.outputs.version }} @@ -445,6 +439,7 @@ jobs: CMAKE_GENERATOR: ${{ matrix.cmake_generator }} CMAKE_GENERATOR_PLATFORM: ${{ matrix.cmake_generator_platform }} DEVELOPER_DIR: '/Applications/Xcode_${{ matrix.xcode }}.app/Contents/Developer' + GITHUB_TOKEN: ${{ github.token }} # needed for vcpkg dependency graph updates, see VCPKG_FEATURE_FLAGS MACOS_CERTIFICATE: ${{ secrets.PROD_MACOS_CERTIFICATE }} MACOS_CERTIFICATE_NAME: ${{ secrets.PROD_MACOS_CERTIFICATE_NAME }} MACOS_CERTIFICATE_PWD: ${{ secrets.PROD_MACOS_CERTIFICATE_PWD }} @@ -455,25 +450,26 @@ jobs: USE_CCACHE: ${{ matrix.use_ccache }} VCPKG_BINARY_SOURCES: 'clear;files,${{ steps.vcpkg-cache.outputs.path }},readwrite' VCPKG_DISABLE_METRICS: 1 + VCPKG_FEATURE_FLAGS: dependencygraph run: .ci/compile.sh --server --test --vcpkg # Delete used cache to emulate a ccache update. See https://github.com/actions/cache/issues/342 - name: "[macOS] Delete remote compiler cache (ccache)" - if: matrix.os == 'macOS' && matrix.use_ccache == 1 && github.ref == 'refs/heads/master' && steps.ccache_restore.outputs.cache-hit + if: matrix.os == 'macOS' && matrix.use_ccache == 1 && github.ref == 'refs/heads/master' && steps.restore_ccache.outputs.cache-hit continue-on-error: true env: - CACHE_PRIMARY_KEY: ${{ steps.ccache_restore.outputs.cache-primary-key }} + CACHE_PRIMARY_KEY: ${{ steps.restore_ccache.outputs.cache-primary-key }} GH_TOKEN: ${{ github.token }} run: | if gh cache delete --repo "$GITHUB_REPOSITORY" "$CACHE_PRIMARY_KEY"; then echo "Cache deleted successfully" fi - - name: "[macOS] Save updated compiler cache (ccache)" + - name: "[macOS] Cache updated compiler cache (ccache)" if: matrix.os == 'macOS' && matrix.use_ccache == 1 && github.ref == 'refs/heads/master' - uses: actions/cache/save@v5 + uses: actions/cache/save@v6 with: - key: ${{ steps.ccache_restore.outputs.cache-primary-key }} + key: ${{ steps.restore_ccache.outputs.cache-primary-key }} path: ${{ env.CCACHE_DIR }} - name: "[macOS] Sign app bundle" diff --git a/.github/workflows/desktop-lint.yml b/.github/workflows/desktop-lint.yml index 5f31ea59c..93ba79464 100644 --- a/.github/workflows/desktop-lint.yml +++ b/.github/workflows/desktop-lint.yml @@ -1,5 +1,8 @@ name: Code Style (C++) +permissions: + contents: read + on: # Push trigger not needed for linting, we do not allow direct pushes to master pull_request: diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index b479322d0..255e8b045 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -1,8 +1,8 @@ -name: Build Docker Image +name: Build Docker permissions: - contents: read - packages: write + contents: read # needed to checkout repo + packages: write # needed for interacting with GHCR on: push: @@ -13,7 +13,10 @@ on: - master paths: - '.github/workflows/docker-release.yml' + - '.dockerignore' - 'Dockerfile' + - 'docker-compose.yml' + - 'docker-compose.yml.windows' release: types: - released # publishing of stable releases @@ -23,55 +26,157 @@ concurrency: group: "${{ github.workflow }} @ ${{ github.ref_name }}" cancel-in-progress: ${{ github.event_name != 'release' }} +env: + GHCR_IMAGE: ghcr.io/cockatrice/servatrice + OCI_DESCRIPTION: Server for Cockatrice, a cross-platform virtual tabletop for multiplayer card games + OCI_TITLE: Servatrice + OCI_URL: https://cockatrice.github.io/ + jobs: - docker: - name: amd64 & arm64 - if: ${{ github.repository_owner == 'Cockatrice' }} - runs-on: ubuntu-latest - + # Create one platform-specific image and publish its OCI image manifest per matrix job + build: + name: "Servatrice (${{ matrix.label }})" + if: github.repository_owner == 'Cockatrice' + runs-on: ${{ matrix.runner }} + + strategy: + fail-fast: false + matrix: + include: + - label: x86 + platform: linux/amd64 + runner: ubuntu-latest # https://github.com/actions/runner-images + + - label: arm + platform: linux/arm64 + runner: ubuntu-24.04-arm # https://github.com/actions/runner-images/blob/main/images/ubuntu/Ubuntu2404-Arm64-Readme.md, replace with "ubuntu-latest-arm" once available + + env: + CACHE_SCOPE: servatrice-${{ matrix.label }} + steps: - name: "Checkout" uses: actions/checkout@v7 - - name: "Docker metadata" - id: metadata - uses: docker/metadata-action@v6 - env: - DOCKER_METADATA_ANNOTATIONS_LEVELS: index # needed for GHCR - with: - annotations: | - org.opencontainers.image.title=Servatrice - org.opencontainers.image.url=https://cockatrice.github.io/ - org.opencontainers.image.description=Server for Cockatrice, a cross-platform virtual tabletop for multiplayer card games - images: | - ghcr.io/cockatrice/servatrice - labels: | - org.opencontainers.image.title=Servatrice - org.opencontainers.image.url=https://cockatrice.github.io/ - org.opencontainers.image.description=Server for Cockatrice, a cross-platform virtual tabletop for multiplayer card games - - - name: "Set up QEMU" - uses: docker/setup-qemu-action@v4 - - name: "Set up Docker buildx" uses: docker/setup-buildx-action@v4 - - name: "Login to GitHub Container Registry" - if: contains(github.event.release.tag_name, 'Release') && github.event.release.target_commitish == 'master' + - name: "Login to GitHub Container Registry (GHCR)" + if: github.event_name == 'release' && github.event.release.prerelease == false + id: login uses: docker/login-action@v4 with: - password: ${{ github.token }} registry: ghcr.io username: ${{ github.actor }} + password: ${{ github.token }} - - name: "Build and push Docker image" + # Don't push for non-release triggers + - name: "Build image" + if: steps.login.outcome != 'success' uses: docker/build-push-action@v7 with: - annotations: ${{ steps.metadata.outputs.annotations }} - cache-from: type=gha,scope=servatrice - cache-to: type=gha,mode=max,scope=servatrice + cache-from: type=gha,scope=${{ env.CACHE_SCOPE }} + cache-to: type=gha,mode=max,scope=${{ env.CACHE_SCOPE }} context: . - labels: ${{ steps.metadata.outputs.labels }} - platforms: linux/amd64,linux/arm64 - push: ${{ github.ref_type == 'tag' }} - tags: ${{ steps.metadata.outputs.tags }} + platforms: ${{ matrix.platform }} + push: false + + # Add OCI labels and push single-platform image by digest (without tags) + - name: "Build image and push by digest" + if: steps.login.outcome == 'success' + id: build + uses: docker/build-push-action@v7 + with: + cache-from: type=gha,scope=${{ env.CACHE_SCOPE }} + cache-to: type=gha,mode=max,scope=${{ env.CACHE_SCOPE }} + context: . + labels: | + org.opencontainers.image.description=${{ env.OCI_DESCRIPTION }} + org.opencontainers.image.title=${{ env.OCI_TITLE }} + org.opencontainers.image.url=${{ env.OCI_URL }} + outputs: type=image,name=${{ env.GHCR_IMAGE }},name-canonical=true,push=true,push-by-digest=true + platforms: ${{ matrix.platform }} + provenance: mode=max # Do not pass secrets as build arguments with this option + sbom: true + + - name: "Export digest" + if: steps.login.outcome == 'success' + env: + DIGEST: ${{ steps.build.outputs.digest }} + run: | + mkdir -p "$RUNNER_TEMP/digests" + touch "$RUNNER_TEMP/digests/${DIGEST#sha256:}" + + - name: "Upload digest" + if: steps.login.outcome == 'success' + uses: actions/upload-artifact@v7 + with: + archive: false + if-no-files-found: error + name: digest-${{ matrix.label }} + path: ${{ runner.temp }}/digests/* + retention-days: 1 + + + # Create an OCI image index from the platform-specific image manifests + index: + name: "Publish multi-platform Servatrice image" + if: github.repository_owner == 'Cockatrice' && github.event_name == 'release' && github.event.release.prerelease == false + needs: build + runs-on: ubuntu-slim # https://github.com/actions/runner-images/blob/main/images/ubuntu-slim/ubuntu-slim-Readme.md + + steps: + - name: "Download digests" + uses: actions/download-artifact@v8 + with: + path: ${{ runner.temp }}/digests + pattern: digest-* + merge-multiple: true + + - name: "Login to GitHub Container Registry (GHCR)" + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + + - name: "Docker metadata" + id: metadata + uses: docker/metadata-action@v6 + with: + images: ${{ env.GHCR_IMAGE }} + flavor: | + latest=auto + tags: | + type=ref,event=tag # if semver, also: type=semver,pattern={{version}} / {{major}}.{{minor}} + + # Add OCI annotations to image index and publish tags + - name: "Create image index" + env: + DOCKER_TAGS: ${{ steps.metadata.outputs.tags }} + working-directory: ${{ runner.temp }}/digests + run: | + TAG_ARGS=() + while IFS= read -r tag; do + TAG_ARGS+=(--tag "$tag") + done <<< "$DOCKER_TAGS" + + DIGEST_ARGS=() + for digest in *; do + DIGEST_ARGS+=("$GHCR_IMAGE@sha256:$digest") + done + + docker buildx imagetools create \ + --prefer-index=true \ + --annotation "index:org.opencontainers.image.description=$OCI_DESCRIPTION" \ + --annotation "index:org.opencontainers.image.title=$OCI_TITLE" \ + --annotation "index:org.opencontainers.image.url=$OCI_URL" \ + "${TAG_ARGS[@]}" \ + "${DIGEST_ARGS[@]}" + + - name: "Inspect images" + env: + GITHUB_TAG: ${{ github.ref_name }} + run: | + docker buildx imagetools inspect "$GHCR_IMAGE:latest" + docker buildx imagetools inspect "$GHCR_IMAGE:$GITHUB_TAG" diff --git a/.github/workflows/documentation-build.yml b/.github/workflows/documentation-build.yml index 4b9ca79ab..419cbfbfb 100644 --- a/.github/workflows/documentation-build.yml +++ b/.github/workflows/documentation-build.yml @@ -1,5 +1,8 @@ name: Generate Docs +permissions: + contents: read # write permission to the destination repo come from 'deploy_key' + on: pull_request: paths: @@ -11,6 +14,11 @@ on: - published # publishing of stable releases and pre-releases workflow_dispatch: +# Cancel earlier, unfinished runs of this workflow on the same branch (unless on release) +concurrency: + group: "${{ github.workflow }} @ ${{ github.ref_name }}" + cancel-in-progress: ${{ github.event_name != 'release' }} + env: COCKATRICE_REF: ${{ github.ref_name }} # tag name if the commit is tagged, otherwise branch name diff --git a/.github/workflows/translations-pull.yml b/.github/workflows/translations-pull.yml index a3db5f86d..71b0b4c22 100644 --- a/.github/workflows/translations-pull.yml +++ b/.github/workflows/translations-pull.yml @@ -1,5 +1,9 @@ name: Update Translations +permissions: + contents: read + pull-requests: write + on: pull_request: paths: diff --git a/.github/workflows/translations-push.yml b/.github/workflows/translations-push.yml index c4d3f61fb..41a7aef40 100644 --- a/.github/workflows/translations-push.yml +++ b/.github/workflows/translations-push.yml @@ -1,5 +1,9 @@ name: Update Translation Source +permissions: + contents: read + pull-requests: write + on: pull_request: paths: diff --git a/.gitignore b/.gitignore index 33c1ae31d..54939bdcf 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ mysql.cnf .DS_Store .idea/ *.aps +*.cache cmake-build* preferences compile_commands.json diff --git a/CMakeLists.txt b/CMakeLists.txt index c10e1db68..0da073464 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -5,23 +5,23 @@ # This file sets all the variables shared between the projects # like the installation path, compilation flags etc.. -# cmake 3.16 is required if using qt6 -cmake_minimum_required(VERSION 3.10) +# 3.16 required for Qt6 and target_precompile_headers() +cmake_minimum_required(VERSION 3.16) -# Early detect ccache +# Use compiler cache (ccache) option(USE_CCACHE "Cache the build results with ccache" ON) # Treat warnings as errors (Debug builds only) option(WARNING_AS_ERROR "Treat warnings as errors in debug builds" ON) # Check for translation updates option(UPDATE_TRANSLATIONS "Update translations on compile" OFF) -# Compile servatrice -option(WITH_SERVER "build servatrice" OFF) -# Compile cockatrice -option(WITH_CLIENT "build cockatrice" ON) -# Compile oracle -option(WITH_ORACLE "build oracle" ON) +# Compile Cockatrice +option(WITH_CLIENT "Build Cockatrice client" ON) +# Compile Oracle +option(WITH_ORACLE "Build Cockatrice card database tool (Oracle)" ON) +# Compile Servatrice +option(WITH_SERVER "Build Cockatrice server (Servatrice)" OFF) # Compile tests -option(TEST "build tests" OFF) +option(TEST "Build tests" OFF) # Use vcpkg regardless of OS option(USE_VCPKG "Use vcpkg regardless of OS" OFF) @@ -39,13 +39,24 @@ else() ) endif() -if(USE_CCACHE) +# ccache does not support MSVC and must not auto-engage on Windows +# (it is installed unintentionally on the Windows CI runner). +# NOTE: this keys off the target OS, so a mingw/Ninja configuration on Windows +# also opts out of ccache even though the GNUCXX branch below supports it. +if(USE_CCACHE AND NOT WIN32) find_program(CCACHE_PROGRAM ccache) if(CCACHE_PROGRAM) # Support Unix Makefiles and Ninja set_property(GLOBAL PROPERTY RULE_LAUNCH_COMPILE "${CCACHE_PROGRAM}") + # PCH-aware caching, matching .ci/compile.sh: without this ccache refuses + # to cache any TU that consumes a precompiled header, so every PCH-backed + # target recompiles from scratch on each build. + execute_process(COMMAND ${CCACHE_PROGRAM} --set-config sloppiness=pch_defines,time_macros) message(STATUS "Found CCache ${CCACHE_PROGRAM}") endif() +elseif(USE_CCACHE AND WIN32) + # An explicit opt-in must not disappear silently on Windows. + message(STATUS "ccache disabled: not supported for the MSVC toolchain on Windows") endif() if(WIN32 OR USE_VCPKG) @@ -64,10 +75,11 @@ if(WIN32 OR USE_VCPKG) else() set(QTDIR "" - CACHE PATH "Path to Qt (e.g. C:/Qt/5.7/msvc2015_64)" + CACHE PATH "Path to Qt (e.g. C:/Qt/6.4.2/msvc2019_64)" ) message( - WARNING "QTDIR variable is missing. Please set this variable to specify path to Qt (e.g. C:/Qt/5.7/msvc2015_64)" + WARNING + "QTDIR variable is missing. Please set this variable to specify path to Qt (e.g. C:/Qt/6.4.2/msvc2019_64)" ) endif() endif() @@ -174,7 +186,7 @@ elseif(CMAKE_COMPILER_IS_GNUCXX) -Wno-error=delete-non-virtual-dtor -Wno-error=sign-compare -Wno-error=missing-declarations - -Wno-error=sfinae-incomplete # GCC 16+: Qt MOC + protobuf forward decls trigger this + -Wno-error=sfinae-incomplete # GCC 16+: Qt MOC + protobuf forward decls trigger this ) foreach(FLAG ${ADDITIONAL_DEBUG_FLAGS}) @@ -183,6 +195,9 @@ elseif(CMAKE_COMPILER_IS_GNUCXX) set(CMAKE_CXX_FLAGS_DEBUG "${CMAKE_CXX_FLAGS_DEBUG} ${FLAG}") endif() endforeach() + + # Reduce compiler I/O by using pipes between stages instead of temp files + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pipe") else() # other: osx/llvm, bsd/llvm set(CMAKE_CXX_FLAGS_RELEASE "-O2") @@ -191,6 +206,9 @@ else() else() set(CMAKE_CXX_FLAGS_DEBUG "-g -O0 -Wall -Wextra") endif() + + # Reduce compiler I/O by using pipes between stages instead of temp files + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pipe") endif() # GNU systems need to define the Mersenne exponent for the RNG to compile w/o warning @@ -238,11 +256,6 @@ if(WIN32) find_package(OpenSSL REQUIRED) if(OPENSSL_FOUND) include_directories(${OPENSSL_INCLUDE_DIRS}) - else() - message( - WARNING - "Could not find OpenSSL runtime libraries. They are not required for compiling, but needs to be available at runtime." - ) endif() endif() @@ -280,11 +293,7 @@ if(UNIX) if(CPACK_GENERATOR STREQUAL "RPM") set(CPACK_RPM_PACKAGE_LICENSE "GPLv2") set(CPACK_RPM_MAIN_COMPONENT "cockatrice") - if(Qt6_FOUND) - set(CPACK_RPM_PACKAGE_REQUIRES "protobuf, qt6-qttools, qt6-qtsvg, qt6-qtmultimedia, qt6-qtimageformats") - elseif(Qt5_FOUND) - set(CPACK_RPM_PACKAGE_REQUIRES "protobuf, qt5-qttools, qt5-qtsvg, qt5-qtmultimedia") - endif() + set(CPACK_RPM_PACKAGE_REQUIRES "protobuf, qt6-qttools, qt6-qtsvg, qt6-qtmultimedia, qt6-qtimageformats") set(CPACK_RPM_PACKAGE_GROUP "Amusements/Games") set(CPACK_RPM_PACKAGE_URL "http://github.com/Cockatrice/Cockatrice") # stop directories from making package conflicts @@ -302,12 +311,8 @@ if(UNIX) set(CPACK_DEBIAN_PACKAGE_SHLIBDEPS ON) set(CPACK_DEBIAN_PACKAGE_SECTION "games") set(CPACK_DEBIAN_PACKAGE_HOMEPAGE "http://github.com/Cockatrice/Cockatrice") - if(Qt6_FOUND) - set(CPACK_DEBIAN_PACKAGE_DEPENDS "libqt6multimedia6, libqt6svg6, qt6-qpa-plugins, qt6-image-formats-plugins") - set(CPACK_DEBIAN_PACKAGE_RECOMMENDS "libqt6sql6-mysql") # for connecting servatrice to a mysql db - elseif(Qt5_FOUND) - set(CPACK_DEBIAN_PACKAGE_DEPENDS "libqt5multimedia5-plugins, libqt5svg5") - endif() + set(CPACK_DEBIAN_PACKAGE_DEPENDS "libqt6multimedia6, libqt6svg6, qt6-qpa-plugins, qt6-image-formats-plugins") + set(CPACK_DEBIAN_PACKAGE_RECOMMENDS "libqt6sql6-mysql") # for connecting servatrice to a mysql db endif() endif() elseif(WIN32) diff --git a/Dockerfile b/Dockerfile index 7c5c773c9..7d3deb5fb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,35 +3,47 @@ FROM ubuntu:26.04 AS build ARG DEBIAN_FRONTEND=noninteractive -RUN apt-get update && apt-get install -y --no-install-recommends \ - build-essential \ - cmake \ - file \ - g++ \ - git \ - libmariadb-dev-compat \ - libprotobuf-dev \ - libqt6sql6-mysql \ - qt6-websockets-dev \ - protobuf-compiler \ - qt6-tools-dev \ - qt6-tools-dev-tools +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential \ + cmake \ + ninja-build \ + file \ + g++ \ + git \ + libmariadb-dev-compat \ + libprotobuf-dev \ + libqt6sql6-mysql \ + libssl-dev \ + qt6-websockets-dev \ + protobuf-compiler \ + qt6-tools-dev \ + qt6-tools-dev-tools WORKDIR /src COPY . . -RUN mkdir build && cd build && \ - cmake .. -DWITH_SERVER=1 -DWITH_CLIENT=0 -DWITH_ORACLE=0 && \ - make -j$(nproc) && \ - make install +RUN cmake \ + -S . \ + -B build \ + -G Ninja \ + -DWITH_CLIENT=0 \ + -DWITH_ORACLE=0 \ + -DWITH_SERVER=1 \ + && cmake --build build \ + && cmake --install build # -------- Runtime Stage (clean) -------- FROM ubuntu:26.04 -RUN apt-get update && apt-get install -y --no-install-recommends \ - libprotobuf32t64 \ - libqt6sql6-mysql \ - libqt6websockets6 \ +ARG DEBIAN_FRONTEND=noninteractive + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + libprotobuf32t64 \ + libqt6sql6-mysql \ + libqt6websockets6 \ + libssl3 \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* diff --git a/Doxyfile b/Doxyfile index fc96b5f22..9b05682cc 100644 --- a/Doxyfile +++ b/Doxyfile @@ -349,7 +349,7 @@ OPTIMIZE_OUTPUT_SLICE = NO # # Note see also the list of default file extension mappings. -EXTENSION_MAPPING = +EXTENSION_MAPPING = proto=C++ # If the MARKDOWN_SUPPORT tag is enabled then Doxygen pre-processes all comments # according to the Markdown format, which allows for more readable @@ -1086,7 +1086,8 @@ FILE_PATTERNS = *.cc \ *.h++ \ *.markdown \ *.md \ - *.dox + *.dox \ + *.proto # The RECURSIVE tag can be used to specify whether or not subdirectories should # be searched for input files as well. @@ -1103,6 +1104,7 @@ RECURSIVE = YES EXCLUDE = build/ \ cmake/ \ + cmake-build-debug/ \ doc/doxygen/theme/docs/ \ doc/doxygen/theme/include/ \ vcpkg/ @@ -1195,7 +1197,7 @@ INPUT_FILTER = # need to set EXTENSION_MAPPING for the extension otherwise the files are not # properly processed by Doxygen. -FILTER_PATTERNS = +FILTER_PATTERNS = "*.proto=python doc/doxygen/filters/proto2cpp.py" # If the FILTER_SOURCE_FILES tag is set to YES, the input filter (if set using # INPUT_FILTER) will also be used to filter the input files that are used for diff --git a/README.md b/README.md index 9a27601cd..5935bb540 100644 --- a/README.md +++ b/README.md @@ -149,16 +149,15 @@ You can then
The following flags (with their non-default values) can be passed to `cmake`: -| Flag | Description | -| --- | --- | -| `-DWITH_SERVER=1` | Build Servatrice server | -| `-DWITH_CLIENT=0` | Don't build Cockatrice client | -| `-DWITH_ORACLE=0` | Don't build Oracle card database tool | -| `-DCMAKE_BUILD_TYPE=Debug` | Compile in debug mode
Enables extra logging output, debug symbols, and much more verbose compiler warnings | -| `-DWARNING_AS_ERROR=0` | Don't treat compilation warnings as errors in debug mode | -| `-DUPDATE_TRANSLATIONS=1` | Configure `make` to update the translation .ts files for new strings in the source code
**Note:** `make clean` will remove the .ts files | -| `-DTEST=1` | Enable regression tests
**Note:** `make test` to run tests, *googletest* will be downloaded if not available | -| `-DFORCE_USE_QT5=1` | Skip looking for Qt6 before trying to find Qt5 | +| Flag | Description | +| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| `-DWITH_SERVER=1` | Build Servatrice server | +| `-DWITH_CLIENT=0` | Don't build Cockatrice client | +| `-DWITH_ORACLE=0` | Don't build Oracle card database tool | +| `-DCMAKE_BUILD_TYPE=Debug` | Compile in debug mode
Enables extra logging output, debug symbols, and much more verbose compiler warnings | +| `-DWARNING_AS_ERROR=0` | Don't treat compilation warnings as errors in debug mode | +| `-DUPDATE_TRANSLATIONS=1` | Configure `make` to update the translation .ts files for new strings in the source code
**Note:** `make clean` will remove the .ts files | +| `-DTEST=1` | Enable regression tests
**Note:** `make test` to run tests, *googletest* will be downloaded if not available | # Run diff --git a/cmake/FindQtRuntime.cmake b/cmake/FindQtRuntime.cmake index 485affe52..971c9094d 100644 --- a/cmake/FindQtRuntime.cmake +++ b/cmake/FindQtRuntime.cmake @@ -1,8 +1,7 @@ # Find a compatible Qt version -# Inputs: WITH_SERVER, WITH_CLIENT, WITH_ORACLE, FORCE_USE_QT5 +# Inputs: WITH_SERVER, WITH_CLIENT, WITH_ORACLE # Optional Input: QT6_DIR -- Hint as to where Qt6 lives on the system -# Optional Input: QT5_DIR -- Hint as to where Qt5 lives on the system -# Output: COCKATRICE_QT_VERSION_NAME -- Example values: Qt5, Qt6 +# Output: COCKATRICE_QT_VERSION_NAME -- Example values: Qt6 # Output: SERVATRICE_QT_MODULES # Output: COCKATRICE_QT_MODULES # Output: ORACLE_QT_MODULES @@ -19,14 +18,17 @@ if(WITH_CLIENT) Multimedia Network PrintSupport + ShaderTools Svg WebSockets Widgets Xml + Quick + QuickWidgets ) endif() if(WITH_ORACLE) - set(_ORACLE_NEEDED Concurrent Network Svg Widgets) + set(_ORACLE_NEEDED Concurrent Network Svg Widgets Xml) endif() if(TEST) # Union of Qt modules required across all test targets (independent of application targets). @@ -39,69 +41,37 @@ set(REQUIRED_QT_COMPONENTS ${REQUIRED_QT_COMPONENTS} ${_SERVATRICE_NEEDED} ${_CO ) list(REMOVE_DUPLICATES REQUIRED_QT_COMPONENTS) -if(NOT FORCE_USE_QT5) - # Linguist is now a component in Qt6 instead of an external package - find_package( - Qt6 6.4.2 - COMPONENTS ${REQUIRED_QT_COMPONENTS} Linguist - QUIET HINTS ${Qt6_DIR} - ) +# Linguist is now a component in Qt6 instead of an external package +find_package( + Qt6 6.4.2 + COMPONENTS ${REQUIRED_QT_COMPONENTS} Linguist + QUIET HINTS ${Qt6_DIR} +) +if(NOT Qt6_FOUND) + message(FATAL_ERROR "No suitable version of Qt was found") endif() -if(Qt6_FOUND) - set(COCKATRICE_QT_VERSION_NAME Qt6) +set(COCKATRICE_QT_VERSION_NAME Qt6) - list(FIND Qt6LinguistTools_TARGETS Qt6::lrelease QT6_LRELEASE_INDEX) - if(QT6_LRELEASE_INDEX EQUAL -1) - message(WARNING "Qt6 lrelease not found.") - endif() - - list(FIND Qt6LinguistTools_TARGETS Qt6::lupdate QT6_LUPDATE_INDEX) - if(QT6_LUPDATE_INDEX EQUAL -1) - message(WARNING "Qt6 lupdate not found.") - endif() -else() - find_package( - Qt5 5.15.2 - COMPONENTS ${REQUIRED_QT_COMPONENTS} - QUIET HINTS ${Qt5_DIR} - ) - if(Qt5_FOUND) - set(COCKATRICE_QT_VERSION_NAME Qt5) - else() - message(FATAL_ERROR "No suitable version of Qt was found") - endif() - - # Qt5 Linguist is in a separate package - find_package(Qt5LinguistTools QUIET) - if(Qt5LinguistTools_FOUND) - if(NOT Qt5_LRELEASE_EXECUTABLE) - message(WARNING "Qt5 lrelease not found.") - endif() - if(NOT Qt5_LUPDATE_EXECUTABLE) - message(WARNING "Qt5 lupdate not found.") - endif() - else() - message(WARNING "Linguist Tools not found, cannot handle translations") - endif() +list(FIND Qt6LinguistTools_TARGETS Qt6::lrelease QT6_LRELEASE_INDEX) +if(QT6_LRELEASE_INDEX EQUAL -1) + message(WARNING "Qt6 lrelease not found.") endif() -if(Qt5_POSITION_INDEPENDENT_CODE OR Qt6_FOUND) - set(CMAKE_POSITION_INDEPENDENT_CODE ON) +list(FIND Qt6LinguistTools_TARGETS Qt6::lupdate QT6_LUPDATE_INDEX) +if(QT6_LUPDATE_INDEX EQUAL -1) + message(WARNING "Qt6 lupdate not found.") endif() +set(CMAKE_POSITION_INDEPENDENT_CODE ON) + # Establish Qt Plugins directory & Library directories get_target_property(QT_LIBRARY_DIR ${COCKATRICE_QT_VERSION_NAME}::Core LOCATION) get_filename_component(QT_LIBRARY_DIR ${QT_LIBRARY_DIR} DIRECTORY) -if(Qt6_FOUND) - get_filename_component(QT_PLUGINS_DIR "${Qt6Core_DIR}/../../../${QT6_INSTALL_PLUGINS}" ABSOLUTE) - get_filename_component(QT_LIBRARY_DIR "${QT_LIBRARY_DIR}/../../.." ABSOLUTE) - if(UNIX AND APPLE) - # Mac needs a bit more help finding all necessary components - list(APPEND QT_LIBRARY_DIR "/usr/local/lib") - endif() -elseif(Qt5_FOUND) - get_filename_component(QT_PLUGINS_DIR "${Qt5Core_DIR}/../../../plugins" ABSOLUTE) - get_filename_component(QT_LIBRARY_DIR "${QT_LIBRARY_DIR}/.." ABSOLUTE) +get_filename_component(QT_PLUGINS_DIR "${Qt6Core_DIR}/../../../${QT6_INSTALL_PLUGINS}" ABSOLUTE) +get_filename_component(QT_LIBRARY_DIR "${QT_LIBRARY_DIR}/../../.." ABSOLUTE) +if(UNIX AND APPLE) + # Mac needs a bit more help finding all necessary components + list(APPEND QT_LIBRARY_DIR "/usr/local/lib") endif() message(DEBUG "QT_PLUGINS_DIR = ${QT_PLUGINS_DIR}") message(DEBUG "QT_LIBRARY_DIR = ${QT_LIBRARY_DIR}") diff --git a/cmake/Info.plist b/cmake/Info.plist index 614d82509..7f01befcb 100644 --- a/cmake/Info.plist +++ b/cmake/Info.plist @@ -1,38 +1,118 @@ - + + + + + + CFBundleDevelopmentRegion English + CFBundleExecutable ${MACOSX_BUNDLE_EXECUTABLE_NAME} + CFBundleGetInfoString ${MACOSX_BUNDLE_INFO_STRING} + CFBundleIconFile ${MACOSX_BUNDLE_ICON_FILE} + CFBundleIdentifier ${MACOSX_BUNDLE_GUI_IDENTIFIER} + CFBundleInfoDictionaryVersion 6.0 + CFBundleLongVersionString ${MACOSX_BUNDLE_LONG_VERSION_STRING} + CFBundleName ${MACOSX_BUNDLE_BUNDLE_NAME} + CFBundlePackageType APPL + CFBundleShortVersionString ${MACOSX_BUNDLE_SHORT_VERSION_STRING} + CFBundleSignature ???? + CFBundleVersion ${MACOSX_BUNDLE_BUNDLE_VERSION} - CSResourcesFileMapped - - LSRequiresCarbon - + NSHumanReadableCopyright ${MACOSX_BUNDLE_COPYRIGHT} + NSHighResolutionCapable + + + + + + UTExportedTypeDeclarations + + + UTTypeIdentifier + org.cockatrice.deck + + UTTypeDescription + Cockatrice Deck + + UTTypeConformsTo + + public.data + + + UTTypeTagSpecification + + public.filename-extension + + cod + + + + + + CFBundleDocumentTypes + + + CFBundleTypeName + Cockatrice Deck + + CFBundleTypeRole + Editor + + LSHandlerRank + Default + + LSItemContentTypes + + org.cockatrice.deck + + + + + + + + + CFBundleURLTypes + + + CFBundleURLName + Cockatrice URL Scheme + + CFBundleURLSchemes + + cockatrice + + + + diff --git a/cmake/pch/qtcore_pch.h b/cmake/pch/qtcore_pch.h new file mode 100644 index 000000000..cc3dd12ee --- /dev/null +++ b/cmake/pch/qtcore_pch.h @@ -0,0 +1,24 @@ +/** @file qtcore_pch.h + * @brief Precompiled header for all Qt targets (Qt Core only). + * + * Safe for every target that links Qt Core, including the headless + * Servatrice binary. Keep this header free of any widget/gui types. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include diff --git a/cmake/pch/qtwidgets_pch.h b/cmake/pch/qtwidgets_pch.h new file mode 100644 index 000000000..2c63f450e --- /dev/null +++ b/cmake/pch/qtwidgets_pch.h @@ -0,0 +1,30 @@ +/** @file qtwidgets_pch.h + * @brief Precompiled header for GUI targets (Cockatrice client, Oracle). + * + * Includes the Qt Core precompiled header plus the heavy Gui, Widgets and + * Network layers that virtually every client translation unit re-parses. + * Do not use on Servatrice (headless, QT_DONT_USE_QTGUI). + */ + +#include "qtcore_pch.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index bd99d08bf..4263fc6e2 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -15,6 +15,9 @@ set(cockatrice_SOURCES src/client/network/update/client/client_update_checker.cpp src/client/network/update/client/release_channel.cpp src/client/network/update/card_spoiler/spoiler_background_updater.cpp + src/client/latency_graph_widget.cpp + src/client/latency_status_widget.cpp + src/client/lag_monitor.cpp src/client/sound_engine.cpp src/client/settings/cache_settings.cpp src/client/settings/card_counter_settings.cpp @@ -36,13 +39,16 @@ set(cockatrice_SOURCES src/interface/widgets/dialogs/dlg_forgot_password_challenge.cpp src/interface/widgets/dialogs/dlg_forgot_password_request.cpp src/interface/widgets/dialogs/dlg_forgot_password_reset.cpp + src/interface/widgets/dialogs/dlg_invite_to_game.cpp src/interface/widgets/dialogs/dlg_load_deck.cpp src/interface/widgets/dialogs/dlg_load_deck_from_clipboard.cpp src/interface/widgets/dialogs/dlg_load_deck_from_website.cpp src/interface/widgets/dialogs/dlg_load_remote_deck.cpp src/interface/widgets/dialogs/dlg_local_game_options.cpp src/interface/widgets/dialogs/dlg_manage_sets.cpp + src/interface/widgets/dialogs/dlg_my_reports.cpp src/interface/widgets/dialogs/dlg_register.cpp + src/interface/widgets/dialogs/dlg_report_user.cpp src/interface/widgets/dialogs/dlg_select_set_for_cards.cpp src/interface/widgets/dialogs/dlg_settings.cpp src/interface/widgets/dialogs/dlg_startup_card_check.cpp @@ -97,7 +103,11 @@ set(cockatrice_SOURCES src/game_graphics/player/menu/rfg_menu.cpp src/game_graphics/player/menu/say_menu.cpp src/game_graphics/player/menu/sideboard_menu.cpp + src/game_graphics/player/menu/tally_menu.cpp src/game_graphics/player/menu/utility_menu.cpp + src/game_graphics/tally/stats_tally.cpp + src/game_graphics/tally/subtype_tally.cpp + src/game_graphics/tally/tally.cpp src/game/player/player_actions.cpp src/game_graphics/player/player_area.cpp src/game_graphics/player/player_dialogs.cpp @@ -131,9 +141,19 @@ set(cockatrice_SOURCES src/interface/card_picture_loader/card_picture_loader_worker.cpp src/interface/card_picture_loader/card_picture_loader_worker_work.cpp src/interface/card_picture_loader/card_picture_to_load.cpp + src/interface/intents/intent.cpp + src/interface/intents/intent.h + src/interface/intents/intent_open_local_deck.cpp + src/interface/intents/intent_open_local_deck.h + src/interface/intents/intent_wait_for_database_load.cpp + src/interface/intents/intent_wait_for_database_load.h src/interface/layouts/flow_layout.cpp src/interface/layouts/overlap_layout.cpp + src/interface/widgets/utility/card_completer_delegate.cpp + src/interface/widgets/utility/card_completer_styler.cpp + src/interface/widgets/utility/completer_utils.cpp src/interface/widgets/utility/line_edit_completer.cpp + src/interface/widgets/utility/reversed_completer_model.cpp src/interface/pixel_map_generator.cpp src/interface/theme_config.cpp src/interface/theme_manager.cpp @@ -145,6 +165,8 @@ set(cockatrice_SOURCES src/interface/widgets/cards/additional_info/color_identity_widget.cpp src/interface/widgets/cards/additional_info/mana_cost_widget.cpp src/interface/widgets/cards/additional_info/mana_symbol_widget.cpp + src/interface/widgets/cards/art_crop_attribution.cpp + src/interface/widgets/cards/card_art_utils.cpp src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.cpp src/interface/widgets/cards/card_group_display_widgets/flat_card_group_display_widget.cpp src/interface/widgets/cards/card_group_display_widgets/overlapped_card_group_display_widget.cpp @@ -193,6 +215,7 @@ set(cockatrice_SOURCES src/interface/widgets/deck_editor/deck_editor_printing_selector_dock_widget.cpp src/interface/widgets/deck_editor/deck_list_style_proxy.cpp src/interface/widgets/deck_editor/deck_state_manager.cpp + src/interface/widgets/deck_editor/deck_zone_dialog.cpp src/interface/widgets/deck_editor/printing_disabled_info_widget.cpp src/interface/widgets/general/background_sources.cpp src/interface/widgets/general/display/background_plate_widget.cpp @@ -209,6 +232,7 @@ set(cockatrice_SOURCES src/interface/widgets/general/display/charts/bars/segmented_bar_widget.cpp src/interface/widgets/general/display/charts/pies/color_pie.cpp src/interface/widgets/general/home_styled_button.cpp + src/interface/widgets/general/home_tab_button_color.h src/interface/widgets/general/home_widget.cpp src/interface/widgets/general/layout_containers/flow_widget.cpp src/interface/widgets/general/layout_containers/overlap_control_widget.cpp @@ -224,33 +248,51 @@ set(cockatrice_SOURCES src/interface/widgets/printing_selector/printing_selector_card_selection_widget.cpp src/interface/widgets/printing_selector/printing_selector_card_sorting_widget.cpp src/interface/widgets/printing_selector/set_name_and_collectors_number_display_widget.cpp + src/interface/widgets/playmat/playmat_collection_dialog.cpp + src/interface/widgets/playmat/playmat_collection_dialog.h + src/interface/widgets/playmat/playmat_preview_widget.cpp + src/interface/widgets/playmat/playmat_settings_dialog.cpp src/interface/widgets/quick_settings/settings_button_widget.cpp src/interface/widgets/quick_settings/settings_popup_widget.cpp src/interface/widgets/replay/replay_manager.cpp + src/interface/widgets/replay/replay_quick_settings_widget.cpp src/interface/widgets/replay/replay_timeline_widget.cpp + src/interface/widgets/replay/replay_widget.cpp src/interface/widgets/server/chat_view/chat_view.cpp src/interface/widgets/server/game_filter_configs.cpp + src/interface/widgets/server/game_link.cpp src/interface/widgets/server/game_selector.cpp src/interface/widgets/server/game_selector_quick_filter_toolbar.cpp src/interface/widgets/server/games_model.cpp src/interface/widgets/server/handle_public_servers.cpp src/interface/widgets/server/remote/remote_decklist_tree_widget.cpp src/interface/widgets/server/remote/remote_replay_list_tree_widget.cpp + src/interface/widgets/server/user/user_avatar_provider.cpp + src/interface/widgets/server/user/user_card_art_provider.cpp + src/interface/widgets/server/user/user_card_settings_dialog.cpp src/interface/widgets/server/user/user_context_menu.cpp src/interface/widgets/server/user/user_info_box.cpp src/interface/widgets/server/user/user_info_connection.cpp + src/interface/widgets/server/user/user_list_dialog.cpp src/interface/widgets/server/user/user_list_manager.cpp + src/interface/widgets/server/user/user_list_painter.cpp + src/interface/widgets/server/user/user_list_panel_widget.cpp src/interface/widgets/server/user/user_list_widget.cpp + src/interface/widgets/settings_page/abstract_settings_page.cpp src/interface/widgets/settings_page/appearance_settings_page.cpp src/interface/widgets/settings_page/deck_editor_settings_page.cpp src/interface/widgets/settings_page/general_settings_page.cpp src/interface/widgets/settings_page/messages_settings_page.cpp + src/interface/widgets/settings_page/settings_search_delegate.cpp + src/interface/widgets/settings_page/settings_search_model.cpp src/interface/widgets/settings_page/shortcut_settings_page.cpp src/interface/widgets/settings_page/sound_settings_page.cpp src/interface/widgets/settings_page/storage_settings_page.cpp src/interface/widgets/settings_page/user_interface_settings_page.cpp src/interface/widgets/utility/custom_line_edit.cpp src/interface/widgets/utility/get_text_with_max.cpp + src/interface/widgets/utility/report_utils.cpp + src/interface/widgets/utility/report_utils.h src/interface/widgets/utility/sequence_edit.cpp src/interface/widgets/utility/visibility_change_listener.cpp src/interface/widgets/utility/visibility_change_listener.h @@ -276,13 +318,16 @@ set(cockatrice_SOURCES src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_item_widget.cpp src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.cpp src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.cpp + src/interface/widgets/visual_deck_storage/visual_deck_storage_model.cpp src/interface/widgets/visual_deck_storage/visual_deck_storage_quick_settings_widget.cpp src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp + src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.cpp src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_widget.cpp src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.cpp src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp src/interface/window_main.cpp src/main.cpp + src/single_instance_manager.cpp src/interface/widgets/tabs/abstract_tab_deck_editor.cpp src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp src/interface/widgets/tabs/api/archidekt/api_response/archidekt_deck_listing_api_response.cpp @@ -298,6 +343,13 @@ set(cockatrice_SOURCES src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_entry_display_widget.cpp src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_listings_display_widget.cpp src/interface/widgets/tabs/api/archidekt/display/archidekt_deck_preview_image_display_widget.cpp + src/interface/widgets/tabs/api/commander_spellbook/api_response/card_in_deck_request.cpp + src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_deck_request.cpp + src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_card_result.cpp + src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_variant_result.cpp + src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_estimate_bracket_result.cpp + src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_bracket_explainer.cpp + src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_api_accessor.cpp src/interface/widgets/tabs/api/edhrec/api_response/archidekt_links/edhrec_api_response_archidekt_links.cpp src/interface/widgets/tabs/api/edhrec/api_response/average_deck/edhrec_average_deck_api_response.cpp src/interface/widgets/tabs/api/edhrec/api_response/average_deck/edhrec_deck_api_response.cpp @@ -327,12 +379,16 @@ set(cockatrice_SOURCES src/interface/widgets/tabs/tab.cpp src/interface/widgets/tabs/tab_account.cpp src/interface/widgets/tabs/tab_admin.cpp + src/interface/widgets/tabs/tab_card_art_rules.cpp src/interface/widgets/tabs/tab_deck_editor.cpp src/interface/widgets/tabs/tab_deck_storage.cpp + src/interface/widgets/tabs/tab_developer.cpp src/interface/widgets/tabs/tab_game.cpp src/interface/widgets/tabs/tab_home.cpp src/interface/widgets/tabs/tab_logs.cpp src/interface/widgets/tabs/tab_message.cpp + src/interface/widgets/tabs/tab_moderation.cpp + src/interface/widgets/tabs/tab_report.cpp src/interface/widgets/tabs/tab_replays.cpp src/interface/widgets/tabs/tab_room.cpp src/interface/widgets/tabs/tab_server.cpp @@ -343,12 +399,55 @@ set(cockatrice_SOURCES src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.cpp src/interface/key_signals.cpp src/interface/logger.cpp + src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_service.cpp + src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp + src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.cpp + src/interface/widgets/onboarding/banner_shader_config.h + src/interface/widgets/onboarding/brand_colors.h + src/interface/widgets/onboarding/first_run_wizard.cpp + src/interface/widgets/onboarding/first_run_wizard.h + src/interface/widgets/onboarding/first_run_wizard_page.cpp + src/interface/widgets/onboarding/first_run_wizard_page.h + src/interface/widgets/onboarding/pages/account_setup_page.cpp + src/interface/widgets/onboarding/pages/account_setup_page.h + src/interface/widgets/onboarding/pages/card_database_setup_page.cpp + src/interface/widgets/onboarding/pages/card_database_setup_page.h + src/interface/widgets/onboarding/pages/finish_page.cpp + src/interface/widgets/onboarding/pages/finish_page.h + src/interface/widgets/onboarding/pages/preferences_setup_page.cpp + src/interface/widgets/onboarding/pages/preferences_setup_page.h + src/interface/widgets/onboarding/pages/theme_setup_page.cpp + src/interface/widgets/onboarding/pages/theme_setup_page.h + src/interface/widgets/onboarding/pages/welcome_page.cpp + src/interface/widgets/onboarding/pages/welcome_page.h + src/interface/widgets/onboarding/shader_banner_widget.cpp + src/interface/widgets/onboarding/shader_banner_widget.h + src/interface/widgets/onboarding/step_indicator_widget.cpp + src/interface/widgets/onboarding/step_indicator_widget.h src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_bracket_navigation_widget.cpp src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_bracket_navigation_widget.h src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.cpp src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.h src/interface/widgets/utility/compact_push_button.cpp src/interface/widgets/utility/compact_push_button.h + src/single_instance_manager.h + src/client/url_scheme_event_filter.h + src/interface/intents/intent_connect_to_server.cpp + src/interface/intents/intent_connect_to_server.h + src/interface/intents/intent_disconnect_from_server.cpp + src/interface/intents/intent_disconnect_from_server.h + src/interface/intents/intent_join_server_game.cpp + src/interface/intents/intent_join_server_game.h + src/interface/intents/intent_join_server_room.cpp + src/interface/intents/intent_join_server_room.h + src/interface/intents/intent_login.cpp + src/interface/intents/intent_login.h + src/interface/intents/intent_open_server_room_by_name.cpp + src/interface/intents/intent_open_server_room_by_name.h + src/interface/intents/url_parser.cpp + src/interface/intents/url_parser.h + src/interface/widgets/server/user/user_info_popup.cpp + src/interface/widgets/server/user/user_info_popup.h ) add_subdirectory(sounds) @@ -390,11 +489,7 @@ if(APPLE) set(cockatrice_SOURCES ${cockatrice_SOURCES} ${CMAKE_CURRENT_SOURCE_DIR}/resources/appicon.icns) endif(APPLE) -if(Qt6_FOUND) - qt6_add_resources(cockatrice_RESOURCES_RCC ${cockatrice_RESOURCES}) -elseif(Qt5_FOUND) - qt5_add_resources(cockatrice_RESOURCES_RCC ${cockatrice_RESOURCES}) -endif() +qt6_add_resources(cockatrice_RESOURCES_RCC ${cockatrice_RESOURCES}) # Declare path variables set(ICONDIR @@ -406,71 +501,62 @@ set(DESKTOPDIR CACHE STRING "desktop file destination" ) +set(MIMEDIR + share/mime/packages + CACHE STRING "mime file destination" +) + set(COCKATRICE_MAC_QM_INSTALL_DIR "cockatrice.app/Contents/Resources/translations") set(COCKATRICE_UNIX_QM_INSTALL_DIR "share/cockatrice/translations") set(COCKATRICE_WIN32_QM_INSTALL_DIR "translations") -if(Qt6_FOUND) - qt6_add_executable( - cockatrice - WIN32 - MACOSX_BUNDLE - ${cockatrice_SOURCES} - ${cockatrice_RESOURCES_RCC} - ${cockatrice_MOC_SRCS} - MANUAL_FINALIZATION - ) -elseif(Qt5_FOUND) - # Qt5 Translations need to be linked at executable creation time - if(Qt5LinguistTools_FOUND) - if(UPDATE_TRANSLATIONS) - qt5_create_translation(cockatrice_QM ${translate_SRCS} ${cockatrice_TS}) - else() - qt5_add_translation(cockatrice_QM ${cockatrice_TS}) - endif() - endif() - add_executable( - cockatrice WIN32 MACOSX_BUNDLE ${cockatrice_MOC_SRCS} ${cockatrice_QM} ${cockatrice_RESOURCES_RCC} - ${cockatrice_SOURCES} - ) - if(UNIX) - if(APPLE) - install(FILES ${cockatrice_QM} DESTINATION ${COCKATRICE_MAC_QM_INSTALL_DIR}) - else() - install(FILES ${cockatrice_QM} DESTINATION ${COCKATRICE_UNIX_QM_INSTALL_DIR}) - endif() - elseif(WIN32) - install(FILES ${cockatrice_QM} DESTINATION ${COCKATRICE_WIN32_QM_INSTALL_DIR}) - endif() -endif() +qt6_add_executable( + cockatrice + WIN32 + MACOSX_BUNDLE + ${cockatrice_SOURCES} + ${cockatrice_RESOURCES_RCC} + ${cockatrice_MOC_SRCS} + MANUAL_FINALIZATION +) -if(Qt5_FOUND) - target_link_libraries( - cockatrice - libcockatrice_card - libcockatrice_deck_list - libcockatrice_filters - libcockatrice_utility - libcockatrice_network - libcockatrice_models - libcockatrice_rng - libcockatrice_settings - ${COCKATRICE_QT_MODULES} - ) -else() - target_link_libraries( - cockatrice - PUBLIC libcockatrice_card - libcockatrice_deck_list - libcockatrice_filters - libcockatrice_utility - libcockatrice_network - libcockatrice_models - libcockatrice_rng - libcockatrice_settings - ${COCKATRICE_QT_MODULES} - ) -endif() +target_precompile_headers(cockatrice PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtwidgets_pch.h") + +qt6_add_shaders( + cockatrice + "onboarding_shaders" + PREFIX + "/onboarding/shaders" + BASE + "src/interface/widgets/onboarding/shaders" + FILES + src/interface/widgets/onboarding/shaders/brand_banner.frag + src/interface/widgets/onboarding/shaders/brand_plate.frag +) + +qt6_add_resources( + cockatrice + "onboarding_qml" + PREFIX + "/onboarding/qml" + BASE + "src/interface/widgets/onboarding/qml" + FILES + src/interface/widgets/onboarding/qml/BrandBanner.qml +) + +target_link_libraries( + cockatrice + PUBLIC libcockatrice_card + libcockatrice_deck_list + libcockatrice_filters + libcockatrice_utility + libcockatrice_network + libcockatrice_models + libcockatrice_rng + libcockatrice_settings + ${COCKATRICE_QT_MODULES} +) if(UNIX) if(APPLE) @@ -490,6 +576,23 @@ if(UNIX) install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/resources/cockatrice.png DESTINATION ${ICONDIR}/hicolor/48x48/apps) install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/resources/cockatrice.svg DESTINATION ${ICONDIR}/hicolor/scalable/apps) install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/cockatrice.desktop DESTINATION ${DESKTOPDIR}) + install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/cockatrice-cod.xml DESTINATION ${MIMEDIR}) + + # Refresh the freedesktop databases so the file associations and scheme + # handler register without requiring the user to run them manually. The + # tools may be missing on minimal systems; that is fine, packaging systems + # usually refresh these databases through their own triggers. + find_program(UPDATE_MIME_DATABASE update-mime-database) + if(UPDATE_MIME_DATABASE) + install(CODE "execute_process(COMMAND \"${UPDATE_MIME_DATABASE}\" \"${CMAKE_INSTALL_PREFIX}/share/mime\")") + endif() + + find_program(UPDATE_DESKTOP_DATABASE update-desktop-database) + if(UPDATE_DESKTOP_DATABASE) + install( + CODE "execute_process(COMMAND \"${UPDATE_DESKTOP_DATABASE}\" \"${CMAKE_INSTALL_PREFIX}/share/applications\")" + ) + endif() endif() elseif(WIN32) install(TARGETS cockatrice RUNTIME DESTINATION ./) @@ -500,7 +603,7 @@ if(APPLE) set(plugin_dest_dir cockatrice.app/Contents/Plugins) set(qtconf_dest_dir cockatrice.app/Contents/Resources) - # Qt plugins: audio (Qt5), iconengines, imageformats, multimedia (Qt6), platforms, printsupport (Qt5), styles, tls (Qt6) + # Qt plugins: audio, iconengines, imageformats, multimedia, platforms, printsupport, styles, tls install( DIRECTORY "${QT_PLUGINS_DIR}/" DESTINATION ${plugin_dest_dir} @@ -567,7 +670,7 @@ if(WIN32) PATTERN "*.ini" ) - # Qt plugins: audio (Qt5), iconengines, imageformats, multimedia (Qt6) platforms, printsupport (Qt5), styles, tls (Qt6) + # Qt plugins: audio, iconengines, imageformats, multimedia, platforms, printsupport, styles, tls install( DIRECTORY "${QT_PLUGINS_DIR}/" DESTINATION ${plugin_dest_dir} @@ -623,7 +726,7 @@ Data = Resources\") endif() endif() -if(Qt6_FOUND AND Qt6LinguistTools_FOUND) +if(Qt6LinguistTools_FOUND) #Qt6 Translations happen after the executable is built up if(UPDATE_TRANSLATIONS) qt6_add_translations( @@ -650,6 +753,4 @@ if(Qt6_FOUND AND Qt6LinguistTools_FOUND) endif() endif() -if(Qt6_FOUND) - qt6_finalize_target(cockatrice) -endif() +qt6_finalize_target(cockatrice) diff --git a/cockatrice/cockatrice-cod.xml b/cockatrice/cockatrice-cod.xml new file mode 100644 index 000000000..1a0199433 --- /dev/null +++ b/cockatrice/cockatrice-cod.xml @@ -0,0 +1,7 @@ + + + + Cockatrice Deck File + + + diff --git a/cockatrice/cockatrice.desktop b/cockatrice/cockatrice.desktop index 092d84ef5..4b15fa9c1 100644 --- a/cockatrice/cockatrice.desktop +++ b/cockatrice/cockatrice.desktop @@ -3,6 +3,8 @@ Version=1.0 Type=Application Name=Cockatrice -Exec=cockatrice +Exec=cockatrice %U Icon=cockatrice Categories=Game;CardGame; +MimeType=application/x-cockatrice; +X-Scheme-Handler/cockatrice=true diff --git a/cockatrice/cockatrice.qrc b/cockatrice/cockatrice.qrc index 9c34929b7..14cf15b2f 100644 --- a/cockatrice/cockatrice.qrc +++ b/cockatrice/cockatrice.qrc @@ -2,6 +2,7 @@ resources/cardback.svg resources/cockatrice.svg + resources/cockatrice-logo-white.svg resources/hand.svg resources/hr.jpg @@ -62,6 +63,8 @@ resources/icons/mana/W.svg resources/backgrounds/home.png + resources/backgrounds/home-dark.png + resources/backgrounds/home-light.png resources/backgrounds/card_triplet.svg resources/backgrounds/placeholder_printing_selector.svg @@ -362,6 +365,8 @@ resources/usericons/pawn_single.svg resources/usericons/pawn_double.svg + resources/usericons/pawn_dev_single.svg + resources/usericons/pawn_dev_double.svg resources/usericons/pawn_donator_single.svg resources/usericons/pawn_donator_double.svg resources/usericons/pawn_judge_single.svg diff --git a/cockatrice/cockatrice_en@source.ts b/cockatrice/cockatrice_en@source.ts index 810b345df..e06972ddc 100644 --- a/cockatrice/cockatrice_en@source.ts +++ b/cockatrice/cockatrice_en@source.ts @@ -4,7 +4,7 @@ AbstractCounter - + &Set counter... @@ -12,12 +12,12 @@ AbstractCounterDialog - + Set counter - + New value for counter '%1': @@ -38,60 +38,60 @@ AbstractTabDeckEditor - + Open in new tab - + Are you sure? - + The decklist has been modified. Do you want to save the changes? - - - - - - + + + + + + Error - + Could not open deck at %1 - + Could not save remote deck - - + + The deck could not be saved. Please check that the directory is writable and try again. - + Save deck - + The deck could not be saved. - + There are no cards in your deck to be exported @@ -107,12 +107,12 @@ Please check that the directory is writable and try again. AdminNotesDialog - + Update Notes - + Admin Notes for %1 @@ -129,172 +129,227 @@ Please check that the directory is writable and try again. Sideboard + + + Tokens + + AppearanceSettingsPage - + seconds - + Error - + Could not create themes directory at '%1'. - + Theme settings - + Current theme: - + Open themes folder - + Home tab background source: - + Home tab background shuffle frequency: - + Disabled - - Display card name of background in bottom right: - - - - + Menu settings - + Show keyboard shortcuts in right-click menus - + Show game filter toolbar above list in room tab - + Card rendering - + Display card names on cards having a picture - + Auto-Rotate cards with sideways layout - + Override all card art with personal set preference (Pre-ProviderID change behavior) - + + Light + + + + + Dark + + + + + System + + + + + Active theme palette: + + + + + Edit theme palette + + + + + Home tab settings + + + + + Display card name of background in bottom right + + + + + Styling settings + + + + + Style user list + + + + + Card printings + + + + Bump sets that the deck contains cards from to the top in the printing selector - + Scale cards on mouse over - + Use rounded card corners - + + Card layout + + + + Minimum overlap percentage of cards on the stack and in vertical hand - + Maximum initial height for card view window: - - + + rows - + Maximum expanded height for card view window: - + Card counters - + Counter %1 - + Hand layout - + Display hand horizontally (wastes space) - + Enable left justification - + Table grid layout - + Invert vertical coordinate - + Minimum player count for multi-column layout: - + Maximum font size for information displayed on cards: @@ -302,12 +357,12 @@ Please check that the directory is writable and try again. ArchidektApiResponseDeckDisplayWidget - + Back to results - + Open Deck in Deck Editor @@ -333,111 +388,111 @@ Please check that the directory is writable and try again. BanDialog - + ban &user name - + ban &IP address - + ban client I&D - + Ban type - + &permanent ban - + &temporary ban - + &Days: - + &Hours: - + &Minutes: - + Duration of the ban - + Please enter the reason for the ban. This is only saved for moderators and cannot be seen by the banned person. - + Please enter the reason for the ban that will be visible to the banned person. - + Redact all messages from this user in all rooms - + &OK - + &Cancel - + Ban user from server - - - - + + + + Error - + You have to select a name-based, IP-based, clientId based, or some combination of the three to place a ban. - + You must have a value in the name ban when selecting the name ban checkbox. - + You must have a value in the ip ban when selecting the ip ban checkbox. - + You must have a value in the clientid ban when selecting the clientid ban checkbox. @@ -445,59 +500,123 @@ This is only saved for moderators and cannot be seen by the banned person. BetaReleaseChannel - + Beta - + No reply received from the release update server. - + Invalid reply received from the release update server. - + No reply received from the file update server. + + CardArtPreviewWidget + + + No card selected + + + + + CardArtRulesModel + + + Card + + + + + ProviderId + + + + + Mode + + + + + Reason + + + CardDatabaseModel - + Name - + Sets - + Mana cost - + Card type - + P/T - + Color(s) + + CardDatabaseView + + + Add to Deck + + + + + Add to Sideboard + + + + + Select Printing + + + + + Show on EDHRec (Commander) + + + + + Show on EDHRec (Card) + + + + + Show Related cards + + + CardFilter @@ -626,22 +745,22 @@ This is only saved for moderators and cannot be seen by the banned person. CardInfoPictureWidget - + View related cards - + Add card to deck - + Mainboard - + Sideboard @@ -664,12 +783,12 @@ This is only saved for moderators and cannot be seen by the banned person. - + Related cards: - + Unknown card: @@ -677,128 +796,146 @@ This is only saved for moderators and cannot be seen by the banned person. CardMenu - + Re&veal to... - + &All players - + View related cards - + Token: - + All tokens - + &Select All - + S&elect Row - + S&elect Column - + &Play - + &Hide - + Play &Face Down - + &Tap / Untap Turn sideways or back again - + Skip &untapping - + T&urn Over Turn face up/face down - + &Peek at card face - + &Clone - + Attac&h to card... - + Unattac&h - + &Draw arrow... - + &Set annotation... - + + Reduce life by power + + + + Ca&rd counters - + &Add counter (%1) - + &Remove counter (%1) - + &Set counters (%1)... + + CardPictureLoaderCacheMethod + + + Network Cache + + + + + Filesystem + + + CardSizeWidget @@ -810,133 +947,133 @@ This is only saved for moderators and cannot be seen by the banned person. CardZoneLogic - + their hand nominative - + %1's hand nominative - + their library look at zone - + %1's library look at zone - + of their library top cards of zone, - + of %1's library top cards of zone - + their library reveal zone - + %1's library reveal zone - + their library shuffle - + %1's library shuffle - + their library nominative - + %1's library nominative - + their graveyard nominative - + %1's graveyard nominative - + their exile nominative - + %1's exile nominative - + their sideboard look at zone - + %1's sideboard look at zone - + their sideboard nominative - + %1's sideboard nominative - + their custom zone '%1' nominative - + %1's custom zone '%2' nominative @@ -958,16 +1095,424 @@ This is only saved for moderators and cannot be seen by the banned person. + + ColorButton + + + Click to pick a color + + + + + Pick colour + + + + + ConnectionController + + + + The server has reached its maximum user capacity, please check back later. + + + + + There are too many concurrent connections from your address. + + + + + Banned by moderator + + + + + Expected end time: %1 + + + + + This ban lasts indefinitely. + + + + + Scheduled server shutdown. + + + + + + Invalid username. + + + + + You have been logged out due to logging in at another location. + + + + + Connection closed + + + + + The server has terminated your connection. +Reason: %1 + + + + + The server is going to be restarted in %n minute(s). +All running games will be lost. +Reason for shutdown: %1 + + + + + + + + Scheduled server shutdown + + + + + Failed Login + + + + + Your client seems to be missing features this server requires for connection. + + + + + To update your client, go to 'Help -> Check for Client Updates'. + + + + + + + + + + + + + + + + + + + + + + Error + + + + + Incorrect username or password. Please check your authentication information and try again. + + + + + There is already an active session using this user name. +Please close that session first and re-login. + + + + + + You are banned until %1. + + + + + + You are banned indefinitely. + + + + + This server requires user registration. Do you want to register now? + + + + + This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. +Please close and reopen your client to try again. + + + + + An internal error has occurred, please close and reopen Cockatrice before trying again. +If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. + + + + + Account activation + + + + + Your account has not been activated yet. +You need to provide the activation token received in the activation email. + + + + + Server Full + + + + + Unknown login error: %1 + + + + + + +This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. + + + + + + + + + + Registration denied + + + + + Registration is currently disabled on this server + + + + + There is already an existing account with the same user name. + + + + + It's mandatory to specify a valid email address when registering. + + + + + It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. + + + + + Password too short. + + + + + Registration failed for a technical problem on the server. + + + + + The connection to the server has been lost. + + + + + Unknown registration error: %1 + + + + + Account activation failed + + + + + Socket error: %1 + + + + + Server timeout + + + + + You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. +Local version is %1, remote version is %2. + + + + + Your Cockatrice client is obsolete. Please update your Cockatrice version. +Local version is %1, remote version is %2. + + + + + + Success + + + + + Registration accepted. +Will now login. + + + + + Account activation accepted. +Will now login. + + + + + Information + + + + + This server supports additional features that your client doesn't have. +This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. + +To update your client, go to Help -> Check for Updates. + + + + + + + Reset Password + + + + + Your password has been reset successfully, you can now log in using the new credentials. + + + + + Failed to reset user account password, please contact the server operator to reset your password. + + + + + Activation request received, please check your email for an activation token. + + + + + Connecting to %1... + + + + + Registering to %1 as %2... + + + + + Disconnected + + + + + Connected, logging in at %1 + + + + + Requesting forgotten password to %1 as %2... + + + + + Your username must respect these rules: + + + + + is %1 - %2 characters long + + + + + can %1 contain lowercase characters + + + + + + + + NOT + + + + + can %1 contain uppercase characters + + + + + can %1 contain numeric characters + + + + + can contain the following punctuation: %1 + + + + + first character can %1 be a punctuation mark + + + + + no unacceptable language as specified by these server rules: + note that the following lines will not be translated + + + + + can not contain any of the following words: %1 + + + + + can not match any of the following expressions: %1 + + + + + You may only use A-Z, a-z, 0-9, _, ., and - in your username. + + + CustomZoneMenu - + C&ustom Zones - - + + View custom zone '%1' @@ -975,30 +1520,35 @@ This is only saved for moderators and cannot be seen by the banned person. DeckAnalyticsWidget - + Add Panel - + Remove Panel - + Save Layout - + Load Layout + + + Include Sideboard + + DeckEditorCardDatabaseDockWidget - + Card Database @@ -1014,47 +1564,17 @@ This is only saved for moderators and cannot be seen by the banned person. DeckEditorDatabaseDisplayWidget - + Search by card name (or search expressions) - - Add to Deck - - - - - Add to Sideboard - - - - - Select Printing - - - - - Show on EDHRec (Commander) - - - - - Show on EDHRec (Card) - - - - - Show Related cards - - - - + Add card to &maindeck - + Add card to &sideboard @@ -1087,72 +1607,72 @@ This is only saved for moderators and cannot be seen by the banned person. - + Select Printing - + Deck - + Deck &name: - + Banner Card/Tags Visibility Settings - + Show banner card selection menu - + Show tags selection menu - + &Comments: - + Group by: - + Format: - + Hash: - + &Increment number - + &Decrement number - + &Remove row - + Swap card to/from sideboard @@ -1160,17 +1680,17 @@ This is only saved for moderators and cannot be seen by the banned person. DeckEditorFilterDockWidget - + Filters - + &Clear all filters - + Delete selected @@ -1301,169 +1821,113 @@ This is only saved for moderators and cannot be seen by the banned person. DeckEditorSettingsPage - - + + Update Spoilers - - + Success - + Download URLs have been reset. - - Downloaded card pictures have been reset. - - - - - Error - - - - - One or more downloaded card pictures could not be cleared. - - - - + Add URL - - + + URL: - - + + Edit URL - - Network Cache Size: - - - - - Redirect Cache TTL: - - - - - How long cached redirects for urls are valid for. - - - - - Picture Cache Size: - - - - + Add New URL - + Remove URL - - Day(s) - - - - + Updating... - + Choose path - + URL Download Priority - + Spoilers - + Download Spoilers Automatically - + Spoiler Location: - + Last Change - + Spoilers download automatically on launch - + Press the button to manually update without relaunching - + Do not close settings until manual update is complete - + Download card pictures on the fly - + How to add a custom URL - - Delete Downloaded Images - - - - + Reset Download URLs - - - On-disk cache for downloaded pictures - - - - - In-memory cache for pictures not currently on screen - - DeckListHistoryManagerWidget @@ -1529,12 +1993,12 @@ This is only saved for moderators and cannot be seen by the banned person. DeckLoader - + Common deck formats (%1) - + All files (*.*) @@ -1756,27 +2220,27 @@ This is only saved for moderators and cannot be seen by the banned person. - + Moved to %1 1 × "%2" (%3) - + Removed "%1" (all copies) - + %1 1 × "%2" (%3) - + Added - + Removed @@ -1798,74 +2262,74 @@ This is only saved for moderators and cannot be seen by the banned person. DeckViewContainer - + Load deck... - + Load remote deck... - + Load from clipboard... - + Load from website... - + Unload deck - + Ready to start - + Force start - + Sideboard unlocked - + Sideboard locked - - + + Error - + The selected file could not be loaded. - + Deck is greater than maximum file size. - + Are you sure you want to force start? This will kick all non-ready players from the game. - + Cockatrice @@ -1964,7 +2428,7 @@ This will kick all non-ready players from the game. - + Webpage @@ -2004,37 +2468,37 @@ This will kick all non-ready players from the game. - + Server URL - + Communication Port - + Unique Server Name - + Connection Warning - + You need to name your new connection profile. - + Connect Warning - + The player name can't be empty. @@ -2147,17 +2611,17 @@ This will kick all non-ready players from the game. - + Game information - + Error - + Server error. @@ -2165,97 +2629,97 @@ This will kick all non-ready players from the game. DlgCreateToken - + &Name: - + Token - + C&olor: - + white - + blue - + black - + red - + green - + multicolor - + colorless - + &P/T: - + &Annotation: - + &Destroy token when it leaves the table - + Create face-down (Only hides name) - + Token data - + Show &all tokens - + Show tokens from this &deck - + Choose token from list - + Create token @@ -2289,7 +2753,7 @@ This will kick all non-ready players from the game. - + @@ -2304,12 +2768,12 @@ This will kick all non-ready players from the game. - + Duplicate Tag - + This tag already exists. @@ -2495,12 +2959,12 @@ To remove your current avatar, confirm without choosing a new image. - + Error - + The chosen name conflicts with an existing card or token. Make sure to enable the 'Token' set in the "Manage sets" dialog to display them correctly. @@ -2524,12 +2988,12 @@ Make sure to enable the 'Token' set in the "Manage sets" dia - + Real name: - + Edit user profile @@ -2567,113 +3031,113 @@ Make sure to enable the 'Token' set in the "Manage sets" dia - + Hide 'buddies only' games - + Hide full games - + Hide games that have started - + Hide password protected games - + Hide 'ignored user' games - + Hide games not created by buddies Hide games not created by buddy - + Hide games with forced open decklists - + &Newer than: - + Game &description: - + &Creator name: - + General - + &Game types - + at &least: - + at &most: - + Maximum player count - + Restrictions - + Show games only if &spectators can watch - + Show spectator password p&rotected games - + Show only if spectators can ch&at - + Show only if spectators can see &hands - + Spectators - + Filter games @@ -2976,37 +3440,37 @@ https://tappedout.net/mtg-decks/your-deck-name/ DlgMoveTopCardsUntil - + Card name (or search expressions): - + Number of hits: - + Auto play hits - + Put top cards on stack until... - + No cards matching the search expression exists in the card database. Proceed anyways? - + Cockatrice - + Invalid filter @@ -3065,40 +3529,40 @@ Your email will be used to verify your account. - + Real name: - + Register to server - - - - + + + + Registration Warning - + Your password is too short. - + Your passwords do not match, please try again. - + Your email addresses do not match, please try again. - + The player name can't be empty. @@ -3106,17 +3570,17 @@ Your email will be used to verify your account. DlgRollDice - + Number of sides: - + Number of dice: - + Roll Dice @@ -3167,12 +3631,12 @@ Your email will be used to verify your account. DlgSettings - + Unknown Error loading card database - + Your card database is invalid. Cockatrice may not function correctly with an invalid database @@ -3183,7 +3647,7 @@ Would you like to change your database location setting? - + Your card database version is too old. This can cause problems loading card information or images @@ -3194,7 +3658,7 @@ Would you like to change your database location setting? - + Your card database did not finish loading Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues with your cards.xml attached @@ -3203,21 +3667,21 @@ Would you like to change your database location setting? - + File Error loading your card database. Would you like to change your database location setting? - + Your card database was loaded but contains no cards. Would you like to change your database location setting? - + Unknown card database load status Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues @@ -3226,59 +3690,64 @@ Would you like to change your database location setting? - - - + + + Error - + The path to your deck directory is invalid. Would you like to go back and set the correct path? - + The path to your card pictures directory is invalid. Would you like to go back and set the correct path? - + Settings - + General - + Appearance - + User Interface - + + Storage + + + + Card Sources - + Chat - + Sound - + Shortcuts @@ -3370,9 +3839,9 @@ You can always change this behavior in the 'General' settings tab. - - - + + + Error @@ -3424,31 +3893,31 @@ Please visit the download page to update manually. - + Update Available - + A new version of Cockatrice is available! - + New version - + Released - + Changelog @@ -3458,50 +3927,50 @@ Please visit the download page to update manually. - + Unfortunately, the automatic updater failed to find a compatible download. You may have to manually download the new version. - + Please check the <a href="%1">releases page</a> on our Github and download the build for your system. - - - + + + Update Error - + An error occurred while checking for updates: - + An error occurred while downloading an update: - + Installing... - + Cockatrice is unable to open the installer. - + Try to update manually by closing Cockatrice and running the installer. - + Download location @@ -3591,67 +4060,67 @@ You may have to manually download the new version. DrawProbabilityWidget - + Draw Probability - + Probability of drawing - + Card Name - + Type - + Subtype - + Mana Value - + At least - + Exactly - + card(s) having drawn at least - + cards - + Category - + Qty - + Odds (%) @@ -3765,7 +4234,7 @@ You may have to manually download the new version. FilterBuilder - + Type your filter here @@ -3796,22 +4265,22 @@ You may have to manually download the new version. GameEventHandler - + kicked by game host or moderator - + player left the game - + player disconnected from server - + reason unknown @@ -3819,140 +4288,140 @@ You may have to manually download the new version. GameSelector - - - - - - - - - + + + + + + + + + Error - + Please join the appropriate room first. - + Wrong password. - + Spectators are not allowed in this game. - + The game is already full. - + The game does not exist any more. - + This game is only open to registered users. - + This game is only open to its creator's buddies. - + You are being ignored by the creator of this game. - + Join Game - + Spectate Game - + Game Information - + Join Game as Judge - + Spectate Game as Judge - + Join game - + Password: - + Please join the respective room first. - + &Filter games - + C&lear filter - + C&reate - + &Join - + Join as judge - + J&oin as spectator - + Join as judge spectator - + Games shown: %1 / %2 - + Games @@ -3960,32 +4429,32 @@ You may have to manually download the new version. GameSelectorQuickFilterToolBar - + All types - + Filter by game name... - + Filter by game type/format - + Hide games not created by buddies - + Hide full games - + Hide started games @@ -3993,12 +4462,12 @@ You may have to manually download the new version. GamesModel - + >1 day - + %1%2 hr short age in hours @@ -4007,12 +4476,12 @@ You may have to manually download the new version. - + new - + %1%2 min short age in minutes @@ -4021,83 +4490,83 @@ You may have to manually download the new version. - + password - + buddies only - + reg. users only - + open decklists - + can chat - + see hands - + can see hands - + not allowed - + Room - + Age - + Description - + Creator - + Type - + Restrictions - + Players - + Spectators @@ -4105,143 +4574,158 @@ You may have to manually download the new version. GeneralSettingsPage - + Reset all paths - + All paths have been reset - - - - - - - + + + + + + + Choose path - - Personal settings + + Language settings - + Language: - + + Version settings + + + + + Card database + + + + + Startup settings + + + + Paths (editing disabled in portable mode) - + Paths - + How to help with translations - + Decks directory: - + Filters directory: - + Replays directory: - + Pictures directory: - + Card database: - + Custom database directory: - + Token database: - + Update channel - + Check for client updates on startup - + Check for card database updates on startup - + Don't check - + Prompt for update - + Always update in the background - + Check for card database updates every - + days - + Notify if a feature supported by the server is missing in my client - + Automatically run Oracle when running a new version of Cockatrice - + Show tips on startup - + Last update check on %1 (%2 days ago) @@ -4249,47 +4733,47 @@ You may have to manually download the new version. GraveyardMenu - + &Graveyard - + &View graveyard - + &Move graveyard to... - + &Top of library - + &Bottom of library - + &All players - + &Hand - + &Exile - + Reveal random card to... @@ -4297,88 +4781,88 @@ You may have to manually download the new version. HandMenu - + &Hand - + &View hand - + Sort hand by... - + Name - + Type - + Mana Value - + Take &mulligan (Choose hand size) - + Take mulligan (Same hand size) - + Take mulligan (Hand size - 1) - + &Move hand to... - + &Top of library - + &Bottom of library - + &Graveyard - + &Exile - + &Reveal hand to... - - + + All players - + Reveal r&andom card to... @@ -4386,52 +4870,52 @@ You may have to manually download the new version. HomeWidget - + Create New Deck - + Browse Decks - + Browse Card Database - + Browse EDHRec - + Browse Archidekt - + View Replays - + Quit - + Connecting... - + Connect - + Play @@ -4439,213 +4923,213 @@ You may have to manually download the new version. LibraryMenu - + &Library - + &View library - + View &top cards of library... - + View bottom cards of library... - + Reveal &library to... - + Lend library to... - + Reveal &top cards to... - + &Top of library... - + &Bottom of library... - + &Always reveal top card - + &Always look at top card - + &Open deck in deck editor - + &Draw card - + D&raw cards... - + &Undo last draw - + Shuffle - + &Play top card - + Play top card &face down - + Put top card on &bottom - + Move top card to grave&yard - + Move top card to e&xile - + Move top cards to &graveyard... - + Move top cards to graveyard face down... - + Move top cards to &exile... - + Move top cards to exile face down... - + Put top cards on stack &until... - + Shuffle top cards... - + &Draw bottom card - + D&raw bottom cards... - + &Play bottom card - + Play bottom card &face down - + Move bottom card to grave&yard - + Move bottom card to e&xile - + Move bottom cards to &graveyard... - + Move bottom cards to graveyard face down... - + Move bottom cards to &exile... - + Move bottom cards to exile face down... - + Put bottom card on &top - + Shuffle bottom cards... - - + + &All players - + Reveal top cards of library - + Number of cards: (max. %1) @@ -4653,655 +5137,300 @@ You may have to manually download the new version. MainWindow - - - The server has reached its maximum user capacity, please check back later. - - - - - There are too many concurrent connections from your address. - - - - - Banned by moderator - - - - - Expected end time: %1 - - - - - This ban lasts indefinitely. - - - - - Scheduled server shutdown. - - - - - - Invalid username. - - - - - You have been logged out due to logging in at another location. - - - - - Connection closed - - - - - The server has terminated your connection. -Reason: %1 - - - - - The server is going to be restarted in %n minute(s). -All running games will be lost. -Reason for shutdown: %1 - - - - - - - - Scheduled server shutdown - - - - - - Success - - - - - Registration accepted. -Will now login. - - - - - Account activation accepted. -Will now login. - - - - - + + Player %1 - + Load replay - + About Cockatrice - + Version - + Cockatrice Webpage - + Project Manager: - + Past Project Managers: - + Developers: - + Our Developers - + Help Develop! - + Translators: - + Our Translators - + Help Translate! - + Support: - + Report an Issue - + Troubleshooting - + F.A.Q. - - - - - - - - - - - - - - - - - - - - + + Error - - Server timeout - - - - - Failed Login - - - - - Your client seems to be missing features this server requires for connection. - - - - - To update your client, go to 'Help -> Check for Client Updates'. - - - - - Incorrect username or password. Please check your authentication information and try again. - - - - - There is already an active session using this user name. -Please close that session first and re-login. - - - - - - You are banned until %1. - - - - - - You are banned indefinitely. - - - - - This server requires user registration. Do you want to register now? - - - - - This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. -Please close and reopen your client to try again. - - - - - An internal error has occurred, please close and reopen Cockatrice before trying again. -If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. - - - - - Account activation - - - - - Your account has not been activated yet. -You need to provide the activation token received in the activation email. - - - - - Server Full - - - - - Unknown login error: %1 - - - - - - -This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. - - - - - Your username must respect these rules: - - - - - is %1 - %2 characters long - - - - - can %1 contain lowercase characters - - - - - - - - NOT - - - - - can %1 contain uppercase characters - - - - - can %1 contain numeric characters - - - - - can contain the following punctuation: %1 - - - - - first character can %1 be a punctuation mark - - - - - no unacceptable language as specified by these server rules: - note that the following lines will not be translated - - - - - can not contain any of the following words: %1 - - - - - can not match any of the following expressions: %1 - - - - - You may only use A-Z, a-z, 0-9, _, ., and - in your username. - - - - - - - - - - Registration denied - - - - - Registration is currently disabled on this server - - - - - There is already an existing account with the same user name. - - - - - It's mandatory to specify a valid email address when registering. - - - - - It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. - - - - - Password too short. - - - - - Registration failed for a technical problem on the server. - - - - - The connection to the server has been lost. - - - - - Unknown registration error: %1 - - - - - Account activation failed - - - - - Socket error: %1 - - - - - You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. -Local version is %1, remote version is %2. - - - - - Your Cockatrice client is obsolete. Please update your Cockatrice version. -Local version is %1, remote version is %2. - - - - - Connecting to %1... - - - - - Registering to %1 as %2... - - - - - Disconnected - - - - - Connected, logging in at %1 - - - - - - - Requesting forgotten password to %1 as %2... - - - - + &Connect... - + &Disconnect - + Start &local game... - + &Watch replay... - + &Full screen - + &Register to server... - + &Restore password... - + &Settings... - + &Exit - + A&ctions - + &Cockatrice - + C&ard Database - + &Manage sets... - + Edit custom &tokens... - + Open custom image folder - + Open custom sets folder - + Add custom sets/cards - + Reload card database - + Tabs - + &Help - + &About Cockatrice - + &Tip of the Day - + Check for Client Updates - + Check for Card Updates... - + Check for Card Updates (Automatic) - + Show Status Bar - + View &Debug Log - + Open Settings Folder - + Show/Hide - + New Version - + Congratulations on updating to Cockatrice %1! Oracle will now launch to update your card database. - + Cockatrice installed - + Congratulations on installing Cockatrice %1! Oracle will now launch to install the initial card database. - + Card database - + Cockatrice is unable to load the card database. Do you want to update your card database now? If unsure or first time user, choose "Yes" - - + + Yes - - + + No - + Open settings - + New sets found - + %n new set(s) found in the card database Set code(s): %1 Do you want to enable it/them? @@ -5311,179 +5440,153 @@ Do you want to enable it/them? - + + Yes, always enable + + + + View sets - + Welcome - + Hi! It seems like you're running this version of Cockatrice for the first time. All the sets in the card database have been enabled. Read more about changing the set order or disabling specific sets and consequent effects in the "Manage Sets" dialog. - - + Information - + A card database update is already running. - + Unable to run the card database updater: - + Card database update running. - + Failed to start. The file might be missing, or permissions might be incorrect. - + The process crashed some time after starting successfully. - + Timed out. The process took too long to respond. The last waitFor...() function timed out. - + An error occurred when attempting to write to the process. For example, the process may not be running, or it may have closed its input channel. - + An error occurred when attempting to read from the process. For example, the process may not be running. - + Unknown error occurred. - + The card database updater exited with an error: %1 - - This server supports additional features that your client doesn't have. -This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. - -To update your client, go to Help -> Check for Updates. - - - - - - - - + + + + + Load sets/cards - + Selected file cannot be found. - + You can only import XML databases at this time. - + The new sets/cards have been added successfully. Cockatrice will now reload the card database. - + Sets/cards failed to import. - - - - - Reset Password - - - - - Your password has been reset successfully, you can now log in using the new credentials. - - - - - Failed to reset user account password, please contact the server operator to reset your password. - - - - - Activation request received, please check your email for an activation token. - - ManaBaseConfigDialog - + Mana Base Configuration - + Display type: - + pie - + bar - + combinedBar - + Filter Colors (optional): - + OK - + Cancel @@ -5555,27 +5658,27 @@ Cockatrice will now reload the card database. ManaDevotionConfigDialog - + Display type: - + pie - + bar - + combinedBar - + Filter Colors (optional): @@ -5645,297 +5748,297 @@ Cockatrice will now reload the card database. MessageLogWidget - + from play - + from their graveyard - + from exile - + from their hand - + the top card of %1's library - + the top card of their library - + from the top of %1's library - + from the top of their library - + the bottom card of %1's library - + the bottom card of their library - + from the bottom of %1's library - + from the bottom of their library - + from %1's library - + from their library - + from sideboard - + from the stack - + from custom zone '%1' - + %1 is now keeping the top card %2 revealed. - + %1 is not revealing the top card %2 any longer. - + %1 can now look at top card %2 at any time. - + %1 no longer can look at top card %2 at any time. - + %1 attaches %2 to %3's %4. - + %1 has conceded the game. - + %1 has unconceded the game. - + %1 has restored connection to the game. - + %1 has lost connection to the game. - + %1 points from their %2 to themselves. - + %1 points from their %2 to %3. - + %1 points from %2's %3 to themselves. - + %1 points from %2's %3 to %4. - + %1 points from their %2 to their %3. - + %1 points from their %2 to %3's %4. - + %1 points from %2's %3 to their own %4. - + %1 points from %2's %3 to %4's %5. - + %1 creates a face down token. - + %1 creates token: %2%3. - + %1 has loaded a deck (%2). - + %1 has loaded a deck with %2 sideboard cards (%3). - + %1 destroys %2. - + a card - + %1 gives %2 control over %3. - + %1 puts %2 into play%3 face down. - + %1 puts %2 into play%3. - + %1 puts %2%3 into their graveyard face down. - + %1 puts %2%3 into their graveyard. - + %1 exiles %2%3 face down. - + %1 exiles %2%3. - + %1 moves %2%3 to their hand. - + %1 puts %2%3 into their library. - + %1 puts %2%3 onto the bottom of their library. - + %1 puts %2%3 on top of their library. - + %1 puts %2%3 into their library %4 cards from the top. - + %1 moves %2%3 to sideboard. - + %1 plays %2%3 face down. - + %1 plays %2%3. - + %1 moves %2%3 to custom zone '%4' face down. - + %1 moves %2%3 to custom zone '%4'. - + %1 tries to draw from an empty library - + %1 draws %2 card(s). @@ -5943,12 +6046,12 @@ Cockatrice will now reload the card database. - + %1 is looking at %2. - + %1 is looking at the %4 %3 card(s) %2. top card for singular, top %3 cards for plural @@ -5957,72 +6060,72 @@ Cockatrice will now reload the card database. - + bottom - + top - + %1 turns %2 face-down. - + %1 turns %2 face-up. - + The game has been closed. - + The game has started. - + You are flooding the game. Please wait a couple of seconds. - + %1 has joined the game. - + %1 is now watching the game. - + You have been kicked out of the game. - + %1 has left the game (%2). - + %1 is not watching the game any more (%2). - + %1 is not ready to start the game any more. - + %1 shuffles their deck and draws a new hand of %2 card(s). @@ -6030,28 +6133,28 @@ Cockatrice will now reload the card database. - + %1 shuffles their deck and draws a new hand. - + You are watching a replay of game #%1. - + %1 is ready to start the game. - + cards an unknown amount of cards - + %1 card(s) a card for singular, %1 cards for plural @@ -6060,213 +6163,218 @@ Cockatrice will now reload the card database. - + %1 lends %2 to %3. - + %1 reveals %2 to %3. - + %1 reveals %2. - + %1 randomly reveals %2%3 to %4. - + %1 randomly reveals %2%3. - + %1 peeks at face down card #%2. - + %1 peeks at face down card #%2: %3. - + %1 reveals %2%3 to %4. - + %1 reveals %2%3. - + %1 reversed turn order, now it's %2. - + reversed - + normal - + Heads - + Tails - + %1 flipped a coin. It landed as %2. - + %1 rolls a %2 with a %3-sided die. - + %1 flips %2 coins. There are %3 heads and %4 tails. - + %1 rolls a %2-sided dice %3 times: %4. - + %1's turn. - + %1 sets annotation of %2 to %3. - - %1 places %2 "%3" counter(s) on %4 (now %5). + + %1 places %2 %3%4 counter(s) on %5 (now %6). - - %1 removes %2 "%3" counter(s) from %4 (now %5). + + %1 removes %2 %3%4 counter(s) from %5 (now %6). - + + %1 failed to undo their last draw. + + + + %1 sets counter %2 to %3 (%4%5). - + %1 sets %2 to not untap normally. - + %1 sets %2 to untap normally. - + %1 removes the PT of %2. - + %1 changes the PT of %2 from nothing to %4. - + %1 changes the PT of %2 from %3 to %4. - + %1 has locked their sideboard. - + %1 has unlocked their sideboard. - + %1 taps their permanents. - + %1 untaps their permanents. - + %1 taps %2. - + %1 untaps %2. - + %1 shuffles %2. - + %1 shuffles the bottom %3 cards of %2. - + %1 shuffles the top %3 cards of %2. - + %1 shuffles cards %3 - %4 of %2. - + %1 unattaches %2. - + %1 undoes their last draw. - + %1 undoes their last draw (%2). @@ -6274,110 +6382,115 @@ Cockatrice will now reload the card database. MessagesSettingsPage - + Word1 Word2 Word3 - + Add New Message - + Edit Message - + Remove Message - + Add message - - + + Message: - + Edit message - + Chat settings - + Custom alert words - + Enable chat mentions - + Enable mention completer - + In-game message macros - + How to use in-game message macros - + Ignore chat room messages sent by unregistered users - + Ignore private messages sent by unregistered users - - + + Ignore private messages sent by non-buddy users + + + + + Invert text color - + Enable desktop notifications for private messages - + Enable desktop notification for mentions - + Enable room message history on join - - + + (Color is hexadecimal) - + Separate words with a space, alphanumeric characters only @@ -6385,42 +6498,42 @@ Cockatrice will now reload the card database. MoveMenu - + Move to - + &Top of library in random order - + X cards from the top of library... - + &Bottom of library in random order - + T&able - + &Hand - + &Graveyard - + &Exile @@ -6433,47 +6546,47 @@ Cockatrice will now reload the card database. - + Mana Value - + Color(s) - + Loyalty - + Main Card Type - + Mana Cost - + P/T - + Side - + Layout - + Color Identity @@ -6496,6 +6609,152 @@ Cockatrice will now reload the card database. + + PaletteEditorDialog + + + + Reset + + + + + + Apply + + + + + + Save && Apply + + + + + ▼ Edit Palette + + + + + + ▶ Edit Palette + + + + + Palette Editor — %1 + + + + + <b>Palette Editor</b> &nbsp;·&nbsp; %1 + + + + + This theme ships no default palette files + + + + + Replace current colours with the theme author's defaults + + + + + Switch between the light and dark palette files + + + + + Editing: + + + + + Show or hide the per-role colour grid for manual tweaks + + + + + ↺ Revert to theme default + + + + + Discard unsaved edits and restore the last saved palette + + + + + Preview this palette without saving to disk + + + + + Write palette-%1.toml and reload the theme + + + + + Cannot save: this theme has no directory on disk + + + + + Save failed + + + + + Could not write %1 to: +%2 + + + + + No default found + + + + + No default palette file found for the "%1" scheme. + + + + + PaletteGridWidget + + + Active + + + + + Disabled + + + + + Inactive + + + + + Normal interactive state + + + + + Widget is disabled / not interactive + + + + + Window is in background / unfocused + + + Phase @@ -6562,57 +6821,57 @@ Cockatrice will now reload the card database. PhasesToolbar - + Untap step - + Upkeep step - + Draw step - + First main phase - + Beginning of combat step - + Declare attackers step - + Declare blockers step - + Combat damage step - + End of combat step - + Second main phase - + End of turn step @@ -6620,7 +6879,7 @@ Cockatrice will now reload the card database. PictureLoader - + en code for scryfall's language property, not available for all languages @@ -6629,151 +6888,142 @@ Cockatrice will now reload the card database. PlayerActions - - View top cards of library - - - - - - - - - - - - - Number of cards: (max. %1) - - - - - View bottom cards of library - - - - - Shuffle top cards of library - - - - - Shuffle bottom cards of library - - - - - Draw hand - - - - - 0 and lower are in comparison to current hand size - - - - - Draw cards - - - - - - - + + + + grave - - - - + + + + exile + + + PlayerDialogs - + + View top cards of library + + + + + + + + + + + + + Number of cards: (max. %1) + + + + + View bottom cards of library + + + + + Shuffle top cards of library + + + + + Shuffle bottom cards of library + + + + + Draw hand + + + + + 0 and lower are in comparison to current hand size + + + + + Draw cards + + + + Move top cards to %1 - + Move bottom cards to %1 - + Draw bottom cards - - - C&reate another %1 token - - - - + Create tokens - - + Number: - + Place card X cards from top of library - + Which position should this card be placed: - + (max. %1) - + Change power/toughness - + Change stats to: - + Set annotation - + Please enter the new annotation: - - - Set counters - - PlayerMenu - + Player "%1" - + &Counters @@ -6803,7 +7053,7 @@ This setting means you'll only see the default printing for each card, inst - + Printing Selector @@ -6811,22 +7061,22 @@ This setting means you'll only see the default printing for each card, inst PrintingSelectorCardOverlayWidget - + Preference - + Pin Printing - + Unpin Printing - + Show Related cards @@ -6897,57 +7147,57 @@ This setting means you'll only see the default printing for each card, inst PtMenu - + Power / toughness - + &Increase power - + &Decrease power - + I&ncrease toughness - + D&ecrease toughness - + In&crease power and toughness - + Dec&rease power and toughness - + Increase power and decrease toughness - + Decrease power and increase toughness - + Set &power and toughness... - + Reset p&ower and toughness @@ -6955,37 +7205,37 @@ This setting means you'll only see the default printing for each card, inst QMenuBar - + Services - + Hide %1 - + Hide Others - + Show All - + Preferences... - + Quit %1 - + About %1 @@ -6993,17 +7243,17 @@ This setting means you'll only see the default printing for each card, inst QObject - + Cockatrice card database (*.xml) - + All files (*.*) - + Cockatrice replays (*.cor) @@ -7063,110 +7313,177 @@ Are you sure you would like to disable this feature? QPlatformTheme - + OK - + Save - + Save All - + Open - + &Yes - + Yes to &All - + &No - + N&o to All - + Abort - + Retry - + Ignore - + Close - + Cancel - + Discard - + Help - + Apply - + Reset - + Restore Defaults + + QuickSetupPanel + + + %1% + + + + + <b>Quick Setup</b> + + + + + Generate all palette roles automatically from a single accent colour + + + + + Accent: + + + + + Primary hue. Used directly for highlights and links. +At high intensity it also tints buttons and backgrounds. + + + + + Intensity: + + + + + Subtle + + + + + Full colour + + + + + 0–30 Subtle tint — only highlights and links change hue +30–70 Accented — buttons, tooltips, and borders join in +70–100 Full colour — backgrounds, everything + + + + + 70% + + + + + Generate ↓ + + + + + Derive all palette roles from the accent colour above. +Fine-tune individual colours in the grid afterwards. + + + RemoteDeckList_TreeModel - + Name - + ID - + Upload time @@ -7174,32 +7491,32 @@ Are you sure you would like to disable this feature? RemoteReplayList_TreeModel - + ID - + Name - + Players - + Keep - + Time started - + Duration (sec) @@ -7207,37 +7524,37 @@ Are you sure you would like to disable this feature? RfgMenu - + &Exile - + &View exile - + &Move exile to... - + &Top of library - + &Bottom of library - + &Hand - + &Graveyard @@ -7283,7 +7600,7 @@ Are you sure you would like to disable this feature? SayMenu - + S&ay @@ -7324,27 +7641,27 @@ Are you sure you would like to disable this feature? SetsModel - + Enabled - + Set type - + Set code - + Long name - + Release date @@ -7352,53 +7669,53 @@ Are you sure you would like to disable this feature? ShortcutSettingsPage - - + + Restore all default shortcuts - + Do you really want to restore all default shortcuts? - + Clear all default shortcuts - + Do you really want to clear all shortcuts? - + Section: - + Action: - + Shortcut: - + How to set custom shortcuts - + Clear all shortcuts - + Search by shortcut name @@ -7452,12 +7769,12 @@ Please check your shortcut settings! SideboardMenu - + &Sideboard - + &View sideboard @@ -7465,27 +7782,27 @@ Please check your shortcut settings! SoundSettingsPage - + Enable &sounds - + Current sounds theme: - + Test system sound engine - + Sound settings - + Master volume @@ -7557,16 +7874,146 @@ Please check your shortcut settings! - + No reply received from the tag update server. - + Invalid reply received from the tag update server. + + StorageSettingsPage + + + + + Success + + + + + Cached card pictures have been reset. + + + + + Downloaded card pictures have been reset. + + + + + Error + + + + + One or more downloaded card pictures could not be cleared. + + + + + In-memory (currently loaded) card pictures have been reset. + + + + + Card Picture Loader Caching Method: + + + + + The network cache is the preferred way of storing images. Downloaded images are stored here until the size of the cache exceeds the configured size. Cockatrice automatically monitors this cache and deletes the least recently seen card images to ensure the cache does not exceed the configured size. + + + + + Writing card images directly to a folder on your hard drive is another way of storing images. This does not change how Cockatrice accesses or downloads images. Cockatrice will NOT automatically monitor and clear this folder, so if you enable this option, it is up to you to ensure sufficient available space. It should also be noted that if a provider outage causes you to download the wrong picture (i.e. wrong printing) you will be stuck with it until you manually delete the file, as opposed to using the network cache, which automatically rotates and thus correct errors after a while. + + + + + This is the in-memory picture cache used by the application at runtime. It determines how much memory (RAM) Cockatrice can use before it has to fetch card images from the hard disk again. Increasing this will allow more card images to be displayed at once but shouldn't be necessary. Clearing this will make Cockatrice reload all images from the network cache or the disk. + + + + + Delete Cached Images + + + + + Delete Saved Images + + + + + Clear In-Memory Images + + + + + Card Picture Loader Cache Method + + + + + Network Cache + + + + + Filesystem + + + + + In-Memory Picture Cache + + + + + Network Cache Size: + + + + + On-disk cache for downloaded pictures + + + + + Redirect Cache TTL: + + + + + How long cached redirects for urls are valid for. + + + + + Picture Cache Size: + + + + + In-memory cache for pictures not currently on screen + + + + + Naming scheme: + + + + + Day(s) + + + TabAccount @@ -7702,117 +8149,117 @@ Please check your shortcut settings! TabArchidekt - - + + Desc. - - + + AND - - + + Require ALL selected colors - - + + Deck name... - - + + Owner... - - + + Packages - - + + Advanced Filters - + Bracket: - - + + Any - - + + Contains card... - - + + Commander... - - + + Tag... - - + + Deck Size - + Cards: - - + + Asc. - + Sort by: - + Filter by: - + Display Settings - - + + Search - - + + Formats @@ -7822,6 +8269,54 @@ Please check your shortcut settings! + + TabCardArtRules + + + Card: + + + + + ProviderId: + + + + + Mode: + + + + + Reason: + + + + + Type a card name... + + + + + Add rule + + + + + Remove rule + + + + + Refresh + + + + + Card Art Rules + + + TabDeckEditor @@ -7870,7 +8365,7 @@ Please check your shortcut settings! - + Deck: %1 @@ -7878,55 +8373,49 @@ Please check your shortcut settings! TabDeckEditorVisual - + Visual Deck: %1 - + &Visual Deck Editor - - + + Card Info - - + + Deck - - - Filters - - - - + &View - + Printing - + Visible - + Floating - + Reset layout @@ -7934,22 +8423,22 @@ Please check your shortcut settings! TabDeckEditorVisualTabWidget - + Visual Deck View - + Visual Database Display - + Deck Analytics - + Sample Hand @@ -7957,133 +8446,133 @@ Please check your shortcut settings! TabDeckStorage - + Local file system - + Server deck storage - - + + Open in deck editor - + Rename deck or folder - + Upload deck - + Download deck - - - + + + New folder - + Delete - + Open decks folder - + Rename local folder - + Rename local file - + New name: - - - - + + + + Error - + Rename failed - - + + Invalid deck file - + Enter deck name - + This decklist does not have a name. Please enter a name: - + Unnamed deck - + Failed to upload deck to server - + Delete local file - + Are you sure you want to delete the selected files? - + Delete remote decks - + Are you sure you want to delete the selected decks? - - + + Name of new folder: @@ -8155,191 +8644,191 @@ Please enter a name: TabGame - - - + + + Replay - - + + Game - - + + Player List - - + + Card Info - - + + Messages - - + + Replay Timeline - + &Phases - + &Game - + Next &phase - + Next phase with &action - + Next &turn - + Reverse turn order - + &Remove all local arrows - + Rotate View Cl&ockwise - + Rotate View Co&unterclockwise - + Game &information - + Un&concede - - - + + + &Concede - + &Leave game - + C&lose replay - + &Focus Chat - + &Say: - + Selected cards - + &View - + Visible - + Floating - + Reset layout - + Concede - + Are you sure you want to concede this game? - + Unconcede - + You have already conceded. Do you want to return to this game? - + Leave game - + Are you sure you want to leave this game? - + A player has joined game #%1 - + %1 has joined the game - + You have been kicked out of the game. @@ -8398,114 +8887,114 @@ Please enter a name: - + Username: - + IP Address: - + Game Name: - + GameID: - + Message: - + Main Room - + Game Room - + Private Chat - + Past X Days: - + Today - + Last Hour - + Maximum Results: - + At least one filter is required. The more information you put in, the more specific your results will be. - + Get User Logs - + Clear Filters - + Filters - + Log Locations - + Date Range - + Maximum Results - - + + Message History - + Failed to collect message history information. - + There are no messages for the selected filters. @@ -8523,27 +9012,27 @@ The more information you put in, the more specific your results will be. - + %1 - Private chat - + This user is ignoring you, they cannot see your messages in main chat and you cannot join their games. - + Private message from - + %1 has left the server. - + %1 has joined the server. @@ -8551,180 +9040,180 @@ The more information you put in, the more specific your results will be. TabReplays - + Local file system - + Server replay storage - - + + Watch replay - + Rename - - + + New folder - - + + Delete - + Open replays folder - + Download replay - + Toggle expiration lock - + Get replay share code - - + + Look up replay by share code - + Rename local folder - + Rename local file - + New name: - + Error - + Rename failed - + Name of new folder: - + Delete local file - + Are you sure you want to delete the selected files? - + Are you sure you want to delete the selected replays? - + Failed to get code - - + + Either this server does not support replay sharing, or does not permit replay sharing for you. - - - + + + Failed - + Could not get replay code - + Replay Share Code - + Others can use this code to add the replay to their list of remote replays: %1 - + Copy to clipboard - + Replay share code - + Replay code found - + Replay was added, or you already had access to it. - + Replay code not found - + Failed to submit code - + Unexpected error - + Delete remote replay @@ -8737,47 +9226,62 @@ The more information you put in, the more specific your results will be. TabRoom - + + Friends + + + + + Online + + + + + Ignored + + + + &Say: - + Chat - + &Room - + &Leave room - + &Clear chat - + Chat Settings... - + mentioned you. - + Click to view - + You are flooding the chat. Please wait a couple of seconds. @@ -8790,30 +9294,30 @@ The more information you put in, the more specific your results will be. - - - - + + + + Error - + Failed to join the server room: it doesn't exist on the server. - + The server thinks you are in the server room but your client is unable to display it. Try restarting your client. - + You do not have the required permission to join this server room. - + Failed to join the server room due to an unknown error: %1. @@ -8821,97 +9325,97 @@ The more information you put in, the more specific your results will be. TabSupervisor - + Deck Editor - + Visual Deck Editor - + EDHRec - + Archidekt - + Home - + &Visual Deck Storage - + Visual Database Display - + Server - + Account - + Deck Storage - + Game Replays - + Administration - + Logs - + Are you sure? - + There are still open games. Are you sure you want to quit? - + Click to view - + Your buddy %1 has signed on! - + Unknown Event - + The server has sent you a message that your client does not understand. This message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -8919,38 +9423,38 @@ To update your client, go to Help -> Check for Updates. - + Idle Timeout - + You are about to be logged out due to inactivity. - + Promotion - + You have been promoted. Please log out and back in for changes to take effect. - + Warned - + You have received a warning due to %1. Please refrain from engaging in this activity or further actions may be taken against you. If you have any questions, please private message a moderator. - + You have received the following message from the server. (custom messages like these could be untranslated) @@ -8959,12 +9463,12 @@ Please refrain from engaging in this activity or further actions may be taken ag TabVisualDatabaseDisplay - + Database Display - + Visual Database Display @@ -9001,42 +9505,42 @@ Please refrain from engaging in this activity or further actions may be taken ag TranslateCounterName - + Life - + White - + Blue - + Black - + Red - + Green - + Colorless - + Other @@ -9044,11 +9548,69 @@ Please refrain from engaging in this activity or further actions may be taken ag UpdateDownloader - + Could not open the file for reading. + + UserCardArtSettingsDialog + + + Card Art Settings + + + + + Type a card name... + + + + + Card name: + + + + + Card ProviderId: + + + + + Left margin (%): + + + + + Right margin (%): + + + + + Vertical offset: + + + + + Zoom: + + + + + Parameters + + + + + Preview + + + + + Remove Banner Card + + + UserContextMenu @@ -9166,7 +9728,7 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Ban History @@ -9181,77 +9743,87 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Failed to collect ban information. - - - + + + Warning History - + Warning Time;Moderator;User Name;Reason - + User has never been warned. - + Failed to collect warning information. - + Failed to get admin notes. - - + + Success - + Successfully promoted user. - + Successfully demoted user. - - - + + Kick Player + + + + + Are you sure you want to kick this player from the game? + + + + + + Failed - + Failed to promote user. - + Failed to demote user. - + Copy hash to clipboard - + Remove this user's messages @@ -9259,109 +9831,116 @@ Please refrain from engaging in this activity or further actions may be taken ag UserInfoBox - + Location: - + Account Age: - + Edit - + Change password - + Change avatar - + Administrator - + Moderator - + Registered user - - + + Unregistered user - + Judge - + Unknown - + The entered password does not match your account. - - - + + + + Information - + User information updated. - - - - - - - - - - + + + + + + + + + + + Error - + User Information - + Real Name: - + User Level: + + + Edit Banner Card + + - + %n Year(s), amount of years (only shown if more than 0) @@ -9370,7 +9949,7 @@ Please refrain from engaging in this activity or further actions may be taken ag - + %10%n Day(s) %20 amount of years (if more than 0), amount of days, date in local short format @@ -9379,212 +9958,433 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Enter Password - + Password verification is required in order to change your email address - - - + + + An error occurred while trying to update your user information. - - This server does not permit you to update your user informations. + + The selected card is blacklisted on this server or another error occurred. - - Password changed. + + Banner card removed. - - This server does not permit you to change your password. - - - - - The new password is too short. - - - - - The old password is incorrect. - - - - - Avatar updated. - - - - - This server does not permit you to update your avatar. + + Banner card updated. - An error occured while trying to updater your avatar. + This server does not permit you to update your user informations. + + + + + Password changed. + + + + + This server does not permit you to change your password. + + + + + The new password is too short. + + + + + The old password is incorrect. + + + + + Avatar updated. + + + + + This server does not permit you to update your avatar. + + + + + An error occured while trying to update your avatar. + + + + + This server does not permit you to update your user informations. + An error occured while trying to updater your avatar. + + + + + UserInfoPopup + + + + Games + + + + + Chat + + + + + Open private chat + + + + + Profile + + + + + View user profile + + + + + Show this user's games + + + + + − Buddy + + + + + Remove from buddy list + + + + + + Buddy + + + + + Add to buddy list + + + + + − Ignore + + + + + Remove from ignore list + + + + + + Ignore + + + + + Add to ignore list + + + + + Ban + + + + + Ban from server + + + + + Warn + + + + + Warn user + + + + + Ban log + + + + + View ban history + + + + + Warn log + + + + + View warning history + + + + + Notes + + + + + View admin notes + + + + + − Mod + + + + + Demote from moderator + + + + + + Mod + + + + + Promote to moderator + + + + + − Judge + + + + + Demote from judge + + + + + + Judge + + + + + Promote to judge + + + + + Join game + + + + + Spectate + + + + + + Loading games… + + + + + Could not load games. + + + + + No active games. UserInterfaceSettingsPage - + General interface settings - + &Double-click cards to play them (instead of single-click) - + &Clicking plays all selected cards (instead of just the clicked card) - + &Play all nonlands onto the stack (not the battlefield) by default - + Do not delete &arrows inside of subphases - + Close card view window when last card is removed - + Auto focus search bar when card view window is opened - + Annotate card text on tokens - - Show selection counter during drag selection + + Show selection count during drag selection - - Show total selection counter + + Show total selection count - + + Show subtype breakdown in selection tally + + + + Use tear-off menus, allowing right click menus to persist on screen - + + Keep game chat focused when clicking in game (Note: disables card view search bar) + + + + Notifications settings - + Enable notifications in taskbar - + Notify in the taskbar for game events while you are spectating - + Notify in the taskbar when users in your buddy list connect - + Animation settings - + &Tap/untap animation - + Deck editor/storage settings - + Open deck in new tab by default - + Use visual deck storage in game lobby - + Use selection animation for Visual Deck Storage - + When adding a tag in the visual deck storage to a .txt deck: - + do nothing - + ask to convert to .cod - + always convert to .cod - + Default deck editor type - + Classic Deck Editor - + Visual Deck Editor - + Replay settings - + Buffer time for backwards skip via shortcut: @@ -9592,22 +10392,22 @@ Please refrain from engaging in this activity or further actions may be taken ag UserListWidget - + Users connected to server: %1 - + Users in this room: %1 - + Buddies online: %1 / %2 - + Ignored users online: %1 / %2 @@ -9615,35 +10415,45 @@ Please refrain from engaging in this activity or further actions may be taken ag UtilityMenu - + Increment all card counters - + &Untap all permanents - + R&oll die... - + + Flip coin + + + + &Create token... - + C&reate another token - + Cr&eate predefined token + + + C&reate another %1 token + + VisualDatabaseDisplayColorFilterWidget @@ -9695,72 +10505,72 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayFilterToolbarWidget - + Sort by - + Filter by - + Save and load filters - + Filter by exact card name - + Filter by card main-type - + Filter by card sub-type - + Filter by set - + Filter by format legality - + Save/Load - + Name - + Main Type - + Sub Type - + Sets - + Formats @@ -9768,22 +10578,22 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayFormatLegalityFilterWidget - + Show formats with at least: - + cards - + Do not display formats with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) @@ -9801,32 +10611,32 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayMainTypeFilterWidget - + Show main types with at least: - + cards - + Do not display card main-types with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) - + Mode: Exact Match - + Mode: Includes @@ -9834,27 +10644,27 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayNameFilterWidget - + Filter by name... (Exact match) - + Load from Deck - + Apply all card names in currently loaded deck as exact match name filters - + Load from Clipboard - + Apply all card names in clipboard as exact match name filters @@ -9862,7 +10672,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayRecentSetFilterSettingsWidget - + Filter to most recent sets @@ -9870,19 +10680,19 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplaySetFilterWidget - + Search sets... - - + + Mode: Exact Match - - + + Mode: Includes @@ -9890,37 +10700,37 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplaySubTypeFilterWidget - + Search subtypes... - + Show sub types with at least: - + cards - + Do not display card sub-types with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) - + Mode: Exact Match - + Mode: Includes @@ -9928,23 +10738,23 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayWidget - + Search by card name (or search expressions) - + Visual - + Loading database ... - + Clear all filters @@ -9983,17 +10793,17 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Toggle Layout: Overlap - + Change how cards are displayed within zones (i.e. overlapped or fully visible.) - + Toggle Layout: Flat @@ -10022,17 +10832,17 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckEditorWidget - + Type a card name here for suggestions from the database... - + Quick search and add card - + Search for closest match in the database (with auto-suggestions) and add preferred printing to the deck on pressing enter @@ -10150,43 +10960,43 @@ Please refrain from engaging in this activity or further actions may be taken ag WarningDialog - + Which warning would you like to send? - + Redact all messages from this user in all rooms - + &OK - + &Cancel - + Warn user for misconduct - - + + Error - + User name to send a warning to can not be blank, please specify a user to warn. - + Warning to use can not be blank, please select a valid warning to send. @@ -10194,133 +11004,133 @@ Please refrain from engaging in this activity or further actions may be taken ag WndSets - + Move selected set to the top - + Move selected set up - + Move selected set down - + Move selected set to the bottom - + Search by set name, code, or type - + Default order - + Restore original art priority order - + Enable all sets - + Disable all sets - + Enable selected set(s) - + Disable selected set(s) - + Deck Editor - + Use CTRL+A to select all sets in the view. - + Only cards in enabled sets will appear in the card list of the deck editor. - + Image priority is decided in the following order: - + first the CUSTOM Folder (%1), then the Enabled Sets in this dialog (Top to Bottom) %1 is a link to the wiki - + Include cards rebalanced for Alchemy [requires restart] - + Card Art - + How to use custom card art - + Hints - + Note - + Sorting by column allows you to find a set while not changing set priority. - + To enable ordering again, click the column header until this message disappears. - + Use the current sorting as the set priority instead - + Sorts the set priority using the same column - + Manage sets @@ -10328,72 +11138,72 @@ Please refrain from engaging in this activity or further actions may be taken ag ZoneViewWidget - + Search by card name (or search expressions) - + Ungrouped - + Group by Type - + Group by Mana Value - + Group by Color - + Unsorted - + Sort by Name - + Sort by Type - + Sort by Mana Cost - + Sort by Colors - + Sort by P/T - + Sort by Set - + shuffle when closing - + pile view @@ -10401,7 +11211,7 @@ Please refrain from engaging in this activity or further actions may be taken ag i18n - + English @@ -10409,12 +11219,12 @@ Please refrain from engaging in this activity or further actions may be taken ag main - + Connect on startup - + Debug to file @@ -10428,7 +11238,7 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Deck Editor @@ -10509,7 +11319,7 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Replays @@ -10624,129 +11434,129 @@ Please refrain from engaging in this activity or further actions may be taken ag - - + + Load Deck from Clipboard... - + Edit Deck in Clipboard, Annotated - + Edit Deck in Clipboard - + New Deck - + Open Custom Pictures Folder - + Print Deck... - + Delete Card - - + + Reset Layout - + Save Deck - + Save Deck as... - + Save Deck to Clipboard, Annotated - + Save Deck to Clipboard, Annotated (No Set Info) - + Save Deck to Clipboard - + Save Deck to Clipboard (No Set Info) - + Load Local Deck... - + Load Remote Deck... - + Set Ready to Start - + Toggle Sideboard Lock - + Add Green Counter - + Remove Green Counter - + Set Green Counters... - + Add Red Counter - + Remove Red Counter - + Set Red Counters... - + Add Life Counter @@ -10756,724 +11566,744 @@ Please refrain from engaging in this activity or further actions may be taken ag - + + Load deck from online service... + + + + + Load from website... + + + + Unload Deck - + Force Start - + Add Card Counter (F) - + Remove Card Counter (F) - + Set Card Counters (F)... - + Add Card Counter (E) - + Remove Card Counter (E) - + Set Card Counters (E)... - + Add Card Counter(D) - + Remove Card Counter (D) - + Set Card Counters (D)... - + Add Card Counter (C) - + Remove Card Counter (C) - + Set Card Counters (C)... - + Add Card Counter (B) - + Remove Card Counter (B) - + Set Card Counters (B)... - + Add Card Counter (A) - + Remove Card Counter (A) - + Set Card Counters (A)... - + Remove Life Counter - + Set Life Counters... - + Add White Counter - + Remove White Counter - + Set White Counters... - + Add Blue Counter - + Remove Blue Counter - + Set Blue Counters... - + Add Black Counter - + Remove Black Counter - + Set Black Counters... - + Add Colorless Counter - + Remove Colorless Counter - + Set Colorless Counters... - + Add Other Counter - + Remove Other Counter - + Set Other Counters... - + Increment all card counters - + Add Power (+1/+0) - + Remove Power (-1/-0) - + Move Toughness to Power (+1/-1) - + Add Toughness (+0/+1) - + Remove Toughness (-0/-1) - + Move Power to Toughness (-1/+1) - + Add Power and Toughness (+1/+1) - + Remove Power and Toughness (-1/-1) - + Set Power and Toughness... - + Reset Power and Toughness - + Untap - + Upkeep - + Draw - + First Main Phase - + Start Combat - + Attack - + Block - + Damage - + End Combat - + Second Main Phase - + End - + Next Phase - + Next Phase Action - + Next Turn - + Hide Card in Reveal Window - + Tap / Untap Card - + Untap All - + Toggle Skip Untapping Toggle Untap - + Turn Card Over - + Peek Card - + Play Card - + Play Card, Face Down - + Attach Card... - + Unattach Card - + Clone Card - + Create Token... - + Create All Related Tokens - + Create Another Token - + Set Annotation... - + + Reduce Life by Power + + + + Select All Cards in Zone - + Select All Cards in Row - + Select All Cards in Column - + Reveal Selected Cards to All Players - - + + Bottom of Library - - - - + + + + Exile - - - - + + + + Graveyard - - + + Hand - - + + Top of Library - - + + Battlefield, Face Down - + Battlefield - + Library - + Sideboard - + Top Cards of Library - + Bottom Cards of Library - + Close Recent View - - + + Stack - - + + Graveyard (Multiple) - - + + Graveyard (Multiple), Face Down - - + + Exile (Multiple) - - + + Exile (Multiple), Face Down - + Stack Until Found - + Draw Bottom Card - + Draw Multiple Cards from Bottom... - + Draw Arrow... - + Remove Local Arrows - + Leave Game - + Concede - + Roll Dice... - + + Flip Coin + + + + Shuffle Library - + Shuffle Top Cards of Library - + Shuffle Bottom Cards of Library - + Mulligan - + Mulligan (Same hand size) - + Mulligan (Hand size - 1) - + Draw a Card - + Draw Multiple Cards... - + Undo Draw - + Always Reveal Top Card - + Always Look At Top Card - + Sort Hand by Name - + Sort Hand by Type - + Sort Hand by Mana Value - + Reveal Hand to All Players - + Reveal Random Card to All Players - + Rotate View Clockwise - + Rotate View Counterclockwise - + Unfocus Text Box - + Focus Chat - + Clear Chat - + Refresh - + Skip Forward - + Skip Backward - + Skip Forward by a lot - + Skip Backward by a lot - + Play/Pause - + Toggle Fast Forward - + Home - + Visual Deck Storage - + Deck Storage - + Server - + Account - + Administration - + Logs diff --git a/cockatrice/resources/backgrounds/home-dark.png b/cockatrice/resources/backgrounds/home-dark.png new file mode 100644 index 000000000..68f48e2c2 Binary files /dev/null and b/cockatrice/resources/backgrounds/home-dark.png differ diff --git a/cockatrice/resources/backgrounds/home-light.png b/cockatrice/resources/backgrounds/home-light.png new file mode 100644 index 000000000..eaaaba932 Binary files /dev/null and b/cockatrice/resources/backgrounds/home-light.png differ diff --git a/cockatrice/resources/backgrounds/home.png b/cockatrice/resources/backgrounds/home.png index 68f48e2c2..eaaaba932 100644 Binary files a/cockatrice/resources/backgrounds/home.png and b/cockatrice/resources/backgrounds/home.png differ diff --git a/cockatrice/resources/cockatrice-logo-white.svg b/cockatrice/resources/cockatrice-logo-white.svg new file mode 100644 index 000000000..b3b31077f --- /dev/null +++ b/cockatrice/resources/cockatrice-logo-white.svg @@ -0,0 +1,21 @@ + + + + + + + + + diff --git a/cockatrice/resources/cockatrice.svg b/cockatrice/resources/cockatrice.svg index d2e22da31..89ba62dcf 100644 --- a/cockatrice/resources/cockatrice.svg +++ b/cockatrice/resources/cockatrice.svg @@ -2,20 +2,20 @@ + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns:xlink="http://www.w3.org/1999/xlink" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:dc="http://purl.org/dc/elements/1.1/" + sodipodi:docname="cockatrice.svg" + xmlns="http://www.w3.org/2000/svg"> + inkscape:current-layer="svg2" + inkscape:showpageshadow="0" + inkscape:pagecheckerboard="0" + inkscape:deskcolor="#505050"> + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + @@ -199,7 +159,7 @@ image/svg+xml - + @@ -213,170 +173,60 @@ inkscape:export-xdpi="91.459999" inkscape:export-ydpi="91.459999"> - - - - - - - - - - - - - - diff --git a/cockatrice/resources/countries/cn.svg b/cockatrice/resources/countries/cn.svg index f510cf049..45b608127 100644 --- a/cockatrice/resources/countries/cn.svg +++ b/cockatrice/resources/countries/cn.svg @@ -52,8 +52,7 @@ id="defs8"> - + diff --git a/cockatrice/resources/help/search.md b/cockatrice/resources/help/search.md index 0c8bdb450..fd0a12507 100644 --- a/cockatrice/resources/help/search.md +++ b/cockatrice/resources/help/search.md @@ -52,6 +52,7 @@ In this list of examples below, each entry has an explanation and can be clicked
Edition:
[set:lea](#set:lea) (Cards that appear in Alpha, which has the set code LEA)
[e:lea OR e:leb](#e:lea OR e:leb) (Cards that appear in Alpha or Beta)
+
[e<8ED](#e<8ED) (Cards that appear before 8th edition)
Negate:
[c:wu -c:m](#c:wu -c:m) (Any card that is white or blue, but not multicolored)
diff --git a/cockatrice/resources/usericons/pawn_dev_double.svg b/cockatrice/resources/usericons/pawn_dev_double.svg new file mode 100644 index 000000000..57ed5c2da --- /dev/null +++ b/cockatrice/resources/usericons/pawn_dev_double.svg @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/cockatrice/resources/usericons/pawn_dev_single.svg b/cockatrice/resources/usericons/pawn_dev_single.svg new file mode 100644 index 000000000..f7c4e7018 --- /dev/null +++ b/cockatrice/resources/usericons/pawn_dev_single.svg @@ -0,0 +1,211 @@ + + + +image/svg+xml + + diff --git a/cockatrice/src/client/lag_monitor.cpp b/cockatrice/src/client/lag_monitor.cpp new file mode 100644 index 000000000..383d64644 --- /dev/null +++ b/cockatrice/src/client/lag_monitor.cpp @@ -0,0 +1,63 @@ +#include "lag_monitor.h" + +#include +#include +#include + +LagMonitor::LagMonitor(QObject *parent) : QObject(parent) +{ + qApp->installEventFilter(this); + + timer = new QTimer(this); + timer->setInterval(TICK_INTERVAL_MS); + connect(timer, &QTimer::timeout, this, &LagMonitor::checkTick); + tickClock.start(); + timer->start(); +} + +QList LagMonitor::recentStalls() const +{ + return stalls; +} + +void LagMonitor::clearStalls() +{ + stalls.clear(); +} + +bool LagMonitor::eventFilter(QObject *obj, QEvent *event) +{ + if (event->type() == QEvent::ApplicationStateChange) { + // The transition may span a suspend or an arbitrary unfocused period; + // discard the gap so it cannot be mistaken for a stall. + tickClock.restart(); + } + return QObject::eventFilter(obj, event); +} + +void LagMonitor::checkTick() +{ + recordGap(tickClock.restart()); +} + +void LagMonitor::recordGap(qint64 gapMs) +{ + if (gapMs <= STALL_THRESHOLD_MS) { + return; + } + + if (gapMs > MAX_PLAUSIBLE_STALL_MS) { + qCDebug(LagMonitorLog, "Ignoring implausible %lld ms gap (likely suspend)", static_cast(gapMs)); + return; + } + + const StallRecord record{.timestampMsSinceEpoch = QDateTime::currentMSecsSinceEpoch(), .durationMs = gapMs}; + + stalls.append(record); + while (stalls.size() > MAX_RECORDED_STALLS) { + stalls.removeFirst(); + } + + qCWarning(LagMonitorLog, "Event loop stalled for %lld ms (threshold: %d ms)", static_cast(gapMs), + STALL_THRESHOLD_MS); +} diff --git a/cockatrice/src/client/lag_monitor.h b/cockatrice/src/client/lag_monitor.h new file mode 100644 index 000000000..9fdf6b283 --- /dev/null +++ b/cockatrice/src/client/lag_monitor.h @@ -0,0 +1,87 @@ +/** + * @file lag_monitor.h + * @ingroup Client + */ + +#ifndef LAG_MONITOR_H +#define LAG_MONITOR_H + +#include +#include +#include +#include +#include + +inline Q_LOGGING_CATEGORY(LagMonitorLog, "lag_monitor"); + +class QEvent; +class QTimer; + +/** + * @brief Detects main-thread event loop stalls ("UI freezes") from the inside. + * + * A timer is expected to fire every TICK_INTERVAL_MS of wall time. When the + * observed gap greatly exceeds that interval, some other task blocked the + * event loop for roughly the overshooting duration. This is what separates + * "my client froze" from "the network is lagging" in user reports. + * + * Gaps that span an application state change (suspend, minimize, focus + * loss) are discarded, and implausibly huge gaps are dropped, so operating + * system power events do not fabricate stalls. This handling is load-bearing + * on Windows, where the monotonic clock used by Qt counts sleep time. + * + * Healthy operation costs one timer wakeup per tick and two integer + * comparisons. Allocations happen only when a stall is actually recorded. + */ +class LagMonitor : public QObject +{ + Q_OBJECT + +public: + struct StallRecord + { + qint64 timestampMsSinceEpoch = 0; ///< when the stalled period ended + qint64 durationMs = 0; ///< approximate length of the freeze; measured tick to tick, so it can exceed the true + ///< stall by up to TICK_INTERVAL_MS + }; + + static constexpr int TICK_INTERVAL_MS = 500; + static constexpr int STALL_THRESHOLD_MS = 2000; + static constexpr int MAX_RECORDED_STALLS = 32; + + /// Gaps beyond this are treated as suspend artifacts rather than stalls. + static constexpr qint64 MAX_PLAUSIBLE_STALL_MS = 600000; + + explicit LagMonitor(QObject *parent = nullptr); + + /** + * @brief Stalls recorded during this session, oldest first. + * + * Intended consumers are log output and the diagnostics export. The list + * holds at most MAX_RECORDED_STALLS entries. + */ + QList recentStalls() const; + + void clearStalls(); + + /** + * @brief Feeds a measured tick-to-tick gap through the detection logic. + * + * Split out of checkTick so threshold, plausibility, and trim behavior + * stay unit-testable without real timing. + */ + void recordGap(qint64 gapMs); + +protected: + bool eventFilter(QObject *obj, QEvent *event) override; + +private slots: + void checkTick(); + +private: + QTimer *timer; + QElapsedTimer tickClock; ///< monotonic clock, so wall clock steps do not fabricate stalls + QList stalls; +}; + +#endif diff --git a/cockatrice/src/client/latency_graph_widget.cpp b/cockatrice/src/client/latency_graph_widget.cpp new file mode 100644 index 000000000..46d6ccdd3 --- /dev/null +++ b/cockatrice/src/client/latency_graph_widget.cpp @@ -0,0 +1,51 @@ +/** + * @file latency_graph_widget.cpp + * @ingroup Client + */ + +#include "latency_graph_widget.h" + +#include + +LatencyGraphWidget::LatencyGraphWidget(QWidget *parent) : QWidget(parent) +{ +} + +void LatencyGraphWidget::setSamples(const QList &samplesMs) +{ + samples = samplesMs; + update(); +} + +void LatencyGraphWidget::paintEvent(QPaintEvent * /* event */) +{ + if (samples.isEmpty()) { + return; + } + + QPainter painter(this); + + // Heights are relative to the window's own worst sample (floored at + // MinScaleMs) so the shape of the variance stays readable even when every + // value is small. + qint64 heightScaleMs = MinScaleMs; + for (int sample : samples) { + heightScaleMs = qMax(heightScaleMs, static_cast(sample)); + } + + const qreal widthPerBar = static_cast(width()) / samples.size(); + for (int i = 0; i < samples.size(); ++i) { + const qreal heightRatio = qBound(0.0, static_cast(samples.at(i)) / heightScaleMs, 1.0); + const qreal barHeight = heightRatio * height(); + + // Colors follow an absolute quality ramp: a steady good ping stays + // green no matter how uniform the window is. + const qreal colorRatio = qBound(0.0, static_cast(samples.at(i)) / ColorScaleMs, 1.0); + QColor color; + color.setHsv(qRound(120.0 * (1.0 - colorRatio)), 255, 255); + + const QRectF bar(static_cast(i) * widthPerBar + 1.0, static_cast(height()) - barHeight, + qMax(1.0, widthPerBar - 2.0), barHeight); + painter.fillRect(bar, color); + } +} diff --git a/cockatrice/src/client/latency_graph_widget.h b/cockatrice/src/client/latency_graph_widget.h new file mode 100644 index 000000000..4f6f38f8b --- /dev/null +++ b/cockatrice/src/client/latency_graph_widget.h @@ -0,0 +1,43 @@ +/** + * @file latency_graph_widget.h + * @ingroup Client + */ + +#ifndef LATENCY_GRAPH_WIDGET_H +#define LATENCY_GRAPH_WIDGET_H + +#include +#include + +/** + * @brief Bar graph of recent network round-trip samples. + * + * Draws one bar per sample, oldest on the left. Bar height is relative to the + * window's own scale so the shape of the variance stays readable, while bar + * color maps each sample onto an absolute quality ramp (green at rest through + * red at ColorScaleMs) so a steady good ping never looks alarming. Size + * agnostic: the status bar embeds a small instance while the latency detail + * popup shows a large one. + */ +class LatencyGraphWidget : public QWidget +{ + Q_OBJECT +public: + explicit LatencyGraphWidget(QWidget *parent = nullptr); + + /// Sample in milliseconds that maps to a fully red bar. + static constexpr qint64 ColorScaleMs = 500; + + void setSamples(const QList &samplesMs); + +protected: + void paintEvent(QPaintEvent *event) override; + +private: + /// Floor of the vertical scale in milliseconds. Keeps small windows readable. + static constexpr qint64 MinScaleMs = 100; + + QList samples; +}; + +#endif diff --git a/cockatrice/src/client/latency_status_widget.cpp b/cockatrice/src/client/latency_status_widget.cpp new file mode 100644 index 000000000..779c726eb --- /dev/null +++ b/cockatrice/src/client/latency_status_widget.cpp @@ -0,0 +1,111 @@ +/** + * @file latency_status_widget.cpp + * @ingroup Client + */ + +#include "latency_status_widget.h" + +#include "latency_graph_widget.h" + +#include +#include +#include +#include + +LatencyStatusWidget::LatencyStatusWidget(QWidget *parent) : QWidget(parent) +{ + pingLabel = new QLabel(this); + pingLabel->setAccessibleName(tr("Ping")); + + latencyGraph = new LatencyGraphWidget(this); + latencyGraph->setFixedSize(90, 14); + + auto *layout = new QHBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(4); + layout->addWidget(latencyGraph); + layout->addWidget(pingLabel); + + // Clicking anywhere in the area opens the detail view. + for (QObject *child : QList{pingLabel, latencyGraph}) { + child->installEventFilter(this); + } + setCursor(Qt::PointingHandCursor); + + hide(); +} + +void LatencyStatusWidget::updateData(const LatencyTracker::Stats &stats, const QList &samplesMs) +{ + latestSamples = samplesMs; + latencyGraph->setSamples(samplesMs); + if (popup && popup->isVisible() && detailGraph) { + detailGraph->setSamples(samplesMs); + } + + if (stats.sampleCount == 0) { + hide(); + return; + } + + const QString statsStr = statsText(stats); + + pingLabel->setText(tr("Ping: %1 ms").arg(stats.lastMs)); + pingLabel->setToolTip(statsStr); + pingLabel->setAccessibleDescription(statsStr); + if (popup && popup->isVisible() && detailLabel) { + detailLabel->setText(statsStr); + } + show(); +} + +bool LatencyStatusWidget::eventFilter(QObject *watched, QEvent *event) +{ + if ((watched == pingLabel || watched == latencyGraph) && event->type() == QEvent::MouseButtonPress) { + togglePopup(); + return true; + } + return QWidget::eventFilter(watched, event); +} + +void LatencyStatusWidget::togglePopup() +{ + if (!popup) { + popup = new QWidget(this, Qt::Popup | Qt::FramelessWindowHint); + auto *layout = new QVBoxLayout(popup); + layout->setContentsMargins(8, 8, 8, 8); + + detailLabel = new QLabel(popup); + detailLabel->setAccessibleName(tr("Connection latency details")); + detailLabel->setTextInteractionFlags(Qt::TextSelectableByMouse); + + detailGraph = new LatencyGraphWidget(popup); + detailGraph->setFixedSize(280, 80); + + layout->addWidget(detailLabel, 0, Qt::AlignLeft); + layout->addWidget(detailGraph, 0, Qt::AlignHCenter); + } + + if (popup->isVisible()) { + popup->hide(); + return; + } + + // Qt::Popup closes itself on any outside click, so just position and show. + if (latestSamples.isEmpty()) { + return; + } + detailGraph->setSamples(latestSamples); + detailLabel->setText(pingLabel->toolTip()); + popup->adjustSize(); + const QPoint anchor = mapToGlobal(QPoint(width() / 2, 0)); + popup->move(anchor.x() - popup->width() / 2, anchor.y() - popup->height() - 6); + popup->show(); +} + +QString LatencyStatusWidget::statsText(const LatencyTracker::Stats &stats) const +{ + return tr("Connection quality over the last %n sample(s):", "", stats.sampleCount) + "\n" + + tr("Last: %1 ms").arg(stats.lastMs) + "\n" + tr("Median: %1 ms").arg(stats.medianMs) + "\n" + + tr("95th percentile: %1 ms").arg(stats.p95Ms) + "\n" + tr("Maximum: %1 ms").arg(stats.maxMs); +} diff --git a/cockatrice/src/client/latency_status_widget.h b/cockatrice/src/client/latency_status_widget.h new file mode 100644 index 000000000..d9e1d130c --- /dev/null +++ b/cockatrice/src/client/latency_status_widget.h @@ -0,0 +1,50 @@ +/** + * @file latency_status_widget.h + * @ingroup Client + */ + +#ifndef LATENCY_STATUS_WIDGET_H +#define LATENCY_STATUS_WIDGET_H + +#include +#include +#include + +class QLabel; +class LatencyGraphWidget; + +/** + * @brief Status bar presentation of server round-trip health. + * + * Combines the textual "Ping" readout with a small LatencyGraphWidget + * sparkline of the rolling sample window. Clicking anywhere in the area opens + * a popup with a larger graph and the numeric statistics. It closes on any + * outside click. Stays hidden while disconnected or before any samples exist. + * Owns all latency display state so MainWindow only needs to forward one + * signal here. + */ +class LatencyStatusWidget : public QWidget +{ + Q_OBJECT +public: + explicit LatencyStatusWidget(QWidget *parent = nullptr); + +public slots: + void updateData(const LatencyTracker::Stats &stats, const QList &samplesMs); + +protected: + bool eventFilter(QObject *watched, QEvent *event) override; + +private: + void togglePopup(); + QString statsText(const LatencyTracker::Stats &stats) const; + + QLabel *pingLabel = nullptr; + LatencyGraphWidget *latencyGraph = nullptr; + QWidget *popup = nullptr; + LatencyGraphWidget *detailGraph = nullptr; + QLabel *detailLabel = nullptr; + QList latestSamples; +}; + +#endif diff --git a/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp b/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp index 75cedcafc..c1598bd25 100644 --- a/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp +++ b/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp @@ -1,5 +1,6 @@ #include "remote_connection_controller.h" +#include "../../../interface/pixel_map_generator.h" #include "../../settings/cache_settings.h" #include "../interface/widgets/dialogs/dlg_connect.h" #include "../interface/widgets/dialogs/dlg_forgot_password_challenge.h" @@ -14,6 +15,7 @@ #include #include #include +#include ConnectionController::ConnectionController(QWidget *dialogParent, QObject *parent) : QObject(parent), dialogParent(dialogParent) @@ -43,6 +45,8 @@ void ConnectionController::wireClientSignals() connect(remoteClient, &RemoteClient::statusChanged, this, &ConnectionController::onStatusChanged); + connect(remoteClient, &AbstractClient::pingStatsUpdated, this, &ConnectionController::pingStatsUpdated); + connect(remoteClient, &RemoteClient::userInfoChanged, this, &ConnectionController::onUserInfoReceived, Qt::BlockingQueuedConnection); @@ -177,7 +181,7 @@ void ConnectionController::onServerShutdownEvent(const Event_ServerShutdown &eve "games will be lost.\nReason for shutdown: %1", "", event.minutes()) .arg(QString::fromStdString(event.reason()))); - serverShutdownMessageBox.setIconPixmap(QPixmap("theme:cockatrice").scaled(64, 64)); + serverShutdownMessageBox.setIconPixmap(themePixmap(QStringLiteral("cockatrice")).scaled(64, 64)); serverShutdownMessageBox.setText(tr("Scheduled server shutdown")); serverShutdownMessageBox.setWindowModality(Qt::ApplicationModal); serverShutdownMessageBox.setVisible(true); @@ -295,6 +299,15 @@ void ConnectionController::onLoginError(int r, return; } + case Response::RespPasswordChangeRequired: { + QMessageBox::information( + dialogParent, tr("Password Change Required"), + tr("An administrator has reset your password. Please contact your server administrator to obtain " + "your temporary password, then log in and change it via Account -> Change Password.")); + remoteClient->disconnectFromServer(); + return; + } + case Response::RespServerFull: { QMessageBox::critical(dialogParent, tr("Server Full"), tr("The server has reached its maximum user capacity, please check back later.")); diff --git a/cockatrice/src/client/network/connection_controller/remote_connection_controller.h b/cockatrice/src/client/network/connection_controller/remote_connection_controller.h index 7486bc81a..bae99a3e0 100644 --- a/cockatrice/src/client/network/connection_controller/remote_connection_controller.h +++ b/cockatrice/src/client/network/connection_controller/remote_connection_controller.h @@ -54,6 +54,10 @@ signals: // action enable/disable logic void statusChanged(ClientStatus status); + // Forwarded from AbstractClient::pingStatsUpdated. See that signal for the + // meaning of the parameters. + void pingStatsUpdated(const LatencyTracker::Stats &stats, const QList &samplesMs); + private slots: // Slots wired directly to RemoteClient signals void onStatusChanged(ClientStatus status); diff --git a/cockatrice/src/client/network/interfaces/deck_stats_interface.cpp b/cockatrice/src/client/network/interfaces/deck_stats_interface.cpp index 8689a19e9..42292b2aa 100644 --- a/cockatrice/src/client/network/interfaces/deck_stats_interface.cpp +++ b/cockatrice/src/client/network/interfaces/deck_stats_interface.cpp @@ -72,7 +72,7 @@ void DeckStatsInterface::copyDeckWithoutTokens(const DeckList &source, DeckList { auto copyIfNotAToken = [&destination](const auto node, const auto card) { CardInfoPtr dbCard = CardDatabaseManager::query()->getCardInfo(card->getName()); - if (dbCard && !dbCard->getIsToken()) { + if (dbCard && !dbCard->getIsToken() && node->getName() != DECK_ZONE_MAYBEBOARD) { DecklistCardNode *addedCard = destination.addCard(card->getName(), node->getName(), -1); addedCard->setNumber(card->getNumber()); } diff --git a/cockatrice/src/client/network/interfaces/tapped_out_interface.cpp b/cockatrice/src/client/network/interfaces/tapped_out_interface.cpp index 5dc77fa2c..627b7fe34 100644 --- a/cockatrice/src/client/network/interfaces/tapped_out_interface.cpp +++ b/cockatrice/src/client/network/interfaces/tapped_out_interface.cpp @@ -99,7 +99,7 @@ void TappedOutInterface::copyDeckSplitMainAndSide(const DeckList &source, DeckLi { auto copyMainOrSide = [&mainboard, &sideboard](const auto node, const auto card) { CardInfoPtr dbCard = CardDatabaseManager::query()->getCardInfo(card->getName()); - if (!dbCard || dbCard->getIsToken()) { + if (!dbCard || dbCard->getIsToken() || node->getName() == DECK_ZONE_MAYBEBOARD) { return; } diff --git a/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.cpp b/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.cpp index 7194c46e5..dae633717 100644 --- a/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.cpp +++ b/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.cpp @@ -14,6 +14,8 @@ #include #include #include +#include +#include #include #define SPOILERS_STATUS_URL "https://raw.githubusercontent.com/Cockatrice/Magic-Spoiler/files/SpoilerSeasonEnabled" @@ -21,7 +23,7 @@ SpoilerBackgroundUpdater::SpoilerBackgroundUpdater(QObject *apParent) : QObject(apParent), cardUpdateProcess(nullptr) { - isSpoilerDownloadEnabled = SettingsCache::instance().getDownloadSpoilersStatus(); + isSpoilerDownloadEnabled = SettingsCache::instance().downloads().getDownloadSpoilersStatus(); if (isSpoilerDownloadEnabled) { // Start the process of checking if we're in spoiler season // File exists means we're in spoiler season @@ -75,7 +77,7 @@ void SpoilerBackgroundUpdater::actDownloadFinishedSpoilersFile() bool SpoilerBackgroundUpdater::deleteSpoilerFile() { - QString fileName = SettingsCache::instance().getSpoilerCardDatabasePath(); + QString fileName = SettingsCache::instance().paths().getSpoilerCardDatabasePath(); QFileInfo fi(fileName); QDir fileDir(fi.path()); QFile file(fileName); @@ -126,7 +128,7 @@ void SpoilerBackgroundUpdater::actCheckIfSpoilerSeasonEnabled() bool SpoilerBackgroundUpdater::saveDownloadedFile(QByteArray data) { - QString fileName = SettingsCache::instance().getSpoilerCardDatabasePath(); + QString fileName = SettingsCache::instance().paths().getSpoilerCardDatabasePath(); QFileInfo fi(fileName); QDir fileDir(fi.path()); diff --git a/cockatrice/src/client/settings/cache_settings.cpp b/cockatrice/src/client/settings/cache_settings.cpp index cc34e1707..eaa5d96ac 100644 --- a/cockatrice/src/client/settings/cache_settings.cpp +++ b/cockatrice/src/client/settings/cache_settings.cpp @@ -1,20 +1,41 @@ #include "cache_settings.h" -#include "../../interface/card_picture_loader/card_picture_loader_cache_method.h" -#include "../../interface/card_picture_loader/card_picture_loader_local_schemes.h" #include "../network/update/client/release_channel.h" #include "card_counter_settings.h" +#include "shortcuts_settings.h" #include "version_string.h" -#include -#include #include #include #include #include #include #include +#include +#include +#include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include Q_GLOBAL_STATIC(SettingsCache, settingsCache) @@ -52,104 +73,6 @@ QString SettingsCache::getNetworkCachePath() const return getCachePath() + "/downloaded/"; } -void SettingsCache::translateLegacySettings() -{ - if (isPortableBuild) { - return; - } - - // Layouts - QFile layoutFile(getSettingsPath() + "layouts/deckLayout.ini"); - if (layoutFile.exists()) { - if (layoutFile.copy(getSettingsPath() + "layouts.ini")) { - layoutFile.remove(); - } - } - - QStringList usedKeys; - QSettings legacySetting; - - // Sets - legacySetting.beginGroup("sets"); - QStringList setsGroups = legacySetting.childGroups(); - for (int i = 0; i < setsGroups.size(); i++) { - legacySetting.beginGroup(setsGroups.at(i)); - cardDatabase()->setEnabled(setsGroups.at(i), legacySetting.value("enabled").toBool()); - cardDatabase()->setIsKnown(setsGroups.at(i), legacySetting.value("isknown").toBool()); - cardDatabase()->setSortKey(setsGroups.at(i), legacySetting.value("sortkey").toUInt()); - legacySetting.endGroup(); - } - QStringList setsKeys = legacySetting.allKeys(); - for (int i = 0; i < setsKeys.size(); ++i) { - usedKeys.append("sets/" + setsKeys.at(i)); - } - legacySetting.endGroup(); - - // Servers - legacySetting.beginGroup("server"); - servers().setPreviousHostLogin(legacySetting.value("previoushostlogin").toInt()); - servers().setPreviousHostList(legacySetting.value("previoushosts").toStringList()); - servers().setAutoConnect(legacySetting.value("auto_connect").toInt()); - servers().setFPHostName(legacySetting.value("fphostname").toString()); - servers().setFPPort(legacySetting.value("fpport").toString()); - servers().setFPPlayerName(legacySetting.value("fpplayername").toString()); - usedKeys.append(legacySetting.allKeys()); - QStringList allKeysServer = legacySetting.allKeys(); - for (int i = 0; i < allKeysServer.size(); ++i) { - usedKeys.append("server/" + allKeysServer.at(i)); - } - legacySetting.endGroup(); - - // Messages - legacySetting.beginGroup("messages"); - QStringList allMessages = legacySetting.allKeys(); - for (int i = 0; i < allMessages.size(); ++i) { - if (allMessages.at(i) != "count") { - QString temp = allMessages.at(i); - int index = temp.remove("msg").toInt(); - messages().setMessageAt(index, legacySetting.value(allMessages.at(i)).toString()); - } - } - messages().setCount(legacySetting.value("count").toInt()); - QStringList allKeysmessages = legacySetting.allKeys(); - for (int i = 0; i < allKeysmessages.size(); ++i) { - usedKeys.append("messages/" + allKeysmessages.at(i)); - } - legacySetting.endGroup(); - - // Game filters - legacySetting.beginGroup("filter_games"); - gameFilters().setGameNameFilter(legacySetting.value("game_name_filter").toString()); - gameFilters().setHideIgnoredUserGames(legacySetting.value("hide_ignored_user_games").toBool()); - gameFilters().setMinPlayers(legacySetting.value("min_players").toInt()); - - if (legacySetting.value("max_players").toInt() > 1) { - gameFilters().setMaxPlayers(legacySetting.value("max_players").toInt()); - } else { - gameFilters().setMaxPlayers(99); // This prevents a bug where no games will show if max was not set before - } - - QStringList allFilters = legacySetting.allKeys(); - for (int i = 0; i < allFilters.size(); ++i) { - if (allFilters.at(i).startsWith("game_type")) { - gameFilters().setGameHashedTypeEnabled(allFilters.at(i), legacySetting.value(allFilters.at(i)).toBool()); - } - } - QStringList allKeysfilter_games = legacySetting.allKeys(); - for (int i = 0; i < allKeysfilter_games.size(); ++i) { - usedKeys.append("filter_games/" + allKeysfilter_games.at(i)); - } - legacySetting.endGroup(); - - QStringList allLegacyKeys = legacySetting.allKeys(); - for (int i = 0; i < allLegacyKeys.size(); ++i) { - if (usedKeys.contains(allLegacyKeys.at(i))) { - continue; - } - settings->setValue(allLegacyKeys.at(i), legacySetting.value(allLegacyKeys.at(i))); - } -} - QString SettingsCache::getSafeConfigPath(QString configEntry, QString defaultPath) const { QString tmp = settings->value(configEntry).toString(); @@ -183,11 +106,18 @@ SettingsCache::SettingsCache() qCInfo(SettingsCacheLog) << "Portable mode enabled"; } - // define a dummy context that will be used where needed - QString dummy = QT_TRANSLATE_NOOP("i18n", "English"); - QString settingsPath = getSettingsPath(); + settings = new QSettings(settingsPath + "global.ini", QSettings::IniFormat, this); + + // Migrate from legacy NativeFormat settings to per-class INI files (runs at most once) + if (!isPortableBuild) { + SettingsMigration::migrateLegacySettings(settingsPath); + } + + // Migrate settings from global.ini to per-class INI files (runs at most once) + SettingsMigration::migrateSettingsFromGlobalIni(settingsPath); + shortcutsSettings = new ShortcutsSettings(settingsPath, this); cardDatabaseSettings = new CardDatabaseSettings(settingsPath, this); serversSettings = new ServersSettings(settingsPath, this); @@ -198,988 +128,51 @@ SettingsCache::SettingsCache() recentsSettings = new RecentsSettings(settingsPath, this); cardOverrideSettings = new CardOverrideSettings(settingsPath, this); debugSettings = new DebugSettings(settingsPath, this); - cardCounterSettings = new CardCounterSettings(settingsPath, this); - if (!QFile(settingsPath + "global.ini").exists()) { - translateLegacySettings(); - } + tabsSettings = new TabsSettings(settingsPath, this); + soundSettings = new SoundSettings(settingsPath, this); + gameSettings = new GameSettings(settingsPath, this); + chatSettings = new ChatSettings(settingsPath, this); + cacheStorageSettings = new CacheStorageSettings(settingsPath, this); + updatesSettings = new UpdatesSettings(settingsPath, this); + personalSettings = new PersonalSettings(settingsPath, this); + cardsDisplaySettings = new CardsDisplaySettings(settingsPath, this); + interfaceSettings = new InterfaceSettings(settingsPath, this); + deckEditorSettings = new DeckEditorSettings(settingsPath, this); + pathsSettings = new PathsSettings(settingsPath, this); + visualDeckStorageSettings = new VisualDeckStorageSettings(settingsPath, this); + appearanceSettings = new AppearanceSettings(settingsPath, this); + networkSettings = new NetworkSettings(settingsPath, this); + commanderBracketSettings = new CommanderBracketSettings(settingsPath, this); + + // Forward ICardDatabasePathProvider signal from PathsSettings + connect(pathsSettings, &PathsSettings::cardDatabasePathChanged, this, + &ICardDatabasePathProvider::cardDatabasePathChanged); // updates - don't reorder them or their index in the settings won't match // append channels one by one, or msvc will add them in the wrong order. releaseChannels << new StableReleaseChannel(); releaseChannels << new BetaReleaseChannel(); - mbDownloadSpoilers = settings->value("personal/downloadspoilers", false).toBool(); + themeName = appearanceSettings->getThemeName(); - if (settings->contains("personal/startupUpdateCheck")) { - checkUpdatesOnStartup = settings->value("personal/startupUpdateCheck", true).toBool(); - } else if (QString(VERSION_STRING).contains("custom", Qt::CaseInsensitive)) { - checkUpdatesOnStartup = false; // do not run auto updater on custom version - } else { - checkUpdatesOnStartup = true; // default to run auto updater - } - startupCardUpdateCheckPromptForUpdate = - settings->value("personal/startupCardUpdateCheckPromptForUpdate", true).toBool(); - startupCardUpdateCheckAlwaysUpdate = settings->value("personal/startupCardUpdateCheckAlwaysUpdate", false).toBool(); - cardUpdateCheckInterval = settings->value("personal/cardUpdateCheckInterval", 7).toInt(); - lastCardUpdateCheck = settings->value("personal/lastCardUpdateCheck", QDateTime::currentDateTime().date()).toDate(); - alwaysEnableNewSets = settings->value("personal/alwaysEnableNewSets", false).toBool(); - notifyAboutUpdates = settings->value("personal/updatenotification", true).toBool(); - notifyAboutNewVersion = settings->value("personal/newversionnotification", true).toBool(); - - if (settings->contains("personal/updatereleasechannel")) { - updateReleaseChannel = settings->value("personal/updatereleasechannel").toInt(); - } else if (QString(VERSION_STRING).contains("beta", Qt::CaseInsensitive)) { - // default to beta if this is a beta release - updateReleaseChannel = 1; - } else { - updateReleaseChannel = 0; // stable - } - - lang = settings->value("personal/lang").toString(); - keepalive = settings->value("personal/keepalive", 3).toInt(); - timeout = settings->value("personal/timeout", 5).toInt(); - - // tip of the day settings - showTipsOnStartup = settings->value("tipOfDay/showTips", true).toBool(); - for (const auto &tipNumber : settings->value("tipOfDay/seenTips").toList()) { - seenTips.append(tipNumber.toInt()); + auto definitions = commanderBracketSettings->loadDefinitions(); + if (definitions.isEmpty()) { + definitions = CommanderBracketSettings::defaultDefinitions(); } + commanderBracketSettings->reloadDefinitions(definitions); loadPaths(); - - themeName = settings->value("theme/name").toString(); - - homeTabBackgroundSource = settings->value("home/background", "themed").toString(); - homeTabBackgroundShuffleFrequency = settings->value("home/background/shuffleTimer", 0).toInt(); - homeTabDisplayCardName = settings->value("home/background/displayCardName", true).toBool(); - - tabVisualDeckStorageOpen = settings->value("tabs/visualDeckStorage", true).toBool(); - tabServerOpen = settings->value("tabs/server", true).toBool(); - tabAccountOpen = settings->value("tabs/account", true).toBool(); - tabDeckStorageOpen = settings->value("tabs/deckStorage", true).toBool(); - tabReplaysOpen = settings->value("tabs/replays", true).toBool(); - tabAdminOpen = settings->value("tabs/admin", true).toBool(); - tabLogOpen = settings->value("tabs/log", true).toBool(); - - // we only want to reset the cache once, then its up to the user - bool updateCache = settings->value("revert/pixmapCacheSize", false).toBool(); - if (!updateCache) { - pixmapCacheSize = PIXMAPCACHE_SIZE_DEFAULT; - settings->setValue("personal/pixmapCacheSize", pixmapCacheSize); - settings->setValue("personal/picturedownloadhq", false); - settings->setValue("revert/pixmapCacheSize", true); - } else { - pixmapCacheSize = settings->value("personal/pixmapCacheSize", PIXMAPCACHE_SIZE_DEFAULT).toInt(); - } - // sanity check - if (pixmapCacheSize < PIXMAPCACHE_SIZE_MIN || pixmapCacheSize > PIXMAPCACHE_SIZE_MAX) { - pixmapCacheSize = PIXMAPCACHE_SIZE_DEFAULT; - } - - networkCacheSize = settings->value("personal/networkCacheSize", NETWORK_CACHE_SIZE_DEFAULT).toInt(); - redirectCacheTtl = settings->value("personal/redirectCacheTtl", NETWORK_REDIRECT_CACHE_TTL_DEFAULT).toInt(); - cardPictureLoaderCacheMethod = - settings - ->value("personal/cardPictureLoaderCacheMethod", - static_cast(CardPictureLoaderCacheMethod::CacheMethod::NETWORK_CACHE)) - .toInt(); - localCardImageStorageNamingScheme = - settings - ->value("personal/localCardImageStorageNamingScheme", - static_cast(CardPictureLoaderLocalSchemes::NamingScheme::Set_Folder_Name_Set_Collector)) - .toInt(); - - picDownload = settings->value("personal/picturedownload", true).toBool(); - showStatusBar = settings->value("personal/showStatusBar", false).toBool(); - - notificationsEnabled = settings->value("interface/notificationsenabled", true).toBool(); - spectatorNotificationsEnabled = settings->value("interface/specnotificationsenabled", false).toBool(); - buddyConnectNotificationsEnabled = settings->value("interface/buddyconnectnotificationsenabled", true).toBool(); - doubleClickToPlay = settings->value("interface/doubleclicktoplay", true).toBool(); - clickPlaysAllSelected = settings->value("interface/clickPlaysAllSelected", true).toBool(); - playToStack = settings->value("interface/playtostack", true).toBool(); - doNotDeleteArrowsInSubPhases = settings->value("interface/doNotDeleteArrowsInSubPhases", true).toBool(); - startingHandSize = settings->value("interface/startinghandsize", 7).toInt(); - annotateTokens = settings->value("interface/annotatetokens", false).toBool(); - knownMissingFeatures = settings->value("interface/knownmissingfeatures", "").toString(); - useTearOffMenus = settings->value("interface/usetearoffmenus", true).toBool(); - cardViewInitialRowsMax = settings->value("interface/cardViewInitialRowsMax", 14).toInt(); - cardViewExpandedRowsMax = settings->value("interface/cardViewExpandedRowsMax", 20).toInt(); - closeEmptyCardView = settings->value("interface/closeEmptyCardView", true).toBool(); - focusCardViewSearchBar = settings->value("interface/focusCardViewSearchBar", true).toBool(); - keepGameChatFocus = settings->value("interface/keepGameChatFocus", false).toBool(); - - showDragSelectionCount = settings->value("interface/showlassoselectioncount", true).toBool(); - showTotalSelectionCount = settings->value("interface/showpersistentselectioncount", true).toBool(); - - showShortcuts = settings->value("menu/showshortcuts", true).toBool(); - showGameSelectorFilterToolbar = settings->value("menu/showgameselectorfiltertoolbar", true).toBool(); - displayCardNames = settings->value("cards/displaycardnames", true).toBool(); - roundCardCorners = settings->value("cards/roundcardcorners", true).toBool(); - overrideAllCardArtWithPersonalPreference = - settings->value("cards/overrideallcardartwithpersonalpreference", false).toBool(); - bumpSetsWithCardsInDeckToTop = settings->value("cards/bumpsetswithcardsindecktotop", true).toBool(); - printingSelectorSortOrder = settings->value("cards/printingselectorsortorder", 1).toInt(); - printingSelectorCardSize = settings->value("cards/printingselectorcardsize", 100).toInt(); - includeRebalancedCards = settings->value("cards/includerebalancedcards", true).toBool(); - printingSelectorNavigationButtonsVisible = - settings->value("cards/printingselectornavigationbuttonsvisible", true).toBool(); - deckEditorBannerCardComboBoxVisible = - settings->value("interface/deckeditorbannercardcomboboxvisible", true).toBool(); - deckEditorTagsWidgetVisible = settings->value("interface/deckeditortagswidgetvisible", true).toBool(); - visualDeckStorageCardSize = settings->value("interface/visualdeckstoragecardsize", 100).toInt(); - visualDeckStorageSortingOrder = settings->value("interface/visualdeckstoragesortingorder", 0).toInt(); - visualDeckStorageShowFolders = settings->value("interface/visualdeckstorageshowfolders", true).toBool(); - visualDeckStorageShowTagFilter = settings->value("interface/visualdeckstorageshowtagfilter", true).toBool(); - visualDeckStorageDefaultTagsList = - settings->value("interface/visualdeckstoragedefaulttagslist", defaultTags).toStringList(); - visualDeckStorageSearchFolderNames = settings->value("interface/visualdeckstoragesearchfoldernames", true).toBool(); - visualDeckStorageShowColorIdentity = settings->value("interface/visualdeckstorageshowcoloridentity", true).toBool(); - visualDeckStorageShowBannerCardComboBox = - settings->value("interface/visualdeckstorageshowbannercardcombobox", true).toBool(); - visualDeckStorageShowTagsOnDeckPreviews = - settings->value("interface/visualdeckstorageshowtagsondeckpreviews", true).toBool(); - visualDeckStorageDrawUnusedColorIdentities = - settings->value("interface/visualdeckstoragedrawunusedcoloridentities", true).toBool(); - visualDeckStorageUnusedColorIdentitiesOpacity = - settings->value("interface/visualdeckstorageunusedcoloridentitiesopacity", 15).toInt(); - visualDeckStorageTooltipType = settings->value("interface/visualdeckstoragetooltiptype", 0).toInt(); - visualDeckStoragePromptForConversion = - settings->value("interface/visualdeckstoragepromptforconversion", true).toBool(); - visualDeckStorageAlwaysConvert = settings->value("interface/visualdeckstoragealwaysconvert", false).toBool(); - visualDeckStorageInGame = settings->value("interface/visualdeckstorageingame", true).toBool(); - visualDeckStorageSelectionAnimation = - settings->value("interface/visualdeckstorageselectionanimation", true).toBool(); - defaultDeckEditorType = settings->value("interface/defaultDeckEditorType", 1).toInt(); - visualDatabaseDisplayFilterToMostRecentSetsEnabled = - settings->value("interface/visualdatabasedisplayfiltertomostrecentsetsenabled", false).toBool(); - visualDatabaseDisplayFilterToMostRecentSetsAmount = - settings->value("interface/visualdatabasedisplayfiltertomostrecentsetsamount", 10).toInt(); - visualDeckEditorSampleHandSize = settings->value("interface/visualdeckeditorsamplehandsize", 7).toInt(); - visualDeckEditorCardSize = settings->value("interface/visualdeckeditorcardsize", 100).toInt(); - visualDatabaseDisplayCardSize = settings->value("interface/visualdatabasedisplaycardsize", 100).toInt(); - edhrecCardSize = settings->value("interface/edhreccardsize", 100).toInt(); - archidektPreviewSize = settings->value("interface/archidektpreviewsize", 100).toInt(); - - horizontalHand = settings->value("hand/horizontal", true).toBool(); - invertVerticalCoordinate = settings->value("table/invert_vertical", false).toBool(); - minPlayersForMultiColumnLayout = settings->value("interface/min_players_multicolumn", 4).toInt(); - tapAnimation = settings->value("cards/tapanimation", true).toBool(); - autoRotateSidewaysLayoutCards = settings->value("cards/autorotatesidewayslayoutcards", true).toBool(); - - openDeckInNewTab = settings->value("editor/openDeckInNewTab", false).toBool(); - rewindBufferingMs = settings->value("replay/rewindBufferingMs", 200).toInt(); - chatMention = settings->value("chat/mention", true).toBool(); - chatMentionCompleter = settings->value("chat/mentioncompleter", true).toBool(); - chatMentionForeground = settings->value("chat/mentionforeground", true).toBool(); - chatHighlightForeground = settings->value("chat/highlightforeground", true).toBool(); - chatMentionColor = settings->value("chat/mentioncolor", "A6120D").toString(); - chatHighlightColor = settings->value("chat/highlightcolor", "A6120D").toString(); - - zoneViewGroupByIndex = settings->value("zoneview/groupby", 1).toInt(); - zoneViewSortByIndex = settings->value("zoneview/sortby", 1).toInt(); - zoneViewPileView = settings->value("zoneview/pileview", true).toBool(); - - soundEnabled = settings->value("sound/enabled", false).toBool(); - soundThemeName = settings->value("sound/theme").toString(); - - maxFontSize = settings->value("game/maxfontsize", DEFAULT_FONT_SIZE).toInt(); - - ignoreUnregisteredUsers = settings->value("chat/ignore_unregistered", false).toBool(); - ignoreUnregisteredUserMessages = settings->value("chat/ignore_unregistered_messages", false).toBool(); - ignoreNonBuddyUserMessages = settings->value("chat/ignore_nonbuddy_messages", false).toBool(); - - scaleCards = settings->value("cards/scaleCards", true).toBool(); - verticalCardOverlapPercent = settings->value("cards/verticalCardOverlapPercent", 33).toInt(); - showMessagePopups = settings->value("chat/showmessagepopups", true).toBool(); - showMentionPopups = settings->value("chat/showmentionpopups", true).toBool(); - roomHistory = settings->value("chat/roomhistory", true).toBool(); - - leftJustified = settings->value("interface/leftjustified", false).toBool(); - - masterVolume = settings->value("sound/mastervolume", 100).toInt(); - - cardInfoViewMode = settings->value("cards/cardinfoviewmode", 0).toInt(); - highlightWords = settings->value("personal/highlightWords", QString()).toString(); - gameDescription = settings->value("game/gamedescription", "").toString(); - maxPlayers = settings->value("game/maxplayers", 2).toInt(); - gameTypes = settings->value("game/gametypes", "").toString(); - onlyBuddies = settings->value("game/onlybuddies", false).toBool(); - onlyRegistered = settings->value("game/onlyregistered", true).toBool(); - spectatorsAllowed = settings->value("game/spectatorsallowed", true).toBool(); - spectatorsNeedPassword = settings->value("game/spectatorsneedpassword", false).toBool(); - spectatorsCanTalk = settings->value("game/spectatorscantalk", false).toBool(); - spectatorsCanSeeEverything = settings->value("game/spectatorscanseeeverything", false).toBool(); - createGameAsSpectator = settings->value("game/creategameasspectator", false).toBool(); - defaultStartingLifeTotal = settings->value("game/defaultstartinglifetotal", 20).toInt(); - shareDecklistsOnLoad = settings->value("game/sharedecklistsonload", false).toBool(); - rememberGameSettings = settings->value("game/remembergamesettings", true).toBool(); - - // Local game settings use "localgameoptions/" prefix to keep them separate - // from server game settings which use "game/" prefix - localGameRememberSettings = settings->value("localgameoptions/remembersettings", false).toBool(); - localGameMaxPlayers = settings->value("localgameoptions/maxplayers", 1).toInt(); - localGameStartingLifeTotal = settings->value("localgameoptions/startinglifetotal", 20).toInt(); - - clientID = settings->value("personal/clientid", CLIENT_INFO_NOT_SET).toString(); - clientVersion = settings->value("personal/clientversion", CLIENT_INFO_NOT_SET).toString(); -} - -void SettingsCache::setUseTearOffMenus(bool _useTearOffMenus) -{ - useTearOffMenus = _useTearOffMenus; - settings->setValue("interface/usetearoffmenus", useTearOffMenus); - emit useTearOffMenusChanged(useTearOffMenus); -} - -void SettingsCache::setCardViewInitialRowsMax(int _cardViewInitialRowsMax) -{ - cardViewInitialRowsMax = _cardViewInitialRowsMax; - settings->setValue("interface/cardViewInitialRowsMax", cardViewInitialRowsMax); -} - -void SettingsCache::setCardViewExpandedRowsMax(int value) -{ - cardViewExpandedRowsMax = value; - settings->setValue("interface/cardViewExpandedRowsMax", cardViewExpandedRowsMax); -} - -void SettingsCache::setCloseEmptyCardView(QT_STATE_CHANGED_T value) -{ - closeEmptyCardView = value; - settings->setValue("interface/closeEmptyCardView", closeEmptyCardView); -} - -void SettingsCache::setFocusCardViewSearchBar(QT_STATE_CHANGED_T value) -{ - focusCardViewSearchBar = value; - settings->setValue("interface/focusCardViewSearchBar", focusCardViewSearchBar); -} - -void SettingsCache::setKeepGameChatFocus(QT_STATE_CHANGED_T value) -{ - keepGameChatFocus = value; - settings->setValue("interface/keepGameChatFocus", keepGameChatFocus); - emit keepGameChatFocusChanged(keepGameChatFocus); -} - -void SettingsCache::setKnownMissingFeatures(const QString &_knownMissingFeatures) -{ - knownMissingFeatures = _knownMissingFeatures; - settings->setValue("interface/knownmissingfeatures", knownMissingFeatures); -} - -void SettingsCache::setCardInfoViewMode(const int _viewMode) -{ - cardInfoViewMode = _viewMode; - settings->setValue("cards/cardinfoviewmode", cardInfoViewMode); -} - -void SettingsCache::setHighlightWords(const QString &_highlightWords) -{ - highlightWords = _highlightWords; - settings->setValue("personal/highlightWords", highlightWords); -} - -void SettingsCache::setMasterVolume(int _masterVolume) -{ - masterVolume = _masterVolume; - settings->setValue("sound/mastervolume", masterVolume); - emit masterVolumeChanged(masterVolume); -} - -void SettingsCache::setLeftJustified(const QT_STATE_CHANGED_T _leftJustified) -{ - leftJustified = (bool)_leftJustified; - settings->setValue("interface/leftjustified", leftJustified); - emit handJustificationChanged(); -} - -void SettingsCache::setCardScaling(const QT_STATE_CHANGED_T _scaleCards) -{ - scaleCards = (bool)_scaleCards; - settings->setValue("cards/scaleCards", scaleCards); -} - -void SettingsCache::setStackCardOverlapPercent(const int _verticalCardOverlapPercent) -{ - verticalCardOverlapPercent = _verticalCardOverlapPercent; - settings->setValue("cards/verticalCardOverlapPercent", verticalCardOverlapPercent); -} - -void SettingsCache::setShowMessagePopups(const QT_STATE_CHANGED_T _showMessagePopups) -{ - showMessagePopups = (bool)_showMessagePopups; - settings->setValue("chat/showmessagepopups", showMessagePopups); -} - -void SettingsCache::setShowMentionPopups(const QT_STATE_CHANGED_T _showMentionPopus) -{ - showMentionPopups = (bool)_showMentionPopus; - settings->setValue("chat/showmentionpopups", showMentionPopups); -} - -void SettingsCache::setRoomHistory(const QT_STATE_CHANGED_T _roomHistory) -{ - roomHistory = (bool)_roomHistory; - settings->setValue("chat/roomhistory", roomHistory); -} - -void SettingsCache::setLang(const QString &_lang) -{ - lang = _lang; - settings->setValue("personal/lang", lang); - emit langChanged(); -} - -void SettingsCache::setShowTipsOnStartup(bool _showTipsOnStartup) -{ - showTipsOnStartup = _showTipsOnStartup; - settings->setValue("tipOfDay/showTips", showTipsOnStartup); -} - -void SettingsCache::setSeenTips(const QList &_seenTips) -{ - seenTips = _seenTips; - QList storedTipList; - for (auto tipNumber : seenTips) { - storedTipList.append(tipNumber); - } - settings->setValue("tipOfDay/seenTips", storedTipList); -} - -void SettingsCache::setDeckPath(const QString &_deckPath) -{ - deckPath = _deckPath; - settings->setValue("paths/decks", deckPath); -} - -void SettingsCache::setFiltersPath(const QString &_filtersPath) -{ - filtersPath = _filtersPath; - settings->setValue("paths/filters", filtersPath); -} - -void SettingsCache::setReplaysPath(const QString &_replaysPath) -{ - replaysPath = _replaysPath; - settings->setValue("paths/replays", replaysPath); -} - -void SettingsCache::setThemesPath(const QString &_themesPath) -{ - themesPath = _themesPath; - settings->setValue("paths/themes", themesPath); - emit themeChanged(); -} - -void SettingsCache::setCustomCardDatabasePath(const QString &_customCardDatabasePath) -{ - customCardDatabasePath = _customCardDatabasePath; - settings->setValue("paths/customsets", customCardDatabasePath); - emit cardDatabasePathChanged(); -} - -void SettingsCache::setPicsPath(const QString &_picsPath) -{ - picsPath = _picsPath; - settings->setValue("paths/pics", picsPath); - // get a new value for customPicsPath, currently derived from picsPath - if (picsPath.endsWith("/")) { - customPicsPath = getSafeConfigPath("paths/custompics", picsPath + "CUSTOM/"); - } else { - customPicsPath = getSafeConfigPath("paths/custompics", picsPath + "/CUSTOM/"); - } - emit picsPathChanged(); -} - -void SettingsCache::setCardDatabasePath(const QString &_cardDatabasePath) -{ - cardDatabasePath = _cardDatabasePath; - settings->setValue("paths/carddatabase", cardDatabasePath); - emit cardDatabasePathChanged(); -} - -void SettingsCache::setSpoilerDatabasePath(const QString &_spoilerDatabasePath) -{ - spoilerDatabasePath = _spoilerDatabasePath; - settings->setValue("paths/spoilerdatabase", spoilerDatabasePath); - emit cardDatabasePathChanged(); -} - -void SettingsCache::setTokenDatabasePath(const QString &_tokenDatabasePath) -{ - tokenDatabasePath = _tokenDatabasePath; - settings->setValue("paths/tokendatabase", tokenDatabasePath); - emit cardDatabasePathChanged(); } void SettingsCache::setThemeName(const QString &_themeName) { themeName = _themeName; - settings->setValue("theme/name", themeName); + appearanceSettings->setThemeName(themeName); emit themeChanged(); } -void SettingsCache::setHomeTabBackgroundSource(const QString &_backgroundSource) -{ - homeTabBackgroundSource = _backgroundSource; - settings->setValue("home/background", homeTabBackgroundSource); - emit homeTabBackgroundSourceChanged(); -} - -void SettingsCache::setHomeTabBackgroundShuffleFrequency(int _frequency) -{ - homeTabBackgroundShuffleFrequency = _frequency; - settings->setValue("home/background/shuffleTimer", homeTabBackgroundShuffleFrequency); - emit homeTabBackgroundShuffleFrequencyChanged(); -} - -void SettingsCache::setHomeTabDisplayCardName(QT_STATE_CHANGED_T _displayCardName) -{ - homeTabDisplayCardName = static_cast(_displayCardName); - settings->setValue("home/background/displayCardName", homeTabDisplayCardName); - emit homeTabDisplayCardNameChanged(); -} - -void SettingsCache::setTabVisualDeckStorageOpen(bool value) -{ - tabVisualDeckStorageOpen = value; - settings->setValue("tabs/visualDeckStorage", tabVisualDeckStorageOpen); -} - -void SettingsCache::setTabServerOpen(bool value) -{ - tabServerOpen = value; - settings->setValue("tabs/server", tabServerOpen); -} - -void SettingsCache::setTabAccountOpen(bool value) -{ - tabAccountOpen = value; - settings->setValue("tabs/account", tabAccountOpen); -} - -void SettingsCache::setTabDeckStorageOpen(bool value) -{ - tabDeckStorageOpen = value; - settings->setValue("tabs/deckStorage", tabDeckStorageOpen); -} - -void SettingsCache::setTabReplaysOpen(bool value) -{ - tabReplaysOpen = value; - settings->setValue("tabs/replays", tabReplaysOpen); -} - -void SettingsCache::setTabAdminOpen(bool value) -{ - tabAdminOpen = value; - settings->setValue("tabs/admin", tabAdminOpen); -} - -void SettingsCache::setTabLogOpen(bool value) -{ - tabLogOpen = value; - settings->setValue("tabs/log", tabLogOpen); -} - -void SettingsCache::setPicDownload(QT_STATE_CHANGED_T _picDownload) -{ - picDownload = static_cast(_picDownload); - settings->setValue("personal/picturedownload", picDownload); - emit picDownloadChanged(); -} - -void SettingsCache::setShowStatusBar(bool value) -{ - showStatusBar = value; - settings->setValue("personal/showStatusBar", showStatusBar); - emit showStatusBarChanged(value); -} - -void SettingsCache::setNotificationsEnabled(QT_STATE_CHANGED_T _notificationsEnabled) -{ - notificationsEnabled = static_cast(_notificationsEnabled); - settings->setValue("interface/notificationsenabled", notificationsEnabled); -} - -void SettingsCache::setSpectatorNotificationsEnabled(QT_STATE_CHANGED_T _spectatorNotificationsEnabled) -{ - spectatorNotificationsEnabled = static_cast(_spectatorNotificationsEnabled); - settings->setValue("interface/specnotificationsenabled", spectatorNotificationsEnabled); -} - -void SettingsCache::setBuddyConnectNotificationsEnabled(QT_STATE_CHANGED_T _buddyConnectNotificationsEnabled) -{ - buddyConnectNotificationsEnabled = static_cast(_buddyConnectNotificationsEnabled); - settings->setValue("interface/buddyconnectnotificationsenabled", buddyConnectNotificationsEnabled); -} - -void SettingsCache::setDoubleClickToPlay(QT_STATE_CHANGED_T _doubleClickToPlay) -{ - doubleClickToPlay = static_cast(_doubleClickToPlay); - settings->setValue("interface/doubleclicktoplay", doubleClickToPlay); -} - -void SettingsCache::setClickPlaysAllSelected(QT_STATE_CHANGED_T _clickPlaysAllSelected) -{ - clickPlaysAllSelected = static_cast(_clickPlaysAllSelected); - settings->setValue("interface/clickPlaysAllSelected", clickPlaysAllSelected); -} - -void SettingsCache::setPlayToStack(QT_STATE_CHANGED_T _playToStack) -{ - playToStack = static_cast(_playToStack); - settings->setValue("interface/playtostack", playToStack); -} - -void SettingsCache::setDoNotDeleteArrowsInSubPhases(QT_STATE_CHANGED_T _doNotDeleteArrowsInSubPhases) -{ - doNotDeleteArrowsInSubPhases = static_cast(_doNotDeleteArrowsInSubPhases); - settings->setValue("interface/doNotDeleteArrowsInSubPhases", doNotDeleteArrowsInSubPhases); -} - -void SettingsCache::setStartingHandSize(int _startingHandSize) -{ - startingHandSize = _startingHandSize; - settings->setValue("interface/startinghandsize", startingHandSize); -} - -void SettingsCache::setAnnotateTokens(QT_STATE_CHANGED_T _annotateTokens) -{ - annotateTokens = static_cast(_annotateTokens); - settings->setValue("interface/annotatetokens", annotateTokens); -} - -void SettingsCache::setShowShortcuts(QT_STATE_CHANGED_T _showShortcuts) -{ - showShortcuts = static_cast(_showShortcuts); - settings->setValue("menu/showshortcuts", showShortcuts); -} - -void SettingsCache::setShowGameSelectorFilterToolbar(QT_STATE_CHANGED_T _showGameSelectorFilterToolbar) -{ - showGameSelectorFilterToolbar = static_cast(_showGameSelectorFilterToolbar); - settings->setValue("menu/showgameselectorfiltertoolbar", showGameSelectorFilterToolbar); - emit showGameSelectorFilterToolbarChanged(showGameSelectorFilterToolbar); -} - -void SettingsCache::setDisplayCardNames(QT_STATE_CHANGED_T _displayCardNames) -{ - displayCardNames = static_cast(_displayCardNames); - settings->setValue("cards/displaycardnames", displayCardNames); - emit displayCardNamesChanged(); -} - -void SettingsCache::setOverrideAllCardArtWithPersonalPreference(QT_STATE_CHANGED_T _overrideAllCardArt) -{ - overrideAllCardArtWithPersonalPreference = static_cast(_overrideAllCardArt); - settings->setValue("cards/overrideallcardartwithpersonalpreference", overrideAllCardArtWithPersonalPreference); - emit overrideAllCardArtWithPersonalPreferenceChanged(overrideAllCardArtWithPersonalPreference); -} - -void SettingsCache::setBumpSetsWithCardsInDeckToTop(QT_STATE_CHANGED_T _bumpSetsWithCardsInDeckToTop) -{ - bumpSetsWithCardsInDeckToTop = static_cast(_bumpSetsWithCardsInDeckToTop); - settings->setValue("cards/bumpsetswithcardsindecktotop", bumpSetsWithCardsInDeckToTop); - emit bumpSetsWithCardsInDeckToTopChanged(); -} - -void SettingsCache::setPrintingSelectorSortOrder(int _printingSelectorSortOrder) -{ - printingSelectorSortOrder = _printingSelectorSortOrder; - settings->setValue("cards/printingselectorsortorder", printingSelectorSortOrder); - emit printingSelectorSortOrderChanged(); -} - -void SettingsCache::setPrintingSelectorCardSize(int _printingSelectorCardSize) -{ - printingSelectorCardSize = _printingSelectorCardSize; - settings->setValue("cards/printingselectorcardsize", printingSelectorCardSize); - emit printingSelectorCardSizeChanged(); -} - -void SettingsCache::setIncludeRebalancedCards(bool _includeRebalancedCards) -{ - if (includeRebalancedCards == _includeRebalancedCards) { - return; - } - - includeRebalancedCards = _includeRebalancedCards; - settings->setValue("cards/includerebalancedcards", includeRebalancedCards); - emit includeRebalancedCardsChanged(includeRebalancedCards); -} - -void SettingsCache::setPrintingSelectorNavigationButtonsVisible(QT_STATE_CHANGED_T _navigationButtonsVisible) -{ - printingSelectorNavigationButtonsVisible = _navigationButtonsVisible; - settings->setValue("cards/printingselectornavigationbuttonsvisible", printingSelectorNavigationButtonsVisible); - emit printingSelectorNavigationButtonsVisibleChanged(); -} - -void SettingsCache::setDeckEditorBannerCardComboBoxVisible(QT_STATE_CHANGED_T _deckEditorBannerCardComboBoxVisible) -{ - deckEditorBannerCardComboBoxVisible = _deckEditorBannerCardComboBoxVisible; - settings->setValue("interface/deckeditorbannercardcomboboxvisible", deckEditorBannerCardComboBoxVisible); - emit deckEditorBannerCardComboBoxVisibleChanged(deckEditorBannerCardComboBoxVisible); -} - -void SettingsCache::setDeckEditorTagsWidgetVisible(QT_STATE_CHANGED_T _deckEditorTagsWidgetVisible) -{ - deckEditorTagsWidgetVisible = _deckEditorTagsWidgetVisible; - settings->setValue("interface/deckeditortagswidgetvisible", deckEditorTagsWidgetVisible); - emit deckEditorTagsWidgetVisibleChanged(deckEditorTagsWidgetVisible); -} - -void SettingsCache::setVisualDeckStorageSortingOrder(int _visualDeckStorageSortingOrder) -{ - visualDeckStorageSortingOrder = _visualDeckStorageSortingOrder; - settings->setValue("interface/visualdeckstoragesortingorder", visualDeckStorageSortingOrder); -} - -void SettingsCache::setVisualDeckStorageShowFolders(QT_STATE_CHANGED_T value) -{ - visualDeckStorageShowFolders = value; - settings->setValue("interface/visualdeckstorageshowfolders", visualDeckStorageShowFolders); -} - -void SettingsCache::setVisualDeckStorageShowTagFilter(QT_STATE_CHANGED_T _showTags) -{ - visualDeckStorageShowTagFilter = _showTags; - settings->setValue("interface/visualdeckstorageshowtagfilter", visualDeckStorageShowTagFilter); - emit visualDeckStorageShowTagFilterChanged(visualDeckStorageShowTagFilter); -} - -void SettingsCache::setVisualDeckStorageDefaultTagsList(QStringList _defaultTagsList) -{ - visualDeckStorageDefaultTagsList = _defaultTagsList; - settings->setValue("interface/visualdeckstoragedefaulttagslist", visualDeckStorageDefaultTagsList); - emit visualDeckStorageDefaultTagsListChanged(); -} - -void SettingsCache::setVisualDeckStorageSearchFolderNames(QT_STATE_CHANGED_T value) -{ - visualDeckStorageSearchFolderNames = value; - settings->setValue("interface/visualdeckstoragesearchfoldernames", visualDeckStorageSearchFolderNames); -} - -void SettingsCache::setVisualDeckStorageShowColorIdentity(QT_STATE_CHANGED_T value) -{ - visualDeckStorageShowColorIdentity = value; - settings->setValue("interface/visualdeckstorageshowcoloridentity", visualDeckStorageShowColorIdentity); - emit visualDeckStorageShowColorIdentityChanged(visualDeckStorageShowColorIdentity); -} - -void SettingsCache::setVisualDeckStorageShowBannerCardComboBox(QT_STATE_CHANGED_T _showBannerCardComboBox) -{ - visualDeckStorageShowBannerCardComboBox = _showBannerCardComboBox; - settings->setValue("interface/visualdeckstorageshowbannercardcombobox", visualDeckStorageShowBannerCardComboBox); - emit visualDeckStorageShowBannerCardComboBoxChanged(visualDeckStorageShowBannerCardComboBox); -} - -void SettingsCache::setVisualDeckStorageShowTagsOnDeckPreviews(QT_STATE_CHANGED_T _showTags) -{ - visualDeckStorageShowTagsOnDeckPreviews = _showTags; - settings->setValue("interface/visualdeckstorageshowtagsondeckpreviews", visualDeckStorageShowTagsOnDeckPreviews); - emit visualDeckStorageShowTagsOnDeckPreviewsChanged(visualDeckStorageShowTagsOnDeckPreviews); -} - -void SettingsCache::setVisualDeckStorageCardSize(int _visualDeckStorageCardSize) -{ - visualDeckStorageCardSize = _visualDeckStorageCardSize; - settings->setValue("interface/visualdeckstoragecardsize", visualDeckStorageCardSize); - emit visualDeckStorageCardSizeChanged(); -} - -void SettingsCache::setVisualDeckStorageDrawUnusedColorIdentities( - QT_STATE_CHANGED_T _visualDeckStorageDrawUnusedColorIdentities) -{ - visualDeckStorageDrawUnusedColorIdentities = _visualDeckStorageDrawUnusedColorIdentities; - settings->setValue("interface/visualdeckstoragedrawunusedcoloridentities", - visualDeckStorageDrawUnusedColorIdentities); - emit visualDeckStorageDrawUnusedColorIdentitiesChanged(visualDeckStorageDrawUnusedColorIdentities); -} - -void SettingsCache::setVisualDeckStorageUnusedColorIdentitiesOpacity(int _visualDeckStorageUnusedColorIdentitiesOpacity) -{ - visualDeckStorageUnusedColorIdentitiesOpacity = _visualDeckStorageUnusedColorIdentitiesOpacity; - settings->setValue("interface/visualdeckstorageunusedcoloridentitiesopacity", - visualDeckStorageUnusedColorIdentitiesOpacity); - emit visualDeckStorageUnusedColorIdentitiesOpacityChanged(visualDeckStorageUnusedColorIdentitiesOpacity); -} - -void SettingsCache::setVisualDeckStorageTooltipType(int value) -{ - visualDeckStorageTooltipType = value; - settings->setValue("interface/visualdeckstoragetooltiptype", visualDeckStorageTooltipType); -} - -void SettingsCache::setVisualDeckStoragePromptForConversion(bool _visualDeckStoragePromptForConversion) -{ - visualDeckStoragePromptForConversion = _visualDeckStoragePromptForConversion; - settings->setValue("interface/visualdeckstoragepromptforconversion", visualDeckStoragePromptForConversion); -} - -void SettingsCache::setVisualDeckStorageAlwaysConvert(bool _visualDeckStorageAlwaysConvert) -{ - visualDeckStorageAlwaysConvert = _visualDeckStorageAlwaysConvert; - settings->setValue("interface/visualdeckstoragealwaysconvert", visualDeckStorageAlwaysConvert); -} - -void SettingsCache::setVisualDeckStorageInGame(QT_STATE_CHANGED_T value) -{ - visualDeckStorageInGame = value; - settings->setValue("interface/visualdeckstorageingame", visualDeckStorageInGame); - emit visualDeckStorageInGameChanged(visualDeckStorageInGame); -} - -void SettingsCache::setVisualDeckStorageSelectionAnimation(QT_STATE_CHANGED_T value) -{ - visualDeckStorageSelectionAnimation = value; - settings->setValue("interface/visualdeckstorageselectionanimation", visualDeckStorageSelectionAnimation); - emit visualDeckStorageSelectionAnimationChanged(visualDeckStorageSelectionAnimation); -} - -void SettingsCache::setVisualDeckEditorCardSize(int _visualDeckEditorCardSize) -{ - visualDeckEditorCardSize = _visualDeckEditorCardSize; - settings->setValue("interface/visualdeckeditorcardsize", visualDeckEditorCardSize); - emit visualDeckEditorCardSizeChanged(); -} - -void SettingsCache::setVisualDatabaseDisplayCardSize(int _visualDatabaseDisplayCardSize) -{ - visualDatabaseDisplayCardSize = _visualDatabaseDisplayCardSize; - settings->setValue("interface/visualdatabasedisplaycardsize", visualDatabaseDisplayCardSize); - emit visualDatabaseDisplayCardSizeChanged(); -} - -void SettingsCache::setEDHRecCardSize(int _edhrecCardSize) -{ - edhrecCardSize = _edhrecCardSize; - settings->setValue("interface/edhreccardsize", edhrecCardSize); - emit edhRecCardSizeChanged(); -} - -void SettingsCache::setArchidektPreviewCardSize(int _archidektPreviewCardSize) -{ - archidektPreviewSize = _archidektPreviewCardSize; - settings->setValue("interface/archidektpreviewsize", archidektPreviewSize); - emit archidektPreviewSizeChanged(); -} - -void SettingsCache::setDefaultDeckEditorType(int value) -{ - defaultDeckEditorType = value; - settings->setValue("interface/defaultDeckEditorType", defaultDeckEditorType); -} - -void SettingsCache::setVisualDatabaseDisplayFilterToMostRecentSetsEnabled(QT_STATE_CHANGED_T _enabled) -{ - visualDatabaseDisplayFilterToMostRecentSetsEnabled = _enabled; - settings->setValue("interface/visualdatabasedisplayfiltertomostrecentsetsenabled", - visualDatabaseDisplayFilterToMostRecentSetsEnabled); - emit visualDatabaseDisplayFilterToMostRecentSetsEnabledChanged(visualDatabaseDisplayFilterToMostRecentSetsEnabled); -} - -void SettingsCache::setVisualDatabaseDisplayFilterToMostRecentSetsAmount(int _amount) -{ - visualDatabaseDisplayFilterToMostRecentSetsAmount = _amount; - settings->setValue("interface/visualdatabasedisplayfiltertomostrecentsetsamount", - visualDatabaseDisplayFilterToMostRecentSetsAmount); - emit visualDatabaseDisplayFilterToMostRecentSetsAmountChanged(visualDatabaseDisplayFilterToMostRecentSetsAmount); -} - -void SettingsCache::setVisualDeckEditorSampleHandSize(int _amount) -{ - visualDeckEditorSampleHandSize = _amount; - settings->setValue("interface/visualdeckeditorsamplehandsize", visualDeckEditorSampleHandSize); - emit visualDeckEditorSampleHandSizeAmountChanged(visualDeckEditorSampleHandSize); -} - -void SettingsCache::setHorizontalHand(QT_STATE_CHANGED_T _horizontalHand) -{ - horizontalHand = static_cast(_horizontalHand); - settings->setValue("hand/horizontal", horizontalHand); - emit horizontalHandChanged(); -} - -void SettingsCache::setInvertVerticalCoordinate(QT_STATE_CHANGED_T _invertVerticalCoordinate) -{ - invertVerticalCoordinate = static_cast(_invertVerticalCoordinate); - settings->setValue("table/invert_vertical", invertVerticalCoordinate); - emit invertVerticalCoordinateChanged(); -} - -void SettingsCache::setMinPlayersForMultiColumnLayout(int _minPlayersForMultiColumnLayout) -{ - minPlayersForMultiColumnLayout = _minPlayersForMultiColumnLayout; - settings->setValue("interface/min_players_multicolumn", minPlayersForMultiColumnLayout); - emit minPlayersForMultiColumnLayoutChanged(); -} - -void SettingsCache::setTapAnimation(QT_STATE_CHANGED_T _tapAnimation) -{ - tapAnimation = static_cast(_tapAnimation); - settings->setValue("cards/tapanimation", tapAnimation); -} - -void SettingsCache::setAutoRotateSidewaysLayoutCards(QT_STATE_CHANGED_T _autoRotateSidewaysLayoutCards) -{ - autoRotateSidewaysLayoutCards = static_cast(_autoRotateSidewaysLayoutCards); - settings->setValue("cards/autorotatesidewayslayoutcards", autoRotateSidewaysLayoutCards); -} - -void SettingsCache::setOpenDeckInNewTab(QT_STATE_CHANGED_T _openDeckInNewTab) -{ - openDeckInNewTab = static_cast(_openDeckInNewTab); - settings->setValue("editor/openDeckInNewTab", openDeckInNewTab); -} - -void SettingsCache::setRewindBufferingMs(int _rewindBufferingMs) -{ - rewindBufferingMs = _rewindBufferingMs; - settings->setValue("replay/rewindBufferingMs", rewindBufferingMs); -} - -void SettingsCache::setChatMention(QT_STATE_CHANGED_T _chatMention) -{ - chatMention = static_cast(_chatMention); - settings->setValue("chat/mention", chatMention); -} - -void SettingsCache::setChatMentionCompleter(const QT_STATE_CHANGED_T _enableMentionCompleter) -{ - chatMentionCompleter = (bool)_enableMentionCompleter; - settings->setValue("chat/mentioncompleter", chatMentionCompleter); - emit chatMentionCompleterChanged(); -} - -void SettingsCache::setChatMentionForeground(QT_STATE_CHANGED_T _chatMentionForeground) -{ - chatMentionForeground = static_cast(_chatMentionForeground); - settings->setValue("chat/mentionforeground", chatMentionForeground); -} - -void SettingsCache::setChatHighlightForeground(QT_STATE_CHANGED_T _chatHighlightForeground) -{ - chatHighlightForeground = static_cast(_chatHighlightForeground); - settings->setValue("chat/highlightforeground", chatHighlightForeground); -} - -void SettingsCache::setChatMentionColor(const QString &_chatMentionColor) -{ - chatMentionColor = _chatMentionColor; - settings->setValue("chat/mentioncolor", chatMentionColor); -} - -void SettingsCache::setChatHighlightColor(const QString &_chatHighlightColor) -{ - chatHighlightColor = _chatHighlightColor; - settings->setValue("chat/highlightcolor", chatHighlightColor); -} - -void SettingsCache::setZoneViewGroupByIndex(int _zoneViewGroupByIndex) -{ - zoneViewGroupByIndex = _zoneViewGroupByIndex; - settings->setValue("zoneview/groupby", zoneViewGroupByIndex); -} - -void SettingsCache::setZoneViewSortByIndex(int _zoneViewSortByIndex) -{ - zoneViewSortByIndex = _zoneViewSortByIndex; - settings->setValue("zoneview/sortby", zoneViewSortByIndex); -} - -void SettingsCache::setZoneViewPileView(QT_STATE_CHANGED_T _zoneViewPileView) -{ - zoneViewPileView = static_cast(_zoneViewPileView); - settings->setValue("zoneview/pileview", zoneViewPileView); -} - -void SettingsCache::setSoundEnabled(QT_STATE_CHANGED_T _soundEnabled) -{ - soundEnabled = static_cast(_soundEnabled); - settings->setValue("sound/enabled", soundEnabled); - emit soundEnabledChanged(); -} - -void SettingsCache::setSoundThemeName(const QString &_soundThemeName) -{ - soundThemeName = _soundThemeName; - settings->setValue("sound/theme", soundThemeName); - emit soundThemeChanged(); -} - -void SettingsCache::setIgnoreUnregisteredUsers(QT_STATE_CHANGED_T _ignoreUnregisteredUsers) -{ - ignoreUnregisteredUsers = static_cast(_ignoreUnregisteredUsers); - settings->setValue("chat/ignore_unregistered", ignoreUnregisteredUsers); -} - -void SettingsCache::setIgnoreUnregisteredUserMessages(QT_STATE_CHANGED_T _ignoreUnregisteredUserMessages) -{ - ignoreUnregisteredUserMessages = static_cast(_ignoreUnregisteredUserMessages); - settings->setValue("chat/ignore_unregistered_messages", ignoreUnregisteredUserMessages); -} - -void SettingsCache::setIgnoreNonBuddyUserMessages(QT_STATE_CHANGED_T _ignoreNonBuddyUserMessages) -{ - ignoreNonBuddyUserMessages = static_cast(_ignoreNonBuddyUserMessages); - settings->setValue("chat/ignore_nonbuddy_messages", ignoreNonBuddyUserMessages); -} - -void SettingsCache::setPixmapCacheSize(const int _pixmapCacheSize) -{ - pixmapCacheSize = _pixmapCacheSize; - settings->setValue("personal/pixmapCacheSize", pixmapCacheSize); - emit pixmapCacheSizeChanged(pixmapCacheSize); -} - -void SettingsCache::setCardImageCacheMethod(const CardPictureLoaderCacheMethod::CacheMethod _cardImageCachingMethod) -{ - cardPictureLoaderCacheMethod = static_cast(_cardImageCachingMethod); - settings->setValue("personal/cardPictureLoaderCacheMethod", cardPictureLoaderCacheMethod); - emit cardPictureLoaderCacheMethodChanged(cardPictureLoaderCacheMethod); -} - -void SettingsCache::setNetworkCacheSizeInMB(const int _networkCacheSize) -{ - networkCacheSize = _networkCacheSize; - settings->setValue("personal/networkCacheSize", networkCacheSize); - emit networkCacheSizeChanged(networkCacheSize); -} - -void SettingsCache::setNetworkRedirectCacheTtl(const int _redirectCacheTtl) -{ - redirectCacheTtl = _redirectCacheTtl; - settings->setValue("personal/redirectCacheSize", redirectCacheTtl); - emit redirectCacheTtlChanged(redirectCacheTtl); -} - -void SettingsCache::setLocalCardImageStorageNamingScheme( - const CardPictureLoaderLocalSchemes::NamingScheme _localCardImageStorageNamingScheme) -{ - localCardImageStorageNamingScheme = static_cast(_localCardImageStorageNamingScheme); - settings->setValue("personal/localCardImageStorageNamingScheme", localCardImageStorageNamingScheme); - emit localCardImageStorageNamingSchemeChanged(localCardImageStorageNamingScheme); -} - -void SettingsCache::setClientID(const QString &_clientID) -{ - clientID = _clientID; - settings->setValue("personal/clientid", clientID); -} - -void SettingsCache::setClientVersion(const QString &_clientVersion) -{ - clientVersion = _clientVersion; - settings->setValue("personal/clientversion", clientVersion); -} - QStringList SettingsCache::getCountries() const { static const QStringList countries = { @@ -1201,230 +194,264 @@ QStringList SettingsCache::getCountries() const return countries; } -void SettingsCache::setGameDescription(const QString _gameDescription) -{ - gameDescription = _gameDescription; - settings->setValue("game/gamedescription", gameDescription); -} - -void SettingsCache::setMaxPlayers(const int _maxPlayers) -{ - maxPlayers = _maxPlayers; - settings->setValue("game/maxplayers", maxPlayers); -} - -void SettingsCache::setGameTypes(const QString _gameTypes) -{ - gameTypes = _gameTypes; - settings->setValue("game/gametypes", gameTypes); -} - -void SettingsCache::setOnlyBuddies(const bool _onlyBuddies) -{ - onlyBuddies = _onlyBuddies; - settings->setValue("game/onlybuddies", onlyBuddies); -} - -void SettingsCache::setOnlyRegistered(const bool _onlyRegistered) -{ - onlyRegistered = _onlyRegistered; - settings->setValue("game/onlyregistered", onlyRegistered); -} - -void SettingsCache::setSpectatorsAllowed(const bool _spectatorsAllowed) -{ - spectatorsAllowed = _spectatorsAllowed; - settings->setValue("game/spectatorsallowed", spectatorsAllowed); -} - -void SettingsCache::setSpectatorsNeedPassword(const bool _spectatorsNeedPassword) -{ - spectatorsNeedPassword = _spectatorsNeedPassword; - settings->setValue("game/spectatorsneedpassword", spectatorsNeedPassword); -} - -void SettingsCache::setSpectatorsCanTalk(const bool _spectatorsCanTalk) -{ - spectatorsCanTalk = _spectatorsCanTalk; - settings->setValue("game/spectatorscantalk", spectatorsCanTalk); -} - -void SettingsCache::setSpectatorsCanSeeEverything(const bool _spectatorsCanSeeEverything) -{ - spectatorsCanSeeEverything = _spectatorsCanSeeEverything; - settings->setValue("game/spectatorscanseeeverything", spectatorsCanSeeEverything); -} - -void SettingsCache::setCreateGameAsSpectator(const bool _createGameAsSpectator) -{ - createGameAsSpectator = _createGameAsSpectator; - settings->setValue("game/creategameasspectator", createGameAsSpectator); -} - -void SettingsCache::setDefaultStartingLifeTotal(const int _defaultStartingLifeTotal) -{ - defaultStartingLifeTotal = _defaultStartingLifeTotal; - settings->setValue("game/defaultstartinglifetotal", defaultStartingLifeTotal); -} - -void SettingsCache::setShareDecklistsOnLoad(const bool _shareDecklistsOnLoad) -{ - shareDecklistsOnLoad = _shareDecklistsOnLoad; - settings->setValue("game/sharedecklistsonload", shareDecklistsOnLoad); -} - -void SettingsCache::setCheckUpdatesOnStartup(QT_STATE_CHANGED_T value) -{ - checkUpdatesOnStartup = static_cast(value); - settings->setValue("personal/startupUpdateCheck", checkUpdatesOnStartup); -} - -void SettingsCache::setStartupCardUpdateCheckPromptForUpdate(bool value) -{ - startupCardUpdateCheckPromptForUpdate = value; - settings->setValue("personal/startupCardUpdateCheckPromptForUpdate", startupCardUpdateCheckPromptForUpdate); -} - -void SettingsCache::setStartupCardUpdateCheckAlwaysUpdate(bool value) -{ - startupCardUpdateCheckAlwaysUpdate = value; - settings->setValue("personal/startupCardUpdateCheckAlwaysUpdate", startupCardUpdateCheckAlwaysUpdate); -} - -void SettingsCache::setCardUpdateCheckInterval(int value) -{ - cardUpdateCheckInterval = value; - settings->setValue("personal/cardUpdateCheckInterval", cardUpdateCheckInterval); -} - -void SettingsCache::setLastCardUpdateCheck(QDate value) -{ - lastCardUpdateCheck = value; - settings->setValue("personal/lastCardUpdateCheck", lastCardUpdateCheck); -} - -void SettingsCache::setAlwaysEnableNewSets(bool value) -{ - alwaysEnableNewSets = value; - settings->setValue("personal/alwaysEnableNewSets", alwaysEnableNewSets); -} - -void SettingsCache::setRememberGameSettings(const bool _rememberGameSettings) -{ - rememberGameSettings = _rememberGameSettings; - settings->setValue("game/remembergamesettings", rememberGameSettings); -} - -void SettingsCache::setLocalGameRememberSettings(bool value) -{ - localGameRememberSettings = value; - settings->setValue("localgameoptions/remembersettings", value); -} - -void SettingsCache::setLocalGameMaxPlayers(int value) -{ - localGameMaxPlayers = value; - settings->setValue("localgameoptions/maxplayers", value); -} - -void SettingsCache::setLocalGameStartingLifeTotal(int value) -{ - localGameStartingLifeTotal = value; - settings->setValue("localgameoptions/startinglifetotal", value); -} - -void SettingsCache::setNotifyAboutUpdate(QT_STATE_CHANGED_T _notifyaboutupdate) -{ - notifyAboutUpdates = static_cast(_notifyaboutupdate); - settings->setValue("personal/updatenotification", notifyAboutUpdates); -} - -void SettingsCache::setNotifyAboutNewVersion(QT_STATE_CHANGED_T _notifyaboutnewversion) -{ - notifyAboutNewVersion = static_cast(_notifyaboutnewversion); - settings->setValue("personal/newversionnotification", notifyAboutNewVersion); -} - -void SettingsCache::setDownloadSpoilerStatus(bool _spoilerStatus) -{ - mbDownloadSpoilers = _spoilerStatus; - settings->setValue("personal/downloadspoilers", mbDownloadSpoilers); - emit downloadSpoilerStatusChanged(); -} - -void SettingsCache::setUpdateReleaseChannelIndex(int value) -{ - updateReleaseChannel = value; - settings->setValue("personal/updatereleasechannel", updateReleaseChannel); -} - -void SettingsCache::setMaxFontSize(int _max) -{ - maxFontSize = _max; - settings->setValue("game/maxfontsize", maxFontSize); -} - -void SettingsCache::setRoundCardCorners(bool _roundCardCorners) -{ - if (_roundCardCorners == roundCardCorners) { - return; - } - - roundCardCorners = _roundCardCorners; - settings->setValue("cards/roundcardcorners", _roundCardCorners); - emit roundCardCornersChanged(roundCardCorners); -} - -void SettingsCache::setShowDragSelectionCount(QT_STATE_CHANGED_T _showDragSelectionCount) -{ - showDragSelectionCount = static_cast(_showDragSelectionCount); - settings->setValue("interface/showlassoselectioncount", showDragSelectionCount); -} - -void SettingsCache::setShowTotalSelectionCount(QT_STATE_CHANGED_T _showTotalSelectionCount) -{ - showTotalSelectionCount = static_cast(_showTotalSelectionCount); - settings->setValue("interface/showpersistentselectioncount", showTotalSelectionCount); -} - void SettingsCache::loadPaths() { QString dataPath = getDataPath(); - deckPath = getSafeConfigPath("paths/decks", dataPath + "/decks/"); - filtersPath = getSafeConfigPath("paths/filters", dataPath + "/filters/"); - replaysPath = getSafeConfigPath("paths/replays", dataPath + "/replays/"); - themesPath = getSafeConfigPath("paths/themes", dataPath + "/themes/"); - picsPath = getSafeConfigPath("paths/pics", dataPath + "/pics/"); - redirectCachePath = getSafeConfigPath("paths/redirects", getCachePath() + "/redirects/"); - // this has never been exposed as an user-configurable setting - if (picsPath.endsWith("/")) { - customPicsPath = getSafeConfigPath("paths/custompics", picsPath + "CUSTOM/"); - } else { - customPicsPath = getSafeConfigPath("paths/custompics", picsPath + "/CUSTOM/"); - } - customCardDatabasePath = getSafeConfigPath("paths/customsets", dataPath + "/customsets/"); + QSettings pathsIni(getSettingsPath() + "paths.ini", QSettings::IniFormat); - cardDatabasePath = getSafeConfigFilePath("paths/carddatabase", dataPath + "/cards.xml"); - tokenDatabasePath = getSafeConfigFilePath("paths/tokendatabase", dataPath + "/tokens.xml"); - spoilerDatabasePath = getSafeConfigFilePath("paths/spoilerdatabase", dataPath + "/spoiler.xml"); + auto computePath = [&](const QString &key, const QString &defaultPath) -> QString { + QString val = pathsIni.value("paths/" + key).toString(); + if (val.isEmpty() || !QDir(val).exists()) { + if (!QDir().mkpath(defaultPath)) { + qCInfo(SettingsCacheLog) << "[SettingsCache] Could not create folder:" << defaultPath; + } + val = defaultPath; + pathsIni.setValue("paths/" + key, val); + } + return val; + }; + + auto computeFilePath = [&](const QString &key, const QString &defaultPath) -> QString { + QString val = pathsIni.value("paths/" + key).toString(); + if (!QFile::exists(val) || val.isEmpty()) { + val = defaultPath; + pathsIni.setValue("paths/" + key, val); + } + return val; + }; + + computePath("decks", dataPath + "/decks/"); + computePath("filters", dataPath + "/filters/"); + computePath("replays", dataPath + "/replays/"); + computePath("themes", dataPath + "/themes/"); + computePath("pics", dataPath + "/pics/"); + computePath("redirects", getCachePath() + "/redirects/"); + + // customPicsPath derived from picsPath + QString picsPath = pathsIni.value("paths/pics").toString(); + if (picsPath.endsWith("/")) { + computePath("customPics", picsPath + "CUSTOM/"); + } else { + computePath("customPics", picsPath + "/CUSTOM/"); + } + + computePath("customSets", dataPath + "/customsets/"); + computeFilePath("cardDatabase", dataPath + "/cards.xml"); + computeFilePath("tokenDatabase", dataPath + "/tokens.xml"); + computeFilePath("spoilerDatabase", dataPath + "/spoiler.xml"); } void SettingsCache::resetPaths() { - QStringList databasePaths{customCardDatabasePath, cardDatabasePath, spoilerDatabasePath, tokenDatabasePath}; - QString picsPath_ = picsPath; - settings->remove("paths"); // removes all keys in paths/* + QStringList databasePaths{pathsSettings->getCustomCardDatabasePath(), pathsSettings->getCardDatabasePath(), + pathsSettings->getSpoilerCardDatabasePath(), pathsSettings->getTokenDatabasePath()}; + QString picsPath_ = pathsSettings->getPicsPath(); + + QSettings pathsIni(getSettingsPath() + "paths.ini", QSettings::IniFormat); + pathsIni.remove("paths"); + loadPaths(); - if (databasePaths != - QStringList{customCardDatabasePath, cardDatabasePath, spoilerDatabasePath, tokenDatabasePath}) { + + if (databasePaths != QStringList{pathsSettings->getCustomCardDatabasePath(), pathsSettings->getCardDatabasePath(), + pathsSettings->getSpoilerCardDatabasePath(), + pathsSettings->getTokenDatabasePath()}) { emit cardDatabasePathChanged(); } - if (picsPath_ != picsPath) { - emit picsPathChanged(); + if (picsPath_ != pathsSettings->getPicsPath()) { + emit pathsSettings->picsPathChanged(); } } +// ICardDatabasePathProvider - delegate to pathsSettings +QString SettingsCache::getCustomCardDatabasePath() const +{ + return pathsSettings->getCustomCardDatabasePath(); +} + +QString SettingsCache::getCardDatabasePath() const +{ + return pathsSettings->getCardDatabasePath(); +} + +QString SettingsCache::getSpoilerCardDatabasePath() const +{ + return pathsSettings->getSpoilerCardDatabasePath(); +} + +QString SettingsCache::getTokenDatabasePath() const +{ + return pathsSettings->getTokenDatabasePath(); +} + +// INetworkSettingsProvider - delegate to sub-objects +int SettingsCache::getKeepAlive() const +{ + return networkSettings->getKeepAlive(); +} + +int SettingsCache::getTimeOut() const +{ + return networkSettings->getTimeOut(); +} + +bool SettingsCache::getNotifyAboutUpdates() const +{ + return updatesSettings->getNotifyAboutUpdates(); +} + +void SettingsCache::setKnownMissingFeatures(const QString &_knownMissingFeatures) +{ + networkSettings->setKnownMissingFeatures(_knownMissingFeatures); +} + +QString SettingsCache::getKnownMissingFeatures() +{ + return networkSettings->getKnownMissingFeatures(); +} + +QString SettingsCache::getClientID() +{ + return networkSettings->getClientID(); +} + +// Release channels +ReleaseChannel *SettingsCache::getUpdateReleaseChannel() const +{ + return releaseChannels.at(qMax(0, updatesSettings->getUpdateReleaseChannelIndex())); +} + +QList SettingsCache::getUpdateReleaseChannels() const +{ + return releaseChannels; +} + +int SettingsCache::getUpdateReleaseChannelIndex() const +{ + return updatesSettings->getUpdateReleaseChannelIndex(); +} + +// Sub-settings accessors +ShortcutsSettings &SettingsCache::shortcuts() const +{ + return *shortcutsSettings; +} + +CardDatabaseSettings &SettingsCache::cardDatabase() const +{ + return *cardDatabaseSettings; +} + +ServersSettings &SettingsCache::servers() const +{ + return *serversSettings; +} + +MessageSettings &SettingsCache::messages() const +{ + return *messageSettings; +} + +GameFiltersSettings &SettingsCache::gameFilters() const +{ + return *gameFiltersSettings; +} + +LayoutsSettings &SettingsCache::layouts() const +{ + return *layoutsSettings; +} + +DownloadSettings &SettingsCache::downloads() const +{ + return *downloadSettings; +} + +RecentsSettings &SettingsCache::recents() const +{ + return *recentsSettings; +} + +CardOverrideSettings &SettingsCache::cardOverrides() const +{ + return *cardOverrideSettings; +} + +DebugSettings &SettingsCache::debug() const +{ + return *debugSettings; +} + CardCounterSettings &SettingsCache::cardCounters() const { return *cardCounterSettings; } + +TabsSettings &SettingsCache::tabs() const +{ + return *tabsSettings; +} + +SoundSettings &SettingsCache::sound() const +{ + return *soundSettings; +} + +GameSettings &SettingsCache::game() const +{ + return *gameSettings; +} + +ChatSettings &SettingsCache::chat() const +{ + return *chatSettings; +} + +CacheStorageSettings &SettingsCache::cacheStorage() const +{ + return *cacheStorageSettings; +} + +UpdatesSettings &SettingsCache::updates() const +{ + return *updatesSettings; +} + +PersonalSettings &SettingsCache::personal() const +{ + return *personalSettings; +} + +CardsDisplaySettings &SettingsCache::cardsDisplay() const +{ + return *cardsDisplaySettings; +} + +InterfaceSettings &SettingsCache::userInterface() const +{ + return *interfaceSettings; +} + +PathsSettings &SettingsCache::paths() const +{ + return *pathsSettings; +} + +DeckEditorSettings &SettingsCache::deckEditor() const +{ + return *deckEditorSettings; +} + +VisualDeckStorageSettings &SettingsCache::visualDeckStorage() const +{ + return *visualDeckStorageSettings; +} + +AppearanceSettings &SettingsCache::appearance() const +{ + return *appearanceSettings; +} + +NetworkSettings &SettingsCache::network() const +{ + return *networkSettings; +} diff --git a/cockatrice/src/client/settings/cache_settings.h b/cockatrice/src/client/settings/cache_settings.h index a166917c1..23cdb4dbf 100644 --- a/cockatrice/src/client/settings/cache_settings.h +++ b/cockatrice/src/client/settings/cache_settings.h @@ -7,195 +7,55 @@ #ifndef SETTINGSCACHE_H #define SETTINGSCACHE_H -#include "../../interface/card_picture_loader/card_picture_loader_cache_method.h" -#include "../../interface/card_picture_loader/card_picture_loader_local_schemes.h" -#include "shortcuts_settings.h" - -#include #include -#include #include #include #include -#include -#include -#include -#include -#include -#include -#include -#include -#include #include inline Q_LOGGING_CATEGORY(SettingsCacheLog, "settings_cache"); class ReleaseChannel; -// In MB (Increments of 64) -#define PIXMAPCACHE_SIZE_DEFAULT 2048 -#define PIXMAPCACHE_SIZE_MIN 64 -#define PIXMAPCACHE_SIZE_MAX 4096 - -// In MB -constexpr int NETWORK_CACHE_SIZE_DEFAULT = 1024 * 4; // 4 GB -constexpr int NETWORK_CACHE_SIZE_MIN = 1; // 1 MB -constexpr int NETWORK_CACHE_SIZE_MAX = 1024 * 1024; // 1 TB - -// In Days -#define NETWORK_REDIRECT_CACHE_TTL_DEFAULT 30 -#define NETWORK_REDIRECT_CACHE_TTL_MIN 1 -#define NETWORK_REDIRECT_CACHE_TTL_MAX 90 - #define DEFAULT_LANG_NAME "English" #define CLIENT_INFO_NOT_SET "notset" #define DEFAULT_FONT_SIZE 12 -inline QStringList defaultTags = { - // Strategies - "🏃️ Aggro", - "🧙‍️ Control", - "⚔️ Midrange", - "🌀 Combo", - "🪓 Mill", - "🔒 Stax", - "🗺️ Landfall", - "🛡️ Pillowfort", - "🌱 Ramp", - "⚡ Storm", - "💀 Aristocrats", - "☠️ Reanimator", - "👹 Sacrifice", - "🔥 Burn", - "🌟 Lifegain", - "🔮 Spellslinger", - "👥 Tokens", - "🎭 Blink", - "⏳ Time Manipulation", - "🌍 Domain", - "💫 Proliferate", - "📜 Saga", - "🎲 Chaos", - "🪄 Auras", - "🔫 Pingers", - - // Themes - "👑 Monarch", - "🚀 Vehicles", - "💉 Infect", - "🩸 Madness", - "🌀 Morph", - - // Card Types - "⚔️ Creature", - "💎 Artifact", - "🌔 Enchantment", - "📖 Sorcery", - "⚡ Instant", - "🌌 Planeswalker", - "🌏 Land", - "🪄 Aura", - - // Kindred Types - "🐉 Kindred", - "🧙 Humans", - "⚔️ Soldiers", - "🛡️ Knights", - "🎻 Bards", - "🧝 Elves", - "🌲 Dryads", - "😇 Angels", - "🎩 Wizards", - "🧛 Vampires", - "🦴 Skeletons", - "💀 Zombies", - "👹 Demons", - "👾 Eldrazi", - "🐉 Dragons", - "🐠 Merfolk", - "🦁 Cats", - "🐺 Wolves", - "🐺 Werewolves", - "🦇 Bats", - "🐀 Rats", - "🦅 Birds", - "🦗 Insects", - "🍄 Fungus", - "🐚 Sea Creatures", - "🐗 Boars", - "🦊 Foxes", - "🦄 Unicorns", - "🐘 Elephants", - "🐻 Bears", - "🦏 Rhinos", - "🦂 Scorpions", -}; - -class QSettings; +class CacheStorageSettings; class CardCounterSettings; +class CardDatabaseSettings; +class CardOverrideSettings; +class CardsDisplaySettings; +class ChatSettings; +class CommanderBracketSettings; +class DebugSettings; +class DeckEditorSettings; +class DownloadSettings; +class GameFiltersSettings; +class GameSettings; +class InterfaceSettings; +class LayoutsSettings; +class MessageSettings; +class PathsSettings; +class PersonalSettings; +class RecentsSettings; +class ServersSettings; +class ShortcutsSettings; +class SoundSettings; +class TabsSettings; +class UpdatesSettings; +class VisualDeckStorageSettings; +class AppearanceSettings; +class NetworkSettings; +class QSettings; class SettingsCache : public ICardDatabasePathProvider, public INetworkSettingsProvider { Q_OBJECT signals: - void langChanged(); - void picsPathChanged(); void themeChanged(); - void homeTabBackgroundSourceChanged(); - void homeTabBackgroundShuffleFrequencyChanged(); - void homeTabDisplayCardNameChanged(); - void picDownloadChanged(); - void showStatusBarChanged(bool state); - void showGameSelectorFilterToolbarChanged(bool state); - void displayCardNamesChanged(); - void overrideAllCardArtWithPersonalPreferenceChanged(bool _overrideAllCardArtWithPersonalPreference); - void bumpSetsWithCardsInDeckToTopChanged(); - void printingSelectorSortOrderChanged(); - void printingSelectorCardSizeChanged(); - void includeRebalancedCardsChanged(bool _includeRebalancedCards); - void printingSelectorNavigationButtonsVisibleChanged(); - void deckEditorBannerCardComboBoxVisibleChanged(bool _visible); - void deckEditorTagsWidgetVisibleChanged(bool _visible); - void visualDeckStorageShowTagFilterChanged(bool _visible); - void visualDeckStorageDefaultTagsListChanged(); - void visualDeckStorageShowColorIdentityChanged(bool _visible); - void visualDeckStorageShowBannerCardComboBoxChanged(bool _visible); - void visualDeckStorageShowTagsOnDeckPreviewsChanged(bool _visible); - void visualDeckStorageCardSizeChanged(); - void visualDeckStorageDrawUnusedColorIdentitiesChanged(bool _visible); - void visualDeckStorageUnusedColorIdentitiesOpacityChanged(bool value); - void visualDeckStorageInGameChanged(bool enabled); - void visualDeckStorageSelectionAnimationChanged(bool enabled); - void visualDatabaseDisplayFilterToMostRecentSetsEnabledChanged(bool enabled); - void visualDatabaseDisplayFilterToMostRecentSetsAmountChanged(int amount); - void visualDeckEditorSampleHandSizeAmountChanged(int amount); - void visualDeckEditorCardSizeChanged(); - void visualDatabaseDisplayCardSizeChanged(); - void edhRecCardSizeChanged(); - void archidektPreviewSizeChanged(); - void horizontalHandChanged(); - void handJustificationChanged(); - void invertVerticalCoordinateChanged(); - void minPlayersForMultiColumnLayoutChanged(); - void soundEnabledChanged(); - void soundThemeChanged(); - void ignoreUnregisteredUsersChanged(); - void ignoreUnregisteredUserMessagesChanged(); - void ignoreNonBuddyUserMessagesChanged(); - void pixmapCacheSizeChanged(int newSizeInMBs); - void networkCacheSizeChanged(int newSizeInMBs); - void redirectCacheTtlChanged(int newTtl); - void cardPictureLoaderCacheMethodChanged(int cardPictureLoaderCacheMethod); - void localCardImageStorageNamingSchemeChanged(int localCardImageStorageNamingScheme); - void masterVolumeChanged(int value); - void chatMentionCompleterChanged(); - void downloadSpoilerTimeIndexChanged(); - void downloadSpoilerStatusChanged(); - void useTearOffMenusChanged(bool state); - void roundCardCornersChanged(bool roundCardCorners); - void keepGameChatFocusChanged(bool value); private: QSettings *settings; @@ -210,149 +70,30 @@ private: CardOverrideSettings *cardOverrideSettings; DebugSettings *debugSettings; CardCounterSettings *cardCounterSettings; + TabsSettings *tabsSettings; + SoundSettings *soundSettings; + GameSettings *gameSettings; + ChatSettings *chatSettings; + CacheStorageSettings *cacheStorageSettings; + UpdatesSettings *updatesSettings; + PersonalSettings *personalSettings; + CardsDisplaySettings *cardsDisplaySettings; + InterfaceSettings *interfaceSettings; + DeckEditorSettings *deckEditorSettings; + PathsSettings *pathsSettings; + VisualDeckStorageSettings *visualDeckStorageSettings; + AppearanceSettings *appearanceSettings; + NetworkSettings *networkSettings; + CommanderBracketSettings *commanderBracketSettings; - QString lang; - QString deckPath, filtersPath, replaysPath, picsPath, redirectCachePath, customPicsPath, cardDatabasePath, - customCardDatabasePath, themesPath, spoilerDatabasePath, tokenDatabasePath, themeName, homeTabBackgroundSource; - bool tabVisualDeckStorageOpen, tabServerOpen, tabAccountOpen, tabDeckStorageOpen, tabReplaysOpen, tabAdminOpen, - tabLogOpen; - bool checkUpdatesOnStartup; - bool startupCardUpdateCheckPromptForUpdate; - bool startupCardUpdateCheckAlwaysUpdate; - bool checkCardUpdatesOnStartup; - int cardUpdateCheckInterval; - QDate lastCardUpdateCheck; - bool alwaysEnableNewSets; - bool notifyAboutUpdates; - bool notifyAboutNewVersion; - bool showTipsOnStartup; - QList seenTips; - int homeTabBackgroundShuffleFrequency; - bool homeTabDisplayCardName; - bool mbDownloadSpoilers; - int updateReleaseChannel; - int maxFontSize; - bool picDownload; - bool notificationsEnabled; - bool spectatorNotificationsEnabled; - bool buddyConnectNotificationsEnabled; - bool doubleClickToPlay; - bool clickPlaysAllSelected; - bool playToStack; - bool doNotDeleteArrowsInSubPhases; - int startingHandSize; - bool annotateTokens; - bool showShortcuts; - bool showGameSelectorFilterToolbar; - bool displayCardNames; - bool overrideAllCardArtWithPersonalPreference; - bool bumpSetsWithCardsInDeckToTop; - int printingSelectorSortOrder; - int printingSelectorCardSize; - bool includeRebalancedCards; - bool printingSelectorNavigationButtonsVisible; - bool deckEditorBannerCardComboBoxVisible; - bool deckEditorTagsWidgetVisible; - int visualDeckStorageSortingOrder; - bool visualDeckStorageShowFolders; - bool visualDeckStorageShowColorIdentity; - bool visualDeckStorageShowBannerCardComboBox; - bool visualDeckStorageShowTagsOnDeckPreviews; - bool visualDeckStorageShowTagFilter; - QStringList visualDeckStorageDefaultTagsList; - bool visualDeckStorageSearchFolderNames; - int visualDeckStorageCardSize; - int visualDeckEditorCardSize; - int visualDatabaseDisplayCardSize; - int edhrecCardSize; - int archidektPreviewSize; - bool visualDeckStorageDrawUnusedColorIdentities; - int visualDeckStorageUnusedColorIdentitiesOpacity; - int visualDeckStorageTooltipType; - bool visualDeckStoragePromptForConversion; - bool visualDeckStorageAlwaysConvert; - bool visualDeckStorageInGame; - bool visualDeckStorageSelectionAnimation; - int defaultDeckEditorType; - bool visualDatabaseDisplayFilterToMostRecentSetsEnabled; - int visualDatabaseDisplayFilterToMostRecentSetsAmount; - int visualDeckEditorSampleHandSize; - bool horizontalHand; - bool invertVerticalCoordinate; - int minPlayersForMultiColumnLayout; - bool tapAnimation; - bool autoRotateSidewaysLayoutCards; - bool openDeckInNewTab; - int rewindBufferingMs; - bool chatMention; - bool chatMentionCompleter; - QString chatMentionColor; - QString chatHighlightColor; - bool chatMentionForeground; - bool chatHighlightForeground; - int zoneViewSortByIndex, zoneViewGroupByIndex; - bool zoneViewPileView; - bool soundEnabled; - QString soundThemeName; - bool ignoreUnregisteredUsers; - bool ignoreUnregisteredUserMessages; - bool ignoreNonBuddyUserMessages; - QString picUrl; - QString picUrlFallback; - QString clientID; - QString clientVersion; - QString knownMissingFeatures; - bool useTearOffMenus; - int cardViewInitialRowsMax; - int cardViewExpandedRowsMax; - bool closeEmptyCardView; - bool focusCardViewSearchBar; - bool keepGameChatFocus; - int pixmapCacheSize; - int networkCacheSize; - int redirectCacheTtl; - int cardPictureLoaderCacheMethod; - int localCardImageStorageNamingScheme; - bool scaleCards; - int verticalCardOverlapPercent; - bool showMessagePopups; - bool showMentionPopups; - bool roomHistory; - bool leftJustified; - int masterVolume; - int cardInfoViewMode; - QString highlightWords; - QString gameDescription; - int maxPlayers; - QString gameTypes; - bool onlyBuddies; - bool onlyRegistered; - bool spectatorsAllowed; - bool spectatorsNeedPassword; - bool spectatorsCanTalk; - bool spectatorsCanSeeEverything; - bool createGameAsSpectator; - int defaultStartingLifeTotal; - bool shareDecklistsOnLoad; - int keepalive; - int timeout; - void translateLegacySettings(); - [[nodiscard]] QString getSafeConfigPath(QString configEntry, QString defaultPath) const; - [[nodiscard]] QString getSafeConfigFilePath(QString configEntry, QString defaultPath) const; - void loadPaths(); - bool rememberGameSettings; - - // Local game settings (separate from server game settings in game/*) - bool localGameRememberSettings; - int localGameMaxPlayers; - int localGameStartingLifeTotal; + QString themeName; QList releaseChannels; bool isPortableBuild; - bool roundCardCorners; - bool showStatusBar; - bool showDragSelectionCount; - bool showTotalSelectionCount; + + [[nodiscard]] QString getSafeConfigPath(QString configEntry, QString defaultPath) const; + [[nodiscard]] QString getSafeConfigFilePath(QString configEntry, QString defaultPath) const; + void loadPaths(); public: SettingsCache(); @@ -360,814 +101,71 @@ public: QString getSettingsPath(); [[nodiscard]] QString getCachePath() const; [[nodiscard]] QString getNetworkCachePath() const; - [[nodiscard]] QString getLang() const - { - return lang; - } - [[nodiscard]] QString getDeckPath() const - { - return deckPath; - } - [[nodiscard]] QString getFiltersPath() const - { - return filtersPath; - } - [[nodiscard]] QString getReplaysPath() const - { - return replaysPath; - } - [[nodiscard]] QString getThemesPath() const - { - return themesPath; - } - [[nodiscard]] QString getPicsPath() const - { - return picsPath; - } - [[nodiscard]] QString getRedirectCachePath() const - { - return redirectCachePath; - } - [[nodiscard]] QString getCustomPicsPath() const - { - return customPicsPath; - } - [[nodiscard]] QString getCustomCardDatabasePath() const override - { - return customCardDatabasePath; - } - [[nodiscard]] QString getCardDatabasePath() const override - { - return cardDatabasePath; - } - [[nodiscard]] QString getSpoilerCardDatabasePath() const override - { - return spoilerDatabasePath; - } - [[nodiscard]] QString getTokenDatabasePath() const override - { - return tokenDatabasePath; - } + + // ICardDatabasePathProvider - delegate to pathsSettings + [[nodiscard]] QString getCustomCardDatabasePath() const override; + [[nodiscard]] QString getCardDatabasePath() const override; + [[nodiscard]] QString getSpoilerCardDatabasePath() const override; + [[nodiscard]] QString getTokenDatabasePath() const override; + + // INetworkSettingsProvider - delegate to sub-objects + [[nodiscard]] int getKeepAlive() const override; + [[nodiscard]] int getTimeOut() const override; + [[nodiscard]] bool getNotifyAboutUpdates() const override; + void setKnownMissingFeatures(const QString &_knownMissingFeatures) override; + [[nodiscard]] QString getKnownMissingFeatures() override; + QString getClientID() override; + [[nodiscard]] QString getThemeName() const { return themeName; } - [[nodiscard]] QString getHomeTabBackgroundSource() const - { - return homeTabBackgroundSource; - } - [[nodiscard]] int getHomeTabBackgroundShuffleFrequency() const - { - return homeTabBackgroundShuffleFrequency; - } - [[nodiscard]] bool getHomeTabDisplayCardName() const - { - return homeTabDisplayCardName; - } - [[nodiscard]] bool getTabVisualDeckStorageOpen() const - { - return tabVisualDeckStorageOpen; - } - [[nodiscard]] bool getTabServerOpen() const - { - return tabServerOpen; - } - [[nodiscard]] bool getTabAccountOpen() const - { - return tabAccountOpen; - } - [[nodiscard]] bool getTabDeckStorageOpen() const - { - return tabDeckStorageOpen; - } - [[nodiscard]] bool getTabReplaysOpen() const - { - return tabReplaysOpen; - } - [[nodiscard]] bool getTabAdminOpen() const - { - return tabAdminOpen; - } - [[nodiscard]] bool getTabLogOpen() const - { - return tabLogOpen; - } - [[nodiscard]] QString getChatMentionColor() const - { - return chatMentionColor; - } - [[nodiscard]] QString getChatHighlightColor() const - { - return chatHighlightColor; - } - [[nodiscard]] bool getPicDownload() const - { - return picDownload; - } - [[nodiscard]] bool getShowStatusBar() const - { - return showStatusBar; - } - [[nodiscard]] bool getShowDragSelectionCount() const - { - return showDragSelectionCount; - } - [[nodiscard]] bool getShowTotalSelectionCount() const - { - return showTotalSelectionCount; - } - [[nodiscard]] bool getNotificationsEnabled() const - { - return notificationsEnabled; - } - [[nodiscard]] bool getSpectatorNotificationsEnabled() const - { - return spectatorNotificationsEnabled; - } - [[nodiscard]] bool getBuddyConnectNotificationsEnabled() const - { - return buddyConnectNotificationsEnabled; - } - [[nodiscard]] bool getCheckUpdatesOnStartup() const - { - return checkUpdatesOnStartup; - } - bool getStartupCardUpdateCheckPromptForUpdate() - { - return startupCardUpdateCheckPromptForUpdate; - } - bool getStartupCardUpdateCheckAlwaysUpdate() - { - return startupCardUpdateCheckAlwaysUpdate; - } - [[nodiscard]] int getCardUpdateCheckInterval() const - { - return cardUpdateCheckInterval; - } - [[nodiscard]] QDate getLastCardUpdateCheck() const - { - return lastCardUpdateCheck; - } - [[nodiscard]] bool getCardUpdateCheckRequired() const - { - return getLastCardUpdateCheck().daysTo(QDateTime::currentDateTime().date()) >= getCardUpdateCheckInterval() && - getLastCardUpdateCheck() != QDateTime::currentDateTime().date(); - } - [[nodiscard]] bool getAlwaysEnableNewSets() const - { - return alwaysEnableNewSets; - } - [[nodiscard]] bool getNotifyAboutUpdates() const override - { - return notifyAboutUpdates; - } - [[nodiscard]] bool getNotifyAboutNewVersion() const - { - return notifyAboutNewVersion; - } - [[nodiscard]] bool getShowTipsOnStartup() const - { - return showTipsOnStartup; - } - [[nodiscard]] QList getSeenTips() const - { - return seenTips; - } - [[nodiscard]] int getUpdateReleaseChannelIndex() const - { - return updateReleaseChannel; - } - [[nodiscard]] ReleaseChannel *getUpdateReleaseChannel() const - { - return releaseChannels.at(qMax(0, updateReleaseChannel)); - } - [[nodiscard]] QList getUpdateReleaseChannels() const - { - return releaseChannels; - } - [[nodiscard]] bool getDoubleClickToPlay() const - { - return doubleClickToPlay; - } - [[nodiscard]] bool getClickPlaysAllSelected() const - { - return clickPlaysAllSelected; - } - [[nodiscard]] bool getPlayToStack() const - { - return playToStack; - } - [[nodiscard]] bool getDoNotDeleteArrowsInSubPhases() const - { - return doNotDeleteArrowsInSubPhases; - } - [[nodiscard]] int getStartingHandSize() const - { - return startingHandSize; - } - [[nodiscard]] bool getAnnotateTokens() const - { - return annotateTokens; - } - [[nodiscard]] bool getShowShortcuts() const - { - return showShortcuts; - } - [[nodiscard]] bool getShowGameSelectorFilterToolbar() const - { - return showGameSelectorFilterToolbar; - } - [[nodiscard]] bool getDisplayCardNames() const - { - return displayCardNames; - } - [[nodiscard]] bool getOverrideAllCardArtWithPersonalPreference() const - { - return overrideAllCardArtWithPersonalPreference; - } - [[nodiscard]] bool getBumpSetsWithCardsInDeckToTop() const - { - return bumpSetsWithCardsInDeckToTop; - } - [[nodiscard]] int getPrintingSelectorSortOrder() const - { - return printingSelectorSortOrder; - } - [[nodiscard]] int getPrintingSelectorCardSize() const - { - return printingSelectorCardSize; - } - [[nodiscard]] bool getIncludeRebalancedCards() const - { - return includeRebalancedCards; - } - [[nodiscard]] bool getPrintingSelectorNavigationButtonsVisible() const - { - return printingSelectorNavigationButtonsVisible; - } - [[nodiscard]] bool getDeckEditorBannerCardComboBoxVisible() const - { - return deckEditorBannerCardComboBoxVisible; - } - [[nodiscard]] bool getDeckEditorTagsWidgetVisible() const - { - return deckEditorTagsWidgetVisible; - } - [[nodiscard]] int getVisualDeckStorageSortingOrder() const - { - return visualDeckStorageSortingOrder; - } - [[nodiscard]] bool getVisualDeckStorageShowFolders() const - { - return visualDeckStorageShowFolders; - } - [[nodiscard]] bool getVisualDeckStorageShowTagFilter() const - { - return visualDeckStorageShowTagFilter; - } - [[nodiscard]] QStringList getVisualDeckStorageDefaultTagsList() const - { - return visualDeckStorageDefaultTagsList; - } - [[nodiscard]] bool getVisualDeckStorageSearchFolderNames() const - { - return visualDeckStorageSearchFolderNames; - } - [[nodiscard]] bool getVisualDeckStorageShowColorIdentity() const - { - return visualDeckStorageShowColorIdentity; - } - [[nodiscard]] bool getVisualDeckStorageShowBannerCardComboBox() const - { - return visualDeckStorageShowBannerCardComboBox; - } - [[nodiscard]] bool getVisualDeckStorageShowTagsOnDeckPreviews() const - { - return visualDeckStorageShowTagsOnDeckPreviews; - } - [[nodiscard]] int getVisualDeckStorageCardSize() const - { - return visualDeckStorageCardSize; - } - [[nodiscard]] bool getVisualDeckStorageDrawUnusedColorIdentities() const - { - return visualDeckStorageDrawUnusedColorIdentities; - } - [[nodiscard]] int getVisualDeckStorageUnusedColorIdentitiesOpacity() const - { - return visualDeckStorageUnusedColorIdentitiesOpacity; - } - [[nodiscard]] int getVisualDeckStorageTooltipType() const - { - return visualDeckStorageTooltipType; - } - [[nodiscard]] bool getVisualDeckStoragePromptForConversion() const - { - return visualDeckStoragePromptForConversion; - } - [[nodiscard]] bool getVisualDeckStorageAlwaysConvert() const - { - return visualDeckStorageAlwaysConvert; - } - [[nodiscard]] bool getVisualDeckStorageInGame() const - { - return visualDeckStorageInGame; - } - [[nodiscard]] bool getVisualDeckStorageSelectionAnimation() const - { - return visualDeckStorageSelectionAnimation; - } - [[nodiscard]] int getVisualDeckEditorCardSize() const - { - return visualDeckEditorCardSize; - } - [[nodiscard]] int getVisualDatabaseDisplayCardSize() const - { - return visualDatabaseDisplayCardSize; - } - [[nodiscard]] int getEDHRecCardSize() const - { - return edhrecCardSize; - } - [[nodiscard]] int getArchidektPreviewSize() const - { - return archidektPreviewSize; - } - [[nodiscard]] int getDefaultDeckEditorType() const - { - return defaultDeckEditorType; - } - [[nodiscard]] bool getVisualDatabaseDisplayFilterToMostRecentSetsEnabled() const - { - return visualDatabaseDisplayFilterToMostRecentSetsEnabled; - } - [[nodiscard]] int getVisualDatabaseDisplayFilterToMostRecentSetsAmount() const - { - return visualDatabaseDisplayFilterToMostRecentSetsAmount; - } - [[nodiscard]] int getVisualDeckEditorSampleHandSize() const - { - return visualDeckEditorSampleHandSize; - } - [[nodiscard]] bool getHorizontalHand() const - { - return horizontalHand; - } - [[nodiscard]] bool getInvertVerticalCoordinate() const - { - return invertVerticalCoordinate; - } - [[nodiscard]] int getMinPlayersForMultiColumnLayout() const - { - return minPlayersForMultiColumnLayout; - } - [[nodiscard]] bool getTapAnimation() const - { - return tapAnimation; - } - [[nodiscard]] bool getAutoRotateSidewaysLayoutCards() const - { - return autoRotateSidewaysLayoutCards; - } - [[nodiscard]] bool getOpenDeckInNewTab() const - { - return openDeckInNewTab; - } - [[nodiscard]] int getRewindBufferingMs() const - { - return rewindBufferingMs; - } - [[nodiscard]] bool getChatMention() const - { - return chatMention; - } - [[nodiscard]] bool getChatMentionCompleter() const - { - return chatMentionCompleter; - } - [[nodiscard]] bool getChatMentionForeground() const - { - return chatMentionForeground; - } - [[nodiscard]] bool getChatHighlightForeground() const - { - return chatHighlightForeground; - } - /** - * Currently selected index for the `Group by X` QComboBox - */ - [[nodiscard]] int getZoneViewGroupByIndex() const - { - return zoneViewGroupByIndex; - } - /** - * Currently selected index for the `Sort by X` QComboBox - */ - [[nodiscard]] int getZoneViewSortByIndex() const - { - return zoneViewSortByIndex; - } - /** - Returns if the view should be sorted into pile view. - @return zoneViewPileView if the view should be sorted into pile view. - */ - [[nodiscard]] bool getZoneViewPileView() const - { - return zoneViewPileView; - } - [[nodiscard]] bool getSoundEnabled() const - { - return soundEnabled; - } - [[nodiscard]] QString getSoundThemeName() const - { - return soundThemeName; - } - [[nodiscard]] bool getIgnoreUnregisteredUsers() const - { - return ignoreUnregisteredUsers; - } - [[nodiscard]] bool getIgnoreUnregisteredUserMessages() const - { - return ignoreUnregisteredUserMessages; - } - [[nodiscard]] bool getIgnoreNonBuddyUserMessages() const - { - return ignoreNonBuddyUserMessages; - } - [[nodiscard]] int getPixmapCacheSize() const - { - return pixmapCacheSize; - } - [[nodiscard]] CardPictureLoaderCacheMethod::CacheMethod getCardPictureLoaderCacheMethod() const - { - return static_cast(cardPictureLoaderCacheMethod); - } - [[nodiscard]] int getNetworkCacheSizeInMB() const - { - return networkCacheSize; - } - [[nodiscard]] int getRedirectCacheTtl() const - { - return redirectCacheTtl; - } - [[nodiscard]] CardPictureLoaderLocalSchemes::NamingScheme getLocalCardImageStorageNamingScheme() const - { - return static_cast(localCardImageStorageNamingScheme); - } - [[nodiscard]] bool getScaleCards() const - { - return scaleCards; - } - [[nodiscard]] int getStackCardOverlapPercent() const - { - return verticalCardOverlapPercent; - } - [[nodiscard]] bool getShowMessagePopup() const - { - return showMessagePopups; - } - [[nodiscard]] bool getShowMentionPopup() const - { - return showMentionPopups; - } - [[nodiscard]] bool getRoomHistory() const - { - return roomHistory; - } - [[nodiscard]] bool getLeftJustified() const - { - return leftJustified; - } - [[nodiscard]] int getMasterVolume() const - { - return masterVolume; - } - [[nodiscard]] int getCardInfoViewMode() const - { - return cardInfoViewMode; - } + [[nodiscard]] ReleaseChannel *getUpdateReleaseChannel() const; + [[nodiscard]] QList getUpdateReleaseChannels() const; + [[nodiscard]] int getUpdateReleaseChannelIndex() const; + [[nodiscard]] QStringList getCountries() const; - [[nodiscard]] QString getHighlightWords() const - { - return highlightWords; - } - [[nodiscard]] QString getGameDescription() const - { - return gameDescription; - } - [[nodiscard]] int getMaxPlayers() const - { - return maxPlayers; - } - [[nodiscard]] QString getGameTypes() const - { - return gameTypes; - } - [[nodiscard]] bool getOnlyBuddies() const - { - return onlyBuddies; - } - [[nodiscard]] bool getOnlyRegistered() const - { - return onlyRegistered; - } - [[nodiscard]] bool getSpectatorsAllowed() const - { - return spectatorsAllowed; - } - [[nodiscard]] bool getSpectatorsNeedPassword() const - { - return spectatorsNeedPassword; - } - [[nodiscard]] bool getSpectatorsCanTalk() const - { - return spectatorsCanTalk; - } - [[nodiscard]] bool getSpectatorsCanSeeEverything() const - { - return spectatorsCanSeeEverything; - } - [[nodiscard]] int getDefaultStartingLifeTotal() const - { - return defaultStartingLifeTotal; - } - [[nodiscard]] bool getShareDecklistsOnLoad() const - { - return shareDecklistsOnLoad; - } - [[nodiscard]] bool getCreateGameAsSpectator() const - { - return createGameAsSpectator; - } - [[nodiscard]] bool getRememberGameSettings() const - { - return rememberGameSettings; - } - [[nodiscard]] bool getLocalGameRememberSettings() const - { - return localGameRememberSettings; - } - [[nodiscard]] int getLocalGameMaxPlayers() const - { - return localGameMaxPlayers; - } - [[nodiscard]] int getLocalGameStartingLifeTotal() const - { - return localGameStartingLifeTotal; - } - [[nodiscard]] int getKeepAlive() const override - { - return keepalive; - } - [[nodiscard]] int getTimeOut() const override - { - return timeout; - } - [[nodiscard]] int getMaxFontSize() const - { - return maxFontSize; - } - void setClientID(const QString &clientID); - void setClientVersion(const QString &clientVersion); - void setKnownMissingFeatures(const QString &_knownMissingFeatures) override; - void setUseTearOffMenus(bool _useTearOffMenus); - void setCardViewInitialRowsMax(int _cardViewInitialRowsMax); - void setCardViewExpandedRowsMax(int value); - void setCloseEmptyCardView(QT_STATE_CHANGED_T value); - void setFocusCardViewSearchBar(QT_STATE_CHANGED_T value); - void setKeepGameChatFocus(QT_STATE_CHANGED_T value); - QString getClientID() override - { - return clientID; - } - QString getClientVersion() - { - return clientVersion; - } - QString getKnownMissingFeatures() override - { - return knownMissingFeatures; - } - bool getUseTearOffMenus() - { - return useTearOffMenus; - } - [[nodiscard]] int getCardViewInitialRowsMax() const - { - return cardViewInitialRowsMax; - } - [[nodiscard]] int getCardViewExpandedRowsMax() const - { - return cardViewExpandedRowsMax; - } - [[nodiscard]] bool getCloseEmptyCardView() const - { - return closeEmptyCardView; - } - [[nodiscard]] bool getFocusCardViewSearchBar() const - { - return focusCardViewSearchBar; - } - [[nodiscard]] bool getKeepGameChatFocus() const - { - return keepGameChatFocus; - } - [[nodiscard]] ShortcutsSettings &shortcuts() const - { - return *shortcutsSettings; - } - [[nodiscard]] CardDatabaseSettings *cardDatabase() const - { - return cardDatabaseSettings; - } - [[nodiscard]] ServersSettings &servers() const - { - return *serversSettings; - } - [[nodiscard]] MessageSettings &messages() const - { - return *messageSettings; - } - [[nodiscard]] GameFiltersSettings &gameFilters() const - { - return *gameFiltersSettings; - } - [[nodiscard]] LayoutsSettings &layouts() const - { - return *layoutsSettings; - } - [[nodiscard]] DownloadSettings &downloads() const - { - return *downloadSettings; - } - [[nodiscard]] RecentsSettings &recents() const - { - return *recentsSettings; - } - [[nodiscard]] CardOverrideSettings &cardOverrides() const - { - return *cardOverrideSettings; - } - [[nodiscard]] DebugSettings &debug() const - { - return *debugSettings; - } + + [[nodiscard]] ShortcutsSettings &shortcuts() const; + [[nodiscard]] CardDatabaseSettings &cardDatabase() const; + [[nodiscard]] ServersSettings &servers() const; + [[nodiscard]] MessageSettings &messages() const; + [[nodiscard]] GameFiltersSettings &gameFilters() const; + [[nodiscard]] LayoutsSettings &layouts() const; + [[nodiscard]] DownloadSettings &downloads() const; + [[nodiscard]] RecentsSettings &recents() const; + [[nodiscard]] CardOverrideSettings &cardOverrides() const; + [[nodiscard]] DebugSettings &debug() const; [[nodiscard]] CardCounterSettings &cardCounters() const; + [[nodiscard]] TabsSettings &tabs() const; + [[nodiscard]] SoundSettings &sound() const; + [[nodiscard]] GameSettings &game() const; + [[nodiscard]] ChatSettings &chat() const; + [[nodiscard]] CacheStorageSettings &cacheStorage() const; + [[nodiscard]] UpdatesSettings &updates() const; + [[nodiscard]] PersonalSettings &personal() const; + [[nodiscard]] CardsDisplaySettings &cardsDisplay() const; + [[nodiscard]] InterfaceSettings &userInterface() const; + [[nodiscard]] DeckEditorSettings &deckEditor() const; + [[nodiscard]] PathsSettings &paths() const; + [[nodiscard]] VisualDeckStorageSettings &visualDeckStorage() const; + [[nodiscard]] AppearanceSettings &appearance() const; + [[nodiscard]] NetworkSettings &network() const; + [[nodiscard]] CommanderBracketSettings &commanderBrackets() const + { + return *commanderBracketSettings; + } [[nodiscard]] bool getIsPortableBuild() const { return isPortableBuild; } - [[nodiscard]] bool getDownloadSpoilersStatus() const - { - return mbDownloadSpoilers; - } - [[nodiscard]] bool getRoundCardCorners() const - { - return roundCardCorners; - } static SettingsCache &instance(); void resetPaths(); public slots: - void setDownloadSpoilerStatus(bool _spoilerStatus); - - void setLang(const QString &_lang); - void setShowTipsOnStartup(bool _showTipsOnStartup); - void setSeenTips(const QList &_seenTips); - void setDeckPath(const QString &_deckPath); - void setFiltersPath(const QString &_filtersPath); - void setReplaysPath(const QString &_replaysPath); - void setThemesPath(const QString &_themesPath); - void setCustomCardDatabasePath(const QString &_customCardDatabasePath); - void setPicsPath(const QString &_picsPath); - void setCardDatabasePath(const QString &_cardDatabasePath); - void setSpoilerDatabasePath(const QString &_spoilerDatabasePath); - void setTokenDatabasePath(const QString &_tokenDatabasePath); void setThemeName(const QString &_themeName); - void setHomeTabBackgroundSource(const QString &_backgroundSource); - void setHomeTabBackgroundShuffleFrequency(int _frequency); - void setHomeTabDisplayCardName(QT_STATE_CHANGED_T _displayCardName); - void setTabVisualDeckStorageOpen(bool value); - void setTabServerOpen(bool value); - void setTabAccountOpen(bool value); - void setTabDeckStorageOpen(bool value); - void setTabReplaysOpen(bool value); - void setTabAdminOpen(bool value); - void setTabLogOpen(bool value); - void setChatMentionColor(const QString &_chatMentionColor); - void setChatHighlightColor(const QString &_chatHighlightColor); - void setPicDownload(QT_STATE_CHANGED_T _picDownload); - void setShowStatusBar(bool value); - void setNotificationsEnabled(QT_STATE_CHANGED_T _notificationsEnabled); - void setSpectatorNotificationsEnabled(QT_STATE_CHANGED_T _spectatorNotificationsEnabled); - void setBuddyConnectNotificationsEnabled(QT_STATE_CHANGED_T _buddyConnectNotificationsEnabled); - void setDoubleClickToPlay(QT_STATE_CHANGED_T _doubleClickToPlay); - void setClickPlaysAllSelected(QT_STATE_CHANGED_T _clickPlaysAllSelected); - void setPlayToStack(QT_STATE_CHANGED_T _playToStack); - void setDoNotDeleteArrowsInSubPhases(QT_STATE_CHANGED_T _doNotDeleteArrowsInSubPhases); - void setStartingHandSize(int _startingHandSize); - void setAnnotateTokens(QT_STATE_CHANGED_T _annotateTokens); - void setShowShortcuts(QT_STATE_CHANGED_T _showShortcuts); - void setShowGameSelectorFilterToolbar(QT_STATE_CHANGED_T _showGameSelectorFilterToolbar); - void setDisplayCardNames(QT_STATE_CHANGED_T _displayCardNames); - void setOverrideAllCardArtWithPersonalPreference(QT_STATE_CHANGED_T _overrideAllCardArt); - void setBumpSetsWithCardsInDeckToTop(QT_STATE_CHANGED_T _bumpSetsWithCardsInDeckToTop); - void setPrintingSelectorSortOrder(int _printingSelectorSortOrder); - void setPrintingSelectorCardSize(int _printingSelectorCardSize); - void setIncludeRebalancedCards(bool _includeRebalancedCards); - void setPrintingSelectorNavigationButtonsVisible(QT_STATE_CHANGED_T _navigationButtonsVisible); - void setDeckEditorBannerCardComboBoxVisible(QT_STATE_CHANGED_T _deckEditorBannerCardComboBoxVisible); - void setDeckEditorTagsWidgetVisible(QT_STATE_CHANGED_T _deckEditorTagsWidgetVisible); - void setVisualDeckStorageSortingOrder(int _visualDeckStorageSortingOrder); - void setVisualDeckStorageShowFolders(QT_STATE_CHANGED_T value); - void setVisualDeckStorageShowTagFilter(QT_STATE_CHANGED_T _showTags); - void setVisualDeckStorageDefaultTagsList(QStringList _defaultTagsList); - void setVisualDeckStorageSearchFolderNames(QT_STATE_CHANGED_T value); - void setVisualDeckStorageShowColorIdentity(QT_STATE_CHANGED_T value); - void setVisualDeckStorageShowBannerCardComboBox(QT_STATE_CHANGED_T _showBannerCardComboBox); - void setVisualDeckStorageShowTagsOnDeckPreviews(QT_STATE_CHANGED_T _showTags); - void setVisualDeckStorageCardSize(int _visualDeckStorageCardSize); - void setVisualDeckStorageDrawUnusedColorIdentities(QT_STATE_CHANGED_T _visualDeckStorageDrawUnusedColorIdentities); - void setVisualDeckStorageUnusedColorIdentitiesOpacity(int _visualDeckStorageUnusedColorIdentitiesOpacity); - void setVisualDeckStorageTooltipType(int value); - void setVisualDeckStoragePromptForConversion(bool _visualDeckStoragePromptForConversion); - void setVisualDeckStorageAlwaysConvert(bool _visualDeckStorageAlwaysConvert); - void setVisualDeckStorageInGame(QT_STATE_CHANGED_T value); - void setVisualDeckStorageSelectionAnimation(QT_STATE_CHANGED_T value); - void setVisualDeckEditorCardSize(int _visualDeckEditorCardSize); - void setVisualDatabaseDisplayCardSize(int _visualDatabaseDisplayCardSize); - void setEDHRecCardSize(int _EDHRecCardSize); - void setArchidektPreviewCardSize(int _archidektPreviewCardSize); - void setDefaultDeckEditorType(int value); - void setVisualDatabaseDisplayFilterToMostRecentSetsEnabled(QT_STATE_CHANGED_T _enabled); - void setVisualDatabaseDisplayFilterToMostRecentSetsAmount(int _amount); - void setVisualDeckEditorSampleHandSize(int _amount); - void setHorizontalHand(QT_STATE_CHANGED_T _horizontalHand); - void setInvertVerticalCoordinate(QT_STATE_CHANGED_T _invertVerticalCoordinate); - void setMinPlayersForMultiColumnLayout(int _minPlayersForMultiColumnLayout); - void setTapAnimation(QT_STATE_CHANGED_T _tapAnimation); - void setAutoRotateSidewaysLayoutCards(QT_STATE_CHANGED_T _autoRotateSidewaysLayoutCards); - void setOpenDeckInNewTab(QT_STATE_CHANGED_T _openDeckInNewTab); - void setRewindBufferingMs(int _rewindBufferingMs); - void setChatMention(QT_STATE_CHANGED_T _chatMention); - void setChatMentionCompleter(QT_STATE_CHANGED_T _chatMentionCompleter); - void setChatMentionForeground(QT_STATE_CHANGED_T _chatMentionForeground); - void setChatHighlightForeground(QT_STATE_CHANGED_T _chatHighlightForeground); - void setZoneViewGroupByIndex(const int _zoneViewGroupByIndex); - void setZoneViewSortByIndex(const int _zoneViewSortByIndex); - void setZoneViewPileView(QT_STATE_CHANGED_T _zoneViewPileView); - void setSoundEnabled(QT_STATE_CHANGED_T _soundEnabled); - void setSoundThemeName(const QString &_soundThemeName); - void setIgnoreUnregisteredUsers(QT_STATE_CHANGED_T _ignoreUnregisteredUsers); - void setIgnoreUnregisteredUserMessages(QT_STATE_CHANGED_T _ignoreUnregisteredUserMessages); - void setIgnoreNonBuddyUserMessages(QT_STATE_CHANGED_T _ignoreNonBuddyUserMessages); - void setPixmapCacheSize(const int _pixmapCacheSize); - void setCardImageCacheMethod(CardPictureLoaderCacheMethod::CacheMethod _cardImageCachingMethod); - void setNetworkCacheSizeInMB(const int _networkCacheSize); - void setNetworkRedirectCacheTtl(const int _redirectCacheTtl); - void setLocalCardImageStorageNamingScheme( - const CardPictureLoaderLocalSchemes::NamingScheme _localCardImageStorageNamingScheme); - void setCardScaling(const QT_STATE_CHANGED_T _scaleCards); - void setStackCardOverlapPercent(const int _verticalCardOverlapPercent); - void setShowMessagePopups(const QT_STATE_CHANGED_T _showMessagePopups); - void setShowMentionPopups(const QT_STATE_CHANGED_T _showMentionPopups); - void setRoomHistory(const QT_STATE_CHANGED_T _roomHistory); - void setLeftJustified(const QT_STATE_CHANGED_T _leftJustified); - void setMasterVolume(const int _masterVolume); - void setCardInfoViewMode(const int _viewMode); - void setHighlightWords(const QString &_highlightWords); - void setGameDescription(const QString _gameDescription); - void setMaxPlayers(const int _maxPlayers); - void setGameTypes(const QString _gameTypes); - void setOnlyBuddies(const bool _onlyBuddies); - void setOnlyRegistered(const bool _onlyRegistered); - void setSpectatorsAllowed(const bool _spectatorsAllowed); - void setSpectatorsNeedPassword(const bool _spectatorsNeedPassword); - void setSpectatorsCanTalk(const bool _spectatorsCanTalk); - void setSpectatorsCanSeeEverything(const bool _spectatorsCanSeeEverything); - void setCreateGameAsSpectator(const bool _createGameAsSpectator); - void setDefaultStartingLifeTotal(const int _defaultStartingLifeTotal); - void setShareDecklistsOnLoad(const bool _shareDecklistsOnLoad); - void setRememberGameSettings(const bool _rememberGameSettings); - void setLocalGameRememberSettings(bool value); - void setLocalGameMaxPlayers(int value); - void setLocalGameStartingLifeTotal(int value); - void setCheckUpdatesOnStartup(QT_STATE_CHANGED_T value); - void setStartupCardUpdateCheckPromptForUpdate(bool value); - void setStartupCardUpdateCheckAlwaysUpdate(bool value); - void setCardUpdateCheckInterval(int value); - void setLastCardUpdateCheck(QDate value); - void setAlwaysEnableNewSets(bool value); - void setNotifyAboutUpdate(QT_STATE_CHANGED_T _notifyaboutupdate); - void setNotifyAboutNewVersion(QT_STATE_CHANGED_T _notifyaboutnewversion); - void setUpdateReleaseChannelIndex(int value); - void setMaxFontSize(int _max); - void setRoundCardCorners(bool _roundCardCorners); - void setShowDragSelectionCount(QT_STATE_CHANGED_T _showDragSelectionCount); - void setShowTotalSelectionCount(QT_STATE_CHANGED_T _showTotalSelectionCount); }; #endif diff --git a/cockatrice/src/client/settings/card_counter_settings.cpp b/cockatrice/src/client/settings/card_counter_settings.cpp index 662ae0c7d..d4030c174 100644 --- a/cockatrice/src/client/settings/card_counter_settings.cpp +++ b/cockatrice/src/client/settings/card_counter_settings.cpp @@ -5,7 +5,7 @@ #include CardCounterSettings::CardCounterSettings(const QString &settingsPath, QObject *parent) - : SettingsManager(settingsPath + "global.ini", "cards", "counters", parent) + : SettingsManager(settingsPath + "card_counters.ini", "cards", "counters", parent) { } diff --git a/cockatrice/src/client/settings/shortcuts_settings.h b/cockatrice/src/client/settings/shortcuts_settings.h index 95155b8d1..f4ebc204e 100644 --- a/cockatrice/src/client/settings/shortcuts_settings.h +++ b/cockatrice/src/client/settings/shortcuts_settings.h @@ -786,6 +786,10 @@ private: ShortcutGroup::Tabs)}, {"Tabs/aTabLogs", ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Logs"), parseSequenceString(""), ShortcutGroup::Tabs)}, + {"Tabs/aTabReport", + ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Report Queue"), parseSequenceString(""), ShortcutGroup::Tabs)}, + {"Tabs/aTabModeration", + ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Moderation"), parseSequenceString(""), ShortcutGroup::Tabs)}, }; }; diff --git a/cockatrice/src/client/sound_engine.cpp b/cockatrice/src/client/sound_engine.cpp index e592b5ea0..96cafa3d3 100644 --- a/cockatrice/src/client/sound_engine.cpp +++ b/cockatrice/src/client/sound_engine.cpp @@ -2,12 +2,11 @@ #include "settings/cache_settings.h" +#include +#include #include #include - -#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) -#include -#endif +#include #define DEFAULT_THEME_NAME "Default" #define TEST_SOUND_FILENAME "player_join" @@ -15,8 +14,10 @@ SoundEngine::SoundEngine(QObject *parent) : QObject(parent), audioOutput(nullptr), player(nullptr) { ensureThemeDirectoryExists(); - connect(&SettingsCache::instance(), &SettingsCache::soundThemeChanged, this, &SoundEngine::themeChangedSlot); - connect(&SettingsCache::instance(), &SettingsCache::soundEnabledChanged, this, &SoundEngine::soundEnabledChanged); + connect(&SettingsCache::instance().sound(), &SoundSettings::soundThemeChanged, this, + &SoundEngine::themeChangedSlot); + connect(&SettingsCache::instance().sound(), &SoundSettings::soundEnabledChanged, this, + &SoundEngine::soundEnabledChanged); soundEnabledChanged(); themeChangedSlot(); @@ -36,14 +37,12 @@ SoundEngine::~SoundEngine() void SoundEngine::soundEnabledChanged() { - if (SettingsCache::instance().getSoundEnabled()) { + if (SettingsCache::instance().sound().getSoundEnabled()) { qCInfo(SoundEngineLog) << "SoundEngine: enabling sound with" << audioData.size() << "sounds"; if (!player) { player = new QMediaPlayer; -#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) audioOutput = new QAudioOutput(player); player->setAudioOutput(audioOutput); -#endif } } else { qCInfo(SoundEngineLog) << "SoundEngine: disabling sound"; @@ -70,14 +69,9 @@ void SoundEngine::playSound(const QString &fileName) } player->stop(); - int volumeSliderValue = SettingsCache::instance().getMasterVolume(); -#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) + int volumeSliderValue = SettingsCache::instance().sound().getMasterVolume(); player->audioOutput()->setVolume(qreal(volumeSliderValue) / 100); player->setSource(QUrl::fromLocalFile(audioData[fileName])); -#else - player->setVolume(volumeSliderValue); - player->setMedia(QUrl::fromLocalFile(audioData[fileName])); -#endif player->play(); } @@ -88,10 +82,10 @@ void SoundEngine::testSound() void SoundEngine::ensureThemeDirectoryExists() { - if (SettingsCache::instance().getSoundThemeName().isEmpty() || - !getAvailableThemes().contains(SettingsCache::instance().getSoundThemeName())) { + if (SettingsCache::instance().sound().getSoundThemeName().isEmpty() || + !getAvailableThemes().contains(SettingsCache::instance().sound().getSoundThemeName())) { qCInfo(SoundEngineLog) << "Sounds theme name not set, setting default value"; - SettingsCache::instance().setSoundThemeName(DEFAULT_THEME_NAME); + SettingsCache::instance().sound().setSoundThemeName(DEFAULT_THEME_NAME); } } @@ -100,7 +94,7 @@ QStringMap &SoundEngine::getAvailableThemes() QDir dir; availableThemes.clear(); - // load themes from user profile dir + // Load themes from user profile dir dir.setPath(SettingsCache::instance().getDataPath() + "/sounds"); @@ -110,7 +104,7 @@ QStringMap &SoundEngine::getAvailableThemes() } } - // load themes from cockatrice system dir + // Load themes from Cockatrice system dir dir.setPath(qApp->applicationDirPath() + #ifdef Q_OS_MAC "/../Resources/sounds" @@ -132,7 +126,7 @@ QStringMap &SoundEngine::getAvailableThemes() void SoundEngine::themeChangedSlot() { - QString themeName = SettingsCache::instance().getSoundThemeName(); + QString themeName = SettingsCache::instance().sound().getSoundThemeName(); qCInfo(SoundEngineLog) << "Sound theme changed:" << themeName; QDir dir = getAvailableThemes().value(themeName); diff --git a/cockatrice/src/client/url_scheme_event_filter.h b/cockatrice/src/client/url_scheme_event_filter.h new file mode 100644 index 000000000..9e96502ca --- /dev/null +++ b/cockatrice/src/client/url_scheme_event_filter.h @@ -0,0 +1,69 @@ +#ifndef COCKATRICE_URL_SCHEME_EVENT_FILTER_H +#define COCKATRICE_URL_SCHEME_EVENT_FILTER_H + +#include +#include +#include +#include +#include + +/** + * @brief Event filter that catches QFileOpenEvent URLs matching a scheme and + * re-emits them as urlReceived(). + * + * On macOS, when the application is registered as a URL scheme handler, the + * OS delivers incoming URLs via QFileOpenEvent on the QApplication object. + * Install this filter on QApplication to intercept them: + * + * @code + * UrlSchemeEventFilter filter(QStringList{QStringLiteral("cockatrice")}); + * QObject::connect(&filter, &UrlSchemeEventFilter::urlReceived, + * &mainWindow, &MainWindow::handleUrl); + * app.installEventFilter(&filter); + * @endcode + * + * Note: the strings are compared against QUrl::scheme(), so they must be + * written without the "://" suffix (e.g. "cockatrice", not "cockatrice://"). + */ +class UrlSchemeEventFilter : public QObject +{ + Q_OBJECT + +public: + explicit UrlSchemeEventFilter(const QStringList &schemes, QObject *parent = nullptr) + : QObject(parent), prefixes(schemes) + { + } + +signals: + void urlReceived(const QString &url); + +public: + bool eventFilter(QObject *watched, QEvent *event) override + { + if (event->type() == QEvent::FileOpen) { + auto *fileEvent = static_cast(event); + + const QUrl url = fileEvent->url(); + + for (const auto &prefix : prefixes) { + if (url.scheme() == prefix) { + emit urlReceived(url.toString()); + return true; + } + } + + if (url.isLocalFile()) { + emit urlReceived(url.toLocalFile()); + return true; + } + } + + return QObject::eventFilter(watched, event); + } + +private: + QStringList prefixes; +}; + +#endif // COCKATRICE_URL_SCHEME_EVENT_FILTER_H diff --git a/cockatrice/src/database/interface/settings_card_database_path_provider.h b/cockatrice/src/database/interface/settings_card_database_path_provider.h new file mode 100644 index 000000000..1c7d02460 --- /dev/null +++ b/cockatrice/src/database/interface/settings_card_database_path_provider.h @@ -0,0 +1,41 @@ +#ifndef SETTINGS_CARD_DATABASE_PATH_PROVIDER_H +#define SETTINGS_CARD_DATABASE_PATH_PROVIDER_H + +#include "../../client/settings/cache_settings.h" + +#include +#include + +class SettingsCardDatabasePathProvider : public ICardDatabasePathProvider +{ + Q_OBJECT + +public: + explicit SettingsCardDatabasePathProvider(QObject *parent = nullptr) : ICardDatabasePathProvider(parent) + { + connect(&SettingsCache::instance().paths(), &PathsSettings::cardDatabasePathChanged, this, + &ICardDatabasePathProvider::cardDatabasePathChanged); + } + + [[nodiscard]] QString getCardDatabasePath() const override + { + return SettingsCache::instance().paths().getCardDatabasePath(); + } + + [[nodiscard]] QString getCustomCardDatabasePath() const override + { + return SettingsCache::instance().paths().getCustomCardDatabasePath(); + } + + [[nodiscard]] QString getTokenDatabasePath() const override + { + return SettingsCache::instance().paths().getTokenDatabasePath(); + } + + [[nodiscard]] virtual QString getSpoilerCardDatabasePath() const override + { + return SettingsCache::instance().paths().getSpoilerCardDatabasePath(); + } +}; + +#endif // SETTINGS_CARD_DATABASE_PATH_PROVIDER_H diff --git a/cockatrice/src/database/interface/settings_card_preference_provider.h b/cockatrice/src/database/interface/settings_card_preference_provider.h index 6c31b9d6d..64502e3a7 100644 --- a/cockatrice/src/database/interface/settings_card_preference_provider.h +++ b/cockatrice/src/database/interface/settings_card_preference_provider.h @@ -3,6 +3,8 @@ #include "../../client/settings/cache_settings.h" #include +#include +#include class SettingsCardPreferenceProvider : public ICardPreferenceProvider { @@ -14,7 +16,7 @@ public: [[nodiscard]] bool getIncludeRebalancedCards() const override { - return SettingsCache::instance().getIncludeRebalancedCards(); + return SettingsCache::instance().cardsDisplay().getIncludeRebalancedCards(); } }; diff --git a/cockatrice/src/filters/deck_filter_string.cpp b/cockatrice/src/filters/deck_filter_string.cpp index dd873cfa5..4abb8210c 100644 --- a/cockatrice/src/filters/deck_filter_string.cpp +++ b/cockatrice/src/filters/deck_filter_string.cpp @@ -52,18 +52,14 @@ static void setupParserRules() search["Start"] = passthru; search["QueryPartList"] = [](const peg::SemanticValues &sv) -> DeckFilter { - return [=](const DeckPreviewWidget *deck, const ExtraDeckSearchInfo &info) { - auto matchesFilter = [&deck, &info](const std::any &query) { - return std::any_cast(query)(deck, info); - }; + return [=](const DeckSearchData &data) { + auto matchesFilter = [&data](const std::any &query) { return std::any_cast(query)(data); }; return std::all_of(sv.begin(), sv.end(), matchesFilter); }; }; search["ComplexQueryPart"] = [](const peg::SemanticValues &sv) -> DeckFilter { - return [=](const DeckPreviewWidget *deck, const ExtraDeckSearchInfo &info) { - auto matchesFilter = [&deck, &info](const std::any &query) { - return std::any_cast(query)(deck, info); - }; + return [=](const DeckSearchData &data) { + auto matchesFilter = [&data](const std::any &query) { return std::any_cast(query)(data); }; return std::any_of(sv.begin(), sv.end(), matchesFilter); }; }; @@ -71,9 +67,7 @@ static void setupParserRules() search["QueryPart"] = passthru; search["NotQuery"] = [](const peg::SemanticValues &sv) -> DeckFilter { const auto dependent = std::any_cast(sv[0]); - return [=](const DeckPreviewWidget *deck, const ExtraDeckSearchInfo &info) -> bool { - return !dependent(deck, info); - }; + return [=](const DeckSearchData &data) -> bool { return !dependent(data); }; }; search["String"] = [](const peg::SemanticValues &sv) -> QString { @@ -125,9 +119,9 @@ static void setupParserRules() auto cardFilter = FilterString(std::any_cast(sv[0])); auto numberMatcher = sv.size() > 1 ? std::any_cast(sv[1]) : [](int count) { return count > 0; }; - return [=](const DeckPreviewWidget *deck, const ExtraDeckSearchInfo &) -> bool { + return [=](const DeckSearchData &data) -> bool { int count = 0; - auto cardNodes = deck->deckLoader->getDeck().deckList.getCardNodes(); + auto cardNodes = data.deck->deckList.getCardNodes(); for (auto node : cardNodes) { auto cardInfoPtr = CardDatabaseManager::query()->getCardInfo(node->getName()); if (!cardInfoPtr.isNull() && cardFilter.check(cardInfoPtr)) { @@ -146,53 +140,49 @@ static void setupParserRules() search["DeckNameQuery"] = [](const peg::SemanticValues &sv) -> DeckFilter { auto name = std::any_cast(sv[0]); - return [=](const DeckPreviewWidget *deck, const ExtraDeckSearchInfo &) { - return deck->deckLoader->getDeck().deckList.getName().contains(name, Qt::CaseInsensitive); + return [=](const DeckSearchData &data) { + return data.deck->deckList.getName().contains(name, Qt::CaseInsensitive); }; }; search["FileNameQuery"] = [](const peg::SemanticValues &sv) -> DeckFilter { auto name = std::any_cast(sv[0]); - return [=](const DeckPreviewWidget *deck, const ExtraDeckSearchInfo &) { - auto filename = QFileInfo(deck->filePath).fileName(); + return [=](const DeckSearchData &data) { + auto filename = QFileInfo(data.filePath).fileName(); return filename.contains(name, Qt::CaseInsensitive); }; }; search["PathQuery"] = [](const peg::SemanticValues &sv) -> DeckFilter { auto name = std::any_cast(sv[0]); - return [=](const DeckPreviewWidget *, const ExtraDeckSearchInfo &info) { - return info.relativeFilePath.contains(name, Qt::CaseInsensitive); - }; + return [=](const DeckSearchData &data) { return data.relativeFilePath.contains(name, Qt::CaseInsensitive); }; }; search["FormatQuery"] = [](const peg::SemanticValues &sv) -> DeckFilter { auto format = std::any_cast(sv[0]); - return [=](const DeckPreviewWidget *deck, const ExtraDeckSearchInfo &) { - auto gameFormat = deck->deckLoader->getDeck().deckList.getGameFormat(); + return [=](const DeckSearchData &data) { + auto gameFormat = data.deck->deckList.getGameFormat(); return QString::compare(format, gameFormat, Qt::CaseInsensitive) == 0; }; }; search["CommentQuery"] = [](const peg::SemanticValues &sv) -> DeckFilter { auto value = std::any_cast(sv[0]); - return [=](const DeckPreviewWidget *deck, const ExtraDeckSearchInfo &) { - auto comments = deck->deckLoader->getDeck().deckList.getComments(); + return [=](const DeckSearchData &data) { + auto comments = data.deck->deckList.getComments(); return comments.contains(value, Qt::CaseInsensitive); }; }; search["GenericQuery"] = [](const peg::SemanticValues &sv) -> DeckFilter { auto name = std::any_cast(sv[0]); - return [=](const DeckPreviewWidget *deck, const ExtraDeckSearchInfo &) { - return deck->getDisplayName().contains(name, Qt::CaseInsensitive); - }; + return [=](const DeckSearchData &data) { return data.displayName.contains(name, Qt::CaseInsensitive); }; }; } DeckFilterString::DeckFilterString() { - filter = [](const DeckPreviewWidget *, const ExtraDeckSearchInfo &) { return false; }; + filter = [](const DeckSearchData &) { return false; }; _error = "Not initialized"; } @@ -205,7 +195,7 @@ DeckFilterString::DeckFilterString(const QString &expr) _error = QString(); if (ba.isEmpty()) { - filter = [](const DeckPreviewWidget *, const ExtraDeckSearchInfo &) { return true; }; + filter = [](const DeckSearchData &) { return true; }; return; } @@ -215,6 +205,6 @@ DeckFilterString::DeckFilterString(const QString &expr) if (!search.parse(ba.data(), filter)) { qCInfo(DeckFilterStringLog).nospace() << "DeckFilterString error for " << expr << "; " << qPrintable(_error); - filter = [](const DeckPreviewWidget *, const ExtraDeckSearchInfo &) { return false; }; + filter = [](const DeckSearchData &) { return false; }; } -} \ No newline at end of file +} diff --git a/cockatrice/src/filters/deck_filter_string.h b/cockatrice/src/filters/deck_filter_string.h index 916b629ee..90a6a17eb 100644 --- a/cockatrice/src/filters/deck_filter_string.h +++ b/cockatrice/src/filters/deck_filter_string.h @@ -7,7 +7,7 @@ #ifndef DECK_FILTER_STRING_H #define DECK_FILTER_STRING_H -#include "../interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.h" +#include "../interface/deck_loader/loaded_deck.h" #include #include @@ -16,26 +16,29 @@ inline Q_LOGGING_CATEGORY(DeckFilterStringLog, "deck_filter_string"); /** - * Extra info relevant to filtering that isn't present in the DeckPreviewWidget + * The data a deck search expression is evaluated against. + * + * This is a data view rather than a widget pointer, so the same filter + * expression can be evaluated against a model or a live widget. */ -struct ExtraDeckSearchInfo +struct DeckSearchData { - /** - * The relative filepath starting from the deck folder - */ - QString relativeFilePath; + const LoadedDeck *deck = nullptr; ///< The loaded deck. Must not be null. + QString filePath; ///< Absolute path of the deck file. + QString displayName; ///< Deck name, or the file name if the deck has no name. + QString relativeFilePath; ///< File path relative to the deck folder. }; -typedef std::function DeckFilter; +typedef std::function DeckFilter; class DeckFilterString { public: DeckFilterString(); explicit DeckFilterString(const QString &expr); - bool check(const DeckPreviewWidget *deck, const ExtraDeckSearchInfo &info) const + bool check(const DeckSearchData &data) const { - return filter(deck, info); + return filter(data); } [[nodiscard]] bool valid() const diff --git a/cockatrice/src/filters/filter_builder.cpp b/cockatrice/src/filters/filter_builder.cpp index 785f753e7..f109fbcd3 100644 --- a/cockatrice/src/filters/filter_builder.cpp +++ b/cockatrice/src/filters/filter_builder.cpp @@ -1,5 +1,6 @@ #include "filter_builder.h" +#include "../interface/pixel_map_generator.h" #include "../interface/widgets/utility/custom_line_edit.h" #include @@ -21,7 +22,7 @@ FilterBuilder::FilterBuilder(QWidget *parent) : QWidget(parent) typeCombo->addItem(CardFilter::typeName(static_cast(i)), QVariant(i)); } - QPushButton *ok = new QPushButton(QPixmap("theme:icons/increment"), QString()); + QPushButton *ok = new QPushButton(themePixmap(QStringLiteral("icons/increment")), QString()); ok->setObjectName("ok"); ok->setMaximumSize(20, 20); diff --git a/cockatrice/src/game/abstract_game.cpp b/cockatrice/src/game/abstract_game.cpp index c20003ece..6aa2ab28f 100644 --- a/cockatrice/src/game/abstract_game.cpp +++ b/cockatrice/src/game/abstract_game.cpp @@ -31,7 +31,7 @@ AbstractClient *AbstractGame::getClientForPlayer(int playerId) const } } -void AbstractGame::loadReplay(GameReplay *replay) +void AbstractGame::loadReplay(const GameReplay *replay) { gameMetaInfo->setFromProto(replay->game_info()); gameMetaInfo->setSpectatorsOmniscient(true); diff --git a/cockatrice/src/game/abstract_game.h b/cockatrice/src/game/abstract_game.h index 5115ed5ca..fcf764492 100644 --- a/cockatrice/src/game/abstract_game.h +++ b/cockatrice/src/game/abstract_game.h @@ -53,7 +53,7 @@ public: AbstractClient *getClientForPlayer(int playerId) const; - void loadReplay(GameReplay *replay); + void loadReplay(const GameReplay *replay); CardItem *getCard(int playerId, const QString &zoneName, int cardId) const; diff --git a/cockatrice/src/game/board/counter_state.cpp b/cockatrice/src/game/board/counter_state.cpp index 6da18b662..0970e4272 100644 --- a/cockatrice/src/game/board/counter_state.cpp +++ b/cockatrice/src/game/board/counter_state.cpp @@ -13,12 +13,12 @@ CounterState *CounterState::fromProto(const ServerInfo_Counter &counter, QObject convertColorToQColor(counter.counter_color()), counter.radius(), counter.count(), parent); } -void CounterState::setValue(int newValue) +void CounterState::setValue(int newValue, bool skipDamageAnimation) { if (newValue == value) { return; } int old = value; value = newValue; - emit valueChanged(old, newValue); + emit valueChanged(old, newValue, skipDamageAnimation); } \ No newline at end of file diff --git a/cockatrice/src/game/board/counter_state.h b/cockatrice/src/game/board/counter_state.h index 0f2f16b55..4c7b34473 100644 --- a/cockatrice/src/game/board/counter_state.h +++ b/cockatrice/src/game/board/counter_state.h @@ -35,10 +35,23 @@ public: return value; } - void setValue(int newValue); + /** + * @brief Set the counter value. + * @param newValue The new value. + * @param skipDamageAnimation When true, valueChanged is emitted with skipDamageAnimation=true, letting views + * suppress damage-related feedback (e.g. battlefield shimmer, life counter flash) for values set during replay + * rewinds. + */ + void setValue(int newValue, bool skipDamageAnimation = false); signals: - void valueChanged(int oldValue, int newValue); + /** + * @brief Emitted whenever the value changes. + * @param oldValue The previous value. + * @param newValue The new value. + * @param skipDamageAnimation True when the change should not trigger damage/life-change feedback in views. + */ + void valueChanged(int oldValue, int newValue, bool skipDamageAnimation); private: int id; diff --git a/cockatrice/src/game/game_event_handler.cpp b/cockatrice/src/game/game_event_handler.cpp index 4a96eebdb..f146cdbb4 100644 --- a/cockatrice/src/game/game_event_handler.cpp +++ b/cockatrice/src/game/game_event_handler.cpp @@ -229,7 +229,11 @@ void GameEventHandler::handleArrowDeletion(int creatorId, int arrowId) void GameEventHandler::handleArrowDeletionFinished(const Response &response, int creatorId, int arrowId) { - if (response.response_code() == Response::RespNameNotFound) { + // The server confirms the arrow no longer exists whether it deleted it itself + // (RespOk, followed by an Event_DeleteArrow broadcast) or never had it + // (RespNameNotFound). In both cases the local copy has to go. deleteArrow is + // a no-op if the arrow was already removed by the event broadcast. + if (response.response_code() == Response::RespOk || response.response_code() == Response::RespNameNotFound) { emit arrowDeleted(creatorId, arrowId); } } @@ -281,12 +285,19 @@ void GameEventHandler::eventGameStateChanged(const Event_GameStateChanged &event emit playerJoined(prop); } player->processPlayerInfo(playerInfo); + // Extract playmat from player properties for opponent display + if (prop.has_playmat_params()) { + player->setPlaymatFromProperties(prop); + } if (player->getPlayerInfo()->getLocal()) { emit localPlayerDeckSelected(player, playerId, playerInfo); } else { if (!game->getGameMetaInfo()->proto().share_decklists_on_load()) { continue; } + if (!playerInfo.has_deck_list()) { + continue; + } opponentDecksToDisplay.append( qMakePair(playerId, qMakePair(playerName, QString::fromStdString(playerInfo.deck_list())))); @@ -344,6 +355,11 @@ void GameEventHandler::eventPlayerPropertiesChanged(const Event_PlayerProperties const ServerInfo_PlayerProperties &prop = event.player_properties(); emit playerPropertiesChanged(prop, eventPlayerId); + // Update playmat from player properties + if (prop.has_playmat_params()) { + player->setPlaymatFromProperties(prop); + } + const auto contextType = static_cast(getPbExtension(context)); switch (contextType) { case GameEventContext::READY_START: { @@ -414,12 +430,13 @@ void GameEventHandler::eventJoin(const Event_Join &event, int /*eventPlayerId*/, QString playerName = QString::fromStdString(playerInfo.user_info().name()); emit addPlayerToAutoCompleteList(playerName); - if (game->getPlayerManager()->getPlayers().contains(playerId)) { + PlayerManager *playerManager = game->getPlayerManager(); + if (playerManager->getPlayers().contains(playerId) || playerManager->getSpectators().contains(playerId)) { return; } if (playerInfo.spectator()) { - game->getPlayerManager()->addSpectator(playerId, playerInfo); + playerManager->addSpectator(playerId, playerInfo); emit logJoinSpectator(playerName); emit spectatorJoined(playerInfo); } else { diff --git a/cockatrice/src/game/game_event_handler.h b/cockatrice/src/game/game_event_handler.h index f47116949..99277abb7 100644 --- a/cockatrice/src/game/game_event_handler.h +++ b/cockatrice/src/game/game_event_handler.h @@ -1,8 +1,16 @@ /** * @file game_event_handler.h * @ingroup GameLogic + * @brief Game-level command sender and event dispatcher. + * + * GameEventHandler sends commands initiated by the local client to the server + * and processes incoming game-wide events. It bridges the networking layer + * (protobuf events received via AbstractClient) with the game model and UI + * (GameState, PlayerManager, logging, widgets). + * + * Player-scoped events are forwarded to PlayerEventHandler instances, while + * spectator and global game events are handled directly here. */ -//! \todo Document this file. #ifndef COCKATRICE_GAME_EVENT_HANDLER_H #define COCKATRICE_GAME_EVENT_HANDLER_H @@ -15,92 +23,310 @@ #include class AbstractClient; -class Response; +class AbstractGame; +class CommandContainer; +class GameCommand; class GameEventContainer; class GameEventContext; -class GameCommand; -class GameState; -class MessageLogWidget; -class CommandContainer; -class Event_GameJoined; +class PendingCommand; +class PlayerLogic; +class Response; + class Event_GameStateChanged; class Event_PlayerPropertiesChanged; class Event_Join; class Event_Leave; class Event_GameHostChanged; class Event_GameClosed; -class Event_GameStart; class Event_SetActivePlayer; class Event_SetActivePhase; -class Event_Ping; class Event_GameSay; class Event_Kicked; class Event_ReverseTurn; -class AbstractGame; -class PendingCommand; -class PlayerLogic; +class Event_Ping; inline Q_LOGGING_CATEGORY(GameEventHandlerLog, "game_event_handler"); +/** + * @class GameEventHandler + * @brief Central dispatcher for game-wide commands and events. + * + * This class owns no game state itself. Instead, it: + * - Sends commands to the server on behalf of local players + * - Receives and dispatches server-side game events + * - Updates the game model indirectly via Player, GameState, and PlayerManager + * - Emits high-level signals for UI updates and logging + */ class GameEventHandler : public QObject { Q_OBJECT private: + /** Pointer to the owning game instance. */ AbstractGame *game; public: + /** @name Construction + * Lifecycle and ownership. + * @{ + */ + + /** + * @brief Construct a GameEventHandler. + * + * The handler is owned by the AbstractGame instance and uses it to + * access the game state, players, and network clients. + * + * @param _game Owning game instance (also used as QObject parent). + */ explicit GameEventHandler(AbstractGame *_game); + /** @} */ + + /** @name Outgoing game commands + * Commands initiated locally and sent to the server. + * + * These methods construct and send protobuf commands corresponding + * to user actions in the UI. + * @{ + */ + + /** @brief Request advancing the game to the next turn. */ void handleNextTurn(); + + /** @brief Request reversing the current turn order. */ void handleReverseTurn(); + /** @brief Concede the game for the currently active local player. */ void handleActiveLocalPlayerConceded(); + + /** @brief Undo a previous concede for the active local player. */ void handleActiveLocalPlayerUnconceded(); + + /** + * @brief Set the active phase of the game. + * + * Typically triggered by the active player selecting a new phase. + * + * @param phase Phase identifier. + */ void handleActivePhaseChanged(int phase); + + /** @brief Leave the current game session. */ void handleGameLeft(); + + /** + * @brief Send a chat message to all players and spectators. + * + * @param chatMessage Message text. + */ void handleChatMessageSent(const QString &chatMessage); + + /** + * @brief Delete an existing arrow. + * + * @param arrowId Unique identifier of the arrow to delete. + */ void handleArrowDeletion(int creatorId, int arrowId); void handleArrowDeletionFinished(const Response &response, int creatorId, int arrowId); + /** @} */ + + /** @name Incoming event processing + * Entry points for server-sent events. + * @{ + */ + + /** + * @brief Process a container of game events received from the server. + * + * This is the main dispatch function for incoming game events. + * Events are routed to spectator handlers, game-level handlers, + * or forwarded to PlayerEventHandler instances as appropriate. + * + * @param cont Game event container from the server. + * @param client Client that received the container. + * @param options Processing flags (e.g. silent, replay). + */ + void + processGameEventContainer(const GameEventContainer &cont, AbstractClient *client, EventProcessingOptions options); + + /** @} */ + + /** @name Command preparation helpers + * Internal helpers for building command containers. + * @{ + */ + + /** + * @brief Wrap a single protobuf command in a PendingCommand. + * + * @param cmd Protobuf command message. + * @return Newly allocated PendingCommand (caller takes ownership). + */ + PendingCommand *prepareGameCommand(const ::google::protobuf::Message &cmd); + + /** + * @brief Wrap multiple protobuf commands in a single PendingCommand. + * + * Ownership of the messages in cmdList is transferred to the handler. + * + * @param cmdList List of protobuf command messages. + * @return Newly allocated PendingCommand. + */ + PendingCommand *prepareGameCommand(const QList &cmdList); + + /** @} */ + + /** @name Spectator event handlers + * Events originating from spectators. + * @{ + */ + + /** + * @brief Handle a spectator chat message. + */ void eventSpectatorSay(const Event_GameSay &event, int eventPlayerId, const GameEventContext &context); + + /** + * @brief Handle a spectator leaving the game. + */ void eventSpectatorLeave(const Event_Leave &event, int eventPlayerId, const GameEventContext &context); + /** @} */ + + /** @name Game state event handlers + * Events that affect global game state. + * @{ + */ + + /** + * @brief Handle a full game state update from the server. + * + * Used during game startup, reconnection, and resynchronization. + */ void eventGameStateChanged(const Event_GameStateChanged &event, int eventPlayerId, const GameEventContext &context); + + /** + * @brief Update card attachment relationships for all players. + * + * Called after a game state update to ensure attachments are resolved + * consistently across all zones. + */ void processCardAttachmentsForPlayers(const Event_GameStateChanged &event); + + /** @brief Handle a change in game host. */ + void eventGameHostChanged(const Event_GameHostChanged &event, int eventPlayerId, const GameEventContext &context); + + /** @brief Handle the game being closed by the server. */ + void eventGameClosed(const Event_GameClosed &event, int eventPlayerId, const GameEventContext &context); + + /** @brief Handle a change of the active player. */ + void eventSetActivePlayer(const Event_SetActivePlayer &event, int eventPlayerId, const GameEventContext &context); + + /** @brief Handle a change of the active phase. */ + void eventSetActivePhase(const Event_SetActivePhase &event, int eventPlayerId, const GameEventContext &context); + + /** @brief Handle a turn reversal event. */ + void eventReverseTurn(const Event_ReverseTurn &event, int eventPlayerId, const GameEventContext &context); + + /** @brief Handle ping / latency updates. */ + void eventPing(const Event_Ping &event, int eventPlayerId, const GameEventContext &context); + + /** @} */ + + /** @name Player lifecycle and property handlers + * Events related to players joining, leaving, or changing state. + * @{ + */ + + /** + * @brief Handle updates to a player's properties. + * + * Includes readiness, concede state, deck selection, sideboard lock, + * and connection state changes. + */ void eventPlayerPropertiesChanged(const Event_PlayerPropertiesChanged &event, int eventPlayerId, const GameEventContext &context); + + /** @brief Handle a player or spectator joining the game. */ void eventJoin(const Event_Join &event, int eventPlayerId, const GameEventContext &context); + + /** @brief Handle a player leaving the game. */ void eventLeave(const Event_Leave &event, int eventPlayerId, const GameEventContext &context); - QString getLeaveReason(Event_Leave::LeaveReason reason); + + /** @brief Handle the local player being kicked from the game. */ void eventKicked(const Event_Kicked &event, int eventPlayerId, const GameEventContext &context); - void eventGameHostChanged(const Event_GameHostChanged &event, int eventPlayerId, const GameEventContext &context); - void eventGameClosed(const Event_GameClosed &event, int eventPlayerId, const GameEventContext &context); - void eventSetActivePlayer(const Event_SetActivePlayer &event, int eventPlayerId, const GameEventContext &context); - void eventSetActivePhase(const Event_SetActivePhase &event, int eventPlayerId, const GameEventContext &context); - void eventPing(const Event_Ping &event, int eventPlayerId, const GameEventContext &context); - void eventReverseTurn(const Event_ReverseTurn &event, int eventPlayerId, const GameEventContext & /*context*/); + /** + * @brief Convert a leave reason enum to a human-readable string. + */ + QString getLeaveReason(Event_Leave::LeaveReason reason); - void commandFinished(const Response &response); + /** @} */ - void - processGameEventContainer(const GameEventContainer &cont, AbstractClient *client, EventProcessingOptions options); - PendingCommand *prepareGameCommand(const ::google::protobuf::Message &cmd); - PendingCommand *prepareGameCommand(const QList &cmdList); public slots: + /** @name Command dispatch slots + * Low-level command transmission. + * @{ + */ + + /** + * @brief Send a prepared PendingCommand. + * + * @param pend Pending command to send. + * @param playerId Player whose client should send the command. + */ void sendGameCommand(PendingCommand *pend, int playerId = -1); + + /** + * @brief Send a single protobuf command. + * + * @param command Protobuf command message. + * @param playerId Player whose client should send the command. + */ void sendGameCommand(const ::google::protobuf::Message &command, int playerId = -1); + /** + * @brief Called when a PendingCommand finishes execution. + * + * Used to detect server-side errors such as chat flood protection. + */ + void commandFinished(const Response &response); + + /** @} */ + signals: + /** @name Core state signals + * @{ + */ + void emitUserEvent(); + void containerProcessingStarted(GameEventContext context); + void containerProcessingDone(); + void gameFlooded(); + void setContextJudgeName(QString judgeName); + + /** @} */ + + /** @name Player and spectator signals + * @{ + */ + void addPlayerToAutoCompleteList(QString playerName); void localPlayerDeckSelected(PlayerLogic *localPlayer, int playerId, ServerInfo_Player playerInfo); void remotePlayerDeckSelected(QString deckList, int playerId, QString playerName); void remotePlayersDecksSelected(QVector>> opponentDecks); void localPlayerSideboardLocked(int playerId, bool sideboardLocked); void localPlayerReadyStateChanged(int playerId, bool ready); + + /** @} */ + + /** @name Game flow signals + * @{ + */ + void gameStopped(); void gameClosed(); void playerPropertiesChanged(const ServerInfo_PlayerProperties &prop, int playerId); @@ -109,11 +335,15 @@ signals: void playerKicked(); void spectatorJoined(const ServerInfo_PlayerProperties &spectatorInfo); void spectatorLeft(int leavingSpectatorId); - void gameFlooded(); - void containerProcessingStarted(GameEventContext context); - void setContextJudgeName(QString judgeName); - void containerProcessingDone(); void arrowDeleted(int creatorId, int arrowId); + + /** @} */ + + /** @name Logging signals + * Signals consumed by MessageLogWidget. + * @{ + */ + void logSpectatorSay(ServerInfo_User userInfo, QString message); void logSpectatorLeave(QString name, QString reason); void logGameStart(); @@ -132,6 +362,8 @@ signals: void logActivePhaseChanged(int activePhase); void logConcede(int playerId); void logUnconcede(int playerId); + + /** @} */ }; #endif // COCKATRICE_GAME_EVENT_HANDLER_H diff --git a/cockatrice/src/game/player/event_processing_options.h b/cockatrice/src/game/player/event_processing_options.h index 4c7663789..06238d77e 100644 --- a/cockatrice/src/game/player/event_processing_options.h +++ b/cockatrice/src/game/player/event_processing_options.h @@ -13,7 +13,8 @@ enum EventProcessingOption { SKIP_REVEAL_WINDOW = 0x0001, - SKIP_TAP_ANIMATION = 0x0002 + SKIP_TAP_ANIMATION = 0x0002, + SKIP_DAMAGE_ANIMATION = 0x0004 }; // Wrap it in a QFlags typedef diff --git a/cockatrice/src/game/player/player_actions.cpp b/cockatrice/src/game/player/player_actions.cpp index fffd23ccf..67c3295d6 100644 --- a/cockatrice/src/game/player/player_actions.cpp +++ b/cockatrice/src/game/player/player_actions.cpp @@ -27,8 +27,11 @@ #include #include #include +#include +#include +#include +#include #include -#include #include // milliseconds in between triggers of the move top cards until action @@ -66,7 +69,7 @@ void PlayerActions::playCard(CardItem *card, bool faceDown) const CardInfo &info = exactCard.getInfo(); int tableRow = info.getUiAttributes().tableRow; - bool playToStack = SettingsCache::instance().getPlayToStack(); + bool playToStack = SettingsCache::instance().userInterface().getPlayToStack(); QString currentZone = card->getZone()->getName(); if (!faceDown && currentZone == ZoneNames::STACK && tableRow == 3) { cmd.set_target_zone(ZoneNames::GRAVE); @@ -309,7 +312,7 @@ void PlayerActions::actDrawCard() void PlayerActions::actRequestMulliganDialog() { - int startSize = SettingsCache::instance().getStartingHandSize(); + int startSize = SettingsCache::instance().userInterface().getStartingHandSize(); int handSize = player->getHandZone()->getCards().size(); int deckSize = player->getDeckZone()->getCards().size() + handSize; @@ -325,7 +328,7 @@ void PlayerActions::actMulligan(int number) } doMulligan(number); - SettingsCache::instance().setStartingHandSize(number); + SettingsCache::instance().userInterface().setStartingHandSize(number); } void PlayerActions::actMulliganSameSize() @@ -929,13 +932,13 @@ void PlayerActions::setLastTokenInfo(CardInfoPtr cardInfo) return; } - lastTokenInfo = {.name = cardInfo->getName(), - .color = cardInfo->getColors().isEmpty() ? QString() : cardInfo->getColors().left(1).toLower(), - .pt = cardInfo->getPowTough(), - .annotation = SettingsCache::instance().getAnnotateTokens() ? cardInfo->getText() : "", - .destroy = true, - .providerId = - SettingsCache::instance().cardOverrides().getCardPreferenceOverride(cardInfo->getName())}; + lastTokenInfo = { + .name = cardInfo->getName(), + .color = cardInfo->getColors().isEmpty() ? QString() : cardInfo->getColors().left(1).toLower(), + .pt = cardInfo->getPowTough(), + .annotation = SettingsCache::instance().userInterface().getAnnotateTokens() ? cardInfo->getText() : "", + .destroy = true, + .providerId = SettingsCache::instance().cardOverrides().getCardPreferenceOverride(cardInfo->getName())}; lastTokenTableRow = TableZone::tableRowToGridY(cardInfo->getUiAttributes().tableRow); @@ -1018,8 +1021,9 @@ void PlayerActions::actCreateAllRelatedCards() if (!cardRelationAll->getDoesAttach() && !cardRelationAll->getIsVariable()) { dbName = cardRelationAll->getName(); bool persistent = cardRelationAll->getIsPersistent(); + bool faceDown = cardRelationAll->getIsFaceDown(); for (int i = 0; i < cardRelationAll->getDefaultCount(); ++i) { - createCard(sourceCard, dbName, CardRelationType::DoesNotAttach, persistent); + createCard(sourceCard, dbName, CardRelationType::DoesNotAttach, persistent, faceDown); } ++tokensTypesCreated; if (tokensTypesCreated == 1) { @@ -1034,8 +1038,9 @@ void PlayerActions::actCreateAllRelatedCards() if (!cardRelationNotExcluded->getDoesAttach() && !cardRelationNotExcluded->getIsVariable()) { dbName = cardRelationNotExcluded->getName(); bool persistent = cardRelationNotExcluded->getIsPersistent(); + bool faceDown = cardRelationNotExcluded->getIsFaceDown(); for (int i = 0; i < cardRelationNotExcluded->getDefaultCount(); ++i) { - createCard(sourceCard, dbName, CardRelationType::DoesNotAttach, persistent); + createCard(sourceCard, dbName, CardRelationType::DoesNotAttach, persistent, faceDown); } ++tokensTypesCreated; if (tokensTypesCreated == 1) { @@ -1073,6 +1078,7 @@ bool PlayerActions::createRelatedFromRelation(const CardItem *sourceCard, const QString dbName = cardRelation->getName(); const bool persistent = cardRelation->getIsPersistent(); + const bool faceDown = cardRelation->getIsFaceDown(); // Variable relations always use DoesNotAttach, regardless of the count the user // entered. @@ -1081,7 +1087,7 @@ bool PlayerActions::createRelatedFromRelation(const CardItem *sourceCard, return false; } for (int i = 0; i < variableCount; ++i) { - createCard(sourceCard, dbName, CardRelationType::DoesNotAttach, persistent); + createCard(sourceCard, dbName, CardRelationType::DoesNotAttach, persistent, faceDown); } return true; } @@ -1090,7 +1096,7 @@ bool PlayerActions::createRelatedFromRelation(const CardItem *sourceCard, if (count > 1) { for (int i = 0; i < count; ++i) { - createCard(sourceCard, dbName, CardRelationType::DoesNotAttach, persistent); + createCard(sourceCard, dbName, CardRelationType::DoesNotAttach, persistent, faceDown); } return true; } @@ -1110,7 +1116,7 @@ bool PlayerActions::createRelatedFromRelation(const CardItem *sourceCard, playCardToTable(sourceCard, false); } - createCard(sourceCard, dbName, attachType, persistent); + createCard(sourceCard, dbName, attachType, persistent, faceDown); return true; } @@ -1137,7 +1143,8 @@ void PlayerActions::onRelatedCardCreated(const CardItem *sourceCard, const CardR void PlayerActions::createCard(const CardItem *sourceCard, const QString &dbCardName, CardRelationType attachType, - bool persistent) + bool persistent, + bool faceDown) { CardInfoPtr cardInfo = CardDatabaseManager::query()->getCardInfo(dbCardName); @@ -1164,7 +1171,7 @@ void PlayerActions::createCard(const CardItem *sourceCard, } cmd.set_pt(cardInfo->getPowTough().toStdString()); - if (SettingsCache::instance().getAnnotateTokens()) { + if (SettingsCache::instance().userInterface().getAnnotateTokens()) { cmd.set_annotation(cardInfo->getText().toStdString()); } else { cmd.set_annotation(""); @@ -1172,6 +1179,7 @@ void PlayerActions::createCard(const CardItem *sourceCard, cmd.set_destroy_on_zone_change(!persistent); cmd.set_x(gridPoint.x()); cmd.set_y(gridPoint.y()); + cmd.set_face_down(faceDown); ExactCard relatedCard = CardDatabaseManager::query()->getCardFromSameSet(cardInfo->getName(), sourceCard->getCard().getPrinting()); @@ -1346,11 +1354,7 @@ void PlayerActions::actSetPT(QList selectedCards, const QString &pt) const auto oldpt = CardItem::parsePT(card->getPT()); int ptIter = 0; for (const auto &_item : ptList) { -#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) if (_item.typeId() == QMetaType::Type::Int) { -#else - if (_item.type() == QVariant::Int) { -#endif int oldItem = ptIter < oldpt.size() ? oldpt.at(ptIter).toInt() : 0; newpt += '/' + QString::number(oldItem + _item.toInt()); } else { @@ -1525,12 +1529,15 @@ void PlayerActions::offsetCardCounter(QList selectedCards, int count QList commandList; for (auto card : selectedCards) { int oldValue = card->getCounters().value(counterId, 0); - int newValue = oldValue + offset; - // Early exit optimization: server enforces [0, MAX_COUNTERS_ON_CARD]. - // Compare clamped value to allow recovery from invalid states. - int clampedValue = qBound(0, newValue, MAX_COUNTERS_ON_CARD); - if (clampedValue != oldValue) { + // Overflow-safe clamp to the server-enforced range [0, MAX_COUNTER_VALUE]; + // a result differing from oldValue also corrects an out-of-range cached value. + // Callers only ever pass offset == ±1 (actAddCardCounter / actRemoveCardCounter). + // This client-side clamp is a defense-in-depth UX check, consistent with + // actSetCardCounter and actIncrementAllCardCounters; the server remains the + // authoritative enforcer of the bounds. + int newValue = addClamped(oldValue, offset, 0, MAX_COUNTER_VALUE); + if (newValue != oldValue) { auto *cmd = new Command_SetCardCounter; cmd->set_zone(card->getZone()->getName().toStdString()); cmd->set_card_id(card->getId()); @@ -1563,7 +1570,7 @@ void PlayerActions::actSetCardCounter(QList selectedCards, int count Expression exp(oldValue); double parsed = exp.parse(counterValue); // Clamp in double precision first to avoid UB, then cast - int number = static_cast(qBound(0.0, parsed, static_cast(MAX_COUNTERS_ON_CARD))); + int number = static_cast(qBound(0.0, parsed, static_cast(MAX_COUNTER_VALUE))); auto *cmd = new Command_SetCardCounter; cmd->set_zone(card->getZone()->getName().toStdString()); @@ -1593,7 +1600,7 @@ void PlayerActions::actIncrementAllCardCounters(QList cardsToUpdate) counterIterator.next(); int counterId = counterIterator.key(); int currentValue = counterIterator.value(); - if (currentValue >= MAX_COUNTERS_ON_CARD) { + if (currentValue >= MAX_COUNTER_VALUE) { continue; } diff --git a/cockatrice/src/game/player/player_actions.h b/cockatrice/src/game/player/player_actions.h index 3f1960892..fa4d54110 100644 --- a/cockatrice/src/game/player/player_actions.h +++ b/cockatrice/src/game/player/player_actions.h @@ -240,7 +240,8 @@ private: void createCard(const CardItem *sourceCard, const QString &dbCardName, CardRelationType attach = CardRelationType::DoesNotAttach, - bool persistent = false); + bool persistent = false, + bool faceDown = false); void playSelectedCards(QList selectedCards, bool faceDown = false); diff --git a/cockatrice/src/game/player/player_event_handler.cpp b/cockatrice/src/game/player/player_event_handler.cpp index bc48298f7..277b8b1d4 100644 --- a/cockatrice/src/game/player/player_event_handler.cpp +++ b/cockatrice/src/game/player/player_event_handler.cpp @@ -262,14 +262,15 @@ void PlayerEventHandler::eventCreateCounter(const Event_CreateCounter &event) player->addCounter(event.counter_info()); } -void PlayerEventHandler::eventSetCounter(const Event_SetCounter &event) +void PlayerEventHandler::eventSetCounter(const Event_SetCounter &event, EventProcessingOptions options) { CounterState *ctr = player->getCounters().value(event.counter_id(), nullptr); if (!ctr) { return; } int oldValue = ctr->getValue(); - ctr->setValue(event.value()); + const bool skipDamageAnimation = options.testFlag(SKIP_DAMAGE_ANIMATION); + ctr->setValue(event.value(), skipDamageAnimation); emit logSetCounter(player, ctr->getName(), event.value(), oldValue); } @@ -625,7 +626,7 @@ void PlayerEventHandler::processGameEvent(GameEvent::GameEventType type, eventCreateCounter(event.GetExtension(Event_CreateCounter::ext)); break; case GameEvent::SET_COUNTER: - eventSetCounter(event.GetExtension(Event_SetCounter::ext)); + eventSetCounter(event.GetExtension(Event_SetCounter::ext), options); break; case GameEvent::DEL_COUNTER: eventDelCounter(event.GetExtension(Event_DelCounter::ext)); diff --git a/cockatrice/src/game/player/player_event_handler.h b/cockatrice/src/game/player/player_event_handler.h index cfd82933f..300cacd08 100644 --- a/cockatrice/src/game/player/player_event_handler.h +++ b/cockatrice/src/game/player/player_event_handler.h @@ -1,11 +1,24 @@ /** * @file player_event_handler.h * @ingroup GameLogicPlayers + * @brief Player-scoped game event handler. + * + * PlayerEventHandler applies game events that affect a single Player’s + * board state, zones, cards, counters, arrows, and related UI/log output. + * + * It is invoked by GameEventHandler after basic routing and validation. + * Each instance is bound 1:1 to a Player and must never mutate state + * belonging to other players except where explicitly required by events + * (e.g. moving cards between players, attaching cards, arrows). + * + * This class is intentionally stateful and tightly coupled to Player, + * PlayerActions, and the board/zones implementation. It performs both + * model mutation and UI-side bookkeeping (zone views, arrows, menus). */ -//! \todo Document this file. #ifndef COCKATRICE_PLAYER_EVENT_HANDLER_H #define COCKATRICE_PLAYER_EVENT_HANDLER_H + #include "event_processing_options.h" #include @@ -16,6 +29,7 @@ class CardItem; class CardZoneLogic; class PlayerLogic; + class Event_AttachCard; class Event_ChangeZoneProperties; class Event_CreateArrow; @@ -37,11 +51,176 @@ class Event_SetCounter; class Event_Shuffle; class Event_GameLogNotice; +/** + * @class PlayerEventHandler + * @brief Applies player-specific game events and emits corresponding log signals. + * + * Design notes: + * - All event handlers assume events are authoritative and already validated + * by the server. + * - Most handlers mutate both logical state (CardItem, CardZoneLogic, counters) + * and visual/UI state (views, arrows, menus). + * - Logging signals are emitted *after* or *during* state mutation, depending + * on whether later mutations would invalidate log data. + */ class PlayerEventHandler : public QObject { - Q_OBJECT + +public: + /** + * @brief Construct a PlayerEventHandler bound to a Player. + * @param player Owning player instance. + */ + explicit PlayerEventHandler(PlayerLogic *player); + + /** @name Event dispatch + * @{ + */ + + /** + * @brief Dispatch a generic GameEvent to the appropriate handler. + * + * This is the single entry point used by GameEventHandler. It extracts + * the correct protobuf extension and forwards the event to a typed + * handler method. + * + * @param type Game event type enum. + * @param event Generic protobuf container. + * @param context Additional context (undo, judge, etc.). + * @param options Processing options (UI suppression, reveal behavior). + */ + void processGameEvent(GameEvent::GameEventType type, + const GameEvent &event, + const GameEventContext &context, + EventProcessingOptions options); + + /** @} */ + + /** @name Chat and randomization events + * @{ + */ + + /// Handle in-game chat messages from this player. + void eventGameSay(const Event_GameSay &event); + + /// Handle zone shuffle events (typically libraries). + void eventShuffle(const Event_Shuffle &event); + + /// Handle die roll events. + void eventRollDie(const Event_RollDie &event); + + /** @} */ + + /** @name Arrow and targeting events + * @{ + */ + + /// Create a visual arrow between cards or players. + void eventCreateArrow(const Event_CreateArrow &event); + + /// Delete an existing arrow. + void eventDeleteArrow(const Event_DeleteArrow &event); + + /** @} */ + + /** @name Token and card creation + * @{ + */ + + /// Create a token card in a target zone. + void eventCreateToken(const Event_CreateToken &event); + + /** @} */ + + /** @name Card attribute and counter updates + * @{ + */ + + /** + * @brief Set a card attribute (tapped, PT, annotation, etc.). + * + * May apply to a single card or all cards in a zone if no card ID + * is provided by the event. + */ + void + eventSetCardAttr(const Event_SetCardAttr &event, const GameEventContext &context, EventProcessingOptions options); + + /// Update a counter attached to a card. + void eventSetCardCounter(const Event_SetCardCounter &event); + + /// Create a player-level counter. + void eventCreateCounter(const Event_CreateCounter &event); + + /// Set a player-level counter value. + void eventSetCounter(const Event_SetCounter &event, EventProcessingOptions options); + + /// Delete a player-level counter. + void eventDelCounter(const Event_DelCounter &event); + + /** @} */ + + /** @name Zone-level operations + * @{ + */ + + /// Log a zone dump (e.g. reveal graveyard/library contents). + void eventDumpZone(const Event_DumpZone &event); + + /** + * @brief Move a card between zones and/or players. + * + * This is one of the most complex handlers: + * - Removes the card from the start zone + * - Updates card identity and ownership if needed + * - Handles attachments and arrows + * - Emits appropriate move or undo-draw logs + * - Inserts the card into the target zone + */ + void eventMoveCard(const Event_MoveCard &event, const GameEventContext &context); + + /// Flip a card face up or face down. + void eventFlipCard(const Event_FlipCard &event); + + /// Destroy a card and clean up attachments. + void eventDestroyCard(const Event_DestroyCard &event); + + /// Attach or detach a card to/from another card. + void eventAttachCard(const Event_AttachCard &event); + + /** @} */ + + /** @name Draw and reveal operations + * @{ + */ + + /// Draw one or more cards from the deck. + void eventDrawCards(const Event_DrawCards &event); + + /** + * @brief Reveal cards from a zone. + * + * Handles peeking, in-place top-card reveals, full reveal windows, + * and write-access granting. + */ + void eventRevealCards(const Event_RevealCards &event, EventProcessingOptions options); + + /** @} */ + + /** @name Zone configuration + * @{ + */ + + /// Update zone visibility and reveal behavior. + void eventChangeZoneProperties(const Event_ChangeZoneProperties &event); + + /** @} */ + + void eventGameLogNotice(const Event_GameLogNotice &event); signals: + /** @name Logging signals + * @{ + */ void logSay(PlayerLogic *player, QString message); void logShuffle(PlayerLogic *player, CardZoneLogic *zone, int start, int end); void logRollDie(PlayerLogic *player, int sides, const QList &rolls); @@ -82,40 +261,13 @@ signals: bool isLentToAnotherPlayer = false); void logAlwaysRevealTopCard(PlayerLogic *player, CardZoneLogic *zone, bool reveal); void logAlwaysLookAtTopCard(PlayerLogic *player, CardZoneLogic *zone, bool reveal); + /** @} */ + void cardZoneChanged(CardItem *card, bool sameZone); void requestCardMenuUpdate(const CardItem *card); -public: - PlayerEventHandler(PlayerLogic *player); - - void processGameEvent(GameEvent::GameEventType type, - const GameEvent &event, - const GameEventContext &context, - EventProcessingOptions options); - - void eventGameSay(const Event_GameSay &event); - void eventShuffle(const Event_Shuffle &event); - void eventRollDie(const Event_RollDie &event); - void eventCreateArrow(const Event_CreateArrow &event); - void eventDeleteArrow(const Event_DeleteArrow &event); - void eventCreateToken(const Event_CreateToken &event); - void - eventSetCardAttr(const Event_SetCardAttr &event, const GameEventContext &context, EventProcessingOptions options); - void eventSetCardCounter(const Event_SetCardCounter &event); - void eventCreateCounter(const Event_CreateCounter &event); - void eventSetCounter(const Event_SetCounter &event); - void eventDelCounter(const Event_DelCounter &event); - void eventDumpZone(const Event_DumpZone &event); - void eventMoveCard(const Event_MoveCard &event, const GameEventContext &context); - void eventFlipCard(const Event_FlipCard &event); - void eventDestroyCard(const Event_DestroyCard &event); - void eventAttachCard(const Event_AttachCard &event); - void eventDrawCards(const Event_DrawCards &event); - void eventRevealCards(const Event_RevealCards &event, EventProcessingOptions options); - void eventChangeZoneProperties(const Event_ChangeZoneProperties &event); - void eventGameLogNotice(const Event_GameLogNotice &event); - private: + /** Owning player instance. */ PlayerLogic *player; void setCardAttrHelper(const GameEventContext &context, diff --git a/cockatrice/src/game/player/player_logic.cpp b/cockatrice/src/game/player/player_logic.cpp index 485e2fc5c..143df5c57 100644 --- a/cockatrice/src/game/player/player_logic.cpp +++ b/cockatrice/src/game/player/player_logic.cpp @@ -175,7 +175,15 @@ void PlayerLogic::processPlayerInfo(const ServerInfo_Player &info) const ServerInfo_Card &cardInfo = zoneInfo.card_list(j); auto *card = new CardItem(this); card->processCardInfo(cardInfo); - zone->addCard(card, false, cardInfo.x(), cardInfo.y()); + // Zones without coordinates (hand, piles, stack) preserve the order + // they arrive in on the server in the positions of their cards list. + // The x coordinate of such cards is always 0, so inserting at it + // would reverse the list on reconnect. Append instead. + if (zoneInfo.with_coords()) { + zone->addCard(card, false, cardInfo.x(), cardInfo.y()); + } else { + zone->addCard(card, false, -1); + } } } if (zoneInfo.has_always_reveal_top_card()) { @@ -250,6 +258,22 @@ void PlayerLogic::setDeck(const DeckList &_deck) emit deckChanged(); } +void PlayerLogic::setPlaymatFromProperties(const ServerInfo_PlayerProperties &props) +{ + if (props.has_playmat_params() && !props.playmat_params().card_name().empty()) { + const auto &pp = props.playmat_params(); + remotePlaymatCard = {QString::fromStdString(pp.card_name()), QString::fromStdString(pp.card_provider_id())}; + remotePlaymatParams = {qBound(0.0, pp.margin_pct_l(), 0.95), qBound(0.0, pp.margin_pct_r(), 0.95), + qBound(0.0, pp.vertical_offset(), 1.0), qBound(0.1, pp.zoom(), 4.0)}; + hasRemotePlaymat = true; + } else { + remotePlaymatCard = CardRef{}; + remotePlaymatParams = PlaymatParams{}; + hasRemotePlaymat = false; + } + emit playmatChanged(); +} + CounterState *PlayerLogic::addCounter(const ServerInfo_Counter &counter) { return addCounter(counter.id(), QString::fromStdString(counter.name()), diff --git a/cockatrice/src/game/player/player_logic.h b/cockatrice/src/game/player/player_logic.h index a89cb6eed..6923b3afe 100644 --- a/cockatrice/src/game/player/player_logic.h +++ b/cockatrice/src/game/player/player_logic.h @@ -17,6 +17,7 @@ #include "../zones/table_zone_logic.h" #include "player_event_handler.h" #include "player_info.h" +#include "player_manager.h" #include #include @@ -72,6 +73,8 @@ signals: const QList &cardList, bool withWritePermission); void deckChanged(); + /** @brief Emitted when the remote playmat (card/params) is updated from player properties. */ + void playmatChanged(); void newCardAdded(AbstractCardItem *card); void requestCardMenuUpdate(const CardItem *card); void counterAdded(CounterState *state); @@ -226,6 +229,20 @@ public: void setZoneId(int _zoneId); + void setPlaymatFromProperties(const ServerInfo_PlayerProperties &props); + const CardRef &getRemotePlaymatCard() const + { + return remotePlaymatCard; + } + const PlaymatParams &getRemotePlaymatParams() const + { + return remotePlaymatParams; + } + bool getHasRemotePlaymat() const + { + return hasRemotePlaymat; + } + private: AbstractGame *game; PlayerInfo *playerInfo; @@ -243,6 +260,11 @@ private: bool dialogSemaphore; QList cardsToDelete; + + // Playmat from player properties (for opponent display) + CardRef remotePlaymatCard; + PlaymatParams remotePlaymatParams; + bool hasRemotePlaymat = false; }; class AnnotationDialog : public QInputDialog diff --git a/cockatrice/src/game/player/player_manager.cpp b/cockatrice/src/game/player/player_manager.cpp index 6772d3ff1..8486efbeb 100644 --- a/cockatrice/src/game/player/player_manager.cpp +++ b/cockatrice/src/game/player/player_manager.cpp @@ -75,6 +75,14 @@ PlayerLogic *PlayerManager::getPlayer(int playerId) const return player; } +void PlayerManager::clearSpectators() +{ + const QList spectatorIds = spectators.keys(); + for (int spectatorId : spectatorIds) { + removeSpectator(spectatorId); + } +} + void PlayerManager::onPlayerConceded(int playerId, bool conceded) { // Everything else cares about this diff --git a/cockatrice/src/game/player/player_manager.h b/cockatrice/src/game/player/player_manager.h index 2f8b87af8..504e65396 100644 --- a/cockatrice/src/game/player/player_manager.h +++ b/cockatrice/src/game/player/player_manager.h @@ -100,6 +100,9 @@ public: emit spectatorRemoved(spectatorId, spectatorInfo); } + /** @brief Remove all spectators, emitting the removal signal for each. */ + void clearSpectators(); + [[nodiscard]] AbstractGame *getGame() const { return game; diff --git a/cockatrice/src/game/replay.cpp b/cockatrice/src/game/replay.cpp index 69f9d8b20..dcf3e9b9b 100644 --- a/cockatrice/src/game/replay.cpp +++ b/cockatrice/src/game/replay.cpp @@ -2,7 +2,7 @@ #include "../interface/widgets/tabs/tab_game.h" -Replay::Replay(QObject *_parent, GameReplay *_replay, bool isLocalGame) : AbstractGame(_parent) +Replay::Replay(QObject *_parent, const GameReplay *_replay, bool isLocalGame) : AbstractGame(_parent) { gameState = new GameState(this, 0, -1, isLocalGame, {}, false, false, -1, false); connect(gameMetaInfo, &GameMetaInfo::startedChanged, gameState, &GameState::onStartedChanged); diff --git a/cockatrice/src/game/replay.h b/cockatrice/src/game/replay.h index ecb3a10d0..1c269b273 100644 --- a/cockatrice/src/game/replay.h +++ b/cockatrice/src/game/replay.h @@ -15,7 +15,7 @@ class Replay : public AbstractGame Q_OBJECT public: - explicit Replay(QObject *_parent, GameReplay *_replay, bool isLocalGame); + explicit Replay(QObject *_parent, const GameReplay *_replay, bool isLocalGame); }; #endif // COCKATRICE_REPLAY_H diff --git a/cockatrice/src/game/zones/view_zone_logic.cpp b/cockatrice/src/game/zones/view_zone_logic.cpp index 8782a1762..2ef04284a 100644 --- a/cockatrice/src/game/zones/view_zone_logic.cpp +++ b/cockatrice/src/game/zones/view_zone_logic.cpp @@ -3,6 +3,7 @@ #include "../../client/settings/cache_settings.h" #include "../../game_graphics/board/card_item.h" +#include /** * @param _player the player that the cards are revealed to. * @param _origZone the zone the cards were revealed from. @@ -57,7 +58,7 @@ bool ZoneViewZoneLogic::prepareAddCard(int x) // autoclose check is done both here and in removeCard - if (cards.isEmpty() && !doInsert && SettingsCache::instance().getCloseEmptyCardView()) { + if (cards.isEmpty() && !doInsert && SettingsCache::instance().userInterface().getCloseEmptyCardView()) { emit closeView(); } @@ -144,7 +145,7 @@ void ZoneViewZoneLogic::removeCard(int position, bool toNewZone) // card gets dragged within the view. // Another autoclose check is done in prepareAddCard so that the view autocloses if the last card was moved to an // unrevealed portion of the same zone. - if (cards.isEmpty() && SettingsCache::instance().getCloseEmptyCardView() && toNewZone) { + if (cards.isEmpty() && SettingsCache::instance().userInterface().getCloseEmptyCardView() && toNewZone) { emit closeView(); return; } diff --git a/cockatrice/src/game_graphics/animated_item.h b/cockatrice/src/game_graphics/animated_item.h new file mode 100644 index 000000000..700e0f62d --- /dev/null +++ b/cockatrice/src/game_graphics/animated_item.h @@ -0,0 +1,26 @@ +#ifndef ANIMATED_ITEM_H +#define ANIMATED_ITEM_H + +/** + * @file animated_item.h + * @ingroup GameGraphics + * @brief Interface for scene items driven by GameScene's shared animation timer. + * + * Items that want per-tick animation while a single QBasicTimer runs (instead of + * owning their own QTimer) implement this interface and register with the scene + * via GameScene::registerAnimationItem. + */ + +class IAnimatedItem +{ +public: + virtual ~IAnimatedItem() = default; + + /** + * @brief Advances the item's animation by one timer tick. + * @return true while the animation is still running, false once it has finished. + */ + virtual bool animationEvent() = 0; +}; + +#endif diff --git a/cockatrice/src/game_graphics/board/abstract_card_drag_item.cpp b/cockatrice/src/game_graphics/board/abstract_card_drag_item.cpp index 026efd60d..2f43591ac 100644 --- a/cockatrice/src/game_graphics/board/abstract_card_drag_item.cpp +++ b/cockatrice/src/game_graphics/board/abstract_card_drag_item.cpp @@ -7,6 +7,7 @@ #include #include #include +#include const QColor GHOST_MASK = QColor(255, 255, 255, 50); @@ -34,12 +35,13 @@ AbstractCardDragItem::AbstractCardDragItem(AbstractCardItem *_item, setCacheMode(DeviceCoordinateCache); - connect(&SettingsCache::instance(), &SettingsCache::roundCardCornersChanged, this, [this](bool _roundCardCorners) { - Q_UNUSED(_roundCardCorners); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::roundCardCornersChanged, this, + [this](bool _roundCardCorners) { + Q_UNUSED(_roundCardCorners); - prepareGeometryChange(); - update(); - }); + prepareGeometryChange(); + update(); + }); connect(item, &QObject::destroyed, this, &AbstractCardDragItem::deleteLater); } @@ -47,7 +49,8 @@ AbstractCardDragItem::AbstractCardDragItem(AbstractCardItem *_item, QPainterPath AbstractCardDragItem::shape() const { QPainterPath shape; - qreal cardCornerRadius = SettingsCache::instance().getRoundCardCorners() ? 0.05 * CardDimensions::WIDTH_F : 0.0; + qreal cardCornerRadius = + SettingsCache::instance().cardsDisplay().getRoundCardCorners() ? 0.05 * CardDimensions::WIDTH_F : 0.0; shape.addRoundedRect(boundingRect(), cardCornerRadius, cardCornerRadius); return shape; } diff --git a/cockatrice/src/game_graphics/board/abstract_card_item.cpp b/cockatrice/src/game_graphics/board/abstract_card_item.cpp index 86b3e27c8..3969b7d03 100644 --- a/cockatrice/src/game_graphics/board/abstract_card_item.cpp +++ b/cockatrice/src/game_graphics/board/abstract_card_item.cpp @@ -1,6 +1,7 @@ #include "abstract_card_item.h" #include "../../client/settings/cache_settings.h" +#include "../../interface/card_localization.h" #include "../../interface/card_picture_loader/card_picture_loader.h" #include "../game_scene.h" #include "../z_values.h" @@ -12,6 +13,9 @@ #include #include #include +#include +#include +#include AbstractCardItem::AbstractCardItem(QGraphicsItem *parent, const CardRef &cardRef, PlayerLogic *_owner, int _id) : ArrowTarget(_owner, parent), id(_id), cardRef(cardRef), tapped(false), facedown(false), tapAngle(0), @@ -21,15 +25,19 @@ AbstractCardItem::AbstractCardItem(QGraphicsItem *parent, const CardRef &cardRef setFlag(ItemIsSelectable); setCacheMode(DeviceCoordinateCache); - connect(&SettingsCache::instance(), &SettingsCache::displayCardNamesChanged, this, [this] { update(); }); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::displayCardNamesChanged, this, + [this] { update(); }); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this, + [this] { update(); }); refreshCardInfo(); - connect(&SettingsCache::instance(), &SettingsCache::roundCardCornersChanged, this, [this](bool _roundCardCorners) { - Q_UNUSED(_roundCardCorners); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::roundCardCornersChanged, this, + [this](bool _roundCardCorners) { + Q_UNUSED(_roundCardCorners); - prepareGeometryChange(); - update(); - }); + prepareGeometryChange(); + update(); + }); } AbstractCardItem::~AbstractCardItem() @@ -45,7 +53,8 @@ QRectF AbstractCardItem::boundingRect() const QPainterPath AbstractCardItem::shape() const { QPainterPath shape; - qreal cardCornerRadius = SettingsCache::instance().getRoundCardCorners() ? 0.05 * CardDimensions::WIDTH_F : 0.0; + qreal cardCornerRadius = + SettingsCache::instance().cardsDisplay().getRoundCardCorners() ? 0.05 * CardDimensions::WIDTH_F : 0.0; shape.addRoundedRect(boundingRect(), cardCornerRadius, cardCornerRadius); return shape; } @@ -101,7 +110,7 @@ QSizeF AbstractCardItem::getTranslatedSize(QPainter *painter) const void AbstractCardItem::transformPainter(QPainter *painter, const QSizeF &translatedSize, int angle) { - const int MAX_FONT_SIZE = SettingsCache::instance().getMaxFontSize(); + const int MAX_FONT_SIZE = SettingsCache::instance().appearance().getMaxFontSize(); const int fontSize = std::max(9, MAX_FONT_SIZE); QRectF totalBoundingRect = painter->combinedTransform().mapRect(boundingRect()); @@ -151,7 +160,7 @@ void AbstractCardItem::paintPicture(QPainter *painter, const QSizeF &translatedS painter->drawPath(shape()); } - if (translatedPixmap.isNull() || SettingsCache::instance().getDisplayCardNames() || facedown) { + if (translatedPixmap.isNull() || SettingsCache::instance().cardsDisplay().getDisplayCardNames() || facedown) { painter->save(); transformPainter(painter, translatedSize, angle); painter->setPen(Qt::white); @@ -165,7 +174,7 @@ void AbstractCardItem::paintPicture(QPainter *painter, const QSizeF &translatedS if (SettingsCache::instance().debug().getShowCardId()) { prefix = "#" + QString::number(id) + " "; } - nameStr = prefix + cardRef.name; + nameStr = prefix + CardLocalization::displayName(getCardInfo()); } painter->drawText(QRectF(3 * scaleFactor, 3 * scaleFactor, translatedSize.width() - 6 * scaleFactor, translatedSize.height() - 6 * scaleFactor), @@ -234,7 +243,7 @@ void AbstractCardItem::setHovered(bool _hovered) isHovered = _hovered; setZValue(_hovered ? ZValues::HOVERED_CARD : realZValue); - setScale(_hovered && SettingsCache::instance().getScaleCards() ? 1.1 : 1); + setScale(_hovered && SettingsCache::instance().cardsDisplay().getScaleCards() ? 1.1 : 1); setTransformOriginPoint(_hovered ? CardDimensions::WIDTH_HALF_F : 0, _hovered ? CardDimensions::HEIGHT_HALF_F : 0); update(); } @@ -287,7 +296,7 @@ void AbstractCardItem::setTapped(bool _tapped, bool canAnimate) } tapped = _tapped; - if (SettingsCache::instance().getTapAnimation() && canAnimate) { + if (SettingsCache::instance().cardsDisplay().getTapAnimation() && canAnimate) { static_cast(scene())->registerAnimationItem(this); } else { tapAngle = tapped ? 90 : 0; @@ -299,6 +308,11 @@ void AbstractCardItem::setTapped(bool _tapped, bool canAnimate) } } +bool AbstractCardItem::animationEvent() +{ + return false; +} + void AbstractCardItem::setFaceDown(bool _facedown) { facedown = _facedown; diff --git a/cockatrice/src/game_graphics/board/abstract_card_item.h b/cockatrice/src/game_graphics/board/abstract_card_item.h index bdb5f7cf1..8cbe95282 100644 --- a/cockatrice/src/game_graphics/board/abstract_card_item.h +++ b/cockatrice/src/game_graphics/board/abstract_card_item.h @@ -7,6 +7,7 @@ #ifndef ABSTRACTCARDITEM_H #define ABSTRACTCARDITEM_H +#include "../animated_item.h" #include "../card_dimensions.h" #include "arrow_target.h" #include "graphics_item_type.h" @@ -16,7 +17,7 @@ class PlayerLogic; -class AbstractCardItem : public ArrowTarget +class AbstractCardItem : public ArrowTarget, public IAnimatedItem { Q_OBJECT protected: @@ -126,6 +127,9 @@ public: emit deleteCardInfoPopup(cardRef.name); } + /** @brief Default: no per-tick animation. Subclasses override to animate. */ + bool animationEvent() override; + protected: void transformPainter(QPainter *painter, const QSizeF &translatedSize, int angle); void mousePressEvent(QGraphicsSceneMouseEvent *event) override; diff --git a/cockatrice/src/game_graphics/board/abstract_counter.cpp b/cockatrice/src/game_graphics/board/abstract_counter.cpp index 219dd456e..4ba04804f 100644 --- a/cockatrice/src/game_graphics/board/abstract_counter.cpp +++ b/cockatrice/src/game_graphics/board/abstract_counter.cpp @@ -1,6 +1,7 @@ #include "abstract_counter.h" #include "../../client/settings/cache_settings.h" +#include "../../client/settings/shortcuts_settings.h" #include "../../game/player/player_actions.h" #include "../../game/player/player_logic.h" #include "../../game_graphics/board/translate_counter_name.h" @@ -28,8 +29,9 @@ AbstractCounter::AbstractCounter(CounterState *state, { setAcceptHoverEvents(true); - connect(state, &CounterState::valueChanged, this, [this](int, int newValue) { + connect(state, &CounterState::valueChanged, this, [this](int oldValue, int newValue, bool skipDamageAnimation) { value = newValue; + onValueChanged(oldValue, newValue, skipDamageAnimation); update(); }); @@ -227,3 +229,9 @@ void AbstractCounterDialog::changeValue(int diff) curValue += diff; setTextValue(QString::number(curValue)); } + +void AbstractCounter::onValueChanged(int /*oldValue*/, int /*newValue*/, bool /*skipDamageAnimation*/) +{ + // Default: no feedback. Subclasses such as PlayerCounter override this to + // flash the counter on meaningful changes (life gain/loss). +} diff --git a/cockatrice/src/game_graphics/board/abstract_counter.h b/cockatrice/src/game_graphics/board/abstract_counter.h index b319a722d..67b5b4074 100644 --- a/cockatrice/src/game_graphics/board/abstract_counter.h +++ b/cockatrice/src/game_graphics/board/abstract_counter.h @@ -35,6 +35,14 @@ protected: bool hovered = false; bool useNameForShortcut; + /** + * @brief Hook for subclasses that need per-value-change feedback (e.g. life-total flash). + * + * Called whenever the counter's value changes, before the item repaints. + * @param skipDamageAnimation True when damage-related feedback should be suppressed (replay rewinds). + */ + virtual void onValueChanged(int oldValue, int newValue, bool skipDamageAnimation); + void mousePressEvent(QGraphicsSceneMouseEvent *event) override; void hoverEnterEvent(QGraphicsSceneHoverEvent *event) override; void hoverLeaveEvent(QGraphicsSceneHoverEvent *event) override; diff --git a/cockatrice/src/game_graphics/board/arrow_item.cpp b/cockatrice/src/game_graphics/board/arrow_item.cpp index af6a6bf36..664d44ecc 100644 --- a/cockatrice/src/game_graphics/board/arrow_item.cpp +++ b/cockatrice/src/game_graphics/board/arrow_item.cpp @@ -4,12 +4,14 @@ #include "../../client/settings/cache_settings.h" #include "../../game/player/player_actions.h" #include "../../game/player/player_logic.h" +#include "../game_scene.h" #include "../player/player_target.h" #include "../z_values.h" #include "../zones/card_zone.h" #include "card_item.h" #include +#include #include #include #include @@ -18,9 +20,27 @@ #include #include #include +#include +#include #include #include +namespace +{ +constexpr qreal kMinStrokeDurationMs = 200.0; +constexpr qreal kMaxStrokeDurationMs = 450.0; +constexpr qreal kMsPerPixel = 0.8; +constexpr qreal kGlowFadeDurationMs = 120.0; +constexpr qreal kSheenHalfWidth = 14.0; + +/// @brief Ease-out cubic, for a natural "slow in / slow out" reveal. +qreal easeOutCubic(qreal t) +{ + const qreal inverse = 1.0 - t; + return 1.0 - inverse * inverse * inverse; +} +} // namespace + ArrowItem::ArrowItem(QSharedPointer _data, ArrowTarget *_startItem, ArrowTarget *_targetItem) : data(std::move(_data)), startItem(_startItem), targetItem(_targetItem) { @@ -46,8 +66,23 @@ ArrowItem::ArrowItem(QSharedPointer _data, ArrowTarget *_startI } } +ArrowItem::~ArrowItem() +{ + if (auto *scene = qobject_cast(this->scene())) { + scene->unregisterAnimationItem(this); + } +} + void ArrowItem::onTargetDestroyed() { + if (data->id == -1) { + // Drag and attach arrows are never inserted into the arrow registry and + // have no server-side counterpart, so no deletion event can clean them + // up. Delete them locally when either endpoint is destroyed. + delArrow(); + return; + } + emit requestDeletion(data->creatorId, data->id); } @@ -90,16 +125,21 @@ void ArrowItem::updatePath(const QPointF &endPoint) prepareGeometryChange(); if (lineLength < 30) { path = QPainterPath(); + bodyPath = QPainterPath(); + headPath = QPainterPath(); + shaftOutlinePath = QPainterPath(); + centerLine = QPainterPath(); + headBaseFraction = 1.0; } else { QPointF c(lineLength / 2, qTan(phi * M_PI / 180) * lineLength); - QPainterPath centerLine; + centerLine = QPainterPath(); centerLine.moveTo(0, 0); centerLine.quadTo(c, QPointF(lineLength, 0)); - double percentage = 1 - headLength / lineLength; - QPointF arrowBodyEndPoint = centerLine.pointAtPercent(percentage); - QLineF testLine(arrowBodyEndPoint, centerLine.pointAtPercent(percentage + 0.001)); + headBaseFraction = 1 - headLength / lineLength; + QPointF arrowBodyEndPoint = centerLine.pointAtPercent(headBaseFraction); + QLineF testLine(arrowBodyEndPoint, centerLine.pointAtPercent(headBaseFraction + 0.001)); qreal alpha = testLine.angle() - 90; QPointF endPoint1 = arrowBodyEndPoint + arrowWidth / 2 * QPointF(qCos(alpha * M_PI / 180), -qSin(alpha * M_PI / 180)); @@ -110,20 +150,89 @@ void ArrowItem::updatePath(const QPointF &endPoint) QPointF point2 = endPoint2 + (headWidth - arrowWidth) / 2 * QPointF(-qCos(alpha * M_PI / 180), qSin(alpha * M_PI / 180)); - path = QPainterPath(-arrowWidth / 2 * QPointF(qCos((phi - 90) * M_PI / 180), qSin((phi - 90) * M_PI / 180))); + QPointF start1 = -arrowWidth / 2 * QPointF(qCos((phi - 90) * M_PI / 180), qSin((phi - 90) * M_PI / 180)); + QPointF start2 = arrowWidth / 2 * QPointF(qCos((phi - 90) * M_PI / 180), qSin((phi - 90) * M_PI / 180)); + + path = QPainterPath(start1); path.quadTo(c, endPoint1); path.lineTo(point1); path.lineTo(QPointF(lineLength, 0)); path.lineTo(point2); path.lineTo(endPoint2); - path.quadTo(c, arrowWidth / 2 * QPointF(qCos((phi - 90) * M_PI / 180), qSin((phi - 90) * M_PI / 180))); - path.lineTo(-arrowWidth / 2 * QPointF(qCos((phi - 90) * M_PI / 180), qSin((phi - 90) * M_PI / 180))); + path.quadTo(c, start2); + path.lineTo(start1); + + bodyPath = QPainterPath(start1); + bodyPath.quadTo(c, endPoint1); + bodyPath.lineTo(endPoint2); + bodyPath.quadTo(c, start2); + bodyPath.lineTo(start1); + + headPath = QPainterPath(endPoint1); + headPath.lineTo(point1); + headPath.lineTo(QPointF(lineLength, 0)); + headPath.lineTo(point2); + headPath.lineTo(endPoint2); + + shaftOutlinePath = QPainterPath(start1); + shaftOutlinePath.quadTo(c, endPoint1); + shaftOutlinePath.moveTo(endPoint2); + shaftOutlinePath.quadTo(c, start2); + shaftOutlinePath.lineTo(start1); } setPos(startPoint); setTransform(QTransform().rotate(-line.angle())); } +void ArrowItem::startDrawAnimation() +{ + if (!SettingsCache::instance().cardsDisplay().getArrowDrawAnimation() || centerLine.isEmpty()) { + return; + } + + strokeDurationMs = qBound(kMinStrokeDurationMs, centerLine.length() * kMsPerPixel, kMaxStrokeDurationMs); + glowFadeDurationMs = kGlowFadeDurationMs; + // The clock is started on the first animationEvent() tick so that t=0 + // corresponds to the first rendered frame. Starting it here would count + // the time spent before the item's first paint (event-loop delays, bursts + // of arrows created together), making the arrow appear already partway + // drawn when it first shows up. + animationStarted = false; + drawProgress = 0.0; + glowAlpha = 1.0; + update(); + if (auto *scene = qobject_cast(this->scene())) { + scene->registerAnimationItem(this); + } +} + +bool ArrowItem::animationEvent() +{ + if (!animationStarted) { + animationClock.start(); + animationStarted = true; + } + + const qint64 elapsed = animationClock.elapsed(); + if (elapsed >= strokeDurationMs + glowFadeDurationMs) { + drawProgress = 1.0; + glowAlpha = 0.0; + update(); + return false; + } + + if (elapsed < strokeDurationMs) { + drawProgress = easeOutCubic(qBound(0.0, elapsed / strokeDurationMs, 1.0)); + glowAlpha = 1.0; + } else { + drawProgress = 1.0; + glowAlpha = 1.0 - (elapsed - strokeDurationMs) / glowFadeDurationMs; + } + update(); + return true; +} + void ArrowItem::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*option*/, QWidget * /*widget*/) { QColor paintColor(data->color); @@ -132,8 +241,66 @@ void ArrowItem::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*opti } else { paintColor.setAlpha(150); } + + painter->save(); + const QPen outlinePen = painter->pen(); painter->setBrush(paintColor); - painter->drawPath(path); + + const auto drawShaft = [this, painter, &outlinePen, paintColor]() { + painter->setPen(Qt::NoPen); + painter->drawPath(bodyPath); + painter->setPen(outlinePen); + painter->setBrush(Qt::NoBrush); + painter->drawPath(shaftOutlinePath); + painter->setBrush(paintColor); + }; + + if (drawProgress >= 1.0 || path.isEmpty()) { + painter->drawPath(path); + } else if (drawProgress < headBaseFraction) { + // The reveal edge and the sheen share the same arc-length parameterization, + // so the stroke stays exactly in sync with the trailing sheen. + const qreal revealX = centerLine.pointAtPercent(drawProgress).x(); + QPainterPath clip; + clip.addRect(QRectF(-glowExtent, path.boundingRect().top() - glowExtent, revealX + glowExtent, + path.boundingRect().height() + 2 * glowExtent)); + painter->setClipPath(clip); + drawShaft(); + } else { + // Once the reveal reaches the head base, pop the whole head in with a fade + // instead of slicing the triangle into a growing stub. + drawShaft(); + const qreal headFadeIn = (drawProgress - headBaseFraction) / (1.0 - headBaseFraction); + painter->setOpacity(headFadeIn); + painter->setPen(Qt::NoPen); + painter->drawPath(headPath); + painter->setPen(outlinePen); + painter->setBrush(Qt::NoBrush); + painter->drawPath(headPath); + painter->setOpacity(1.0); + painter->setBrush(paintColor); + } + + if (glowAlpha > 0.0 && !centerLine.isEmpty()) { + // Sweep a bright band across the arrow. Clipping to the + // silhouette keeps it flat against the shaft so it reads as a light reflection. + const qreal anticipation = qMin(1.0, drawProgress / 0.08); + const QPointF sweep = centerLine.pointAtPercent(qMin(drawProgress, 1.0)); + QLinearGradient sheen(sweep.x() - kSheenHalfWidth, 0.0, sweep.x() + kSheenHalfWidth, 0.0); + sheen.setColorAt(0.0, QColor(paintColor.red(), paintColor.green(), paintColor.blue(), 0)); + sheen.setColorAt(0.5, QColor(255, 255, 255, 200)); + sheen.setColorAt(1.0, QColor(paintColor.red(), paintColor.green(), paintColor.blue(), 0)); + painter->save(); + painter->setPen(Qt::NoPen); + painter->setClipPath(path); + painter->setBrush(sheen); + painter->setOpacity(glowAlpha * anticipation); + painter->drawRect(QRectF(sweep.x() - kSheenHalfWidth - glowExtent, path.boundingRect().top() - glowExtent, + (kSheenHalfWidth + glowExtent) * 2.0, + path.boundingRect().height() + glowExtent * 2.0)); + painter->restore(); + } + painter->restore(); } void ArrowItem::mousePressEvent(QGraphicsSceneMouseEvent *event) @@ -225,6 +392,12 @@ void ArrowDragItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event) void ArrowDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) { if (!startItem) { + // The source card was destroyed while the arrow was being drawn. + // Clean up the arrow and its children instead of leaking them. + delArrow(); + for (auto *child : childArrows) { + child->mouseReleaseEvent(event); + } return; } @@ -261,7 +434,7 @@ void ArrowDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) if (startZone->getName() == ZoneNames::HAND) { startCard->playCard(false); CardInfoPtr ci = startCard->getCard().getCardPtr(); - bool playToStack = SettingsCache::instance().getPlayToStack(); + bool playToStack = SettingsCache::instance().userInterface().getPlayToStack(); if (ci && ((!playToStack && ci->getUiAttributes().tableRow == 3) || (playToStack && ci->getUiAttributes().tableRow != 0 && startCard->getZone()->getName() != ZoneNames::STACK))) { @@ -348,6 +521,12 @@ void ArrowAttachItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event) void ArrowAttachItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) { if (!startItem) { + // The source card was destroyed while the arrow was being drawn. + // Clean up the arrow and its children instead of leaking them. + delArrow(); + for (auto *child : childArrows) { + child->mouseReleaseEvent(event); + } return; } diff --git a/cockatrice/src/game_graphics/board/arrow_item.h b/cockatrice/src/game_graphics/board/arrow_item.h index 1c306e065..76a2d5d6c 100644 --- a/cockatrice/src/game_graphics/board/arrow_item.h +++ b/cockatrice/src/game_graphics/board/arrow_item.h @@ -2,9 +2,13 @@ #define ARROWITEM_H #include "../../game/board/arrow_data.h" +#include "../animated_item.h" #include "arrow_target.h" +#include "graphics_item_type.h" +#include #include +#include #include #include @@ -12,7 +16,7 @@ class CardItem; class QGraphicsSceneMouseEvent; class PlayerLogic; -class ArrowItem : public QObject, public QGraphicsItem +class ArrowItem : public QObject, public QGraphicsItem, public IAnimatedItem { Q_OBJECT Q_INTERFACES(QGraphicsItem) @@ -21,6 +25,19 @@ signals: private: QPainterPath path; + QPainterPath bodyPath; + QPainterPath headPath; + QPainterPath shaftOutlinePath; + QPainterPath centerLine; + qreal headBaseFraction = 1.0; + QElapsedTimer animationClock; + qreal strokeDurationMs = 0; + qreal glowFadeDurationMs = 0; + qreal drawProgress = 1.0; + qreal glowAlpha = 0.0; + bool animationStarted = false; + + static constexpr qreal glowExtent = 12.0; protected: QSharedPointer data; @@ -32,17 +49,28 @@ protected: void mousePressEvent(QGraphicsSceneMouseEvent *event) override; public: + enum + { + Type = typeArrow + }; + [[nodiscard]] int type() const override + { + return Type; + } ArrowItem(QSharedPointer _data, ArrowTarget *_startItem, ArrowTarget *_targetItem); + ~ArrowItem() override; void onTargetDestroyed(); void delArrow(); void updatePath(); void updatePath(const QPointF &endPoint); + void startDrawAnimation(); + bool animationEvent() override; void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override; [[nodiscard]] QRectF boundingRect() const override { - return path.boundingRect(); + return path.boundingRect().adjusted(-glowExtent, -glowExtent, glowExtent, glowExtent); } [[nodiscard]] QPainterPath shape() const override { @@ -106,4 +134,4 @@ protected: void mouseReleaseEvent(QGraphicsSceneMouseEvent *event) override; }; -#endif \ No newline at end of file +#endif diff --git a/cockatrice/src/game_graphics/board/card_item.cpp b/cockatrice/src/game_graphics/board/card_item.cpp index cabe988c2..c2dc455cc 100644 --- a/cockatrice/src/game_graphics/board/card_item.cpp +++ b/cockatrice/src/game_graphics/board/card_item.cpp @@ -1,6 +1,7 @@ #include "card_item.h" #include "../../client/settings/cache_settings.h" +#include "../../client/settings/card_counter_settings.h" #include "../../game/phase.h" #include "../../game/player/player_actions.h" #include "../../game/player/player_logic.h" @@ -19,6 +20,7 @@ #include #include #include +#include CardItem::CardItem(PlayerLogic *_owner, QGraphicsItem *parent, @@ -279,7 +281,7 @@ void CardItem::drawArrow(const QColor &arrowColor) auto *game = owner->getGame(); PlayerLogic *arrowOwner = game->getPlayerManager()->getActiveLocalPlayer(game->getGameState()->getActivePlayer()); int phase = 0; // 0 means to not set the phase - if (SettingsCache::instance().getDoNotDeleteArrowsInSubPhases()) { + if (SettingsCache::instance().userInterface().getDoNotDeleteArrowsInSubPhases()) { int currentPhase = game->getGameState()->getCurrentPhase(); phase = Phases::getLastSubphase(currentPhase) + 1; } @@ -314,7 +316,7 @@ void CardItem::drawAttachArrow() for (const auto &item : scene()->selectedItems()) { CardItem *card = qgraphicsitem_cast(item); - if (card == nullptr) { + if (card == nullptr || card == this) { continue; } if (card->getZone() != state->getZone()) { @@ -398,7 +400,7 @@ void CardItem::playCard(bool faceDown) if (tz) { emit tz->toggleTapped(); } else { - if (SettingsCache::instance().getClickPlaysAllSelected()) { + if (SettingsCache::instance().userInterface().getClickPlaysAllSelected()) { if (faceDown) { emit playSelectedFaceDown(this); } else { @@ -462,7 +464,7 @@ static bool isUnwritableRevealZone(CardZoneLogic *zone) void CardItem::handleClickedToPlay(bool shiftHeld) { if (isUnwritableRevealZone(state->getZone())) { - if (SettingsCache::instance().getClickPlaysAllSelected()) { + if (SettingsCache::instance().userInterface().getClickPlaysAllSelected()) { emit hideSelected(this); } else { state->getZone()->removeCard(this); @@ -479,7 +481,7 @@ void CardItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) return; } if ((event->modifiers() != Qt::AltModifier) && (event->button() == Qt::LeftButton) && - (!SettingsCache::instance().getDoubleClickToPlay())) { + (!SettingsCache::instance().userInterface().getDoubleClickToPlay())) { handleClickedToPlay(event->modifiers().testFlag(Qt::ShiftModifier)); } if (owner != nullptr) { @@ -491,7 +493,7 @@ void CardItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) void CardItem::mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event) { if ((event->modifiers() != Qt::AltModifier) && (event->buttons() == Qt::LeftButton) && - (SettingsCache::instance().getDoubleClickToPlay())) { + (SettingsCache::instance().userInterface().getDoubleClickToPlay())) { handleClickedToPlay(event->modifiers().testFlag(Qt::ShiftModifier)); } event->accept(); diff --git a/cockatrice/src/game_graphics/board/card_item.h b/cockatrice/src/game_graphics/board/card_item.h index 8efcd085d..2ba43d03d 100644 --- a/cockatrice/src/game_graphics/board/card_item.h +++ b/cockatrice/src/game_graphics/board/card_item.h @@ -12,7 +12,6 @@ #include "abstract_card_item.h" #include -#include class CardDatabase; class CardDragItem; @@ -138,7 +137,7 @@ public: void resetState(bool keepAnnotations = false); void processCardInfo(const ServerInfo_Card &_info); - bool animationEvent(); + bool animationEvent() override; CardDragItem *createDragItem(int _id, const QPointF &_pos, const QPointF &_scenePos, bool forceFaceDown); void deleteDragItem(); void drawArrow(const QColor &arrowColor); diff --git a/cockatrice/src/game_graphics/board/graphics_item_type.h b/cockatrice/src/game_graphics/board/graphics_item_type.h index 7eac132b0..afac7881f 100644 --- a/cockatrice/src/game_graphics/board/graphics_item_type.h +++ b/cockatrice/src/game_graphics/board/graphics_item_type.h @@ -16,7 +16,8 @@ enum GraphicsItemType typeZone = QGraphicsItem::UserType + 3, typePlayerTarget = QGraphicsItem::UserType + 4, typeDeckViewCardContainer = QGraphicsItem::UserType + 5, - typeOther = QGraphicsItem::UserType + 6 + typeOther = QGraphicsItem::UserType + 6, + typeArrow = QGraphicsItem::UserType + 7 }; #endif // COCKATRICE_GRAPHICS_ITEM_TYPE_H diff --git a/cockatrice/src/game_graphics/deckview/deck_view.cpp b/cockatrice/src/game_graphics/deckview/deck_view.cpp index a5d0fa3bc..1acd02a75 100644 --- a/cockatrice/src/game_graphics/deckview/deck_view.cpp +++ b/cockatrice/src/game_graphics/deckview/deck_view.cpp @@ -10,7 +10,7 @@ #include #include #include -#include +#include DeckViewCardDragItem::DeckViewCardDragItem(DeckViewCard *_item, const QPointF &_hotSpot, @@ -77,11 +77,12 @@ DeckViewCard::DeckViewCard(QGraphicsItem *parent, const CardRef &cardRef, const { setAcceptHoverEvents(true); - connect(&SettingsCache::instance(), &SettingsCache::roundCardCornersChanged, this, [this](bool _roundCardCorners) { - Q_UNUSED(_roundCardCorners); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::roundCardCornersChanged, this, + [this](bool _roundCardCorners) { + Q_UNUSED(_roundCardCorners); - update(); - }); + update(); + }); } DeckViewCard::~DeckViewCard() @@ -99,7 +100,8 @@ void DeckViewCard::paint(QPainter *painter, const QStyleOptionGraphicsItem *opti pen.setJoinStyle(Qt::MiterJoin); pen.setColor(originZone == DECK_ZONE_MAIN ? Qt::green : Qt::red); painter->setPen(pen); - qreal cardRadius = SettingsCache::instance().getRoundCardCorners() ? 0.05 * (CardDimensions::WIDTH_F - 3) : 0.0; + qreal cardRadius = + SettingsCache::instance().cardsDisplay().getRoundCardCorners() ? 0.05 * (CardDimensions::WIDTH_F - 3) : 0.0; painter->drawRoundedRect(QRectF(1.5, 1.5, CardDimensions::WIDTH_F - 3, CardDimensions::HEIGHT_F - 3), cardRadius, cardRadius); painter->restore(); @@ -378,12 +380,10 @@ void DeckViewScene::rebuildTree() addItem(container); } - for (int j = 0; j < currentZone->size(); j++) { - auto *currentCard = dynamic_cast(currentZone->at(j)); - if (!currentCard) { - continue; - } - + // Cards in custom zones nested under a board are regular board cards in-game. + // They are collected recursively (like every other consumer) and reported with + // the top-level board zone as their origin, so that sideboard plans keep working. + for (auto *currentCard : deck->getCardNodes({currentZone->getName()})) { for (int k = 0; k < currentCard->getNumber(); ++k) { auto *newCard = new DeckViewCard(container, currentCard->toCardRef(), currentZone->getName()); container->addCard(newCard); diff --git a/cockatrice/src/game_graphics/deckview/deck_view_container.cpp b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp index 21284c517..bc07ac183 100644 --- a/cockatrice/src/game_graphics/deckview/deck_view_container.cpp +++ b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp @@ -1,6 +1,7 @@ #include "deck_view_container.h" #include "../../client/settings/cache_settings.h" +#include "../../client/settings/shortcuts_settings.h" #include "../../interface/card_picture_loader/card_picture_loader.h" #include "../../interface/deck_loader/deck_loader.h" #include "../../interface/widgets/dialogs/dlg_load_deck.h" @@ -8,18 +9,23 @@ #include "../../interface/widgets/dialogs/dlg_load_deck_from_website.h" #include "../../interface/widgets/dialogs/dlg_load_remote_deck.h" #include "../../interface/widgets/tabs/tab_game.h" +#include "../../interface/widgets/visual_deck_storage/visual_deck_storage_widget.h" #include "deck_view.h" #include #include #include +#include #include #include +#include #include #include #include #include -#include +#include +#include +#include ToggleButton::ToggleButton(QWidget *parent) : QPushButton(parent), state(false) { @@ -95,8 +101,11 @@ DeckViewContainer::DeckViewContainer(int _playerId, TabGame *parent) &DeckViewContainer::refreshShortcuts); refreshShortcuts(); - connect(&SettingsCache::instance(), &SettingsCache::visualDeckStorageInGameChanged, this, - &DeckViewContainer::setVisualDeckStorageExists); + connect(&SettingsCache::instance().visualDeckStorage(), &VisualDeckStorageSettings::visualDeckStorageInGameChanged, + this, &DeckViewContainer::setVisualDeckStorageExists); + + connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::playmatSettingsChanged, this, + &DeckViewContainer::onPlaymatSettingsChanged); switchToDeckSelectView(); } @@ -138,7 +147,7 @@ static void setVisibility(QPushButton *button, bool visible) void DeckViewContainer::switchToDeckSelectView() { - if (SettingsCache::instance().getVisualDeckStorageInGame()) { + if (SettingsCache::instance().visualDeckStorage().getVisualDeckStorageInGame()) { deckView->setHidden(true); tryCreateVisualDeckStorageWidget(); @@ -275,6 +284,8 @@ void DeckViewContainer::loadDeckFromFile(const QString &filePath) void DeckViewContainer::loadDeckFromDeckList(const DeckList &deck) { + currentDeck = deck; + QString deckString = deck.writeToString_Native(); if (deckString.length() > MAX_FILE_LENGTH) { @@ -287,6 +298,52 @@ void DeckViewContainer::loadDeckFromDeckList(const DeckList &deck) PendingCommand *pend = parentGame->getGame()->getGameEventHandler()->prepareGameCommand(cmd); connect(pend, &PendingCommand::finished, this, &DeckViewContainer::deckSelectFinished); parentGame->getGame()->getGameEventHandler()->sendGameCommand(pend, playerId); + + resolveAndSendPlaymat(); +} + +void DeckViewContainer::resolveAndSendPlaymat() +{ + if (currentDeck.getCardRefList().isEmpty() && currentDeck.getPlaymat().card.isEmpty()) { + return; + } + + const auto &settings = SettingsCache::instance().userInterface(); + const auto fallbackBehavior = static_cast(settings.getPlaymatFallbackBehavior()); + + QList fallbackList = settings.getPlaymatFallbackList(); + + // In random mode with 2+ entries, remove the last-resolved mat to avoid repeats. + if (fallbackBehavior == PlaymatFallbackModeRandom && fallbackList.size() > 1) { + fallbackList.removeAll(lastResolvedPlaymat); + } + + const PlaymatInfo resolved = + resolvePlaymatForDeck(currentDeck, fallbackList, static_cast(settings.getPlaymatMode()), + fallbackBehavior, playmatRotationIndex); + + lastResolvedPlaymat = resolved; + + Command_SetPlaymat playmatCmd; + auto *pp = playmatCmd.mutable_playmat_params(); + pp->set_card_name(resolved.card.name.toStdString()); + pp->set_card_provider_id(resolved.card.providerId.toStdString()); + pp->set_margin_pct_l(resolved.params.marginPctL); + pp->set_margin_pct_r(resolved.params.marginPctR); + pp->set_vertical_offset(resolved.params.verticalOffset); + pp->set_zoom(resolved.params.zoom); + PendingCommand *playmatPend = parentGame->getGame()->getGameEventHandler()->prepareGameCommand(playmatCmd); + parentGame->getGame()->getGameEventHandler()->sendGameCommand(playmatPend, playerId); +} + +void DeckViewContainer::onPlaymatSettingsChanged() +{ + resolveAndSendPlaymat(); +} + +void DeckViewContainer::advancePlaymatRotation() +{ + playmatRotationIndex++; } void DeckViewContainer::loadRemoteDeck() @@ -377,6 +434,10 @@ void DeckViewContainer::sideboardPlanChanged() */ void DeckViewContainer::sendReadyStartCommand(bool ready) { + if (ready) { + resolveAndSendPlaymat(); + } + Command_ReadyStart cmd; cmd.set_ready(ready); parentGame->getGame()->getGameEventHandler()->sendGameCommand(cmd, playerId); @@ -414,6 +475,7 @@ void DeckViewContainer::setSideboardLocked(bool locked) void DeckViewContainer::setDeck(const DeckList &deck) { + currentDeck = deck; deckView->setDeck(deck); switchToDeckLoadedView(); } \ No newline at end of file diff --git a/cockatrice/src/game_graphics/deckview/deck_view_container.h b/cockatrice/src/game_graphics/deckview/deck_view_container.h index ec024bace..b5317c39a 100644 --- a/cockatrice/src/game_graphics/deckview/deck_view_container.h +++ b/cockatrice/src/game_graphics/deckview/deck_view_container.h @@ -57,6 +57,9 @@ private: VisualDeckStorageWidget *visualDeckStorageWidget; TabGame *parentGame; int playerId; + int playmatRotationIndex = 0; ///< Per-match cursor for round-robin playmat mode. + DeckList currentDeck; ///< Cached deck for live settings re-resolution. + PlaymatInfo lastResolvedPlaymat; ///< Tracks last sent playmat to avoid repeats in random mode. void tryCreateVisualDeckStorageWidget(); void sendReadyStartCommand(bool ready); @@ -75,6 +78,7 @@ private slots: void sideboardLockButtonClicked(); void updateSideboardLockButtonText(); void refreshShortcuts(); + void onPlaymatSettingsChanged(); signals: void newCardAdded(AbstractCardItem *card); void notIdle(); @@ -87,6 +91,8 @@ public: void setSideboardLocked(bool locked); void setDeck(const DeckList &deck); void setVisualDeckStorageExists(bool exists); + void advancePlaymatRotation(); + void resolveAndSendPlaymat(); public slots: void loadDeckFromFile(const QString &filePath); diff --git a/cockatrice/src/game_graphics/dialogs/dlg_create_token.cpp b/cockatrice/src/game_graphics/dialogs/dlg_create_token.cpp index 11c24b72e..b311d2ebd 100644 --- a/cockatrice/src/game_graphics/dialogs/dlg_create_token.cpp +++ b/cockatrice/src/game_graphics/dialogs/dlg_create_token.cpp @@ -20,7 +20,10 @@ #include #include #include -#include +#include +#include +#include +#include DlgCreateToken::DlgCreateToken(const QStringList &_predefinedTokens, QWidget *parent) : QDialog(parent), predefinedTokens(_predefinedTokens) @@ -186,7 +189,7 @@ void DlgCreateToken::tokenSelectionChanged(const QModelIndex ¤t, const QMo const QChar cardColor = cardInfo->getColorChar(); colorEdit->setCurrentIndex(colorEdit->findData(cardColor, Qt::UserRole, Qt::MatchFixedString)); ptEdit->setText(cardInfo->getPowTough()); - if (SettingsCache::instance().getAnnotateTokens()) { + if (SettingsCache::instance().userInterface().getAnnotateTokens()) { annotationEdit->setText(cardInfo->getText()); } } else { diff --git a/cockatrice/src/game_graphics/dialogs/dlg_roll_dice.cpp b/cockatrice/src/game_graphics/dialogs/dlg_roll_dice.cpp index dfb3d0bc5..4f5d5b861 100644 --- a/cockatrice/src/game_graphics/dialogs/dlg_roll_dice.cpp +++ b/cockatrice/src/game_graphics/dialogs/dlg_roll_dice.cpp @@ -5,7 +5,7 @@ #include #include #include -#include +#include DlgRollDice::DlgRollDice(QWidget *parent) : QDialog(parent) { diff --git a/cockatrice/src/game_graphics/game_scene.cpp b/cockatrice/src/game_graphics/game_scene.cpp index b9816a602..17af7618b 100644 --- a/cockatrice/src/game_graphics/game_scene.cpp +++ b/cockatrice/src/game_graphics/game_scene.cpp @@ -17,8 +17,8 @@ #include #include #include -#include #include +#include #include #include @@ -36,7 +36,7 @@ GameScene::GameScene(PhasesToolbar *_phasesToolbar, QObject *parent) { animationTimer = new QBasicTimer; addItem(phasesToolbar); - connect(&SettingsCache::instance(), &SettingsCache::minPlayersForMultiColumnLayoutChanged, this, + connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::minPlayersForMultiColumnLayoutChanged, this, &GameScene::rearrange); rearrange(); @@ -44,7 +44,30 @@ GameScene::GameScene(PhasesToolbar *_phasesToolbar, QObject *parent) GameScene::~GameScene() { + // Sever all destroyed->removeAnimatedItem connections before the members below + // are destroyed: the base QGraphicsScene destructor destroys the remaining items, + // and their destroyed() signals must not reach slots that reference members that + // no longer exist. The connection handle overload is used because the string-based + // disconnect(nullptr, nullptr, this, nullptr) is invalid (the sender must never be + // nullptr) and would otherwise fail to sever these pointer-to-member connections. + for (auto it = animationItemConnections.constBegin(); it != animationItemConnections.constEnd(); ++it) { + QObject::disconnect(*it); + } + animationItemConnections.clear(); + delete animationTimer; + animationTimer = nullptr; + + // Delete all ArrowItems before QGraphicsScene's base destructor runs. + // QGraphicsScene::~QGraphicsScene() destroys items in arbitrary order. + // If a PlayerTarget is destroyed before an ArrowItem pointing to it, + // ArrowItem::onTargetDestroyed fires and emits on the partially-destroyed + // GameScene, causing a segfault. + for (auto *item : items()) { + if (auto *arrow = qgraphicsitem_cast(item)) { + delete arrow; + } + } // DO NOT call clearViews() here // clearViews calls close() on the zoneViews, which sends signals; sending signals in destructors leads to segfaults @@ -198,7 +221,12 @@ void GameScene::removePlayer(PlayerLogic *player) clearArrowsForPlayer(player->getPlayerInfo()->getId()); - for (ZoneViewWidget *zone : zoneViews) { + // Closing a view removes it from zoneViews synchronously, so iterate over a + // copy: otherwise a player with several open views (e.g. library and hand) + // only has the first one closed here and the remaining views are left + // pointing at a player that is about to be deleted. + const QList zoneViewCopy = zoneViews; + for (ZoneViewWidget *zone : zoneViewCopy) { if (zone->getPlayer() == player) { zone->close(); } @@ -234,17 +262,27 @@ void GameScene::adjustPlayerRotation(int rotationAdjustment) */ void GameScene::rearrange() { - int firstPlayerIndex = 0; - auto playersPlaying = collectActivePlayers(firstPlayerIndex); - playersPlaying = rotatePlayers(playersPlaying, firstPlayerIndex); + if (rearranging) { + needsReArrange = true; + return; + } + rearranging = true; + do { + needsReArrange = false; - int columns = determineColumnCount(playersPlaying.size()); - QSizeF sceneSize = computeSceneSizeAndPlayerLayout(playersPlaying, columns); + int firstPlayerIndex = 0; + auto playersPlaying = collectActivePlayers(firstPlayerIndex); + playersPlaying = rotatePlayers(playersPlaying, firstPlayerIndex); - phasesToolbar->setHeight(sceneSize.height()); - setSceneRect(0, 0, sceneSize.width(), sceneSize.height()); + int columns = determineColumnCount(playersPlaying.size()); + QSizeF sceneSize = computeSceneSizeAndPlayerLayout(playersPlaying, columns); - processViewSizeChange(viewSize); + phasesToolbar->setHeight(sceneSize.height()); + setSceneRect(0, 0, sceneSize.width(), sceneSize.height()); + + processViewSizeChange(viewSize); + } while (needsReArrange); + rearranging = false; } // ---------- View Size ---------- @@ -324,7 +362,7 @@ QList GameScene::rotatePlayers(const QList &active int GameScene::determineColumnCount(int playerCount) { - return playerCount < SettingsCache::instance().getMinPlayersForMultiColumnLayout() ? 1 : 2; + return playerCount < SettingsCache::instance().userInterface().getMinPlayersForMultiColumnLayout() ? 1 : 2; } /** @@ -441,8 +479,14 @@ void GameScene::resizeColumnsAndPlayers(const QList &minWidthByColumn, qr qreal extraWidthPerColumn = (newWidth - minWidth) / playersByColumn.size(); qreal newx = phasesToolbar->getWidth(); - for (int col = 0; col < playersByColumn.size(); ++col) { - for (PlayerGraphicsItem *player : playersByColumn[col]) { + // Snapshot the columns: resizing a player's table can synchronously trigger + // GameScene::rearrange (table width -> sizeChanged -> updateBoundingRect -> + // sizeChanged -> rearrange), and rearrange rebuilds playersByColumn. Iterating + // the live container across that re-entrant call would use invalidated iterators. + const QList> columns = playersByColumn; + + for (int col = 0; col < columns.size(); ++col) { + for (PlayerGraphicsItem *player : columns[col]) { player->processSceneSizeChange(minWidthByColumn[col] + extraWidthPerColumn); player->setPos(newx, player->y()); } @@ -484,6 +528,7 @@ void GameScene::addArrow(QSharedPointer data) auto *arrow = new ArrowItem(data, startCard, targetItem); addItem(arrow); + arrow->startDrawAnimation(); arrowRegistry.insert(data, arrow); connect(arrow, &ArrowItem::requestDeletion, this, &GameScene::requestArrowDeletion); } @@ -529,7 +574,9 @@ void GameScene::clearArrowsForPlayer(int playerId) void GameScene::clearArrowsForPlayerLocally(int playerId) { for (int arrowId : arrowRegistry.idsForPlayer(playerId)) { - arrowRegistry.take(playerId, arrowId)->delArrow(); + if (auto *arrow = arrowRegistry.take(playerId, arrowId)) { + arrow->delArrow(); + } } } @@ -622,7 +669,10 @@ CardItem *GameScene::findTopmostCardInZone(const QList &items, */ void GameScene::toggleZoneView(PlayerLogic *player, const QString &zoneName, int numberCards, bool isReversed) { - for (auto &view : zoneViews) { + // Closing a view removes it from zoneViews synchronously, so iterate over a + // copy to make sure every already-open matching view is closed. + const QList zoneViewCopy = zoneViews; + for (auto *view : zoneViewCopy) { ZoneViewZone *temp = view->getZone(); if (temp->getLogic()->getName() == zoneName && temp->getLogic()->getPlayer() == player && qobject_cast(temp->getLogic())->getNumberCards() == numberCards) { @@ -722,30 +772,53 @@ bool GameScene::event(QEvent *event) void GameScene::timerEvent(QTimerEvent * /*event*/) { - QMutableSetIterator i(cardsToAnimate); + QMutableHashIterator i(animatedItems); while (i.hasNext()) { i.next(); if (!i.value()->animationEvent()) { i.remove(); } } - if (cardsToAnimate.isEmpty()) { + if (animatedItems.isEmpty()) { animationTimer->stop(); } } -void GameScene::registerAnimationItem(AbstractCardItem *card) +void GameScene::registerAnimationItem(IAnimatedItem *item) { - cardsToAnimate.insert(static_cast(card)); - if (!animationTimer->isActive()) { + auto *object = dynamic_cast(item); + if (!object) { + return; + } + // Guard against duplicate connections using the connection map, not + // animatedItems: the animation timer removes entries from animatedItems when an + // animation completes, but the destroyed->removeAnimatedItem connection must + // persist until the object is destroyed. Relying on animatedItems here would let + // a re-registered item (e.g. a life counter that flashes repeatedly) accumulate + // duplicate destroyed connections, the older ones of which would survive teardown. + if (!animationItemConnections.contains(object)) { + animationItemConnections.insert(object, + connect(object, &QObject::destroyed, this, &GameScene::removeAnimatedItem)); + } + animatedItems.insert(object, item); + if (animationTimer && !animationTimer->isActive()) { animationTimer->start(10, this); } } -void GameScene::unregisterAnimationItem(AbstractCardItem *card) +void GameScene::unregisterAnimationItem(IAnimatedItem *item) { - cardsToAnimate.remove(static_cast(card)); - if (cardsToAnimate.isEmpty()) { + animatedItems.remove(dynamic_cast(item)); + if (animationTimer && animatedItems.isEmpty()) { + animationTimer->stop(); + } +} + +void GameScene::removeAnimatedItem(QObject *item) +{ + animatedItems.remove(item); + animationItemConnections.remove(item); + if (animationTimer && animatedItems.isEmpty()) { animationTimer->stop(); } } diff --git a/cockatrice/src/game_graphics/game_scene.h b/cockatrice/src/game_graphics/game_scene.h index 74e979556..859d7a6eb 100644 --- a/cockatrice/src/game_graphics/game_scene.h +++ b/cockatrice/src/game_graphics/game_scene.h @@ -4,13 +4,14 @@ #include "../game/arrow_registry.h" #include "../game/board/arrow_data.h" #include "../game/zones/card_zone_logic.h" +#include "animated_item.h" #include "board/arrow_item.h" #include +#include #include #include #include -#include inline Q_LOGGING_CATEGORY(GameSceneLog, "game_scene"); inline Q_LOGGING_CATEGORY(GameScenePlayerAdditionRemovalLog, "game_scene.player_addition_removal"); @@ -24,6 +25,7 @@ class CardItem; class ServerInfo_Card; class PhasesToolbar; class QBasicTimer; +class QObject; /** * @class GameScene @@ -50,9 +52,13 @@ private: QList zoneViews; ///< Active zone view widgets QSize viewSize; ///< Current view size QPointer hoveredCard; ///< Currently hovered card - QBasicTimer *animationTimer; ///< Timer for card animations - QSet cardsToAnimate; ///< Cards currently animating - int playerRotation; ///< Rotation offset for player layout + QBasicTimer *animationTimer; ///< Timer for scene animations + QHash animatedItems; ///< Items currently animating + QHash + animationItemConnections; ///< destroyed->removeAnimatedItem handles per animated item + int playerRotation; ///< Rotation offset for player layout + bool rearranging = false; ///< Guard against re-entrant rearrange + bool needsReArrange = false; ///< Pending rearrange requested during a pass /** * @brief Updates which card is currently hovered based on scene coordinates. @@ -182,15 +188,24 @@ public: /** @brief Updates hovered card highlighting. */ void updateHoveredCard(CardItem *newCard); - /** @brief Registers a card for animation updates. */ - void registerAnimationItem(AbstractCardItem *card); + /** + * @brief Registers an item for animation updates with the shared scene timer. + * + * The item must inherit QObject; it is unregistered automatically when it is + * destroyed, so it may be deleted mid-animation without a dangling pointer. + */ + void registerAnimationItem(IAnimatedItem *item); - /** @brief Unregisters a card from animation updates. */ - void unregisterAnimationItem(AbstractCardItem *card); + /** @brief Unregisters an item from animation updates. */ + void unregisterAnimationItem(IAnimatedItem *item); void startRubberBand(const QPointF &selectionOrigin); void resizeRubberBand(const QPointF &cursorPoint, int selectedCount); void stopRubberBand(); +private slots: + /** @brief Removes a destroyed item from the animation set. */ + void removeAnimatedItem(QObject *item); + public slots: void onCardSelectionChanged(AbstractCardItem *card, bool selected); void onCardRightClicked(AbstractCardItem *card, QPoint screenPos); diff --git a/cockatrice/src/game_graphics/game_view.cpp b/cockatrice/src/game_graphics/game_view.cpp index 41befd9a4..ed6355157 100644 --- a/cockatrice/src/game_graphics/game_view.cpp +++ b/cockatrice/src/game_graphics/game_view.cpp @@ -1,12 +1,17 @@ #include "game_view.h" #include "../client/settings/cache_settings.h" +#include "../client/settings/shortcuts_settings.h" #include "game_scene.h" #include +#include #include +#include #include #include +#include +#include // QRubberBand calls raise() in showEvent() and changeEvent() to stay on top of siblings. // This subclass disables that behavior so dragCountLabel can appear above it. @@ -42,9 +47,12 @@ GameView::GameView(GameScene *scene, QWidget *parent) : QGraphicsView(scene, par connect(scene, &GameScene::sigResizeRubberBand, this, &GameView::resizeRubberBand); connect(scene, &GameScene::sigStopRubberBand, this, &GameView::stopRubberBand); connect(scene, &QGraphicsScene::selectionChanged, this, [this]() { updateTotalSelectionCount(); }); + connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::tallyTypeChanged, this, + [this] { updateTotalSelectionCount(); }); - setFocusDisabled(SettingsCache::instance().getKeepGameChatFocus()); - connect(&SettingsCache::instance(), &SettingsCache::keepGameChatFocusChanged, this, &GameView::setFocusDisabled); + setFocusDisabled(SettingsCache::instance().userInterface().getKeepGameChatFocus()); + connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::keepGameChatFocusChanged, this, + &GameView::setFocusDisabled); aCloseMostRecentZoneView = new QAction(this); @@ -55,31 +63,40 @@ GameView::GameView(GameScene *scene, QWidget *parent) : QGraphicsView(scene, par refreshShortcuts(); rubberBand = new SelectionRubberBand(QRubberBand::Rectangle, this); - const QString countLabelStyle = "color: white; " - "font-size: 14px; " - "font-weight: bold; " - "background-color: rgba(0, 0, 0, 160); " - "border-radius: 3px; " - "padding: 1px 2px;"; + const QString baseProperties = "color: white; " + "font-family: monospace; " + "background-color: rgba(0, 0, 0, 160); " + "border-radius: 3px; " + "padding: 1px 2px; " + "white-space: pre;"; + + const QString dragCountLabelStyle = baseProperties + "font-size: 14px; font-weight: bold;"; + const QString totalCountLabelStyle = baseProperties + "font-size: 16px; font-weight: bold;"; + const QString subtypeTallyLabelStyle = baseProperties + "font-size: 12px;"; dragCountLabel = new QLabel(this); - dragCountLabel->setStyleSheet(countLabelStyle); + dragCountLabel->setStyleSheet(dragCountLabelStyle); dragCountLabel->hide(); dragCountLabel->raise(); totalCountLabel = new QLabel(this); - totalCountLabel->setStyleSheet(countLabelStyle); + totalCountLabel->setStyleSheet(totalCountLabelStyle); totalCountLabel->hide(); + + tallyContainer = new QWidget(this); + tallyContainer->setStyleSheet(subtypeTallyLabelStyle); + tallyLayout = new QGridLayout(tallyContainer); + tallyLayout->setContentsMargins(2, 2, 2, 2); + tallyLayout->setSpacing(2); + tallyContainer->hide(); } void GameView::resizeEvent(QResizeEvent *event) { QGraphicsView::resizeEvent(event); - GameScene *s = dynamic_cast(scene()); - if (s) { - s->processViewSizeChange(event->size()); - } + GameScene *s = static_cast(scene()); + s->processViewSizeChange(event->size()); updateSceneRect(scene()->sceneRect()); updateTotalSelectionCount(event->size()); @@ -97,6 +114,7 @@ void GameView::startRubberBand(const QPointF &_selectionOrigin) } selectionOrigin = _selectionOrigin; + previousBandRect = QRect(); rubberBand->setGeometry(QRect(mapFromScene(selectionOrigin), QSize(0, 0))); rubberBand->show(); } @@ -111,9 +129,19 @@ void GameView::resizeRubberBand(const QPointF &cursorPoint, int selectedCount) QPoint cursor = cursorPoint.toPoint(); QRect rect = QRect(mapFromScene(selectionOrigin), cursor).normalized(); - rubberBand->setGeometry(rect); - if (!SettingsCache::instance().getShowDragSelectionCount()) { + rubberBand->setGeometry(rect); + if (viewport()) { + // Repaint the union of the previous and current band rects: the vacated + // strip of a child widget is not reliably invalidated on all platforms + // (notably macOS), leaving stale pixels under the selection. + QRect dirty = previousBandRect.isNull() ? rect : previousBandRect.united(rect); + dirty.adjust(-1, -1, 1, 1); + viewport()->update(dirty); + previousBandRect = rect; + } + + if (!SettingsCache::instance().userInterface().getShowDragSelectionCount()) { dragCountLabel->hide(); return; } @@ -154,7 +182,13 @@ void GameView::stopRubberBand() return; } + // Same rationale as resizeRubberBand: repaint the last known band area + // since hiding a child widget doesn't reliably invalidate its region. rubberBand->hide(); + if (viewport() && !previousBandRect.isNull()) { + viewport()->update(previousBandRect.adjusted(-1, -1, 1, 1)); + previousBandRect = QRect(); + } dragCountLabel->hide(); } @@ -164,29 +198,110 @@ void GameView::refreshShortcuts() SettingsCache::instance().shortcuts().getShortcut("Player/aCloseMostRecentZoneView")); } +void GameView::clearTallyLabels() +{ + QtUtils::clearLayoutRec(tallyLayout); +} + +QSize GameView::rebuildTallyLabels(const QList &entries) +{ + clearTallyLabels(); + + const QString nameStyle = QStringLiteral("color: white; font-size: 12px; background: transparent;"); + const QString countStyle = + QStringLiteral("color: white; font-size: 14px; font-weight: bold; background: transparent;"); + + int totalHeight = 0; + int maxNameWidth = 0; + int maxCountWidth = 0; + + int row = 0; + for (const TallyRow &entry : entries) { + auto *nameLabel = new QLabel(entry.name, tallyContainer); + nameLabel->setStyleSheet(nameStyle); + nameLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + tallyLayout->addWidget(nameLabel, row, 0); + + auto *countLabel = new QLabel(entry.value, tallyContainer); + countLabel->setStyleSheet(countStyle); + countLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + tallyLayout->addWidget(countLabel, row, 1); + + QSize nameSize = nameLabel->sizeHint(); + QSize countSize = countLabel->sizeHint(); + maxNameWidth = qMax(maxNameWidth, nameSize.width()); + maxCountWidth = qMax(maxCountWidth, countSize.width()); + totalHeight += qMax(nameSize.height(), countSize.height()); + + ++row; + } + + int spacing = tallyLayout->spacing(); + int margins = tallyLayout->contentsMargins().left() + tallyLayout->contentsMargins().right(); + int verticalMargins = tallyLayout->contentsMargins().top() + tallyLayout->contentsMargins().bottom(); + + int width = maxNameWidth + spacing + maxCountWidth + margins; + int height = totalHeight + (row - 1) * spacing + verticalMargins; + + return QSize(width, height); +} + void GameView::updateTotalSelectionCount(const QSize &viewSize) { - if (!SettingsCache::instance().getShowTotalSelectionCount()) { - totalCountLabel->hide(); - return; - } + constexpr int kMarginInPixels = 10; + constexpr int kSpacingBetweenLabels = 4; + + int availableWidth = viewSize.isValid() ? viewSize.width() : viewport()->width(); + int availableHeight = viewSize.isValid() ? viewSize.height() : viewport()->height(); int count = scene()->selectedItems().count(); - if (count > 1) { + if (!SettingsCache::instance().userInterface().getShowTotalSelectionCount() || count <= 1) { + totalCountLabel->hide(); + } else { totalCountLabel->setText(QString::number(count)); totalCountLabel->adjustSize(); - constexpr int kMarginInPixels = 10; - int availableWidth = viewSize.isValid() ? viewSize.width() : viewport()->width(); - int availableHeight = viewSize.isValid() ? viewSize.height() : viewport()->height(); int x = availableWidth - totalCountLabel->width() - kMarginInPixels; int y = availableHeight - totalCountLabel->height() - kMarginInPixels; totalCountLabel->move(x, y); totalCountLabel->show(); - } else { - totalCountLabel->hide(); } + + TallyType tallyType = Tally::intToType(SettingsCache::instance().userInterface().getTallyType()); + + GameScene *gameScene = static_cast(scene()); + QList entries = Tally::compute(gameScene->selectedCards(), tallyType); + + if (entries.isEmpty()) { + tallyContainer->hide(); + cachedTallyRows.clear(); + return; + } + + // Only rebuild labels if entries changed + QSize containerSize; + if (entries != cachedTallyRows) { + cachedTallyRows = entries; + containerSize = rebuildTallyLabels(entries); + tallyContainer->resize(containerSize); + } else { + containerSize = tallyContainer->size(); + } + + int x = availableWidth - containerSize.width() - kMarginInPixels; + int y; + + if (totalCountLabel->isVisible()) { + y = totalCountLabel->y() - containerSize.height() - kSpacingBetweenLabels; + } else { + y = availableHeight - containerSize.height() - kMarginInPixels; + } + + y = qMax(kMarginInPixels, y); + + tallyContainer->move(x, y); + tallyContainer->show(); } /** diff --git a/cockatrice/src/game_graphics/game_view.h b/cockatrice/src/game_graphics/game_view.h index 80e8e96b5..a23655513 100644 --- a/cockatrice/src/game_graphics/game_view.h +++ b/cockatrice/src/game_graphics/game_view.h @@ -7,9 +7,12 @@ #ifndef GAMEVIEW_H #define GAMEVIEW_H +#include "tally/tally.h" + #include class GameScene; +class QGridLayout; class QLabel; class QRubberBand; @@ -21,7 +24,14 @@ private: QRubberBand *rubberBand; QLabel *dragCountLabel; QLabel *totalCountLabel; + QWidget *tallyContainer; + QGridLayout *tallyLayout; QPointF selectionOrigin; + QRect previousBandRect; ///< Last rubber-band rect for targeted repaint + QList cachedTallyRows; ///< Cached entries to avoid redundant rebuilds + + QSize rebuildTallyLabels(const QList &entries); + void clearTallyLabels(); protected: void resizeEvent(QResizeEvent *event) override; diff --git a/cockatrice/src/game_graphics/hand_counter.cpp b/cockatrice/src/game_graphics/hand_counter.cpp index 35989ff38..8dcbcfdaa 100644 --- a/cockatrice/src/game_graphics/hand_counter.cpp +++ b/cockatrice/src/game_graphics/hand_counter.cpp @@ -1,5 +1,6 @@ #include "hand_counter.h" +#include "../interface/pixel_map_generator.h" #include "zones/card_zone.h" #include @@ -32,7 +33,8 @@ void HandCounter::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*op QSize translatedSize = painter->combinedTransform().mapRect(boundingRect()).size().toSize(); QPixmap cachedPixmap; if (!QPixmapCache::find("handCounter" + QString::number(translatedSize.width()), &cachedPixmap)) { - cachedPixmap = QPixmap("theme:hand").scaled(translatedSize, Qt::KeepAspectRatio, Qt::SmoothTransformation); + cachedPixmap = + themePixmap(QStringLiteral("hand")).scaled(translatedSize, Qt::KeepAspectRatio, Qt::SmoothTransformation); QPixmapCache::insert("handCounter" + QString::number(translatedSize.width()), cachedPixmap); } resetPainterTransform(painter); diff --git a/cockatrice/src/game_graphics/player/menu/card_menu.cpp b/cockatrice/src/game_graphics/player/menu/card_menu.cpp index aa94c3be7..75ca1b28b 100644 --- a/cockatrice/src/game_graphics/player/menu/card_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/card_menu.cpp @@ -1,6 +1,7 @@ #include "card_menu.h" #include "../../../client/settings/card_counter_settings.h" +#include "../../../client/settings/shortcuts_settings.h" #include "../../../interface/widgets/tabs/tab_game.h" #include "../../board/card_item.h" #include "../../game/player/player_actions.h" diff --git a/cockatrice/src/game_graphics/player/menu/grave_menu.cpp b/cockatrice/src/game_graphics/player/menu/grave_menu.cpp index 698481f7a..6d9364579 100644 --- a/cockatrice/src/game_graphics/player/menu/grave_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/grave_menu.cpp @@ -1,5 +1,6 @@ #include "grave_menu.h" +#include "../../../client/settings/shortcuts_settings.h" #include "../../game/abstract_game.h" #include "../../game/player/player_actions.h" #include "../../game/player/player_logic.h" diff --git a/cockatrice/src/game_graphics/player/menu/move_menu.cpp b/cockatrice/src/game_graphics/player/menu/move_menu.cpp index 5b7209a9f..632e0ddda 100644 --- a/cockatrice/src/game_graphics/player/menu/move_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/move_menu.cpp @@ -1,5 +1,6 @@ #include "move_menu.h" +#include "../../../client/settings/shortcuts_settings.h" #include "../../game/player/player_actions.h" #include "../../game/player/player_logic.h" #include "../card_menu_action_type.h" diff --git a/cockatrice/src/game_graphics/player/menu/player_menu.cpp b/cockatrice/src/game_graphics/player/menu/player_menu.cpp index 17b791222..7c90211df 100644 --- a/cockatrice/src/game_graphics/player/menu/player_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/player_menu.cpp @@ -1,5 +1,6 @@ #include "player_menu.h" +#include "../../../client/settings/shortcuts_settings.h" #include "../../../game_graphics/zones/hand_zone.h" #include "../../../game_graphics/zones/pile_zone.h" #include "../../../game_graphics/zones/table_zone.h" @@ -44,6 +45,8 @@ PlayerMenu::PlayerMenu(PlayerGraphicsItem *_player) : QObject(_player), player(_ utilityMenu = nullptr; } + tallyMenu = addManagedMenu(); + if (player->getLogic()->getPlayerInfo()->getLocal()) { sayMenu = addManagedMenu(player); } else { diff --git a/cockatrice/src/game_graphics/player/menu/player_menu.h b/cockatrice/src/game_graphics/player/menu/player_menu.h index 62ba66df7..a6e89fab0 100644 --- a/cockatrice/src/game_graphics/player/menu/player_menu.h +++ b/cockatrice/src/game_graphics/player/menu/player_menu.h @@ -15,11 +15,13 @@ #include "rfg_menu.h" #include "say_menu.h" #include "sideboard_menu.h" +#include "tally_menu.h" #include "utility_menu.h" #include #include #include +#include class CardItem; class CardMenu; @@ -87,6 +89,7 @@ private: GraveyardMenu *graveMenu; RfgMenu *rfgMenu; UtilityMenu *utilityMenu; + TallyMenu *tallyMenu; SayMenu *sayMenu; CustomZoneMenu *customZonesMenu; diff --git a/cockatrice/src/game_graphics/player/menu/pt_menu.cpp b/cockatrice/src/game_graphics/player/menu/pt_menu.cpp index a01be9424..152461c18 100644 --- a/cockatrice/src/game_graphics/player/menu/pt_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/pt_menu.cpp @@ -1,5 +1,6 @@ #include "pt_menu.h" +#include "../../../client/settings/shortcuts_settings.h" #include "../../game/player/player_actions.h" #include "../../game/player/player_logic.h" #include "../player_graphics_item.h" diff --git a/cockatrice/src/game_graphics/player/menu/say_menu.cpp b/cockatrice/src/game_graphics/player/menu/say_menu.cpp index 336b70f0d..e7b0b3826 100644 --- a/cockatrice/src/game_graphics/player/menu/say_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/say_menu.cpp @@ -5,6 +5,7 @@ #include "../../game/player/player_logic.h" #include "../player_graphics_item.h" +#include SayMenu::SayMenu(PlayerGraphicsItem *_player) : player(_player) { connect(&SettingsCache::instance().messages(), &MessageSettings::messageMacrosChanged, this, &SayMenu::initSayMenu); diff --git a/cockatrice/src/game_graphics/player/menu/sideboard_menu.cpp b/cockatrice/src/game_graphics/player/menu/sideboard_menu.cpp index 0dd7894d2..6753f743e 100644 --- a/cockatrice/src/game_graphics/player/menu/sideboard_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/sideboard_menu.cpp @@ -1,5 +1,6 @@ #include "sideboard_menu.h" +#include "../../../client/settings/shortcuts_settings.h" #include "../../game/player/player_actions.h" #include "../../game/player/player_logic.h" #include "../player_graphics_item.h" diff --git a/cockatrice/src/game_graphics/player/menu/tally_menu.cpp b/cockatrice/src/game_graphics/player/menu/tally_menu.cpp new file mode 100644 index 000000000..08cb6cac9 --- /dev/null +++ b/cockatrice/src/game_graphics/player/menu/tally_menu.cpp @@ -0,0 +1,60 @@ +#include "tally_menu.h" + +#include "../../../client/settings/cache_settings.h" + +#include + +TallyMenu::TallyMenu() +{ + actionGroup = new QActionGroup(this); + actionGroup->setExclusive(true); + + aTallyNone = createTallyAction(TallyType::None); + aTallySubtypes = createTallyAction(TallyType::Subtypes); + aTallyTotalPower = createTallyAction(TallyType::TotalPower); + aTallyTotalToughness = createTallyAction(TallyType::TotalToughness); + + addAction(aTallyNone); + addSeparator(); + addAction(aTallySubtypes); + addAction(aTallyTotalPower); + addAction(aTallyTotalToughness); + + retranslateUi(); +} + +QAction *TallyMenu::createTallyAction(TallyType tallyType) +{ + TallyType currentType = Tally::intToType(SettingsCache::instance().userInterface().getTallyType()); + + QAction *action = new QAction(this); + action->setCheckable(true); + action->setChecked(tallyType == currentType); + + connect(action, &QAction::triggered, &SettingsCache::instance().userInterface(), + [tallyType] { SettingsCache::instance().userInterface().setTallyType(static_cast(tallyType)); }); + + actionGroup->addAction(action); + + return action; +} + +void TallyMenu::setShortcutsActive() +{ + // no-op because we haven't decided if we're adding shortcuts for tally types +} + +void TallyMenu::setShortcutsInactive() +{ + // no-op because we haven't decided if we're adding shortcuts for tally types +} + +void TallyMenu::retranslateUi() +{ + setTitle(tr("Tally")); + + aTallyNone->setText(tr("None")); + aTallySubtypes->setText(tr("Subtypes")); + aTallyTotalPower->setText(tr("Total Power")); + aTallyTotalToughness->setText(tr("Total Toughness")); +} diff --git a/cockatrice/src/game_graphics/player/menu/tally_menu.h b/cockatrice/src/game_graphics/player/menu/tally_menu.h new file mode 100644 index 000000000..11802fd20 --- /dev/null +++ b/cockatrice/src/game_graphics/player/menu/tally_menu.h @@ -0,0 +1,32 @@ +#ifndef COCKATRICE_TALLY_MENU_H +#define COCKATRICE_TALLY_MENU_H + +#include "../../../interface/widgets/menus/tearoff_menu.h" +#include "../../tally/tally.h" +#include "abstract_player_component.h" + +#include + +class TallyMenu : public TearOffMenu, public AbstractPlayerComponent +{ + Q_OBJECT + +public: + TallyMenu(); + + void setShortcutsActive() override; + void setShortcutsInactive() override; + void retranslateUi() override; + +private: + QActionGroup *actionGroup = nullptr; + + QAction *aTallyNone = nullptr; + QAction *aTallySubtypes = nullptr; + QAction *aTallyTotalPower = nullptr; + QAction *aTallyTotalToughness = nullptr; + + QAction *createTallyAction(TallyType tallyType); +}; + +#endif // COCKATRICE_TALLY_MENU_H diff --git a/cockatrice/src/game_graphics/player/menu/utility_menu.cpp b/cockatrice/src/game_graphics/player/menu/utility_menu.cpp index 61a822b21..a7ea38d9a 100644 --- a/cockatrice/src/game_graphics/player/menu/utility_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/utility_menu.cpp @@ -1,5 +1,6 @@ #include "utility_menu.h" +#include "../../../client/settings/shortcuts_settings.h" #include "../../../interface/deck_loader/deck_loader.h" #include "../../game/player/player_actions.h" #include "../../game/player/player_logic.h" diff --git a/cockatrice/src/game_graphics/player/player_dialogs.cpp b/cockatrice/src/game_graphics/player/player_dialogs.cpp index 3c26ae1fe..95c225812 100644 --- a/cockatrice/src/game_graphics/player/player_dialogs.cpp +++ b/cockatrice/src/game_graphics/player/player_dialogs.cpp @@ -8,6 +8,7 @@ #include #include +#include PlayerDialogs::PlayerDialogs(PlayerGraphicsItem *_player, PlayerActions *_playerActions) : QObject(_player), player(_player), playerActions(_playerActions) diff --git a/cockatrice/src/game_graphics/player/player_graphics_item.cpp b/cockatrice/src/game_graphics/player/player_graphics_item.cpp index e0194abda..122ab83be 100644 --- a/cockatrice/src/game_graphics/player/player_graphics_item.cpp +++ b/cockatrice/src/game_graphics/player/player_graphics_item.cpp @@ -1,6 +1,10 @@ #include "player_graphics_item.h" #include "../../game/player/player_actions.h" +#include "../../interface/card_picture_loader/card_picture_loader.h" +#include "../../interface/widgets/cards/art_crop_attribution.h" +#include "../../interface/widgets/cards/card_art_utils.h" +#include "../../interface/widgets/playmat/playmat_utils.h" #include "../../interface/widgets/tabs/tab_game.h" #include "../board/abstract_card_item.h" #include "../board/counter_general.h" @@ -13,12 +17,16 @@ #include "player_dialogs.h" #include +#include +#include +#include +#include PlayerGraphicsItem::PlayerGraphicsItem(PlayerLogic *_player) : player(_player) { - connect(&SettingsCache::instance(), &SettingsCache::horizontalHandChanged, this, + connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::horizontalHandChanged, this, &PlayerGraphicsItem::rearrangeZones); - connect(&SettingsCache::instance(), &SettingsCache::handJustificationChanged, this, + connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::handJustificationChanged, this, &PlayerGraphicsItem::rearrangeZones); connect(player, &PlayerLogic::rearrangeCounters, this, &PlayerGraphicsItem::rearrangeCounters); connect(player, &PlayerLogic::activeChanged, this, &PlayerGraphicsItem::onPlayerActiveChanged); @@ -27,6 +35,10 @@ PlayerGraphicsItem::PlayerGraphicsItem(PlayerLogic *_player) : player(_player) connect(player, &PlayerLogic::counterAdded, this, &PlayerGraphicsItem::onCounterAdded); connect(player, &PlayerLogic::counterRemoved, this, &PlayerGraphicsItem::onCounterRemoved); + connect(player, &PlayerLogic::deckChanged, this, &PlayerGraphicsItem::updatePlaymat); + connect(player, &PlayerLogic::playmatChanged, this, &PlayerGraphicsItem::updatePlaymat); + connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::playmatVisibilityChanged, this, + [this](int) { updatePlaymat(); }); playerMenu = new PlayerMenu(this); @@ -66,6 +78,9 @@ PlayerGraphicsItem::PlayerGraphicsItem(PlayerLogic *_player) : player(_player) connect(tableZoneGraphicsItem, &TableZone::sizeChanged, this, &PlayerGraphicsItem::updateBoundingRect); + connect(this, &PlayerGraphicsItem::playmatChanged, tableZoneGraphicsItem, &TableZone::onPlaymatChanged); + connect(this, &PlayerGraphicsItem::playmatChanged, stackZoneGraphicsItem, &StackZone::onPlaymatChanged); + updateBoundingRect(); rearrangeZones(); @@ -111,7 +126,6 @@ void PlayerGraphicsItem::initializeZones() rfgZoneGraphicsItem->setPos(base + QPointF(0, 2 * h + h2 + 10)); tableZoneGraphicsItem = new TableZone(player->getTableZone(), mirrored, this); - connect(tableZoneGraphicsItem, &TableZone::sizeChanged, this, &PlayerGraphicsItem::updateBoundingRect); connect(this, &PlayerGraphicsItem::mirroredChanged, tableZoneGraphicsItem, &TableZone::setMirrored); stackZoneGraphicsItem = @@ -148,16 +162,67 @@ qreal PlayerGraphicsItem::getMinimumWidth() const { qreal result = tableZoneGraphicsItem->getMinimumWidth() + CardDimensions::HEIGHT_F + 15 + counterAreaWidth + stackZoneGraphicsItem->boundingRect().width(); - if (!SettingsCache::instance().getHorizontalHand()) { + if (!SettingsCache::instance().userInterface().getHorizontalHand()) { result += handZoneGraphicsItem->boundingRect().width(); } return result; } -void PlayerGraphicsItem::paint(QPainter * /*painter*/, - const QStyleOptionGraphicsItem * /*option*/, - QWidget * /*widget*/) +void PlayerGraphicsItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *, QWidget *) { + if (!hasPlaymat || playmatPixmap.isNull()) { + return; + } + + // Calculate the combined bounding rect of stack + table zones + QPointF stackPos = stackZoneGraphicsItem->pos(); + QPointF tablePos = tableZoneGraphicsItem->pos(); + QSizeF stackSize = stackZoneGraphicsItem->boundingRect().size(); + QSizeF tableSize = tableZoneGraphicsItem->boundingRect().size(); + + // Combined area: from stack left edge to table right edge + double combinedLeft = qMin(stackPos.x(), tablePos.x()); + double combinedTop = qMin(stackPos.y(), tablePos.y()); + double combinedRight = qMax(stackPos.x() + stackSize.width(), tablePos.x() + tableSize.width()); + double combinedBottom = qMax(stackPos.y() + stackSize.height(), tablePos.y() + tableSize.height()); + + QRectF combinedArea(combinedLeft, combinedTop, combinedRight - combinedLeft, combinedBottom - combinedTop); + + const QRectF srcRect = PlaymatUtils::computeArtSourceRect(playmatPixmap.size(), playmatParams); + const QRectF dstRect = PlaymatUtils::coverFitRect(combinedArea, srcRect.size()); + + painter->save(); + painter->setClipRect(combinedArea); + painter->setRenderHint(QPainter::SmoothPixmapTransform, true); + + // Render from a down-scaled copy of the art so the full-resolution source + // pixmap is never re-sampled at a tiny device size (also much cheaper than + // scaling it on every frame). + const QPixmap scaledPixmap = scaledPlaymatFor(srcRect, painter->worldTransform().mapRect(dstRect).size()); + painter->drawPixmap(dstRect, scaledPixmap, QRectF(scaledPixmap.rect())); + + painter->restore(); + + if (!playmatAttribution.isEmpty()) { + paintArtAttribution(*painter, combinedArea, playmatAttribution, Qt::AlignRight | Qt::AlignBottom, 0.8); + } +} + +QPixmap PlayerGraphicsItem::scaledPlaymatFor(const QRectF &srcRect, const QSizeF &deviceDstSize) +{ + // Bucket the render size so the source pixmap is re-scaled at most once per + // zoom step instead of once per frame. + constexpr int bucketSize = 32; + const QSize target = QSize(qMax(1, qRound(deviceDstSize.width() / bucketSize) * bucketSize), + qMax(1, qRound(deviceDstSize.height() / bucketSize) * bucketSize)) + .boundedTo(srcRect.toAlignedRect().size()); + + if (scaledPlaymatKey != target) { + const QPixmap crop = playmatPixmap.copy(srcRect.toAlignedRect()); + scaledPlaymatPixmap = crop.scaled(target, Qt::KeepAspectRatio, Qt::SmoothTransformation); + scaledPlaymatKey = target; + } + return scaledPlaymatPixmap; } void PlayerGraphicsItem::processSceneSizeChange(int newPlayerWidth) @@ -165,7 +230,7 @@ void PlayerGraphicsItem::processSceneSizeChange(int newPlayerWidth) // Extend table (and hand, if horizontal) to accommodate the new player width. qreal tableWidth = newPlayerWidth - CardDimensions::HEIGHT_F - 15 - counterAreaWidth - stackZoneGraphicsItem->boundingRect().width(); - if (!SettingsCache::instance().getHorizontalHand()) { + if (!SettingsCache::instance().userInterface().getHorizontalHand()) { tableWidth -= handZoneGraphicsItem->boundingRect().width(); } @@ -187,6 +252,11 @@ void PlayerGraphicsItem::onCounterAdded(CounterState *state) AbstractCounter *widget; if (state->getName() == "life") { widget = playerTarget->addCounter(state); + connect(state, &CounterState::valueChanged, this, [this](int oldValue, int newValue, bool skipDamageAnimation) { + if (newValue < oldValue && !skipDamageAnimation) { + tableZoneGraphicsItem->triggerDamageShimmer(); + } + }); } else { widget = new GeneralCounter(state, player, true, this); } @@ -233,7 +303,7 @@ void PlayerGraphicsItem::rearrangeCounters() void PlayerGraphicsItem::rearrangeZones() { auto base = QPointF(CardDimensions::HEIGHT_F + counterAreaWidth + 15, 0); - if (SettingsCache::instance().getHorizontalHand()) { + if (SettingsCache::instance().userInterface().getHorizontalHand()) { if (mirrored) { if (player->getHandZone()->contentsKnown()) { handVisible = true; @@ -284,7 +354,7 @@ void PlayerGraphicsItem::updateBoundingRect() { prepareGeometryChange(); qreal width = CardDimensions::HEIGHT_F + 15 + counterAreaWidth + stackZoneGraphicsItem->boundingRect().width(); - if (SettingsCache::instance().getHorizontalHand()) { + if (SettingsCache::instance().userInterface().getHorizontalHand()) { qreal handHeight = handVisible ? handZoneGraphicsItem->boundingRect().height() : 0; bRect = QRectF(0, 0, width + tableZoneGraphicsItem->boundingRect().width(), tableZoneGraphicsItem->boundingRect().height() + handHeight); @@ -297,3 +367,100 @@ void PlayerGraphicsItem::updateBoundingRect() emit sizeChanged(); } + +void PlayerGraphicsItem::updatePlaymat() +{ + int visibility = SettingsCache::instance().userInterface().getPlaymatVisibility(); + + // "Don't use playmats" — never show + if (visibility == PlaymatVisibilityNone) { + clearPlaymat(); + return; + } + + // "Show own playmat only" — hide playmats for remote players + if (visibility == PlaymatVisibilityOwnOnly && !player->getPlayerInfo()->getLocal()) { + clearPlaymat(); + return; + } + + CardRef playmatCard; + PlaymatParams params; + + if (player->getHasRemotePlaymat()) { + // Prefer the server-confirmed playmat (updated by Command_SetPlaymat). + playmatCard = player->getRemotePlaymatCard(); + params = player->getRemotePlaymatParams(); + } else if (player->getPlayerInfo()->getLocal()) { + // Local player without a server broadcast yet: apply the full + // settings-based resolution chain (mode, fallback list, behavior). + const auto &settings = SettingsCache::instance().userInterface(); + const PlaymatInfo resolved = resolvePlaymatForDeck( + player->getDeck(), settings.getPlaymatFallbackList(), static_cast(settings.getPlaymatMode()), + static_cast(settings.getPlaymatFallbackBehavior()), 0); + playmatCard = resolved.card; + params = resolved.params; + } else { + // Opponent without a server broadcast: use the deck-embedded playmat. + const DeckList &deck = player->getDeck(); + const PlaymatInfo &deckPlaymat = deck.getPlaymat(); + if (!deckPlaymat.card.isEmpty()) { + playmatCard = deckPlaymat.card; + params = deckPlaymat.params; + } + } + + if (playmatCard.isEmpty()) { + clearPlaymat(); + return; + } + + playmatParams = params; + scaledPlaymatKey = QSize(); // the art crop depends on the params, drop any cached scale + + ExactCard card = CardDatabaseManager::query()->getCard(playmatCard); + if (!card) { + clearPlaymat(); + return; + } + + playmatAttribution = buildArtAttribution(card); + + QPixmap fullRes; + CardPictureLoader::getPixmap(fullRes, card, QSize(745, 1040)); + + if (fullRes.isNull()) { + disconnect(playmatPixmapConnection); + CardInfo *cardInfo = card.getCardPtr().data(); + if (cardInfo) { + playmatPixmapConnection = + connect(cardInfo, &CardInfo::pixmapUpdated, this, &PlayerGraphicsItem::onPlaymatPixmapReady); + } + return; + } + + if (!hasPlaymat) { + hasPlaymat = true; + emit playmatChanged(true); + } + playmatPixmap = CardArtUtils::rotateSidewaysLayoutArt(fullRes, card); + update(); +} + +void PlayerGraphicsItem::clearPlaymat() +{ + disconnect(playmatPixmapConnection); + playmatAttribution.clear(); + if (hasPlaymat) { + hasPlaymat = false; + playmatPixmap = QPixmap(); + scaledPlaymatKey = QSize(); + emit playmatChanged(false); + update(); + } +} + +void PlayerGraphicsItem::onPlaymatPixmapReady() +{ + updatePlaymat(); +} diff --git a/cockatrice/src/game_graphics/player/player_graphics_item.h b/cockatrice/src/game_graphics/player/player_graphics_item.h index d02234ded..e5ae59a61 100644 --- a/cockatrice/src/game_graphics/player/player_graphics_item.h +++ b/cockatrice/src/game_graphics/player/player_graphics_item.h @@ -11,6 +11,7 @@ #include "../game_scene.h" #include +#include class HandZone; class PileZone; @@ -126,6 +127,7 @@ signals: void playerCountChanged(); void mirroredChanged(bool isMirrored); void cardInfoRequested(const CardRef &cardRef); + void playmatChanged(bool hasPlaymat); private: PlayerLogic *player; @@ -146,9 +148,23 @@ private: bool mirrored; bool handVisible = false; + QPixmap playmatPixmap; + QPixmap scaledPlaymatPixmap; // down-scaled copy of playmatPixmap for the current render size + QSize scaledPlaymatKey; // size bucket scaledPlaymatPixmap was rendered for + PlaymatParams playmatParams; + QString playmatAttribution; + bool hasPlaymat = false; + QMetaObject::Connection playmatPixmapConnection; + private slots: void updateBoundingRect(); void rearrangeZones(); + void clearPlaymat(); + void updatePlaymat(); + void onPlaymatPixmapReady(); + +private: + QPixmap scaledPlaymatFor(const QRectF &srcRect, const QSizeF &deviceDstSize); }; #endif // COCKATRICE_PLAYER_GRAPHICS_ITEM_H diff --git a/cockatrice/src/game_graphics/player/player_list_widget.cpp b/cockatrice/src/game_graphics/player/player_list_widget.cpp index 6b1cf6cc6..a7ec2e4a9 100644 --- a/cockatrice/src/game_graphics/player/player_list_widget.cpp +++ b/cockatrice/src/game_graphics/player/player_list_widget.cpp @@ -25,11 +25,7 @@ bool PlayerListItemDelegate::editorEvent(QEvent *event, if ((event->type() == QEvent::MouseButtonPress) && index.isValid()) { auto *const mouseEvent = static_cast(event); if (mouseEvent->button() == Qt::RightButton) { -#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) static_cast(parent())->showContextMenu(mouseEvent->globalPosition().toPoint(), index); -#else - static_cast(parent())->showContextMenu(mouseEvent->globalPos(), index); -#endif return true; } } @@ -57,13 +53,13 @@ PlayerListWidget::PlayerListWidget(TabSupervisor *_tabSupervisor, QWidget *parent) : QTreeWidget(parent), tabSupervisor(_tabSupervisor), client(_client), game(_game), gameStarted(false) { - readyIcon = QPixmap("theme:icons/ready_start"); - notReadyIcon = QPixmap("theme:icons/not_ready_start"); - concededIcon = QPixmap("theme:icons/conceded"); + readyIcon = themePixmap(QStringLiteral("icons/ready_start")); + notReadyIcon = themePixmap(QStringLiteral("icons/not_ready_start")); + concededIcon = themePixmap(QStringLiteral("icons/conceded")); playerIcon = loadColorAdjustedPixmap("theme:icons/player"); judgeIcon = loadColorAdjustedPixmap("theme:icons/scales"); spectatorIcon = loadColorAdjustedPixmap("theme:icons/spectator"); - lockIcon = QPixmap("theme:icons/lock"); + lockIcon = themePixmap(QStringLiteral("icons/lock")); if (tabSupervisor) { itemDelegate = new PlayerListItemDelegate(this); @@ -96,6 +92,11 @@ void PlayerListWidget::retranslateUi() void PlayerListWidget::addPlayer(const ServerInfo_PlayerProperties &player) { + if (players.contains(player.player_id())) { + updatePlayerProperties(player); + return; + } + QTreeWidgetItem *newPlayer = new PlayerListTWI; players.insert(player.player_id(), newPlayer); updatePlayerProperties(player); @@ -180,6 +181,17 @@ void PlayerListWidget::removePlayer(int playerId) delete takeTopLevelItem(indexOfTopLevelItem(player)); } +void PlayerListWidget::clearSpectators() +{ + const QList playerIds = players.keys(); + for (int playerId : playerIds) { + QTreeWidgetItem *player = players.value(playerId, 0); + if (player && !player->data(1, Qt::UserRole).toBool()) { + removePlayer(playerId); + } + } +} + void PlayerListWidget::setActivePlayer(int playerId) { QMapIterator i(players); diff --git a/cockatrice/src/game_graphics/player/player_list_widget.h b/cockatrice/src/game_graphics/player/player_list_widget.h index a53cfa989..f2f0be5fd 100644 --- a/cockatrice/src/game_graphics/player/player_list_widget.h +++ b/cockatrice/src/game_graphics/player/player_list_widget.h @@ -66,6 +66,7 @@ public slots: void addPlayer(const ServerInfo_PlayerProperties &player); void removePlayer(int playerId); void updatePlayerProperties(const ServerInfo_PlayerProperties &prop, int playerId = -1); + void clearSpectators(); }; #endif diff --git a/cockatrice/src/game_graphics/player/player_target.cpp b/cockatrice/src/game_graphics/player/player_target.cpp index 567f3d44d..d6c28370d 100644 --- a/cockatrice/src/game_graphics/player/player_target.cpp +++ b/cockatrice/src/game_graphics/player/player_target.cpp @@ -1,8 +1,11 @@ #include "player_target.h" +#include "../../client/settings/cache_settings.h" #include "../../game/player/player_logic.h" #include "../../interface/pixel_map_generator.h" +#include "../game_scene.h" +#include #include #include #include @@ -21,17 +24,24 @@ QRectF PlayerCounter::boundingRect() const void PlayerCounter::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*option*/, QWidget * /*widget*/) { - const int radius = 8; - const qreal border = 1; - QPainterPath path(QPointF(50 - border / 2, border / 2)); - path.lineTo(radius, border / 2); - path.arcTo(border / 2, border / 2, 2 * radius, 2 * radius, 90, 90); - path.lineTo(border / 2, 30 - border / 2); - path.lineTo(50 - border / 2, 30 - border / 2); - path.closeSubpath(); + const int radius = 15; + const qreal border = 1.5; + // The box is drawn with a border-wide stroke straddling the path, so the + // visible outline spans [inset, inset + border]. Fills that must not cover + // the outline (e.g. the life-change flash) use a path inset by `border`. + const auto makePath = [](qreal inset) { + QPainterPath path(QPointF(50 - inset, inset)); + path.lineTo(radius, inset); + path.arcTo(inset, inset, 2 * radius, 2 * radius, 90, 90); + path.lineTo(inset, 30 - inset); + path.lineTo(50 - inset, 30 - inset); + path.closeSubpath(); + return path; + }; + QPainterPath path = makePath(border / 2); QPen pen(QColor(100, 100, 100)); - pen.setWidth(border); + pen.setWidthF(border); painter->setPen(pen); painter->setBrush(hovered ? QColor(50, 50, 50, 160) : QColor(0, 0, 0, 160)); @@ -45,6 +55,53 @@ void PlayerCounter::paint(QPainter *painter, const QStyleOptionGraphicsItem * /* painter->setFont(font); painter->setPen(Qt::white); painter->drawText(translatedRect, Qt::AlignCenter, QString::number(value)); + + // Life-change flash: emerald on gain, red on loss, decaying over a few ticks. + if (flashAlpha > 0) { + painter->save(); + QColor flashColor = flashDelta > 0 ? QColor(52, 224, 122) : QColor(239, 68, 68); + flashColor.setAlphaF(0.45 * flashAlpha); + painter->setPen(Qt::NoPen); + painter->setBrush(flashColor); + painter->setOpacity(0.85); + painter->drawPath(makePath(border)); + painter->restore(); + } +} + +void PlayerCounter::onValueChanged(int oldValue, int newValue, bool skipDamageAnimation) +{ + flashDelta = newValue - oldValue; + if (flashDelta == 0) { + return; + } + + if (!SettingsCache::instance().userInterface().getLifeCounterAnimationsEnabled()) { + flashAlpha = 0.0; + return; + } + + if (skipDamageAnimation) { + flashAlpha = 0.0; + return; + } + + flashAlpha = 1.0; + flashClock.start(); + if (scene()) { + static_cast(scene())->registerAnimationItem(this); + } +} + +bool PlayerCounter::animationEvent() +{ + flashAlpha = 1.0 - flashClock.elapsed() / flashDurationMs; + if (flashAlpha <= 0.0) { + flashAlpha = 0.0; + return false; + } + update(); + return true; } PlayerTarget::PlayerTarget(PlayerLogic *_owner, QGraphicsItem *parentItem) @@ -80,8 +137,18 @@ void PlayerTarget::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*o QRectF translatedRect = painter->combinedTransform().mapRect(avatarBoundingRect); QSize translatedSize = translatedRect.size().toSize(); QPixmap cachedPixmap; + // The key must cover everything the generated pawn depends on: the rendered + // size, the user level, and the pixmap being drawn. fullPixmap.cacheKey() is + // 0 for every null pixmap, so the default-pawn branch additionally needs the + // pawn's privlevel (lowercased, matching UserLevelPixmapGenerator) and colors + // in the key — otherwise two players without a custom avatar (and the same + // user level) would share one cached pawn. const QString cacheKey = "avatar" + QString::number(translatedSize.width()) + "_" + - QString::number(info->user_level()) + "_" + QString::number(fullPixmap.cacheKey()); + QString::number(translatedSize.height()) + "_" + QString::number(info->user_level()) + + "_" + QString::number(fullPixmap.cacheKey()) + "_" + + QString::fromStdString(info->privlevel()).toLower() + "_" + + QString::fromStdString(info->pawn_colors().left_side()) + "_" + + QString::fromStdString(info->pawn_colors().right_side()); if (!QPixmapCache::find(cacheKey, &cachedPixmap)) { cachedPixmap = QPixmap(translatedSize.width(), translatedSize.height()); diff --git a/cockatrice/src/game_graphics/player/player_target.h b/cockatrice/src/game_graphics/player/player_target.h index 67e155660..1d06c6274 100644 --- a/cockatrice/src/game_graphics/player/player_target.h +++ b/cockatrice/src/game_graphics/player/player_target.h @@ -7,21 +7,34 @@ #ifndef PLAYERTARGET_H #define PLAYERTARGET_H +#include "../animated_item.h" #include "../board/abstract_counter.h" #include "../board/arrow_target.h" #include "../board/graphics_item_type.h" +#include #include class PlayerLogic; -class PlayerCounter : public AbstractCounter +class PlayerCounter : public AbstractCounter, public IAnimatedItem { Q_OBJECT +protected: + void onValueChanged(int oldValue, int newValue, bool skipDamageAnimation) override; + +private: + static constexpr qreal flashDurationMs = 450.0; + + QElapsedTimer flashClock; + qreal flashAlpha = 0.0; + int flashDelta = 0; + public: PlayerCounter(CounterState *state, PlayerLogic *player, QGraphicsItem *parent); QRectF boundingRect() const override; void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override; + bool animationEvent() override; }; class PlayerTarget : public ArrowTarget diff --git a/cockatrice/src/game_graphics/tally/stats_tally.cpp b/cockatrice/src/game_graphics/tally/stats_tally.cpp new file mode 100644 index 000000000..7e05c3fb1 --- /dev/null +++ b/cockatrice/src/game_graphics/tally/stats_tally.cpp @@ -0,0 +1,64 @@ +#include "stats_tally.h" + +#include "../board/card_item.h" + +#include +#include +#include + +static int sumPowers(const QList &cards) +{ + // calculate total power; + int total = 0; + for (auto card : cards) { + QVariantList parsed = CardItem::parsePT(card->getPT()); + if (!parsed.isEmpty()) { + int power = parsed.first().toInt(); // toInt will default to 0 if it's not an int + total += qMax(power, 0); + } + } + return total; +} + +QList StatsTally::computeTotalPower(const QList &cards) +{ + // don't bother if none of the cards have pt + bool hasPT = + std::any_of(cards.cbegin(), cards.cend(), [](const CardItem *card) { return !card->getPT().isEmpty(); }); + if (!hasPT) { + return {}; + } + + int total = sumPowers(cards); + + QString name = QCoreApplication::translate("StatsTally", "Total Power"); + return {TallyRow{name, QString::number(total)}}; +} + +static int sumToughness(const QList &cards) +{ + int total = 0; + for (auto card : cards) { + QVariantList parsed = CardItem::parsePT(card->getPT()); + if (parsed.size() == 2) { + int toughness = parsed.at(1).toInt(); // toInt will default to 0 if it's not an int + total += qMax(toughness, 0); + } + } + return total; +} + +QList StatsTally::computeTotalToughness(const QList &cards) +{ + // don't bother if none of the cards have pt + bool hasPT = + std::any_of(cards.cbegin(), cards.cend(), [](const CardItem *card) { return !card->getPT().isEmpty(); }); + if (!hasPT) { + return {}; + } + + int total = sumToughness(cards); + + QString name = QCoreApplication::translate("StatsTally", "Total Toughness"); + return {TallyRow{name, QString::number(total)}}; +} diff --git a/cockatrice/src/game_graphics/tally/stats_tally.h b/cockatrice/src/game_graphics/tally/stats_tally.h new file mode 100644 index 000000000..e499587eb --- /dev/null +++ b/cockatrice/src/game_graphics/tally/stats_tally.h @@ -0,0 +1,29 @@ +#ifndef COCKATRICE_STATS_TALLY_H +#define COCKATRICE_STATS_TALLY_H +#include "tally.h" + +/** + * @brief Extracts and tallies stats from selected cards. + */ +namespace StatsTally +{ + +/** + * @brief Sums the power of all selected cards + * + * @param cards The list of selected card items to analyze. + * @return A single row containing the total, or an empty list if none of the cards have pt + */ +QList computeTotalPower(const QList &cards); + +/** + * @brief Sums the toughness of all selected cards + * + * @param cards The list of selected card items to analyze. + * @return A single row containing the total, or an empty list if none of the cards have pt + */ +QList computeTotalToughness(const QList &cards); + +} // namespace StatsTally + +#endif // COCKATRICE_STATS_TALLY_H diff --git a/cockatrice/src/game_graphics/tally/subtype_tally.cpp b/cockatrice/src/game_graphics/tally/subtype_tally.cpp new file mode 100644 index 000000000..2241eb3b2 --- /dev/null +++ b/cockatrice/src/game_graphics/tally/subtype_tally.cpp @@ -0,0 +1,77 @@ +#include "subtype_tally.h" + +#include "../board/card_item.h" + +#include +#include + +namespace +{ + +/** @brief Extracts subtypes from a single card face's type line. */ +QStringList extractSubtypesFromFace(const QString &faceType) +{ + // Card type format: "Creature — Goblin Warrior" or "Legendary Enchantment — Saga" + QStringList parts = faceType.split(QStringLiteral(" — ")); + if (parts.size() > 1) { + return parts[1].split(QStringLiteral(" "), Qt::SkipEmptyParts); + } + return {}; +} + +/** @brief A single subtype (e.g., "Goblin", "Warrior") with its occurrence count. */ +struct SubtypeEntry +{ + QString name; + int count; +}; + +} // anonymous namespace + +namespace SubtypeTally +{ + +QList countSubtypes(const QList &cards) +{ + QMap subtypeCounts; + + for (CardItem *card : cards) { + if (card->getFaceDown() || card->getCard().isEmpty()) { + continue; + } + + QString cardType = card->getCardInfo().getCardType(); + // Handle double-faced cards: "Creature — Human // Creature — Werewolf" + QStringList cardFaces = cardType.split(QStringLiteral(" // ")); + + for (const QString &face : cardFaces) { + QStringList subtypes = extractSubtypesFromFace(face); + for (const QString &subtype : subtypes) { + subtypeCounts[subtype]++; + } + } + } + + QList entries; + for (auto it = subtypeCounts.constBegin(); it != subtypeCounts.constEnd(); ++it) { + entries.append({it.key(), it.value()}); + } + + // Sort by count ascending, then alphabetically (lowest counts at bottom of display) + std::sort(entries.begin(), entries.end(), [](const SubtypeEntry &a, const SubtypeEntry &b) { + if (a.count != b.count) { + return a.count < b.count; + } + return a.name < b.name; + }); + + // convert entries into TallyRows + QList rows; + rows.reserve(entries.size()); + std::transform(entries.begin(), entries.end(), std::back_inserter(rows), + [](const SubtypeEntry &e) { return TallyRow{e.name, QString::number(e.count)}; }); + + return rows; +} + +} // namespace SubtypeTally diff --git a/cockatrice/src/game_graphics/tally/subtype_tally.h b/cockatrice/src/game_graphics/tally/subtype_tally.h new file mode 100644 index 000000000..9926528e0 --- /dev/null +++ b/cockatrice/src/game_graphics/tally/subtype_tally.h @@ -0,0 +1,26 @@ +#ifndef COCKATRICE_SUBTYPE_TALLY_H +#define COCKATRICE_SUBTYPE_TALLY_H + +#include "tally.h" + +#include +#include + +class CardItem; + +/** + * @brief Extracts and tallies subtypes from selected cards. + */ +namespace SubtypeTally +{ +/** + * @brief Parses card type lines and counts each subtype occurrence. + * + * Skips face-down cards and cards without type info. + * @param cards The list of selected card items to analyze. + * @return Entries sorted by count ascending, then alphabetically. + */ +QList countSubtypes(const QList &cards); +} // namespace SubtypeTally + +#endif diff --git a/cockatrice/src/game_graphics/tally/tally.cpp b/cockatrice/src/game_graphics/tally/tally.cpp new file mode 100644 index 000000000..21806ee84 --- /dev/null +++ b/cockatrice/src/game_graphics/tally/tally.cpp @@ -0,0 +1,28 @@ +#include "tally.h" + +#include "stats_tally.h" +#include "subtype_tally.h" + +TallyType Tally::intToType(int value) +{ + if (value < static_cast(TallyType::None) || value > static_cast(TallyType::MaxValue)) { + return TallyType::None; + } + + return static_cast(value); +} + +QList Tally::compute(const QList &cards, const TallyType type) +{ + switch (type) { + case TallyType::None: + return {}; + case TallyType::Subtypes: + return SubtypeTally::countSubtypes(cards); + case TallyType::TotalPower: + return StatsTally::computeTotalPower(cards); + case TallyType::TotalToughness: + return StatsTally::computeTotalToughness(cards); + } + return {}; +} diff --git a/cockatrice/src/game_graphics/tally/tally.h b/cockatrice/src/game_graphics/tally/tally.h new file mode 100644 index 000000000..84c54918f --- /dev/null +++ b/cockatrice/src/game_graphics/tally/tally.h @@ -0,0 +1,51 @@ +#ifndef COCKATRICE_TALLY_H +#define COCKATRICE_TALLY_H +#include + +class CardItem; + +/** @brief A single row of the tally output. */ +struct TallyRow +{ + QString name; ///< The row name (displayed on the left) + QString value; ///< Value for the row (displayed on the right) + + bool operator==(const TallyRow &) const = default; +}; + +/** + * The tally type + */ +enum class TallyType +{ + None, + Subtypes, + TotalPower, + TotalToughness, + MaxValue = TotalToughness // sentinel value +}; + +namespace Tally +{ + +/** + * Safely converts an int into the corresponding TallyType. + * + * @param value The int value + * @return The TallyType. Returns TallyType::None if the value is not within range + */ +TallyType intToType(int value); + +/** + * @brief Analyzes the selected cards according to the tally type and builds the resulting tally rows. + * This forwards the cards to the code for that tally type. + * + * @param cards The list of selected card items to analyze. + * @param type The type of tally to do + * @return Rows sorted in top-to-bottom display order + */ +QList compute(const QList &cards, TallyType type); + +} // namespace Tally + +#endif // COCKATRICE_TALLY_H diff --git a/cockatrice/src/game_graphics/zones/hand_zone.cpp b/cockatrice/src/game_graphics/zones/hand_zone.cpp index 5885e3630..1a8f7a910 100644 --- a/cockatrice/src/game_graphics/zones/hand_zone.cpp +++ b/cockatrice/src/game_graphics/zones/hand_zone.cpp @@ -9,6 +9,7 @@ #include #include +#include HandZone::HandZone(HandZoneLogic *_logic, int _zoneHeight, QGraphicsItem *parent) : SelectZone(_logic, parent), zoneHeight(_zoneHeight) @@ -33,14 +34,15 @@ void HandZone::handleDropEvent(const QList &dragItems, QPoint point = dropPoint + scenePos().toPoint(); int x = -1; - if (SettingsCache::instance().getHorizontalHand()) { + if (SettingsCache::instance().userInterface().getHorizontalHand()) { for (x = 0; x < getLogic()->getCards().size(); x++) { if (point.x() < static_cast(getLogic()->getCards().at(x))->scenePos().x()) { break; } } } else { - x = calcDropIndexFromY(dropPoint.y()); + bool sameZone = startZone == getLogic(); + x = calcDropIndexFromY(dropPoint.y(), !sameZone); } Command_MoveCard cmd; @@ -60,7 +62,7 @@ void HandZone::handleDropEvent(const QList &dragItems, QRectF HandZone::boundingRect() const { - if (SettingsCache::instance().getHorizontalHand()) { + if (SettingsCache::instance().userInterface().getHorizontalHand()) { return QRectF(0, 0, width, CardDimensions::HEIGHT_F + 10); } else { return QRectF(0, 0, CardDimensions::WIDTH_F * 1.5, zoneHeight); @@ -77,8 +79,8 @@ void HandZone::reorganizeCards() { if (!getLogic()->getCards().isEmpty()) { const int cardCount = getLogic()->getCards().size(); - if (SettingsCache::instance().getHorizontalHand()) { - bool leftJustified = SettingsCache::instance().getLeftJustified(); + if (SettingsCache::instance().userInterface().getHorizontalHand()) { + bool leftJustified = SettingsCache::instance().userInterface().getLeftJustified(); qreal cardWidth = getLogic()->getCards().at(0)->boundingRect().width(); const int xPadding = leftJustified ? cardWidth * 1.4 : 5; qreal totalWidth = @@ -126,7 +128,7 @@ void HandZone::sortHand(const QList &options) void HandZone::setWidth(qreal _width) { - if (SettingsCache::instance().getHorizontalHand()) { + if (SettingsCache::instance().userInterface().getHorizontalHand()) { prepareGeometryChange(); width = _width; reorganizeCards(); diff --git a/cockatrice/src/game_graphics/zones/pile_zone.cpp b/cockatrice/src/game_graphics/zones/pile_zone.cpp index 7bb0e695a..abdf262f4 100644 --- a/cockatrice/src/game_graphics/zones/pile_zone.cpp +++ b/cockatrice/src/game_graphics/zones/pile_zone.cpp @@ -1,5 +1,6 @@ #include "pile_zone.h" +#include "../../client/settings/cache_settings.h" #include "../../game/player/player_actions.h" #include "../../game/player/player_logic.h" #include "../../game/zones/pile_zone_logic.h" @@ -11,6 +12,7 @@ #include #include #include +#include PileZone::PileZone(PileZoneLogic *_logic, QGraphicsItem *parent) : CardZone(_logic, parent) { @@ -23,12 +25,13 @@ PileZone::PileZone(PileZoneLogic *_logic, QGraphicsItem *parent) : CardZone(_log .rotate(90) .translate(-CardDimensions::WIDTH_HALF_F, -CardDimensions::HEIGHT_HALF_F)); - connect(&SettingsCache::instance(), &SettingsCache::roundCardCornersChanged, this, [this](bool _roundCardCorners) { - Q_UNUSED(_roundCardCorners); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::roundCardCornersChanged, this, + [this](bool _roundCardCorners) { + Q_UNUSED(_roundCardCorners); - prepareGeometryChange(); - update(); - }); + prepareGeometryChange(); + update(); + }); } QRectF PileZone::boundingRect() const @@ -39,7 +42,8 @@ QRectF PileZone::boundingRect() const QPainterPath PileZone::shape() const { QPainterPath shape; - qreal cardCornerRadius = SettingsCache::instance().getRoundCardCorners() ? 0.05 * CardDimensions::WIDTH_F : 0.0; + qreal cardCornerRadius = + SettingsCache::instance().cardsDisplay().getRoundCardCorners() ? 0.05 * CardDimensions::WIDTH_F : 0.0; shape.addRoundedRect(boundingRect(), cardCornerRadius, cardCornerRadius); return shape; } diff --git a/cockatrice/src/game_graphics/zones/select_zone.cpp b/cockatrice/src/game_graphics/zones/select_zone.cpp index f2e720686..470c70fcf 100644 --- a/cockatrice/src/game_graphics/zones/select_zone.cpp +++ b/cockatrice/src/game_graphics/zones/select_zone.cpp @@ -7,10 +7,11 @@ #include #include #include +#include static qreal stackingOffset(qreal cardHeight) { - const qreal overlapPercent = SettingsCache::instance().getStackCardOverlapPercent(); + const qreal overlapPercent = SettingsCache::instance().cardsDisplay().getStackCardOverlapPercent(); return cardHeight * (100.0 - overlapPercent) / 100.0; } @@ -21,40 +22,32 @@ SelectZone::ZoneLayout SelectZone::computeZoneLayout(const StackLayoutParams &pa } qreal effectiveOffset = params.desiredOffset; if (params.cardCount > 1) { - qreal fitOffset; - if (params.totalHeight < params.cardHeight && params.minOffset > 0.0) { - // Zone is shorter than a card (e.g. minimized). Compress offsets so - // every card has at least minOffset pixels of its top visible. - fitOffset = (params.totalHeight - params.minOffset) / (params.cardCount - 1); - effectiveOffset = qMax(0.0, qMin(params.desiredOffset, fitOffset)); + qreal reservedForBottomCard; + if (params.allowBottomOverflow) { + // Allow the bottom card to partially overflow in tight zones, scaling the + // overflow allowance by sqrt(cardCount-1) so offsets decrease smoothly + // as cards are added rather than dropping by 1/(n-1) each time. + // The 0.75 ratio was tuned experimentally to balance card visibility vs. overflow. + constexpr qreal bottomCardZoneRatio = 0.75; + const qreal adjustedRatio = bottomCardZoneRatio / qSqrt(static_cast(params.cardCount - 1)); + reservedForBottomCard = qMin(params.cardHeight, params.totalHeight * adjustedRatio); } else { - qreal reservedForBottomCard; - if (params.allowBottomOverflow) { - // Allow the bottom card to partially overflow in tight zones, scaling the - // overflow allowance by sqrt(cardCount-1) so offsets decrease smoothly - // as cards are added rather than dropping by 1/(n-1) each time. - // The 0.75 ratio was tuned experimentally to balance card visibility vs. overflow. - constexpr qreal bottomCardZoneRatio = 0.75; - const qreal adjustedRatio = bottomCardZoneRatio / qSqrt(static_cast(params.cardCount - 1)); - reservedForBottomCard = qMin(params.cardHeight, params.totalHeight * adjustedRatio); - } else { - // No overflow: reserve full card height for the bottom card - reservedForBottomCard = params.cardHeight; - } - fitOffset = (params.totalHeight - reservedForBottomCard) / (params.cardCount - 1); + // No overflow: reserve full card height for the bottom card + reservedForBottomCard = params.cardHeight; + } + qreal fitOffset = (params.totalHeight - reservedForBottomCard) / (params.cardCount - 1); - if (!params.allowBottomOverflow) { - // Constrain offset so all card tops remain within zone bounds. - // With start=0, last card top at (cardCount-1) * effectiveOffset must be < totalHeight. - qreal maxOffsetForTops = params.totalHeight / (params.cardCount - 1); - fitOffset = qMin(fitOffset, maxOffsetForTops); - } + if (!params.allowBottomOverflow) { + // Constrain offset so all card tops remain within zone bounds. + // With start=0, last card top at (cardCount-1) * effectiveOffset must be < totalHeight. + qreal maxOffsetForTops = params.totalHeight / (params.cardCount - 1); + fitOffset = qMin(fitOffset, maxOffsetForTops); + } - // Apply minOffset only if it fits; otherwise compress further to keep all card tops visible. - effectiveOffset = qMin(params.desiredOffset, fitOffset); - if (fitOffset >= params.minOffset) { - effectiveOffset = qMax(params.minOffset, effectiveOffset); - } + // Apply minOffset only if it fits; otherwise compress further to keep all card tops visible. + effectiveOffset = qMin(params.desiredOffset, fitOffset); + if (fitOffset >= params.minOffset) { + effectiveOffset = qMax(params.minOffset, effectiveOffset); } } qreal stackHeight = (params.cardCount - 1) * effectiveOffset + params.cardHeight; @@ -90,7 +83,7 @@ SelectZone::StackLayoutParams SelectZone::buildStackParams(qreal minOffset) cons return {cardCount, boundingRect().height(), cardHeight, offset, minOffset}; } -int SelectZone::calcDropIndexFromY(qreal dropY, qreal minOffset) const +int SelectZone::calcDropIndexFromY(qreal dropY, bool allowCountExpand, qreal minOffset) const { const auto &cards = getLogic()->getCards(); if (cards.isEmpty()) { @@ -101,7 +94,8 @@ int SelectZone::calcDropIndexFromY(qreal dropY, qreal minOffset) const if (effectiveOffset <= 0.0) { return 0; } - return qBound(0, qRound((dropY - start) / effectiveOffset), params.cardCount - 1); + int max = allowCountExpand ? params.cardCount : params.cardCount - 1; + return qBound(0, qRound((dropY - start) / effectiveOffset), max); } void SelectZone::restoreStaleEscapedCards() diff --git a/cockatrice/src/game_graphics/zones/select_zone.h b/cockatrice/src/game_graphics/zones/select_zone.h index 7408f29b6..b5d3ca37a 100644 --- a/cockatrice/src/game_graphics/zones/select_zone.h +++ b/cockatrice/src/game_graphics/zones/select_zone.h @@ -104,8 +104,12 @@ protected: /** * @brief Computes the card index at a given y-coordinate within the zone's vertical layout. * Returns 0 if the zone has no cards or the offset is zero. + * + * @param dropY The y-coordinate that the card was dropped at + * @param allowCountExpand If false, clamps the index at the number of cards minus 1 + * @param minOffset Minimum offset to preserve */ - int calcDropIndexFromY(qreal dropY, qreal minOffset = 0.0) const; + int calcDropIndexFromY(qreal dropY, bool allowCountExpand, qreal minOffset = 0.0) const; /** * @brief Positions cards vertically with alternating left/right x-offsets. diff --git a/cockatrice/src/game_graphics/zones/stack_zone.cpp b/cockatrice/src/game_graphics/zones/stack_zone.cpp index 46ff099ab..ff62097c7 100644 --- a/cockatrice/src/game_graphics/zones/stack_zone.cpp +++ b/cockatrice/src/game_graphics/zones/stack_zone.cpp @@ -31,8 +31,22 @@ QRectF StackZone::boundingRect() const void StackZone::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*option*/, QWidget * /*widget*/) { - QBrush brush = themeManager->getExtraBgBrush(ThemeManager::Stack, getLogic()->getPlayer()->getZoneId()); - painter->fillRect(boundingRect(), brush); + if (playmatActive) { + // Subtle overlay to distinguish stack zone from table zone (slightly darker) + painter->fillRect(boundingRect(), QColor(0, 0, 0, 80)); + } else { + QBrush brush = themeManager->getExtraBgBrush(ThemeManager::Stack, getLogic()->getPlayer()->getZoneId()); + painter->fillRect(boundingRect(), brush); + } +} + +void StackZone::onPlaymatChanged(bool active) +{ + playmatActive = active; + // See TableZone::onPlaymatChanged for the rationale. Translucent overlay + // over a dynamic playmat should not be held in the device cache. + setCacheMode(active ? QGraphicsItem::NoCache : QGraphicsItem::DeviceCoordinateCache); + update(); } void StackZone::handleDropEvent(const QList &dragItems, @@ -43,12 +57,14 @@ void StackZone::handleDropEvent(const QList &dragItems, return; } - int index = calcDropIndexFromY(dropPoint.y(), MIN_CARD_VISIBLE); - - // Same-zone no-op: don't move a card onto itself - const auto &cards = getLogic()->getCards(); - if (!cards.isEmpty() && startZone == getLogic() && cards.at(index)->getId() == dragItems.at(0)->getId()) { - return; + bool sameZone = startZone == getLogic(); + int index = calcDropIndexFromY(dropPoint.y(), !sameZone, MIN_CARD_VISIBLE); + if (sameZone) { + // Same-zone no-op: don't move a card onto itself + const auto &cards = getLogic()->getCards(); + if (!cards.isEmpty() && cards.at(index)->getId() == dragItems.at(0)->getId()) { + return; + } } Command_MoveCard cmd; diff --git a/cockatrice/src/game_graphics/zones/stack_zone.h b/cockatrice/src/game_graphics/zones/stack_zone.h index 147c3e2fc..96b3f96a6 100644 --- a/cockatrice/src/game_graphics/zones/stack_zone.h +++ b/cockatrice/src/game_graphics/zones/stack_zone.h @@ -15,9 +15,13 @@ class StackZone : public SelectZone Q_OBJECT private: qreal zoneHeight; + bool playmatActive = false; private slots: void updateBg(); +public slots: + void onPlaymatChanged(bool active); + public: StackZone(StackZoneLogic *_logic, int _zoneHeight, QGraphicsItem *parent); /** @brief Resizes the stack zone height, e.g. when sharing vertical space with the command zone. */ diff --git a/cockatrice/src/game_graphics/zones/table_zone.cpp b/cockatrice/src/game_graphics/zones/table_zone.cpp index e886f62e9..88e9abe6c 100644 --- a/cockatrice/src/game_graphics/zones/table_zone.cpp +++ b/cockatrice/src/game_graphics/zones/table_zone.cpp @@ -8,6 +8,7 @@ #include "../board/arrow_item.h" #include "../board/card_drag_item.h" #include "../board/card_item.h" +#include "../game_scene.h" #include "../z_values.h" #include @@ -15,6 +16,7 @@ #include #include #include +#include #include const QColor TableZone::BACKGROUND_COLOR = QColor(100, 100, 100); @@ -28,7 +30,7 @@ TableZone::TableZone(TableZoneLogic *_logic, bool _mirrored, QGraphicsItem *pare connect(_logic, &TableZoneLogic::contentSizeChanged, this, &TableZone::resizeToContents); connect(_logic, &TableZoneLogic::toggleTapped, this, &TableZone::toggleTapped); connect(themeManager, &ThemeManager::themeChanged, this, &TableZone::updateBg); - connect(&SettingsCache::instance(), &SettingsCache::invertVerticalCoordinateChanged, this, + connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::invertVerticalCoordinateChanged, this, &TableZone::reorganizeCards); updateBg(); @@ -46,6 +48,31 @@ void TableZone::updateBg() update(); } +void TableZone::triggerDamageShimmer() +{ + if (!SettingsCache::instance().userInterface().getBattlefieldFlashEnabled()) { + damageShimmerAlpha = 0.0; + return; + } + + damageShimmerAlpha = 1.0; + shimmerClock.start(); + if (scene()) { + static_cast(scene())->registerAnimationItem(this); + } +} + +bool TableZone::animationEvent() +{ + damageShimmerAlpha = 1.0 - shimmerClock.elapsed() / shimmerDurationMs; + if (damageShimmerAlpha <= 0.0) { + damageShimmerAlpha = 0.0; + return false; + } + update(); + return true; +} + QRectF TableZone::boundingRect() const { return QRectF(0, 0, width, height); @@ -59,26 +86,49 @@ void TableZone::setMirrored(bool isMirrored) bool TableZone::isInverted() const { - return ((mirrored && !SettingsCache::instance().getInvertVerticalCoordinate()) || - (!mirrored && SettingsCache::instance().getInvertVerticalCoordinate())); + return ((mirrored && !SettingsCache::instance().userInterface().getInvertVerticalCoordinate()) || + (!mirrored && SettingsCache::instance().userInterface().getInvertVerticalCoordinate())); } void TableZone::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*option*/, QWidget * /*widget*/) { - QBrush brush = themeManager->getExtraBgBrush(ThemeManager::Table, getLogic()->getPlayer()->getZoneId()); - painter->fillRect(boundingRect(), brush); + if (playmatActive) { + // Subtle overlay to distinguish table zone from stack zone + painter->fillRect(boundingRect(), QColor(0, 0, 0, 60)); + } else { + QBrush brush = themeManager->getExtraBgBrush(ThemeManager::Table, getLogic()->getPlayer()->getZoneId()); + painter->fillRect(boundingRect(), brush); + } if (active) { paintZoneOutline(painter); } else { // inactive player gets a darker table zone with a semi transparent black mask - // this means if the user provides a custom background it will fade + // this means if the user provides a custom background or playmat it will fade painter->fillRect(boundingRect(), FADE_MASK); } + // Decaying crimson wash from taking damage. + if (damageShimmerAlpha > 0.0) { + QColor shimmerColor(239, 68, 68); + shimmerColor.setAlphaF(0.22 * damageShimmerAlpha); + painter->fillRect(boundingRect(), shimmerColor); + } + paintLandDivider(painter); } +void TableZone::onPlaymatChanged(bool active) +{ + playmatActive = active; + // While a playmat is shown the zone paints a translucent overlay over the + // dynamic playmat behind it. Keep it out of the device cache so the cached + // pixels are never stale relative to the playmat (and to avoid compositing + // artifacts of cached translucent content on some platforms). + setCacheMode(active ? QGraphicsItem::NoCache : QGraphicsItem::DeviceCoordinateCache); + update(); +} + /** Render a soft outline around the edge of the TableZone. diff --git a/cockatrice/src/game_graphics/zones/table_zone.h b/cockatrice/src/game_graphics/zones/table_zone.h index 0d7e58206..92915a2ed 100644 --- a/cockatrice/src/game_graphics/zones/table_zone.h +++ b/cockatrice/src/game_graphics/zones/table_zone.h @@ -8,16 +8,19 @@ #define TABLEZONE_H #include "../../game/zones/table_zone_logic.h" +#include "../animated_item.h" #include "../board/abstract_card_item.h" #include "select_zone.h" +#include + /** * @brief TableZone is the grid based rect where CardItems may be placed. * * It is the main play zone and can be customized with background images. */ //! \todo Refactor methods to make more readable, extract logic to private methods (especially reorganizeCards()). -class TableZone : public SelectZone +class TableZone : public SelectZone, public IAnimatedItem { Q_OBJECT @@ -83,6 +86,7 @@ private: */ bool active = false; bool mirrored = false; + bool playmatActive = false; [[nodiscard]] bool isInverted() const; @@ -92,6 +96,9 @@ private slots: */ void updateBg(); +public slots: + void onPlaymatChanged(bool active); + public slots: /** Reorganizes CardItems in the TableZone @@ -121,6 +128,16 @@ public: */ void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override; + /** + Flashes the table surface after a player loses life. + + Wired up through the life counter so the battlefield glows when life drops. + */ + void triggerDamageShimmer(); + + /** @brief Decays the damage shimmer by one timer tick. */ + bool animationEvent() override; + /** Toggles the selected items as tapped. */ @@ -171,8 +188,17 @@ public: } void setWidth(qreal _width) { + // The width is stored as an int; truncate to match the previous implicit conversion. + const int newWidth = static_cast(_width); + if (width == newWidth) { + return; + } prepareGeometryChange(); - width = _width; + width = newWidth; + // The parent player item's boundingRect (which clips the playmat painting) is + // derived from this zone's size. Without this signal the playmat is cut off at + // the stale boundingRect edge whenever the scene is resized wider. + emit sizeChanged(); } [[nodiscard]] qreal getWidth() const { @@ -185,6 +211,11 @@ public: } private: + static constexpr qreal shimmerDurationMs = 450.0; + + QElapsedTimer shimmerClock; + qreal damageShimmerAlpha = 0.0; + void paintZoneOutline(QPainter *painter); void paintLandDivider(QPainter *painter); diff --git a/cockatrice/src/game_graphics/zones/view_zone_widget.cpp b/cockatrice/src/game_graphics/zones/view_zone_widget.cpp index 14537a826..c13b79fea 100644 --- a/cockatrice/src/game_graphics/zones/view_zone_widget.cpp +++ b/cockatrice/src/game_graphics/zones/view_zone_widget.cpp @@ -21,6 +21,7 @@ #include #include #include +#include namespace { @@ -61,11 +62,11 @@ ZoneViewWidget::ZoneViewWidget(PlayerLogic *_player, searchEdit.setPlaceholderText(tr("Search by card name (or search expressions)")); searchEdit.setClearButtonEnabled(true); searchEdit.addAction(loadColorAdjustedPixmap("theme:icons/search"), QLineEdit::LeadingPosition); - auto help = searchEdit.addAction(QPixmap("theme:icons/info"), QLineEdit::TrailingPosition); + auto help = searchEdit.addAction(themePixmap(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); connect(help, &QAction::triggered, this, [this] { createSearchSyntaxHelpWindow(&searchEdit); }); - if (SettingsCache::instance().getFocusCardViewSearchBar()) { + if (SettingsCache::instance().userInterface().getFocusCardViewSearchBar()) { this->setActive(true); searchEdit.setFocus(); } @@ -76,9 +77,9 @@ ZoneViewWidget::ZoneViewWidget(PlayerLogic *_player, vbox->addItem(searchEditProxy); // hide search bar if chat autofocus setting is enabled, since typing into it will no longer work anyway - searchEditProxy->setVisible(!SettingsCache::instance().getKeepGameChatFocus()); - connect(&SettingsCache::instance(), &SettingsCache::keepGameChatFocusChanged, searchEditProxy, - [searchEditProxy](bool keepFocus) { searchEditProxy->setVisible(!keepFocus); }); + searchEditProxy->setVisible(!SettingsCache::instance().userInterface().getKeepGameChatFocus()); + connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::keepGameChatFocusChanged, + searchEditProxy, [searchEditProxy](bool keepFocus) { searchEditProxy->setVisible(!keepFocus); }); // top row QGraphicsLinearLayout *hTopRow = new QGraphicsLinearLayout(Qt::Horizontal); @@ -158,9 +159,9 @@ ZoneViewWidget::ZoneViewWidget(PlayerLogic *_player, connect(&sortBySelector, static_cast(&QComboBox::currentIndexChanged), this, &ZoneViewWidget::processSortBy); connect(&pileViewCheckBox, &QCheckBox::QT_STATE_CHANGED, this, &ZoneViewWidget::processSetPileView); - groupBySelector.setCurrentIndex(SettingsCache::instance().getZoneViewGroupByIndex()); - sortBySelector.setCurrentIndex(SettingsCache::instance().getZoneViewSortByIndex()); - pileViewCheckBox.setChecked(SettingsCache::instance().getZoneViewPileView()); + groupBySelector.setCurrentIndex(SettingsCache::instance().userInterface().getZoneViewGroupByIndex()); + sortBySelector.setCurrentIndex(SettingsCache::instance().userInterface().getZoneViewSortByIndex()); + pileViewCheckBox.setChecked(SettingsCache::instance().userInterface().getZoneViewPileView()); if (CardList::NoSort == static_cast(groupBySelector.currentData().toInt())) { pileViewCheckBox.setEnabled(false); @@ -190,7 +191,7 @@ ZoneViewWidget::ZoneViewWidget(PlayerLogic *_player, void ZoneViewWidget::processGroupBy(int index) { auto option = static_cast(groupBySelector.itemData(index).toInt()); - SettingsCache::instance().setZoneViewGroupByIndex(index); + SettingsCache::instance().userInterface().setZoneViewGroupByIndex(index); zone->setGroupBy(option); // disable pile view checkbox if we're not grouping by anything @@ -214,13 +215,13 @@ void ZoneViewWidget::processSortBy(int index) return; } - SettingsCache::instance().setZoneViewSortByIndex(index); + SettingsCache::instance().userInterface().setZoneViewSortByIndex(index); zone->setSortBy(option); } void ZoneViewWidget::processSetPileView(QT_STATE_CHANGED_T value) { - SettingsCache::instance().setZoneViewPileView(value); + SettingsCache::instance().userInterface().setZoneViewPileView(value); zone->setPileView(value); } @@ -477,7 +478,7 @@ static qreal rowsToHeight(int rows) **/ static qreal calcMaxInitialHeight() { - return rowsToHeight(SettingsCache::instance().getCardViewInitialRowsMax()); + return rowsToHeight(SettingsCache::instance().userInterface().getCardViewInitialRowsMax()); } /** @@ -548,7 +549,7 @@ void ZoneViewWidget::initStyleOption(QStyleOption *option) const { QStyleOptionTitleBar *titleBar = qstyleoption_cast(option); if (titleBar) { - titleBar->icon = QPixmap("theme:cockatrice"); + titleBar->icon = themePixmap(QStringLiteral("cockatrice")); } } @@ -559,7 +560,7 @@ void ZoneViewWidget::initStyleOption(QStyleOption *option) const void ZoneViewWidget::expandWindow() { qreal maxInitialHeight = calcMaxInitialHeight(); - qreal maxExpandedHeight = rowsToHeight(SettingsCache::instance().getCardViewExpandedRowsMax()); + qreal maxExpandedHeight = rowsToHeight(SettingsCache::instance().userInterface().getCardViewExpandedRowsMax()); qreal height = rect().height() - extraHeight - 10; qreal maxHeight = maximumHeight() - extraHeight - 10; diff --git a/cockatrice/src/interface/card_localization.h b/cockatrice/src/interface/card_localization.h new file mode 100644 index 000000000..0bfe4a764 --- /dev/null +++ b/cockatrice/src/interface/card_localization.h @@ -0,0 +1,59 @@ +#ifndef COCKATRICE_CARD_LOCALIZATION_H +#define COCKATRICE_CARD_LOCALIZATION_H + +#include "../client/settings/cache_settings.h" + +#include +#include +#include + +namespace CardLocalization +{ +/** + * @brief The language code selected for localized card text and images. + */ +inline QString displayLang() +{ + return SettingsCache::instance().cardsDisplay().getCardLang(); +} + +/** + * @brief Card name in the configured display language, falling back to English. + * @param card The card to display. + * @return The localized name, or an empty string for a null card. + */ +inline QString displayName(const CardInfoPtr &card) +{ + return card.isNull() ? QString() : card->getLocalizedName(displayLang()); +} + +/** + * @brief Card rules text in the configured display language, falling back to English. + * @param card The card to display. + * @return The localized text, or an empty string for a null card. + */ +inline QString displayText(const CardInfoPtr &card) +{ + return card.isNull() ? QString() : card->getLocalizedText(displayLang()); +} + +/** + * @brief Card name in the configured display language, falling back to English. + * @param card The card to display. + */ +inline QString displayName(const CardInfo &card) +{ + return card.getLocalizedName(displayLang()); +} + +/** + * @brief Card rules text in the configured display language, falling back to English. + * @param card The card to display. + */ +inline QString displayText(const CardInfo &card) +{ + return card.getLocalizedText(displayLang()); +} +} // namespace CardLocalization + +#endif // COCKATRICE_CARD_LOCALIZATION_H \ No newline at end of file diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader.cpp b/cockatrice/src/interface/card_picture_loader/card_picture_loader.cpp index bd427a6c8..b8a54761a 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader.cpp +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader.cpp @@ -1,11 +1,14 @@ #include "card_picture_loader.h" #include "../../client/settings/cache_settings.h" +#include "../pixel_map_generator.h" +#include "card_picture_loader_cache_method.h" +#include "card_picture_loader_local_schemes.h" #include #include #include -#include +#include #include #include #include @@ -16,19 +19,29 @@ #include #include #include +#include +#include +#include +#include #include // never cache more than 300 cards at once for a single deck #define CACHED_CARD_PER_DECK_MAX 300 +// wait at least this long before retrying a card whose picture failed to load +static constexpr int RETRY_FAILED_CARDS_SECS = 300; + CardPictureLoader::CardPictureLoader() : QObject(nullptr) { worker = new CardPictureLoaderWorker; - connect(&SettingsCache::instance(), &SettingsCache::picsPathChanged, this, &CardPictureLoader::picsPathChanged); - connect(&SettingsCache::instance(), &SettingsCache::picDownloadChanged, this, + connect(&SettingsCache::instance().paths(), &PathsSettings::picsPathChanged, this, + &CardPictureLoader::picsPathChanged); + connect(&SettingsCache::instance().downloads(), &DownloadSettings::picDownloadChanged, this, &CardPictureLoader::picDownloadChanged); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this, + &CardPictureLoader::cardLangChanged); - qRegisterMetaType(); + qRegisterMetaType("ExactCard"); connect(worker, &CardPictureLoaderWorker::imageLoaded, this, &CardPictureLoader::imageLoaded); statusBar = new CardPictureLoaderStatusBar(nullptr); @@ -53,7 +66,7 @@ void CardPictureLoader::getCardBackPixmap(QPixmap &pixmap, QSize size) QString backCacheKey = "_trice_card_back_" + QString::number(size.width()) + "x" + QString::number(size.height()); if (!QPixmapCache::find(backCacheKey, &pixmap)) { qCDebug(CardPictureLoaderLog) << "PictureLoader: cache miss for" << backCacheKey; - QPixmap tmpPixmap("theme:cardback"); + QPixmap tmpPixmap = themePixmap(QStringLiteral("cardback")); if (tmpPixmap.isNull()) { qCWarning(CardPictureLoaderLog) << "Failed to load 'theme:cardback'! Using fallback pixmap."; @@ -74,7 +87,7 @@ void CardPictureLoader::getCardBackLoadingInProgressPixmap(QPixmap &pixmap, QSiz "_trice_card_back_inprogress_" + QString::number(size.width()) + "x" + QString::number(size.height()); if (!QPixmapCache::find(backCacheKey, &pixmap)) { qCDebug(CardPictureLoaderCardBackCacheFailLog) << "PictureLoader: cache miss for" << backCacheKey; - QPixmap tmpPixmap("theme:cardback"); + QPixmap tmpPixmap = themePixmap(QStringLiteral("cardback")); if (tmpPixmap.isNull()) { qCWarning(CardPictureLoaderLog) << "Failed to load 'theme:cardback' for in-progress state! Using fallback."; @@ -96,7 +109,7 @@ void CardPictureLoader::getCardBackLoadingFailedPixmap(QPixmap &pixmap, QSize si "_trice_card_back_failed_" + QString::number(size.width()) + "x" + QString::number(size.height()); if (!QPixmapCache::find(backCacheKey, &pixmap)) { qCDebug(CardPictureLoaderCardBackCacheFailLog) << "PictureLoader: cache miss for" << backCacheKey; - QPixmap tmpPixmap("theme:cardback"); + QPixmap tmpPixmap = themePixmap(QStringLiteral("cardback")); if (tmpPixmap.isNull()) { qCWarning(CardPictureLoaderLog) << "Failed to load 'theme:cardback' for failed state! Using fallback."; @@ -129,7 +142,15 @@ void CardPictureLoader::getPixmap(QPixmap &pixmap, const ExactCard &card, QSize QPixmap bigPixmap; if (QPixmapCache::find(key, &bigPixmap)) { if (bigPixmap.isNull()) { - qCDebug(CardPictureLoaderLog) << "Cached pixmap for key" << key << "is NULL!"; + // Leave the pixmap null so callers fall back to a solid color + // instead of showing the card back. + QDateTime failedAtTime = getInstance().failedAt.value(key); + if (!failedAtTime.isValid() || + failedAtTime.addSecs(RETRY_FAILED_CARDS_SECS) < QDateTime::currentDateTime()) { + getInstance().failedAt.remove(key); + QPixmapCache::remove(key); + getInstance().worker->enqueueImageLoad(card); + } return; } @@ -153,8 +174,10 @@ void CardPictureLoader::imageLoaded(const ExactCard &card, const QImage &image) QPixmap finalPixmap; if (image.isNull()) { + getInstance().failedAt.insert(card.getPixmapCacheKey(), QDateTime::currentDateTime()); qCDebug(CardPictureLoaderLog) << "Caching NULL pixmap for" << card.getName(); } else { + getInstance().failedAt.remove(card.getPixmapCacheKey()); if (card.getInfo().getUiAttributes().upsideDownArt) { #if (QT_VERSION >= QT_VERSION_CHECK(6, 9, 0)) QImage mirrorImage = image.flipped(Qt::Horizontal | Qt::Vertical); @@ -169,7 +192,8 @@ void CardPictureLoader::imageLoaded(const ExactCard &card, const QImage &image) QPixmapCache::insert(card.getPixmapCacheKey(), finalPixmap); - if (SettingsCache::instance().getCardPictureLoaderCacheMethod() == + if (static_cast( + SettingsCache::instance().cacheStorage().getCardPictureLoaderCacheMethod()) == CardPictureLoaderCacheMethod::CacheMethod::FILESYSTEM_CACHE) { saveCardImageToLocalStorage(card, finalPixmap); } @@ -177,21 +201,65 @@ void CardPictureLoader::imageLoaded(const ExactCard &card, const QImage &image) // imageLoaded should only be reached if the exactCard isn't already in cache. // (plus there's a deduplication mechanism in CardPictureLoaderWorker) // It should be safe to connect the CardInfo here without worrying about redundant connections. - connect(card.getCardPtr().data(), &QObject::destroyed, this, - [cacheKey = card.getPixmapCacheKey()] { QPixmapCache::remove(cacheKey); }); + connect(card.getCardPtr().data(), &QObject::destroyed, this, [cacheKey = card.getPixmapCacheKey()] { + QPixmapCache::remove(cacheKey); + getInstance().failedAt.remove(cacheKey); + }); card.emitPixmapUpdated(); } -void CardPictureLoader::saveCardImageToLocalStorage(const ExactCard &card, const QPixmap &pixmap) +void CardPictureLoader::deleteAllLocalOverrides(const ExactCard &card) +{ + const QString picsRoot = SettingsCache::instance().paths().getPicsPath(); + if (picsRoot.isEmpty() || !card) { + return; + } + + QDir baseDir(picsRoot); + if (!baseDir.cd("downloadedPics")) { + return; + } + + const QString name = card.getInfo().getCorrectedName(); + + QString set, collector, uuid; + auto printing = card.getPrinting(); + if (printing.getSet()) { + set = printing.getSet()->getCorrectedShortName(); + collector = printing.getProperty("num"); + uuid = printing.getUuid(); + } + + for (const auto &scheme : CardPictureLoaderLocalSchemes::exportSchemes()) { + QString rel = CardPictureLoaderLocalSchemes::expandPattern(scheme.pattern, name, set, collector, uuid); + + if (rel.isEmpty()) { + continue; + } + + rel += ".png"; + rel = QDir::cleanPath(rel); + + QString fullPath = baseDir.filePath(rel); + + if (QFile::exists(fullPath)) { + QFile::remove(fullPath); + } + } +} + +void CardPictureLoader::saveCardImageToLocalStorage(const ExactCard &card, + const QPixmap &pixmap, + const bool allowOverwrite) { if (pixmap.isNull() || !card) { return; } - const QString picsRoot = SettingsCache::instance().getPicsPath(); - CardPictureLoaderLocalSchemes::NamingScheme scheme = - SettingsCache::instance().getLocalCardImageStorageNamingScheme(); + const QString picsRoot = SettingsCache::instance().paths().getPicsPath(); + CardPictureLoaderLocalSchemes::NamingScheme scheme = static_cast( + SettingsCache::instance().cacheStorage().getLocalCardImageStorageNamingScheme()); QString pattern; @@ -243,8 +311,9 @@ void CardPictureLoader::saveCardImageToLocalStorage(const ExactCard &card, const QFileInfo outInfo(baseDir.filePath(relativePath)); - // Do not overwrite existing files - if (outInfo.exists()) { + // Automatic cache writes (FILESYSTEM_CACHE) must never clobber an explicit user override. + // Only the explicit override paths pass allowOverwrite == true. + if (!allowOverwrite && outInfo.exists()) { return; } @@ -265,6 +334,122 @@ void CardPictureLoader::saveCardImageToLocalStorage(const ExactCard &card, const } } +void CardPictureLoader::installPrintingOverrideOnLoad(const ExactCard &originalCard, const ExactCard &overrideCard) +{ + // Overriding a card with itself is the reset case, not a real override: every code path below + // would re-enter itself through emitPixmapUpdated(). Reject it outright. + if (originalCard == overrideCard) { + return; + } + + CardInfoPtr cardPtr = overrideCard.getCardPtr(); + if (!cardPtr) { + return; + } + + // Heap-allocate so the lambda can capture it before the connection is made + auto *connectionHandle = new QMetaObject::Connection; + + *connectionHandle = + connect(cardPtr.data(), &CardInfo::pixmapUpdated, cardPtr.data(), + [originalCard, overrideCard, connectionHandle, this](const PrintingInfo &printing) { + // All printings share the same CardInfo, so ignore updates triggered by any + // other printing (e.g., the original card re-loading from disk). + if (printing != overrideCard.getPrinting()) { + return; + } + + QPixmap pixmap; + if (QPixmapCache::find(overrideCard.getPixmapCacheKey(), &pixmap) && !pixmap.isNull()) { + // The override art has resolved — persist it and reflect it immediately. + // Retire the connection before emitting so the refresh can't re-enter. + saveCardImageToLocalStorage(originalCard, pixmap, /*allowOverwrite=*/true); + + QObject::disconnect(*connectionHandle); + delete connectionHandle; + + QPixmapCache::clear(); + originalCard.emitPixmapUpdated(); + return; + } + + // The art could not be resolved. Keep the connection armed so a late resolution + // still lands, and surface a visible refusal instead of a silent no-op. An + // override already on disk is left untouched and simply re-displayed. + QPixmapCache::clear(); + if (!hasLocalOverrides(originalCard)) { + QPixmap refusedPixmap; + getCardBackLoadingFailedPixmap(refusedPixmap, QSize(480, 672)); + QPixmapCache::insert(originalCard.getPixmapCacheKey(), refusedPixmap); + } + originalCard.emitPixmapUpdated(); + }); + + // Now enqueue; if the image is already loading (deduplicated in the worker), + // the signal will still fire when it completes + CardPictureLoader::getInstance().worker->enqueueImageLoad(overrideCard); +} + +void CardPictureLoader::installPrintingOverride(const ExactCard &originalCard, const ExactCard &overrideCard) +{ + // Same guard as installPrintingOverrideOnLoad: self-override is the reset case. + if (originalCard == overrideCard) { + return; + } + + QPixmap pixmap; + const QString key = overrideCard.getPixmapCacheKey(); + + if (QPixmapCache::find(key, &pixmap) && !pixmap.isNull()) { + // Already cached — save immediately; the caller refreshes the card. + saveCardImageToLocalStorage(originalCard, pixmap, /*allowOverwrite=*/true); + return; + } + + // Cache miss or previously failed load — enqueue load and wait for the signal. + installPrintingOverrideOnLoad(originalCard, overrideCard); +} + +bool CardPictureLoader::hasLocalOverrides(const ExactCard &card) +{ + const QString picsRoot = SettingsCache::instance().paths().getPicsPath(); + if (picsRoot.isEmpty() || !card) { + return false; + } + + QDir baseDir(picsRoot); + if (!baseDir.cd("downloadedPics")) { + return false; + } + + const QString name = card.getInfo().getCorrectedName(); + + QString set, collector, uuid; + const PrintingInfo printing = card.getPrinting(); + if (printing.getSet()) { + set = printing.getSet()->getCorrectedShortName(); + collector = printing.getProperty("num"); + uuid = printing.getUuid(); + } + + for (const auto &scheme : CardPictureLoaderLocalSchemes::exportSchemes()) { + QString rel = CardPictureLoaderLocalSchemes::expandPattern(scheme.pattern, name, set, collector, uuid); + + if (rel.isEmpty()) { + continue; + } + + rel += ".png"; + rel = QDir::cleanPath(rel); + + if (QFile::exists(baseDir.filePath(rel))) { + return true; + } + } + + return false; +} + void CardPictureLoader::clearPixmapCache() { QPixmapCache::clear(); @@ -304,31 +489,11 @@ void CardPictureLoader::picsPathChanged() QPixmapCache::clear(); } -bool CardPictureLoader::hasCustomArt() +void CardPictureLoader::cardLangChanged() { - auto picsPath = SettingsCache::instance().getPicsPath(); - QDirIterator it(picsPath, QDir::Dirs | QDir::NoDotAndDotDot); - - // Check if there is at least one non-directory file in the pics path, other - // than in the "downloadedPics" subdirectory. - while (it.hasNext()) { -#if (QT_VERSION >= QT_VERSION_CHECK(6, 3, 0)) - QFileInfo dir(it.nextFileInfo()); -#else - // nextFileInfo() is only available in Qt 6.3+, for previous versions, we build - // the QFileInfo from a QString which requires more system calls. - QFileInfo dir(it.next()); -#endif - - if (it.fileName() == "downloadedPics") { - continue; - } - - QDirIterator subIt(it.filePath(), QDir::Files, QDirIterator::Subdirectories | QDirIterator::FollowSymlinks); - if (subIt.hasNext()) { - return true; - } - } - - return false; + // Localized images are fetched via a different URL, but the in-memory + // pixmap cache is keyed by card name/uuid, so drop everything cached + // (including failure timestamps) to force a reload in the new language. + QPixmapCache::clear(); + failedAt.clear(); } diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader.h b/cockatrice/src/interface/card_picture_loader/card_picture_loader.h index 0c114ae92..0a4934e6d 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader.h +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader.h @@ -4,6 +4,8 @@ #include "card_picture_loader_status_bar.h" #include "card_picture_loader_worker.h" +#include +#include #include inline Q_LOGGING_CATEGORY(CardPictureLoaderLog, "card_picture_loader"); @@ -56,6 +58,7 @@ private: CardPictureLoaderWorker *worker; ///< Worker thread for async image loading CardPictureLoaderStatusBar *statusBar; ///< Status bar widget showing load progress + QHash failedAt; ///< Timestamp of the last failed load attempt per pixmap cache key public: /** @@ -94,10 +97,17 @@ public: static void cacheCardPixmaps(const QList &cards); /** - * @brief Check if the user has custom card art in the picsPath directory. - * @return True if any custom art exists. + * @brief Check if a local override image already exists for the card. + * @param card The card to check. + * @return True if the card has at least one locally stored override image. */ - static bool hasCustomArt(); + static bool hasLocalOverrides(const ExactCard &card); + + /** + * @brief Removes all locally stored override images for the card. + * @param card The card to remove the override images of. + */ + static void deleteAllLocalOverrides(const ExactCard &card); /** * @brief Clears the in-memory QPixmap cache for all cards. @@ -117,7 +127,9 @@ public slots: * @param image Loaded QImage. */ void imageLoaded(const ExactCard &card, const QImage &image); - void saveCardImageToLocalStorage(const ExactCard &card, const QPixmap &pixmap); + void saveCardImageToLocalStorage(const ExactCard &card, const QPixmap &pixmap, bool allowOverwrite = false); + void installPrintingOverride(const ExactCard &originalCard, const ExactCard &overrideCard); + void installPrintingOverrideOnLoad(const ExactCard &originalCard, const ExactCard &overrideCard); private slots: /** @@ -131,6 +143,12 @@ private slots: * Clears the QPixmap cache to reload images. */ void picsPathChanged(); + + /** + * @brief Triggered when the card language setting changes. + * Clears the in-memory picture caches so images reload in the new language. + */ + void cardLangChanged(); }; #endif diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp b/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp index bb10d1c42..c82fca403 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp @@ -7,15 +7,16 @@ #include #include #include +#include static constexpr int REFRESH_INTERVAL_MS = 10 * 1000; CardPictureLoaderLocal::CardPictureLoaderLocal(QObject *parent) - : QObject(parent), picsPath(SettingsCache::instance().getPicsPath()), - customPicsPath(SettingsCache::instance().getCustomPicsPath()) + : QObject(parent), picsPath(SettingsCache::instance().paths().getPicsPath()), + customPicsPath(SettingsCache::instance().paths().getCustomPicsPath()) { // Hook up signals to settings - connect(&SettingsCache::instance(), &SettingsCache::picsPathChanged, this, + connect(&SettingsCache::instance().paths(), &PathsSettings::picsPathChanged, this, &CardPictureLoaderLocal::picsPathChanged); refreshIndex(); @@ -93,6 +94,10 @@ QImage CardPictureLoaderLocal::tryLoadCardImageFromDisk(const QString &setName, candidatePaths << picsPath + "/downloadedPics/" + setName + "/" + nameVariant; } + // Non-set-folder export schemes (e.g., Name_Set_Collector) write straight into + // downloadedPics/; check there as a fallback so local overrides round-trip. + candidatePaths << picsPath + "/downloadedPics/" + nameVariant; + for (const QString &path : candidatePaths) { QFileInfo fileInfo(path); QDir dir = fileInfo.dir(); @@ -104,7 +109,8 @@ QImage CardPictureLoaderLocal::tryLoadCardImageFromDisk(const QString &setName, QStringList files = dir.entryList(QDir::Files); for (const QString &file : files) { - if (!file.startsWith(baseName)) { + QFileInfo fi(file); + if (fi.completeBaseName() != baseName) { continue; } @@ -127,6 +133,6 @@ QImage CardPictureLoaderLocal::tryLoadCardImageFromDisk(const QString &setName, void CardPictureLoaderLocal::picsPathChanged() { - picsPath = SettingsCache::instance().getPicsPath(); - customPicsPath = SettingsCache::instance().getCustomPicsPath(); + picsPath = SettingsCache::instance().paths().getPicsPath(); + customPicsPath = SettingsCache::instance().paths().getCustomPicsPath(); } diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp index 2f51ba986..34092f361 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp @@ -1,6 +1,7 @@ #include "card_picture_loader_worker.h" #include "../../client/settings/cache_settings.h" +#include "card_picture_loader_cache_method.h" #include "card_picture_loader_local.h" #include "card_picture_loader_worker_work.h" @@ -9,13 +10,19 @@ #include #include #include +#include +#include +#include #include #include static constexpr int MAX_REQUESTS_PER_SEC = 10; +static constexpr int MIN_HOST_QUOTA = 1; ///< Floor for the per-host request allowance +static constexpr qint64 QUOTA_RECOVER_MS = 60000; ///< Idle time before a reduced quota starts recovering CardPictureLoaderWorker::CardPictureLoaderWorker() - : QObject(nullptr), picDownload(SettingsCache::instance().getPicDownload()), requestQuota(MAX_REQUESTS_PER_SEC) + : QObject(nullptr), picDownload(SettingsCache::instance().downloads().getPicDownload()), + requestQuota(MAX_REQUESTS_PER_SEC) { networkManager = new QNetworkAccessManager(this); // We need a timeout to ensure requests don't hang indefinitely in case of @@ -25,13 +32,14 @@ CardPictureLoaderWorker::CardPictureLoaderWorker() cache = new QNetworkDiskCache(this); cache->setCacheDirectory(SettingsCache::instance().getNetworkCachePath()); cache->setMaximumCacheSize(1024L * 1024L * - static_cast(SettingsCache::instance().getNetworkCacheSizeInMB())); + static_cast(SettingsCache::instance().cacheStorage().getNetworkCacheSizeInMB())); - connect(&SettingsCache::instance(), &SettingsCache::networkCacheSizeChanged, cache, [this](int newSizeInMB) { - if (cache) { - cache->setMaximumCacheSize(1024L * 1024L * static_cast(newSizeInMB)); - } - }); + connect(&SettingsCache::instance().cacheStorage(), &CacheStorageSettings::networkCacheSizeChanged, cache, + [this](int newSizeInMB) { + if (cache) { + cache->setMaximumCacheSize(1024L * 1024L * static_cast(newSizeInMB)); + } + }); networkManager->setCache(cache); @@ -39,7 +47,7 @@ CardPictureLoaderWorker::CardPictureLoaderWorker() // We can't use NoLessSafeRedirectPolicy because it is not applied with AlwaysCache networkManager->setRedirectPolicy(QNetworkRequest::ManualRedirectPolicy); - cacheFilePath = SettingsCache::instance().getRedirectCachePath() + REDIRECT_CACHE_FILENAME; + cacheFilePath = SettingsCache::instance().paths().getRedirectCachePath() + REDIRECT_CACHE_FILENAME; loadRedirectCache(); cleanStaleEntries(); @@ -72,11 +80,12 @@ void CardPictureLoaderWorker::queueRequest(const QUrl &url, CardPictureLoaderWor queueRequest(cachedRedirect, worker); return; } - if (SettingsCache::instance().getCardPictureLoaderCacheMethod() == + if (static_cast( + SettingsCache::instance().cacheStorage().getCardPictureLoaderCacheMethod()) == CardPictureLoaderCacheMethod::CacheMethod::NETWORK_CACHE && cache->metaData(url).isValid()) { - // If we hit a cached url, we get to make the request for free, since it won't contribute towards the - // rate-limit + // A request that will be served from the disk cache never touches the network and therefore + // doesn't use up any of the rate limit, so it gets to skip the queue. makeRequest(url, worker); return; } @@ -98,8 +107,13 @@ QNetworkReply *CardPictureLoaderWorker::makeRequest(const QUrl &url, CardPicture req.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING)); req.setRawHeader("Accept", "image/avif,image/webp,image/apng,image/,/*;q=0.8"); - bool useNetworkCache = !picDownload && SettingsCache::instance().getCardPictureLoaderCacheMethod() == - CardPictureLoaderCacheMethod::CacheMethod::NETWORK_CACHE; + // Cached entries are served straight from the disk cache even when picture downloads are + // enabled: re-fetching an already-cached image would burn the rate limit for nothing. Only a + // genuine cache miss goes to the network, and only when downloads are enabled. + bool useNetworkCache = static_cast( + SettingsCache::instance().cacheStorage().getCardPictureLoaderCacheMethod()) == + CardPictureLoaderCacheMethod::CacheMethod::NETWORK_CACHE && + (cache->metaData(url).isValid() || !picDownload); req.setAttribute(QNetworkRequest::CacheLoadControlAttribute, useNetworkCache ? QNetworkRequest::AlwaysCache : QNetworkRequest::AlwaysNetwork); @@ -115,6 +129,19 @@ QNetworkReply *CardPictureLoaderWorker::makeRequest(const QUrl &url, CardPicture void CardPictureLoaderWorker::resetRequestQuota() { requestQuota = MAX_REQUESTS_PER_SEC; + + QDateTime now = QDateTime::currentDateTime(); + for (auto it = hostRequestQuota.begin(); it != hostRequestQuota.end(); ++it) { + if (!hostLast429.contains(it.key()) || now.msecsTo(hostLast429.value(it.key())) < -QUOTA_RECOVER_MS) { + it.value() = qMin(MAX_REQUESTS_PER_SEC, it.value() + 1); + } + } + + for (const auto &request : requestLoadQueue) { + const QString host = request.first.host(); + hostQuotaRemaining.insert(host, hostRequestQuota.value(host, MAX_REQUESTS_PER_SEC)); + } + processQueuedRequests(); } @@ -127,14 +154,26 @@ void CardPictureLoaderWorker::processQueuedRequests() bool CardPictureLoaderWorker::processSingleRequest() { - if (!requestLoadQueue.isEmpty()) { - auto request = requestLoadQueue.takeFirst(); - makeRequest(request.first, request.second); - return true; + for (int i = 0; i < requestLoadQueue.size(); ++i) { + const auto &request = requestLoadQueue.at(i); + QString host = request.first.host(); + int allowance = hostQuotaRemaining.value(host, MAX_REQUESTS_PER_SEC); + if (allowance > 0) { + hostQuotaRemaining.insert(host, allowance - 1); + makeRequest(request.first, request.second); + requestLoadQueue.removeAt(i); + return true; + } } return false; } +void CardPictureLoaderWorker::onHostRateLimited(const QString &host) +{ + hostRequestQuota.insert(host, qMax(MIN_HOST_QUOTA, hostRequestQuota.value(host, MAX_REQUESTS_PER_SEC) / 2)); + hostLast429.insert(host, QDateTime::currentDateTime()); +} + void CardPictureLoaderWorker::enqueueImageLoad(const ExactCard &card) { // Send call through a connection to ensure the handling is run on the pictureLoader thread @@ -229,7 +268,7 @@ void CardPictureLoaderWorker::cleanStaleEntries() auto it = redirectCache.begin(); while (it != redirectCache.end()) { - if (it.value().second.addDays(SettingsCache::instance().getRedirectCacheTtl()) < now) { + if (it.value().second.addDays(SettingsCache::instance().cacheStorage().getRedirectCacheTtl()) < now) { it = redirectCache.erase(it); // Remove stale entry } else { ++it; diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.h b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.h index f927abde6..d1c519b7a 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.h +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.h @@ -5,6 +5,8 @@ #include "card_picture_loader_worker_work.h" #include "card_picture_to_load.h" +#include +#include #include #include #include @@ -66,6 +68,12 @@ public: */ void queueRequest(const QUrl &url, CardPictureLoaderWorkerWork *worker); + /** + * @brief Handles a server returning HTTP 429 by reducing that host's request quota. + * @param host The host that returned 429 + */ + void onHostRateLimited(const QString &host); + /** @brief Clears the network cache and redirect cache. */ void clearNetworkCache(); @@ -110,8 +118,11 @@ private: bool picDownload; ///< Whether downloading images from network is enabled QQueue> requestLoadQueue; ///< Queue of pending network requests - int requestQuota; ///< Remaining requests allowed per second - QTimer requestTimer; ///< Timer to reset the request quota + int requestQuota; ///< Remaining requests allowed per second + QTimer requestTimer; ///< Timer to reset the request quota + QHash hostRequestQuota; ///< Sustained per-host request allowance + QHash hostQuotaRemaining; ///< Per-host allowance left in the current second + QHash hostLast429; ///< When each host was last rate limited CardPictureLoaderLocal *localLoader; ///< Loader for local images QSet currentlyLoading; ///< Deduplication: contains pixmapCacheKey currently being loaded diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.cpp b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.cpp index 6bd57c3d5..66c56337c 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.cpp +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.cpp @@ -8,8 +8,13 @@ #include #include #include +#include #include #include +#include + +ServerRateLimiter CardPictureLoaderWorkerWork::s_rateLimiter; +#include // Card back returned by gatherer when card is not found static const QStringList MD5_BLACKLIST = { @@ -19,7 +24,7 @@ static const QStringList MD5_BLACKLIST = { CardPictureLoaderWorkerWork::CardPictureLoaderWorkerWork(const CardPictureLoaderWorker *worker, const ExactCard &toLoad) : QObject(nullptr), cardToDownload(CardPictureToLoad(toLoad)), - picDownload(SettingsCache::instance().getPicDownload()) + picDownload(SettingsCache::instance().downloads().getPicDownload()) { // Hook up signals to the orchestrator connect(this, &CardPictureLoaderWorkerWork::requestImageDownload, worker, &CardPictureLoaderWorker::queueRequest); @@ -29,19 +34,48 @@ CardPictureLoaderWorkerWork::CardPictureLoaderWorkerWork(const CardPictureLoader connect(this, &CardPictureLoaderWorkerWork::imageLoaded, worker, &CardPictureLoaderWorker::handleImageLoaded); connect(this, &CardPictureLoaderWorkerWork::requestSucceeded, worker, &CardPictureLoaderWorker::imageRequestSucceeded); + connect(this, &CardPictureLoaderWorkerWork::rateLimited, worker, &CardPictureLoaderWorker::onHostRateLimited); // Hook up signals to settings - connect(&SettingsCache::instance(), SIGNAL(picDownloadChanged()), this, SLOT(picDownloadChanged())); + connect(&SettingsCache::instance().downloads(), SIGNAL(picDownloadChanged()), this, SLOT(picDownloadChanged())); startNextPicDownload(); } void CardPictureLoaderWorkerWork::startNextPicDownload() { + QDateTime now = QDateTime::currentDateTime(); + while (!cardToDownload.getCurrentUrl().isEmpty() && + s_rateLimiter.isRateLimited(QUrl(cardToDownload.getCurrentUrl()).host(), now)) { + QString host = QUrl(cardToDownload.getCurrentUrl()).host(); + if (s_rateLimiter.rounds(host) == 1) { + // First 429 round for this server: wait out the backoff and give it + // one more chance instead of immediately falling through to a worse + // source. A second 429 makes us fall through instead. + qCDebug(CardPictureLoaderWorkerWorkLog).nospace() + << "PictureLoader: [card: " << cardToDownload.getCard().getInfo().getCorrectedName() + << " set: " << cardToDownload.getSetName() << "]: Waiting out backoff for " << host << " to retry " + << cardToDownload.getCurrentUrl(); + scheduleDeferredRetry(); + return; + } + + // The server has already 429'd us at least twice, so further retries are + // unlikely to succeed: move on to the other configured sources. + qCDebug(CardPictureLoaderWorkerWorkLog).nospace() + << "PictureLoader: [card: " << cardToDownload.getCard().getInfo().getCorrectedName() + << " set: " << cardToDownload.getSetName() << "]: Skipping rate-limited URL " + << cardToDownload.getCurrentUrl() << " (server " << host << " still rate limiting)"; + if (!cardToDownload.nextUrl() && !cardToDownload.nextSet()) { + scheduleDeferredRetry(); + return; + } + } + QString picUrl = cardToDownload.getCurrentUrl(); if (picUrl.isEmpty()) { - picDownloadFailed(); + scheduleDeferredRetry(); } else { QUrl url(picUrl); qCDebug(CardPictureLoaderWorkerWorkLog).nospace() @@ -107,7 +141,41 @@ static bool imageIsBlackListed(const QByteArray &picData) void CardPictureLoaderWorkerWork::handleFailedReply(const QNetworkReply *reply) { if (reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() == 429) { - qCWarning(CardPictureLoaderWorkerWorkLog) << "Too many requests."; + QString host = reply->url().host(); + QDateTime now = QDateTime::currentDateTime(); + + qint64 retryAfterMs = 0; + const QByteArray retryAfterHeader = reply->rawHeader("Retry-After"); + if (!retryAfterHeader.isEmpty()) { + bool ok = false; + int seconds = retryAfterHeader.toInt(&ok); + if (ok && seconds > 0) { + retryAfterMs = static_cast(seconds) * 1000; + } else { + QDateTime retryAfterDate = + QDateTime::fromString(QString::fromLatin1(retryAfterHeader), Qt::RFC2822Date); + if (retryAfterDate.isValid()) { + retryAfterMs = qMax(0, now.msecsTo(retryAfterDate)); + } + } + } + + QDateTime backoffUntil = s_rateLimiter.on429(host, now, retryAfterMs); + emit rateLimited(host); + + if (s_rateLimiter.rounds(host) == 1) { + qCWarning(CardPictureLoaderWorkerWorkLog).nospace() + << "PictureLoader: [card: " << cardToDownload.getCard().getName() + << " set: " << cardToDownload.getSetName() << "]: Too many requests from " << host + << ", backing off until " << backoffUntil.toString(Qt::ISODate) << ", retrying the same url"; + scheduleDeferredRetry(); + } else { + qCWarning(CardPictureLoaderWorkerWorkLog).nospace() + << "PictureLoader: [card: " << cardToDownload.getCard().getName() + << " set: " << cardToDownload.getSetName() << "]: Too many requests from " << host + << ", retry already attempted, falling through to other sources"; + picDownloadFailed(); + } } else { bool isFromCache = reply->attribute(QNetworkRequest::SourceIsFromCacheAttribute).toBool(); @@ -148,6 +216,9 @@ void CardPictureLoaderWorkerWork::handleSuccessfulReply(QNetworkReply *reply) return; } + // A non-redirect successful response means the server is not rate limiting us anymore. + s_rateLimiter.onSuccess(reply->url().host()); + // peek is used to keep the data in the buffer for use by QImageReader const QByteArray &picData = reply->peek(reply->size()); @@ -202,6 +273,42 @@ QImage CardPictureLoaderWorkerWork::tryLoadImageFromReply(QNetworkReply *reply) return imgReader.read(); } +void CardPictureLoaderWorkerWork::scheduleDeferredRetry() +{ + QDateTime now = QDateTime::currentDateTime(); + + // Prefer waiting on the current URL's server so we retry the same source. + QString currentHost = QUrl(cardToDownload.getCurrentUrl()).host(); + QDateTime backoffUntil = s_rateLimiter.deadline(currentHost); + if (!s_rateLimiter.isRateLimited(currentHost, now)) { + backoffUntil = s_rateLimiter.earliestDeadline(now); + } + + if (!backoffUntil.isValid()) { + qCWarning(CardPictureLoaderWorkerWorkLog).nospace() + << "PictureLoader: [card: " << cardToDownload.getCard().getInfo().getCorrectedName() + << " set: " << cardToDownload.getSetName() << "]: All URLs exhausted, no servers in backoff: BAILING OUT"; + concludeImageLoad(QImage()); + return; + } + + qint64 waitMs = qMax(0, now.msecsTo(backoffUntil)); + // Add some jitter to desynchronize concurrent retries and avoid a thundering herd. + waitMs += QRandomGenerator::global()->bounded(5000); + + qCDebug(CardPictureLoaderWorkerWorkLog).nospace() + << "PictureLoader: [card: " << cardToDownload.getCard().getInfo().getCorrectedName() + << " set: " << cardToDownload.getSetName() << "]: All URLs exhausted, scheduling deferred retry in " << waitMs + << "ms"; + + QTimer::singleShot(waitMs, this, [this] { + s_rateLimiter.clearExpired(QDateTime::currentDateTime()); + + cardToDownload.resetIndices(); + startNextPicDownload(); + }); +} + void CardPictureLoaderWorkerWork::concludeImageLoad(const QImage &image) { emit imageLoaded(cardToDownload.getCard(), image); @@ -210,5 +317,5 @@ void CardPictureLoaderWorkerWork::concludeImageLoad(const QImage &image) void CardPictureLoaderWorkerWork::picDownloadChanged() { - picDownload = SettingsCache::instance().getPicDownload(); + picDownload = SettingsCache::instance().downloads().getPicDownload(); } diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.h b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.h index cdffc1dff..1e56a4373 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.h +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.h @@ -4,12 +4,15 @@ #include "card_picture_loader_worker.h" #include "card_picture_to_load.h" +#include #include #include #include #include +#include #include #include +#include inline Q_LOGGING_CATEGORY(CardPictureLoaderWorkerWorkLog, "card_picture_loader.worker"); @@ -50,6 +53,8 @@ public slots: private: bool picDownload; ///< Whether network downloading is enabled + static ServerRateLimiter s_rateLimiter; ///< Shared per-server 429 backoff state + /** @brief Starts downloading the next URL for this card. */ void startNextPicDownload(); @@ -77,6 +82,16 @@ private: */ void concludeImageLoad(const QImage &image); + /** + * @brief Schedules a deferred retry after the relevant server backoff expires. + * + * Waits on the current URL's server when it is the reason we are blocked, + * otherwise on the earliest active backoff. If no servers are in backoff, + * concludes with failure. Otherwise resets the CardPictureToLoad indices and + * retries after the backoff period. + */ + void scheduleDeferredRetry(); + private slots: /** @brief Updates the picDownload setting when it changes. */ void picDownloadChanged(); @@ -100,6 +115,9 @@ signals: /** @brief Emitted when a URL has been redirected. */ void urlRedirected(const QUrl &originalUrl, const QUrl &redirectUrl); + /** @brief Emitted when a server returned HTTP 429. */ + void rateLimited(const QString &host); + /** @brief Emitted when a cached URL is invalid and must be removed. */ void cachedUrlInvalidated(const QUrl &url); }; diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_to_load.cpp b/cockatrice/src/interface/card_picture_loader/card_picture_to_load.cpp index 63846e5fd..7cb502e92 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_to_load.cpp +++ b/cockatrice/src/interface/card_picture_loader/card_picture_to_load.cpp @@ -9,14 +9,17 @@ #include #include #include +#include +#include CardPictureToLoad::CardPictureToLoad(const ExactCard &_card) : card(_card), urlTemplates(SettingsCache::instance().downloads().getAllURLs()) { if (card) { sortedSets = extractSetsSorted(card); - // The first time called, nextSet will also populate the Urls for the first set. - nextSet(); + currentSetIndex = 0; + currentSet = sortedSets.first(); + populateSetUrls(); } } @@ -34,7 +37,7 @@ QList CardPictureToLoad::extractSetsSorted(const ExactCard &card) std::sort(sortedSets.begin(), sortedSets.end(), SetPriorityComparator()); // If the user hasn't disabled arts other than their personal preference... - if (!SettingsCache::instance().getOverrideAllCardArtWithPersonalPreference()) { + if (!SettingsCache::instance().cardsDisplay().getOverrideAllCardArtWithPersonalPreference()) { // If the pixmapCacheKey corresponds to a specific set, we have to try to load it first. qsizetype setIndex = sortedSets.indexOf(card.getPrinting().getSet()); if (setIndex > 0) { // we don't need to move the set if it's already first @@ -91,7 +94,8 @@ void CardPictureToLoad::populateSetUrls() } } - for (const QString &urlTemplate : urlTemplates) { + const QStringList orderedTemplates = urlTemplates; + for (const QString &urlTemplate : orderedTemplates) { QString transformedUrl = transformUrl(urlTemplate); if (!transformedUrl.isEmpty()) { @@ -99,15 +103,19 @@ void CardPictureToLoad::populateSetUrls() } } - /* Call nextUrl to make sure currentUrl is up-to-date - but we don't need the result here. */ - (void)nextUrl(); + currentUrlIndex = 0; + if (!currentSetUrls.isEmpty()) { + currentUrl = currentSetUrls.first(); + } else { + currentUrl = QString(); + } } bool CardPictureToLoad::nextSet() { - if (!sortedSets.isEmpty()) { - currentSet = sortedSets.takeFirst(); + currentSetIndex++; + if (currentSetIndex < sortedSets.size()) { + currentSet = sortedSets.at(currentSetIndex); populateSetUrls(); return true; } @@ -117,8 +125,9 @@ bool CardPictureToLoad::nextSet() bool CardPictureToLoad::nextUrl() { - if (!currentSetUrls.isEmpty()) { - currentUrl = currentSetUrls.takeFirst(); + currentUrlIndex++; + if (currentUrlIndex < currentSetUrls.size()) { + currentUrl = currentSetUrls.at(currentUrlIndex); return true; } currentUrl = QString(); @@ -134,6 +143,28 @@ QString CardPictureToLoad::getSetName() const } } +QString CardPictureToLoad::peekNextUrl() const +{ + int nextIndex = currentUrlIndex + 1; + if (nextIndex < currentSetUrls.size()) { + return currentSetUrls.at(nextIndex); + } + return QString(); +} + +void CardPictureToLoad::resetIndices() +{ + currentSetIndex = 0; + if (!sortedSets.isEmpty()) { + currentSet = sortedSets.first(); + populateSetUrls(); + } else { + currentSet = {}; + currentSetUrls.clear(); + currentUrl = QString(); + } +} + static int parse(const QString &urlTemplate, const QString &propType, const QString &cardName, @@ -252,8 +283,15 @@ QString CardPictureToLoad::transformUrl(const QString &urlTemplate) const } // language setting - transformMap["!sflang!"] = QString(QCoreApplication::translate( - "PictureLoader", "en", "code for scryfall's language property, not available for all languages")); + const QString cardLang = SettingsCache::instance().cardsDisplay().getCardLang(); + transformMap["!sflang!"] = cardLang; + + // The localized printing's own id is unknown, so Scryfall must resolve it by + // its translated name (see populateSetUrls); expose that name for the + // `/cards/named` template. + if (cardLang != "en") { + transformMap["!localizedName!"] = card.getInfo().getLocalizedName(cardLang); + } QString transformedUrl = urlTemplate; for (const QString &prop : transformMap.keys()) { diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_to_load.h b/cockatrice/src/interface/card_picture_loader/card_picture_to_load.h index b57e57644..9e0e7449c 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_to_load.h +++ b/cockatrice/src/interface/card_picture_loader/card_picture_to_load.h @@ -25,6 +25,8 @@ private: QList currentSetUrls; ///< URLs for the current set being attempted QString currentUrl; ///< Currently active URL to download CardSetPtr currentSet; ///< Currently active set + int currentSetIndex = 0; ///< Current position in sortedSets + int currentUrlIndex = 0; ///< Current position in currentSetUrls public: /** @@ -56,6 +58,9 @@ public: /** @return The short name of the current set, or empty string if no set. */ [[nodiscard]] QString getSetName() const; + /** @return The next URL in the current set's list without advancing, or empty if at end. */ + [[nodiscard]] QString peekNextUrl() const; + /** * @brief Transforms a URL template into a concrete URL for this card/set. * @param urlTemplate The URL template to transform @@ -88,6 +93,14 @@ public: */ void populateSetUrls(); + /** + * @brief Resets iteration indices to the beginning. + * + * Restarts URL/set iteration from the first set and first URL. + * Used for deferred retry after server backoff expires. + */ + void resetIndices(); + /** * @brief Extract all sets from the card and sort them by priority. * @param card The card to extract sets from diff --git a/cockatrice/src/interface/deck_loader/deck_file_format.h b/cockatrice/src/interface/deck_loader/deck_file_format.h index 995de32c0..3a25797ec 100644 --- a/cockatrice/src/interface/deck_loader/deck_file_format.h +++ b/cockatrice/src/interface/deck_loader/deck_file_format.h @@ -17,7 +17,7 @@ enum Format PlainText, /** - * This is cockatrice's native deck file format, and supports deck metadata such as banner cards and tags. + * This is Cockatrice's native deck file format, and supports deck metadata such as banner cards and tags. * Stored as .cod files. */ Cockatrice diff --git a/cockatrice/src/interface/deck_loader/deck_loader.cpp b/cockatrice/src/interface/deck_loader/deck_loader.cpp index 39a0c1071..f29b4eed2 100644 --- a/cockatrice/src/interface/deck_loader/deck_loader.cpp +++ b/cockatrice/src/interface/deck_loader/deck_loader.cpp @@ -50,7 +50,7 @@ DeckLoader::loadFromFile(const QString &fileName, DeckFileFormat::Format fmt, bo result = deckList.loadFromFile_Native(&file); if (!result) { qCInfo(DeckLoaderLog) << "Failed to load " << fileName - << "as cockatrice format; retrying as plain format"; + << "as Cockatrice format; retrying as plain format"; file.seek(0); result = deckList.loadFromFile_Plain(&file, CardNameNormalizer()); fmt = DeckFileFormat::PlainText; @@ -375,15 +375,32 @@ void DeckLoader::saveToStream_DeckHeader(QTextStream &out, const DeckList &deckL void DeckLoader::saveToStream_DeckZone(QTextStream &out, const InnerDecklistNode *zoneNode, bool addComments, - bool addSetNameAndNumber) + bool addSetNameAndNumber, + const QString &boardZoneName) { + // Nested sub-zones keep their owning board's identity: the top-level call + // passes no board, so the zone's own name is used; recursive calls carry the + // owning board down so the sideboard marker survives sub-zone nesting. + const QString owningBoardZoneName = boardZoneName.isEmpty() ? zoneNode->getName() : boardZoneName; + // group cards by card type and count the subtotals QMultiMap cardsByType; QMap cardTotalByType; int cardTotal = 0; + QList subZones; for (int j = 0; j < zoneNode->size(); j++) { auto *card = dynamic_cast(zoneNode->at(j)); + if (!card) { + // Cards collected in nested sub-zones are exported by recursion so + // they don't end up invisible in the plain text output. They are + // deferred until after this zone's own header and cards so they read + // as part of this zone's block. + if (auto *subZone = dynamic_cast(zoneNode->at(j))) { + subZones.append(subZone); + } + continue; + } CardInfoPtr info = CardDatabaseManager::query()->getCardInfo(card->getName()); QString cardType = info ? info->getMainCardType() : "unknown"; @@ -411,25 +428,30 @@ void DeckLoader::saveToStream_DeckZone(QTextStream &out, QList cards = cardsByType.values(cardType); - saveToStream_DeckZoneCards(out, zoneNode, cards, addComments, addSetNameAndNumber); + saveToStream_DeckZoneCards(out, cards, addComments, addSetNameAndNumber, owningBoardZoneName); if (addComments) { out << "\n"; } } + + // Nested sub-zones come last, after the parent's own header and cards. + for (const auto *subZone : subZones) { + saveToStream_DeckZone(out, subZone, addComments, addSetNameAndNumber, owningBoardZoneName); + } } void DeckLoader::saveToStream_DeckZoneCards(QTextStream &out, - const InnerDecklistNode *zoneNode, QList cards, bool addComments, - bool addSetNameAndNumber) + bool addSetNameAndNumber, + const QString &boardZoneName) { // QMultiMap sorts values in reverse order for (int i = cards.size() - 1; i >= 0; --i) { DecklistCardNode *card = cards[i]; - if (zoneNode->getName() == DECK_ZONE_SIDE && addComments) { + if (boardZoneName == DECK_ZONE_SIDE && addComments) { out << "SB: "; } @@ -510,9 +532,26 @@ bool DeckLoader::convertToCockatriceFormat(LoadedDeck &deck) void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode *node) { + if (!node || node->isEmpty()) { + return; + } + const int totalColumns = 2; - if (node->height() == 1) { + // Dispatch children by type instead of trusting a whole-node height: a deck + // node may hold direct cards and nested zones side by side (custom zones), + // and an empty node would previously crash on at(0). + QVector cards; + QVector subZones; + for (int i = 0; i < node->size(); i++) { + if (auto *card = dynamic_cast(node->at(i))) { + cards.append(card); + } else if (auto *zone = dynamic_cast(node->at(i))) { + subZones.append(zone); + } + } + + if (!cards.isEmpty()) { QTextBlockFormat blockFormat; QTextCharFormat charFormat; charFormat.setFontPointSize(11); @@ -523,9 +562,9 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode tableFormat.setCellPadding(0); tableFormat.setCellSpacing(0); tableFormat.setBorder(0); - QTextTable *table = cursor->insertTable(node->size() + 1, totalColumns, tableFormat); - for (int i = 0; i < node->size(); i++) { - auto *card = dynamic_cast(node->at(i)); + QTextTable *table = cursor->insertTable(cards.size() + 1, totalColumns, tableFormat); + for (int i = 0; i < cards.size(); i++) { + const AbstractDecklistCardNode *card = cards[i]; QTextCharFormat cellCharFormat; cellCharFormat.setFontPointSize(9); @@ -540,7 +579,13 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode cellCursor = cell.firstCursorPosition(); cellCursor.insertText(card->getName()); } - } else if (node->height() == 2) { + } + + for (const InnerDecklistNode *subZone : subZones) { + if (subZone->isEmpty()) { + continue; + } + QTextBlockFormat blockFormat; QTextCharFormat charFormat; charFormat.setFontPointSize(14); @@ -559,10 +604,8 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode tableFormat.setColumnWidthConstraints(constraints); QTextTable *table = cursor->insertTable(1, totalColumns, tableFormat); - for (int i = 0; i < node->size(); i++) { - QTextCursor cellCursor = table->cellAt(0, (i * totalColumns) / node->size()).lastCursorPosition(); - printDeckListNode(&cellCursor, dynamic_cast(node->at(i))); - } + QTextCursor cellCursor = table->cellAt(0, 0).firstCursorPosition(); + printDeckListNode(&cellCursor, subZone); } cursor->movePosition(QTextCursor::End); diff --git a/cockatrice/src/interface/deck_loader/deck_loader.h b/cockatrice/src/interface/deck_loader/deck_loader.h index ac23e1ee0..be0df311d 100644 --- a/cockatrice/src/interface/deck_loader/deck_loader.h +++ b/cockatrice/src/interface/deck_loader/deck_loader.h @@ -131,7 +131,7 @@ public: static void printDeckList(QPrinter *printer, const DeckList &deckList); /** - * Converts the given deck's file to the cockatrice file format. + * Converts the given deck's file to the Cockatrice file format. * Uses the lastLoadInfo in the LoadedDeck to determine the current name of the file and where to save to. * @param deck The deck to convert. Should have valid lastLoadInfo. Will update the lastLoadInfo. * @return Whether the conversion succeeded. @@ -159,12 +159,13 @@ private: static void saveToStream_DeckZone(QTextStream &out, const InnerDecklistNode *zoneNode, bool addComments = true, - bool addSetNameAndNumber = true); + bool addSetNameAndNumber = true, + const QString &boardZoneName = QString()); static void saveToStream_DeckZoneCards(QTextStream &out, - const InnerDecklistNode *zoneNode, QList cards, bool addComments = true, - bool addSetNameAndNumber = true); + bool addSetNameAndNumber = true, + const QString &boardZoneName = QString()); }; #endif diff --git a/cockatrice/src/interface/intents/contexts/context_connect_to_server.h b/cockatrice/src/interface/intents/contexts/context_connect_to_server.h new file mode 100644 index 000000000..c7c40b261 --- /dev/null +++ b/cockatrice/src/interface/intents/contexts/context_connect_to_server.h @@ -0,0 +1,14 @@ +#ifndef COCKATRICE_CONTEXT_CONNECT_TO_SERVER_H +#define COCKATRICE_CONTEXT_CONNECT_TO_SERVER_H + +#include + +struct ContextConnectToServer +{ + QString hostname; + QString port; + QString username; + QString password; +}; + +#endif // COCKATRICE_CONTEXT_CONNECT_TO_SERVER_H diff --git a/cockatrice/src/interface/intents/contexts/context_join_game.h b/cockatrice/src/interface/intents/contexts/context_join_game.h new file mode 100644 index 000000000..2e5a88ea2 --- /dev/null +++ b/cockatrice/src/interface/intents/contexts/context_join_game.h @@ -0,0 +1,12 @@ +#ifndef COCKATRICE_CONTEXT_JOIN_GAME_H +#define COCKATRICE_CONTEXT_JOIN_GAME_H +#include "context_join_room.h" + +struct ContextJoinGame +{ + ContextJoinRoom roomContext; + int gameId; + bool asSpectator = false; +}; + +#endif // COCKATRICE_CONTEXT_JOIN_GAME_H diff --git a/cockatrice/src/interface/intents/contexts/context_join_room.h b/cockatrice/src/interface/intents/contexts/context_join_room.h new file mode 100644 index 000000000..23ae05e81 --- /dev/null +++ b/cockatrice/src/interface/intents/contexts/context_join_room.h @@ -0,0 +1,14 @@ +#ifndef COCKATRICE_CONTEXT_JOIN_ROOM_H +#define COCKATRICE_CONTEXT_JOIN_ROOM_H + +#include "context_connect_to_server.h" + +#include + +struct ContextJoinRoom +{ + ContextConnectToServer serverContext; + int roomId; +}; + +#endif // COCKATRICE_CONTEXT_JOIN_ROOM_H diff --git a/cockatrice/src/interface/intents/intent.cpp b/cockatrice/src/interface/intents/intent.cpp new file mode 100644 index 000000000..c02a89f35 --- /dev/null +++ b/cockatrice/src/interface/intents/intent.cpp @@ -0,0 +1,48 @@ +#include "intent.h" + +Intent::Intent(QObject *parent) : QObject(parent) +{ + // An intent is done as soon as it reports success or failure. Deleting it + // also tears down its dependency chain and disconnects any signal wiring. + connect(this, &Intent::finished, this, &QObject::deleteLater); + connect(this, &Intent::failed, this, &QObject::deleteLater); +} + +Intent::~Intent() = default; + +void Intent::execute() +{ + if (checkPrecondition()) { + onPreconditionSatisfied(); + } else { + onPreconditionNotSatisfied(); + } +} + +void Intent::runDependency(Intent *dependency) +{ + dependency->setParent(this); + connect(dependency, &Intent::finished, this, [this]() { + // Re-check after dependency finishes + this->execute(); + }); + connect(dependency, &Intent::failed, this, &Intent::failed); + + dependency->execute(); +} + +void Intent::emitFinished() +{ + if (!completed) { + completed = true; + emit finished(); + } +} + +void Intent::emitFailed(const QString &reason) +{ + if (!completed) { + completed = true; + emit failed(reason); + } +} diff --git a/cockatrice/src/interface/intents/intent.h b/cockatrice/src/interface/intents/intent.h new file mode 100644 index 000000000..125900ecd --- /dev/null +++ b/cockatrice/src/interface/intents/intent.h @@ -0,0 +1,37 @@ +#ifndef COCKATRICE_INTENT_H +#define COCKATRICE_INTENT_H + +#include + +class Intent : public QObject +{ + Q_OBJECT + +public: + explicit Intent(QObject *parent = nullptr); + ~Intent() override; + + void execute(); + +signals: + void finished(); + void failed(QString reason); + +protected: + // --- Subclasses must implement these --- + virtual bool checkPrecondition() const = 0; + virtual void onPreconditionSatisfied() = 0; + virtual void onPreconditionNotSatisfied() = 0; + + // Helper to chain another intent + void runDependency(Intent *dependency); + + // Emit the outcome exactly once; ignore late signals after the intent is done. + void emitFinished(); + void emitFailed(const QString &reason); + +private: + bool completed = false; +}; + +#endif // COCKATRICE_INTENT_H diff --git a/cockatrice/src/interface/intents/intent_connect_to_server.cpp b/cockatrice/src/interface/intents/intent_connect_to_server.cpp new file mode 100644 index 000000000..1cccc5a23 --- /dev/null +++ b/cockatrice/src/interface/intents/intent_connect_to_server.cpp @@ -0,0 +1,46 @@ +#include "intent_connect_to_server.h" + +#include "intent_disconnect_from_server.h" + +#include + +IntentConnectToServer::IntentConnectToServer(RemoteClient *_remoteClient, ContextConnectToServer *_context) + : Intent(), remoteClient(_remoteClient), context(_context) +{ +} + +bool IntentConnectToServer::checkPrecondition() const +{ + return remoteClient->getStatus() == ClientStatus::StatusDisconnected; +} + +void IntentConnectToServer::onPreconditionSatisfied() +{ + remoteClient->connectToServer(context->hostname, context->port.toUInt(), context->username, context->password); + connect(remoteClient, &RemoteClient::statusChanged, this, &IntentConnectToServer::onStatusChanged); + connect(remoteClient, &RemoteClient::socketError, this, &IntentConnectToServer::onSocketError); + connect( + remoteClient, &RemoteClient::loginError, this, + [this](Response::ResponseCode, const QString &reason, quint32, const QList &) { emitFailed(reason); }); + + QTimer::singleShot(15000, this, [this]() { + emitFailed(tr("Timed out while connecting to %1:%2").arg(context->hostname, context->port)); + }); +} + +void IntentConnectToServer::onPreconditionNotSatisfied() +{ + runDependency(new IntentDisconnectFromServer(remoteClient)); +} + +void IntentConnectToServer::onStatusChanged(ClientStatus status) +{ + if (status == ClientStatus::StatusLoggedIn) { + emitFinished(); + } +} + +void IntentConnectToServer::onSocketError(const QString &errorString) +{ + emitFailed(tr("Failed to connect to %1:%2: %3").arg(context->hostname, context->port, errorString)); +} diff --git a/cockatrice/src/interface/intents/intent_connect_to_server.h b/cockatrice/src/interface/intents/intent_connect_to_server.h new file mode 100644 index 000000000..eab4d1a21 --- /dev/null +++ b/cockatrice/src/interface/intents/intent_connect_to_server.h @@ -0,0 +1,29 @@ +#ifndef COCKATRICE_INTENT_CONNECT_TO_SERVER_H +#define COCKATRICE_INTENT_CONNECT_TO_SERVER_H + +#include "contexts/context_connect_to_server.h" +#include "intent.h" +#include "remote_client.h" + +class IntentConnectToServer : public Intent +{ + Q_OBJECT + +public: + IntentConnectToServer(RemoteClient *_remoteClient, ContextConnectToServer *_context); + +protected: + bool checkPrecondition() const override; + void onPreconditionSatisfied() override; + void onPreconditionNotSatisfied() override; + +private: + RemoteClient *remoteClient; + ContextConnectToServer *context; + +private slots: + void onStatusChanged(ClientStatus status); + void onSocketError(const QString &errorString); +}; + +#endif // COCKATRICE_INTENT_CONNECT_TO_SERVER_H diff --git a/cockatrice/src/interface/intents/intent_disconnect_from_server.cpp b/cockatrice/src/interface/intents/intent_disconnect_from_server.cpp new file mode 100644 index 000000000..cb39d7bab --- /dev/null +++ b/cockatrice/src/interface/intents/intent_disconnect_from_server.cpp @@ -0,0 +1,29 @@ +#include "intent_disconnect_from_server.h" + +IntentDisconnectFromServer::IntentDisconnectFromServer(RemoteClient *_remoteClient) + : Intent(), remoteClient(_remoteClient) +{ +} + +bool IntentDisconnectFromServer::checkPrecondition() const +{ + return remoteClient->getStatus() == ClientStatus::StatusDisconnected; +} + +void IntentDisconnectFromServer::onPreconditionSatisfied() +{ + emitFinished(); +} + +void IntentDisconnectFromServer::onPreconditionNotSatisfied() +{ + connect(remoteClient, &RemoteClient::statusChanged, this, &IntentDisconnectFromServer::onStatusChanged); + remoteClient->disconnectFromServer(); +} + +void IntentDisconnectFromServer::onStatusChanged(ClientStatus status) +{ + if (status == ClientStatus::StatusDisconnected) { + emitFinished(); + } +} diff --git a/cockatrice/src/interface/intents/intent_disconnect_from_server.h b/cockatrice/src/interface/intents/intent_disconnect_from_server.h new file mode 100644 index 000000000..6e1dfd0c1 --- /dev/null +++ b/cockatrice/src/interface/intents/intent_disconnect_from_server.h @@ -0,0 +1,26 @@ +#ifndef COCKATRICE_INTENT_DISCONNECT_FROM_SERVER_H +#define COCKATRICE_INTENT_DISCONNECT_FROM_SERVER_H + +#include "intent.h" +#include "remote_client.h" + +class IntentDisconnectFromServer : public Intent +{ + Q_OBJECT + +public: + IntentDisconnectFromServer(RemoteClient *_remoteClient); + +protected: + bool checkPrecondition() const override; + void onPreconditionSatisfied() override; + void onPreconditionNotSatisfied() override; + +private: + RemoteClient *remoteClient; + +private slots: + void onStatusChanged(ClientStatus status); +}; + +#endif // COCKATRICE_INTENT_DISCONNECT_FROM_SERVER_H diff --git a/cockatrice/src/interface/intents/intent_join_server_game.cpp b/cockatrice/src/interface/intents/intent_join_server_game.cpp new file mode 100644 index 000000000..205c4dc70 --- /dev/null +++ b/cockatrice/src/interface/intents/intent_join_server_game.cpp @@ -0,0 +1,76 @@ +#include "intent_join_server_game.h" + +#include "../widgets/server/game_selector.h" +#include "../widgets/tabs/tab_room.h" +#include "../widgets/tabs/tab_supervisor.h" +#include "intent_join_server_room.h" + +#include + +IntentJoinServerGame::IntentJoinServerGame(TabSupervisor *_tabSupervisor, + RemoteClient *_remoteClient, + std::unique_ptr _context) + : Intent(), tabSupervisor(_tabSupervisor), remoteClient(_remoteClient), context(_context.release()) +{ +} + +bool IntentJoinServerGame::checkPrecondition() const +{ + if (remoteClient->getStatus() != ClientStatus::StatusLoggedIn) { + return false; + } + // peerPort() reflects the actual TCP peer, which may differ from the + // configured server port (e.g. when connecting through a proxy), so only + // the hostname is compared here. + if (remoteClient->peerName() != context->roomContext.serverContext.hostname) { + return false; + } + if (QString::number(remoteClient->peerPort()) != context->roomContext.serverContext.port) { + return false; + } + + if (!tabSupervisor->getRoomTabs().contains(context->roomContext.roomId)) { + return false; + } + + return true; +} + +void IntentJoinServerGame::onPreconditionSatisfied() +{ + TabRoom *room = tabSupervisor->getRoomTabs().value(context->roomContext.roomId); + if (!tryJoinGame(room)) { + waitForGame(room); + } +} + +void IntentJoinServerGame::onPreconditionNotSatisfied() +{ + runDependency(new IntentJoinServerRoom(tabSupervisor, remoteClient, &context->roomContext)); +} + +bool IntentJoinServerGame::tryJoinGame(TabRoom *room) +{ + if (!room) { + return false; + } + + if (room->getGameSelector()->joinGameById(context->gameId, context->asSpectator)) { + emitFinished(); + return true; + } + + return false; +} + +void IntentJoinServerGame::waitForGame(TabRoom *room) +{ + connect(room, &TabRoom::gameListUpdated, this, [this]() { + TabRoom *updatedRoom = tabSupervisor->getRoomTabs().value(context->roomContext.roomId); + if (updatedRoom) { + tryJoinGame(updatedRoom); + } + }); + + QTimer::singleShot(15000, this, [this]() { emitFailed(tr("Game %1 not found in the room").arg(context->gameId)); }); +} diff --git a/cockatrice/src/interface/intents/intent_join_server_game.h b/cockatrice/src/interface/intents/intent_join_server_game.h new file mode 100644 index 000000000..5e196df38 --- /dev/null +++ b/cockatrice/src/interface/intents/intent_join_server_game.h @@ -0,0 +1,37 @@ +#ifndef COCKATRICE_INTENT_JOIN_SERVER_GAME_H +#define COCKATRICE_INTENT_JOIN_SERVER_GAME_H + +#include "contexts/context_join_game.h" +#include "intent.h" +#include "remote_client.h" + +#include +#include + +class TabRoom; +class TabSupervisor; + +class IntentJoinServerGame : public Intent +{ + Q_OBJECT + +public: + IntentJoinServerGame(TabSupervisor *_tabSupervisor, + RemoteClient *_remoteClient, + std::unique_ptr _context); + +protected: + bool checkPrecondition() const override; + void onPreconditionSatisfied() override; + void onPreconditionNotSatisfied() override; + +private: + bool tryJoinGame(TabRoom *room); + void waitForGame(TabRoom *room); + + TabSupervisor *tabSupervisor; + RemoteClient *remoteClient; + QScopedPointer context; +}; + +#endif // COCKATRICE_INTENT_JOIN_SERVER_GAME_H diff --git a/cockatrice/src/interface/intents/intent_join_server_room.cpp b/cockatrice/src/interface/intents/intent_join_server_room.cpp new file mode 100644 index 000000000..d25bc8d17 --- /dev/null +++ b/cockatrice/src/interface/intents/intent_join_server_room.cpp @@ -0,0 +1,74 @@ +#include "intent_join_server_room.h" + +#include "../widgets/tabs/tab_room.h" +#include "../widgets/tabs/tab_server.h" +#include "../widgets/tabs/tab_supervisor.h" +#include "intent_connect_to_server.h" + +#include +#include + +IntentJoinServerRoom::IntentJoinServerRoom(TabSupervisor *_tabSupervisor, + RemoteClient *_remoteClient, + ContextJoinRoom *_context) + : Intent(), tabSupervisor(_tabSupervisor), remoteClient(_remoteClient), context(_context) +{ +} + +bool IntentJoinServerRoom::checkPrecondition() const +{ + if (remoteClient->getStatus() != ClientStatus::StatusLoggedIn) { + return false; + } + // peerPort() reflects the actual TCP peer, which may differ from the + // configured server port (e.g. when connecting through a proxy), so only + // the hostname is compared here. + if (remoteClient->peerName() != context->serverContext.hostname) { + return false; + } + if (QString::number(remoteClient->peerPort()) != context->serverContext.port) { + return false; + } + + return true; +} + +void IntentJoinServerRoom::onPreconditionSatisfied() +{ + if (tabSupervisor->getRoomTabs().contains(context->roomId)) { + tabSupervisor->setCurrentWidget(tabSupervisor->getRoomTabs().value(context->roomId)); + emitFinished(); + return; + } + + TabServer *tabServer = tabSupervisor->getTabServer(); + if (!tabServer) { + tabSupervisor->openTabServer(); + tabServer = tabSupervisor->getTabServer(); + } + if (!tabServer) { + emitFailed(tr("No server tab available")); + return; + } + + const int roomId = context->roomId; + tabServer->joinRoom(roomId, true); + connect(tabServer, &TabServer::roomJoined, this, [this, roomId](const ServerInfo_Room &info, bool) { + if (info.room_id() == roomId) { + emitFinished(); + } + }); + connect(tabServer, &TabServer::roomJoinFailed, this, [this, roomId](int failedRoomId) { + if (failedRoomId == roomId) { + emitFailed(tr("Failed to join the server room %1").arg(roomId)); + } + }); + + QTimer::singleShot(15000, this, + [this, roomId]() { emitFailed(tr("Timed out while joining the server room %1").arg(roomId)); }); +} + +void IntentJoinServerRoom::onPreconditionNotSatisfied() +{ + runDependency(new IntentConnectToServer(remoteClient, &context->serverContext)); +} diff --git a/cockatrice/src/interface/intents/intent_join_server_room.h b/cockatrice/src/interface/intents/intent_join_server_room.h new file mode 100644 index 000000000..4a5599896 --- /dev/null +++ b/cockatrice/src/interface/intents/intent_join_server_room.h @@ -0,0 +1,28 @@ +#ifndef COCKATRICE_INTENT_JOIN_SERVER_ROOM_H +#define COCKATRICE_INTENT_JOIN_SERVER_ROOM_H + +#include "contexts/context_join_room.h" +#include "intent.h" +#include "remote_client.h" + +class TabSupervisor; + +class IntentJoinServerRoom : public Intent +{ + Q_OBJECT + +public: + IntentJoinServerRoom(TabSupervisor *_tabSupervisor, RemoteClient *_remoteClient, ContextJoinRoom *_context); + +protected: + bool checkPrecondition() const override; + void onPreconditionSatisfied() override; + void onPreconditionNotSatisfied() override; + +private: + TabSupervisor *tabSupervisor; + RemoteClient *remoteClient; + ContextJoinRoom *context; +}; + +#endif // COCKATRICE_INTENT_JOIN_SERVER_ROOM_H diff --git a/cockatrice/src/interface/intents/intent_login.cpp b/cockatrice/src/interface/intents/intent_login.cpp new file mode 100644 index 000000000..ff871fd03 --- /dev/null +++ b/cockatrice/src/interface/intents/intent_login.cpp @@ -0,0 +1,33 @@ +#include "intent_login.h" + +#include "../../client/settings/cache_settings.h" +#include "libcockatrice/settings/servers_settings.h" + +IntentGetLoginCredentials::IntentGetLoginCredentials(ContextConnectToServer *_context) : Intent(), context(_context) +{ +} + +bool IntentGetLoginCredentials::checkPrecondition() const +{ + ServersSettings &servers = SettingsCache::instance().servers(); + return servers.hasLoginData(context->hostname, context->port); +} + +void IntentGetLoginCredentials::onPreconditionSatisfied() +{ + ServersSettings &servers = SettingsCache::instance().servers(); + const int index = servers.findServerIndex(context->hostname, context->port); + + if (index >= 0) { + context->username = servers.getValue(QString("username%1").arg(index), "server", "server_details").toString(); + context->password = servers.getValue(QString("password%1").arg(index), "server", "server_details").toString(); + emitFinished(); + } else { + emitFailed(tr("No saved credentials for this server")); + } +} + +void IntentGetLoginCredentials::onPreconditionNotSatisfied() +{ + emitFailed(tr("No saved credentials for this server")); +} diff --git a/cockatrice/src/interface/intents/intent_login.h b/cockatrice/src/interface/intents/intent_login.h new file mode 100644 index 000000000..c7fec92b7 --- /dev/null +++ b/cockatrice/src/interface/intents/intent_login.h @@ -0,0 +1,23 @@ +#ifndef COCKATRICE_INTENT_LOGIN_H +#define COCKATRICE_INTENT_LOGIN_H + +#include "contexts/context_connect_to_server.h" +#include "intent.h" + +class IntentGetLoginCredentials : public Intent +{ + Q_OBJECT + +public: + IntentGetLoginCredentials(ContextConnectToServer *_context); + +protected: + bool checkPrecondition() const override; + void onPreconditionSatisfied() override; + void onPreconditionNotSatisfied() override; + +private: + ContextConnectToServer *context; +}; + +#endif // COCKATRICE_INTENT_LOGIN_H diff --git a/cockatrice/src/interface/intents/intent_open_local_deck.cpp b/cockatrice/src/interface/intents/intent_open_local_deck.cpp new file mode 100644 index 000000000..2457bec72 --- /dev/null +++ b/cockatrice/src/interface/intents/intent_open_local_deck.cpp @@ -0,0 +1,34 @@ +#include "intent_open_local_deck.h" + +#include "../deck_loader/deck_file_format.h" +#include "../deck_loader/deck_loader.h" +#include "../widgets/tabs/tab_supervisor.h" +#include "intent_wait_for_database_load.h" + +#include + +IntentOpenLocalDeck::IntentOpenLocalDeck(TabSupervisor *_tabSupervisor, const QString &_file) + : Intent(), tabSupervisor(_tabSupervisor), file(_file) +{ +} + +bool IntentOpenLocalDeck::checkPrecondition() const +{ + return CardDatabaseManager::getInstance()->getLoadStatus() == LoadStatus::Ok; +} + +void IntentOpenLocalDeck::onPreconditionSatisfied() +{ + std::optional deckOpt = DeckLoader::loadFromFile(file, DeckFileFormat::getFormatFromName(file), true); + if (deckOpt) { + tabSupervisor->openDeckInNewTab(deckOpt.value()); + emitFinished(); + } else { + emitFailed(tr("Unable to load deck file %1").arg(file)); + } +} + +void IntentOpenLocalDeck::onPreconditionNotSatisfied() +{ + runDependency(new IntentWaitForDatabaseLoad); +} diff --git a/cockatrice/src/interface/intents/intent_open_local_deck.h b/cockatrice/src/interface/intents/intent_open_local_deck.h new file mode 100644 index 000000000..97f875e39 --- /dev/null +++ b/cockatrice/src/interface/intents/intent_open_local_deck.h @@ -0,0 +1,27 @@ +#ifndef COCKATRICE_INTENT_OPEN_LOCAL_DECK_H +#define COCKATRICE_INTENT_OPEN_LOCAL_DECK_H + +#include "intent.h" + +#include + +class TabSupervisor; + +class IntentOpenLocalDeck : public Intent +{ + Q_OBJECT + +public: + IntentOpenLocalDeck(TabSupervisor *_tabSupervisor, const QString &_file); + +protected: + bool checkPrecondition() const override; + void onPreconditionSatisfied() override; + void onPreconditionNotSatisfied() override; + +private: + TabSupervisor *tabSupervisor; + QString file; +}; + +#endif // COCKATRICE_INTENT_OPEN_LOCAL_DECK_H diff --git a/cockatrice/src/interface/intents/intent_open_server_room_by_name.cpp b/cockatrice/src/interface/intents/intent_open_server_room_by_name.cpp new file mode 100644 index 000000000..d50f509a0 --- /dev/null +++ b/cockatrice/src/interface/intents/intent_open_server_room_by_name.cpp @@ -0,0 +1,183 @@ +#include "intent_open_server_room_by_name.h" + +#include "../widgets/tabs/tab_room.h" +#include "../widgets/tabs/tab_supervisor.h" +#include "intent_connect_to_server.h" + +#include +#include +#include +#include + +IntentOpenServerRoomByName::IntentOpenServerRoomByName(TabSupervisor *_tabSupervisor, + RemoteClient *_remoteClient, + std::unique_ptr _context, + const QString &_roomName) + : Intent(), tabSupervisor(_tabSupervisor), remoteClient(_remoteClient), context(_context.release()), + roomName(_roomName) +{ + checkTimer.setInterval(250); + connect(&checkTimer, &QTimer::timeout, this, [this]() { + if (selectOpenRoom()) { + checkTimer.stop(); + } + }); +} + +bool IntentOpenServerRoomByName::checkPrecondition() const +{ + if (remoteClient->getStatus() != ClientStatus::StatusLoggedIn) { + return false; + } + // peerPort() reflects the actual TCP peer, which may differ from the + // configured server port (e.g. when connecting through a proxy), so only + // the hostname is compared here. + if (remoteClient->peerName() != context->serverContext.hostname) { + return false; + } + if (QString::number(remoteClient->peerPort()) != context->serverContext.port) { + return false; + } + + return true; +} + +void IntentOpenServerRoomByName::onPreconditionSatisfied() +{ + if (listening) { + return; + } + listening = true; + + if (selectOpenRoom()) { + return; + } + + // The room selector is the component that requests the room list, so the + // server tab must exist for the room to be resolved by name. + if (!tabSupervisor->getTabServer()) { + tabSupervisor->openTabServer(); + } + if (!tabSupervisor->getTabServer()) { + emitFailed(tr("No server tab available")); + return; + } + + connect(remoteClient, &RemoteClient::listRoomsEventReceived, this, &IntentOpenServerRoomByName::processListRooms); + connect(remoteClient, &RemoteClient::statusChanged, this, &IntentOpenServerRoomByName::onClientStatusChanged); + + // The room tab may be opened by our own join, by the room selector's auto-join, or by a + // join that was already in flight. Poll until it shows up. + checkTimer.start(); + + // While no join has been sent yet, keep the room list fresh: the list may have been + // requested before we subscribed to it, or a response may have been dropped during a + // busy login burst. A stale list would otherwise leave the room unresolved forever. + connect(&refreshTimer, &QTimer::timeout, this, [this]() { + if (!joinPending) { + remoteClient->sendCommand(remoteClient->prepareSessionCommand(Command_ListRooms())); + } + }); + refreshTimer.setInterval(5000); + refreshTimer.start(); + + // Last-resort failure for "the room genuinely is not in a fresh list". This must NOT + // fire while a join is in flight: a loaded server may take longer than that to answer + // during a login burst, and killing the intent early would leave the connection + // registered in the room with no tab to display it and every later join attempt + // would then be rejected with RespContextError. + QTimer::singleShot(20000, this, [this]() { + if (!joinPending) { + emitFailed(tr("Timed out while looking for the server room %1").arg(roomName)); + } + }); +} + +void IntentOpenServerRoomByName::onPreconditionNotSatisfied() +{ + runDependency(new IntentConnectToServer(remoteClient, &context->serverContext)); +} + +void IntentOpenServerRoomByName::onClientStatusChanged(ClientStatus status) +{ + if (status != ClientStatus::StatusLoggedIn) { + emitFailed(tr("Disconnected while looking for the server room %1").arg(roomName)); + } +} + +bool IntentOpenServerRoomByName::selectOpenRoom() +{ + const auto &roomTabs = tabSupervisor->getRoomTabs(); + for (auto i = roomTabs.cbegin(), end = roomTabs.cend(); i != end; ++i) { + TabRoom *room = i.value(); + if (room->getRoomName() == roomName) { + tabSupervisor->setCurrentWidget(room); + emitFinished(); + return true; + } + } + return false; +} + +void IntentOpenServerRoomByName::processListRooms(const Event_ListRooms &event) +{ + if (selectOpenRoom()) { + return; + } + + for (int i = 0; i < event.room_list_size(); ++i) { + const ServerInfo_Room &room = event.room_list(i); + if (room.has_name() && QString::fromStdString(room.name()) == roomName) { + openRoom(room); + return; + } + } +} + +void IntentOpenServerRoomByName::openRoom(const ServerInfo_Room &roomInfo) +{ + if (joinPending) { + return; + } + joinPending = true; + + // Rooms flagged auto_join are joined by the room selector automatically. Sending our own + // Command_JoinRoom on top of that would be answered with RespContextError. + if (roomInfo.has_auto_join() && roomInfo.auto_join()) { + return; + } + + Command_JoinRoom cmd; + cmd.set_room_id(roomInfo.room_id()); + PendingCommand *pend = remoteClient->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, + [this](const Response &r, const CommandContainer &, const QVariant &) { handleJoinResponse(r); }); + remoteClient->sendCommand(pend); +} + +void IntentOpenServerRoomByName::handleJoinResponse(const Response &response) +{ + switch (response.response_code()) { + case Response::RespOk: { + const Response_JoinRoom &resp = response.GetExtension(Response_JoinRoom::ext); + if (!tabSupervisor->getRoomTabs().contains(resp.room_info().room_id())) { + tabSupervisor->addRoomTab(resp.room_info(), true); + } + emitFinished(); + return; + } + case Response::RespNameNotFound: + emitFailed(tr("Failed to join the server room %1: it doesn't exist on the server.").arg(roomName)); + return; + case Response::RespUserLevelTooLow: + emitFailed(tr("You do not have the required permission to join the server room %1.").arg(roomName)); + return; + case Response::RespContextError: + // The room was already joined by someone else (e.g. the room selector's + // auto-join). It will show up in the room tabs shortly, so keep waiting. + return; + default: + emitFailed(tr("Failed to join the server room %1 due to an unknown error.").arg(roomName)); + return; + } +} diff --git a/cockatrice/src/interface/intents/intent_open_server_room_by_name.h b/cockatrice/src/interface/intents/intent_open_server_room_by_name.h new file mode 100644 index 000000000..2f9e716af --- /dev/null +++ b/cockatrice/src/interface/intents/intent_open_server_room_by_name.h @@ -0,0 +1,61 @@ +#ifndef COCKATRICE_INTENT_OPEN_SERVER_ROOM_BY_NAME_H +#define COCKATRICE_INTENT_OPEN_SERVER_ROOM_BY_NAME_H + +#include "contexts/context_join_room.h" +#include "intent.h" +#include "remote_client.h" + +#include +#include +#include +#include + +class TabRoom; +class TabSupervisor; +class Event_ListRooms; +class ServerInfo_Room; + +/** + * @brief Connects to the configured server and opens a room identified by its name. + * + * Room ids are assigned by the server per session, so the room is resolved by name from the + * room list once the client is logged in. If the room is already open it is simply selected. + * + * The join itself is sent directly through the client instead of `TabServer::joinRoom`, so a + * failed join only fails the intent silently instead of popping a modal error box during + * startup. Success is routed to `TabSupervisor::addRoomTab`, the same tab-creation machinery + * the normal join flow uses. + */ +class IntentOpenServerRoomByName : public Intent +{ + Q_OBJECT + +public: + IntentOpenServerRoomByName(TabSupervisor *_tabSupervisor, + RemoteClient *_remoteClient, + std::unique_ptr _context, + const QString &_roomName); + +protected: + bool checkPrecondition() const override; + void onPreconditionSatisfied() override; + void onPreconditionNotSatisfied() override; + +private: + void processListRooms(const Event_ListRooms &event); + void openRoom(const ServerInfo_Room &roomInfo); + void handleJoinResponse(const Response &response); + void onClientStatusChanged(ClientStatus status); + bool selectOpenRoom(); + + TabSupervisor *tabSupervisor; + RemoteClient *remoteClient; + QScopedPointer context; + QString roomName; + bool listening = false; + bool joinPending = false; + QTimer checkTimer; + QTimer refreshTimer; +}; + +#endif // COCKATRICE_INTENT_OPEN_SERVER_ROOM_BY_NAME_H diff --git a/cockatrice/src/interface/intents/intent_wait_for_database_load.cpp b/cockatrice/src/interface/intents/intent_wait_for_database_load.cpp new file mode 100644 index 000000000..c36378818 --- /dev/null +++ b/cockatrice/src/interface/intents/intent_wait_for_database_load.cpp @@ -0,0 +1,19 @@ +#include "intent_wait_for_database_load.h" + +#include + +bool IntentWaitForDatabaseLoad::checkPrecondition() const +{ + return CardDatabaseManager::getInstance()->getLoadStatus() == LoadStatus::Ok; +} + +void IntentWaitForDatabaseLoad::onPreconditionSatisfied() +{ + emitFinished(); +} + +void IntentWaitForDatabaseLoad::onPreconditionNotSatisfied() +{ + connect(CardDatabaseManager::getInstance(), &CardDatabase::cardDatabaseLoadingFinished, this, + [this]() { emitFinished(); }); +} diff --git a/cockatrice/src/interface/intents/intent_wait_for_database_load.h b/cockatrice/src/interface/intents/intent_wait_for_database_load.h new file mode 100644 index 000000000..72f4a1ffc --- /dev/null +++ b/cockatrice/src/interface/intents/intent_wait_for_database_load.h @@ -0,0 +1,16 @@ +#ifndef COCKATRICE_INTENT_WAIT_FOR_DATABASE_LOAD_H +#define COCKATRICE_INTENT_WAIT_FOR_DATABASE_LOAD_H + +#include "intent.h" + +class IntentWaitForDatabaseLoad : public Intent +{ + Q_OBJECT + +protected: + bool checkPrecondition() const override; + void onPreconditionSatisfied() override; + void onPreconditionNotSatisfied() override; +}; + +#endif // COCKATRICE_INTENT_WAIT_FOR_DATABASE_LOAD_H diff --git a/cockatrice/src/interface/intents/url_parser.cpp b/cockatrice/src/interface/intents/url_parser.cpp new file mode 100644 index 000000000..509390611 --- /dev/null +++ b/cockatrice/src/interface/intents/url_parser.cpp @@ -0,0 +1,136 @@ +#include "url_parser.h" + +#include "../widgets/tabs/tab_room.h" +#include "../widgets/tabs/tab_supervisor.h" +#include "../window_main.h" +#include "contexts/context_join_game.h" +#include "intent_join_server_game.h" +#include "intent_login.h" + +#include +#include +#include +#include +#include +#include + +IntentUrlParser::IntentUrlParser(QObject *parent, MainWindow *_mainWindow) : QObject(parent), mainWindow(_mainWindow) +{ +} + +void IntentUrlParser::handle(const QString &urlStr) +{ + QUrl url(urlStr); + + if (url.scheme() != "cockatrice") { + return; + } + + const QString action = url.host(); + QUrlQuery query(url); + + if (action == "joingame") { + handleJoinGame(query); + } else if (action == "opendeck") { + // handleOpenDeck(query); + } else { + qWarning() << "Unknown intent:" << action; + } +} + +void IntentUrlParser::handleJoinGame(const QUrlQuery &query) +{ + auto showError = [this](const QString &message) { QMessageBox::warning(mainWindow, tr("Open game"), message); }; + + auto ctx = std::make_unique(); + + ctx->roomContext.serverContext.hostname = query.queryItemValue("hostname"); + ctx->roomContext.serverContext.port = query.queryItemValue("port"); + + if (ctx->roomContext.serverContext.hostname.isEmpty()) { + showError(tr("Missing or empty hostname in the game link")); + return; + } + + bool ok = false; + ctx->roomContext.serverContext.port.toUShort(&ok); + if (!ok) { + showError(tr("Invalid or missing port in the game link")); + return; + } + + ctx->roomContext.roomId = query.queryItemValue("roomid").toInt(&ok); + + if (!ok) { + showError(tr("Invalid or missing room id in the game link")); + return; + } + + ok = false; + ctx->gameId = query.queryItemValue("gameid").toInt(&ok); + + if (!ok) { + showError(tr("Invalid or missing game id in the game link")); + return; + } + + const QString gameDescription = query.queryItemValue("game", QUrl::FullyDecoded); + const QString message = generateJoinGameMessage(*ctx, gameDescription); + + const QMessageBox::StandardButton answer = QMessageBox::question( + mainWindow, tr("Join game"), message, QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes); + if (answer != QMessageBox::Yes) { + return; + } + + // The join game intent owns the context and the credential lookup; once the + // chain finishes (or fails) it deletes the whole tree. + ContextConnectToServer *serverContext = &ctx->roomContext.serverContext; + auto joinGameIntent = + new IntentJoinServerGame(mainWindow->getTabSupervisor(), mainWindow->getRemoteClient(), std::move(ctx)); + joinGameIntent->setParent(this); + + auto getLoginCredentialsIntent = new IntentGetLoginCredentials(serverContext); + getLoginCredentialsIntent->setParent(joinGameIntent); + + connect(getLoginCredentialsIntent, &Intent::finished, joinGameIntent, &Intent::execute); + connect(getLoginCredentialsIntent, &Intent::failed, joinGameIntent, &Intent::failed); + connect(joinGameIntent, &Intent::failed, this, [showError](const QString &reason) { showError(reason); }); + + getLoginCredentialsIntent->execute(); +} + +QString IntentUrlParser::generateJoinGameMessage(const ContextJoinGame &context, const QString &gameDescription) +{ + const QString hostname = context.roomContext.serverContext.hostname; + const QString port = context.roomContext.serverContext.port; + const int roomId = context.roomContext.roomId; + const int gameId = context.gameId; + const QString server = QStringLiteral("%1:%2").arg(hostname, port); + + // Prefer the room name over the raw numeric id: it means something to the + // user. The name is only known when we are already connected to the same + // server and sitting in that room — otherwise fall back to a plain prompt. + AbstractClient *client = mainWindow->getTabSupervisor()->getClient(); + const bool sameServer = client != nullptr && client->getStatus() == StatusLoggedIn && + hostname.compare(client->serverName(), Qt::CaseInsensitive) == 0 && + QString::number(client->serverPort()) == port; + TabRoom *roomTab = sameServer ? mainWindow->getTabSupervisor()->getRoomTabs().value(roomId) : nullptr; + + const QString gameIdStr = QString::number(gameId); + // Links built by newer clients embed the game description ("game" item); + // restate it in the confirm so it matches what the chat anchor showed. + // Unknown query items are ignored, so old links without it keep working. + // The multi-arg .arg() overloads replace in a single pass, so a description + // containing "%…" cannot corrupt later placeholders. + // FullyDecoded undoes every %XX escape and must match the chat anchor's + // decode mode, so a description containing "%" reads identically in both. + if (gameDescription.isEmpty()) { + return roomTab ? tr("Join game #%1 in \"%2\" on %3?").arg(gameIdStr, roomTab->getRoomName(), server) + : tr("Join game #%1 on %2?").arg(gameIdStr, server); + } + + return roomTab ? tr("Join game \"%1\" (#%2) in \"%3\" on %4?") + .arg(gameDescription, gameIdStr, roomTab->getRoomName(), server) + : tr("Join game \"%1\" (#%2) on %3?").arg(gameDescription, gameIdStr, server); +} diff --git a/cockatrice/src/interface/intents/url_parser.h b/cockatrice/src/interface/intents/url_parser.h new file mode 100644 index 000000000..6d705e013 --- /dev/null +++ b/cockatrice/src/interface/intents/url_parser.h @@ -0,0 +1,23 @@ +#ifndef COCKATRICE_URL_PARSER_H +#define COCKATRICE_URL_PARSER_H +#include +#include + +class MainWindow; +struct ContextJoinGame; +class IntentUrlParser : public QObject +{ + Q_OBJECT + +public: + IntentUrlParser(QObject *parent, MainWindow *mainWindow); + void handle(const QString &urlStr); + void handleJoinGame(const QUrlQuery &query); + +private: + QString generateJoinGameMessage(const ContextJoinGame &context, const QString &gameDescription); + + MainWindow *mainWindow; +}; + +#endif // COCKATRICE_URL_PARSER_H diff --git a/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp b/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp index 1b9be1dd4..d5a168708 100644 --- a/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp +++ b/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp @@ -8,12 +8,12 @@ #include #include #include +#include +#include #include -#include #include #include #include -#include #include PaletteEditorDialog::PaletteEditorDialog(const QString &_themeDirPath, const QString &_themeName, QWidget *parent) @@ -22,6 +22,11 @@ PaletteEditorDialog::PaletteEditorDialog(const QString &_themeDirPath, const QSt setMinimumSize(740, 220); setupUi(); + // Resolve a writable directory for saving. Built-in (Default / Fusion) and + // other read-only theme directories must be customised in the user-writable + // themes directory; otherwise the write would fail or be lost on upgrade. + saveDir = ThemeManager::writableThemeDir(themeName); + // Load both scheme configs upfront so switching is instant loadSchemes(); @@ -31,7 +36,9 @@ PaletteEditorDialog::PaletteEditorDialog(const QString &_themeDirPath, const QSt schemeComboBox->setCurrentText(loadedScheme); schemeComboBox->blockSignals(false); + paletteGrid->blockSignals(true); paletteGrid->loadPalette(workingConfig[loadedScheme]); + paletteGrid->blockSignals(false); seedAccentFromScheme(loadedScheme); retranslateUi(); @@ -124,8 +131,7 @@ void PaletteEditorDialog::setupUi() buttonBox = new QDialogButtonBox; resetBtn = buttonBox->addButton(tr("Reset"), QDialogButtonBox::ResetRole); - applyBtn = buttonBox->addButton(tr("Apply"), QDialogButtonBox::ApplyRole); - saveBtn = buttonBox->addButton(tr("Save && Apply"), QDialogButtonBox::AcceptRole); + saveBtn = buttonBox->addButton(tr("Save"), QDialogButtonBox::AcceptRole); closeBtn = buttonBox->addButton(QDialogButtonBox::Close); footerLayout->addWidget(revertButton); @@ -135,10 +141,14 @@ void PaletteEditorDialog::setupUi() // Connections connect(schemeComboBox, &QComboBox::currentTextChanged, this, &PaletteEditorDialog::onSchemeChanged); - connect(quickSetupPanel, &QuickSetupPanel::generateRequested, this, &PaletteEditorDialog::onGenerateFromAccent); + autoApplyTimer = new QTimer(this); + autoApplyTimer->setSingleShot(true); + autoApplyTimer->setInterval(150); + connect(autoApplyTimer, &QTimer::timeout, this, &PaletteEditorDialog::onApply); + connect(quickSetupPanel, &QuickSetupPanel::valueChanged, this, &PaletteEditorDialog::onGenerateFromAccent); + connect(paletteGrid, &PaletteGridWidget::paletteChanged, this, [this] { autoApplyTimer->start(); }); connect(revertButton, &QPushButton::clicked, this, &PaletteEditorDialog::onRevertToDefault); connect(resetBtn, &QPushButton::clicked, this, &PaletteEditorDialog::onReset); - connect(applyBtn, &QPushButton::clicked, this, &PaletteEditorDialog::onApply); connect(saveBtn, &QPushButton::clicked, this, &PaletteEditorDialog::onSave); connect(closeBtn, &QPushButton::clicked, this, &QDialog::reject); @@ -162,15 +172,8 @@ void PaletteEditorDialog::retranslateUi() setWindowTitle(tr("Palette Editor — %1").arg(themeName)); titleLabel->setText(tr("Palette Editor  ·  %1").arg(themeName)); - // Revert button only makes sense when the theme ships default palette files - const bool hasDefault = PaletteConfig::fromDefault(themeDirPath, "Light").hasPalette() || - PaletteConfig::fromDefault(themeDirPath, "Dark").hasPalette(); - revertButton->setEnabled(hasDefault); - if (!hasDefault) { - revertButton->setToolTip(tr("This theme ships no default palette files")); - } else { - revertButton->setToolTip(tr("Replace current colours with the theme author's defaults")); - } + revertButton->setToolTip( + tr("Delete this scheme's custom palette and revert to the theme default (or the application palette)")); schemeComboBox->setToolTip(tr("Switch between the light and dark palette files")); editingLabel->setText(tr("Editing:")); @@ -179,15 +182,13 @@ void PaletteEditorDialog::retranslateUi() revertButton->setText(tr("↺ Revert to theme default")); resetBtn->setText(tr("Reset")); - applyBtn->setText(tr("Apply")); - saveBtn->setText(tr("Save && Apply")); + saveBtn->setText(tr("Save")); resetBtn->setToolTip(tr("Discard unsaved edits and restore the last saved palette")); - applyBtn->setToolTip(tr("Preview this palette without saving to disk")); saveBtn->setToolTip(tr("Write palette-%1.toml and reload the theme").arg(loadedScheme.toLower())); - if (themeDirPath.isEmpty()) { + if (saveDir.isEmpty() || !ThemeManager::isDirReallyWritable(saveDir)) { saveBtn->setEnabled(false); - saveBtn->setToolTip(tr("Cannot save: this theme has no directory on disk")); + saveBtn->setToolTip(tr("Cannot save: this theme has no writable directory")); } } @@ -198,7 +199,7 @@ void PaletteEditorDialog::loadSchemes() PaletteConfig cfg = PaletteConfig::fromScheme(themeDirPath, scheme); if (!cfg.hasPalette()) { - cfg = PaletteConfig::fromDefault(themeDirPath, scheme); + cfg = ThemeManager::loadDefaultPaletteConfig(themeDirPath, themeName, scheme); } if (!cfg.hasPalette()) { @@ -235,7 +236,6 @@ void PaletteEditorDialog::onSchemeChanged(const QString &scheme) loadedScheme = scheme; paletteGrid->loadPalette(workingConfig.value(scheme)); seedAccentFromScheme(scheme); - onApply(); } void PaletteEditorDialog::onGenerateFromAccent(const QColor &accent, int intensity) @@ -256,20 +256,48 @@ void PaletteEditorDialog::onSave() return; } - PaletteConfig cfg = paletteGrid->currentPaletteConfig(); + // Snapshot the currently displayed scheme so unsaved edits are not lost. + workingConfig[loadedScheme] = paletteGrid->currentPaletteConfig(); - if (!ThemeManager::savePaletteConfig(themeDirPath, loadedScheme, cfg)) { - QMessageBox::warning(this, tr("Save failed"), - tr("Could not write %1 to:\n%2").arg(PaletteConfig::fileName(loadedScheme), themeDirPath)); - return; + // Persist every scheme that changed, not just the one on screen. Each scheme + // has its own file, so edits to the non-active scheme would otherwise be + // silently discarded when the dialog closes. + // + // Save the loaded scheme last so commitPalette's global colour-scheme + // update (ThemeConfig::colorScheme) points at the active scheme. + for (auto it = workingConfig.begin(); it != workingConfig.end(); ++it) { + if (it.key() == loadedScheme) { + continue; + } + if (it.value() == savedConfig.value(it.key())) { + continue; + } + if (!ThemeManager::commitPalette(saveDir, it.key(), it.value())) { + QMessageBox::warning(this, tr("Save failed"), + tr("Could not write %1 to:\n%2").arg(PaletteConfig::fileName(it.key()), saveDir)); + return; + } } - ThemeConfig globalCfg = ThemeConfig::fromThemeDir(themeDirPath); - globalCfg.colorScheme = loadedScheme; - globalCfg.save(themeDirPath); + // Commit the active scheme last so the global colour scheme matches. + if (workingConfig[loadedScheme] != savedConfig.value(loadedScheme)) { + if (!ThemeManager::commitPalette(saveDir, loadedScheme, workingConfig[loadedScheme])) { + QMessageBox::warning(this, tr("Save failed"), + tr("Could not write %1 to:\n%2").arg(PaletteConfig::fileName(loadedScheme), saveDir)); + return; + } + } else { + // No palette change but scheme may have switched -- still update global config. + ThemeConfig globalCfg = ThemeConfig::fromThemeDir(saveDir); + globalCfg.colorScheme = loadedScheme; + globalCfg.save(saveDir); + } + + // Keep the saved snapshot in sync so Reset behaves correctly afterwards. + for (auto it = workingConfig.begin(); it != workingConfig.end(); ++it) { + savedConfig[it.key()] = it.value(); + } - savedConfig[loadedScheme] = cfg; - workingConfig[loadedScheme] = cfg; themeManager->reloadCurrentTheme(); accept(); } @@ -278,18 +306,40 @@ void PaletteEditorDialog::onReset() { workingConfig[loadedScheme] = savedConfig[loadedScheme]; paletteGrid->loadPalette(savedConfig[loadedScheme]); + seedAccentFromScheme(loadedScheme); } void PaletteEditorDialog::onRevertToDefault() { - PaletteConfig def = PaletteConfig::fromDefault(themeDirPath, loadedScheme); + // Delete this scheme's custom palette file so the theme falls back to its + // default (or, when it ships none, the application palette). + // Note: shipped defaults use palette-default-.toml so this only + // removes user-written custom palette files; the theme author's defaults + // are left untouched. + QFile::remove(QDir(saveDir).absoluteFilePath(PaletteConfig::fileName(loadedScheme))); + + // Reload the live theme so the revert takes effect immediately. + themeManager->reloadCurrentTheme(); + + // Reflect the resolved palette (theme default, else current app palette) in + // the editor so it no longer shows the deleted custom colours. + PaletteConfig def = ThemeManager::loadDefaultPaletteConfig(themeDirPath, themeName, loadedScheme); if (!def.hasPalette()) { - QMessageBox::information(this, tr("No default found"), - tr("No default palette file found for the \"%1\" scheme.").arg(loadedScheme)); - return; + const QPalette appPal = qApp->palette(); + for (auto group : {QPalette::Active, QPalette::Disabled, QPalette::Inactive}) { + for (int i = 0; i < QPalette::NColorRoles; ++i) { + auto role = static_cast(i); + if (role != QPalette::NoRole) { + def.colors[group][role] = appPal.color(group, role); + } + } + } } + + savedConfig[loadedScheme] = def; workingConfig[loadedScheme] = def; paletteGrid->loadPalette(def); + seedAccentFromScheme(loadedScheme); } void PaletteEditorDialog::changeEvent(QEvent *e) diff --git a/cockatrice/src/interface/palette_editor/palette_editor_dialog.h b/cockatrice/src/interface/palette_editor/palette_editor_dialog.h index cec4c1700..953902c06 100644 --- a/cockatrice/src/interface/palette_editor/palette_editor_dialog.h +++ b/cockatrice/src/interface/palette_editor/palette_editor_dialog.h @@ -7,6 +7,8 @@ #include #include +class QTimer; + class QLabel; class QComboBox; class QDialogButtonBox; @@ -48,7 +50,6 @@ private: QComboBox *schemeComboBox = nullptr; QDialogButtonBox *buttonBox = nullptr; QPushButton *resetBtn = nullptr; - QPushButton *applyBtn = nullptr; QPushButton *saveBtn = nullptr; QPushButton *closeBtn = nullptr; QPushButton *revertButton = nullptr; @@ -57,10 +58,15 @@ private: QString themeDirPath; QString themeName; QString loadedScheme; + // Directory writes are directed to; may differ from themeDirPath when the + // latter is read-only (e.g. a built-in / system theme directory). + QString saveDir; QMap workingConfig; QMap savedConfig; + QTimer *autoApplyTimer = nullptr; + protected: void changeEvent(QEvent *e) override; }; diff --git a/cockatrice/src/interface/palette_editor/palette_generator.cpp b/cockatrice/src/interface/palette_editor/palette_generator.cpp index d30dd14f1..822e57250 100644 --- a/cockatrice/src/interface/palette_editor/palette_generator.cpp +++ b/cockatrice/src/interface/palette_editor/palette_generator.cpp @@ -150,6 +150,17 @@ PaletteConfig fromAccent(const QColor &accent, int intensity, const QString &sch cfg.colors[CG::Disabled][CR::HighlightedText] = disText; cfg.colors[CG::Inactive][CR::HighlightedText] = dark ? Qt::white : Qt::black; + // Accent: same primary hue as Highlight, so palettes derived from a + // QuickSetup accent always carry a matching Accent role. +#if QT_VERSION >= QT_VERSION_CHECK(6, 6, 0) + set3(CR::Accent, hl, disText, hl); +#endif + + // Application role colors: Strong tracks the primary accent, while Soft is + // the lightened, desaturated companion used for button-gradient highlights. + cfg.appColors[AppColor::AccentStrong] = hl; + cfg.appColors[AppColor::AccentSoft] = hsl(accent.lightness() + 60, qRound(accent.hslSaturation() * 70 / 100.0)); + // BrightText QColor bright; if (achromatic) { diff --git a/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp b/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp index e4ac5a16f..97d28b731 100644 --- a/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp +++ b/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp @@ -1,5 +1,7 @@ #include "palette_grid_widget.h" +#include "../theme_manager.h" + #include #include #include @@ -45,6 +47,11 @@ static const QMap ROLE_DESCRIPTIONS = { {QPalette::Shadow, QT_TR_NOOP("Very dark shadow colour")}, }; +static const QMap APP_ROLE_DESCRIPTIONS = { + {AppColor::AccentStrong, QT_TR_NOOP("Vivid primary accent (e.g. home-tab button gradient start)")}, + {AppColor::AccentSoft, QT_TR_NOOP("Lightened, desaturated accent (e.g. home-tab button gradient end)")}, +}; + PaletteGridWidget::PaletteGridWidget(QWidget *parent) : QWidget(parent) { scroll = new QScrollArea(this); @@ -117,10 +124,51 @@ void PaletteGridWidget::buildGrid(QWidget *host) for (int col = 0; col < 3; ++col) { auto group = ALL_GROUPS[col]; auto *btn = new ColorButton(host); + connect(btn, &ColorButton::colorChanged, this, [this] { emit paletteChanged(); }); colorButtons[group][role] = btn; grid->addWidget(btn, row + 1, col + 1, Qt::AlignHCenter | Qt::AlignVCenter); } } + + // Application color section: one ColorButton per role below the role grid. + // These are not tied to a color group, so a single button spans the row. + QMetaEnum appEnum = QMetaEnum::fromType(); + + const int appHeaderRow = roles.size() + 1; + + auto *appHeader = new QLabel(tr("App colors"), host); + appHeader->setToolTip(tr("Application-specific colors layered on top of the Qt palette")); + QFont appHeaderFont = appHeader->font(); + appHeaderFont.setBold(true); + appHeader->setFont(appHeaderFont); + appHeader->setAutoFillBackground(true); + appHeader->setContentsMargins(4, 4, 4, 4); + grid->addWidget(appHeader, appHeaderRow, 0, 1, 4); + headerLabels.append(appHeader); + + for (int i = 0; i < appEnum.keyCount(); ++i) { + auto role = static_cast(appEnum.value(i)); + const int row = appHeaderRow + 1 + i; + + if (i % 2 == 0) { + for (int col = 0; col < 4; ++col) { + auto *shade = new QWidget(host); + shade->setAutoFillBackground(true); + grid->addWidget(shade, row, col); + rowShadeWidgets.push_back(shade); + } + } + + auto *label = new QLabel(QString(appEnum.valueToKey(role)), host); + label->setToolTip(APP_ROLE_DESCRIPTIONS.value(role, {})); + label->setContentsMargins(4, 2, 8, 2); + grid->addWidget(label, row, 0); + + auto *btn = new ColorButton(host); + connect(btn, &ColorButton::colorChanged, this, [this] { emit paletteChanged(); }); + appColorButtons[role] = btn; + grid->addWidget(btn, row, 1, Qt::AlignHCenter | Qt::AlignVCenter); + } } void PaletteGridWidget::changeEvent(QEvent *e) @@ -165,6 +213,16 @@ void PaletteGridWidget::loadPalette(const PaletteConfig &cfg) colorButtons[group][role]->setColor(color); } } + + QMetaEnum appEnum = QMetaEnum::fromType(); + for (int i = 0; i < appEnum.keyCount(); ++i) { + auto role = static_cast(appEnum.value(i)); + QColor color = cfg.appColors.value(role); + if (!color.isValid()) { + color = themeManager->appColor(role); + } + appColorButtons[role]->setColor(color); + } } PaletteConfig PaletteGridWidget::currentPaletteConfig() const @@ -175,5 +233,12 @@ PaletteConfig PaletteGridWidget::currentPaletteConfig() const cfg.colors[group][role] = colorButtons[group][role]->getColor(); } } + + QMetaEnum appEnum = QMetaEnum::fromType(); + for (int i = 0; i < appEnum.keyCount(); ++i) { + auto role = static_cast(appEnum.value(i)); + cfg.appColors[role] = appColorButtons[role]->getColor(); + } + return cfg; } \ No newline at end of file diff --git a/cockatrice/src/interface/palette_editor/palette_grid_widget.h b/cockatrice/src/interface/palette_editor/palette_grid_widget.h index 1d85f1ac8..77cbf1c62 100644 --- a/cockatrice/src/interface/palette_editor/palette_grid_widget.h +++ b/cockatrice/src/interface/palette_editor/palette_grid_widget.h @@ -22,12 +22,16 @@ public: void loadPalette(const PaletteConfig &cfg); PaletteConfig currentPaletteConfig() const; +signals: + void paletteChanged(); + private: void buildGrid(QWidget *host); void changeEvent(QEvent *e); void refreshChromePalettes(); QMap> colorButtons; + QMap appColorButtons; QScrollArea *scroll; QWidget *gridHost; QVBoxLayout *layout; diff --git a/cockatrice/src/interface/palette_editor/quick_setup_panel.cpp b/cockatrice/src/interface/palette_editor/quick_setup_panel.cpp index fe3af9b52..85098bde5 100644 --- a/cockatrice/src/interface/palette_editor/quick_setup_panel.cpp +++ b/cockatrice/src/interface/palette_editor/quick_setup_panel.cpp @@ -3,7 +3,6 @@ #include #include #include -#include #include QuickSetupPanel::QuickSetupPanel(QWidget *parent) : QWidget(parent) @@ -41,8 +40,6 @@ QuickSetupPanel::QuickSetupPanel(QWidget *parent) : QWidget(parent) intensityPercentageLabel->setFixedWidth(34); intensityPercentageLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter); - generateButton = new QPushButton(this); - layout->addWidget(heading); layout->addSpacing(6); layout->addWidget(accentLabel); @@ -54,12 +51,13 @@ QuickSetupPanel::QuickSetupPanel(QWidget *parent) : QWidget(parent) layout->addWidget(labelHigh); layout->addWidget(intensityPercentageLabel); layout->addStretch(); - layout->addWidget(generateButton); - connect(intensitySlider, &QSlider::valueChanged, this, - [this](int v) { intensityPercentageLabel->setText(tr("%1%").arg(v)); }); - connect(generateButton, &QPushButton::clicked, this, - [this] { emit generateRequested(accentButton->getColor(), intensitySlider->value()); }); + connect(intensitySlider, &QSlider::valueChanged, this, [this](int v) { + intensityPercentageLabel->setText(tr("%1%").arg(v)); + emit valueChanged(accentButton->getColor(), v); + }); + connect(accentButton, &ColorButton::colorChanged, this, + [this](const QColor &c) { emit valueChanged(c, intensitySlider->value()); }); retranslateUi(); } @@ -77,10 +75,6 @@ void QuickSetupPanel::retranslateUi() "30–70 Accented — buttons, tooltips, and borders join in\n" "70–100 Full colour — backgrounds, everything")); intensityPercentageLabel->setText(tr("70%")); - - generateButton->setText(tr("Generate ↓")); - generateButton->setToolTip(tr("Derive all palette roles from the accent colour above.\n" - "Fine-tune individual colours in the grid afterwards.")); } QColor QuickSetupPanel::accentColor() const @@ -95,5 +89,7 @@ int QuickSetupPanel::intensity() const void QuickSetupPanel::setAccentColor(const QColor &c) { + accentButton->blockSignals(true); accentButton->setColor(c); -} \ No newline at end of file + accentButton->blockSignals(false); +} diff --git a/cockatrice/src/interface/palette_editor/quick_setup_panel.h b/cockatrice/src/interface/palette_editor/quick_setup_panel.h index dddd0aaa3..c83e27cf2 100644 --- a/cockatrice/src/interface/palette_editor/quick_setup_panel.h +++ b/cockatrice/src/interface/palette_editor/quick_setup_panel.h @@ -5,7 +5,6 @@ #include -class QPushButton; class QHBoxLayout; class QLabel; class QSlider; @@ -16,12 +15,11 @@ class QSlider; * * The panel contains: * - an accent color picker, - * - an intensity slider, - * - and a generate button. + * - an intensity slider. * - * When the user clicks the generate button, the panel emits - * generateRequested() with the currently selected accent color - * and intensity value. + * Whenever either value changes the panel emits valueChanged() with + * the current accent colour and intensity, which the parent dialog + * uses to auto-apply the generated palette. * * Typically used together with PaletteGenerator::fromAccent() * to quickly generate color schemes from a chosen accent color. @@ -71,12 +69,12 @@ public: signals: /** - * @brief Emitted when the user requests palette generation. + * @brief Emitted whenever the accent colour or intensity changes. * - * @param accent The selected accent color. - * @param intensity The selected intensity value. + * The parent dialog consumes this to auto-apply the generated palette + * without requiring an explicit Generate click. */ - void generateRequested(QColor accent, int intensity); + void valueChanged(QColor accent, int intensity); private: QHBoxLayout *layout; @@ -88,7 +86,6 @@ private: QLabel *labelHigh; QSlider *intensitySlider; QLabel *intensityPercentageLabel; - QPushButton *generateButton; }; -#endif // COCKATRICE_QUICK_SETUP_PANEL_H \ No newline at end of file +#endif // COCKATRICE_QUICK_SETUP_PANEL_H diff --git a/cockatrice/src/interface/pixel_map_generator.cpp b/cockatrice/src/interface/pixel_map_generator.cpp index d3b0252a6..b70dc576f 100644 --- a/cockatrice/src/interface/pixel_map_generator.cpp +++ b/cockatrice/src/interface/pixel_map_generator.cpp @@ -1,8 +1,11 @@ #include "pixel_map_generator.h" +#include "theme_manager.h" + #include #include #include +#include #include #include #include @@ -14,6 +17,32 @@ #define DEFAULT_COLOR_MODERATOR_RIGHT "#000000"; #define DEFAULT_COLOR_ADMIN "#ff2701"; +/** + * Clamps an svg render size so that rendering does not exceed a multiple of the requested size. + * + * Rendering at the full native size of an svg just to scale it down afterwards wastes memory, + * and canvases with extreme coordinates can exceed Qt's rasterizer coordinate limit which makes + * Qt silently drop shapes from the rendered image. + * + * @param renderSize The size the svg would be rendered at. + * @param requestedSize The size that was actually requested. + * + * @return A size with the aspect ratio of renderSize whose longest side is at most four times + * the longest side of requestedSize. + */ +static QSize capRenderSize(const QSize &renderSize, const QSize &requestedSize) +{ + const int longestRequestedSide = qMax(requestedSize.width(), requestedSize.height()); + if (longestRequestedSide <= 0) { + return renderSize; + } + + const int longestRenderSide = qMax(renderSize.width(), renderSize.height()); + const qreal scale = qMin(1.0, static_cast(longestRequestedSide * 4) / longestRenderSide); + return QSize(qMax(1, static_cast(renderSize.width() * scale)), + qMax(1, static_cast(renderSize.height() * scale))); +} + /** * Loads in an svg from file and scales it without affecting image quality. * @@ -35,6 +64,9 @@ static QPixmap loadSvg(const QString &svgPath, const QSize &size, bool expandOnl // If expandOnly, make sure the pixmap is at least as large as the svg, so that we don't lose any detail. // QIcon.pixmap(size) will automatically scale down the image, but it won't scale it up. QSize pixmapSize = expandOnly ? svgRenderer.defaultSize().expandedTo(size) : size; + if (expandOnly) { + pixmapSize = capRenderSize(pixmapSize, size); + } QPixmap pix(pixmapSize); pix.fill(Qt::transparent); @@ -52,7 +84,13 @@ static QPixmap loadSvg(const QString &svgPath, const QSize &size, bool expandOnl /** * Try to load path image from non-SVG formats, otherwise fall back to SVG. * This is to allow custom themes to support non-SVG format type overrides, since SVG requires custom loading. - * @param path The path to the file, with no file extension. File formats will be automatically detected. + * + * The path may already carry the resolved file extension (e.g. via + * ThemeManager::assetPath); such paths are loaded directly. Otherwise a + * format-agnostic lookup probes png, jpg and finally svg. + * + * @param path The path to the file, with no file extension unless the caller + * already resolved it. File formats will be automatically detected. * @param size The desired size of the pixmap. * @param expandOnly If true, then keep the size of the initial pixmap to at least the size (Only relevant if SVG). * @@ -60,6 +98,19 @@ static QPixmap loadSvg(const QString &svgPath, const QSize &size, bool expandOnl */ static QPixmap tryLoadImage(const QString &path, const QSize &size, bool expandOnly = false) { + if (path.endsWith(QLatin1String(".svg"), Qt::CaseInsensitive)) { + return loadSvg(path, size, expandOnly); + } + if (path.endsWith(QLatin1String(".png"), Qt::CaseInsensitive) || + path.endsWith(QLatin1String(".jpg"), Qt::CaseInsensitive) || + path.endsWith(QLatin1String(".jpeg"), Qt::CaseInsensitive)) { + QPixmap pix(path); + if (!pix.isNull()) { + return pix.scaled(size, Qt::KeepAspectRatio, Qt::SmoothTransformation); + } + return {}; + } + const auto formats = {"png", "jpg"}; QPixmap returnPixmap; @@ -81,7 +132,8 @@ QPixmap PhasePixmapGenerator::generatePixmap(int height, QString name) return pmCache.value(key); } - QPixmap pixmap = tryLoadImage("theme:phases/" + name, QSize(height, height)); + QPixmap pixmap = tryLoadImage(QStringLiteral("theme:") + themeManager->assetPath(QStringLiteral("phases/") + name), + QSize(height, height)); pmCache.insert(key, pixmap); return pixmap; @@ -247,7 +299,9 @@ static QIcon loadAndColorSvg(const QString &iconPath, QSvgRenderer svgRenderer(doc.toByteArray()); - QPixmap pix(svgRenderer.defaultSize().expandedTo(QSize(minSize, minSize))); + const QSize pixmapSize = + capRenderSize(svgRenderer.defaultSize().expandedTo(QSize(minSize, minSize)), QSize(minSize, minSize)); + QPixmap pix(pixmapSize); pix.fill(Qt::transparent); QPainter pixPainter(&pix); @@ -307,6 +361,10 @@ static QString getIconType(const bool isBuddy, const UserLevelFlags &userLevelFl return "pawn_judge"; } + if (userLevelFlags.testFlag(ServerInfo_User::IsDeveloper)) { + return "pawn_dev"; + } + if (!privLevel.isEmpty() && privLevel.toLower() != "none") { return QString("pawn_%1").arg(privLevel.toLower()); } @@ -364,7 +422,8 @@ QPixmap LockPixmapGenerator::generatePixmap(int height) return pmCache.value(key); } - QPixmap pixmap = tryLoadImage("theme:icons/lock", QSize(height, height), true); + QPixmap pixmap = tryLoadImage(QStringLiteral("theme:") + themeManager->assetPath(QStringLiteral("icons/lock")), + QSize(height, height), true); pmCache.insert(key, pixmap); return pixmap; } @@ -379,7 +438,8 @@ QPixmap DropdownIconPixmapGenerator::generatePixmap(int height, bool expanded) } QString name = expanded ? "dropdown_expanded" : "dropdown_collapsed"; - QPixmap pixmap = tryLoadImage("theme:icons/" + name, QSize(height, height), true); + QPixmap pixmap = tryLoadImage(QStringLiteral("theme:") + themeManager->assetPath(QStringLiteral("icons/") + name), + QSize(height, height), true); pmCache.insert(key, pixmap); return pixmap; @@ -387,8 +447,66 @@ QPixmap DropdownIconPixmapGenerator::generatePixmap(int height, bool expanded) QMap DropdownIconPixmapGenerator::pmCache; +namespace +{ +/// Longest side mana symbols are rendered at before being scaled to their final size. +constexpr int MASTER_ICON_SIZE = 128; + +QString manaSymbolCacheKey(const QString &symbol, const QSize &size) +{ + return symbol + QLatin1Char('|') + QString::number(size.width()) + QLatin1Char('x') + + QString::number(size.height()); +} +} // namespace + +const QPixmap &ManaSymbolPixmapGenerator::masterIcon(const QString &symbol) +{ + auto it = masterCache.constFind(symbol); + if (it != masterCache.constEnd()) { + return it.value(); + } + + QImageReader reader("theme:icons/mana/" + symbol); + QSize sourceSize = reader.size(); + if (!sourceSize.isEmpty()) { + sourceSize.scale(QSize(MASTER_ICON_SIZE, MASTER_ICON_SIZE), Qt::KeepAspectRatio); + reader.setScaledSize(sourceSize); + } + const QPixmap rendered = QPixmap::fromImageReader(&reader); + + return masterCache.insert(symbol, rendered).value(); +} + +QPixmap ManaSymbolPixmapGenerator::generatePixmap(const QString &symbol, const QSize &size) +{ + const QString key = manaSymbolCacheKey(symbol, size); + auto it = scaledCache.constFind(key); + if (it != scaledCache.constEnd()) { + return it.value(); + } + + const QPixmap &icon = masterIcon(symbol); + if (icon.isNull()) { + return {}; + } + + QPixmap scaled = icon.scaled(size, Qt::KeepAspectRatio, Qt::SmoothTransformation); + scaledCache.insert(key, scaled); + return scaled; +} + +QHash ManaSymbolPixmapGenerator::masterCache; +QHash ManaSymbolPixmapGenerator::scaledCache; + QPixmap loadColorAdjustedPixmap(const QString &name) { + // Prefer an authored scheme-qualified variant when one exists for this asset. + const QString variant = themeManager->schemeVariantPath(QStringView(name).mid(QStringLiteral("theme:").size())); + if (!variant.isEmpty()) { + return QPixmap(QStringLiteral("theme:") + variant); + } + + // Legacy fallback: runtime-invert for dark mode when no authored variant. if (qApp->palette().windowText().color().lightness() > 200) { QImage img(name); img.invertPixels(); @@ -399,3 +517,21 @@ QPixmap loadColorAdjustedPixmap(const QString &name) return QPixmap(name); } } + +QPixmap themePixmap(QStringView prefix) +{ + const QString resolved = themeManager->assetPath(prefix); + return QPixmap(QStringLiteral("theme:") + resolved); +} + +void clearPixmapGeneratorCaches() +{ + PhasePixmapGenerator::clear(); + CounterPixmapGenerator::clear(); + PingPixmapGenerator::clear(); + CountryPixmapGenerator::clear(); + UserLevelPixmapGenerator::clear(); + LockPixmapGenerator::clear(); + DropdownIconPixmapGenerator::clear(); + ManaSymbolPixmapGenerator::clear(); +} diff --git a/cockatrice/src/interface/pixel_map_generator.h b/cockatrice/src/interface/pixel_map_generator.h index 22f44d8db..b6e822fd9 100644 --- a/cockatrice/src/interface/pixel_map_generator.h +++ b/cockatrice/src/interface/pixel_map_generator.h @@ -7,6 +7,7 @@ #ifndef PIXMAPGENERATOR_H #define PIXMAPGENERATOR_H +#include #include #include #include @@ -125,6 +126,45 @@ public: } }; +class ManaSymbolPixmapGenerator +{ +private: + static QHash masterCache; + static QHash scaledCache; + + /** + * @brief Renders \a symbol once at a fixed moderate size, so repeated scalings never + * re-rasterize the source file (SVG sources can be very expensive to rasterize). + */ + static const QPixmap &masterIcon(const QString &symbol); + +public: + /** + * @brief Returns a smooth-scaled rendering of the given mana symbol icon. + * + * Results are shared between all callers via a process-wide cache keyed by symbol + * and size, so scaling work is done once per distinct combination instead of once + * per widget creation or resize. + */ + static QPixmap generatePixmap(const QString &symbol, const QSize &size); + static void clear() + { + masterCache.clear(); + scaledCache.clear(); + } +}; + QPixmap loadColorAdjustedPixmap(const QString &name); +// Loads a "theme:" asset (with no file extension in prefix), preferring the +// scheme-qualified variant (prefix-dark / prefix-light, resolved via +// ThemeManager::assetPath) and falling back to the plain asset. Callers load +// the returned path directly. Use for scheme-sensitive pixmaps like +// backgrounds, the card back, and the app logo. +QPixmap themePixmap(QStringView prefix); + +// Clears every PixmapGenerator's static cache so scheme variants are +// re-resolved when the active theme or color scheme changes. +void clearPixmapGeneratorCaches(); + #endif diff --git a/cockatrice/src/interface/theme_config.cpp b/cockatrice/src/interface/theme_config.cpp index b79c91265..8293a82cf 100644 --- a/cockatrice/src/interface/theme_config.cpp +++ b/cockatrice/src/interface/theme_config.cpp @@ -16,7 +16,7 @@ QString ThemeConfig::toIni() const out += "[Appearance]\n"; out += QString("ColorScheme = %1\n").arg(colorScheme.isEmpty() ? "System" : colorScheme); out += "\n[Style]\n"; - out += QString("Name = %1\n").arg(styleName.isEmpty() ? "Default" : styleName); + out += QString("Name = %1\n").arg(styleName.isEmpty() ? "System" : styleName); return out; } @@ -96,7 +96,7 @@ bool ThemeConfig::save(const QString &themeDirPath) const bool PaletteConfig::hasPalette() const { - return !colors.isEmpty(); + return !colors.isEmpty() || !appColors.isEmpty(); } QString PaletteConfig::toToml() const @@ -133,6 +133,24 @@ QString PaletteConfig::toToml() const out += "\n"; } + if (!appColors.isEmpty()) { + QMetaEnum appEnum = QMetaEnum::fromType(); + + out += "[AppColors]\n"; + + for (auto it = appColors.cbegin(); it != appColors.cend(); ++it) { + const char *roleName = appEnum.valueToKey(it.key()); + + if (!roleName) { + continue; + } + + out += QString("%1 = %2\n").arg(QString(roleName), -20).arg(it.value().name(QColor::HexArgb)); + } + + out += "\n"; + } + return out; } @@ -152,6 +170,7 @@ PaletteConfig PaletteConfig::fromFile(const QString &filePath) } QMetaEnum roleEnum = QMetaEnum::fromType(); + QMetaEnum appEnum = QMetaEnum::fromType(); QString currentSection; QPalette::ColorGroup currentGroup = QPalette::Active; @@ -202,6 +221,26 @@ PaletteConfig PaletteConfig::fromFile(const QString &filePath) } } + QColor color(value); + + if (!color.isValid()) { + continue; + } + + if (currentSection.compare("AppColors", Qt::CaseInsensitive) == 0) { + if (key.startsWith("AppColor::")) { + key = key.mid(10); + } + + int appRoleInt = appEnum.keyToValue(key.toUtf8().constData()); + + if (appRoleInt >= 0) { + cfg.appColors[static_cast(appRoleInt)] = color; + } + + continue; + } + if (!currentSection.startsWith("Palette", Qt::CaseInsensitive)) { continue; } @@ -216,11 +255,7 @@ PaletteConfig PaletteConfig::fromFile(const QString &filePath) continue; } - QColor color(value); - - if (color.isValid()) { - cfg.colors[currentGroup][static_cast(roleInt)] = color; - } + cfg.colors[currentGroup][static_cast(roleInt)] = color; } return cfg; @@ -245,14 +280,11 @@ PaletteConfig PaletteConfig::fromDefault(const QString &themeDirPath, const QStr bool wantDark = colorScheme.compare("Dark", Qt::CaseInsensitive) == 0; - PaletteConfig cfg = - fromFile(dir.absoluteFilePath(wantDark ? "palette-default-dark.toml" : "palette-default-light.toml")); - - if (!cfg.hasPalette()) { - cfg = fromFile(dir.absoluteFilePath(wantDark ? "palette-default-light.toml" : "palette-default-dark.toml")); - } - - return cfg; + // Only the default file matching the requested scheme is used. Falling back + // to the opposite scheme's default would silently apply dark colours to a + // "Light" scheme (or vice versa). Callers already fall back to the OS / + // application palette when no default palette is available. + return fromFile(dir.absoluteFilePath(wantDark ? "palette-default-dark.toml" : "palette-default-light.toml")); } QPalette PaletteConfig::apply(QPalette base) const diff --git a/cockatrice/src/interface/theme_config.h b/cockatrice/src/interface/theme_config.h index 07bf55b7a..567aeccda 100644 --- a/cockatrice/src/interface/theme_config.h +++ b/cockatrice/src/interface/theme_config.h @@ -3,9 +3,25 @@ #include #include +#include #include #include +// Application-specific color roles, layered on top of the fixed QPalette role +// set. Stored in the same palette-.toml under an [AppColors] section +// and editable from the palette editor, so theme authors can control colors +// beyond what Qt's palette can express. +namespace AppColor +{ +Q_NAMESPACE +enum Role +{ + AccentStrong, + AccentSoft, +}; +Q_ENUM_NS(Role) +} // namespace AppColor + struct ThemeConfig { QString colorScheme; @@ -21,7 +37,16 @@ struct ThemeConfig struct PaletteConfig { QMap> colors; + QMap appColors; + bool operator==(const PaletteConfig &rhs) const + { + return colors == rhs.colors && appColors == rhs.appColors; + } + bool operator!=(const PaletteConfig &rhs) const + { + return !(*this == rhs); + } bool hasPalette() const; QString toToml() const; diff --git a/cockatrice/src/interface/theme_manager.cpp b/cockatrice/src/interface/theme_manager.cpp index 086845fe6..d86ed77f9 100644 --- a/cockatrice/src/interface/theme_manager.cpp +++ b/cockatrice/src/interface/theme_manager.cpp @@ -1,10 +1,13 @@ #include "theme_manager.h" #include "../../client/settings/cache_settings.h" +#include "pixel_map_generator.h" #include #include #include +#include +#include #include #include #include @@ -17,8 +20,9 @@ #include #include #include +#include -#define NONE_THEME_NAME "Default" +#define SYSTEM_THEME_NAME "System" #define FUSION_THEME_NAME "Fusion" #define STYLE_CSS_NAME "style.css" #define HANDZONE_BG_NAME "handzone" @@ -89,16 +93,25 @@ struct PaletteColorInfo } } +static QString usableDefaultStyle(const QString &style) +{ + // The Windows 11 native style is broken: when the OS default + // ("System" theme selection) would use it, fall back to the Vista style. + // Explicitly choosing "windows11" in a theme is still honored. + return style.compare("windows11", Qt::CaseInsensitive) == 0 ? QStringLiteral("windowsvista") : style; +} + ThemeManager::ThemeManager(QObject *parent) : QObject(parent) { - defaultStyleName = qApp->style()->objectName(); - //! \todo Workaround for windows11 style being broken. - if (defaultStyleName == "windows11") { - defaultStyleName = "windowsvista"; - } + defaultStyleName = usableDefaultStyle(qApp->style()->objectName()); + // Capture the untouched application palette before any theme is applied. + defaultPalette = qApp->palette(); ensureThemeDirectoryExists(); #if (QT_VERSION >= QT_VERSION_CHECK(6, 5, 0)) - connect(QGuiApplication::styleHints(), &QStyleHints::colorSchemeChanged, this, &ThemeManager::themeChangedSlot); + connect(QGuiApplication::styleHints(), &QStyleHints::colorSchemeChanged, this, [this] { + defaultPalette = qApp->palette(); + themeChangedSlot(); + }); #endif connect(&SettingsCache::instance(), &SettingsCache::themeChanged, this, &ThemeManager::themeChangedSlot); themeChangedSlot(); @@ -106,14 +119,20 @@ ThemeManager::ThemeManager(QObject *parent) : QObject(parent) void ThemeManager::ensureThemeDirectoryExists() { - if (SettingsCache::instance().getThemeName().isEmpty() || - !getAvailableThemes().contains(SettingsCache::instance().getThemeName())) { + auto &settings = SettingsCache::instance(); + + // Migrate the old "Default" theme name to "System" + if (settings.getThemeName() == "Default") { + settings.setThemeName(SYSTEM_THEME_NAME); + } + + if (settings.getThemeName().isEmpty() || !getAvailableThemes().contains(settings.getThemeName())) { qCInfo(ThemeManagerLog) << "Theme name not set, setting default value"; - SettingsCache::instance().setThemeName(NONE_THEME_NAME); + settings.setThemeName(FUSION_THEME_NAME); } } -bool ThemeManager::isDarkMode(const QString &themeDirPath) +bool ThemeManager::isDarkMode(const QString &themeDirPath) const { ThemeConfig themeConfig = ThemeConfig::fromThemeDir(themeDirPath); if (themeConfig.colorScheme.compare("Dark", Qt::CaseInsensitive) == 0) { @@ -130,11 +149,88 @@ bool ThemeManager::isDarkMode(const QString &themeDirPath) } } -bool ThemeManager::isBuiltInTheme() +QString ThemeManager::schemeVariantPath(QStringView prefix) const { - const auto themeName = SettingsCache::instance().getThemeName(); + static const QStringList formats = {QStringLiteral(".png"), QStringLiteral(".jpg"), QStringLiteral(".jpeg"), + QStringLiteral(".svg")}; + const QString scheme = isDarkMode(currentThemePath) ? QStringLiteral("dark") : QStringLiteral("light"); + const QString variantStem = prefix.toString() + QLatin1Char('-') + scheme; - return themeName == NONE_THEME_NAME || themeName == FUSION_THEME_NAME; + for (const QString &format : formats) { + if (QFileInfo::exists(QStringLiteral("theme:") + variantStem + format)) { + return variantStem + format; + } + } + return QString(); +} + +QString ThemeManager::assetPath(QStringView prefix) const +{ + // Probe order mirrors tryLoadImage: a theme may override the default SVG + // with a raster of the same stem, so raster wins over SVG within a stem. + static const QStringList formats = {QStringLiteral(".png"), QStringLiteral(".jpg"), QStringLiteral(".jpeg"), + QStringLiteral(".svg")}; + + auto findExisting = [](const QString &stem) { + for (const QString &format : formats) { + if (QFileInfo::exists(QStringLiteral("theme:") + stem + format)) { + return stem + format; + } + } + return QString(); + }; + + // Prefer the scheme-qualified variant when it exists, else the plain + // asset as the super fallback. Both return the resolved path including + // its file extension so callers can load it directly. + const QString variant = schemeVariantPath(prefix); + if (!variant.isEmpty()) { + return variant; + } + const QString resolvedPlain = findExisting(prefix.toString()); + return resolvedPlain.isEmpty() ? prefix.toString() : resolvedPlain; +} + +// Probe whether a directory is truly writable by trying to create and remove a +// temporary file. QFileInfo::isWritable() on a directory is unreliable (notably +// on Windows where UAC VirtualStore can make a system dir appear writable). +bool ThemeManager::isDirReallyWritable(const QString &dirPath) +{ + const QString probe = QDir(dirPath).absoluteFilePath(".cockatrice_write_test"); + QFile f(probe); + if (!f.open(QIODevice::WriteOnly)) { + return false; + } + f.close(); + f.remove(); + return true; +} + +QString ThemeManager::writableThemeDir(const QString &themeName) +{ + // All theme writes go to the user themes directory regardless of whether + // the resolved (system) theme directory happens to be writable. Even when a + // write would succeed in-place, routing it to the user directory keeps the + // install intact and guarantees changes survive upgrades. + const QString dirPath = QDir(SettingsCache::instance().paths().getThemesPath()).absoluteFilePath(themeName); + if (!QDir().mkpath(dirPath)) { + qWarning() << "Failed to create theme save directory:" << dirPath; + } + return dirPath; +} + +// System (read-only) themes location, relative to the application binary. +static QString systemThemesBasePath() +{ + QString base = qApp->applicationDirPath(); +#ifdef Q_OS_MAC + base += "/../Resources/themes"; +#elif defined(Q_OS_WIN) + base += "/themes"; +#else // linux + base += "/../share/cockatrice/themes"; +#endif + return base; } QStringMap &ThemeManager::getAvailableThemes() @@ -143,11 +239,9 @@ QStringMap &ThemeManager::getAvailableThemes() availableThemes.clear(); // load themes from user profile dir - dir.setPath(SettingsCache::instance().getThemesPath()); - - // add default value - availableThemes.insert(NONE_THEME_NAME, dir.absoluteFilePath("Default")); + dir.setPath(SettingsCache::instance().paths().getThemesPath()); + availableThemes.insert(SYSTEM_THEME_NAME, dir.absoluteFilePath("System")); availableThemes.insert(FUSION_THEME_NAME, dir.absoluteFilePath("Fusion")); for (QString themeName : dir.entryList(QDir::AllDirs | QDir::NoDotAndDotDot, QDir::Name)) { @@ -156,16 +250,8 @@ QStringMap &ThemeManager::getAvailableThemes() } } - // load themes from cockatrice system dir - dir.setPath(qApp->applicationDirPath() + -#ifdef Q_OS_MAC - "/../Resources/themes" -#elif defined(Q_OS_WIN) - "/themes" -#else // linux - "/../share/cockatrice/themes" -#endif - ); + // Load themes from Cockatrice system dir + dir.setPath(systemThemesBasePath()); for (QString themeName : dir.entryList(QDir::AllDirs | QDir::NoDotAndDotDot, QDir::Name)) { if (!availableThemes.contains(themeName)) { @@ -179,7 +265,7 @@ QStringMap &ThemeManager::getAvailableThemes() QBrush ThemeManager::loadBrush(QString fileName, QColor fallbackColor) { QBrush brush; - QPixmap tmp = QPixmap("theme:zones/" + fileName); + QPixmap tmp = QPixmap("theme:" + assetPath(QStringLiteral("zones/") + fileName)); if (tmp.isNull()) { brush.setColor(fallbackColor); brush.setStyle(Qt::SolidPattern); @@ -193,7 +279,7 @@ QBrush ThemeManager::loadBrush(QString fileName, QColor fallbackColor) QBrush ThemeManager::loadExtraBrush(QString fileName, QBrush &fallbackBrush) { QBrush brush; - QPixmap tmp = QPixmap("theme:zones/" + fileName); + QPixmap tmp = QPixmap("theme:" + assetPath(QStringLiteral("zones/") + fileName)); if (tmp.isNull()) { brush = fallbackBrush; @@ -242,9 +328,36 @@ bool ThemeManager::savePaletteConfig(const QString &themeDirPath, const QString return true; } +PaletteConfig ThemeManager::loadDefaultPaletteConfig(const QString &themeDirPath, + const QString &themeName, + const QString &colorScheme) +{ + PaletteConfig cfg = PaletteConfig::fromDefault(themeDirPath, colorScheme); + if (!cfg.hasPalette()) { + // The shipped default may live in the system theme directory rather + // than the resolved (user) theme directory, so built-in themes still + // get their curated defaults. + cfg = PaletteConfig::fromDefault(QDir(systemThemesBasePath()).absoluteFilePath(themeName), colorScheme); + } + return cfg; +} + +bool ThemeManager::commitPalette(const QString &themeDirPath, const QString &colorScheme, const PaletteConfig &cfg) +{ + if (!savePaletteConfig(themeDirPath, colorScheme, cfg)) { + return false; + } + + ThemeConfig globalCfg = ThemeConfig::fromThemeDir(themeDirPath); + globalCfg.colorScheme = colorScheme; + globalCfg.save(themeDirPath); + + return true; +} + void ThemeManager::setColorScheme(const QString &scheme) { - const QString dirPath = getAvailableThemes().value(SettingsCache::instance().getThemeName()); + const QString dirPath = writableThemeDir(SettingsCache::instance().getThemeName()); ThemeConfig cfg = ThemeConfig::fromThemeDir(dirPath); cfg.colorScheme = scheme; @@ -253,6 +366,17 @@ void ThemeManager::setColorScheme(const QString &scheme) reloadCurrentTheme(); } +void ThemeManager::setStyleName(const QString &styleName) +{ + const QString dirPath = writableThemeDir(SettingsCache::instance().getThemeName()); + ThemeConfig cfg = ThemeConfig::fromThemeDir(dirPath); + + cfg.styleName = styleName; + + cfg.save(dirPath); + reloadCurrentTheme(); +} + void ThemeManager::reloadCurrentTheme() { themeChangedSlot(); @@ -271,18 +395,21 @@ void ThemeManager::applyStyleAndPalette(const QString &themeName, const PaletteConfig &palCfg, const QString &activeScheme) { +#if (QT_VERSION < QT_VERSION_CHECK(6, 5, 0)) + Q_UNUSED(activeScheme) +#endif QString styleName = themeCfg.styleName; - if (styleName.isEmpty() || styleName.compare("Default", Qt::CaseInsensitive) == 0) { + if (styleName.isEmpty() || styleName.compare("System", Qt::CaseInsensitive) == 0) { if (themeName == FUSION_THEME_NAME) { styleName = "Fusion"; } else { - styleName = defaultStyleName; + styleName = usableDefaultStyle(defaultStyleName); } } QStyle *style = QStyleFactory::create(styleName); if (!style) { - style = QStyleFactory::create(defaultStyleName); + style = QStyleFactory::create(usableDefaultStyle(defaultStyleName)); } // Base palette @@ -295,7 +422,11 @@ void ThemeManager::applyStyleAndPalette(const QString &themeName, } #endif } else { - base = qApp->palette(); + // Use the pristine startup palette rather than qApp->palette(): the + // latter may already carry a previously-applied custom (e.g. dark) + // palette, which would otherwise persist when switching to a scheme + // that supplies no palette of its own. + base = defaultPalette; } // Overlay custom palette colours @@ -311,6 +442,8 @@ void ThemeManager::applyStyleAndPalette(const QString &themeName, qApp->setPalette(base); qApp->setStyle(style); + currentAppColors = palCfg.appColors; + // Force every widget to re-polish and repaint immediately rather than // waiting for natural expose events, which produces a patchwork of old // and new colours during a live preview. @@ -323,6 +456,35 @@ void ThemeManager::applyStyleAndPalette(const QString &themeName, style->polish(widget); widget->update(); } + + emit paletteChanged(); +} + +QColor ThemeManager::appColor(AppColor::Role role) const +{ + const auto it = currentAppColors.constFind(role); + if (it != currentAppColors.constEnd()) { + return it.value(); + } + + // QPalette::Accent was introduced in Qt 6.6 and several shipped palettes + // set it to a value barely distinguishable from Window, so it is not a + // reliable accent source. The selection highlight is the stable accent + // (Accent defaults to Highlight when unset), and deriving from it + // unconditionally keeps every Qt version rendering identically. + const QColor accent = qApp->palette().color(QPalette::Active, QPalette::Highlight); + + if (role == AppColor::AccentSoft) { + constexpr int SOFT_SATURATION_PERCENT = 70; + constexpr int SOFT_LIGHTNESS_OFFSET = 60; + + // Light end of the gradient: same hue, softened and lightened + return QColor::fromHsl(qMax(0, accent.hslHue()), + qBound(0, qRound(accent.hslSaturation() * SOFT_SATURATION_PERCENT / 100.0), 255), + qBound(0, accent.lightness() + SOFT_LIGHTNESS_OFFSET, 255)); + } + + return accent; } void ThemeManager::themeChangedSlot() @@ -332,9 +494,19 @@ void ThemeManager::themeChangedSlot() currentThemePath = dirPath; QDir dir(dirPath); - // CSS - if (!dirPath.isEmpty() && dir.exists(STYLE_CSS_NAME)) { - qApp->setStyleSheet("file:///" + dir.absoluteFilePath(STYLE_CSS_NAME)); + // CSS — prefer the scheme-qualified stylesheet (style-dark.css / + // style-light.css) when present, else the plain style.css as fallback. + if (!dirPath.isEmpty()) { + const QString scheme = isDarkMode(dirPath) ? QStringLiteral("dark") : QStringLiteral("light"); + const QString schemeCss = QFileInfo(QStringLiteral(STYLE_CSS_NAME)).completeBaseName() + QLatin1Char('-') + + scheme + QStringLiteral(".css"); + if (dir.exists(schemeCss)) { + qApp->setStyleSheet("file:///" + dir.absoluteFilePath(schemeCss)); + } else if (dir.exists(STYLE_CSS_NAME)) { + qApp->setStyleSheet("file:///" + dir.absoluteFilePath(STYLE_CSS_NAME)); + } else { + qApp->setStyleSheet(""); + } } else { qApp->setStyleSheet(""); } @@ -349,8 +521,19 @@ void ThemeManager::themeChangedSlot() // ── Load palette: custom first, then theme default ──────────────────── PaletteConfig palette = PaletteConfig::fromScheme(dirPath, activeScheme); - if (!palette.hasPalette()) { - palette = PaletteConfig::fromDefault(dirPath, activeScheme); + const PaletteConfig themeDefault = ThemeManager::loadDefaultPaletteConfig(dirPath, themeName, activeScheme); + if (palette.hasPalette()) { + // A custom palette written before [AppColors] existed carries no app + // colors; merge the theme's shipped defaults so the identity colors + // survive (hasPalette() counts an app-colors-only file as a palette, + // so those are kept wholesale and never reach here empty). + for (auto it = themeDefault.appColors.cbegin(); it != themeDefault.appColors.cend(); ++it) { + if (!palette.appColors.contains(it.key())) { + palette.appColors.insert(it.key(), it.value()); + } + } + } else { + palette = themeDefault; } applyStyleAndPalette(themeName, themeCfg, palette, activeScheme); @@ -359,6 +542,16 @@ void ThemeManager::themeChangedSlot() if (!dirPath.isEmpty()) { resources << dir.absolutePath(); } + + // When the resolved dir is a user copy (e.g. user/), also + // include the system theme dir as a fallback so shipped assets like + // zones/*.png and style.css still resolve for themes that ship only + // those files (e.g. Leather, Plasma, Fabric, VelvetMarble). + const QString sysPath = QDir(systemThemesBasePath()).absoluteFilePath(themeName); + if (sysPath != dirPath && QDir(sysPath).exists()) { + resources << sysPath; + } + resources << DEFAULT_RESOURCE_PATHS; QDir::setSearchPaths("theme", resources); @@ -375,6 +568,7 @@ void ThemeManager::themeChangedSlot() } QPixmapCache::clear(); + clearPixmapGeneratorCaches(); emit themeChanged(); } @@ -396,6 +590,7 @@ static QString roleBgName(ThemeManager::Role role) default: Q_ASSERT(false); + return {}; } } diff --git a/cockatrice/src/interface/theme_manager.h b/cockatrice/src/interface/theme_manager.h index b9e764d08..aadb38ee9 100644 --- a/cockatrice/src/interface/theme_manager.h +++ b/cockatrice/src/interface/theme_manager.h @@ -43,9 +43,14 @@ public: private: QString defaultStyleName; + // Pristine application palette captured at startup, before any custom theme + // palette is applied. Used as the base when a theme supplies no palette, so + // switching away from a custom palette restores the original colours. + QPalette defaultPalette; QString currentThemePath; std::array brushes; QStringMap availableThemes; + QMap currentAppColors; /* Internal cache for multiple backgrounds */ @@ -61,8 +66,24 @@ protected: const QString &activeScheme); public: - bool isBuiltInTheme(); - bool isDarkMode(const QString &themeDirPath); + // Resolves the directory to write theme changes to for the given theme + // name. The resolved theme dir (user or system) is used when writable; + // read-only system themes fall back to the user themes directory, creating + // it if needed, so customisations never get lost on upgrade. + static QString writableThemeDir(const QString &themeName); + // Probe whether a directory is truly writable by trying to create and remove + // a temporary file. QFileInfo::isWritable() on a directory is unreliable + // (notably on Windows where UAC VirtualStore can make a system dir appear + // writable). + static bool isDirReallyWritable(const QString &dirPath); + // Explicit color scheme of the theme: theme.cfg's ColorScheme setting + // (Dark/Light), falling back to the OS color scheme when it is "System". + bool isDarkMode(const QString &themeDirPath) const; + // The resolved scheme of the currently active theme. + bool isDarkModeActive() const + { + return isDarkMode(currentThemePath); + } QStringMap &getAvailableThemes(); // Returns the path to the currently active theme directory (empty = default) QString getCurrentThemePath() const @@ -76,17 +97,45 @@ public: // Load/save per-scheme palette colors static PaletteConfig loadPaletteConfig(const QString &themeDirPath, const QString &colorScheme); static bool savePaletteConfig(const QString &themeDirPath, const QString &colorScheme, const PaletteConfig &cfg); + // Resolve prefix to a scheme-qualified "theme:" path. Existence is probed + // internally across the formats themes may ship (.png/.jpg/.svg), so + // callers load the returned path directly. Prefers "-" + // when a file exists at that stem, otherwise the plain "" as the + // super fallback. The resolved scheme covers explicit light/dark as well + // as OS-resolved "system". Returns the path with its file extension when a + // match is found; unqualified assets keep working unchanged. + QString assetPath(QStringView prefix) const; + // Like assetPath, but resolves only the scheme-qualified variant + // ("-.") and returns an empty string when no + // variant exists — it never falls back to the plain "" asset. + // Callers that must distinguish "no authored variant" (e.g. to keep a + // legacy runtime fallback alive) should use this instead of assetPath. + QString schemeVariantPath(QStringView prefix) const; + // Load the theme's shipped default palette, falling back to the system + // theme directory when it is absent from the resolved (user) directory. + static PaletteConfig + loadDefaultPaletteConfig(const QString &themeDirPath, const QString &themeName, const QString &colorScheme); + /** @brief Writes cfg to disk as the theme's palette-.toml and updates the + * theme's stored colour scheme to match. Shared by PaletteEditorDialog::onSave + * and FirstRunWizard's theme step so the two "generate + keep" paths can't drift. */ + static bool commitPalette(const QString &themeDirPath, const QString &colorScheme, const PaletteConfig &cfg); void setColorScheme(const QString &scheme); + void setStyleName(const QString &styleName); void reloadCurrentTheme(); void previewPalette(const PaletteConfig &cfg, const QString &scheme); + // Resolves an application color role: the theme's stored [AppColors] value + // when present, otherwise a palette-accent-derived fallback. + QColor appColor(AppColor::Role role) const; + QBrush &getBgBrush(Role zone); QBrush getExtraBgBrush(Role zone, int zoneId = 0); protected slots: void themeChangedSlot(); signals: void themeChanged(); + void paletteChanged(); }; extern ThemeManager *themeManager; diff --git a/cockatrice/src/interface/widgets/cards/additional_info/color_identity_widget.cpp b/cockatrice/src/interface/widgets/cards/additional_info/color_identity_widget.cpp index 1c38214bd..1ea1bcb10 100644 --- a/cockatrice/src/interface/widgets/cards/additional_info/color_identity_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/additional_info/color_identity_widget.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include ColorIdentityWidget::ColorIdentityWidget(QWidget *parent, const QString &_colorIdentity) @@ -21,7 +22,8 @@ ColorIdentityWidget::ColorIdentityWidget(QWidget *parent, const QString &_colorI populateManaSymbolWidgets(); - connect(&SettingsCache::instance(), &SettingsCache::visualDeckStorageDrawUnusedColorIdentitiesChanged, this, + connect(&SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::visualDeckStorageDrawUnusedColorIdentitiesChanged, this, &ColorIdentityWidget::toggleUnusedVisibility); } @@ -39,8 +41,13 @@ void ColorIdentityWidget::populateManaSymbolWidgets() // clear old layout QtUtils::clearLayoutRec(layout); + // The freshly created symbols haven't been sized yet, so force the next resize pass + // to apply the symbol size again. + lastIconSize = -1; + lastWidth = -1; + // populate mana symbols - if (SettingsCache::instance().getVisualDeckStorageDrawUnusedColorIdentities()) { + if (SettingsCache::instance().visualDeckStorage().getVisualDeckStorageDrawUnusedColorIdentities()) { for (const QString symbol : fullColorIdentity) { auto *manaSymbol = new ManaSymbolWidget(this, symbol, symbols.contains(symbol)); layout->addWidget(manaSymbol); @@ -71,20 +78,33 @@ void ColorIdentityWidget::toggleUnusedVisibility() void ColorIdentityWidget::resizeEvent(QResizeEvent *event) { QWidget::resizeEvent(event); - QList manaSymbols = findChildren(); - if (!manaSymbols.isEmpty()) { - int totalWidth = event->size().width(); - int totalHeight = totalWidth / 6; // Set height to 1/4 of the width - setFixedHeight(totalHeight); + const int totalWidth = event->size().width(); + if (totalWidth == lastWidth && lastIconSize != -1) { + return; + } + lastWidth = totalWidth; - int spacing = layout->spacing(); - int count = manaSymbols.size(); - int availableWidth = totalWidth - (spacing * (count - 1)); - int iconSize = qMin(availableWidth / count, totalHeight); // Ensure icons fit within the new height + const int totalHeight = totalWidth / 6; // Set height to 1/4 of the width + setFixedHeight(totalHeight); - for (ManaSymbolWidget *manaSymbol : manaSymbols) { - manaSymbol->setFixedSize(iconSize, iconSize); + const int count = layout->count(); + if (count == 0) { + return; + } + + const int spacing = layout->spacing(); + const int availableWidth = totalWidth - (spacing * (count - 1)); + const int iconSize = qMin(availableWidth / count, totalHeight); // Ensure icons fit within the new height + + if (iconSize == lastIconSize) { + return; + } + lastIconSize = iconSize; + + for (int i = 0; i < count; ++i) { + if (auto *w = qobject_cast(layout->itemAt(i)->widget())) { + w->setFixedSize(iconSize, iconSize); } } } diff --git a/cockatrice/src/interface/widgets/cards/additional_info/color_identity_widget.h b/cockatrice/src/interface/widgets/cards/additional_info/color_identity_widget.h index f776d4c77..315ac07d6 100644 --- a/cockatrice/src/interface/widgets/cards/additional_info/color_identity_widget.h +++ b/cockatrice/src/interface/widgets/cards/additional_info/color_identity_widget.h @@ -30,6 +30,8 @@ public slots: private: QString colorIdentity; QHBoxLayout *layout; + int lastIconSize = -1; ///< The symbol size last applied, to skip redundant resize passes. + int lastWidth = -1; ///< The width last processed, to skip redundant resize passes. }; #endif // COLOR_IDENTITY_WIDGET_H diff --git a/cockatrice/src/interface/widgets/cards/additional_info/mana_cost_widget.h b/cockatrice/src/interface/widgets/cards/additional_info/mana_cost_widget.h index b2f6b62c0..8bd2e9bc0 100644 --- a/cockatrice/src/interface/widgets/cards/additional_info/mana_cost_widget.h +++ b/cockatrice/src/interface/widgets/cards/additional_info/mana_cost_widget.h @@ -17,7 +17,7 @@ class ManaCostWidget : public QWidget public: explicit ManaCostWidget(QWidget *parent, CardInfoPtr card); - QStringList parseManaCost(const QString &manaString); + static QStringList parseManaCost(const QString &manaString); public slots: void resizeEvent(QResizeEvent *event) override; diff --git a/cockatrice/src/interface/widgets/cards/additional_info/mana_symbol_widget.cpp b/cockatrice/src/interface/widgets/cards/additional_info/mana_symbol_widget.cpp index 07cc382ad..18011909f 100644 --- a/cockatrice/src/interface/widgets/cards/additional_info/mana_symbol_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/additional_info/mana_symbol_widget.cpp @@ -1,14 +1,15 @@ #include "mana_symbol_widget.h" #include "../../../../client/settings/cache_settings.h" +#include "../../../pixel_map_generator.h" #include +#include ManaSymbolWidget::ManaSymbolWidget(QWidget *parent, QString _symbol, bool _isActive, bool _mayBeToggled) - : QLabel(parent), symbol(_symbol), isActive(_isActive), mayBeToggled(_mayBeToggled) + : QLabel(parent), symbol(std::move(_symbol)), isActive(_isActive), mayBeToggled(_mayBeToggled) { - loadManaIcon(); - setPixmap(manaIcon.scaled(50, 50, Qt::KeepAspectRatio, Qt::SmoothTransformation)); + setPixmap(ManaSymbolPixmapGenerator::generatePixmap(symbol, QSize(50, 50))); setMaximumWidth(50); // Initialize opacity effect @@ -16,7 +17,8 @@ ManaSymbolWidget::ManaSymbolWidget(QWidget *parent, QString _symbol, bool _isAct setGraphicsEffect(opacityEffect); updateOpacity(); - connect(&SettingsCache::instance(), &SettingsCache::visualDeckStorageUnusedColorIdentitiesOpacityChanged, this, + connect(&SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::visualDeckStorageUnusedColorIdentitiesOpacityChanged, this, &ManaSymbolWidget::updateOpacity); } @@ -42,7 +44,11 @@ void ManaSymbolWidget::updateOpacity() opacity = isActive ? 1.0 : 0.5; } else { // It's just for display, they can do whatever they want. - opacity = isActive ? 1.0 : SettingsCache::instance().getVisualDeckStorageUnusedColorIdentitiesOpacity() / 100.0; + opacity = + isActive + ? 1.0 + : SettingsCache::instance().visualDeckStorage().getVisualDeckStorageUnusedColorIdentitiesOpacity() / + 100.0; } opacityEffect->setOpacity(opacity); } @@ -58,16 +64,13 @@ void ManaSymbolWidget::mousePressEvent(QMouseEvent *event) void ManaSymbolWidget::resizeEvent(QResizeEvent *event) { QLabel::resizeEvent(event); - setPixmap(manaIcon.scaled(event->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation)); -} + const QSize newSize = event->size(); -void ManaSymbolWidget::loadManaIcon() -{ - QString filename = "theme:icons/mana/"; - - if (symbol == "W" || symbol == "U" || symbol == "B" || symbol == "R" || symbol == "G") { - filename += symbol; + // Skip the rescale when the size didn't actually change: layout passes resize these + // widgets repeatedly with identical sizes. + if (newSize.isEmpty() || pixmap().size() == newSize) { + return; } - manaIcon = QPixmap(filename); + setPixmap(ManaSymbolPixmapGenerator::generatePixmap(symbol, newSize)); } diff --git a/cockatrice/src/interface/widgets/cards/additional_info/mana_symbol_widget.h b/cockatrice/src/interface/widgets/cards/additional_info/mana_symbol_widget.h index 0f2d7acd1..705873dff 100644 --- a/cockatrice/src/interface/widgets/cards/additional_info/mana_symbol_widget.h +++ b/cockatrice/src/interface/widgets/cards/additional_info/mana_symbol_widget.h @@ -33,8 +33,6 @@ public: return symbol[0]; } - void loadManaIcon(); - public slots: void resizeEvent(QResizeEvent *event) override; void mousePressEvent(QMouseEvent *event) override; @@ -44,7 +42,6 @@ signals: private: QString symbol; - QPixmap manaIcon; bool isActive; bool mayBeToggled; QGraphicsOpacityEffect *opacityEffect; diff --git a/cockatrice/src/interface/widgets/cards/art_crop_attribution.cpp b/cockatrice/src/interface/widgets/cards/art_crop_attribution.cpp new file mode 100644 index 000000000..8ab18ddd9 --- /dev/null +++ b/cockatrice/src/interface/widgets/cards/art_crop_attribution.cpp @@ -0,0 +1,61 @@ +#include "art_crop_attribution.h" + +#include +#include +#include +#include + +QString buildArtAttribution(const ExactCard &card) +{ + const QString artist = card.getPrinting().getArtist(); + if (artist.isEmpty()) { + return QString(); + } + return QObject::tr("Art: %1").arg(artist); +} + +QRectF paintArtAttribution(QPainter &painter, + const QRectF &rect, + const QString &attribution, + Qt::Alignment anchor, + qreal scale) +{ + if (attribution.isEmpty()) { + return QRectF(); + } + + painter.save(); + + QFont font = painter.font(); + font.setPointSizeF(qMax(6.0, font.pointSizeF() * scale)); + painter.setFont(font); + + const QFontMetrics fm(font); + const qreal maxTextWidth = rect.width() * 0.45; + const QString elided = fm.elidedText(attribution, Qt::ElideRight, qMax(qreal(80.0) * scale, maxTextWidth)); + + const qreal pad = 6.0 * scale; + QRectF captionRect(QPointF(0, 0), QSizeF(fm.horizontalAdvance(elided) + pad * 2.0, fm.height() + pad * 2.0)); + const qreal margin = 4.0 * scale; + if (anchor.testFlag(Qt::AlignLeft)) { + captionRect.moveLeft(rect.left() + margin); + } else { + captionRect.moveRight(rect.right() - margin); + } + if (anchor.testFlag(Qt::AlignTop)) { + captionRect.moveTop(rect.top() + margin); + } else { + captionRect.moveBottom(rect.bottom() - margin); + } + + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(0, 0, 0, 120)); + painter.drawRoundedRect(captionRect, 4, 4); + + painter.setPen(QColor(255, 255, 255, 220)); + painter.drawText(captionRect, Qt::AlignCenter, elided); + + painter.restore(); + + return captionRect; +} diff --git a/cockatrice/src/interface/widgets/cards/art_crop_attribution.h b/cockatrice/src/interface/widgets/cards/art_crop_attribution.h new file mode 100644 index 000000000..0b3fb3d56 --- /dev/null +++ b/cockatrice/src/interface/widgets/cards/art_crop_attribution.h @@ -0,0 +1,41 @@ +#ifndef COCKATRICE_ART_CROP_ATTRIBUTION_H +#define COCKATRICE_ART_CROP_ATTRIBUTION_H + +#include + +class ExactCard; +class QPainter; +class QRectF; +class QString; + +/** + * @brief Builds an attribution caption for a cropped card art display. + * + * When a card image's art region is shown cropped (an "art crop"), the artist + * should be credited in the same interface. Returns an empty string when the + * database has no artist data for the card. + * + * @param card The card whose art is being displayed. + * @return Caption such as "Art: John Avon", or empty. + */ +QString buildArtAttribution(const ExactCard &card); + +/** + * @brief Paints an attribution caption in a corner of a rect. + * + * Draws a subtle semi-transparent pill containing the caption, elided to fit. + * + * @param painter Painter to draw with. + * @param rect The area (e.g. the cropped art region) the caption belongs to. + * @param attribution Caption text (see buildArtAttribution()). + * @param anchor Corner of @p rect to pin the pill to (default bottom-right). + * @param scale Size multiplier for the pill (e.g. 0.8 for a smaller pill). + * @return The rect the pill was drawn in, or an empty rect if @p attribution is empty. + */ +QRectF paintArtAttribution(QPainter &painter, + const QRectF &rect, + const QString &attribution, + Qt::Alignment anchor = Qt::AlignRight | Qt::AlignBottom, + qreal scale = 1.0); + +#endif // COCKATRICE_ART_CROP_ATTRIBUTION_H diff --git a/cockatrice/src/interface/widgets/cards/card_art_utils.cpp b/cockatrice/src/interface/widgets/cards/card_art_utils.cpp new file mode 100644 index 000000000..b26b73593 --- /dev/null +++ b/cockatrice/src/interface/widgets/cards/card_art_utils.cpp @@ -0,0 +1,18 @@ +#include "card_art_utils.h" + +#include +#include + +namespace CardArtUtils +{ +QPixmap rotateSidewaysLayoutArt(const QPixmap &art, const ExactCard &card) +{ + if (!card.getInfo().getUiAttributes().landscapeOrientation) { + return art; + } + + QTransform transform; + transform.rotate(90); + return art.transformed(transform, Qt::SmoothTransformation); +} +} // namespace CardArtUtils \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/cards/card_art_utils.h b/cockatrice/src/interface/widgets/cards/card_art_utils.h new file mode 100644 index 000000000..5c331a12c --- /dev/null +++ b/cockatrice/src/interface/widgets/cards/card_art_utils.h @@ -0,0 +1,25 @@ +#ifndef CARD_ART_UTILS_H +#define CARD_ART_UTILS_H + +#include + +class ExactCard; + +namespace CardArtUtils +{ +/** + * @brief Rotates a card's art upright when its layout shows sideways. + * + * Sideways-layout cards (planes, sieges/battles, split cards) store their + * landscape artwork rotated 90° inside a portrait frame. Art-crop displays, + * playmat art, and the card-info picture must show such art upright before + * sampling or painting. Portrait cards are returned unchanged. + * + * @param art The card pixmap to orient. + * @param card The card describing the art orientation. + * @return @p art rotated 90° clockwise for sideways-layout cards, else @p art. + */ +QPixmap rotateSidewaysLayoutArt(const QPixmap &art, const ExactCard &card); +} // namespace CardArtUtils + +#endif // CARD_ART_UTILS_H \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.cpp b/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.cpp index 3f36e559c..bfbdd7e42 100644 --- a/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.cpp @@ -174,16 +174,18 @@ void CardGroupDisplayWidget::updateCardDisplays() QModelIndex sourceIndex = proxy.mapToSource(proxyIndex); // 4. persist the source index - QPersistentModelIndex persistent(sourceIndex); + addCardWidgets(QPersistentModelIndex(sourceIndex)); + } +} - // Get the card amount - int cardAmount = - sourceIndex.sibling(sourceIndex.row(), DeckListModelColumns::CARD_AMOUNT).data(Qt::EditRole).toInt(); +void CardGroupDisplayWidget::addCardWidgets(const QPersistentModelIndex &persistent) +{ + // Get the card amount + int cardAmount = persistent.sibling(persistent.row(), DeckListModelColumns::CARD_AMOUNT).data(Qt::EditRole).toInt(); - // Create multiple widgets for the card count - for (int copy = 0; copy < cardAmount; ++copy) { - addToLayout(constructWidgetForIndex(persistent)); - } + // Create multiple widgets for the card count + for (int copy = 0; copy < cardAmount; ++copy) { + addToLayout(constructWidgetForIndex(persistent)); } } diff --git a/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.h b/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.h index 2308ccf8d..a3bf70981 100644 --- a/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.h +++ b/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.h @@ -35,6 +35,7 @@ public: void onSelectionChanged(const QItemSelection &selected, const QItemSelection &deselected); void refreshSelectionForIndex(const QPersistentModelIndex &persistent); void clearAllDisplayWidgets(); + void addCardWidgets(const QPersistentModelIndex &persistent); DeckListModel *deckListModel; QItemSelectionModel *selectionModel; diff --git a/cockatrice/src/interface/widgets/cards/card_info_frame_widget.cpp b/cockatrice/src/interface/widgets/cards/card_info_frame_widget.cpp index 2e7c62461..fcccf445a 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_frame_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_info_frame_widget.cpp @@ -10,6 +10,7 @@ #include #include #include +#include CardInfoFrameWidget::CardInfoFrameWidget(QWidget *parent) : QTabWidget(parent), viewTransformationButton(nullptr), cardTextOnly(false) @@ -60,7 +61,7 @@ CardInfoFrameWidget::CardInfoFrameWidget(QWidget *parent) tab3Layout->addWidget(splitter); tab3->setLayout(tab3Layout); - setViewMode(SettingsCache::instance().getCardInfoViewMode()); + setViewMode(SettingsCache::instance().cardsDisplay().getCardInfoViewMode()); } void CardInfoFrameWidget::retranslateUi() @@ -127,7 +128,7 @@ void CardInfoFrameWidget::setViewMode(int mode) refreshLayout(); - SettingsCache::instance().setCardInfoViewMode(mode); + SettingsCache::instance().cardsDisplay().setCardInfoViewMode(mode); } static bool hasTransformation(const CardInfo &info) diff --git a/cockatrice/src/interface/widgets/cards/card_info_picture_enlarged_widget.cpp b/cockatrice/src/interface/widgets/cards/card_info_picture_enlarged_widget.cpp index 185bb20d1..40bef3df3 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_picture_enlarged_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_info_picture_enlarged_widget.cpp @@ -5,6 +5,7 @@ #include #include +#include /** * @brief Constructs a CardPictureEnlargedWidget. @@ -17,11 +18,12 @@ CardInfoPictureEnlargedWidget::CardInfoPictureEnlargedWidget(QWidget *parent) : setWindowFlags(Qt::ToolTip); // Keeps this widget on top of everything setAttribute(Qt::WA_TranslucentBackground); - connect(&SettingsCache::instance(), &SettingsCache::roundCardCornersChanged, this, [this](bool _roundCardCorners) { - Q_UNUSED(_roundCardCorners); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::roundCardCornersChanged, this, + [this](bool _roundCardCorners) { + Q_UNUSED(_roundCardCorners); - update(); - }); + update(); + }); } /** @@ -50,12 +52,29 @@ void CardInfoPictureEnlargedWidget::loadPixmap(const QSize &size) * @param size The desired size for the pixmap. * * Sets the widget's pixmap to the card image and resizes the widget to match the specified size. Triggers a repaint. + * + * When the image is not yet cached, the pixmap is cleared (instead of showing a stale previous card) and the widget + * refreshes automatically once the card image finishes loading. */ void CardInfoPictureEnlargedWidget::setCardPixmap(const ExactCard &_card, const QSize size) { + if (card.getCardPtr()) { + disconnect(card.getCardPtr().data(), nullptr, this, nullptr); + } + card = _card; + + // Clear any previous card's art so we never paint a stale pixmap while the new image loads + enlargedPixmap = QPixmap(); loadPixmap(size); + if (card.getCardPtr()) { + connect(card.getCardPtr().data(), &CardInfo::pixmapUpdated, this, [this]() { + loadPixmap(this->size()); + update(); + }); + } + setFixedSize(size); // Set the widget size to the enlarged size update(); // Trigger a repaint @@ -99,7 +118,8 @@ void CardInfoPictureEnlargedWidget::paintEvent(QPaintEvent *event) QPoint topLeft{(width() - scaledLogicalSize.width()) / 2, (height() - scaledLogicalSize.height()) / 2}; // Rounded corner radius based on logical width - qreal radius = SettingsCache::instance().getRoundCardCorners() ? 0.05 * scaledLogicalSize.width() : 0.0; + qreal radius = + SettingsCache::instance().cardsDisplay().getRoundCardCorners() ? 0.05 * scaledLogicalSize.width() : 0.0; QStylePainter painter(this); // Fill the background with transparent color to ensure rounded corners are rendered properly diff --git a/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp b/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp index 3bfd9ce7d..2dd21e78a 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp @@ -5,6 +5,7 @@ #include "../../../interface/card_picture_loader/card_picture_loader.h" #include "../../../interface/widgets/tabs/tab_supervisor.h" #include "../../window_main.h" +#include "card_art_utils.h" #include #include @@ -13,6 +14,7 @@ #include #include #include +#include #include static constexpr qreal MTG_CARD_ASPECT_RATIO = 1.396; @@ -66,11 +68,14 @@ CardInfoPictureWidget::CardInfoPictureWidget(QWidget *parent, const bool _hoverT animation->setStartValue(originalPos); animation->setEndValue(originalPos - QPoint(0, ANIMATION_OFFSET)); - connect(&SettingsCache::instance(), &SettingsCache::roundCardCornersChanged, this, [this](bool _roundCardCorners) { - Q_UNUSED(_roundCardCorners); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::roundCardCornersChanged, this, + [this](bool _roundCardCorners) { + Q_UNUSED(_roundCardCorners); - update(); - }); + update(); + }); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this, + &CardInfoPictureWidget::updatePixmap); } /** @@ -190,13 +195,8 @@ void CardInfoPictureWidget::paintEvent(QPaintEvent *event) } QPixmap transformedPixmap = resizedPixmap; // Default pixmap - if (SettingsCache::instance().getAutoRotateSidewaysLayoutCards()) { - if (exactCard.getInfo().getUiAttributes().landscapeOrientation) { - // Rotate pixmap 90 degrees to the left - QTransform transform; - transform.rotate(90); - transformedPixmap = resizedPixmap.transformed(transform, Qt::SmoothTransformation); - } + if (SettingsCache::instance().cardsDisplay().getAutoRotateSidewaysLayoutCards()) { + transformedPixmap = CardArtUtils::rotateSidewaysLayoutArt(resizedPixmap, exactCard); } // Handle DPI scaling @@ -220,7 +220,9 @@ void CardInfoPictureWidget::paintEvent(QPaintEvent *event) // Compute rounded corner radius // Ensure consistent rounding - qreal radius = SettingsCache::instance().getRoundCardCorners() ? 0.05 * static_cast(targetRect.width()) : 0.; + qreal radius = SettingsCache::instance().cardsDisplay().getRoundCardCorners() + ? 0.05 * static_cast(targetRect.width()) + : 0.; // Draw the pixmap with rounded corners QStylePainter painter(this); @@ -246,11 +248,7 @@ QSize CardInfoPictureWidget::sizeHint() const * @brief Starts the hover timer to show the enlarged pixmap on hover. * @param event The enter event. */ -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) void CardInfoPictureWidget::enterEvent(QEnterEvent *event) -#else -void CardInfoPictureWidget::enterEvent(QEvent *event) -#endif { QWidget::enterEvent(event); // Call the base class implementation diff --git a/cockatrice/src/interface/widgets/cards/card_info_picture_widget.h b/cockatrice/src/interface/widgets/cards/card_info_picture_widget.h index 1f065eed9..4fe84ed0b 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_picture_widget.h +++ b/cockatrice/src/interface/widgets/cards/card_info_picture_widget.h @@ -48,11 +48,7 @@ signals: protected: void resizeEvent(QResizeEvent *event) override; void paintEvent(QPaintEvent *) override; -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) - void enterEvent(QEnterEvent *event) override; // Qt6 signature -#else - void enterEvent(QEvent *event) override; // Qt5 signature -#endif + void enterEvent(QEnterEvent *event) override; void leaveEvent(QEvent *event) override; void moveEvent(QMoveEvent *event) override; void mouseMoveEvent(QMouseEvent *event) override; diff --git a/cockatrice/src/interface/widgets/cards/card_info_text_widget.cpp b/cockatrice/src/interface/widgets/cards/card_info_text_widget.cpp index c6af5320b..e98c3c02a 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_text_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_info_text_widget.cpp @@ -1,6 +1,7 @@ #include "card_info_text_widget.h" #include "../../../game_graphics/board/card_item.h" +#include "../../card_localization.h" #include #include @@ -10,7 +11,7 @@ #include #include -CardInfoTextWidget::CardInfoTextWidget(QWidget *parent) : QFrame(parent), info(nullptr) +CardInfoTextWidget::CardInfoTextWidget(QWidget *parent) : QFrame(parent) { propsLabel = new QLabel; propsLabel->setOpenExternalLinks(false); @@ -39,6 +40,12 @@ CardInfoTextWidget::CardInfoTextWidget(QWidget *parent) : QFrame(parent), info(n grid->setRowStretch(1, 1); retranslateUi(); + + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this, [this] { + if (currentCard) { + setCard(currentCard); + } + }); } void CardInfoTextWidget::setTexts(const QString &propsText, const QString &textText) @@ -60,7 +67,7 @@ void CardInfoTextWidget::setCard(const ExactCard &exactCard) QString text = ""; text += QString("") - .arg(tr("Name:"), card->getName().toHtmlEscaped()); + .arg(tr("Name:"), CardLocalization::displayName(card).toHtmlEscaped()); if (!exactCard.getPrinting().isEmpty()) { QString setShort = exactCard.getPrinting().getSet()->getShortName().toHtmlEscaped(); @@ -94,7 +101,8 @@ void CardInfoTextWidget::setCard(const ExactCard &exactCard) } text += "
%1%2
"; - setTexts(text, card->getText()); + setTexts(text, CardLocalization::displayText(card)); + currentCard = exactCard; } void CardInfoTextWidget::setInvalidCardName(const QString &cardName) diff --git a/cockatrice/src/interface/widgets/cards/card_info_text_widget.h b/cockatrice/src/interface/widgets/cards/card_info_text_widget.h index a9c29da37..683be5ab5 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_text_widget.h +++ b/cockatrice/src/interface/widgets/cards/card_info_text_widget.h @@ -23,7 +23,7 @@ private: QLabel *propsLabel; QScrollArea *propsScroll; QTextEdit *textLabel; - CardInfoPtr info; + ExactCard currentCard; ///< Last card set, re-rendered when the card language changes. void setTexts(const QString &propsText, const QString &textText); public: diff --git a/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.cpp b/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.cpp index eaf3a67b0..b00d9db1e 100644 --- a/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.cpp @@ -5,6 +5,7 @@ #include "libcockatrice/card/database/card_database_manager.h" #include +#include #include DeckCardZoneDisplayWidget::DeckCardZoneDisplayWidget(QWidget *parent, @@ -51,11 +52,6 @@ DeckCardZoneDisplayWidget::DeckCardZoneDisplayWidget(QWidget *parent, // User Interaction // ===================================================================================================================== -void DeckCardZoneDisplayWidget::onClick(QMouseEvent *event, const ExactCard &card) -{ - emit cardClicked(event, card, zoneName); -} - void DeckCardZoneDisplayWidget::onHover(const ExactCard &card) { emit cardHovered(card); @@ -95,12 +91,18 @@ void DeckCardZoneDisplayWidget::constructAppropriateWidget(QPersistentModelIndex } auto categoryName = index.sibling(index.row(), DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString(); + // Cards in a custom zone belong to that zone, not the board zone, so that + // increment/decrement/swap actions target the custom zone. + const bool isCustomZone = index.data(DeckRoles::IsCustomZoneRole).toBool(); + const QString effectiveZoneName = isCustomZone ? categoryName : zoneName; + const auto routeCardClick = [this, effectiveZoneName](QMouseEvent *event, const ExactCard &card) { + emit cardClicked(event, card, effectiveZoneName); + }; if (displayType == DisplayType::Overlap) { auto *displayWidget = new OverlappedCardGroupDisplayWidget( - cardGroupContainer, deckListModel, selectionModel, index, zoneName, categoryName, activeGroupCriteria, - activeSortCriteria, subBannerOpacity, cardSizeWidget); - connect(displayWidget, &OverlappedCardGroupDisplayWidget::cardClicked, this, - &DeckCardZoneDisplayWidget::onClick); + cardGroupContainer, deckListModel, selectionModel, index, effectiveZoneName, categoryName, + activeGroupCriteria, activeSortCriteria, subBannerOpacity, cardSizeWidget); + connect(displayWidget, &OverlappedCardGroupDisplayWidget::cardClicked, this, routeCardClick); connect(displayWidget, &OverlappedCardGroupDisplayWidget::cardHovered, this, &DeckCardZoneDisplayWidget::onHover); connect(displayWidget, &CardGroupDisplayWidget::cleanupRequested, this, @@ -111,9 +113,9 @@ void DeckCardZoneDisplayWidget::constructAppropriateWidget(QPersistentModelIndex indexToWidgetMap.insert(index, displayWidget); } else if (displayType == DisplayType::Flat) { auto *displayWidget = new FlatCardGroupDisplayWidget(cardGroupContainer, deckListModel, selectionModel, index, - zoneName, categoryName, activeGroupCriteria, + effectiveZoneName, categoryName, activeGroupCriteria, activeSortCriteria, subBannerOpacity, cardSizeWidget); - connect(displayWidget, &FlatCardGroupDisplayWidget::cardClicked, this, &DeckCardZoneDisplayWidget::onClick); + connect(displayWidget, &FlatCardGroupDisplayWidget::cardClicked, this, routeCardClick); connect(displayWidget, &FlatCardGroupDisplayWidget::cardHovered, this, &DeckCardZoneDisplayWidget::onHover); connect(displayWidget, &CardGroupDisplayWidget::cleanupRequested, this, &DeckCardZoneDisplayWidget::cleanupInvalidCardGroup); @@ -126,24 +128,18 @@ void DeckCardZoneDisplayWidget::constructAppropriateWidget(QPersistentModelIndex void DeckCardZoneDisplayWidget::displayCards() { - QSortFilterProxyModel proxy; - proxy.setSourceModel(deckListModel); - proxy.setSortRole(Qt::EditRole); - proxy.sort(DeckListModelColumns::CARD_NAME, Qt::AscendingOrder); + if (!trackedIndex.isValid()) { + return; + } - // 1. trackedIndex is a source index → map it to proxy space - QModelIndex proxyParent = proxy.mapFromSource(trackedIndex); - - // 2. iterate children under the proxy parent - for (int i = 0; i < proxy.rowCount(proxyParent); ++i) { - QModelIndex proxyIndex = proxy.index(i, 0, proxyParent); - - // 3. map back to source - QModelIndex sourceIndex = proxy.mapToSource(proxyIndex); - - // 4. persist the source index - QPersistentModelIndex persistent(sourceIndex); + // Iterate the direct children of the tracked zone, keeping the tree view's row + // order (criteria groups first, then custom zones, both in the model's sort order). + QList rows; + for (int i = 0; i < deckListModel->rowCount(trackedIndex); ++i) { + rows.append(QPersistentModelIndex(deckListModel->index(i, 0, trackedIndex))); + } + for (const QPersistentModelIndex &persistent : rows) { constructAppropriateWidget(persistent); } } diff --git a/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.h b/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.h index b426fca30..53f3fa7cf 100644 --- a/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.h +++ b/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.h @@ -42,7 +42,6 @@ public: void addCardsToOverlapWidget(); public slots: - void onClick(QMouseEvent *event, const ExactCard &card); void onHover(const ExactCard &card); void cleanupInvalidCardGroup(CardGroupDisplayWidget *displayWidget); void constructAppropriateWidget(QPersistentModelIndex index); diff --git a/cockatrice/src/interface/widgets/cards/deck_preview_card_picture_widget.cpp b/cockatrice/src/interface/widgets/cards/deck_preview_card_picture_widget.cpp index b27fe274f..1614836fc 100644 --- a/cockatrice/src/interface/widgets/cards/deck_preview_card_picture_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/deck_preview_card_picture_widget.cpp @@ -8,6 +8,7 @@ #include #include #include +#include /** * @brief Constructs a CardPictureWithTextOverlay widget. @@ -38,7 +39,8 @@ DeckPreviewCardPictureWidget::DeckPreviewCardPictureWidget(QWidget *parent, singleClickTimer = new QTimer(this); singleClickTimer->setSingleShot(true); connect(singleClickTimer, &QTimer::timeout, this, [this]() { emit imageClicked(lastMouseEvent, this); }); - connect(&SettingsCache::instance(), &SettingsCache::visualDeckStorageSelectionAnimationChanged, this, + connect(&SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::visualDeckStorageSelectionAnimationChanged, this, &CardInfoPictureWidget::setRaiseOnEnterEnabled); } diff --git a/cockatrice/src/interface/widgets/deck_analytics/abstract_analytics_panel_widget.cpp b/cockatrice/src/interface/widgets/deck_analytics/abstract_analytics_panel_widget.cpp index 089abc5c8..483fc71b0 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/abstract_analytics_panel_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_analytics/abstract_analytics_panel_widget.cpp @@ -1,5 +1,6 @@ #include "abstract_analytics_panel_widget.h" +#include "../../pixel_map_generator.h" #include "deck_list_statistics_analyzer.h" #include @@ -20,7 +21,7 @@ AbstractAnalyticsPanelWidget::AbstractAnalyticsPanelWidget(QWidget *parent, Deck // config button configureButton = new QPushButton(this); - configureButton->setIcon(QPixmap("theme:icons/cogwheel")); + configureButton->setIcon(themePixmap(QStringLiteral("icons/cogwheel"))); configureButton->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum); connect(configureButton, &QPushButton::clicked, this, &AbstractAnalyticsPanelWidget::applyConfigFromDialog); bannerAndSettingsLayout->addWidget(configureButton, 0); diff --git a/cockatrice/src/interface/widgets/deck_analytics/resizable_panel.cpp b/cockatrice/src/interface/widgets/deck_analytics/resizable_panel.cpp index f7bb5ac35..250fe1ad3 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/resizable_panel.cpp +++ b/cockatrice/src/interface/widgets/deck_analytics/resizable_panel.cpp @@ -147,11 +147,7 @@ bool ResizablePanel::eventFilter(QObject *obj, QEvent *event) if (event->type() == QEvent::MouseButtonPress) { auto *mouseEvent = static_cast(event); if (mouseEvent->button() == Qt::LeftButton) { -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) dragStartPos = mouseEvent->globalPosition().toPoint(); -#else - dragStartPos = mouseEvent->globalPos(); -#endif isDraggingPanel = false; dragButton->setCursor(Qt::ClosedHandCursor); } @@ -159,11 +155,7 @@ bool ResizablePanel::eventFilter(QObject *obj, QEvent *event) } else if (event->type() == QEvent::MouseMove) { auto *mouseEvent = static_cast(event); if (mouseEvent->buttons() & Qt::LeftButton) { -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) QPoint currentPos = mouseEvent->globalPosition().toPoint(); -#else - QPoint currentPos = mouseEvent->globalPos(); -#endif int distance = (currentPos - dragStartPos).manhattanLength(); if (distance >= 5 && !isDraggingPanel) { isDraggingPanel = true; @@ -182,22 +174,14 @@ bool ResizablePanel::eventFilter(QObject *obj, QEvent *event) if (obj == resizeHandle) { if (event->type() == QEvent::MouseButtonPress) { auto *mouseEvent = static_cast(event); -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) resizeStartY = mouseEvent->globalPosition().y(); -#else - resizeStartY = mouseEvent->globalPos().y(); -#endif isResizing = true; resizeStartHeight = currentHeight; resizeHandle->grabMouse(); return true; } else if (event->type() == QEvent::MouseMove && isResizing) { auto *mouseEvent = static_cast(event); -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) int deltaY = mouseEvent->globalPosition().y() - resizeStartY; -#else - int deltaY = mouseEvent->globalPos().y() - resizeStartY; -#endif int newHeight = resizeStartHeight + deltaY; int minAllowed = getMinimumAllowedHeight(); @@ -221,11 +205,7 @@ void ResizablePanel::dragEnterEvent(QDragEnterEvent *event) { if (event->mimeData()->hasFormat("application/x-resizablepanel")) { event->acceptProposedAction(); -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) showDropIndicator(event->position().y()); -#else - showDropIndicator(event->pos().y()); -#endif } } @@ -233,13 +213,8 @@ void ResizablePanel::dragMoveEvent(QDragMoveEvent *event) { if (event->mimeData()->hasFormat("application/x-resizablepanel")) { event->acceptProposedAction(); -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) showDropIndicator(event->position().y()); lastDragPos = mapToGlobal(event->position().toPoint()); -#else - showDropIndicator(event->pos().y()); - lastDragPos = mapToGlobal(event->pos()); -#endif if (!autoScrollTimer->isActive()) { autoScrollTimer->start(); @@ -265,11 +240,7 @@ void ResizablePanel::dropEvent(QDropEvent *event) ResizablePanel *draggedPanel = reinterpret_cast(ptr); if (draggedPanel && draggedPanel != this) { -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) bool insertBefore = (event->position().y() < height() / 2); -#else - bool insertBefore = (event->pos().y() < height() / 2); -#endif emit dropRequested(draggedPanel, this, insertBefore); event->acceptProposedAction(); } diff --git a/cockatrice/src/interface/widgets/deck_editor/card_database_view.cpp b/cockatrice/src/interface/widgets/deck_editor/card_database_view.cpp index a1c29e241..00388a3cd 100644 --- a/cockatrice/src/interface/widgets/deck_editor/card_database_view.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/card_database_view.cpp @@ -11,6 +11,7 @@ #include #include #include +#include static bool canBeCommander(const CardInfo &cardInfo) { @@ -89,6 +90,13 @@ void CardDatabaseView::decrementCard(const QString &zoneName) emit cardDecremented(currentCardName(), zoneName); } +void CardDatabaseView::setZoneMenuProvider(const std::function>()> &provider, + const std::function &newZoneHandler) +{ + zoneMenuProvider = provider; + this->newZoneHandler = newZoneHandler; +} + void CardDatabaseView::updateCard(const QModelIndex ¤t, const QModelIndex & /*previous*/) { if (!current.isValid()) { @@ -141,6 +149,50 @@ void CardDatabaseView::openCustomMenu(QPoint point) [this, card] { emit cardAdded(card->getName(), DECK_ZONE_SIDE); }); connect(selectPrinting, &QAction::triggered, this, &CardDatabaseView::selectPrintingClicked); + if (zoneMenuProvider) { + QMenu *addToZoneMenu = menu.addMenu(tr("Add to Zone")); + const auto zoneBoards = zoneMenuProvider(); + for (const QString &boardName : InnerDecklistNode::boardZoneNames()) { + // Boards with zones nest their children so no two menu entries + // share a visible name: "Maindeck ▸ { Maindeck (whole board), … }". + const QStringList customZones = [&zoneBoards, boardName] { + for (const auto &zoneBoard : zoneBoards) { + if (zoneBoard.first == boardName) { + return zoneBoard.second; + } + } + return QStringList(); + }(); + if (customZones.isEmpty()) { + QAction *action = addToZoneMenu->addAction(InnerDecklistNode::visibleNameFromName(boardName)); + connect(action, &QAction::triggered, this, + [this, card, boardName] { emit cardAdded(card->getName(), boardName); }); + } else { + QMenu *boardSubmenu = addToZoneMenu->addMenu(InnerDecklistNode::visibleNameFromName(boardName)); + QAction *wholeBoardAction = boardSubmenu->addAction(InnerDecklistNode::visibleNameFromName(boardName)); + connect(wholeBoardAction, &QAction::triggered, this, + [this, card, boardName] { emit cardAdded(card->getName(), boardName); }); + for (const QString &zoneName : customZones) { + QAction *action = boardSubmenu->addAction(zoneName); + connect(action, &QAction::triggered, this, + [this, card, zoneName] { emit cardAdded(card->getName(), zoneName); }); + } + } + } + + if (newZoneHandler) { + addToZoneMenu->addSeparator(); + + QAction *newZoneAction = addToZoneMenu->addAction(tr("Create &new zone...")); + connect(newZoneAction, &QAction::triggered, this, [this, card] { + const QString zoneName = newZoneHandler(); + if (!zoneName.isEmpty()) { + emit cardAdded(card->getName(), zoneName); + } + }); + } + } + if (canBeCommander(*card)) { QAction *edhRecCommander = menu.addAction(tr("Show on EDHRec (Commander)")); connect(edhRecCommander, &QAction::triggered, this, [this, card] { emit edhrecClicked(card, true); }); diff --git a/cockatrice/src/interface/widgets/deck_editor/card_database_view.h b/cockatrice/src/interface/widgets/deck_editor/card_database_view.h index 175ec12b9..668444199 100644 --- a/cockatrice/src/interface/widgets/deck_editor/card_database_view.h +++ b/cockatrice/src/interface/widgets/deck_editor/card_database_view.h @@ -4,6 +4,7 @@ #include "../../key_signals.h" #include +#include #include class CardDatabaseModel; @@ -19,6 +20,13 @@ class CardDatabaseView : public QTreeView KeySignals searchKeySignals; CardDatabaseDisplayModel *databaseDisplayModel; + /// Provides the custom zones available in the current deck, grouped by board zone. + /// The list contains (board zone name, custom zone names) pairs for every board. + std::function>()> zoneMenuProvider; + /// Handler invoked when the user picks "New zone..." from the add-to-zone menu. + /// Returns the name of the created zone, or an empty string if creation was cancelled. + std::function newZoneHandler; + public: explicit CardDatabaseView(QWidget *parent, CardDatabaseDisplayModel *model); @@ -33,6 +41,17 @@ public: return &searchKeySignals; } + /** + * @brief Sets the provider used to populate the "Add to zone" submenu of the context menu. + * If no provider is set, the submenu is not shown. + * + * @param provider Returns the custom zones of the current deck, grouped by board zone + * @param newZoneHandler Creates a new custom zone and returns its name, or an empty string + * if creation was cancelled. The menu entry is hidden when not provided. + */ + void setZoneMenuProvider(const std::function>()> &provider, + const std::function &newZoneHandler); + signals: void cardChanged(const QString &cardName); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_card_database_dock_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_card_database_dock_widget.cpp index 2a491de4f..6269f0323 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_card_database_dock_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_card_database_dock_widget.cpp @@ -1,5 +1,12 @@ #include "deck_editor_card_database_dock_widget.h" +#include "../../../interface/widgets/tabs/abstract_tab_deck_editor.h" +#include "card_database_view.h" +#include "deck_state_manager.h" +#include "deck_zone_dialog.h" + +#include + DeckEditorCardDatabaseDockWidget::DeckEditorCardDatabaseDockWidget(AbstractTabDeckEditor *parent) : QDockWidget(parent) { setObjectName("databaseDisplayDock"); @@ -15,6 +22,27 @@ void DeckEditorCardDatabaseDockWidget::createDatabaseDisplayDock(AbstractTabDeck { databaseDisplayWidget = new DeckEditorDatabaseDisplayWidget(this, deckEditor->databaseModel); + databaseDisplayWidget->getDatabaseView()->setZoneMenuProvider( + [deckEditor]() -> QList> { + QList> result; + auto *deckListModel = deckEditor->deckStateManager->getModel(); + for (const QString &boardName : InnerDecklistNode::boardZoneNames()) { + result.append({boardName, deckListModel->getCustomZoneNames(boardName)}); + } + return result; + }, + [this, deckEditor]() -> QString { + QString boardName; + const QString zoneName = + DeckZoneDialog::promptForNewZone(this, {}, &boardName, [deckEditor](const QString &candidate) { + return deckEditor->deckStateManager->validateNewZoneName(candidate); + }); + if (!zoneName.isEmpty()) { + deckEditor->deckStateManager->createCustomZone(boardName, zoneName); + } + return zoneName; + }); + auto *frame = new QVBoxLayout; frame->setObjectName("databaseDisplayFrame"); frame->addWidget(databaseDisplayWidget); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp index 9da821813..c8bdacf0d 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp @@ -28,7 +28,7 @@ DeckEditorDatabaseDisplayWidget::DeckEditorDatabaseDisplayWidget(QWidget *parent searchEdit->setPlaceholderText(tr("Search by card name (or search expressions)")); searchEdit->setClearButtonEnabled(true); searchEdit->addAction(loadColorAdjustedPixmap("theme:icons/search"), QLineEdit::LeadingPosition); - auto help = searchEdit->addAction(QPixmap("theme:icons/info"), QLineEdit::TrailingPosition); + auto help = searchEdit->addAction(themePixmap(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); setFocusProxy(searchEdit); setFocusPolicy(Qt::ClickFocus); @@ -59,13 +59,13 @@ DeckEditorDatabaseDisplayWidget::DeckEditorDatabaseDisplayWidget(QWidget *parent &DeckEditorDatabaseDisplayWidget::onRelatedCardClicked); aAddCard = new QAction(QString(), this); - aAddCard->setIcon(QPixmap("theme:icons/arrow_right_green")); + aAddCard->setIcon(themePixmap(QStringLiteral("icons/arrow_right_green"))); connect(aAddCard, &QAction::triggered, this, &DeckEditorDatabaseDisplayWidget::actAddCardToMainDeck); auto *tbAddCard = new QToolButton(this); tbAddCard->setDefaultAction(aAddCard); aAddCardToSideboard = new QAction(QString(), this); - aAddCardToSideboard->setIcon(QPixmap("theme:icons/arrow_right_blue")); + aAddCardToSideboard->setIcon(themePixmap(QStringLiteral("icons/arrow_right_blue"))); connect(aAddCardToSideboard, &QAction::triggered, this, &DeckEditorDatabaseDisplayWidget::actAddCardToSideboard); auto *tbAddCardToSideboard = new QToolButton(this); tbAddCardToSideboard->setDefaultAction(aAddCardToSideboard); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp index f751fa225..4b141e255 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp @@ -1,17 +1,29 @@ #include "deck_editor_deck_dock_widget.h" #include "../../../client/settings/cache_settings.h" +#include "../../../client/settings/shortcuts_settings.h" +#include "../../pixel_map_generator.h" +#include "../playmat/playmat_settings_dialog.h" +#include "../settings_page/user_interface_settings_page.h" +#include "../tabs/api/commander_spellbook/commander_bracket_widget.h" #include "deck_list_style_proxy.h" #include "deck_state_manager.h" +#include "deck_zone_dialog.h" #include #include #include #include +#include +#include #include #include #include -#include +#include +#include +#include +#include +#include static int findRestoreIndex(const CardRef &wanted, const QComboBox *combo) { @@ -108,18 +120,18 @@ void DeckEditorDeckDockWidget::createDeckDock() showBannerCardCheckBox = new QCheckBox(); showBannerCardCheckBox->setObjectName("showBannerCardCheckBox"); - showBannerCardCheckBox->setChecked(SettingsCache::instance().getDeckEditorBannerCardComboBoxVisible()); - connect(showBannerCardCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setDeckEditorBannerCardComboBoxVisible); - connect(&SettingsCache::instance(), &SettingsCache::deckEditorBannerCardComboBoxVisibleChanged, this, + showBannerCardCheckBox->setChecked(SettingsCache::instance().deckEditor().getBannerCardComboBoxVisible()); + connect(showBannerCardCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().deckEditor(), + &DeckEditorSettings::setBannerCardComboBoxVisible); + connect(&SettingsCache::instance().deckEditor(), &DeckEditorSettings::bannerCardComboBoxVisibleChanged, this, &DeckEditorDeckDockWidget::updateShowBannerCardComboBox); showTagsWidgetCheckBox = new QCheckBox(); showTagsWidgetCheckBox->setObjectName("showTagsWidgetCheckBox"); - showTagsWidgetCheckBox->setChecked(SettingsCache::instance().getDeckEditorTagsWidgetVisible()); - connect(showTagsWidgetCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setDeckEditorTagsWidgetVisible); - connect(&SettingsCache::instance(), &SettingsCache::deckEditorTagsWidgetVisibleChanged, this, + showTagsWidgetCheckBox->setChecked(SettingsCache::instance().deckEditor().getTagsWidgetVisible()); + connect(showTagsWidgetCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().deckEditor(), + &DeckEditorSettings::setTagsWidgetVisible); + connect(&SettingsCache::instance().deckEditor(), &DeckEditorSettings::tagsWidgetVisibleChanged, this, &DeckEditorDeckDockWidget::updateShowTagsWidget); quickSettingsWidget->addSettingsWidget(showBannerCardCheckBox); @@ -131,6 +143,8 @@ void DeckEditorDeckDockWidget::createDeckDock() formatComboBox->addItem(tr("Loading Database...")); formatComboBox->setEnabled(false); // Disable until loaded + commanderBracketWidget = new CommanderBracketWidget(this); + commentsLabel = new QLabel(); commentsLabel->setObjectName("commentsLabel"); commentsEdit = new QTextEdit; @@ -151,7 +165,7 @@ void DeckEditorDeckDockWidget::createDeckDock() bannerCardLabel = new QLabel(); bannerCardLabel->setObjectName("bannerCardLabel"); bannerCardLabel->setText(tr("Banner Card")); - bannerCardLabel->setHidden(!SettingsCache::instance().getDeckEditorBannerCardComboBoxVisible()); + bannerCardLabel->setHidden(!SettingsCache::instance().deckEditor().getBannerCardComboBoxVisible()); bannerCardComboBox = new QComboBox(this); connect(getModel(), &DeckListModel::cardNodesChanged, this, [this]() { // Delay the update to avoid race conditions @@ -162,10 +176,10 @@ void DeckEditorDeckDockWidget::createDeckDock() connect(bannerCardComboBox, QOverload::of(&QComboBox::currentIndexChanged), this, &DeckEditorDeckDockWidget::writeBannerCard); - bannerCardComboBox->setHidden(!SettingsCache::instance().getDeckEditorBannerCardComboBoxVisible()); + bannerCardComboBox->setHidden(!SettingsCache::instance().deckEditor().getBannerCardComboBoxVisible()); deckTagsDisplayWidget = new DeckPreviewDeckTagsDisplayWidget(this, {}); - deckTagsDisplayWidget->setHidden(!SettingsCache::instance().getDeckEditorTagsWidgetVisible()); + deckTagsDisplayWidget->setHidden(!SettingsCache::instance().deckEditor().getTagsWidgetVisible()); connect(deckTagsDisplayWidget, &DeckPreviewDeckTagsDisplayWidget::tagsChanged, deckStateManager, &DeckStateManager::setTags); @@ -179,25 +193,25 @@ void DeckEditorDeckDockWidget::createDeckDock() &DeckEditorDeckDockWidget::applyActiveGroupCriteria); aIncrement = new QAction(QString(), this); - aIncrement->setIcon(QPixmap("theme:icons/increment")); + aIncrement->setIcon(themePixmap(QStringLiteral("icons/increment"))); connect(aIncrement, &QAction::triggered, this, &DeckEditorDeckDockWidget::actIncrementSelection); auto *tbIncrement = new QToolButton(this); tbIncrement->setDefaultAction(aIncrement); aDecrement = new QAction(QString(), this); - aDecrement->setIcon(QPixmap("theme:icons/decrement")); + aDecrement->setIcon(themePixmap(QStringLiteral("icons/decrement"))); connect(aDecrement, &QAction::triggered, this, &DeckEditorDeckDockWidget::actDecrementSelection); auto *tbDecrement = new QToolButton(this); tbDecrement->setDefaultAction(aDecrement); aRemoveCard = new QAction(QString(), this); - aRemoveCard->setIcon(QPixmap("theme:icons/remove_row")); + aRemoveCard->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); connect(aRemoveCard, &QAction::triggered, this, &DeckEditorDeckDockWidget::actRemoveCard); auto *tbRemoveCard = new QToolButton(this); tbRemoveCard->setDefaultAction(aRemoveCard); aSwapCard = new QAction(QString(), this); - aSwapCard->setIcon(QPixmap("theme:icons/swap")); + aSwapCard->setIcon(themePixmap(QStringLiteral("icons/swap"))); connect(aSwapCard, &QAction::triggered, this, &DeckEditorDeckDockWidget::actSwapSelection); auto *tbSwapCard = new QToolButton(this); tbSwapCard->setDefaultAction(aSwapCard); @@ -216,13 +230,23 @@ void DeckEditorDeckDockWidget::createDeckDock() upperLayout->addWidget(formatLabel, 2, 0); upperLayout->addWidget(formatComboBox, 2, 1); - upperLayout->addWidget(bannerCardLabel, 3, 0); - upperLayout->addWidget(bannerCardComboBox, 3, 1); + upperLayout->addWidget(commanderBracketWidget, 3, 0, 1, 2); - upperLayout->addWidget(deckTagsDisplayWidget, 4, 1); + upperLayout->addWidget(bannerCardLabel, 4, 0); + upperLayout->addWidget(bannerCardComboBox, 4, 1); - upperLayout->addWidget(activeGroupCriteriaLabel, 5, 0); - upperLayout->addWidget(activeGroupCriteriaComboBox, 5, 1); + playmatLabel = new QLabel(); + playmatLabel->setObjectName("playmatLabel"); + playmatLabel->setText(tr("Playmat")); + playmatSettingsButton = new QPushButton(tr("Edit Playmat...")); + connect(playmatSettingsButton, &QPushButton::clicked, this, &DeckEditorDeckDockWidget::openPlaymatSettings); + upperLayout->addWidget(playmatLabel, 5, 0); + upperLayout->addWidget(playmatSettingsButton, 5, 1); + + upperLayout->addWidget(deckTagsDisplayWidget, 6, 1); + + upperLayout->addWidget(activeGroupCriteriaLabel, 7, 0); + upperLayout->addWidget(activeGroupCriteriaComboBox, 7, 1); hashLabel1 = new QLabel(); hashLabel1->setObjectName("hashLabel1"); @@ -300,15 +324,19 @@ void DeckEditorDeckDockWidget::initializeFormats() // Ensure no selection is visible initially formatComboBox->setCurrentIndex(-1); } - connect(formatComboBox, QOverload::of(&QComboBox::currentIndexChanged), this, [this](int index) { + QString formatKey; if (index >= 0) { - QString formatKey = formatComboBox->itemData(index).toString(); + formatKey = formatComboBox->itemData(index).toString(); deckStateManager->setFormat(formatKey); } else { deckStateManager->setFormat(""); // clear format if deselected } + + commanderBracketWidget->setDeck(deckStateManager->getDeckListShared()); }); + + commanderBracketWidget->setDeck(deckStateManager->getDeckListShared()); } ExactCard DeckEditorDeckDockWidget::getCurrentCard() @@ -317,7 +345,9 @@ ExactCard DeckEditorDeckDockWidget::getCurrentCard() if (!current.isValid()) { return {}; } - const QString cardName = current.siblingAtColumn(DeckListModelColumns::CARD_NAME).data().toString(); + // The display role holds the localized card name; the edit role always carries the + // canonical English name needed to look the card up in the database. + const QString cardName = current.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString(); const QString cardProviderID = current.siblingAtColumn(DeckListModelColumns::CARD_PROVIDER_ID).data().toString(); const QModelIndex gparent = current.parent().parent(); @@ -427,6 +457,35 @@ void DeckEditorDeckDockWidget::writeBannerCard(int index) deckStateManager->setBannerCard(bannerCard); } +void DeckEditorDeckDockWidget::openPlaymatSettings() +{ + PlaymatInfo current = deckStateManager->getMetadata().playmat; + + PlaymatSettingsDialog dialog(current.card, current.params, this); + if (dialog.exec() == QDialog::Accepted) { + CardRef newCard = dialog.card(); + PlaymatParams newParams = dialog.params(); + + if (newCard.isEmpty()) { + deckStateManager->setPlaymat(PlaymatInfo{}); + } else { + deckStateManager->setPlaymat({newCard, newParams}); + } + + updatePlaymatLabel(); + } +} + +void DeckEditorDeckDockWidget::updatePlaymatLabel() +{ + CardRef playmat = deckStateManager->getMetadata().playmat.card; + if (playmat.isEmpty()) { + playmatSettingsButton->setText(tr("Edit Playmat...")); + } else { + playmatSettingsButton->setText(tr("Edit Playmat (%1)").arg(playmat.name)); + } +} + void DeckEditorDeckDockWidget::applyActiveGroupCriteria() { getModel()->setActiveGroupCriteria( @@ -461,8 +520,11 @@ void DeckEditorDeckDockWidget::syncBannerCardComboBoxSelectionWithDeck() void DeckEditorDeckDockWidget::setSelectedIndex(const QModelIndex &newCardIndex, bool preserveWidgetFocus) { + const QModelIndex proxyIndex = proxy->mapFromSource(newCardIndex); + deckView->clearSelection(); - deckView->setCurrentIndex(newCardIndex); + deckView->setCurrentIndex(proxyIndex); + deckView->scrollTo(proxyIndex); recursiveExpand(newCardIndex); if (!preserveWidgetFocus) { @@ -484,12 +546,15 @@ void DeckEditorDeckDockWidget::syncDisplayWidgetsToModel() syncBannerCardComboBoxSelectionWithDeck(); updateBannerCardComboBox(); bannerCardComboBox->blockSignals(false); + updatePlaymatLabel(); updateHash(); formatComboBox->blockSignals(true); formatComboBox->setCurrentIndex(formatComboBox->findData(deckStateManager->getMetadata().gameFormat)); formatComboBox->blockSignals(false); + commanderBracketWidget->setDeck(deckStateManager->getDeckListShared()); + deckTagsDisplayWidget->blockSignals(true); deckTagsDisplayWidget->setTags(deckStateManager->getMetadata().tags); deckTagsDisplayWidget->blockSignals(false); @@ -716,14 +781,213 @@ void DeckEditorDeckDockWidget::offsetCountAtIndex(const QModelIndex &idx, bool i void DeckEditorDeckDockWidget::decklistCustomMenu(QPoint point) { + const QModelIndex sourceIndex = proxy->mapToSource(deckView->indexAt(point)); + QMenu menu; + const bool isCustomZoneRow = sourceIndex.isValid() && sourceIndex.data(DeckRoles::IsCustomZoneRole).toBool(); + const bool isBoardZoneRow = sourceIndex.isValid() && !isCustomZoneRow && !sourceIndex.parent().isValid(); + const bool isCardRow = + sourceIndex.isValid() && !isCustomZoneRow && !isBoardZoneRow && !getModel()->hasChildren(sourceIndex); + + // Walk the row up to its top-level node to find the hosting board. Cards in + // the tokens board cannot be moved (moveCardToZone bails for it), so the + // move menu is skipped for them. + QString currentBoardName; + QModelIndex board = sourceIndex.parent(); + while (board.isValid() && board.parent().isValid()) { + board = board.parent(); + } + if (board.isValid()) { + currentBoardName = board.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString(); + } + + if (isCardRow) { + if (currentBoardName != DECK_ZONE_TOKENS) { + addMoveToZoneMenu(&menu, sourceIndex, currentBoardName); + menu.addSeparator(); + } + } else if (isCustomZoneRow) { + const QString zoneName = + sourceIndex.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString(); + + QAction *renameAction = menu.addAction(tr("&Rename zone...")); + connect(renameAction, &QAction::triggered, this, [this, zoneName] { + // The unchanged name must not validate as a duplicate. + const QString newName = + DeckZoneDialog::promptForRename(this, zoneName, [this, zoneName](const QString &candidate) { + return candidate == zoneName ? QString() : deckStateManager->validateNewZoneName(candidate); + }); + if (!newName.isEmpty() && newName != zoneName) { + deckStateManager->renameCustomZone(zoneName, newName); + } + }); + + QMenu *boardMenu = menu.addMenu(tr("Change &board")); + addChangeBoardMenu(boardMenu, zoneName); + + QAction *deleteAction = menu.addAction(tr("&Delete zone")); + const bool zoneHasCards = getModel()->hasChildren(sourceIndex); + deleteAction->setEnabled(!zoneHasCards); + if (zoneHasCards) { + deleteAction->setToolTip(tr("Move or remove all cards first.")); + menu.setToolTipsVisible(true); + } + connect(deleteAction, &QAction::triggered, this, [this, zoneName] { + const auto result = + QMessageBox::warning(this, tr("Delete zone"), tr("Delete the zone \"%1\"?").arg(zoneName), + QMessageBox::Yes | QMessageBox::No, QMessageBox::No); + if (result == QMessageBox::Yes) { + deckStateManager->removeCustomZone(zoneName); + } + }); + menu.addSeparator(); + } else if (isBoardZoneRow) { + const QString boardName = + sourceIndex.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString(); + // Tokens cannot host custom zones, so only offer the action on real boards. + const bool canHostCustomZones = + boardName == DECK_ZONE_MAIN || boardName == DECK_ZONE_SIDE || boardName == DECK_ZONE_MAYBEBOARD; + if (canHostCustomZones) { + addNewZoneAction(&menu, boardName); + menu.addSeparator(); + } + } else if (!sourceIndex.isValid()) { + addNewZoneAction(&menu); + menu.addSeparator(); + } + QAction *selectPrinting = menu.addAction(tr("Select Printing")); connect(selectPrinting, &QAction::triggered, deckEditor, &AbstractTabDeckEditor::showPrintingSelector); menu.exec(deckView->mapToGlobal(point)); } +void DeckEditorDeckDockWidget::addMoveToZoneMenu(QMenu *menu, + const QModelIndex &sourceCardIndex, + const QString ¤tBoardName) +{ + // The card's current *zone*, derived with the same ancestor walk as + // DeckStateManager::moveCardToZone (nearest custom-zone ancestor, else the + // top-level board/zone): a card inside "Removal" under the maindeck lives in + // "Removal", not "main". Comparing against that instead of the board keeps + // the enabled state and the same-zone no-op consistent with the move logic. + QString currentZoneName; + for (QModelIndex ancestor = sourceCardIndex.parent(); ancestor.isValid(); ancestor = ancestor.parent()) { + if (ancestor.data(DeckRoles::IsCustomZoneRole).toBool() || !ancestor.parent().isValid()) { + currentZoneName = ancestor.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString(); + break; + } + } + + const auto addMoveAction = [this, sourceCardIndex](QMenu *targetMenu, const QString &targetZoneName, + const QString &label, bool enabled) { + QAction *action = targetMenu->addAction(label); + action->setEnabled(enabled); + if (enabled) { + connect(action, &QAction::triggered, this, [this, sourceCardIndex, targetZoneName] { + deckStateManager->moveCardToZone(sourceCardIndex, targetZoneName); + }); + } + }; + + const auto tree = deckStateManager->getDeckListShared()->getTree(); + + QMenu *moveMenu = menu->addMenu(tr("Move to &zone")); + + for (const QString &boardName : InnerDecklistNode::boardZoneNames()) { + const QString boardLabel = InnerDecklistNode::visibleNameFromName(boardName); + const auto customZones = tree->getCustomZones(boardName); + + // Boards with zones nest their children so no two menu entries share a + // visible name: "Maindeck ▸ { Maindeck (whole board), Removal, … }". + // The board the card already lives on is marked instead of offered. + if (!customZones.isEmpty()) { + QMenu *boardSubmenu = moveMenu->addMenu(boardLabel); + addMoveAction(boardSubmenu, boardName, boardLabel, boardName != currentZoneName); + for (const auto *customZone : customZones) { + addMoveAction(boardSubmenu, customZone->getName(), customZone->getName(), + customZone->getName() != currentZoneName); + } + } else { + addMoveAction(moveMenu, boardName, boardLabel, boardName != currentZoneName); + } + } + + moveMenu->addSeparator(); + + QAction *newZoneAction = moveMenu->addAction(tr("Create new zone and move &here...")); + connect(newZoneAction, &QAction::triggered, this, [this, sourceCardIndex, currentBoardName, currentZoneName] { + // Resolve the card's identity before creating the zone: + // createNewCustomZone rebuilds the model tree, so sourceCardIndex's + // internal pointer is freed by the time it would be used. + const QString cardName = + sourceCardIndex.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString(); + const QString providerId = + sourceCardIndex.siblingAtColumn(DeckListModelColumns::CARD_PROVIDER_ID).data(Qt::DisplayRole).toString(); + const QString collectorNumber = sourceCardIndex.siblingAtColumn(DeckListModelColumns::CARD_COLLECTOR_NUMBER) + .data(Qt::DisplayRole) + .toString(); + + const QString zoneName = createNewCustomZone(currentBoardName); + if (!zoneName.isEmpty()) { + // Re-find the card: the old index is no longer safe since rows were + // rebuilt. Mirror DeckStateManager::decrementCard's re-find pattern. + const QModelIndex refreshed = getModel()->findCard(cardName, currentZoneName, providerId, collectorNumber); + if (refreshed.isValid()) { + deckStateManager->moveCardToZone(refreshed, zoneName); + } + } + }); +} + +void DeckEditorDeckDockWidget::addChangeBoardMenu(QMenu *menu, const QString &zoneName) +{ + const auto tree = deckStateManager->getDeckListShared()->getTree(); + for (const QString &boardName : InnerDecklistNode::boardZoneNames()) { + QAction *action = menu->addAction(InnerDecklistNode::visibleNameFromName(boardName)); + + // The board currently holding the zone is marked instead of offered. + // Duplicate names cannot come up through the editor, so this doubles as + // the uniqueness guard for imported decks. + bool holdsTheZone = false; + for (const auto *customZone : tree->getCustomZones(boardName)) { + if (customZone->getName() == zoneName) { + holdsTheZone = true; + break; + } + } + if (holdsTheZone) { + action->setCheckable(true); + action->setChecked(true); + continue; + } + + connect(action, &QAction::triggered, this, + [this, zoneName, boardName] { deckStateManager->moveCustomZone(zoneName, boardName); }); + } +} + +void DeckEditorDeckDockWidget::addNewZoneAction(QMenu *menu, const QString &initialBoardName) +{ + QAction *newZoneAction = menu->addAction(tr("Create &new zone...")); + connect(newZoneAction, &QAction::triggered, this, + [this, initialBoardName] { createNewCustomZone(initialBoardName); }); +} + +QString DeckEditorDeckDockWidget::createNewCustomZone(const QString &initialBoardName) +{ + QString boardName; + const QString zoneName = + DeckZoneDialog::promptForNewZone(this, initialBoardName, &boardName, [this](const QString &candidate) { + return deckStateManager->validateNewZoneName(candidate); + }); + if (!zoneName.isEmpty()) { + deckStateManager->createCustomZone(boardName, zoneName); + } + return zoneName; +} + void DeckEditorDeckDockWidget::refreshShortcuts() { ShortcutsSettings &shortcuts = SettingsCache::instance().shortcuts(); @@ -743,6 +1007,7 @@ void DeckEditorDeckDockWidget::retranslateUi() commentsLabel->setText(tr("&Comments:")); activeGroupCriteriaLabel->setText(tr("Group by:")); formatLabel->setText(tr("Format:")); + commanderBracketWidget->retranslateUi(); hashLabel1->setText(tr("Hash:")); @@ -750,4 +1015,4 @@ void DeckEditorDeckDockWidget::retranslateUi() aDecrement->setText(tr("&Decrement number")); aRemoveCard->setText(tr("&Remove row")); aSwapCard->setText(tr("Swap card to/from sideboard")); -} \ No newline at end of file +} diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.h b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.h index 8dddf5882..1e5f4e677 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.h +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.h @@ -15,12 +15,18 @@ #include "deck_list_history_manager_widget.h" #include "deck_list_style_proxy.h" +#include +#include #include #include +#include +#include #include #include #include +#include +class CommanderBracketWidget; class DeckListModel; class AbstractTabDeckEditor; class DeckEditorDeckDockWidget : public QDockWidget @@ -32,6 +38,8 @@ public: DeckListStyleProxy *proxy; QTreeView *deckView; QComboBox *bannerCardComboBox; + QLabel *playmatLabel; + QPushButton *playmatSettingsButton; void createDeckDock(); ExactCard getCurrentCard(); void retranslateUi(); @@ -89,16 +97,25 @@ private: QAction *aRemoveCard, *aIncrement, *aDecrement, *aSwapCard; + CommanderBracketWidget *commanderBracketWidget; + DeckListModel *getModel() const; [[nodiscard]] QModelIndexList getSelectedCardNodeSourceIndices() const; void offsetCountAtIndex(const QModelIndex &idx, bool isIncrement); + void addMoveToZoneMenu(QMenu *menu, const QModelIndex &sourceCardIndex, const QString ¤tBoardName); + void addChangeBoardMenu(QMenu *menu, const QString &zoneName); + QString createNewCustomZone(const QString &initialBoardName = {}); + void addNewZoneAction(QMenu *menu, const QString &initialBoardName = {}); + private slots: void decklistCustomMenu(QPoint point); void updateCard(QModelIndex, const QModelIndex ¤t); void writeName(); void writeComments(); void writeBannerCard(int); + void openPlaymatSettings(); + void updatePlaymatLabel(); void applyActiveGroupCriteria(); void setSelectedIndex(const QModelIndex &newCardIndex, bool preserveWidgetFocus); void updateHash(); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp index 6b18db82e..c6b6e4416 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp @@ -1,8 +1,10 @@ #include "deck_editor_filter_dock_widget.h" #include "../../../client/settings/cache_settings.h" +#include "../../../client/settings/shortcuts_settings.h" #include "../../../filters/filter_builder.h" #include "../../../filters/filter_tree_model.h" +#include "../../pixel_map_generator.h" #include #include @@ -41,11 +43,11 @@ void DeckEditorFilterDockWidget::createFiltersDock() connect(filterBuilder, &FilterBuilder::add, filterModel, &FilterTreeModel::addFilter); aClearFilterOne = new QAction(QString(), this); - aClearFilterOne->setIcon(QPixmap("theme:icons/decrement")); + aClearFilterOne->setIcon(themePixmap(QStringLiteral("icons/decrement"))); connect(aClearFilterOne, &QAction::triggered, this, &DeckEditorFilterDockWidget::actClearFilterOne); aClearFilterAll = new QAction(QString(), this); - aClearFilterAll->setIcon(QPixmap("theme:icons/clearsearch")); + aClearFilterAll->setIcon(themePixmap(QStringLiteral("icons/clearsearch"))); connect(aClearFilterAll, &QAction::triggered, this, &DeckEditorFilterDockWidget::actClearFilterAll); auto *filterDelOne = new QToolButton(); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_printing_selector_dock_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_printing_selector_dock_widget.cpp index a91601f2e..06d8d1de7 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_printing_selector_dock_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_printing_selector_dock_widget.cpp @@ -5,6 +5,7 @@ #include "printing_disabled_info_widget.h" #include +#include DeckEditorPrintingSelectorDockWidget::DeckEditorPrintingSelectorDockWidget(AbstractTabDeckEditor *parent) : QDockWidget(parent), deckEditor(parent) @@ -18,8 +19,9 @@ DeckEditorPrintingSelectorDockWidget::DeckEditorPrintingSelectorDockWidget(Abstr createPrintingSelectorDock(); printingDisabledInfoWidget = new PrintingDisabledInfoWidget(this); - setVisibleWidget(SettingsCache::instance().getOverrideAllCardArtWithPersonalPreference()); - connect(&SettingsCache::instance(), &SettingsCache::overrideAllCardArtWithPersonalPreferenceChanged, this, + setVisibleWidget(SettingsCache::instance().cardsDisplay().getOverrideAllCardArtWithPersonalPreference()); + connect(&SettingsCache::instance().cardsDisplay(), + &CardsDisplaySettings::overrideAllCardArtWithPersonalPreferenceChanged, this, &DeckEditorPrintingSelectorDockWidget::setVisibleWidget); retranslateUi(); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_list_history_manager_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_list_history_manager_widget.cpp index cef459752..c93f12b34 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_list_history_manager_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_list_history_manager_widget.cpp @@ -1,5 +1,6 @@ #include "deck_list_history_manager_widget.h" +#include "../../pixel_map_generator.h" #include "deck_state_manager.h" DeckListHistoryManagerWidget::DeckListHistoryManagerWidget(DeckStateManager *_deckStateManager, @@ -10,7 +11,7 @@ DeckListHistoryManagerWidget::DeckListHistoryManagerWidget(DeckStateManager *_de layout = new QHBoxLayout(this); aUndo = new QAction(QString(), this); - aUndo->setIcon(QPixmap("theme:icons/arrow_undo")); + aUndo->setIcon(themePixmap(QStringLiteral("icons/arrow_undo"))); aUndo->setShortcut(QKeySequence::Undo); aUndo->setShortcutContext(Qt::ApplicationShortcut); connect(aUndo, &QAction::triggered, this, &DeckListHistoryManagerWidget::doUndo); @@ -19,7 +20,7 @@ DeckListHistoryManagerWidget::DeckListHistoryManagerWidget(DeckStateManager *_de undoButton->setDefaultAction(aUndo); aRedo = new QAction(QString(), this); - aRedo->setIcon(QPixmap("theme:icons/arrow_redo")); + aRedo->setIcon(themePixmap(QStringLiteral("icons/arrow_redo"))); aRedo->setShortcut(QKeySequence::Redo); aRedo->setShortcutContext(Qt::ApplicationShortcut); connect(aRedo, &QAction::triggered, this, &DeckListHistoryManagerWidget::doRedo); @@ -31,7 +32,7 @@ DeckListHistoryManagerWidget::DeckListHistoryManagerWidget(DeckStateManager *_de layout->addWidget(redoButton); historyButton = new SettingsButtonWidget(this); - historyButton->setButtonIcon(QPixmap("theme:icons/arrow_history")); + historyButton->setButtonIcon(themePixmap(QStringLiteral("icons/arrow_history"))); historyLabel = new QLabel(this); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp index f8fb450ce..cfe989e9f 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp @@ -1,12 +1,20 @@ #include "deck_state_manager.h" +#include "../../../client/settings/cache_settings.h" + #include #include +#include +#include DeckStateManager::DeckStateManager(QObject *parent) : QObject(parent), deckList(QSharedPointer(new DeckList)), deckListModel(new DeckListModel(this, deckList)), historyManager(new DeckListHistoryManager(this)) { + deckListModel->setDisplayLanguage(SettingsCache::instance().cardsDisplay().getCardLang()); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, deckListModel, + [this](const QString &lang) { deckListModel->setDisplayLanguage(lang); }); + connect(historyManager, &DeckListHistoryManager::undoRedoStateChanged, this, [this] { setModified(true); emit historyChanged(); @@ -142,6 +150,19 @@ void DeckStateManager::setBannerCard(const CardRef &bannerCard) doMetadataModified(); } +void DeckStateManager::setPlaymat(const PlaymatInfo &playmat) +{ + PlaymatInfo previous = deckList->getPlaymat(); + if (previous == playmat) { + return; + } + + requestHistorySave(tr("Set playmat to %1").arg(playmat.card.name)); + deckList->setPlaymat(playmat); + + doMetadataModified(); +} + void DeckStateManager::setTags(const QStringList &tags) { QStringList previous = deckList->getTags(); @@ -246,7 +267,10 @@ bool DeckStateManager::swapCardAtIndex(const QModelIndex &idx) return false; } - QString cardName = idx.siblingAtColumn(DeckListModelColumns::CARD_NAME).data().toString(); + // The display role holds the localized card name; the edit role always carries the + // canonical English name needed to look the card up in the database. + QString displayCardName = idx.siblingAtColumn(DeckListModelColumns::CARD_NAME).data().toString(); + QString cardName = idx.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString(); QString providerId = idx.siblingAtColumn(DeckListModelColumns::CARD_PROVIDER_ID).data().toString(); QModelIndex gparent = idx.parent().parent(); @@ -263,7 +287,7 @@ bool DeckStateManager::swapCardAtIndex(const QModelIndex &idx) QString reason = tr("Moved to %1 1 × \"%2\" (%3)") // .arg(otherZoneName) - .arg(cardName) + .arg(displayCardName) .arg(providerId); return modifyDeck(reason, [&idx, &cardName, &providerId, &otherZoneName](auto model) { @@ -277,9 +301,8 @@ bool DeckStateManager::removeCardAtIndex(const QModelIndex &idx) return false; } - QString cardName = idx.siblingAtColumn(DeckListModelColumns::CARD_NAME).data().toString(); - - QString reason = tr("Removed \"%1\" (all copies)").arg(cardName); + QString reason = + tr("Removed \"%1\" (all copies)").arg(idx.siblingAtColumn(DeckListModelColumns::CARD_NAME).data().toString()); return modifyDeck(reason, [&idx](auto model) { return model->removeRow(idx.row(), idx.parent()); }); } @@ -294,6 +317,170 @@ bool DeckStateManager::decrementCountAtIndex(const QModelIndex &idx) return offsetCountAtIndex(idx, -1); } +bool DeckStateManager::moveCardToZone(const QModelIndex &idx, const QString &targetZoneName) +{ + if (!idx.isValid()) { + return false; + } + + // Only actual card rows can be moved. Group or zone rows report an + // aggregate amount and must never be deleted by this operation. + if (!idx.data(DeckRoles::IsCardRole).toBool()) { + return false; + } + + QString cardName = idx.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString(); + QString providerId = idx.siblingAtColumn(DeckListModelColumns::CARD_PROVIDER_ID).data(Qt::DisplayRole).toString(); + int copies = idx.siblingAtColumn(DeckListModelColumns::CARD_AMOUNT).data(Qt::EditRole).toInt(); + + if (copies <= 0) { + return false; + } + + // Tokens only live in the tokens zone and cannot be moved into decks. + CardInfoPtr info = CardDatabaseManager::query()->getCardInfo(cardName); + if (info && info->getIsToken()) { + return false; + } + + // Determine the zone the card currently lives in: the enclosing custom + // zone, or the nearest top-level zone (board zone or legacy zone). + QString currentZoneName; + for (QModelIndex ancestor = idx.parent(); ancestor.isValid(); ancestor = ancestor.parent()) { + bool isCustomZone = ancestor.data(DeckRoles::IsCustomZoneRole).toBool(); + if (isCustomZone || !ancestor.parent().isValid()) { + currentZoneName = ancestor.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString(); + break; + } + } + + if (currentZoneName == targetZoneName) { + return false; + } + + QString reason = tr("Moved %1 × \"%2\" (%3) to %4") + .arg(copies) + .arg(cardName) + .arg(providerId) + .arg(InnerDecklistNode::visibleNameFromName(targetZoneName)); + + return modifyDeck(reason, [&idx, &cardName, &providerId, &targetZoneName, copies](auto model) { + if (!model->removeRow(idx.row(), idx.parent())) { + return false; + } + + if (ExactCard card = CardDatabaseManager::query()->getCard({cardName, providerId})) { + for (int i = 0; i < copies; ++i) { + model->addCard(card, targetZoneName); + } + } else { + for (int i = 0; i < copies; ++i) { + model->addPreferredPrintingCard(cardName, targetZoneName, true); + } + } + + return true; + }); +} + +bool DeckStateManager::createCustomZone(const QString &boardZoneName, const QString &zoneName) +{ + const QString trimmedZoneName = zoneName.trimmed(); + if (trimmedZoneName.isEmpty()) { + return false; + } + + QString reason = + tr("Created zone \"%1\" in %2").arg(trimmedZoneName, InnerDecklistNode::visibleNameFromName(boardZoneName)); + + return modifyTree(reason, [&boardZoneName, &trimmedZoneName](DecklistNodeTree *tree) { + return tree->addCustomZone(boardZoneName, trimmedZoneName) != nullptr; + }); +} + +bool DeckStateManager::renameCustomZone(const QString &oldZoneName, const QString &newZoneName) +{ + const QString trimmedNewZoneName = newZoneName.trimmed(); + if (trimmedNewZoneName.isEmpty() || oldZoneName == trimmedNewZoneName) { + return false; + } + + QString reason = tr("Renamed zone \"%1\" to \"%2\"").arg(oldZoneName, trimmedNewZoneName); + + return modifyTree(reason, [&oldZoneName, &trimmedNewZoneName](DecklistNodeTree *tree) { + return tree->renameCustomZone(oldZoneName, trimmedNewZoneName); + }); +} + +bool DeckStateManager::moveCustomZone(const QString &zoneName, const QString &newBoardZoneName) +{ + const auto *tree = deckList->getTree(); + + // Locate the zone through the tree's own lookup, which walks every top-level + // zone (not just the standard boards) and covers the same-board no-op below. + const auto *zone = tree->findCustomZoneByName(zoneName); + if (!zone) { + return false; + } + + // Same-board moves are no-ops and must not pollute the history. + const QString currentBoardName = zone->getParent() ? zone->getParent()->getName() : QString(); + if (currentBoardName == newBoardZoneName) { + return true; + } + + // Zone names are deck-unique among zones created through this manager, so a + // same-named zone on the target board can only come from an imported deck. + // Refuse the move instead of silently stacking same-named zones. + for (const auto *targetZone : tree->getCustomZones(newBoardZoneName)) { + if (targetZone->getName() == zoneName) { + return false; + } + } + + QString reason = + tr("Moved zone \"%1\" to %2").arg(zoneName, InnerDecklistNode::visibleNameFromName(newBoardZoneName)); + + return modifyTree(reason, [&zoneName, &newBoardZoneName](DecklistNodeTree *tree) { + return tree->moveCustomZone(zoneName, newBoardZoneName); + }); +} + +bool DeckStateManager::removeCustomZone(const QString &zoneName) +{ + QString reason = tr("Deleted zone \"%1\"").arg(zoneName); + + return modifyTree(reason, [&zoneName](DecklistNodeTree *tree) { return tree->removeCustomZone(zoneName); }); +} + +QString DeckStateManager::validateNewZoneName(const QString &zoneName) const +{ + if (zoneName.trimmed().isEmpty()) { + return tr("Enter a zone name."); + } + + const QString trimmedZoneName = zoneName.trimmed(); + + // The standard zone names are reserved even before they exist. + if (trimmedZoneName == DECK_ZONE_MAIN || trimmedZoneName == DECK_ZONE_SIDE || + trimmedZoneName == DECK_ZONE_MAYBEBOARD || trimmedZoneName == DECK_ZONE_TOKENS) { + return tr("This name is reserved."); + } + + const auto *tree = deckList->getTree(); + + // Reuse the tree's own uniqueness contract: any top-level zone and any + // custom zone on *every* board claims the name (hasZoneName also reserves + // the standard board names, which we already rejected with a dedicated + // message above). Scanning only the standard boards here would miss a + // custom zone an imported deck carries under `tokens`. + if (tree->hasZoneName(trimmedZoneName)) { + return tr("A zone with this name already exists."); + } + + return {}; +} + bool DeckStateManager::offsetCountAtIndex(const QModelIndex &idx, int offset) { if (!idx.isValid()) { @@ -354,6 +541,25 @@ void DeckStateManager::requestHistorySave(const QString &reason) historyManager->save(deckList->createMemento(reason)); } +bool DeckStateManager::modifyTree(const QString &reason, const std::function &operation) +{ + DeckListMemento memento = deckList->createMemento(reason); + bool success = operation(deckList->getTree()); + + if (success) { + historyManager->save(memento); + deckListModel->rebuildTree(); + deckList->refreshDeckHash(); + emit deckListModel->deckHashChanged(); + // removeCustomZone can drop whole card sets the model never notified + // about (rebuildTree emits no cardNodesChanged), so tell the consumers. + emit deckListModel->cardNodesChanged(); + doCardModified(); + } + + return success; +} + /** * @brief Handles updating state and emitting signals whenever the cards are modified */ diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h index 10312d0a0..2c8b34a39 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h +++ b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h @@ -5,6 +5,7 @@ #include "deck_list_model.h" #include +#include #include class DeckListHistoryManager; @@ -57,6 +58,14 @@ public: */ const DeckList &getDeckList() const; + /** + * @brief Gets the underlying DeckList. + */ + QSharedPointer getDeckListShared() const + { + return deckList; + } + /** * @brief Creates a LoadedDeck containing the contents of the current deck and the current LoadInfo. * @@ -163,6 +172,7 @@ public: void setName(const QString &name); void setComments(const QString &comments); void setBannerCard(const CardRef &bannerCard); + void setPlaymat(const PlaymatInfo &playmat); void setTags(const QStringList &tags); void setFormat(const QString &format); ///@} @@ -227,6 +237,68 @@ public: */ bool decrementCountAtIndex(const QModelIndex &idx); + /** + * @brief Moves all copies of the card at the given index to the given zone. + * No-ops if the index is invalid, not a card node, the card is a token, or the + * card is already in the target zone. + * Saves the operation to history if successful. + * + * @param idx The model index of the card to move + * @param targetZoneName The zone to move the card to (board zone or custom zone name) + * @return Whether the operation was successfully performed + */ + bool moveCardToZone(const QModelIndex &idx, const QString &targetZoneName); + + /** + * @brief Creates a new custom zone nested under a board zone. + * Saves the operation to history if successful. + * + * @param boardZoneName The board zone to nest the custom zone under + * @param zoneName The name of the new custom zone. Gets trimmed and must be + * unique across the deck. + * @return Whether the zone was created + */ + bool createCustomZone(const QString &boardZoneName, const QString &zoneName); + + /** + * @brief Renames a custom zone. + * Saves the operation to history if successful. + * + * @param oldZoneName The current name of the custom zone + * @param newZoneName The new name. Gets trimmed and must be unique across the deck. + * @return Whether the rename succeeded + */ + bool renameCustomZone(const QString &oldZoneName, const QString &newZoneName); + + /** + * @brief Moves a custom zone (and its cards) to a different board zone. + * Same-board moves succeed without creating a history entry. + * Saves the operation to history if successful. + * + * @param zoneName The custom zone to move + * @param newBoardZoneName The board zone to move the custom zone under + * @return Whether the move succeeded + */ + bool moveCustomZone(const QString &zoneName, const QString &newBoardZoneName); + + /** + * @brief Removes a custom zone and all its cards. + * Saves the operation to history if successful. + * + * @param zoneName The custom zone to remove + * @return Whether the zone was removed + */ + bool removeCustomZone(const QString &zoneName); + + /** + * @brief Checks whether a candidate name is usable for a new custom zone. + * + * @param zoneName The candidate name + * @return An empty string when the name is usable, otherwise a user-facing + * error message describing the problem + */ + [[nodiscard]] QString validateNewZoneName(const QString &zoneName) const; + /** * Undoes n steps of the history, setting the decklist state and updating the current step in the historyManager. * @param steps Number of steps to undo. @@ -248,6 +320,7 @@ public slots: private: bool offsetCountAtIndex(const QModelIndex &idx, int offset); + bool modifyTree(const QString &reason, const std::function &operation); void doCardModified(); void doMetadataModified(); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.cpp new file mode 100644 index 000000000..9a0be2570 --- /dev/null +++ b/cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.cpp @@ -0,0 +1,145 @@ +#include "deck_zone_dialog.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +DeckZoneDialog::DeckZoneDialog(QWidget *parent, + const QString &initialBoardName, + const std::function &_nameValidator, + bool _allowBoardSelection) + : QDialog(parent), nameValidator(_nameValidator), allowBoardSelection(_allowBoardSelection) +{ + nameLabel = new QLabel(this); + nameEdit = new QLineEdit(this); + nameEdit->setMaxLength(MAX_NAME_LENGTH); + + errorLabel = new QLabel(this); + errorLabel->hide(); + + boardLabel = new QLabel(this); + boardCombo = new QComboBox(this); + for (const QString &boardName : InnerDecklistNode::boardZoneNames()) { + // Use the icon overload explicitly so `boardName` lands in the user data role + // (visible text is applied below in retranslateUi). The two-argument form + // addItem({}, boardName) would be ambiguous and resolve to the icon overload + // with empty user data, yielding empty entries and an empty getBoardName(). + boardCombo->addItem({}, {}, boardName); + } + if (!initialBoardName.isEmpty()) { + int idx = boardCombo->findData(initialBoardName); + if (idx != -1) { + boardCombo->setCurrentIndex(idx); + } + } + + buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + buttonBox->button(QDialogButtonBox::Ok)->setEnabled(false); + connect(buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); + + auto *layout = new QVBoxLayout(this); + layout->addWidget(nameLabel); + layout->addWidget(nameEdit); + layout->addWidget(errorLabel); + if (allowBoardSelection) { + layout->addWidget(boardLabel); + layout->addWidget(boardCombo); + } else { + boardLabel->hide(); + boardCombo->hide(); + } + layout->addWidget(buttonBox); + + retranslateUi(); + + connect(nameEdit, &QLineEdit::textChanged, this, [this] { validateName(); }); + validateName(); + + nameEdit->setFocus(); +} + +QString DeckZoneDialog::getZoneName() const +{ + return nameEdit->text().trimmed(); +} + +QString DeckZoneDialog::getBoardName() const +{ + return boardCombo->currentData().toString(); +} + +void DeckZoneDialog::setZoneName(const QString &zoneName) +{ + nameEdit->setText(zoneName); + nameEdit->selectAll(); +} + +void DeckZoneDialog::changeEvent(QEvent *event) +{ + QDialog::changeEvent(event); + + if (event->type() == QEvent::LanguageChange) { + retranslateUi(); + } +} + +void DeckZoneDialog::retranslateUi() +{ + setWindowTitle(allowBoardSelection ? tr("New zone") : tr("Rename zone")); + + nameLabel->setText(tr("Zone &name:")); + nameLabel->setBuddy(nameEdit); + + boardLabel->setText(tr("&Parent zone:")); + boardLabel->setBuddy(boardCombo); + + for (int i = 0; i < boardCombo->count(); i++) { + boardCombo->setItemText(i, InnerDecklistNode::visibleNameFromName(boardCombo->itemData(i).toString())); + } +} + +void DeckZoneDialog::validateName() +{ + const QString zoneName = nameEdit->text().trimmed(); + QString error; + if (zoneName.isEmpty()) { + error = tr("Enter a zone name."); + } else if (nameValidator) { + error = nameValidator(zoneName); + } + + errorLabel->setText(error); + errorLabel->setVisible(!error.isEmpty()); + buttonBox->button(QDialogButtonBox::Ok)->setEnabled(error.isEmpty()); +} + +QString DeckZoneDialog::promptForNewZone(QWidget *parent, + const QString &initialBoardName, + QString *chosenBoardName, + const std::function &nameValidator) +{ + DeckZoneDialog dialog(parent, initialBoardName, nameValidator); + if (dialog.exec() != QDialog::Accepted) { + return {}; + } + + if (chosenBoardName) { + *chosenBoardName = dialog.getBoardName(); + } + return dialog.getZoneName(); +} + +QString DeckZoneDialog::promptForRename(QWidget *parent, + const QString ¤tZoneName, + const std::function &nameValidator) +{ + DeckZoneDialog dialog(parent, {}, nameValidator, false); + dialog.setZoneName(currentZoneName); + return dialog.exec() == QDialog::Accepted ? dialog.getZoneName() : QString(); +} diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.h b/cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.h new file mode 100644 index 000000000..6f55617a8 --- /dev/null +++ b/cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.h @@ -0,0 +1,123 @@ +/** + * @file deck_zone_dialog.h + * @ingroup DeckEditorWidgets + * @brief Shared dialog for creating custom deck zones. + */ + +#ifndef DECK_ZONE_DIALOG_H +#define DECK_ZONE_DIALOG_H + +#include +#include +#include +#include + +class QComboBox; +class QDialogButtonBox; +class QLabel; +class QLineEdit; +class QWidget; + +/** + * @brief Modal dialog asking for the name and parent zone of a new custom deck zone. + * + * Menus construct the dialog transiently around exec(), so validation state only + * ever reflects the name currently typed. + */ +class DeckZoneDialog : public QDialog +{ + Q_OBJECT + +public: + /** + * @brief Constructs the dialog and runs the initial validation pass. + * + * @param parent The parent widget for the dialog + * @param initialBoardName The board zone to preselect in the combo. Unknown names + * fall back to main. + * @param _nameValidator Given the trimmed candidate name, returns an empty string + * when it is usable, otherwise a user-facing error message. May be empty. + * @param _allowBoardSelection When false the parent-zone combo is hidden and the + * dialog acts as a rename prompt for an existing zone. + */ + explicit DeckZoneDialog(QWidget *parent = nullptr, + const QString &initialBoardName = {}, + const std::function &_nameValidator = {}, + bool _allowBoardSelection = true); + + /** + * @brief The trimmed zone name entered by the user. + */ + [[nodiscard]] QString getZoneName() const; + + /** + * @brief The internal name of the board zone selected in the combo. + */ + [[nodiscard]] QString getBoardName() const; + + /** + * @brief Prefills the name field, e.g. with the current name when renaming. + * + * @param zoneName The text to put into the name field, selected for quick editing + */ + void setZoneName(const QString &zoneName); + + /** + * @brief Prompts the user for a new custom zone name and the board zone to nest it under. + * + * Convenience wrapper that runs DeckZoneDialog modally. + * + * @param parent The parent widget for the dialog + * @param initialBoardName The board zone to preselect in the dialog. Unknown names fall + * back to main. + * @param chosenBoardName (out) The internal name of the board zone the user chose + * @param nameValidator Optional validator forwarded to the dialog + * @return The trimmed zone name, or an empty string if the user cancelled + */ + static QString promptForNewZone(QWidget *parent, + const QString &initialBoardName, + QString *chosenBoardName, + const std::function &nameValidator = {}); + + /** + * @brief Prompts the user for a new name for an existing custom zone. + * + * Same inline validation as promptForNewZone, but without a parent-zone picker. + * + * @param parent The parent widget for the dialog + * @param currentZoneName The current name, prefilled for editing + * @param nameValidator Validator deciding whether a candidate name is usable. It sees + * the current name too, so callers wanting to allow unchanged names must + * special-case that themselves. + * @return The trimmed new name, or an empty string if the user cancelled + */ + static QString promptForRename(QWidget *parent, + const QString ¤tZoneName, + const std::function &nameValidator = {}); + +protected: + void changeEvent(QEvent *event) override; + +private: + /** + * @brief Sets every user-visible string. Runs on construction and on runtime + * language changes. + */ + void retranslateUi(); + + /** + * @brief Validates the current input, toggling Ok and the inline error label. + */ + void validateName(); + + QLabel *nameLabel; + QLineEdit *nameEdit; + QLabel *errorLabel; + QLabel *boardLabel; + QComboBox *boardCombo; + QDialogButtonBox *buttonBox; + std::function nameValidator; + bool allowBoardSelection; +}; + +#endif // DECK_ZONE_DIALOG_H diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp index fdbc90542..700012d45 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp @@ -1,6 +1,7 @@ #include "dlg_connect.h" #include "../../../client/settings/cache_settings.h" +#include "../../pixel_map_generator.h" #include #include @@ -12,7 +13,8 @@ #include #include #include -#include +#include +#include DlgConnect::DlgConnect(QWidget *parent) : QDialog(parent) { @@ -20,7 +22,7 @@ DlgConnect::DlgConnect(QWidget *parent) : QDialog(parent) previousHosts = new QComboBox(this); btnDeleteServer = new QPushButton(this); - btnDeleteServer->setIcon(QPixmap("theme:icons/remove_row")); + btnDeleteServer->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); btnDeleteServer->setToolTip(tr("Delete the currently selected saved server")); btnDeleteServer->setFixedWidth(30); @@ -28,7 +30,7 @@ DlgConnect::DlgConnect(QWidget *parent) : QDialog(parent) hps = new HandlePublicServers(this); btnRefreshServers = new QPushButton(this); - btnRefreshServers->setIcon(QPixmap("theme:icons/sync")); + btnRefreshServers->setIcon(themePixmap(QStringLiteral("icons/sync"))); btnRefreshServers->setToolTip(tr("Refresh the server list with known public servers")); btnRefreshServers->setFixedWidth(30); @@ -98,7 +100,7 @@ DlgConnect::DlgConnect(QWidget *parent) : QDialog(parent) updateDisplayInfo(previousHosts->currentText()); btnForgotPassword = new QPushButton(this); - btnForgotPassword->setIcon(QPixmap("theme:icons/forgot_password")); + btnForgotPassword->setIcon(themePixmap(QStringLiteral("icons/forgot_password"))); btnForgotPassword->setToolTip(tr("Reset Password")); btnForgotPassword->setFixedWidth(30); connect(btnForgotPassword, &QPushButton::released, this, &DlgConnect::actForgotPassword); @@ -270,6 +272,7 @@ void DlgConnect::updateDisplayInfo(const QString &saveName) hostEdit->setText(_data.at(1)); portEdit->setText(_data.at(2)); playernameEdit->setText(_data.at(3)); + playernameEdit->setFocus(); savePasswordCheckBox->setChecked(savePasswordStatus); if (savePasswordStatus) { diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.cpp index 198fa259b..a4a31d78d 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.cpp @@ -1,9 +1,17 @@ #include "dlg_convert_deck_to_cod_format.h" +#include "../../../client/settings/cache_settings.h" +#include "../../deck_loader/deck_loader.h" + #include #include +#include +#include +#include #include +#include #include +#include DialogConvertDeckToCodFormat::DialogConvertDeckToCodFormat(QWidget *parent) : QDialog(parent) { @@ -38,3 +46,71 @@ bool DialogConvertDeckToCodFormat::dontAskAgain() const { return dontAskAgainCheckbox->isChecked(); } + +namespace +{ + +bool confirmOverwriteIfExists(QWidget *parent, const QString &filePath) +{ + QFileInfo fileInfo(filePath); + QString newFileName = QDir::toNativeSeparators(fileInfo.path() + "/" + fileInfo.completeBaseName() + ".cod"); + + if (QFile::exists(newFileName)) { + QMessageBox::StandardButton reply = + QMessageBox::question(parent, QObject::tr("Overwrite Existing File?"), + QObject::tr("A .cod version of this deck already exists. Overwrite it?"), + QMessageBox::Yes | QMessageBox::No); + return reply == QMessageBox::Yes; + } + return true; // Safe to proceed +} + +} // namespace + +bool DialogConvertDeckToCodFormat::promptIfRequired(QWidget *parent, + const QString &filePath, + const std::function &convert) +{ + if (DeckFileFormat::getFormatFromName(filePath) == DeckFileFormat::Cockatrice) { + return true; + } + + // Retrieve saved preference if the prompt is disabled + if (!SettingsCache::instance().visualDeckStorage().getVisualDeckStoragePromptForConversion()) { + if (!SettingsCache::instance().visualDeckStorage().getVisualDeckStorageAlwaysConvert()) { + return false; + } + + if (!confirmOverwriteIfExists(parent, filePath)) { + return false; + } + + return convert(); + } + + // Show the dialog to the user + DialogConvertDeckToCodFormat conversionDialog(parent); + if (conversionDialog.exec() != QDialog::Accepted) { + SettingsCache::instance().visualDeckStorage().setVisualDeckStoragePromptForConversion( + !conversionDialog.dontAskAgain()); + SettingsCache::instance().visualDeckStorage().setVisualDeckStorageAlwaysConvert(false); + + return false; + } + + // Try to convert file + if (!confirmOverwriteIfExists(parent, filePath)) { + return false; + } + + if (!convert()) { + return false; + } + + if (conversionDialog.dontAskAgain()) { + SettingsCache::instance().visualDeckStorage().setVisualDeckStoragePromptForConversion(false); + SettingsCache::instance().visualDeckStorage().setVisualDeckStorageAlwaysConvert(true); + } + + return true; +} diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.h b/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.h index 6642ad8c6..526582135 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.h @@ -13,6 +13,9 @@ #include #include #include +#include + +class QWidget; class DialogConvertDeckToCodFormat : public QDialog { @@ -24,6 +27,21 @@ public: [[nodiscard]] bool dontAskAgain() const; + /** + * @brief Checks whether the deck file at \a filePath can store tags. + * + * If the file is not a .cod deck, prompts the user for conversion to the + * Cockatrice format, honoring the saved "always convert / don't ask again" + * preference. On acceptance \a convert is called to perform the conversion. + * + * @param parent The widget to parent the prompt to. + * @param filePath The path of the deck file to check. + * @param convert Called to convert the deck once the user agrees. + * @return true if tags can be stored (no conversion needed, or the conversion + * was performed), false if the user declined to convert. + */ + static bool promptIfRequired(QWidget *parent, const QString &filePath, const std::function &convert); + private: QVBoxLayout *layout; QLabel *label; diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_create_game.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_create_game.cpp index 30364f242..59f3e033d 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_create_game.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_create_game.cpp @@ -17,7 +17,8 @@ #include #include #include -#include +#include +#include void DlgCreateGame::sharedCtor() { @@ -49,7 +50,7 @@ void DlgCreateGame::sharedCtor() auto *gameTypeRadioButton = new QRadioButton(gameTypeIterator.value(), this); gameTypeLayout->addWidget(gameTypeRadioButton); gameTypeCheckBoxes.insert(gameTypeIterator.key(), gameTypeRadioButton); - bool isChecked = SettingsCache::instance().getGameTypes().contains(gameTypeIterator.value() + ", "); + bool isChecked = SettingsCache::instance().game().getGameTypes().contains(gameTypeIterator.value() + ", "); gameTypeCheckBoxes[gameTypeIterator.key()]->setChecked(isChecked); } auto *gameTypeGroupBox = new QGroupBox(tr("Game type")); @@ -154,23 +155,23 @@ DlgCreateGame::DlgCreateGame(TabRoom *_room, const QMap &_gameType { sharedCtor(); - rememberGameSettings->setChecked(SettingsCache::instance().getRememberGameSettings()); - descriptionEdit->setText(SettingsCache::instance().getGameDescription()); - maxPlayersEdit->setValue(SettingsCache::instance().getMaxPlayers()); + rememberGameSettings->setChecked(SettingsCache::instance().game().getRememberGameSettings()); + descriptionEdit->setText(SettingsCache::instance().game().getGameDescription()); + maxPlayersEdit->setValue(SettingsCache::instance().game().getMaxPlayers()); if (room && room->getUserInfo()->user_level() & ServerInfo_User::IsRegistered) { - onlyBuddiesCheckBox->setChecked(SettingsCache::instance().getOnlyBuddies()); - onlyRegisteredCheckBox->setChecked(SettingsCache::instance().getOnlyRegistered()); + onlyBuddiesCheckBox->setChecked(SettingsCache::instance().game().getOnlyBuddies()); + onlyRegisteredCheckBox->setChecked(SettingsCache::instance().game().getOnlyRegistered()); } else { onlyBuddiesCheckBox->setEnabled(false); onlyRegisteredCheckBox->setEnabled(false); } - spectatorsAllowedCheckBox->setChecked(SettingsCache::instance().getSpectatorsAllowed()); - spectatorsNeedPasswordCheckBox->setChecked(SettingsCache::instance().getSpectatorsNeedPassword()); - spectatorsCanTalkCheckBox->setChecked(SettingsCache::instance().getSpectatorsCanTalk()); - spectatorsSeeEverythingCheckBox->setChecked(SettingsCache::instance().getSpectatorsCanSeeEverything()); - createGameAsSpectatorCheckBox->setChecked(SettingsCache::instance().getCreateGameAsSpectator()); - startingLifeTotalEdit->setValue(SettingsCache::instance().getDefaultStartingLifeTotal()); - shareDecklistsOnLoadCheckBox->setChecked(SettingsCache::instance().getShareDecklistsOnLoad()); + spectatorsAllowedCheckBox->setChecked(SettingsCache::instance().game().getSpectatorsAllowed()); + spectatorsNeedPasswordCheckBox->setChecked(SettingsCache::instance().game().getSpectatorsNeedPassword()); + spectatorsCanTalkCheckBox->setChecked(SettingsCache::instance().game().getSpectatorsCanTalk()); + spectatorsSeeEverythingCheckBox->setChecked(SettingsCache::instance().game().getSpectatorsCanSeeEverything()); + createGameAsSpectatorCheckBox->setChecked(SettingsCache::instance().game().getCreateGameAsSpectator()); + startingLifeTotalEdit->setValue(SettingsCache::instance().game().getDefaultStartingLifeTotal()); + shareDecklistsOnLoadCheckBox->setChecked(SettingsCache::instance().game().getShareDecklistsOnLoad()); if (!rememberGameSettings->isChecked()) { actReset(); @@ -213,6 +214,7 @@ DlgCreateGame::DlgCreateGame(const ServerInfo_Game &gameInfo, const QMapsetChecked(gameInfo.spectators_need_password()); spectatorsCanTalkCheckBox->setChecked(gameInfo.spectators_can_chat()); spectatorsSeeEverythingCheckBox->setChecked(gameInfo.spectators_omniscient()); + shareDecklistsOnLoadCheckBox->setChecked(gameInfo.share_decklists_on_load()); QSet types; for (int i = 0; i < gameInfo.game_types_size(); ++i) { @@ -291,20 +293,20 @@ void DlgCreateGame::actOK() } } - SettingsCache::instance().setRememberGameSettings(rememberGameSettings->isChecked()); + SettingsCache::instance().game().setRememberGameSettings(rememberGameSettings->isChecked()); if (rememberGameSettings->isChecked()) { - SettingsCache::instance().setGameDescription(descriptionEdit->text()); - SettingsCache::instance().setMaxPlayers(maxPlayersEdit->value()); - SettingsCache::instance().setOnlyBuddies(onlyBuddiesCheckBox->isChecked()); - SettingsCache::instance().setOnlyRegistered(onlyRegisteredCheckBox->isChecked()); - SettingsCache::instance().setSpectatorsAllowed(spectatorsAllowedCheckBox->isChecked()); - SettingsCache::instance().setSpectatorsNeedPassword(spectatorsNeedPasswordCheckBox->isChecked()); - SettingsCache::instance().setSpectatorsCanTalk(spectatorsCanTalkCheckBox->isChecked()); - SettingsCache::instance().setSpectatorsCanSeeEverything(spectatorsSeeEverythingCheckBox->isChecked()); - SettingsCache::instance().setCreateGameAsSpectator(createGameAsSpectatorCheckBox->isChecked()); - SettingsCache::instance().setDefaultStartingLifeTotal(startingLifeTotalEdit->value()); - SettingsCache::instance().setShareDecklistsOnLoad(shareDecklistsOnLoadCheckBox->isChecked()); - SettingsCache::instance().setGameTypes(_gameTypes); + SettingsCache::instance().game().setGameDescription(descriptionEdit->text()); + SettingsCache::instance().game().setMaxPlayers(maxPlayersEdit->value()); + SettingsCache::instance().game().setOnlyBuddies(onlyBuddiesCheckBox->isChecked()); + SettingsCache::instance().game().setOnlyRegistered(onlyRegisteredCheckBox->isChecked()); + SettingsCache::instance().game().setSpectatorsAllowed(spectatorsAllowedCheckBox->isChecked()); + SettingsCache::instance().game().setSpectatorsNeedPassword(spectatorsNeedPasswordCheckBox->isChecked()); + SettingsCache::instance().game().setSpectatorsCanTalk(spectatorsCanTalkCheckBox->isChecked()); + SettingsCache::instance().game().setSpectatorsCanSeeEverything(spectatorsSeeEverythingCheckBox->isChecked()); + SettingsCache::instance().game().setCreateGameAsSpectator(createGameAsSpectatorCheckBox->isChecked()); + SettingsCache::instance().game().setDefaultStartingLifeTotal(startingLifeTotalEdit->value()); + SettingsCache::instance().game().setShareDecklistsOnLoad(shareDecklistsOnLoadCheckBox->isChecked()); + SettingsCache::instance().game().setGameTypes(_gameTypes); } PendingCommand *pend = room->prepareRoomCommand(cmd); connect(pend, &PendingCommand::finished, this, &DlgCreateGame::checkResponse); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_default_tags_editor.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_default_tags_editor.cpp index 2d6ee7909..e9c73b025 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_default_tags_editor.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_default_tags_editor.cpp @@ -5,6 +5,7 @@ #include #include #include +#include DlgDefaultTagsEditor::DlgDefaultTagsEditor(QWidget *parent) : QDialog(parent) { @@ -54,7 +55,7 @@ void DlgDefaultTagsEditor::retranslateUi() void DlgDefaultTagsEditor::loadStringList() { listWidget->clear(); - QStringList tags = SettingsCache::instance().getVisualDeckStorageDefaultTagsList(); + QStringList tags = SettingsCache::instance().visualDeckStorage().getVisualDeckStorageDefaultTagsList(); for (const QString &tag : tags) { auto *item = new QListWidgetItem(); // Create item but don't insert yet @@ -147,6 +148,6 @@ void DlgDefaultTagsEditor::confirmChanges() updatedList.append(lineEdit->text()); } } - SettingsCache::instance().setVisualDeckStorageDefaultTagsList(updatedList); + SettingsCache::instance().visualDeckStorage().setVisualDeckStorageDefaultTagsList(updatedList); accept(); // Close dialog and confirm changes } diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_edit_avatar.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_edit_avatar.cpp index db5f21701..b5710b208 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_edit_avatar.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_edit_avatar.cpp @@ -8,7 +8,7 @@ #include #include #include -#include +#include DlgEditAvatar::DlgEditAvatar(QWidget *parent) : QDialog(parent), image() { diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_edit_password.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_edit_password.cpp index cdd4433a7..4310c03fc 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_edit_password.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_edit_password.cpp @@ -7,7 +7,8 @@ #include #include #include -#include +#include +#include DlgEditPassword::DlgEditPassword(QWidget *parent) : QDialog(parent) { diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp index 381aa2b11..a56aa8e35 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp @@ -1,5 +1,6 @@ #include "dlg_edit_tokens.h" +#include "../../pixel_map_generator.h" #include "../interface/widgets/utility/get_text_with_max.h" #include @@ -19,7 +20,7 @@ #include #include #include -#include +#include DlgEditTokens::DlgEditTokens(QWidget *parent) : QDialog(parent), currentCard(nullptr) { @@ -90,10 +91,10 @@ DlgEditTokens::DlgEditTokens(QWidget *parent) : QDialog(parent), currentCard(nul &DlgEditTokens::tokenSelectionChanged); QAction *aAddToken = new QAction(tr("Add token"), this); - aAddToken->setIcon(QPixmap("theme:icons/increment")); + aAddToken->setIcon(themePixmap(QStringLiteral("icons/increment"))); connect(aAddToken, &QAction::triggered, this, &DlgEditTokens::actAddToken); QAction *aRemoveToken = new QAction(tr("Remove token"), this); - aRemoveToken->setIcon(QPixmap("theme:icons/decrement")); + aRemoveToken->setIcon(themePixmap(QStringLiteral("icons/decrement"))); connect(aRemoveToken, &QAction::triggered, this, &DlgEditTokens::actRemoveToken); auto *databaseToolBar = new QToolBar; diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_edit_user.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_edit_user.cpp index 7015f9d47..85b95f335 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_edit_user.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_edit_user.cpp @@ -6,7 +6,7 @@ #include #include #include -#include +#include DlgEditUser::DlgEditUser(QWidget *parent, QString email, QString country, QString realName) : QDialog(parent) { diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_filter_games.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_filter_games.cpp index 8f498cc2c..7e174a228 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_filter_games.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_filter_games.cpp @@ -30,7 +30,7 @@ DlgFilterGames::DlgFilterGames(const QMap &_allGameTypes, hideFullGames = new QCheckBox(tr("Hide full games")); hideFullGames->setChecked(filters.hideFullGames); - hideGamesThatStarted = new QCheckBox(tr("Hide games that have started")); + hideGamesThatStarted = new QCheckBox(tr("Hide started games")); hideGamesThatStarted->setChecked(filters.hideGamesThatStarted); hidePasswordProtectedGames = new QCheckBox(tr("Hide password protected games")); @@ -57,16 +57,16 @@ DlgFilterGames::DlgFilterGames(const QMap &_allGameTypes, gameNameFilterEdit->setText(filters.gameNameFilter); auto *gameNameFilterLabel = new QLabel(tr("Game &description:")); gameNameFilterLabel->setBuddy(gameNameFilterEdit); - creatorNameFilterEdit = new QLineEdit; - creatorNameFilterEdit->setText(filters.creatorNameFilters.join(", ")); - auto *creatorNameFilterLabel = new QLabel(tr("&Creator name:")); - creatorNameFilterLabel->setBuddy(creatorNameFilterEdit); + hostNameFilterEdit = new QLineEdit; + hostNameFilterEdit->setText(filters.hostNameFilters.join(", ")); + auto *hostNameFilterLabel = new QLabel(tr("&Host name:")); + hostNameFilterLabel->setBuddy(hostNameFilterEdit); auto *generalGrid = new QGridLayout; generalGrid->addWidget(gameNameFilterLabel, 0, 0); generalGrid->addWidget(gameNameFilterEdit, 0, 1); - generalGrid->addWidget(creatorNameFilterLabel, 1, 0); - generalGrid->addWidget(creatorNameFilterEdit, 1, 1); + generalGrid->addWidget(hostNameFilterLabel, 1, 0); + generalGrid->addWidget(hostNameFilterEdit, 1, 1); generalGrid->addWidget(maxGameAgeLabel, 2, 0); generalGrid->addWidget(maxGameAgeComboBox, 2, 1); generalGroupBox = new QGroupBox(tr("General")); @@ -193,7 +193,7 @@ GameFilterConfigs DlgFilterGames::getFilters() const hideNotBuddyCreatedGames->isChecked(), hideOpenDecklistGames->isChecked(), gameNameFilterEdit->text(), - getCreatorNameFilters(), + getHostNameFilters(), getGameTypeFilter(), maxPlayersFilterMinSpinBox->value(), maxPlayersFilterMaxSpinBox->value(), @@ -216,9 +216,9 @@ void DlgFilterGames::toggleSpectatorCheckboxEnabledness(bool spectatorsEnabled) showOnlyIfSpectatorsCanSeeHands->setDisabled(!spectatorsEnabled); } -QStringList DlgFilterGames::getCreatorNameFilters() const +QStringList DlgFilterGames::getHostNameFilters() const { - return creatorNameFilterEdit->text().split(",", Qt::SkipEmptyParts); + return hostNameFilterEdit->text().split(",", Qt::SkipEmptyParts); } QSet DlgFilterGames::getGameTypeFilter() const diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_filter_games.h b/cockatrice/src/interface/widgets/dialogs/dlg_filter_games.h index 447f9b16c..1cf822b89 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_filter_games.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_filter_games.h @@ -35,7 +35,7 @@ private: QCheckBox *hideNotBuddyCreatedGames; QCheckBox *hideOpenDecklistGames; QLineEdit *gameNameFilterEdit; - QLineEdit *creatorNameFilterEdit; + QLineEdit *hostNameFilterEdit; QMap gameTypeFilterCheckBoxes; QSpinBox *maxPlayersFilterMinSpinBox; QSpinBox *maxPlayersFilterMaxSpinBox; @@ -50,7 +50,7 @@ private: const GamesProxyModel *gamesProxyModel; const QMap gameAgeMap; - [[nodiscard]] QStringList getCreatorNameFilters() const; + [[nodiscard]] QStringList getHostNameFilters() const; [[nodiscard]] QSet getGameTypeFilter() const; [[nodiscard]] QTime getMaxGameAge() const; [[nodiscard]] bool getShowSpectatorPasswordProtected() const; diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_forgot_password_challenge.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_forgot_password_challenge.cpp index 24e9030e0..c21955373 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_forgot_password_challenge.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_forgot_password_challenge.cpp @@ -7,7 +7,8 @@ #include #include #include -#include +#include +#include DlgForgotPasswordChallenge::DlgForgotPasswordChallenge(QWidget *parent) : QDialog(parent) { diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_forgot_password_request.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_forgot_password_request.cpp index c33a41bed..4ee0c9c88 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_forgot_password_request.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_forgot_password_request.cpp @@ -7,7 +7,8 @@ #include #include #include -#include +#include +#include DlgForgotPasswordRequest::DlgForgotPasswordRequest(QWidget *parent) : QDialog(parent) { diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_forgot_password_reset.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_forgot_password_reset.cpp index d2eb081d1..2800d5c0c 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_forgot_password_reset.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_forgot_password_reset.cpp @@ -7,7 +7,8 @@ #include #include #include -#include +#include +#include DlgForgotPasswordReset::DlgForgotPasswordReset(QWidget *parent) : QDialog(parent) { diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_invite_to_game.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_invite_to_game.cpp new file mode 100644 index 000000000..b5451bb2c --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_invite_to_game.cpp @@ -0,0 +1,112 @@ +#include "dlg_invite_to_game.h" + +#include "../server/user/user_list_manager.h" +#include "../server/user/user_list_widget.h" +#include "../tabs/tab_supervisor.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +DlgInviteToGame::DlgInviteToGame(TabSupervisor *_tabSupervisor, + const QString &_inviteUrl, + bool _onlyBuddies, + const QStringList &_excludeUserNames, + QWidget *parent) + : QDialog(parent), tabSupervisor(_tabSupervisor), inviteUrl(_inviteUrl), onlyBuddies(_onlyBuddies), + excludeUserNames(_excludeUserNames) +{ + setModal(true); + + searchEdit = new QLineEdit(this); + searchEdit->setClearButtonEnabled(true); + connect(searchEdit, &QLineEdit::textChanged, this, &DlgInviteToGame::searchTextChanged); + + // The embedded list is the real room user list without the hover popup: + // same manager, same delegate/painter, same sections, live via manager + // signals while the modal loop runs. + UserListManager *manager = tabSupervisor->getUserListManager(); + userList = new UserListWidget(tabSupervisor, tabSupervisor->getClient(), UserListWidget::RoomList, this, + /*hasUserInfoPopup=*/false); + userList->setUserFilter([this, manager](const QString &name, bool online) { + return !excludeUserNames.contains(name) && online && !manager->isUserIgnored(name); + }); + if (onlyBuddies) { + userList->setSectioned({UserListWidget::Section::Buddy}); + } else { + userList->setSectioned({UserListWidget::Section::Buddy, UserListWidget::Section::Online}); + } + userList->bind(manager); + userList->rebuild(); + + connect(userList, &UserListWidget::userActivated, this, &DlgInviteToGame::inviteCurrentUser); + connect(userList, &UserListWidget::currentUserChanged, this, [this](const QString &userName) { + currentUserName = userName; + inviteButton->setEnabled(!userName.isEmpty()); + }); + + inviteButton = new QPushButton(this); + inviteButton->setEnabled(false); + inviteButton->setDefault(true); + connect(inviteButton, &QPushButton::clicked, this, [this] { inviteCurrentUser(currentUserName); }); + + cancelButton = new QPushButton(this); + connect(cancelButton, &QPushButton::clicked, this, &QDialog::reject); + + auto *buttonRow = new QHBoxLayout; + buttonRow->addStretch(); + buttonRow->addWidget(inviteButton); + buttonRow->addWidget(cancelButton); + + auto *layout = new QVBoxLayout(this); + layout->addWidget(searchEdit); + layout->addWidget(userList, 1); + layout->addLayout(buttonRow); + + retranslateUi(); + + // Default to a comfortably tall dialog so the list has room to breathe, + // capped by the available screen. No minimum is enforced: small screens + // and manual resizing can go shorter than this. + const QRect availableScreen = QGuiApplication::primaryScreen()->availableGeometry(); + resize(sizeHint().width(), qMin(sizeHint().height() * 3, availableScreen.height() * 4 / 5)); +} + +void DlgInviteToGame::searchTextChanged(const QString &text) +{ + userList->setFilterText(text); +} + +void DlgInviteToGame::inviteCurrentUser(const QString &userName) +{ + if (userName.isEmpty()) { + return; + } + // The invite link carries the game's id and, when the game has one, its + // description (makeGameJoinLink embeds both). Read them back so the prefix + // names the game by description first, then its id — identical to the + // context-menu invite so recipients see one consistent message style. + const QUrl inviteUrlObj(inviteUrl); + const QUrlQuery inviteQuery(inviteUrlObj); + const int gameId = inviteQuery.queryItemValue("gameid").toInt(); + const QString gameDescription = inviteQuery.queryItemValue("game"); + const QString prefix = gameDescription.isEmpty() + ? tr("Join my game (#%1):").arg(gameId) + : tr("Join my game \"%1\" (#%2):").arg(gameDescription).arg(gameId); + tabSupervisor->sendInviteToUser(userName, prefix + " " + inviteUrl); + accept(); +} + +void DlgInviteToGame::retranslateUi() +{ + setWindowTitle(tr("Invite to Game")); + searchEdit->setPlaceholderText(tr("Search users...")); + inviteButton->setText(tr("Invite")); + cancelButton->setText(tr("Cancel")); +} diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_invite_to_game.h b/cockatrice/src/interface/widgets/dialogs/dlg_invite_to_game.h new file mode 100644 index 000000000..bbb589bdb --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_invite_to_game.h @@ -0,0 +1,46 @@ +/** + * @file dlg_invite_to_game.h + * @ingroup RoomDialogs + */ +//! \todo Document this file. + +#ifndef DLG_INVITE_TO_GAME_H +#define DLG_INVITE_TO_GAME_H + +#include +#include + +class QLineEdit; +class QPushButton; +class TabSupervisor; +class UserListWidget; + +class DlgInviteToGame : public QDialog +{ + Q_OBJECT +public: + DlgInviteToGame(TabSupervisor *_tabSupervisor, + const QString &_inviteUrl, + bool _onlyBuddies, + const QStringList &_excludeUserNames, + QWidget *parent = nullptr); + +private slots: + void searchTextChanged(const QString &text); + void inviteCurrentUser(const QString &userName); + +private: + TabSupervisor *tabSupervisor; + QString inviteUrl; + bool onlyBuddies; + QStringList excludeUserNames; + QString currentUserName; + QLineEdit *searchEdit; + UserListWidget *userList; + QPushButton *inviteButton; + QPushButton *cancelButton; + + void retranslateUi(); +}; + +#endif diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_load_deck.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_load_deck.cpp index b87800909..626ebed50 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_load_deck.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_load_deck.cpp @@ -3,11 +3,13 @@ #include "../../../client/settings/cache_settings.h" #include "../../deck_loader/deck_loader.h" +#include +#include DlgLoadDeck::DlgLoadDeck(QWidget *parent) : QFileDialog(parent, tr("Load Deck")) { QString startingDir = SettingsCache::instance().recents().getLatestDeckDirPath(); if (startingDir.isEmpty()) { - startingDir = SettingsCache::instance().getDeckPath(); + startingDir = SettingsCache::instance().paths().getDeckPath(); } setDirectory(startingDir); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_clipboard.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_clipboard.cpp index 9ceb35d78..a3242c797 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_clipboard.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_clipboard.cpp @@ -1,9 +1,9 @@ #include "dlg_load_deck_from_clipboard.h" #include "../../../client/settings/cache_settings.h" +#include "../../../client/settings/shortcuts_settings.h" #include "../../deck_loader/card_node_function.h" #include "../../deck_loader/deck_loader.h" -#include "dlg_settings.h" #include #include diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_local_game_options.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_local_game_options.cpp index d2d291556..eca0ec658 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_local_game_options.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_local_game_options.cpp @@ -9,6 +9,7 @@ #include #include #include +#include DlgLocalGameOptions::DlgLocalGameOptions(QWidget *parent) : QDialog(parent) { @@ -53,10 +54,10 @@ DlgLocalGameOptions::DlgLocalGameOptions(QWidget *parent) : QDialog(parent) mainLayout->addWidget(buttonBox); setLayout(mainLayout); - rememberSettingsCheckBox->setChecked(SettingsCache::instance().getLocalGameRememberSettings()); + rememberSettingsCheckBox->setChecked(SettingsCache::instance().game().getLocalGameRememberSettings()); if (rememberSettingsCheckBox->isChecked()) { - numberPlayersEdit->setValue(SettingsCache::instance().getLocalGameMaxPlayers()); - startingLifeTotalEdit->setValue(SettingsCache::instance().getLocalGameStartingLifeTotal()); + numberPlayersEdit->setValue(SettingsCache::instance().game().getLocalGameMaxPlayers()); + startingLifeTotalEdit->setValue(SettingsCache::instance().game().getLocalGameStartingLifeTotal()); } setWindowTitle(tr("Local game options")); @@ -67,10 +68,10 @@ DlgLocalGameOptions::DlgLocalGameOptions(QWidget *parent) : QDialog(parent) void DlgLocalGameOptions::actOK() { - SettingsCache::instance().setLocalGameRememberSettings(rememberSettingsCheckBox->isChecked()); + SettingsCache::instance().game().setLocalGameRememberSettings(rememberSettingsCheckBox->isChecked()); if (rememberSettingsCheckBox->isChecked()) { - SettingsCache::instance().setLocalGameMaxPlayers(numberPlayersEdit->value()); - SettingsCache::instance().setLocalGameStartingLifeTotal(startingLifeTotalEdit->value()); + SettingsCache::instance().game().setLocalGameMaxPlayers(numberPlayersEdit->value()); + SettingsCache::instance().game().setLocalGameStartingLifeTotal(startingLifeTotalEdit->value()); } accept(); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp index c693fb02e..556c270be 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp @@ -1,6 +1,7 @@ #include "dlg_manage_sets.h" #include "../../../client/settings/cache_settings.h" +#include "../../pixel_map_generator.h" #include "../interface/card_picture_loader/card_picture_loader.h" #include "../interface/widgets/utility/custom_line_edit.h" @@ -20,6 +21,9 @@ #include #include #include +#include +#include +#include WndSets::WndSets(QWidget *parent) : QMainWindow(parent) { @@ -32,28 +36,28 @@ WndSets::WndSets(QWidget *parent) : QMainWindow(parent) setsEditToolBar->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum); aTop = new QAction(QString(), this); - aTop->setIcon(QPixmap("theme:icons/arrow_top_green")); + aTop->setIcon(themePixmap(QStringLiteral("icons/arrow_top_green"))); aTop->setToolTip(tr("Move selected set to the top")); aTop->setEnabled(false); connect(aTop, &QAction::triggered, this, &WndSets::actTop); setsEditToolBar->addAction(aTop); aUp = new QAction(QString(), this); - aUp->setIcon(QPixmap("theme:icons/arrow_up_green")); + aUp->setIcon(themePixmap(QStringLiteral("icons/arrow_up_green"))); aUp->setToolTip(tr("Move selected set up")); aUp->setEnabled(false); connect(aUp, &QAction::triggered, this, &WndSets::actUp); setsEditToolBar->addAction(aUp); aDown = new QAction(QString(), this); - aDown->setIcon(QPixmap("theme:icons/arrow_down_green")); + aDown->setIcon(themePixmap(QStringLiteral("icons/arrow_down_green"))); aDown->setToolTip(tr("Move selected set down")); aDown->setEnabled(false); connect(aDown, &QAction::triggered, this, &WndSets::actDown); setsEditToolBar->addAction(aDown); aBottom = new QAction(QString(), this); - aBottom->setIcon(QPixmap("theme:icons/arrow_bottom_green")); + aBottom->setIcon(themePixmap(QStringLiteral("icons/arrow_bottom_green"))); aBottom->setToolTip(tr("Move selected set to the bottom")); aBottom->setEnabled(false); connect(aBottom, &QAction::triggered, this, &WndSets::actBottom); @@ -62,8 +66,8 @@ WndSets::WndSets(QWidget *parent) : QMainWindow(parent) // search field searchField = new LineEditUnfocusable; searchField->setObjectName("searchEdit"); - searchField->setPlaceholderText(tr("Search by set name, code, or type")); - searchField->addAction(QPixmap("theme:icons/search"), LineEditUnfocusable::LeadingPosition); + searchField->setPlaceholderText(tr("Search by set name, code, type, or release date")); + searchField->addAction(themePixmap(QStringLiteral("icons/search")), LineEditUnfocusable::LeadingPosition); searchField->setClearButtonEnabled(true); setFocusProxy(searchField); @@ -153,7 +157,7 @@ WndSets::WndSets(QWidget *parent) : QMainWindow(parent) sortWarning->setLayout(sortWarningLayout); sortWarning->setVisible(false); - includeRebalancedCards = SettingsCache::instance().getIncludeRebalancedCards(); + includeRebalancedCards = SettingsCache::instance().cardsDisplay().getIncludeRebalancedCards(); QCheckBox *includeRebalancedCardsCheckBox = new QCheckBox(tr("Include cards rebalanced for Alchemy [requires restart]")); includeRebalancedCardsCheckBox->setChecked(includeRebalancedCards); @@ -253,7 +257,7 @@ void WndSets::includeRebalancedCardsChanged(bool _includeRebalancedCards) void WndSets::actSave() { model->save(CardDatabaseManager::getInstance()); - SettingsCache::instance().setIncludeRebalancedCards(includeRebalancedCards); + SettingsCache::instance().cardsDisplay().setIncludeRebalancedCards(includeRebalancedCards); CardPictureLoader::clearPixmapCache(); const auto reloadOk1 = QtConcurrent::run([] { CardDatabaseManager::getInstance()->reloadCardDatabasesAndNotify(); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp new file mode 100644 index 000000000..1af83bfb5 --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp @@ -0,0 +1,297 @@ +#include "dlg_my_reports.h" + +#include "../utility/report_utils.h" +#include "abstract_client.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +constexpr int COL_ID = 0; +constexpr int COL_TIME = 1; +constexpr int COL_REPORTED = 2; +constexpr int COL_CATEGORY = 3; +constexpr int COL_GAMEID = 4; +constexpr int COL_STATUS = 5; +constexpr int COL_ASSIGNED = 6; +constexpr int COL_COUNT = 7; +} // namespace + +DlgMyReports::DlgMyReports(AbstractClient *_client, QWidget *parent) + : QDialog(parent), client(_client), selectedReportId(-1) +{ + setWindowTitle(tr("My Reports")); + setMinimumSize(800, 500); + + table = new QTableWidget(0, COL_COUNT); + table->setHorizontalHeaderLabels( + {tr("#"), tr("Time"), tr("Reported User"), tr("Category"), tr("Game ID"), tr("Status"), tr("Assigned To")}); + table->setSelectionBehavior(QAbstractItemView::SelectRows); + table->setSelectionMode(QAbstractItemView::SingleSelection); + table->setEditTriggers(QAbstractItemView::NoEditTriggers); + table->setSortingEnabled(true); + table->verticalHeader()->setVisible(false); + table->setAlternatingRowColors(true); + table->horizontalHeader()->setSectionResizeMode(COL_TIME, QHeaderView::ResizeToContents); + table->horizontalHeader()->setSectionResizeMode(COL_REPORTED, QHeaderView::Stretch); + connect(table, &QTableWidget::itemSelectionChanged, this, &DlgMyReports::onSelectionChanged); + + auto *detailsGroup = new QGroupBox(tr("Report Details")); + descriptionEdit = new QTextEdit; + descriptionEdit->setReadOnly(true); + descriptionEdit->setFixedHeight(80); + + auto *chatGroup = new QGroupBox(tr("Chat Log Context")); + chatLogEdit = new QTextEdit; + chatLogEdit->setReadOnly(true); + QFont monoFont("monospace"); + monoFont.setStyleHint(QFont::Monospace); + const int systemPointSize = QFontDatabase::systemFont(QFontDatabase::GeneralFont).pointSize(); + if (systemPointSize > 0) { + monoFont.setPointSize(systemPointSize); + } + chatLogEdit->setFont(monoFont); + auto *chatLayout = new QVBoxLayout(chatGroup); + chatLayout->setContentsMargins(4, 4, 4, 4); + chatLayout->addWidget(chatLogEdit); + + auto *commentsLabel = new QLabel(tr("Comments:")); + commentsEdit = new QTextEdit; + commentsEdit->setReadOnly(true); + commentsEdit->setFixedHeight(120); + + auto *addCommentLabel = new QLabel(tr("Add a comment:")); + commentInput = new QLineEdit; + commentInput->setPlaceholderText(tr("Type your comment here...")); + commentButton = new QPushButton(tr("Send")); + commentButton->setEnabled(false); + connect(commentButton, &QPushButton::clicked, this, &DlgMyReports::addComment); + connect(commentInput, &QLineEdit::returnPressed, this, &DlgMyReports::addComment); + + auto *detailsLayout = new QVBoxLayout(detailsGroup); + detailsLayout->setContentsMargins(4, 4, 4, 4); + detailsLayout->addWidget(descriptionEdit); + detailsLayout->addWidget(chatGroup); + detailsLayout->addWidget(commentsLabel); + detailsLayout->addWidget(commentsEdit); + detailsLayout->addWidget(addCommentLabel); + auto *commentRow = new QHBoxLayout; + commentRow->addWidget(commentInput); + commentRow->addWidget(commentButton); + detailsLayout->addLayout(commentRow); + + closeButton = new QPushButton(tr("Close")); + connect(closeButton, &QPushButton::clicked, this, &QDialog::accept); + + refreshButton = new QPushButton(tr("Refresh")); + connect(refreshButton, &QPushButton::clicked, this, &DlgMyReports::refreshList); + + statusLabel = new QLabel; + + auto *bottomBar = new QHBoxLayout; + bottomBar->addWidget(statusLabel); + bottomBar->addStretch(); + bottomBar->addWidget(refreshButton); + bottomBar->addWidget(closeButton); + + auto *layout = new QVBoxLayout(this); + layout->addWidget(table, 1); + layout->addWidget(detailsGroup); + layout->addLayout(bottomBar); + + setActionsEnabled(false); + refreshList(); +} + +void DlgMyReports::refreshList() +{ + selectedReportIdBeforeRefresh = selectedReportId; + commentDraftBeforeRefresh = commentInput->text(); + statusLabel->setText(tr("Loading...")); + refreshButton->setEnabled(false); + table->setRowCount(0); + currentReports.clear(); + descriptionEdit->clear(); + chatLogEdit->clear(); + commentsEdit->clear(); + commentInput->clear(); + setActionsEnabled(false); + selectedReportId = -1; + + Command_ReportMyList cmd; + + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &DlgMyReports::reportListResponse); + client->sendCommand(pend); +} + +void DlgMyReports::reportListResponse(const Response &response) +{ + refreshButton->setEnabled(true); + + if (response.response_code() != Response::RespOk) { + statusLabel->setText(tr("Failed to load reports.")); + return; + } + + const Response_ReportMyList &resp = response.GetExtension(Response_ReportMyList::ext); + currentReports.clear(); + for (int i = 0; i < resp.reports_size(); ++i) { + currentReports.append(resp.reports(i)); + } + + table->setSortingEnabled(false); + table->setRowCount(currentReports.size()); + + for (int row = 0; row < currentReports.size(); ++row) { + const ServerInfo_Report &r = currentReports[row]; + + report_utils::fillReportTableRow(table, row, r, COL_ID, COL_TIME, COL_REPORTED, COL_CATEGORY, COL_GAMEID, + COL_STATUS, COL_ASSIGNED); + } + + table->setSortingEnabled(true); + table->resizeColumnsToContents(); + table->horizontalHeader()->setSectionResizeMode(COL_REPORTED, QHeaderView::Stretch); + + if (selectedReportIdBeforeRefresh >= 0) { + for (int row = 0; row < table->rowCount(); ++row) { + if (table->item(row, COL_ID) && + table->item(row, COL_ID)->data(Qt::UserRole).toInt() == selectedReportIdBeforeRefresh) { + table->setCurrentCell(row, 0); + break; + } + } + } + + if (commentInput->text().isEmpty()) { + commentInput->setText(commentDraftBeforeRefresh); + } + + statusLabel->setText(tr("%1 report(s)").arg(currentReports.size())); +} + +void DlgMyReports::onSelectionChanged() +{ + const int row = table->currentRow(); + if (row < 0 || !table->item(row, COL_ID)) { + descriptionEdit->clear(); + chatLogEdit->clear(); + commentsEdit->clear(); + commentInput->clear(); + commentButton->setEnabled(false); + selectedReportId = -1; + return; + } + + const int reportId = table->item(row, COL_ID)->data(Qt::UserRole).toInt(); + selectedReportId = reportId; + + for (const ServerInfo_Report &r : currentReports) { + if (r.report_id() == reportId) { + descriptionEdit->setPlainText(QString::fromStdString(r.description())); + break; + } + } + + chatLogEdit->setPlainText(tr("Loading...")); + commentsEdit->setPlainText(tr("Loading...")); + + Command_ReportDetails cmd; + cmd.set_report_id(reportId); + + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &DlgMyReports::reportDetailsResponse); + client->sendCommand(pend); + + QString status = table->item(row, COL_STATUS)->text(); + bool canComment = (status == "open" || status == "assigned"); + commentButton->setEnabled(canComment); + commentInput->setEnabled(canComment); + if (!canComment) { + commentInput->setPlaceholderText(tr("This report is closed.")); + } else { + commentInput->setPlaceholderText(tr("Type your comment here...")); + } +} + +void DlgMyReports::reportDetailsResponse(const Response &response) +{ + if (response.response_code() != Response::RespOk) { + if (selectedReportId == -1) { + return; + } + chatLogEdit->clear(); + commentsEdit->setPlainText(tr("Failed to load report details.")); + return; + } + + const Response_ReportDetails &resp = response.GetExtension(Response_ReportDetails::ext); + const ServerInfo_Report &r = resp.report(); + + if (selectedReportId != r.report_id()) { + return; + } + + loadReportDetails(r); +} + +void DlgMyReports::loadReportDetails(const ServerInfo_Report &report) +{ + report_utils::renderReportDetails(chatLogEdit, commentsEdit, report, tr("No comments yet."), tr("[Moderator]"), + tr("[You]")); +} + +void DlgMyReports::addComment() +{ + if (selectedReportId < 0) { + return; + } + + QString text = commentInput->text().trimmed(); + if (text.isEmpty()) { + return; + } + + commentButton->setEnabled(false); + + Command_ReportAddComment cmd; + cmd.set_report_id(selectedReportId); + cmd.set_comment(text.toStdString()); + + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &DlgMyReports::addCommentResponse); + client->sendCommand(pend); +} + +void DlgMyReports::addCommentResponse(const Response &response) +{ + if (response.response_code() == Response::RespOk) { + commentInput->clear(); + refreshList(); + } else { + commentButton->setEnabled(true); + } +} + +void DlgMyReports::setActionsEnabled(bool enabled) +{ + commentButton->setEnabled(enabled); + commentInput->setEnabled(enabled); +} diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.h b/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.h new file mode 100644 index 000000000..09b1963dd --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.h @@ -0,0 +1,52 @@ +#ifndef COCKATRICE_DLG_MY_REPORTS_H +#define COCKATRICE_DLG_MY_REPORTS_H + +#include +#include +#include +#include + +class AbstractClient; +class QTableWidget; +class QTextEdit; +class QLineEdit; +class QPushButton; +class QLabel; + +class DlgMyReports : public QDialog +{ + Q_OBJECT +public: + explicit DlgMyReports(AbstractClient *_client, QWidget *parent = nullptr); + +private slots: + void refreshList(); + void reportListResponse(const Response &response); + void onSelectionChanged(); + void reportDetailsResponse(const Response &response); + void addComment(); + void addCommentResponse(const Response &response); + +private: + void loadReportDetails(const ServerInfo_Report &report); + void setActionsEnabled(bool enabled); + + AbstractClient *client; + + QTableWidget *table; + QTextEdit *descriptionEdit; + QTextEdit *chatLogEdit; + QTextEdit *commentsEdit; + QLineEdit *commentInput; + QPushButton *commentButton; + QPushButton *refreshButton; + QPushButton *closeButton; + QLabel *statusLabel; + + QList currentReports; + int selectedReportId; + int selectedReportIdBeforeRefresh = -1; + QString commentDraftBeforeRefresh; +}; + +#endif // COCKATRICE_DLG_MY_REPORTS_H diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp index 0f7c17b18..6d022861c 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp @@ -1,17 +1,63 @@ #include "dlg_register.h" #include "../../../client/settings/cache_settings.h" +#include "../../pixel_map_generator.h" +#include "../server/handle_public_servers.h" +#include "../server/user/user_info_connection.h" -#include +#include #include #include +#include #include #include #include -#include +#include +#include +#include +#include +#include DlgRegister::DlgRegister(QWidget *parent) : QDialog(parent) { + // ── Server picker ────────────────────────────────────────────────── + previousHostButton = new QRadioButton(tr("Known Hosts"), this); + previousHosts = new QComboBox(this); + + btnDeleteServer = new QPushButton(this); + btnDeleteServer->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); + btnDeleteServer->setToolTip(tr("Delete the currently selected saved server")); + btnDeleteServer->setFixedWidth(30); + + connect(btnDeleteServer, &QPushButton::clicked, this, &DlgRegister::actRemoveSavedServer); + + hps = new HandlePublicServers(this); + btnRefreshServers = new QPushButton(this); + btnRefreshServers->setIcon(themePixmap(QStringLiteral("icons/sync"))); + btnRefreshServers->setToolTip(tr("Refresh the server list with known public servers")); + btnRefreshServers->setFixedWidth(30); + + connect(hps, &HandlePublicServers::sigPublicServersDownloadedSuccessfully, this, [this] { rebuildComboBoxList(); }); + connect(hps, &HandlePublicServers::sigPublicServersDownloadedUnsuccessfully, this, + &DlgRegister::rebuildComboBoxList); + connect(btnRefreshServers, &QPushButton::released, this, &DlgRegister::downloadThePublicServers); + + newHostButton = new QRadioButton(tr("New Host"), this); + + auto *serverPickerRow = new QHBoxLayout; + serverPickerRow->addWidget(previousHosts); + serverPickerRow->addWidget(btnDeleteServer); + serverPickerRow->addWidget(btnRefreshServers); + + auto *serverGroupLayout = new QVBoxLayout; + serverGroupLayout->addWidget(previousHostButton); + serverGroupLayout->addLayout(serverPickerRow); + serverGroupLayout->addWidget(newHostButton); + + auto *serverGroupBox = new QGroupBox(tr("Server")); + serverGroupBox->setLayout(serverGroupLayout); + + // ── Registration fields ──────────────────────────────────────────── ServersSettings &servers = SettingsCache::instance().servers(); infoLabel = new QLabel(tr("Enter your information and the information of the server you'd like to register to.\n" "Your email will be used to verify your account.")); @@ -320,26 +366,28 @@ DlgRegister::DlgRegister(QWidget *parent) : QDialog(parent) realnameEdit->setMaxLength(MAX_NAME_LENGTH); realnameLabel->setBuddy(realnameEdit); + // ── Layout ───────────────────────────────────────────────────────── auto *grid = new QGridLayout; - grid->addWidget(infoLabel, 0, 0, 1, 2); - grid->addWidget(hostLabel, 1, 0); - grid->addWidget(hostEdit, 1, 1); - grid->addWidget(portLabel, 2, 0); - grid->addWidget(portEdit, 2, 1); - grid->addWidget(playernameLabel, 3, 0); - grid->addWidget(playernameEdit, 3, 1); - grid->addWidget(passwordLabel, 4, 0); - grid->addWidget(passwordEdit, 4, 1); - grid->addWidget(passwordConfirmationLabel, 5, 0); - grid->addWidget(passwordConfirmationEdit, 5, 1); - grid->addWidget(emailLabel, 6, 0); - grid->addWidget(emailEdit, 6, 1); - grid->addWidget(emailConfirmationLabel, 7, 0); - grid->addWidget(emailConfirmationEdit, 7, 1); - grid->addWidget(countryLabel, 9, 0); - grid->addWidget(countryEdit, 9, 1); - grid->addWidget(realnameLabel, 10, 0); - grid->addWidget(realnameEdit, 10, 1); + grid->addWidget(serverGroupBox, 0, 0, 1, 2); + grid->addWidget(infoLabel, 1, 0, 1, 2); + grid->addWidget(hostLabel, 2, 0); + grid->addWidget(hostEdit, 2, 1); + grid->addWidget(portLabel, 3, 0); + grid->addWidget(portEdit, 3, 1); + grid->addWidget(playernameLabel, 4, 0); + grid->addWidget(playernameEdit, 4, 1); + grid->addWidget(passwordLabel, 5, 0); + grid->addWidget(passwordEdit, 5, 1); + grid->addWidget(passwordConfirmationLabel, 6, 0); + grid->addWidget(passwordConfirmationEdit, 6, 1); + grid->addWidget(emailLabel, 7, 0); + grid->addWidget(emailEdit, 7, 1); + grid->addWidget(emailConfirmationLabel, 8, 0); + grid->addWidget(emailConfirmationEdit, 8, 1); + grid->addWidget(countryLabel, 10, 0); + grid->addWidget(countryEdit, 10, 1); + grid->addWidget(realnameLabel, 11, 0); + grid->addWidget(realnameEdit, 11, 1); auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgRegister::actOk); @@ -351,13 +399,115 @@ DlgRegister::DlgRegister(QWidget *parent) : QDialog(parent) setLayout(mainLayout); setWindowTitle(tr("Register to server")); - setFixedHeight(sizeHint().height()); - setMinimumWidth(300); + setMinimumWidth(360); + + connect(previousHostButton, &QRadioButton::toggled, this, &DlgRegister::previousHostSelected); + connect(newHostButton, &QRadioButton::toggled, this, &DlgRegister::newHostSelected); + connect(previousHosts, &QComboBox::currentTextChanged, this, &DlgRegister::updateDisplayInfo); + + previousHostButton->setChecked(true); + + preRebuildComboBoxList(); +} + +DlgRegister::~DlgRegister() = default; + +void DlgRegister::downloadThePublicServers() +{ + btnRefreshServers->setDisabled(true); + previousHosts->clear(); + previousHosts->addItem(placeHolderText); + hps->downloadPublicServers(); +} + +void DlgRegister::preRebuildComboBoxList() +{ + UserConnection_Information uci; + savedHostList = uci.getServerInfo(); + + if (savedHostList.size() == 1) { + downloadThePublicServers(); + } else { + rebuildComboBoxList(); + } +} + +void DlgRegister::rebuildComboBoxList(int failure) +{ + Q_UNUSED(failure); + + previousHosts->clear(); + + UserConnection_Information uci; + savedHostList = uci.getServerInfo(); + + auto &servers = SettingsCache::instance().servers(); + QString previousHostName = servers.getPrevioushostName(); + + for (const auto &pair : savedHostList) { + const auto &tmp = pair.second; + QString saveName = tmp.getSaveName(); + if (saveName.size()) { + previousHosts->addItem(saveName); + if (saveName.compare(previousHostName) == 0) { + previousHosts->setCurrentIndex(previousHosts->count() - 1); + } + } + } + + btnRefreshServers->setDisabled(false); +} + +void DlgRegister::previousHostSelected(bool state) +{ + if (state) { + previousHosts->setDisabled(false); + btnRefreshServers->setDisabled(false); + hostEdit->setDisabled(true); + portEdit->setDisabled(true); + } +} + +void DlgRegister::newHostSelected(bool state) +{ + if (state) { + previousHosts->setDisabled(true); + btnRefreshServers->setDisabled(true); + hostEdit->setDisabled(false); + hostEdit->clear(); + hostEdit->setPlaceholderText(tr("Server URL")); + portEdit->setDisabled(false); + portEdit->clear(); + portEdit->setPlaceholderText(tr("Communication Port")); + playernameEdit->setDisabled(false); + playernameEdit->clear(); + } else { + // Rebuild the list so the previously selected host's details are + // repopulated (mirrors DlgConnect::newHostSelected). + preRebuildComboBoxList(); + } +} + +void DlgRegister::updateDisplayInfo(const QString &saveName) +{ + if (saveName.isEmpty() || saveName == placeHolderText) { + return; + } + + UserConnection_Information uci; + QStringList _data = uci.getServerInfo(saveName); + + if (_data.size() < 7) { + return; + } + + hostEdit->setText(_data.at(1)); + portEdit->setText(_data.at(2)); + playernameEdit->setText(_data.at(3)); } void DlgRegister::actOk() { - //! \todo This stuff should be using QValidators. if (passwordEdit->text().length() < 8) { QMessageBox::critical(this, tr("Registration Warning"), tr("Your password is too short.")); return; @@ -374,5 +524,29 @@ void DlgRegister::actOk() return; } + ServersSettings &servers = SettingsCache::instance().servers(); + + if (newHostButton->isChecked()) { + // Persist the new host so it shows up in the Connect dialog later. + // The password is never stored: the account is not verified yet. + const QString host = hostEdit->text().trimmed(); + if (!host.isEmpty()) { + servers.addNewServer(host, host, portEdit->text().trimmed(), playernameEdit->text().trimmed(), QString(), + false); + servers.setPrevioushostName(host); + } + } else { + const QString saveName = previousHosts->currentText(); + if (!saveName.isEmpty() && saveName != placeHolderText) { + servers.setPrevioushostName(saveName); + } + } + accept(); } + +void DlgRegister::actRemoveSavedServer() +{ + SettingsCache::instance().servers().removeServer(hostEdit->text()); + previousHosts->removeItem(previousHosts->currentIndex()); +} diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_register.h b/cockatrice/src/interface/widgets/dialogs/dlg_register.h index abed9ff51..ce14eb427 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_register.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_register.h @@ -1,25 +1,24 @@ -/** - * @file dlg_register.h - * @ingroup AccountDialogs - */ -//! \todo Document this file. - #ifndef DLG_REGISTER_H #define DLG_REGISTER_H #include #include #include +#include +class HandlePublicServers; class QLabel; class QPushButton; -class QCheckBox; +class QRadioButton; +class UserConnection_Information; class DlgRegister : public QDialog { Q_OBJECT public: explicit DlgRegister(QWidget *parent = nullptr); + ~DlgRegister() override; + [[nodiscard]] QString getHost() const { return hostEdit->text(); @@ -48,15 +47,35 @@ public: { return realnameEdit->text(); } + +public slots: + void downloadThePublicServers(); + private slots: void actOk(); + void previousHostSelected(bool state); + void newHostSelected(bool state); + void updateDisplayInfo(const QString &saveName); + void preRebuildComboBoxList(); + void rebuildComboBoxList(int failure = -1); + void actRemoveSavedServer(); private: + QRadioButton *newHostButton; + QRadioButton *previousHostButton; + QComboBox *previousHosts; + QPushButton *btnDeleteServer; + QPushButton *btnRefreshServers; + HandlePublicServers *hps; + QLabel *infoLabel, *hostLabel, *portLabel, *playernameLabel, *passwordLabel, *passwordConfirmationLabel, *emailLabel, *emailConfirmationLabel, *countryLabel, *realnameLabel; QLineEdit *hostEdit, *portEdit, *playernameEdit, *passwordEdit, *passwordConfirmationEdit, *emailEdit, *emailConfirmationEdit, *realnameEdit; QComboBox *countryEdit; + + QMap> savedHostList; + const QString placeHolderText = tr("Downloading..."); }; -#endif +#endif // DLG_REGISTER_H diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_report_user.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_report_user.cpp new file mode 100644 index 000000000..9519846e2 --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_report_user.cpp @@ -0,0 +1,191 @@ +#include "dlg_report_user.h" + +#include "abstract_client.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +DlgReportUser::DlgReportUser(AbstractClient *_client, + const QString &_reportedUser, + int _gameId, + const QString &_autoChatLog, + QWidget *parent) + : QDialog(parent), client(_client), reportedUser(_reportedUser), gameId(_gameId) +{ + setWindowTitle(tr("Report User")); + setMinimumWidth(500); + + auto *infoLabel = + new QLabel(tr("Reports are reviewed by moderators. False reports may result in account penalties.")); + infoLabel->setWordWrap(true); + infoLabel->setStyleSheet("color: palette(placeholderText); padding: 5px;"); + + auto *reportGroup = new QGroupBox(tr("Report Details")); + auto *reportGrid = new QGridLayout(reportGroup); + + reportGrid->addWidget(new QLabel(tr("Reported User:")), 0, 0); + reportedUserLabel = new QLabel(reportedUser); + reportedUserLabel->setStyleSheet("font-weight: bold;"); + reportGrid->addWidget(reportedUserLabel, 0, 1); + + reportGrid->addWidget(new QLabel(tr("Game ID:")), 1, 0); + if (gameId >= 0) { + gameIdLabel = new QLabel(QString::number(gameId)); + gameIdLabel->setStyleSheet("font-weight: bold;"); + reportGrid->addWidget(gameIdLabel, 1, 1); + } else { + gameIdEdit = new QLineEdit; + gameIdEdit->setPlaceholderText(tr("(Optional) Enter game ID if available")); + gameIdEdit->setToolTip(tr("If the report is related to a specific game, enter its ID.")); + reportGrid->addWidget(gameIdEdit, 1, 1); + gameIdLabel = nullptr; + } + + auto *categoryGroup = new QGroupBox(tr("Category")); + auto *categoryGrid = new QGridLayout(categoryGroup); + + categoryBox = new QComboBox; + categoryBox->addItem(tr("Cheating / Unsporting behavior"), "cheating"); + categoryBox->setItemData(categoryBox->count() - 1, + tr("Using external tools, card marked manipulation, or exploiting game bugs"), + Qt::ToolTipRole); + categoryBox->addItem(tr("Harassment / Abuse"), "harassment"); + categoryBox->setItemData(categoryBox->count() - 1, tr("Threatening, bullying, or persistent unwanted contact"), + Qt::ToolTipRole); + categoryBox->addItem(tr("Hate speech"), "hate_speech"); + categoryBox->setItemData(categoryBox->count() - 1, + tr("Discriminatory language targeting race, gender, religion, etc."), Qt::ToolTipRole); + categoryBox->addItem(tr("Spam"), "spam"); + categoryBox->setItemData(categoryBox->count() - 1, tr("Repeated unwanted messages or advertisements"), + Qt::ToolTipRole); + categoryBox->addItem(tr("Other"), "other"); + categoryBox->setItemData(categoryBox->count() - 1, tr("Any behavior not covered by the above categories"), + Qt::ToolTipRole); + + categoryGrid->addWidget(new QLabel(tr("Category:")), 0, 0); + categoryGrid->addWidget(categoryBox, 0, 1); + + auto *descGroup = new QGroupBox(tr("Description")); + auto *descLayout = new QVBoxLayout(descGroup); + + descriptionEdit = new QTextEdit; + descriptionEdit->setPlaceholderText( + tr("Please describe what happened. Include dates, game details, or any evidence if available.")); + descriptionEdit->setFixedHeight(120); + descLayout->addWidget(descriptionEdit); + + auto *chatGroup = new QGroupBox(tr("Chat Log Context")); + auto *chatLayout = new QVBoxLayout(chatGroup); + chatLogEdit = new QTextEdit; + chatLogEdit->setReadOnly(true); + QFont monoFont("monospace"); + monoFont.setStyleHint(QFont::Monospace); + const int systemPointSize = QFontDatabase::systemFont(QFontDatabase::GeneralFont).pointSize(); + if (systemPointSize > 0) { + monoFont.setPointSize(systemPointSize); + } + chatLogEdit->setFont(monoFont); + if (!_autoChatLog.isEmpty()) { + chatLogEdit->setPlainText(_autoChatLog); + } else { + chatLogEdit->setPlaceholderText(tr("No chat context available (not triggered from chat).")); + } + chatLogEdit->setFixedHeight(100); + chatLayout->addWidget(chatLogEdit); + + auto *chatNote = new QLabel( + tr("This chat log is captured from your local chat window and may not reflect the full conversation.")); + chatNote->setWordWrap(true); + chatNote->setStyleSheet("color: palette(placeholderText);"); + chatLayout->addWidget(chatNote); + + buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); + buttonBox->button(QDialogButtonBox::Ok)->setText(tr("Submit Report")); + connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgReportUser::actSubmit); + connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); + + auto *layout = new QVBoxLayout(this); + layout->addWidget(infoLabel); + layout->addWidget(reportGroup); + layout->addWidget(categoryGroup); + layout->addWidget(descGroup); + layout->addWidget(chatGroup); + layout->addWidget(buttonBox); +} + +void DlgReportUser::actSubmit() +{ + const QString description = descriptionEdit->toPlainText().trimmed(); + if (description.isEmpty()) { + QMessageBox::warning(this, tr("Missing description"), tr("Please describe what happened before submitting.")); + return; + } + + QMessageBox::StandardButton reply = + QMessageBox::question(this, tr("Confirm Report"), + tr("Submit report against %1 for %2?").arg(reportedUser, categoryBox->currentText()), + QMessageBox::Yes | QMessageBox::No); + + if (reply != QMessageBox::Yes) { + return; + } + + buttonBox->setEnabled(false); + buttonBox->button(QDialogButtonBox::Ok)->setText(tr("Submitting...")); + + Command_Report cmd; + cmd.set_reported_user(reportedUser.toStdString()); + cmd.set_category(categoryBox->currentData().toString().toStdString()); + cmd.set_description(description.toStdString()); + + if (gameId >= 0) { + cmd.set_game_id(gameId); + } else if (gameIdEdit && !gameIdEdit->text().trimmed().isEmpty()) { + bool ok; + int manualGameId = gameIdEdit->text().trimmed().toInt(&ok); + if (ok && manualGameId > 0) { + cmd.set_game_id(manualGameId); + } + } + + const QString chatLog = chatLogEdit->toPlainText().trimmed(); + if (!chatLog.isEmpty()) { + cmd.set_chat_log(chatLog.toStdString()); + } + + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &DlgReportUser::reportResponse); + client->sendCommand(pend); +} + +void DlgReportUser::reportResponse(const Response &response) +{ + buttonBox->setEnabled(true); + buttonBox->button(QDialogButtonBox::Ok)->setText(tr("Submit Report")); + + if (response.response_code() == Response::RespOk) { + QMessageBox::information(this, tr("Report Submitted"), + tr("Your report has been submitted and will be reviewed by a moderator. Thank you.")); + accept(); + } else if (response.response_code() == Response::RespTooManyRequests) { + QMessageBox::warning(this, tr("Submission Failed"), + tr("You have reached the daily report limit. Please try again later.")); + } else if (response.response_code() == Response::RespNameNotFound) { + QMessageBox::warning( + this, tr("Submission Failed"), + tr("The reported user could not be found. Guests (unregistered users) cannot be reported.")); + } else { + QMessageBox::warning(this, tr("Submission Failed"), tr("Failed to submit report. Please try again.")); + } +} diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_report_user.h b/cockatrice/src/interface/widgets/dialogs/dlg_report_user.h new file mode 100644 index 000000000..c59fc5084 --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_report_user.h @@ -0,0 +1,42 @@ +#ifndef COCKATRICE_DLG_REPORT_USER_H +#define COCKATRICE_DLG_REPORT_USER_H + +#include +#include + +class AbstractClient; +class QComboBox; +class QDialogButtonBox; +class QLineEdit; +class QTextEdit; +class QLabel; + +class DlgReportUser : public QDialog +{ + Q_OBJECT +public: + DlgReportUser(AbstractClient *_client, + const QString &_reportedUser, + int _gameId = -1, + const QString &_autoChatLog = QString(), + QWidget *parent = nullptr); + +private slots: + void actSubmit(); + void reportResponse(const Response &response); + +private: + AbstractClient *client; + QString reportedUser; + int gameId; + + QLabel *reportedUserLabel; + QLabel *gameIdLabel = nullptr; + QLineEdit *gameIdEdit = nullptr; + QComboBox *categoryBox; + QTextEdit *descriptionEdit; + QTextEdit *chatLogEdit; + QDialogButtonBox *buttonBox; +}; + +#endif // COCKATRICE_DLG_REPORT_USER_H diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_select_set_for_cards.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_select_set_for_cards.cpp index bc846bf3d..8eed083e1 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_select_set_for_cards.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_select_set_for_cards.cpp @@ -321,11 +321,7 @@ void DlgSelectSetForCards::dropEvent(QDropEvent *event) { QByteArray itemData = event->mimeData()->data("application/x-setentrywidget"); QString draggedSetName = QString::fromUtf8(itemData); -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) QPoint adjustedPos = event->position().toPoint() + QPoint(0, scrollArea->verticalScrollBar()->value()); -#else - QPoint adjustedPos = event->pos() + QPoint(0, scrollArea->verticalScrollBar()->value()); -#endif int dropIndex = -1; for (int i = 0; i < listLayout->count(); ++i) { QWidget *widget = listLayout->itemAt(i)->widget(); @@ -491,11 +487,7 @@ void SetEntryWidget::mousePressEvent(QMouseEvent *event) } } -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) void SetEntryWidget::enterEvent(QEnterEvent *event) -#else -void SetEntryWidget::enterEvent(QEvent *event) -#endif { QWidget::enterEvent(event); // Call the base class handler // Highlight the widget by changing the background color only for the widget itself diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_select_set_for_cards.h b/cockatrice/src/interface/widgets/dialogs/dlg_select_set_for_cards.h index 92f285aa0..4528552d0 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_select_set_for_cards.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_select_set_for_cards.h @@ -87,11 +87,7 @@ public: public slots: void mousePressEvent(QMouseEvent *event) override; -#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) void enterEvent(QEnterEvent *event) override; -#else - void enterEvent(QEvent *event) override; -#endif void leaveEvent(QEvent *event) override; void dragMoveEvent(QDragMoveEvent *event) override; diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp index ff42596ea..581920dbc 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp @@ -1,11 +1,19 @@ +/** + * @file dlg_settings.cpp + * @brief Implementation of the main settings dialog + * @ingroup Dialogs + */ #include "dlg_settings.h" #include "../../../client/settings/cache_settings.h" +#include "../../pixel_map_generator.h" #include "../main.h" #include "../settings_page/appearance_settings_page.h" #include "../settings_page/deck_editor_settings_page.h" #include "../settings_page/general_settings_page.h" #include "../settings_page/messages_settings_page.h" +#include "../settings_page/settings_search_delegate.h" +#include "../settings_page/settings_search_model.h" #include "../settings_page/shortcut_settings_page.h" #include "../settings_page/sound_settings_page.h" #include "../settings_page/storage_settings_page.h" @@ -13,18 +21,36 @@ #include "libcockatrice/card/database/card_database_loader.h" #include "libcockatrice/card/database/card_database_manager.h" +#include #include -#include #include +#include +#include #include -#include +#include +#include +#include +#include #include +#include +#include #include #include #include +#include +#include +#include #include +#include #include +#include +#include +/** + * @brief Wraps a widget in a scroll area for long settings pages + * @param widget The widget to wrap + * @return The scroll area containing the widget + */ static QScrollArea *makeScrollable(QWidget *widget) { widget->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Maximum); @@ -38,121 +64,412 @@ static QScrollArea *makeScrollable(QWidget *widget) return scrollArea; } -DlgSettings::DlgSettings(QWidget *parent) : QDialog(parent) +/** + * @brief Returns the theme icon resources for each settings page, indexed by SettingsPage order + */ +static QStringList pageIconResources() +{ + return {QStringLiteral("theme:config/general"), QStringLiteral("theme:config/appearance"), + QStringLiteral("theme:config/interface"), QStringLiteral("theme:config/deckeditor"), + QStringLiteral("theme:config/storage"), QStringLiteral("theme:config/messages"), + QStringLiteral("theme:config/sound"), QStringLiteral("theme:config/shorcuts")}; +} + +DlgSettings::DlgSettings(QWidget *parent) : QDialog(parent), currentTabIndex(0), searchActive(false) { auto rec = QGuiApplication::primaryScreen()->availableGeometry(); - this->setMinimumSize(qMin(700, rec.width()), qMin(700, rec.height())); + setMinimumSize(qMin(750, rec.width()), qMin(700, rec.height())); - connect(&SettingsCache::instance(), &SettingsCache::langChanged, this, &DlgSettings::updateLanguage); + connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &DlgSettings::updateLanguage); - contentsWidget = new QListWidget; - contentsWidget->setViewMode(QListView::IconMode); - contentsWidget->setIconSize(QSize(58, 50)); - contentsWidget->setMovement(QListView::Static); - contentsWidget->setMinimumHeight(85); - contentsWidget->setMaximumHeight(85); - contentsWidget->setSpacing(5); + setupUi(); - pagesWidget = new QStackedWidget; - pagesWidget->addWidget(makeScrollable(new GeneralSettingsPage)); - pagesWidget->addWidget(makeScrollable(new AppearanceSettingsPage)); - pagesWidget->addWidget(makeScrollable(new UserInterfaceSettingsPage)); - pagesWidget->addWidget(new DeckEditorSettingsPage); - pagesWidget->addWidget(makeScrollable(new StorageSettingsPage)); - pagesWidget->addWidget(new MessagesSettingsPage); - pagesWidget->addWidget(new SoundSettingsPage); - pagesWidget->addWidget(new ShortcutSettingsPage); - - createIcons(); - contentsWidget->setCurrentRow(0); - - auto *vboxLayout = new QVBoxLayout; - vboxLayout->addWidget(contentsWidget); - vboxLayout->addWidget(pagesWidget); - - auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok); - connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgSettings::close); - - auto *mainLayout = new QVBoxLayout; - mainLayout->addLayout(vboxLayout); - mainLayout->addSpacing(2); - mainLayout->addWidget(buttonBox); - setLayout(mainLayout); - - connect(&SettingsCache::instance(), &SettingsCache::langChanged, this, &DlgSettings::retranslateUi); + connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &DlgSettings::retranslateUi); retranslateUi(); + searchEdit->setFocus(); + adjustSize(); } -void DlgSettings::createIcons() +void DlgSettings::setupUi() { - generalButton = new QListWidgetItem(contentsWidget); - generalButton->setTextAlignment(Qt::AlignHCenter); - generalButton->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled); - generalButton->setIcon(QPixmap("theme:config/general")); + // Search bar + searchEdit = new QLineEdit; + searchEdit->setClearButtonEnabled(true); + searchEdit->addAction(themePixmap(QStringLiteral("icons/search")), QLineEdit::LeadingPosition); + searchEdit->installEventFilter(this); + connect(searchEdit, &QLineEdit::textChanged, this, &DlgSettings::onSearchTextChanged); - appearanceButton = new QListWidgetItem(contentsWidget); - appearanceButton->setTextAlignment(Qt::AlignHCenter); - appearanceButton->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled); - appearanceButton->setIcon(QPixmap("theme:config/appearance")); + auto *searchLayout = new QHBoxLayout; + searchLayout->addWidget(searchEdit); - userInterfaceButton = new QListWidgetItem(contentsWidget); - userInterfaceButton->setTextAlignment(Qt::AlignHCenter); - userInterfaceButton->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled); - userInterfaceButton->setIcon(QPixmap("theme:config/interface")); + // Tab bar (built in setupTabBar) + setupTabBar(); - deckEditorButton = new QListWidgetItem(contentsWidget); - deckEditorButton->setTextAlignment(Qt::AlignHCenter); - deckEditorButton->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled); - deckEditorButton->setIcon(QPixmap("theme:config/deckeditor")); + // Pages stacked widget + pagesWidget = new QStackedWidget; - storageButton = new QListWidgetItem(contentsWidget); - storageButton->setTextAlignment(Qt::AlignHCenter); - storageButton->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled); - storageButton->setIcon(QPixmap("theme:config/storage")); + auto *generalPage = new GeneralSettingsPage; + auto *appearancePage = new AppearanceSettingsPage; + auto *userInterfacePage = new UserInterfaceSettingsPage; + auto *deckEditorPage = new DeckEditorSettingsPage; + auto *storagePage = new StorageSettingsPage; + auto *messagesPage = new MessagesSettingsPage; + auto *soundPage = new SoundSettingsPage; + auto *shortcutsPage = new ShortcutSettingsPage; - messagesButton = new QListWidgetItem(contentsWidget); - messagesButton->setTextAlignment(Qt::AlignHCenter); - messagesButton->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled); - messagesButton->setIcon(QPixmap("theme:config/messages")); + pages.append(generalPage); + pages.append(appearancePage); + pages.append(userInterfacePage); + pages.append(deckEditorPage); + pages.append(storagePage); + pages.append(messagesPage); + pages.append(soundPage); + pages.append(shortcutsPage); - soundButton = new QListWidgetItem(contentsWidget); - soundButton->setTextAlignment(Qt::AlignHCenter); - soundButton->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled); - soundButton->setIcon(QPixmap("theme:config/sound")); + pagesWidget->addWidget(makeScrollable(generalPage)); + pagesWidget->addWidget(makeScrollable(appearancePage)); + pagesWidget->addWidget(makeScrollable(userInterfacePage)); + pagesWidget->addWidget(makeScrollable(deckEditorPage)); + pagesWidget->addWidget(makeScrollable(storagePage)); + pagesWidget->addWidget(makeScrollable(messagesPage)); + pagesWidget->addWidget(soundPage); + pagesWidget->addWidget(shortcutsPage); - shortcutsButton = new QListWidgetItem(contentsWidget); - shortcutsButton->setTextAlignment(Qt::AlignHCenter); - shortcutsButton->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled); - shortcutsButton->setIcon(QPixmap("theme:config/shorcuts")); + Q_ASSERT(pages.size() == NumPages); - connect(contentsWidget, &QListWidget::currentItemChanged, this, &DlgSettings::changePage); + // Search results view (hidden by default) + searchResultsView = new QListView; + searchResultsView->setUniformItemSizes(false); + searchResultsView->setSelectionMode(QAbstractItemView::SingleSelection); + searchResultsView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + searchResultsView->setVisible(false); + searchResultsView->setStyleSheet( + "QListView::item:selected { background: palette(highlight); color: palette(highlighted-text); }"); + + searchModel = new SettingsSearchModel(this); + searchDelegate = new SettingsSearchDelegate(this); + searchResultsView->setModel(searchModel); + searchResultsView->setItemDelegate(searchDelegate); + connect(searchResultsView, &QListView::clicked, this, &DlgSettings::onSearchResultClicked); + + connect(&SettingsCache::instance(), &SettingsCache::themeChanged, this, [this] { + const QStringList icons = pageIconResources(); + for (int i = 0; i < tabButtons.size() && i < icons.size(); ++i) { + tabButtons[i]->setIcon(QPixmap(icons[i])); + } + searchDelegate->setPageIcons(icons); + searchResultsView->viewport()->update(); + }); + + // Build search index after pages are created + buildSearchIndex(); + + // Pages container (stacked widget + search results overlay) + pagesContainer = new QWidget; + auto *containerLayout = new QStackedLayout; + containerLayout->setStackingMode(QStackedLayout::StackAll); + containerLayout->addWidget(pagesWidget); + containerLayout->addWidget(searchResultsView); + pagesContainer->setLayout(containerLayout); + + // Bottom buttons + auto *buttonBox = new QHBoxLayout; + buttonBox->addStretch(); + okButton = new QPushButton; + okButton->setDefault(true); + connect(okButton, &QPushButton::clicked, this, &DlgSettings::close); + buttonBox->addWidget(okButton); + + // Main layout + auto *mainLayout = new QVBoxLayout; + mainLayout->addLayout(searchLayout); + mainLayout->addWidget(tabBarWidget); + auto *separator = new QFrame; + separator->setFrameShape(QFrame::HLine); + separator->setFrameShadow(QFrame::Sunken); + mainLayout->addWidget(separator); + mainLayout->addWidget(pagesContainer); + mainLayout->addSpacing(4); + mainLayout->addLayout(buttonBox); + setLayout(mainLayout); + + // Keyboard shortcuts + auto *searchShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_F), this); + connect(searchShortcut, &QShortcut::activated, searchEdit, qOverload<>(&QLineEdit::setFocus)); + + auto *nextTabShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_Tab), this); + connect(nextTabShortcut, &QShortcut::activated, this, [this] { + int next = (currentTabIndex + 1) % tabButtons.size(); + setActiveTab(next); + }); + + auto *prevTabShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_Tab), this); + connect(prevTabShortcut, &QShortcut::activated, this, [this] { + int prev = (currentTabIndex - 1 + tabButtons.size()) % tabButtons.size(); + setActiveTab(prev); + }); + + // Initialize to first tab + setActiveTab(0); } -void DlgSettings::changePage(QListWidgetItem *current, QListWidgetItem *previous) +void DlgSettings::setupTabBar() { - if (!current) { - current = previous; + tabBarWidget = new QWidget; + auto *tabLayout = new QHBoxLayout; + tabLayout->setContentsMargins(0, 0, 0, 0); + tabLayout->setSpacing(2); + + const QStringList iconResources = pageIconResources(); + + for (int i = 0; i < iconResources.size(); ++i) { + auto *tabButton = new QToolButton; + tabButton->setCheckable(true); + tabButton->setIcon(QPixmap(iconResources[i])); + tabButton->setIconSize(QSize(48, 48)); + tabButton->setToolButtonStyle(Qt::ToolButtonTextUnderIcon); + tabButton->setAutoExclusive(true); + tabButton->setMinimumHeight(85); + tabButton->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + + connect(tabButton, &QToolButton::clicked, this, [this, idx = i] { onTabClicked(idx); }); + + tabButtons.append(tabButton); + tabLayout->addWidget(tabButton); } - pagesWidget->setCurrentIndex(contentsWidget->row(current)); + tabBarWidget->setLayout(tabLayout); +} + +void DlgSettings::buildSearchIndex() +{ + QList allEntries; + + const QStringList pageNames = translatedPageNames(); + searchDelegate->setPageNames(pageNames); + searchDelegate->setPageIcons(pageIconResources()); + + for (int i = 0; i < pages.size(); ++i) { + QList pageEntries = pages[i]->getSearchEntries(); + for (auto &entry : pageEntries) { + if (entry.pageIndex == -1) { + entry.pageIndex = i; + } + } + allEntries.append(pageEntries); + } + + searchModel->setSourceEntries(allEntries); +} + +void DlgSettings::onTabClicked(int index) +{ + if (searchActive) { + switchToTabMode(); + } + setActiveTab(index); +} + +void DlgSettings::setActiveTab(int index) +{ + if (index < 0 || index >= tabButtons.size()) { + return; + } + + currentTabIndex = index; + pagesWidget->setCurrentIndex(index); + + for (int i = 0; i < tabButtons.size(); ++i) { + tabButtons[i]->setChecked(i == index); + } + + // Style active tab with a thick accent border + subtle background tint + for (int i = 0; i < tabButtons.size(); ++i) { + if (i == index) { + tabButtons[i]->setStyleSheet("QToolButton { border: none; border-bottom: 3px solid palette(highlight); " + "border-top-left-radius: 4px; border-top-right-radius: 4px; " + "background: palette(window); padding-bottom: 1px; }"); + } else { + tabButtons[i]->setStyleSheet("QToolButton { border: none; border-bottom: 1px solid transparent; " + "border-top-left-radius: 4px; border-top-right-radius: 4px; " + "background: transparent; }"); + } + } +} + +void DlgSettings::flashWidget(QWidget *widget) +{ + auto *overlay = new QWidget(widget); + overlay->setGeometry(widget->rect()); + overlay->setAttribute(Qt::WA_TransparentForMouseEvents, true); + + QPalette pal = overlay->palette(); + QColor flashColor = pal.color(QPalette::Highlight); + flashColor.setAlpha(100); + pal.setBrush(QPalette::Window, flashColor); + overlay->setPalette(pal); + overlay->setAutoFillBackground(true); + + auto *effect = new QGraphicsOpacityEffect(overlay); + effect->setOpacity(0.0); + overlay->setGraphicsEffect(effect); + overlay->show(); + overlay->raise(); + + auto *flashIn = new QPropertyAnimation(effect, "opacity"); + flashIn->setDuration(120); + flashIn->setStartValue(0.0); + flashIn->setEndValue(0.6); + flashIn->setEasingCurve(QEasingCurve::OutCubic); + + auto *fadeOut = new QPropertyAnimation(effect, "opacity"); + fadeOut->setDuration(900); + fadeOut->setStartValue(0.6); + fadeOut->setEndValue(0.0); + fadeOut->setEasingCurve(QEasingCurve::InCubic); + + auto *group = new QSequentialAnimationGroup(overlay); + group->addAnimation(flashIn); + group->addAnimation(fadeOut); + + connect(group, &QSequentialAnimationGroup::finished, overlay, &QWidget::deleteLater); + + group->start(QAbstractAnimation::DeleteWhenStopped); +} + +void DlgSettings::onSearchTextChanged(const QString &text) +{ + searchModel->setFilterString(text); + + if (searchModel->isFilterActive() && !text.trimmed().isEmpty()) { + if (!searchActive) { + switchToSearchMode(); + } + if (searchModel->rowCount(QModelIndex()) > 0) { + searchResultsView->setCurrentIndex(searchModel->index(0)); + } + } else if (searchActive) { + switchToTabMode(); + } +} + +void DlgSettings::switchToSearchMode() +{ + searchActive = true; + tabBarWidget->setVisible(false); + pagesWidget->setVisible(false); + searchResultsView->setVisible(true); + if (searchModel->rowCount(QModelIndex()) > 0) { + searchResultsView->setCurrentIndex(searchModel->index(0)); + } +} + +void DlgSettings::switchToTabMode() +{ + searchActive = false; + tabBarWidget->setVisible(true); + pagesWidget->setVisible(true); + searchResultsView->setVisible(false); + searchEdit->blockSignals(true); + searchEdit->clear(); + searchEdit->blockSignals(false); + setActiveTab(currentTabIndex); +} + +void DlgSettings::onSearchResultClicked(const QModelIndex &index) +{ + navigateToSearchResult(index); +} + +void DlgSettings::navigateToSearchResult(const QModelIndex &index) +{ + SettingsSearchEntry entry = searchModel->entryForIndex(index); + if (entry.pageIndex < 0 || entry.pageIndex >= pages.size()) { + return; + } + + // Switch to the page + switchToTabMode(); + setActiveTab(entry.pageIndex); + + // Scroll to the widget, focus it, and flash to highlight it + if (entry.widget) { + QWidget *widget = entry.widget; + while (widget) { + if (auto *scrollArea = qobject_cast(widget)) { + scrollArea->ensureWidgetVisible(entry.widget); + break; + } + widget = widget->parentWidget(); + } + entry.widget->setFocus(); + flashWidget(entry.widget); + } } void DlgSettings::setTab(int index) { - if (index <= contentsWidget->count() - 1 && index >= 0) { - changePage(contentsWidget->item(index), contentsWidget->currentItem()); - contentsWidget->setCurrentRow(index); + if (index >= 0 && index < tabButtons.size()) { + setActiveTab(index); } } +AbstractSettingsPage *DlgSettings::page(SettingsPage which) const +{ + return pages.value(static_cast(which)); +} + void DlgSettings::updateLanguage() { qApp->removeTranslator(translator); // NOLINT(cppcoreguidelines-pro-type-static-cast-downcast) installNewTranslator(); } +bool DlgSettings::eventFilter(QObject *watched, QEvent *event) +{ + if (watched == searchEdit && event->type() == QEvent::KeyPress) { + auto *keyEvent = static_cast(event); + if (keyEvent->key() == Qt::Key_Escape) { + if (searchActive) { + switchToTabMode(); + return true; + } + } else if (keyEvent->key() == Qt::Key_Return || keyEvent->key() == Qt::Key_Enter) { + if (searchActive) { + if (searchResultsView->currentIndex().isValid()) { + navigateToSearchResult(searchResultsView->currentIndex()); + } + return true; + } + } else if (keyEvent->key() == Qt::Key_Down) { + if (searchActive) { + int nextRow = searchResultsView->currentIndex().row() + 1; + if (nextRow >= searchModel->rowCount()) { + nextRow = 0; + } + searchResultsView->setCurrentIndex(searchModel->index(nextRow)); + searchResultsView->scrollTo(searchModel->index(nextRow)); + return true; + } + } else if (keyEvent->key() == Qt::Key_Up) { + if (searchActive) { + int prevRow = searchResultsView->currentIndex().row() - 1; + if (prevRow < 0) { + prevRow = searchModel->rowCount() - 1; + } + if (prevRow >= 0) { + searchResultsView->setCurrentIndex(searchModel->index(prevRow)); + searchResultsView->scrollTo(searchModel->index(prevRow)); + } + return true; + } + } + } + return QDialog::eventFilter(watched, event); +} + void DlgSettings::closeEvent(QCloseEvent *event) { bool showLoadError = true; @@ -166,13 +483,13 @@ void DlgSettings::closeEvent(QCloseEvent *event) case Invalid: loadErrorMessage = tr("Your card database is invalid.\n\n" "Cockatrice may not function correctly with an invalid database\n\n" - "You may need to rerun oracle to update your card database.\n\n" + "You may need to rerun Oracle to update your card database.\n\n" "Would you like to change your database location setting?"); break; case VersionTooOld: loadErrorMessage = tr("Your card database version is too old.\n\n" "This can cause problems loading card information or images\n\n" - "Usually this can be fixed by rerunning oracle to to update your card database.\n\n" + "Usually this can be fixed by rerunning Oracle to to update your card database.\n\n" "Would you like to change your database location setting?"); break; case NotLoaded: @@ -205,8 +522,8 @@ void DlgSettings::closeEvent(QCloseEvent *event) } } - if (!QDir(SettingsCache::instance().getDeckPath()).exists() || SettingsCache::instance().getDeckPath().isEmpty()) { - //! \todo Prompt to create the deck directory. + if (!QDir(SettingsCache::instance().paths().getDeckPath()).exists() || + SettingsCache::instance().paths().getDeckPath().isEmpty()) { if (QMessageBox::critical( this, tr("Error"), tr("The path to your deck directory is invalid. Would you like to go back and set the correct path?"), @@ -216,8 +533,8 @@ void DlgSettings::closeEvent(QCloseEvent *event) } } - if (!QDir(SettingsCache::instance().getPicsPath()).exists() || SettingsCache::instance().getPicsPath().isEmpty()) { - //! \todo Prompt to create the pictures directory. + if (!QDir(SettingsCache::instance().paths().getPicsPath()).exists() || + SettingsCache::instance().paths().getPicsPath().isEmpty()) { if (QMessageBox::critical(this, tr("Error"), tr("The path to your card pictures directory is invalid. Would you like to go back " "and set the correct path?"), @@ -232,15 +549,26 @@ void DlgSettings::closeEvent(QCloseEvent *event) void DlgSettings::retranslateUi() { setWindowTitle(tr("Settings")); + retranslateTabNames(); - generalButton->setText(tr("General")); - appearanceButton->setText(tr("Appearance")); - userInterfaceButton->setText(tr("User Interface")); - storageButton->setText(tr("Storage")); - deckEditorButton->setText(tr("Card Sources")); - messagesButton->setText(tr("Chat")); - soundButton->setText(tr("Sound")); - shortcutsButton->setText(tr("Shortcuts")); + searchEdit->setPlaceholderText(tr("Search settings...")); + okButton->setText(tr("OK")); - contentsWidget->reset(); + // Rebuild search index for translated text + buildSearchIndex(); +} + +QStringList DlgSettings::translatedPageNames() +{ + return {tr("General"), tr("Appearance"), tr("User Interface"), tr("Card Sources"), + tr("Storage"), tr("Chat"), tr("Sound"), tr("Shortcuts")}; +} + +void DlgSettings::retranslateTabNames() +{ + const QStringList tabLabels = translatedPageNames(); + + for (int i = 0; i < tabButtons.size() && i < tabLabels.size(); ++i) { + tabButtons[i]->setText(tabLabels[i]); + } } diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_settings.h b/cockatrice/src/interface/widgets/dialogs/dlg_settings.h index 3ffee6388..845c0b4d6 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_settings.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_settings.h @@ -1,43 +1,100 @@ /** * @file dlg_settings.h + * @brief Main settings dialog for the Cockatrice client * @ingroup Dialogs */ -//! \todo Document this file. - #ifndef DLG_SETTINGS_H #define DLG_SETTINGS_H -#include #include #include +class QPushButton; + inline Q_LOGGING_CATEGORY(DlgSettingsLog, "dlg_settings"); -class QListWidget; class QStackedWidget; -class QListWidgetItem; +class QToolButton; +class QListView; +class QLineEdit; +class AbstractSettingsPage; +class SettingsSearchModel; +class SettingsSearchDelegate; + +/** + * @brief Main application settings dialog with tabbed navigation and search + * + * Provides a modern settings interface organized into tabbed pages. Users can + * either navigate by clicking tabs or search for specific settings using the + * built-in search bar. Search results are filtered and ranked by relevance. + */ class DlgSettings : public QDialog { Q_OBJECT public: + /** + * @brief Page order in the tab bar, matching the order pages are added in setupUi() + * + * Use these values instead of raw indices so reordering pages never silently + * breaks external callers like tab_room.cpp. + */ + enum SettingsPage + { + GeneralPage = 0, + AppearancePage, + UserInterfacePage, + DeckEditorPage, + StoragePage, + MessagesPage, + SoundPage, + ShortcutsPage, + NumPages + }; + explicit DlgSettings(QWidget *parent = nullptr); void setTab(int index); + AbstractSettingsPage *page(SettingsPage which) const; private slots: - void changePage(QListWidgetItem *current, QListWidgetItem *previous); + void onTabClicked(int index); + void onSearchTextChanged(const QString &text); + void onSearchResultClicked(const QModelIndex &index); void updateLanguage(); private: - QListWidget *contentsWidget; - QStackedWidget *pagesWidget; - QListWidgetItem *generalButton, *appearanceButton, *userInterfaceButton, *deckEditorButton, *storageButton, - *messagesButton, *soundButton, *shortcutsButton; - void createIcons(); + // UI elements + QLineEdit *searchEdit; ///< Search bar for filtering settings + QWidget *tabBarWidget; ///< Container widget for the tab buttons + QList tabButtons; ///< Navigation tab buttons + QStackedWidget *pagesWidget; ///< Stacked widget containing settings pages + QListView *searchResultsView; ///< Search results list view + QWidget *pagesContainer; ///< Container stacking pages and search results + QPushButton *okButton; ///< Button to close the dialog + + // Data + QList pages; ///< All settings page instances + SettingsSearchModel *searchModel; ///< Model for search results + SettingsSearchDelegate *searchDelegate; ///< Delegate for search result rendering + int currentTabIndex; ///< Currently active tab index + bool searchActive; ///< Whether search mode is active + + void setupUi(); + void setupTabBar(); + void buildSearchIndex(); + void switchToTabMode(); + void switchToSearchMode(); + void navigateToSearchResult(const QModelIndex &index); + void setActiveTab(int index); + static void flashWidget(QWidget *widget); + static QStringList translatedPageNames(); + void retranslateUi(); + void retranslateTabNames(); protected: void closeEvent(QCloseEvent *event) override; + bool eventFilter(QObject *watched, QEvent *event) override; }; -#endif +#endif // DLG_SETTINGS_H diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_startup_card_check.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_startup_card_check.cpp index ac1922a9d..4322beb8c 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_startup_card_check.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_startup_card_check.cpp @@ -3,6 +3,7 @@ #include "../../../client/settings/cache_settings.h" #include +#include DlgStartupCardCheck::DlgStartupCardCheck(QWidget *parent) : QDialog(parent) { @@ -10,7 +11,7 @@ DlgStartupCardCheck::DlgStartupCardCheck(QWidget *parent) : QDialog(parent) layout = new QVBoxLayout(this); - QDate lastCheckDate = SettingsCache::instance().getLastCardUpdateCheck(); + QDate lastCheckDate = SettingsCache::instance().updates().getLastCardUpdateCheck(); int daysAgo = lastCheckDate.daysTo(QDate::currentDate()); instructionLabel = new QLabel( diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_tip_of_the_day.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_tip_of_the_day.cpp index 59330e27e..3d3619bf0 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_tip_of_the_day.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_tip_of_the_day.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #define MIN_TIP_IMAGE_HEIGHT 200 #define MIN_TIP_IMAGE_WIDTH 200 @@ -41,7 +42,7 @@ DlgTipOfTheDay::DlgTipOfTheDay(QWidget *parent) : QDialog(parent) tipNumber = new QLabel(); tipNumber->setAlignment(Qt::AlignCenter); - QList seenTips = SettingsCache::instance().getSeenTips(); + QList seenTips = SettingsCache::instance().personal().getSeenTips(); newTipsAvailable = false; currentTip = 0; for (int i = 0; i < tipDatabase->rowCount(); i++) { @@ -73,9 +74,9 @@ DlgTipOfTheDay::DlgTipOfTheDay(QWidget *parent) : QDialog(parent) connect(previousButton, &QPushButton::clicked, this, &DlgTipOfTheDay::previousClicked); showTipsOnStartupCheck = new QCheckBox("Show tips on startup"); - showTipsOnStartupCheck->setChecked(SettingsCache::instance().getShowTipsOnStartup()); - connect(showTipsOnStartupCheck, &QCheckBox::clicked, &SettingsCache::instance(), - &SettingsCache::setShowTipsOnStartup); + showTipsOnStartupCheck->setChecked(SettingsCache::instance().personal().getShowTipsOnStartup()); + connect(showTipsOnStartupCheck, &QCheckBox::clicked, &SettingsCache::instance().personal(), + &PersonalSettings::setShowTipsOnStartup); buttonBar = new QHBoxLayout(); buttonBar->addWidget(showTipsOnStartupCheck); buttonBar->addWidget(tipNumber); @@ -130,10 +131,10 @@ void DlgTipOfTheDay::updateTip(int tipId) } // Store tip id as seen - QList seenTips = SettingsCache::instance().getSeenTips(); + QList seenTips = SettingsCache::instance().personal().getSeenTips(); if (!seenTips.contains(tipId)) { seenTips.append(tipId); - SettingsCache::instance().setSeenTips(seenTips); + SettingsCache::instance().personal().setSeenTips(seenTips); } TipOfTheDay tip = tipDatabase->getTip(tipId); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp index ee2149309..7cf58d3e0 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp @@ -134,7 +134,7 @@ void DlgUpdate::finishedUpdateCheck(bool needToUpdate, bool isCompatible, Releas // If there's no need to update, tell them that. However we still allow them to run the // downloader themselves if there's a compatible build QMessageBox::information( - this, tr("No Update Available"), + window(), tr("No Update Available"), tr("Cockatrice is up to date!") + "

" + tr("You are already running the latest version available in the chosen release channel.") + "
" + "" + tr("Current version") + QString(": %1
").arg(VERSION_STRING) + "" + @@ -147,7 +147,7 @@ void DlgUpdate::finishedUpdateCheck(bool needToUpdate, bool isCompatible, Releas if (isCompatible) { int reply; reply = QMessageBox::question( - this, tr("Update Available"), + window(), tr("Update Available"), tr("A new version of Cockatrice is available!") + "

" + "" + tr("New version") + QString(": %1
").arg(release->getName()) + "" + tr("Released") + QString(": %1 (").arg(publishDate, release->getDescriptionUrl()) + tr("Changelog") + @@ -156,10 +156,12 @@ void DlgUpdate::finishedUpdateCheck(bool needToUpdate, bool isCompatible, Releas if (reply == QMessageBox::Yes) { downloadUpdate(release->getName()); + } else { + closeDialog(); } } else { QMessageBox::information( - this, tr("Update Available"), + window(), tr("Update Available"), tr("A new version of Cockatrice is available!") + "

" + "" + tr("New version") + QString(": %1
").arg(release->getName()) + "" + tr("Released") + QString(": %1 (
").arg(publishDate, release->getDescriptionUrl()) + tr("Changelog") + diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_view_log.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_view_log.cpp index 4eb054647..957e039c0 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_view_log.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_view_log.cpp @@ -3,11 +3,13 @@ #include "../../../client/settings/cache_settings.h" #include "../../logger.h" +#include #include #include #include #include #include +#include DlgViewLog::DlgViewLog(QWidget *parent) : QDialog(parent) { diff --git a/cockatrice/src/interface/widgets/dialogs/override_printing_warning.cpp b/cockatrice/src/interface/widgets/dialogs/override_printing_warning.cpp index d7699fbe4..33d102f5e 100644 --- a/cockatrice/src/interface/widgets/dialogs/override_printing_warning.cpp +++ b/cockatrice/src/interface/widgets/dialogs/override_printing_warning.cpp @@ -3,6 +3,7 @@ #include "../../card_picture_loader/card_picture_loader.h" #include "../../client/settings/cache_settings.h" +#include bool OverridePrintingWarning::execMessageBox(QWidget *parent, bool enable) { QString message; @@ -27,7 +28,7 @@ bool OverridePrintingWarning::execMessageBox(QWidget *parent, bool enable) QMessageBox::question(parent, QObject::tr("Confirm Change"), message, QMessageBox::Yes | QMessageBox::No); if (result == QMessageBox::Yes) { - SettingsCache::instance().setOverrideAllCardArtWithPersonalPreference(static_cast(enable)); + SettingsCache::instance().cardsDisplay().setOverrideAllCardArtWithPersonalPreference(enable); // Caches are now invalid. CardPictureLoader::clearPixmapCache(); CardPictureLoader::clearNetworkCache(); diff --git a/cockatrice/src/interface/widgets/general/display/banner_widget.cpp b/cockatrice/src/interface/widgets/general/display/banner_widget.cpp index 5de5457ea..31f384cac 100644 --- a/cockatrice/src/interface/widgets/general/display/banner_widget.cpp +++ b/cockatrice/src/interface/widgets/general/display/banner_widget.cpp @@ -32,6 +32,7 @@ BannerWidget::BannerWidget(QWidget *parent, const QString &text, Qt::Orientation // Set minimum height for the widget setMinimumHeight(50); + setMaximumHeight(100); connect(this, &BannerWidget::buddyVisibilityChanged, this, &BannerWidget::toggleBuddyVisibility); updateDropdownIconState(); diff --git a/cockatrice/src/interface/widgets/general/display/charts/bars/bar_chart_widget.cpp b/cockatrice/src/interface/widgets/general/display/charts/bars/bar_chart_widget.cpp index d9e108e6a..04315d0ce 100644 --- a/cockatrice/src/interface/widgets/general/display/charts/bars/bar_chart_widget.cpp +++ b/cockatrice/src/interface/widgets/general/display/charts/bars/bar_chart_widget.cpp @@ -207,11 +207,7 @@ void BarChartWidget::mouseMoveEvent(QMouseEvent *e) if (hoveredSegment >= 0) { const auto &s = segments[hoveredSegment]; QString text = QString("%1: %2 cards\n\n%3").arg(s.category).arg(s.value).arg(s.cards.join("\n")); -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) QToolTip::showText(e->globalPosition().toPoint(), text, this); -#else - QToolTip::showText(e->globalPos(), text, this); -#endif } else { QToolTip::hideText(); } diff --git a/cockatrice/src/interface/widgets/general/display/charts/bars/color_bar.cpp b/cockatrice/src/interface/widgets/general/display/charts/bars/color_bar.cpp index 12ab5bb3b..1087c621e 100644 --- a/cockatrice/src/interface/widgets/general/display/charts/bars/color_bar.cpp +++ b/cockatrice/src/interface/widgets/general/display/charts/bars/color_bar.cpp @@ -83,19 +83,11 @@ void ColorBar::paintEvent(QPaintEvent *) } } -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) void ColorBar::enterEvent(QEnterEvent *event) { Q_UNUSED(event); isHovered = true; } -#else -void ColorBar::enterEvent(QEvent *event) -{ - Q_UNUSED(event); - isHovered = true; -} -#endif void ColorBar::leaveEvent(QEvent *) { @@ -108,13 +100,8 @@ void ColorBar::mouseMoveEvent(QMouseEvent *event) return; } -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) int x = int(event->position().x()); QPoint gp = event->globalPosition().toPoint(); -#else - int x = event->pos().x(); - QPoint gp = event->globalPos(); -#endif QString text = tooltipForPosition(x); if (!text.isEmpty()) { diff --git a/cockatrice/src/interface/widgets/general/display/charts/bars/color_bar.h b/cockatrice/src/interface/widgets/general/display/charts/bars/color_bar.h index 100f95310..1d435cec1 100644 --- a/cockatrice/src/interface/widgets/general/display/charts/bars/color_bar.h +++ b/cockatrice/src/interface/widgets/general/display/charts/bars/color_bar.h @@ -76,17 +76,10 @@ protected: */ void paintEvent(QPaintEvent *event) override; -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) /** - * @brief Handles mouse hover entering (Qt6 version). + * @brief Handles mouse hover entering. */ void enterEvent(QEnterEvent *event) override; -#else - /** - * @brief Handles mouse hover entering (Qt5 version). - */ - void enterEvent(QEvent *event) override; -#endif /** * @brief Handles mouse hover leaving. diff --git a/cockatrice/src/interface/widgets/general/display/charts/bars/segmented_bar_widget.cpp b/cockatrice/src/interface/widgets/general/display/charts/bars/segmented_bar_widget.cpp index 9bad32bda..03c69373a 100644 --- a/cockatrice/src/interface/widgets/general/display/charts/bars/segmented_bar_widget.cpp +++ b/cockatrice/src/interface/widgets/general/display/charts/bars/segmented_bar_widget.cpp @@ -134,9 +134,5 @@ void SegmentedBarWidget::mouseMoveEvent(QMouseEvent *e) const Segment &s = segments[idx]; QString text = QString("%1: %2 cards\n%3").arg(s.category).arg(s.value).arg(s.cards.join(", ")); -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) QToolTip::showText(e->globalPosition().toPoint(), text, this); -#else - QToolTip::showText(e->globalPos(), text, this); -#endif } diff --git a/cockatrice/src/interface/widgets/general/display/charts/pies/color_pie.cpp b/cockatrice/src/interface/widgets/general/display/charts/pies/color_pie.cpp index b129fbe18..bd4641981 100644 --- a/cockatrice/src/interface/widgets/general/display/charts/pies/color_pie.cpp +++ b/cockatrice/src/interface/widgets/general/display/charts/pies/color_pie.cpp @@ -82,11 +82,7 @@ void ColorPie::paintEvent(QPaintEvent *) QString label = QString("%1%").arg(int(ratio * 100 + 0.5)); QFontMetrics fm(p.font()); -#if QT_VERSION >= QT_VERSION_CHECK(5, 11, 0) int labelWidth = fm.horizontalAdvance(label); -#else - int labelWidth = fm.width(label); -#endif QRectF textRect(labelPos.x() - labelWidth / 2.0, labelPos.y() - fm.height() / 2.0, labelWidth, fm.height()); p.setPen(Qt::black); @@ -96,19 +92,11 @@ void ColorPie::paintEvent(QPaintEvent *) } } -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) void ColorPie::enterEvent(QEnterEvent *event) { Q_UNUSED(event); isHovered = true; } -#else -void ColorPie::enterEvent(QEvent *event) -{ - Q_UNUSED(event); - isHovered = true; -} -#endif void ColorPie::leaveEvent(QEvent *) { @@ -121,13 +109,8 @@ void ColorPie::mouseMoveEvent(QMouseEvent *event) return; } -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) QPoint p = event->position().toPoint(); QPoint gp = event->globalPosition().toPoint(); -#else - QPoint p = event->pos(); - QPoint gp = event->globalPos(); -#endif QString text = tooltipForPoint(p); if (!text.isEmpty()) { diff --git a/cockatrice/src/interface/widgets/general/display/charts/pies/color_pie.h b/cockatrice/src/interface/widgets/general/display/charts/pies/color_pie.h index 7d71ea3b9..60d8e18f2 100644 --- a/cockatrice/src/interface/widgets/general/display/charts/pies/color_pie.h +++ b/cockatrice/src/interface/widgets/general/display/charts/pies/color_pie.h @@ -22,11 +22,7 @@ public: protected: void paintEvent(QPaintEvent *) override; -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) void enterEvent(QEnterEvent *event) override; -#else - void enterEvent(QEvent *event) override; -#endif void leaveEvent(QEvent *) override; void mouseMoveEvent(QMouseEvent *event) override; diff --git a/cockatrice/src/interface/widgets/general/home_tab_button_color.h b/cockatrice/src/interface/widgets/general/home_tab_button_color.h new file mode 100644 index 000000000..b45bd7a92 --- /dev/null +++ b/cockatrice/src/interface/widgets/general/home_tab_button_color.h @@ -0,0 +1,49 @@ +#ifndef COCKATRICE_HOME_TAB_BUTTON_COLOR_H +#define COCKATRICE_HOME_TAB_BUTTON_COLOR_H + +#include + +namespace HomeTabButtonColor +{ + +/** + * @brief Where to get the colors for the home tab buttons from + */ +enum Source +{ + FromThemeColors, ///< Use the theme's identity accent colors + FromBackground, ///< Extract colour from the background image +}; + +struct Entry +{ + Source source; + const char *trKey; ///< key for translation +}; + +inline QList all() +{ + static QList entries = {{FromThemeColors, QT_TR_NOOP("From theme colors")}, + {FromBackground, QT_TR_NOOP("Extract from background")}}; + + return entries; +} + +/** + * Safely converts an int into the corresponding Source. + * + * @param value The int value + * @return The Source. Returns Source::FromThemeColors if the value is not within range + */ +inline Source intToSource(int value) +{ + if (value > FromBackground) { + return FromThemeColors; // default + } + + return static_cast(value); +} + +} // namespace HomeTabButtonColor + +#endif // COCKATRICE_HOME_TAB_BUTTON_COLOR_H diff --git a/cockatrice/src/interface/widgets/general/home_widget.cpp b/cockatrice/src/interface/widgets/general/home_widget.cpp index e1687f997..648d315f9 100644 --- a/cockatrice/src/interface/widgets/general/home_widget.cpp +++ b/cockatrice/src/interface/widgets/general/home_widget.cpp @@ -2,27 +2,34 @@ #include "../../../client/settings/cache_settings.h" #include "../../../interface/widgets/tabs/tab_supervisor.h" +#include "../../pixel_map_generator.h" #include "../../theme_manager.h" #include "../../window_main.h" +#include "../cards/art_crop_attribution.h" #include "background_sources.h" #include "home_styled_button.h" +#include "home_tab_button_color.h" #include +#include +#include #include #include #include #include #include #include +#include +#include HomeWidget::HomeWidget(QWidget *parent, TabSupervisor *_tabSupervisor) - : QWidget(parent), tabSupervisor(_tabSupervisor), background("theme:backgrounds/home"), overlay("theme:cockatrice") + : QWidget(parent), tabSupervisor(_tabSupervisor), background(themePixmap(QStringLiteral("backgrounds/home"))) { layout = new QGridLayout(this); backgroundSourceCard = new CardInfoPictureArtCropWidget(this); - gradientColors = extractDominantColors(background); + gradientColors = determineButtonColor(); layout->addWidget(createButtons(), 1, 1, Qt::AlignVCenter | Qt::AlignHCenter); @@ -41,32 +48,47 @@ HomeWidget::HomeWidget(QWidget *parent, TabSupervisor *_tabSupervisor) updateConnectButton(tabSupervisor->getClient()->getStatus()); connect(tabSupervisor->getClient(), &RemoteClient::statusChanged, this, &HomeWidget::updateConnectButton); - connect(&SettingsCache::instance(), &SettingsCache::homeTabBackgroundSourceChanged, this, + connect(&SettingsCache::instance().appearance(), &AppearanceSettings::homeTabBackgroundSourceChanged, this, &HomeWidget::initializeBackgroundFromSource); - connect(&SettingsCache::instance(), &SettingsCache::homeTabBackgroundShuffleFrequencyChanged, this, - &HomeWidget::onBackgroundShuffleFrequencyChanged); + connect(&SettingsCache::instance().appearance(), &AppearanceSettings::homeTabBackgroundShuffleFrequencyChanged, + this, &HomeWidget::onBackgroundShuffleFrequencyChanged); // Lambda is cleaner to read than overloading this - connect(&SettingsCache::instance(), &SettingsCache::homeTabDisplayCardNameChanged, this, [this] { repaint(); }); + connect(&SettingsCache::instance().appearance(), &AppearanceSettings::homeTabDisplayCardNameChanged, this, + [this] { repaint(); }); + connect(&SettingsCache::instance().appearance(), &AppearanceSettings::homeTabBackgroundDimChanged, this, + [this] { repaint(); }); connect(&SettingsCache::instance(), &SettingsCache::themeChanged, this, &HomeWidget::initializeBackgroundFromSource); connect(&SettingsCache::instance(), &SettingsCache::themeChanged, this, &HomeWidget::updateButtonsToBackgroundColor); + // Scheme flips (light/dark/system with an OS switch) fire on themeManager, + // not on SettingsCache::themeChanged, so re-resolve the variant background. + connect(themeManager, &ThemeManager::themeChanged, this, &HomeWidget::initializeBackgroundFromSource); + connect(themeManager, &ThemeManager::paletteChanged, this, &HomeWidget::updateButtonsToBackgroundColor); + connect(themeManager, &ThemeManager::paletteChanged, this, &HomeWidget::updateLogoOverlay); + connect(&SettingsCache::instance().appearance(), &AppearanceSettings::homeTabButtonColorChanged, this, + &HomeWidget::updateButtonsToBackgroundColor); } void HomeWidget::initializeBackgroundFromSource() { + // The featured logo is theme/scheme-derived too; reload it alongside the + // background so a theme or appearance switch doesn't leave it stale. + updateLogoOverlay(); + if (CardDatabaseManager::getInstance()->getLoadStatus() != LoadStatus::Ok) { connect(CardDatabaseManager::getInstance(), &CardDatabase::cardDatabaseLoadingFinished, this, &HomeWidget::initializeBackgroundFromSource); return; } - auto backgroundSourceType = BackgroundSources::fromId(SettingsCache::instance().getHomeTabBackgroundSource()); + auto backgroundSourceType = + BackgroundSources::fromId(SettingsCache::instance().appearance().getHomeTabBackgroundSource()); switch (backgroundSourceType) { case BackgroundSources::Theme: cardChangeTimer->stop(); - background = QPixmap("theme:backgrounds/home"); + background = themePixmap(QStringLiteral("backgrounds/home")); backgroundSourceDeck = DeckList(); backgroundSourceCard->setCard(ExactCard()); updateButtonsToBackgroundColor(); @@ -88,10 +110,30 @@ void HomeWidget::initializeBackgroundFromSource() void HomeWidget::loadBackgroundSourceDeck() { std::optional deckOpt = DeckLoader::loadFromFile( - SettingsCache::instance().getDeckPath() + "background.cod", DeckFileFormat::Cockatrice, false); + SettingsCache::instance().paths().getDeckPath() + "background.cod", DeckFileFormat::Cockatrice, false); backgroundSourceDeck = deckOpt.has_value() ? deckOpt.value().deckList : DeckList(); } +static QPair paletteDerivedButtonColors() +{ + return {themeManager->appColor(AppColor::AccentStrong), themeManager->appColor(AppColor::AccentSoft)}; +} + +QPair HomeWidget::determineButtonColor() const +{ + auto colorSource = + HomeTabButtonColor::intToSource(SettingsCache::instance().appearance().getHomeTabButtonColorSourceIndex()); + + switch (colorSource) { + case HomeTabButtonColor::FromThemeColors: + return paletteDerivedButtonColors(); + case HomeTabButtonColor::FromBackground: + return extractDominantColors(background); + } + + return paletteDerivedButtonColors(); +} + void HomeWidget::setRandomCard(ExactCard &newCard) { static constexpr int ATTEMPTS = 10; @@ -108,7 +150,8 @@ void HomeWidget::setRandomCard(ExactCard &newCard) void HomeWidget::updateRandomCard() { - auto backgroundSourceType = BackgroundSources::fromId(SettingsCache::instance().getHomeTabBackgroundSource()); + auto backgroundSourceType = + BackgroundSources::fromId(SettingsCache::instance().appearance().getHomeTabBackgroundSource()); ExactCard newCard; @@ -151,8 +194,8 @@ void HomeWidget::updateRandomCard() void HomeWidget::onBackgroundShuffleFrequencyChanged() { cardChangeTimer->stop(); - if (SettingsCache::instance().getHomeTabBackgroundShuffleFrequency() > 0) { - cardChangeTimer->start(SettingsCache::instance().getHomeTabBackgroundShuffleFrequency() * 1000); + if (SettingsCache::instance().appearance().getHomeTabBackgroundShuffleFrequency() > 0) { + cardChangeTimer->start(SettingsCache::instance().appearance().getHomeTabBackgroundShuffleFrequency() * 1000); } } @@ -165,7 +208,7 @@ void HomeWidget::updateBackgroundProperties() void HomeWidget::updateButtonsToBackgroundColor() { - gradientColors = extractDominantColors(background); + gradientColors = determineButtonColor(); for (HomeStyledButton *button : findChildren()) { button->updateStylesheet(gradientColors); button->update(); @@ -192,10 +235,10 @@ QGroupBox *HomeWidget::createButtons() QVBoxLayout *boxLayout = new QVBoxLayout; boxLayout->setAlignment(Qt::AlignHCenter); - QLabel *logoLabel = new QLabel; - logoLabel->setPixmap(overlay.scaledToWidth(200, Qt::SmoothTransformation)); + logoLabel = new QLabel; logoLabel->setAlignment(Qt::AlignCenter); boxLayout->addWidget(logoLabel); + updateLogoOverlay(); boxLayout->addSpacing(25); connectButton = new HomeStyledButton("Connect/Play", gradientColors); @@ -260,11 +303,6 @@ void HomeWidget::updateConnectButton(const ClientStatus status) QPair HomeWidget::extractDominantColors(const QPixmap &pixmap) { - if (themeManager->isBuiltInTheme() && - SettingsCache::instance().getHomeTabBackgroundSource() == BackgroundSources::toId(BackgroundSources::Theme)) { - return QPair(QColor::fromRgb(20, 140, 60), QColor::fromRgb(120, 200, 80)); - } - // Step 1: Downscale image for performance QImage image = pixmap.toImage() .scaled(64, 64, Qt::KeepAspectRatio, Qt::SmoothTransformation) @@ -328,16 +366,19 @@ void HomeWidget::paintEvent(QPaintEvent *event) painter.drawPixmap(topLeft, toDraw); } - // Draw translucent black overlay with rounded corners - QRectF overlayRect(5, 5, width() - 10, height() - 10); - QPainterPath roundedRectPath; - roundedRectPath.addRoundedRect(overlayRect, 20, 20); + if (SettingsCache::instance().appearance().getHomeTabBackgroundDim()) { + // Draw translucent black overlay with rounded corners + QRectF overlayRect(5, 5, width() - 10, height() - 10); + QPainterPath roundedRectPath; + roundedRectPath.addRoundedRect(overlayRect, 20, 20); - QColor semiTransparentBlack(0, 0, 0, static_cast(255 * 0.33)); - painter.fillPath(roundedRectPath, semiTransparentBlack); + QColor semiTransparentBlack(0, 0, 0, static_cast(255 * 0.33)); + painter.fillPath(roundedRectPath, semiTransparentBlack); + } - // Card name overlay (bottom-right) + // Card name overlay (above the attribution, bottom-right) QString cardName; + QString attribution; ExactCard card = backgroundSourceCard->getCard(); if (card) { cardName = card.getCardPtr()->getName(); @@ -345,9 +386,28 @@ void HomeWidget::paintEvent(QPaintEvent *event) cardName += " (" + card.getPrinting().getSet()->getCorrectedShortName() + ") " + card.getPrinting().getProperty("num"); } + attribution = buildArtAttribution(card); } - if (!cardName.isEmpty() && SettingsCache::instance().getHomeTabDisplayCardName()) { + // Scryfall requires artist attribution wherever card art is shown cropped. + // Pin it to the bottom-right corner, using the same font as the card name pill, + // and align its right edge with the card name pill's right edge. + constexpr int margin = 15; + constexpr qreal attributionMargin = 4.0; + + QFont attributionFont = painter.font(); + attributionFont.setPointSize(14); + attributionFont.setBold(true); + painter.setFont(attributionFont); + + // paintArtAttribution insets the pill 4px from the given rect's right edge, + // so nudge the rect's right edge to land exactly on the pill's right edge. + QRectF attributionArea = rect(); + attributionArea.setRight(width() - margin + attributionMargin); + const QRectF attributionRect = paintArtAttribution(painter, attributionArea, attribution); + + // Card name bubble above the attribution (when enabled). + if (!cardName.isEmpty() && SettingsCache::instance().appearance().getHomeTabDisplayCardName()) { QFont font = painter.font(); font.setPointSize(14); font.setBold(true); @@ -355,24 +415,80 @@ void HomeWidget::paintEvent(QPaintEvent *event) QFontMetrics fm(font); constexpr int padding = 10; - constexpr int margin = 15; QRect textRect = fm.boundingRect(cardName); - QRect bgRect(width() - textRect.width() - padding * 2 - margin, - height() - textRect.height() - padding * 2 - margin, textRect.width() + padding * 2, - textRect.height() + padding * 2); + int bubbleBottom = height() - margin; + if (!attributionRect.isEmpty()) { + bubbleBottom = attributionRect.top() - 6; + } + const QRect nameBubbleRect(width() - textRect.width() - padding * 2 - margin, + bubbleBottom - textRect.height() - padding * 2, textRect.width() + padding * 2, + textRect.height() + padding * 2); // Background bubble painter.setPen(Qt::NoPen); painter.setBrush(QColor(0, 0, 0, 160)); - painter.drawRoundedRect(bgRect, 8, 8); + painter.drawRoundedRect(nameBubbleRect, 8, 8); // Text painter.setPen(Qt::white); - painter.drawText(bgRect.adjusted(padding, padding, -padding, -padding), Qt::AlignRight | Qt::AlignVCenter, - cardName); + painter.drawText(nameBubbleRect.adjusted(padding, padding, -padding, -padding), + Qt::AlignRight | Qt::AlignVCenter, cardName); } QWidget::paintEvent(event); } + +void HomeWidget::updateLogoOverlay() +{ + // Emulate cockatrice.svg in Qt rather than rendering the baked-in SVG. + // The SVG has no separate plate: the gradient fills the bird's silhouette + // paths (light #c9fd62/AccentSoft at the top-left, dark #139740/AccentStrong + // toward the bottom-right — the SVG's linearGradient4265-7-8 stops along + // its userSpaceOnUse axis), and the white highlight path + // (cockatrice-logo-white) sits on top. So we paint that gradient clipped to + // the full logo silhouette (the full-color logo's alpha), then overlay the + // white mark. Colours stay fully theme-driven and independent of the static + // greens baked into the SVG. + const QColor strong = themeManager->appColor(AppColor::AccentStrong); + const QColor soft = themeManager->appColor(AppColor::AccentSoft); + + const QPixmap silhouette = themePixmap(QStringLiteral("cockatrice")).scaledToWidth(200, Qt::SmoothTransformation); + const QPixmap whiteMark = + themePixmap(QStringLiteral("cockatrice-logo-white")).scaledToWidth(200, Qt::SmoothTransformation); + if (silhouette.isNull() || whiteMark.isNull()) { + return; + } + + QPixmap composite(silhouette.size()); + composite.fill(Qt::transparent); + + { + QPainter painter(&composite); + painter.setRenderHint(QPainter::Antialiasing); + painter.setRenderHint(QPainter::SmoothPixmapTransform); + + // Recreate cockatrice.svg's own gradient geometry (linearGradient + // 4265-7-8, userSpaceOnUse): light AccentSoft at S=(-8.097,-97.746), + // dark AccentStrong at E=(162.455,295.208), on the SVG's 300x300 + // canvas. Scale those coordinates to this composite's size. + const qreal scale = composite.width() / 300.0; + QLinearGradient gradient(QPointF(-8.097, -97.746) * scale, QPointF(162.455, 295.208) * scale); + gradient.setColorAt(0.0, soft); + gradient.setColorAt(1.0, strong); + painter.fillRect(composite.rect(), gradient); + + // Clip the gradient to the full logo silhouette exactly as the SVG's + // gradient paths are confined to the bird. + painter.setCompositionMode(QPainter::CompositionMode_DestinationIn); + painter.drawPixmap(0, 0, silhouette); + + painter.setCompositionMode(QPainter::CompositionMode_SourceOver); + painter.drawPixmap(0, 0, whiteMark); + } + + if (logoLabel) { + logoLabel->setPixmap(composite); + } +} diff --git a/cockatrice/src/interface/widgets/general/home_widget.h b/cockatrice/src/interface/widgets/general/home_widget.h index 90d003aa7..1cadc4a67 100644 --- a/cockatrice/src/interface/widgets/general/home_widget.h +++ b/cockatrice/src/interface/widgets/general/home_widget.h @@ -15,6 +15,9 @@ #include #include +class QGridLayout; +class QLabel; + class HomeWidget : public QWidget { @@ -23,7 +26,7 @@ class HomeWidget : public QWidget public: HomeWidget(QWidget *parent, TabSupervisor *tabSupervisor); void updateRandomCard(); - QPair extractDominantColors(const QPixmap &pixmap); + static QPair extractDominantColors(const QPixmap &pixmap); public slots: void paintEvent(QPaintEvent *event) override; @@ -41,12 +44,14 @@ private: QPixmap background; CardInfoPictureArtCropWidget *backgroundSourceCard = nullptr; DeckList backgroundSourceDeck; - QPixmap overlay; + QLabel *logoLabel = nullptr; QPair gradientColors; HomeStyledButton *connectButton; void setRandomCard(ExactCard &newCard); void loadBackgroundSourceDeck(); + QPair determineButtonColor() const; + void updateLogoOverlay(); }; #endif // HOME_WIDGET_H diff --git a/cockatrice/src/interface/widgets/menus/deck_editor_menu.cpp b/cockatrice/src/interface/widgets/menus/deck_editor_menu.cpp index d6df694df..f057680be 100644 --- a/cockatrice/src/interface/widgets/menus/deck_editor_menu.cpp +++ b/cockatrice/src/interface/widgets/menus/deck_editor_menu.cpp @@ -4,6 +4,7 @@ #include "../../../client/settings/shortcuts_settings.h" #include "../tabs/abstract_tab_deck_editor.h" +#include DeckEditorMenu::DeckEditorMenu(AbstractTabDeckEditor *parent) : QMenu(parent), deckEditor(parent) { aNewDeck = new QAction(QString(), this); diff --git a/cockatrice/src/interface/widgets/menus/tearoff_menu.h b/cockatrice/src/interface/widgets/menus/tearoff_menu.h index ef1dfbcb3..9a9c4ff01 100644 --- a/cockatrice/src/interface/widgets/menus/tearoff_menu.h +++ b/cockatrice/src/interface/widgets/menus/tearoff_menu.h @@ -9,22 +9,23 @@ #include "../../../client/settings/cache_settings.h" #include +#include class TearOffMenu : public QMenu { public: explicit TearOffMenu(const QString &title, QWidget *parent = nullptr) : QMenu(title, parent) { - connect(&SettingsCache::instance(), &SettingsCache::useTearOffMenusChanged, this, + connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::useTearOffMenusChanged, this, [this](const bool state) { setTearOffEnabled(state); }); - setTearOffEnabled(SettingsCache::instance().getUseTearOffMenus()); + setTearOffEnabled(SettingsCache::instance().userInterface().getUseTearOffMenus()); } explicit TearOffMenu(QWidget *parent = nullptr) : QMenu(parent) { - connect(&SettingsCache::instance(), &SettingsCache::useTearOffMenusChanged, this, + connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::useTearOffMenusChanged, this, [this](const bool state) { setTearOffEnabled(state); }); - setTearOffEnabled(SettingsCache::instance().getUseTearOffMenus()); + setTearOffEnabled(SettingsCache::instance().userInterface().getUseTearOffMenus()); } TearOffMenu *addTearOffMenu(const QString &title) diff --git a/cockatrice/src/interface/widgets/onboarding/banner_shader_config.h b/cockatrice/src/interface/widgets/onboarding/banner_shader_config.h new file mode 100644 index 000000000..6008044ff --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/banner_shader_config.h @@ -0,0 +1,313 @@ +#ifndef BANNER_SHADER_CONFIG_H +#define BANNER_SHADER_CONFIG_H + +#include +#include + +/** + * Uniform values fed to brand_banner.frag, exposed to QML as the + * "bannerConfig" context property. + * + * Two independent "banks" (A/B) each carry their own mode/speed/seed so + * BrandBanner.qml can render both simultaneously and crossfade between + * them via opacity -- see frontIsA. The shared palette (colorA/colorB/ + * accent) and clock (time/aspect) apply to both banks identically, since + * only the foreground motif changes between onboarding pages, never the + * brand palette. + * + * Deliberately plain `property` (not `required property`) on the QML side + * -- a required-property shadowing bug bit the home-screen particle + * background before, and there's no reason to reintroduce that risk here. + */ +class BannerShaderConfig : public QObject +{ + Q_OBJECT + Q_PROPERTY(qreal time READ time WRITE setTime NOTIFY timeChanged) + Q_PROPERTY(qreal aspect READ aspect WRITE setAspect NOTIFY aspectChanged) + + Q_PROPERTY(qreal modeA READ modeA WRITE setModeA NOTIFY modeAChanged) + Q_PROPERTY(qreal speedA READ speedA WRITE setSpeedA NOTIFY speedAChanged) + Q_PROPERTY(qreal seedA READ seedA WRITE setSeedA NOTIFY seedAChanged) + + Q_PROPERTY(qreal modeB READ modeB WRITE setModeB NOTIFY modeBChanged) + Q_PROPERTY(qreal speedB READ speedB WRITE setSpeedB NOTIFY speedBChanged) + Q_PROPERTY(qreal seedB READ seedB WRITE setSeedB NOTIFY seedBChanged) + + Q_PROPERTY(bool frontIsA READ frontIsA WRITE setFrontIsA NOTIFY frontIsAChanged) + + Q_PROPERTY(QColor colorA READ colorA WRITE setColorA NOTIFY colorAChanged) + Q_PROPERTY(QColor colorB READ colorB WRITE setColorB NOTIFY colorBChanged) + Q_PROPERTY(QColor accent READ accent WRITE setAccent NOTIFY accentChanged) + Q_PROPERTY(QColor glowColor READ glowColor WRITE setGlowColor NOTIFY glowColorChanged) + Q_PROPERTY(QColor brandStrong READ brandStrong WRITE setBrandStrong NOTIFY brandStrongChanged) + Q_PROPERTY(QColor brandSoft READ brandSoft WRITE setBrandSoft NOTIFY brandSoftChanged) + Q_PROPERTY(qreal vignetteMin READ vignetteMin WRITE setVignetteMin NOTIFY vignetteMinChanged) + + Q_PROPERTY(bool logoVisible READ logoVisible WRITE setLogoVisible NOTIFY logoVisibleChanged) + Q_PROPERTY(qreal logoGlow READ logoGlow WRITE setLogoGlow NOTIFY logoGlowChanged) + +public: + explicit BannerShaderConfig(QObject *parent = nullptr) : QObject(parent) + { + } + + qreal time() const + { + return m_time; + } + void setTime(qreal v) + { + if (v != m_time) { + m_time = v; + emit timeChanged(); + } + } + + qreal aspect() const + { + return m_aspect; + } + void setAspect(qreal v) + { + if (v != m_aspect) { + m_aspect = v; + emit aspectChanged(); + } + } + + qreal modeA() const + { + return m_modeA; + } + void setModeA(qreal v) + { + if (v != m_modeA) { + m_modeA = v; + emit modeAChanged(); + } + } + qreal speedA() const + { + return m_speedA; + } + void setSpeedA(qreal v) + { + if (v != m_speedA) { + m_speedA = v; + emit speedAChanged(); + } + } + qreal seedA() const + { + return m_seedA; + } + void setSeedA(qreal v) + { + if (v != m_seedA) { + m_seedA = v; + emit seedAChanged(); + } + } + + qreal modeB() const + { + return m_modeB; + } + void setModeB(qreal v) + { + if (v != m_modeB) { + m_modeB = v; + emit modeBChanged(); + } + } + qreal speedB() const + { + return m_speedB; + } + void setSpeedB(qreal v) + { + if (v != m_speedB) { + m_speedB = v; + emit speedBChanged(); + } + } + qreal seedB() const + { + return m_seedB; + } + void setSeedB(qreal v) + { + if (v != m_seedB) { + m_seedB = v; + emit seedBChanged(); + } + } + + bool frontIsA() const + { + return m_frontIsA; + } + void setFrontIsA(bool v) + { + if (v != m_frontIsA) { + m_frontIsA = v; + emit frontIsAChanged(); + } + } + + QColor colorA() const + { + return m_colorA; + } + void setColorA(const QColor &c) + { + if (c != m_colorA) { + m_colorA = c; + emit colorAChanged(); + } + } + QColor colorB() const + { + return m_colorB; + } + void setColorB(const QColor &c) + { + if (c != m_colorB) { + m_colorB = c; + emit colorBChanged(); + } + } + QColor accent() const + { + return m_accent; + } + void setAccent(const QColor &c) + { + if (c != m_accent) { + m_accent = c; + emit accentChanged(); + } + } + + QColor glowColor() const + { + return m_glowColor; + } + void setGlowColor(const QColor &c) + { + if (c != m_glowColor) { + m_glowColor = c; + emit glowColorChanged(); + } + } + + QColor brandStrong() const + { + return m_brandStrong; + } + void setBrandStrong(const QColor &c) + { + if (c != m_brandStrong) { + m_brandStrong = c; + emit brandStrongChanged(); + } + } + + QColor brandSoft() const + { + return m_brandSoft; + } + void setBrandSoft(const QColor &c) + { + if (c != m_brandSoft) { + m_brandSoft = c; + emit brandSoftChanged(); + } + } + + qreal vignetteMin() const + { + return m_vignetteMin; + } + void setVignetteMin(qreal v) + { + if (v != m_vignetteMin) { + m_vignetteMin = v; + emit vignetteMinChanged(); + } + } + + bool logoVisible() const + { + return m_logoVisible; + } + void setLogoVisible(bool v) + { + if (v != m_logoVisible) { + m_logoVisible = v; + emit logoVisibleChanged(); + } + } + + qreal logoGlow() const + { + return m_logoGlow; + } + void setLogoGlow(qreal v) + { + if (v != m_logoGlow) { + m_logoGlow = v; + emit logoGlowChanged(); + } + } + +signals: + void timeChanged(); + void aspectChanged(); + void modeAChanged(); + void speedAChanged(); + void seedAChanged(); + void modeBChanged(); + void speedBChanged(); + void seedBChanged(); + void frontIsAChanged(); + void colorAChanged(); + void colorBChanged(); + void accentChanged(); + void glowColorChanged(); + void brandStrongChanged(); + void brandSoftChanged(); + void vignetteMinChanged(); + void logoVisibleChanged(); + void logoGlowChanged(); + +private: + qreal m_time = 0.0; + qreal m_aspect = 16.0 / 9.0; + + qreal m_modeA = 0.0; + qreal m_speedA = 1.0; + qreal m_seedA = 0.0; + + qreal m_modeB = 0.0; + qreal m_speedB = 1.0; + qreal m_seedB = 0.0; + + bool m_frontIsA = true; + + // Curated fallback seed values -- BannerHost overwrites these with + // palette-derived colours (see shader_banner_widget.cpp) before the first + // paint, so they only matter as a safe pre-first-apply default. + QColor m_colorA{0x1A, 0x1A, 0x20}; + QColor m_colorB{0x0E, 0x0E, 0x12}; + QColor m_accent{0x8B, 0xDD, 0x6B}; + QColor m_glowColor{Qt::white}; + QColor m_brandStrong{0x13, 0x97, 0x40}; + QColor m_brandSoft{0xC9, 0xFD, 0x62}; + qreal m_vignetteMin = 0.62; + + bool m_logoVisible = false; + qreal m_logoGlow = 1.0; +}; + +#endif // BANNER_SHADER_CONFIG_H diff --git a/cockatrice/src/interface/widgets/onboarding/brand_colors.h b/cockatrice/src/interface/widgets/onboarding/brand_colors.h new file mode 100644 index 000000000..bf173270b --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/brand_colors.h @@ -0,0 +1,15 @@ +#ifndef BRAND_COLORS_H +#define BRAND_COLORS_H + +#include + +/** @brief Cockatrice brand green. + * + * Single source of truth for the onboarding brand accent: it backs the + * banner's shader-accent uniform as the curated fallback when the active + * palette resolves no usable Highlight, and it preseads the wizard's + * QuickSetupPanel so a freshly generated palette keeps the brand identity + * until the user picks their own look. */ +inline const QColor kCockatriceBrandGreen(0x8B, 0xDD, 0x6B); + +#endif // BRAND_COLORS_H \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp b/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp new file mode 100644 index 000000000..4af02fe4f --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp @@ -0,0 +1,225 @@ +#include "first_run_wizard.h" + +#include "first_run_wizard_page.h" +#include "pages/account_setup_page.h" +#include "pages/card_database_setup_page.h" +#include "pages/finish_page.h" +#include "pages/preferences_setup_page.h" +#include "pages/theme_setup_page.h" +#include "pages/welcome_page.h" +#include "shader_banner_widget.h" +#include "step_indicator_widget.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +FirstRunWizard::FirstRunWizard(QWidget *parent) : QDialog(parent) +{ + setWindowFlag(Qt::WindowContextHelpButtonHint, false); + setMinimumSize(640, 490); + resize(720, 550); + + bannerHost = new BannerHost(this); + + titleLabel = new QLabel(this); + QFont titleFont = titleLabel->font(); + titleFont.setPointSizeF(titleFont.pointSizeF() * 1.4); + titleFont.setBold(true); + titleLabel->setFont(titleFont); + + subtitleLabel = new QLabel(this); + subtitleLabel->setWordWrap(true); + + stack = new QStackedWidget(this); + stepIndicator = new StepIndicatorWidget(this); + + backButton = new QPushButton(this); + skipButton = new QPushButton(this); + nextButton = new QPushButton(this); + nextButton->setDefault(true); + + connect(backButton, &QPushButton::clicked, this, &FirstRunWizard::goBack); + connect(skipButton, &QPushButton::clicked, this, &FirstRunWizard::skip); + connect(nextButton, &QPushButton::clicked, this, &FirstRunWizard::goNext); + + auto *headerLayout = new QVBoxLayout; + headerLayout->setContentsMargins(0, 0, 0, 0); + headerLayout->addWidget(bannerHost); + headerLayout->addSpacing(12); + headerLayout->addWidget(titleLabel); + headerLayout->addWidget(subtitleLabel); + + auto *navLayout = new QHBoxLayout; + navLayout->addWidget(backButton); + navLayout->addWidget(skipButton); + navLayout->addStretch(); + navLayout->addWidget(stepIndicator); + navLayout->addStretch(); + navLayout->addWidget(nextButton); + + auto *root = new QVBoxLayout(this); + root->addLayout(headerLayout); + root->addSpacing(8); + root->addWidget(stack, 1); + root->addSpacing(8); + root->addLayout(navLayout); + + auto *welcome = new WelcomePage(this); + auto *cardDb = new CardDatabaseSetupPage(this); + auto *theme = new ThemeSetupPage(this); + auto *account = new AccountSetupPage(this); + auto *prefs = new PreferencesSetupPage(this); + auto *finishPg = new FinishPage(this); + + cardDatabasePage = cardDb; + + connect(cardDb, &CardDatabaseSetupPage::updateRequested, this, &FirstRunWizard::cardDatabaseUpdateRequested); + connect(cardDb, &CardDatabaseSetupPage::manualSetupRequested, this, + &FirstRunWizard::manualCardDatabaseSetupRequested); + connect(account, &AccountSetupPage::registerRequested, this, &FirstRunWizard::registerRequested); + connect(account, &AccountSetupPage::connectRequested, this, &FirstRunWizard::connectRequested); + + connect(cardDb, &CardDatabaseSetupPage::advanceRequested, this, [this] { + if (stack->currentWidget() == cardDatabasePage) { + showPage(currentIndex + 1); + } + }); + + addPage(welcome); + addPage(cardDb); + addPage(theme); + addPage(account); + addPage(prefs); + addPage(finishPg); + + stepIndicator->setStepCount(pages.count()); + retranslateUi(); + showPage(0); +} + +void FirstRunWizard::addPage(FirstRunWizardPage *page) +{ + pages.append(page); + stack->addWidget(page); + connect(page, &FirstRunWizardPage::completeChanged, this, &FirstRunWizard::updateChrome); +} + +void FirstRunWizard::showPage(int index) +{ + if (index < 0 || index >= pages.count()) { + return; + } + currentIndex = index; + stack->setCurrentIndex(index); + pages[index]->initializePage(); + stepIndicator->setCurrentStep(index); + static const QList motifs = { + BannerHost::Motif::Welcome, BannerHost::Motif::CardDatabase, BannerHost::Motif::Theming, + BannerHost::Motif::Account, BannerHost::Motif::Preferences, BannerHost::Motif::Finish, + }; + if (index < motifs.size()) { + bannerHost->setMotif(motifs[index]); + } + titleLabel->setText(pages[index]->stepTitle()); + subtitleLabel->setText(pages[index]->stepSubtitle()); + subtitleLabel->setVisible(!pages[index]->stepSubtitle().isEmpty()); + updateChrome(); +} + +void FirstRunWizard::updateChrome() +{ + if (currentIndex < 0) { + return; + } + FirstRunWizardPage *page = pages[currentIndex]; + const bool isLast = (currentIndex == pages.count() - 1); + + backButton->setVisible(currentIndex > 0); + skipButton->setVisible(page->isSkippable()); + nextButton->setEnabled(page->isComplete()); + + QString customText = page->nextButtonText(); + if (!customText.isEmpty()) { + nextButton->setText(customText); + } else { + nextButton->setText(isLast ? tr("Finish") : tr("Next")); + } +} + +void FirstRunWizard::goNext() +{ + FirstRunWizardPage *page = pages[currentIndex]; + if (!page->validatePage() || !page->handleNextClick()) { + return; + } + if (currentIndex == pages.count() - 1) { + finish(); + return; + } + showPage(currentIndex + 1); +} + +void FirstRunWizard::goBack() +{ + showPage(currentIndex - 1); +} + +void FirstRunWizard::skip() +{ + showPage(currentIndex + 1); +} + +void FirstRunWizard::onCardDatabaseUpdateFinished(bool success) +{ + if (cardDatabasePage) { + cardDatabasePage->onUpdateFinished(success); + } +} + +void FirstRunWizard::onCardDatabaseUpdateProgress(const QString &stage, qint64 done, qint64 total) +{ + if (cardDatabasePage) { + cardDatabasePage->onUpdateProgress(stage, done, total); + } +} + +void FirstRunWizard::finish() +{ + accept(); +} + +void FirstRunWizard::closeEvent(QCloseEvent *event) +{ + // Every step persists its own choice as it's made, so closing early + // isn't destructive -- treat it exactly like reaching the end. + QDialog::closeEvent(event); +} + +void FirstRunWizard::changeEvent(QEvent *event) +{ + if (event->type() == QEvent::LanguageChange) { + retranslateUi(); + } + QDialog::changeEvent(event); +} + +void FirstRunWizard::retranslateUi() +{ + setWindowTitle(tr("Welcome to Cockatrice")); + backButton->setText(tr("Back")); + skipButton->setText(tr("Skip")); + for (FirstRunWizardPage *page : std::as_const(pages)) { + page->retranslateUi(); + } + if (currentIndex >= 0) { + titleLabel->setText(pages[currentIndex]->stepTitle()); + subtitleLabel->setText(pages[currentIndex]->stepSubtitle()); + } + updateChrome(); +} diff --git a/cockatrice/src/interface/widgets/onboarding/first_run_wizard.h b/cockatrice/src/interface/widgets/onboarding/first_run_wizard.h new file mode 100644 index 000000000..21d7b6e06 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/first_run_wizard.h @@ -0,0 +1,74 @@ +#ifndef FIRST_RUN_WIZARD_H +#define FIRST_RUN_WIZARD_H + +#include +#include + +class BannerHost; +class FirstRunWizardPage; +class StepIndicatorWidget; +class CardDatabaseSetupPage; +class QLabel; +class QPushButton; +class QStackedWidget; + +/** @brief Polished first-run onboarding flow: card database setup, theme + * selection, server account setup, and a handful of key preferences. + * + * Deliberately ignorant of network/registration/download internals -- + * pages that need them emit request signals for MainWindow to fulfill. + * Every choice is written to SettingsCache as it's made (via the pages + * themselves, same as AppearanceSettingsPage does), so "Skip" or closing + * the window never discards anything already confirmed. */ +class FirstRunWizard : public QDialog +{ + Q_OBJECT + +public: + explicit FirstRunWizard(QWidget *parent = nullptr); + +signals: + void registerRequested(); + void connectRequested(); + void cardDatabaseUpdateRequested(); + void manualCardDatabaseSetupRequested(); + +public slots: + /** @brief Forwarded from MainWindow once the background card database update process exits. */ + void onCardDatabaseUpdateFinished(bool success); + + /** @brief Forwarded from MainWindow while the background card database update process runs. */ + void onCardDatabaseUpdateProgress(const QString &stage, qint64 done, qint64 total); + +protected: + void closeEvent(QCloseEvent *event) override; + void changeEvent(QEvent *event) override; + +private slots: + void goNext(); + void goBack(); + void skip(); + void updateChrome(); + +private: + void addPage(FirstRunWizardPage *page); + void showPage(int index); + void retranslateUi(); + void finish(); + + QStackedWidget *stack; + StepIndicatorWidget *stepIndicator; + BannerHost *bannerHost; + QLabel *titleLabel; + QLabel *subtitleLabel; + QPushButton *backButton; + QPushButton *skipButton; + QPushButton *nextButton; + + CardDatabaseSetupPage *cardDatabasePage = nullptr; + + QList pages; + int currentIndex = -1; +}; + +#endif // FIRST_RUN_WIZARD_H diff --git a/cockatrice/src/interface/widgets/onboarding/first_run_wizard_page.cpp b/cockatrice/src/interface/widgets/onboarding/first_run_wizard_page.cpp new file mode 100644 index 000000000..6da8958f2 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/first_run_wizard_page.cpp @@ -0,0 +1 @@ +#include "first_run_wizard_page.h" diff --git a/cockatrice/src/interface/widgets/onboarding/first_run_wizard_page.h b/cockatrice/src/interface/widgets/onboarding/first_run_wizard_page.h new file mode 100644 index 000000000..bdcd123bd --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/first_run_wizard_page.h @@ -0,0 +1,75 @@ +#ifndef FIRST_RUN_WIZARD_PAGE_H +#define FIRST_RUN_WIZARD_PAGE_H + +#include + +/** @brief Base class for a single step of FirstRunWizard. + * + * QWidget-based rather than QWizardPage-based: FirstRunWizard is a + * QDialog + QStackedWidget shell (not a QWizard) so it can own the + * banner/step-dot chrome that QWizard's native styles don't give us + * consistent control over. Naming mirrors OracleWizardPage for + * familiarity only -- the two hierarchies are unrelated. */ +class FirstRunWizardPage : public QWidget +{ + Q_OBJECT + +public: + explicit FirstRunWizardPage(QWidget *parent = nullptr) : QWidget(parent) + { + } + + /** @brief Called every time the page becomes visible, including navigating back to it. */ + virtual void initializePage() + { + } + + /** @brief Called before advancing past this page. Return false to block navigation; + the page itself is responsible for telling the user why. */ + virtual bool validatePage() + { + return true; + } + + /** @brief Whether Next/Finish should currently be enabled. Pages doing async work + can flip this mid-step; emit completeChanged() when they do. */ + virtual bool isComplete() const + { + return true; + } + + /** @brief Whether the wizard's "Skip" button should be offered on this page. */ + virtual bool isSkippable() const + { + return false; + } + + virtual QString stepTitle() const = 0; + virtual QString stepSubtitle() const + { + return {}; + } + + /** @brief Override to replace the "Next"/"Finish" button text on this page. + Return an empty string to use the default label. */ + virtual QString nextButtonText() const + { + return {}; + } + + /** @brief Called when the user presses the Next button. Return true to allow + advancing to the next page, false to stay on this page (e.g. to + trigger an async action first). */ + virtual bool handleNextClick() + { + return true; + } + + virtual void retranslateUi() = 0; + +signals: + void completeChanged(); + void advanceRequested(); +}; + +#endif // FIRST_RUN_WIZARD_PAGE_H diff --git a/cockatrice/src/interface/widgets/onboarding/pages/account_setup_page.cpp b/cockatrice/src/interface/widgets/onboarding/pages/account_setup_page.cpp new file mode 100644 index 000000000..2107ea8bf --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/account_setup_page.cpp @@ -0,0 +1,56 @@ +#include "account_setup_page.h" + +#include +#include +#include + +AccountSetupPage::AccountSetupPage(QWidget *parent) : FirstRunWizardPage(parent) +{ + bodyLabel = new QLabel(this); + bodyLabel->setWordWrap(true); + bodyLabel->setAlignment(Qt::AlignCenter); + + registerButton = new QPushButton(this); + connectButton = new QPushButton(this); + skipHintLabel = new QLabel(this); + skipHintLabel->setWordWrap(true); + skipHintLabel->setAlignment(Qt::AlignCenter); + + connect(registerButton, &QPushButton::clicked, this, &AccountSetupPage::registerRequested); + connect(connectButton, &QPushButton::clicked, this, &AccountSetupPage::connectRequested); + + auto *layout = new QVBoxLayout(this); + layout->addStretch(); + layout->addWidget(bodyLabel); + layout->addSpacing(16); + layout->addWidget(registerButton, 0, Qt::AlignHCenter); + layout->addWidget(connectButton, 0, Qt::AlignHCenter); + layout->addSpacing(16); + layout->addWidget(skipHintLabel); + layout->addStretch(); + + retranslateUi(); +} + +bool AccountSetupPage::isSkippable() const +{ + return true; +} + +QString AccountSetupPage::stepTitle() const +{ + return tr("Join a Server"); +} + +QString AccountSetupPage::stepSubtitle() const +{ + return tr("Optional — you can always do this later from the menu."); +} + +void AccountSetupPage::retranslateUi() +{ + bodyLabel->setText(tr("Playing online needs a server account.")); + registerButton->setText(tr("Register a new account…")); + connectButton->setText(tr("I already have one — Connect…")); + skipHintLabel->setText(tr("Just want to play locally? Skip this and connect whenever you're ready.")); +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/onboarding/pages/account_setup_page.h b/cockatrice/src/interface/widgets/onboarding/pages/account_setup_page.h new file mode 100644 index 000000000..0d9b76699 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/account_setup_page.h @@ -0,0 +1,38 @@ +#ifndef ACCOUNT_SETUP_PAGE_H +#define ACCOUNT_SETUP_PAGE_H + +#include "../first_run_wizard_page.h" + +class QLabel; +class QPushButton; + +/** @brief First-run account step. Does NOT embed DlgRegister's fields: they exist + * to be handed to ConnectionController's network registration flow, which + * this wizard has no visibility into. Reimplementing the fields here + * without that wiring would look functional and silently do nothing -- + * worse than reuse. So: a friendly landing spot that opens the *existing* + * DlgRegister / connect flow via signals FirstRunWizard forwards. */ +class AccountSetupPage : public FirstRunWizardPage +{ + Q_OBJECT + +public: + explicit AccountSetupPage(QWidget *parent = nullptr); + + bool isSkippable() const override; + QString stepTitle() const override; + QString stepSubtitle() const override; + void retranslateUi() override; + +signals: + void registerRequested(); + void connectRequested(); + +private: + QLabel *bodyLabel; + QPushButton *registerButton; + QPushButton *connectButton; + QLabel *skipHintLabel; +}; + +#endif // ACCOUNT_SETUP_PAGE_H diff --git a/cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.cpp b/cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.cpp new file mode 100644 index 000000000..50e8ff63d --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.cpp @@ -0,0 +1,334 @@ +#include "card_database_setup_page.h" + +#include "../../client/settings/cache_settings.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +CardDatabaseSetupPage::CardDatabaseSetupPage(QWidget *parent) : FirstRunWizardPage(parent) +{ + statusLabel = new QLabel(this); + statusLabel->setWordWrap(true); + statusLabel->setAlignment(Qt::AlignCenter); + + progressBar = new QProgressBar(this); + progressBar->setRange(0, 0); + progressBar->setTextVisible(false); + progressBar->setFixedWidth(280); + + retryButton = new QPushButton(this); + manualButton = new QPushButton(this); + + connect(retryButton, &QPushButton::clicked, this, [this] { + setState(State::Running); + emit updateRequested(); + }); + connect(manualButton, &QPushButton::clicked, this, &CardDatabaseSetupPage::manualSetupRequested); + + // ── Advanced: custom download source ─────────────────────────────── + advancedToggleButton = new QPushButton(this); + advancedToggleButton->setCheckable(true); + advancedToggleButton->setChecked(false); + advancedToggleButton->setFlat(true); + advancedToggleButton->setStyleSheet("QPushButton { text-align: left; padding: 5px 12px; font-weight: bold; }" + "QPushButton:checked { }"); + + advancedPanel = new QWidget(this); + advancedPanel->setVisible(false); + + urlLineEdit = new QLineEdit(advancedPanel); + urlHintLabel = new QLabel(advancedPanel); + urlHintLabel->setWordWrap(true); + + restoreDefaultUrlButton = new QPushButton(advancedPanel); + applyAndRetryButton = new QPushButton(advancedPanel); + + connect(advancedToggleButton, &QPushButton::toggled, this, &CardDatabaseSetupPage::onToggleAdvanced); + connect(restoreDefaultUrlButton, &QPushButton::clicked, this, &CardDatabaseSetupPage::onRestoreDefaultUrl); + connect(applyAndRetryButton, &QPushButton::clicked, this, &CardDatabaseSetupPage::onApplyCustomUrl); + + auto *advancedButtonRow = new QHBoxLayout; + advancedButtonRow->addWidget(restoreDefaultUrlButton); + advancedButtonRow->addStretch(); + advancedButtonRow->addWidget(applyAndRetryButton); + + auto *advancedLayout = new QVBoxLayout(advancedPanel); + advancedLayout->setContentsMargins(12, 4, 12, 4); + advancedLayout->addWidget(urlLineEdit); + advancedLayout->addWidget(urlHintLabel); + advancedLayout->addLayout(advancedButtonRow); + + // ── Startup card update check ─────────────────────────────────────── + auto &upd = SettingsCache::instance().updates(); + + const auto updateBehavior = [this] { + auto &u = SettingsCache::instance().updates(); + int idx = startupBehaviorCombo->currentIndex(); + u.setStartupCardUpdateCheckPromptForUpdate(idx == 1); + u.setStartupCardUpdateCheckAlwaysUpdate(idx == 2); + }; + + startupBehaviorLabel = new QLabel(this); + startupBehaviorCombo = new QComboBox(this); + startupBehaviorCombo->addItem(QString()); // placeholder, filled in retranslateUi + startupBehaviorCombo->addItem(QString()); + startupBehaviorCombo->addItem(QString()); + if (upd.getStartupCardUpdateCheckPromptForUpdate()) { + startupBehaviorCombo->setCurrentIndex(1); + } else if (upd.getStartupCardUpdateCheckAlwaysUpdate()) { + startupBehaviorCombo->setCurrentIndex(2); + } else { + startupBehaviorCombo->setCurrentIndex(0); + } + connect(startupBehaviorCombo, QOverload::of(&QComboBox::currentIndexChanged), this, updateBehavior); + + checkIntervalLabel = new QLabel(this); + checkIntervalSpinBox = new QSpinBox(this); + checkIntervalSpinBox->setMinimum(1); + checkIntervalSpinBox->setMaximum(30); + checkIntervalSpinBox->setValue(upd.getCardUpdateCheckInterval()); + connect(checkIntervalSpinBox, QOverload::of(&QSpinBox::valueChanged), &upd, + &UpdatesSettings::setCardUpdateCheckInterval); + + auto *checkGrid = new QGridLayout; + checkGrid->addWidget(startupBehaviorLabel, 0, 0); + checkGrid->addWidget(startupBehaviorCombo, 0, 1); + checkGrid->addWidget(checkIntervalLabel, 1, 0); + checkGrid->addWidget(checkIntervalSpinBox, 1, 1); + + auto *layout = new QVBoxLayout(this); + layout->addStretch(); + layout->addWidget(statusLabel); + layout->addSpacing(12); + layout->addWidget(progressBar, 0, Qt::AlignHCenter); + layout->addSpacing(12); + layout->addWidget(retryButton, 0, Qt::AlignHCenter); + layout->addWidget(manualButton, 0, Qt::AlignHCenter); + layout->addSpacing(16); + layout->addWidget(advancedToggleButton); + layout->addWidget(advancedPanel); + layout->addSpacing(8); + layout->addLayout(checkGrid); + layout->addStretch(); + + retranslateUi(); +} + +bool CardDatabaseSetupPage::alreadyHaveDatabase() const +{ + return CardDatabaseManager::getInstance()->getCardList().count() > 0; +} + +QString CardDatabaseSetupPage::oracleSettingsFilePath() const +{ + return SettingsCache::instance().getSettingsPath() + "oracle.ini"; +} + +QString CardDatabaseSetupPage::readCustomUrl() const +{ + QSettings oracleSettings(oracleSettingsFilePath(), QSettings::IniFormat); + return oracleSettings.value("allsetsurl").toString(); +} + +void CardDatabaseSetupPage::writeCustomUrl(const QString &url) +{ + QSettings oracleSettings(oracleSettingsFilePath(), QSettings::IniFormat); + if (url.isEmpty()) { + oracleSettings.remove("allsetsurl"); + } else { + oracleSettings.setValue("allsetsurl", url); + } +} + +void CardDatabaseSetupPage::initializePage() +{ + urlLineEdit->setText(readCustomUrl()); + + if (state != State::NotStarted) { + return; + } + + if (alreadyHaveDatabase()) { + setState(State::Succeeded); + return; + } + + // Don't auto-download — wait for the user to press "Download". + setState(State::NotStarted); + statusLabel->setText(tr("Press Download to fetch the card database, or Skip to do it later.")); +} + +void CardDatabaseSetupPage::onUpdateFinished(bool success) +{ + setState(success ? State::Succeeded : State::Failed); + if (success) { + emit advanceRequested(); + } +} + +void CardDatabaseSetupPage::onUpdateProgress(const QString &stage, qint64 done, qint64 total) +{ + if (state != State::Running) { + return; + } + progressBar->setRange(0, total > 0 ? static_cast(qMin(total, INT_MAX)) : 0); + progressBar->setValue(static_cast(qMin(done, INT_MAX))); + if (total > 0) { + const int percent = static_cast((100.0 * done) / total); + if (stage == QLatin1String("download")) { + statusLabel->setText(tr("Downloading the card database (%1%)…").arg(percent)); + } else if (stage == QLatin1String("scan")) { + statusLabel->setText(tr("Parsing the card database (%1%)…").arg(percent)); + } else if (stage == QLatin1String("import")) { + statusLabel->setText(tr("Importing cards (%1%)…").arg(percent)); + } + } +} + +QString CardDatabaseSetupPage::nextButtonText() const +{ + return state == State::NotStarted ? tr("Download") : QString(); +} + +bool CardDatabaseSetupPage::handleNextClick() +{ + if (state == State::NotStarted) { + setState(State::Running); + emit updateRequested(); + return false; + } + return true; +} + +void CardDatabaseSetupPage::onToggleAdvanced(bool open) +{ + advancedToggleButton->setText(open ? tr("▼ Advanced: custom download source") + : tr("▶ Advanced: custom download source")); + advancedPanel->setVisible(open); + + QWidget *wizardWindow = window(); + if (!wizardWindow) { + return; + } + + if (open) { + windowSizeBeforeExpansion = wizardWindow->size(); + QTimer::singleShot(0, this, [wizardWindow] { + wizardWindow->resize(wizardWindow->size().expandedTo(wizardWindow->sizeHint())); + }); + } else { + QTimer::singleShot(0, this, [this, wizardWindow] { + wizardWindow->resize(wizardWindow->size().boundedTo(windowSizeBeforeExpansion)); + }); + } +} + +void CardDatabaseSetupPage::onApplyCustomUrl() +{ + const QString text = urlLineEdit->text().trimmed(); + + if (!text.isEmpty()) { + const QUrl url = QUrl::fromUserInput(text); + if (!url.isValid()) { + QMessageBox::warning(this, tr("Invalid URL"), + tr("That doesn't look like a valid URL. Double-check it and try again, " + "or clear the field to use the default source.")); + return; + } + } + + writeCustomUrl(text); + setState(State::Running); + emit updateRequested(); +} + +void CardDatabaseSetupPage::onRestoreDefaultUrl() +{ + urlLineEdit->clear(); + writeCustomUrl(QString()); +} + +void CardDatabaseSetupPage::setState(State newState) +{ + state = newState; + + progressBar->setVisible(state == State::Running); + retryButton->setVisible(state == State::Failed); + manualButton->setVisible(state == State::Failed); + applyAndRetryButton->setEnabled(state != State::Running); + + switch (state) { + case State::NotStarted: + statusLabel->setText(tr("Press Download to fetch the card database, or Skip to do it later.")); + break; + case State::Running: + statusLabel->setText(tr("Downloading the latest card database…")); + break; + case State::Succeeded: + statusLabel->setText(tr("Card database ready ✓")); + break; + case State::Failed: + statusLabel->setText( + tr("Couldn't download the card database automatically. Check your connection and retry, " + "set it up manually, or skip this for now — you can do it later from the Card Database menu.")); + break; + } + + emit completeChanged(); +} + +bool CardDatabaseSetupPage::isComplete() const +{ + return state != State::Running; +} + +bool CardDatabaseSetupPage::isSkippable() const +{ + return state != State::Succeeded; +} + +QString CardDatabaseSetupPage::stepTitle() const +{ + return tr("Card Database"); +} + +QString CardDatabaseSetupPage::stepSubtitle() const +{ + return tr("Cockatrice needs card data to know what you're playing with."); +} + +void CardDatabaseSetupPage::retranslateUi() +{ + retryButton->setText(tr("Retry")); + manualButton->setText(tr("Set up manually…")); + + onToggleAdvanced(advancedToggleButton->isChecked()); + urlLineEdit->setPlaceholderText(tr("Leave blank to use the default source")); + urlHintLabel->setText(tr("Only change this if you know you need a mirror or a custom card data source.")); + restoreDefaultUrlButton->setText(tr("Restore default")); + applyAndRetryButton->setText(tr("Apply && retry")); + + startupBehaviorLabel->setText(tr("Check for card database updates on startup")); + startupBehaviorCombo->setItemText(0, tr("Don't check")); + startupBehaviorCombo->setItemText(1, tr("Prompt for update")); + startupBehaviorCombo->setItemText(2, tr("Always update in the background")); + + checkIntervalLabel->setText(tr("Check for card database updates every")); + checkIntervalSpinBox->setSuffix(tr(" days")); + + setState(state); +} diff --git a/cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.h b/cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.h new file mode 100644 index 000000000..870e759ea --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.h @@ -0,0 +1,80 @@ +#ifndef CARD_DATABASE_SETUP_PAGE_H +#define CARD_DATABASE_SETUP_PAGE_H + +#include "../first_run_wizard_page.h" + +#include + +class QComboBox; +class QLabel; +class QLineEdit; +class QProgressBar; +class QPushButton; +class QSpinBox; +class QWidget; + +class CardDatabaseSetupPage : public FirstRunWizardPage +{ + Q_OBJECT + +public: + explicit CardDatabaseSetupPage(QWidget *parent = nullptr); + + void initializePage() override; + bool isComplete() const override; + bool isSkippable() const override; + QString stepTitle() const override; + QString stepSubtitle() const override; + QString nextButtonText() const override; + bool handleNextClick() override; + void retranslateUi() override; + + void onUpdateFinished(bool success); + void onUpdateProgress(const QString &stage, qint64 done, qint64 total); + +signals: + void updateRequested(); + void manualSetupRequested(); + +private: + enum class State + { + NotStarted, + Running, + Succeeded, + Failed, + }; + + void setState(State newState); + bool alreadyHaveDatabase() const; + + QString oracleSettingsFilePath() const; + QString readCustomUrl() const; + void writeCustomUrl(const QString &url); + + void onToggleAdvanced(bool open); + void onApplyCustomUrl(); + void onRestoreDefaultUrl(); + + QLabel *statusLabel; + QProgressBar *progressBar; + QPushButton *retryButton; + QPushButton *manualButton; + + QPushButton *advancedToggleButton; + QWidget *advancedPanel; + QLineEdit *urlLineEdit; + QLabel *urlHintLabel; + QPushButton *restoreDefaultUrlButton; + QPushButton *applyAndRetryButton; + + QLabel *startupBehaviorLabel; + QComboBox *startupBehaviorCombo; + QLabel *checkIntervalLabel; + QSpinBox *checkIntervalSpinBox; + + State state = State::NotStarted; + QSize windowSizeBeforeExpansion; +}; + +#endif // CARD_DATABASE_SETUP_PAGE_H diff --git a/cockatrice/src/interface/widgets/onboarding/pages/finish_page.cpp b/cockatrice/src/interface/widgets/onboarding/pages/finish_page.cpp new file mode 100644 index 000000000..4205fa532 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/finish_page.cpp @@ -0,0 +1,30 @@ +#include "finish_page.h" + +#include +#include + +FinishPage::FinishPage(QWidget *parent) : FirstRunWizardPage(parent) +{ + bodyLabel = new QLabel(this); + bodyLabel->setWordWrap(true); + bodyLabel->setAlignment(Qt::AlignCenter); + + auto *layout = new QVBoxLayout(this); + layout->addStretch(); + layout->addWidget(bodyLabel); + layout->addStretch(); + + retranslateUi(); +} + +QString FinishPage::stepTitle() const +{ + return tr("You're All Set"); +} + +void FinishPage::retranslateUi() +{ + bodyLabel->setText( + tr("That's everything for now. Jump into Settings any time to change your mind about any of this.\n\n" + "Have fun!")); +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/onboarding/pages/finish_page.h b/cockatrice/src/interface/widgets/onboarding/pages/finish_page.h new file mode 100644 index 000000000..40ebc6ed0 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/finish_page.h @@ -0,0 +1,22 @@ +#ifndef FINISH_PAGE_H +#define FINISH_PAGE_H + +#include "../first_run_wizard_page.h" + +class QLabel; + +class FinishPage : public FirstRunWizardPage +{ + Q_OBJECT + +public: + explicit FinishPage(QWidget *parent = nullptr); + + QString stepTitle() const override; + void retranslateUi() override; + +private: + QLabel *bodyLabel; +}; + +#endif // FINISH_PAGE_H diff --git a/cockatrice/src/interface/widgets/onboarding/pages/preferences_setup_page.cpp b/cockatrice/src/interface/widgets/onboarding/pages/preferences_setup_page.cpp new file mode 100644 index 000000000..fceb34deb --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/preferences_setup_page.cpp @@ -0,0 +1,173 @@ +#include "preferences_setup_page.h" + +#include "../../client/settings/cache_settings.h" +#include "../../client/sound_engine.h" +#include "libcockatrice/settings/interface_settings.h" +#include "libcockatrice/settings/sound_settings.h" +#include "libcockatrice/settings/tabs_settings.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +// The server destinations are omitted: during first run their tabs are not +// open yet, and the wizard offers no way to fill in the server/room details. +QList wizardStartupTabOrder() +{ + return {StartupTabHome, StartupTabVisualDeckStorage, StartupTabDeckStorage, + StartupTabReplays, StartupTabDeckEditor, StartupTabVisualDeckEditor}; +} +} // namespace + +PreferencesSetupPage::PreferencesSetupPage(QWidget *parent) : FirstRunWizardPage(parent) +{ + auto *content = new QWidget; + auto *contentLayout = new QVBoxLayout(content); + + gameplayGroup = new QGroupBox(content); + auto *gameplayLayout = new QVBoxLayout(gameplayGroup); + contentLayout->addWidget(gameplayGroup); + doubleClickToPlayCheckBox = new QCheckBox(gameplayGroup); + horizontalHandCheckBox = new QCheckBox(gameplayGroup); + playToStackCheckBox = new QCheckBox(gameplayGroup); + gameplayLayout->addWidget(doubleClickToPlayCheckBox); + gameplayLayout->addWidget(horizontalHandCheckBox); + gameplayLayout->addWidget(playToStackCheckBox); + + notificationsGroup = new QGroupBox(content); + auto *notificationsLayout = new QVBoxLayout(notificationsGroup); + contentLayout->addWidget(notificationsGroup); + notificationsEnabledCheckBox = new QCheckBox(notificationsGroup); + soundEnabledCheckBox = new QCheckBox(notificationsGroup); + notificationsLayout->addWidget(notificationsEnabledCheckBox); + notificationsLayout->addWidget(soundEnabledCheckBox); + + startupGroup = new QGroupBox(content); + auto *startupForm = new QFormLayout(startupGroup); + contentLayout->addWidget(startupGroup); + startupTabLabel = new QLabel(startupGroup); + startupTabSelector = new QComboBox(startupGroup); + startupTabSelector->setSizeAdjustPolicy(QComboBox::AdjustToContents); + for (StartupTab tab : wizardStartupTabOrder()) { + startupTabSelector->addItem(QString(), tab); // texts set in retranslateUi + } + startupForm->addRow(startupTabLabel, startupTabSelector); + + contentLayout->addStretch(); + + auto *scrollArea = new QScrollArea(this); + scrollArea->setWidget(content); + scrollArea->setWidgetResizable(true); + scrollArea->setFrameShape(QFrame::NoFrame); + + auto *layout = new QVBoxLayout(this); + layout->addWidget(scrollArea); + + SettingsCache &settings = SettingsCache::instance(); + + connect(doubleClickToPlayCheckBox, &QCheckBox::toggled, &settings.userInterface(), + &InterfaceSettings::setDoubleClickToPlay); + connect(horizontalHandCheckBox, &QCheckBox::toggled, &settings.userInterface(), + &InterfaceSettings::setHorizontalHand); + connect(playToStackCheckBox, &QCheckBox::toggled, &settings.userInterface(), &InterfaceSettings::setPlayToStack); + + connect(notificationsEnabledCheckBox, &QCheckBox::toggled, &settings.userInterface(), + &InterfaceSettings::setNotificationsEnabled); + connect(soundEnabledCheckBox, &QCheckBox::toggled, &settings.sound(), &SoundSettings::setSoundEnabled); + connect(soundEnabledCheckBox, &QCheckBox::toggled, soundEngine, &SoundEngine::testSound); + + connect(startupTabSelector, QOverload::of(&QComboBox::currentIndexChanged), this, [this](int index) { + if (index < 0) { + return; + } + SettingsCache::instance().tabs().setStartupTabIndex(startupTabSelector->itemData(index).toInt()); + }); + + retranslateUi(); +} + +void PreferencesSetupPage::initializePage() +{ + SettingsCache &settings = SettingsCache::instance(); + + doubleClickToPlayCheckBox->setChecked(settings.userInterface().getDoubleClickToPlay()); + horizontalHandCheckBox->setChecked(settings.userInterface().getHorizontalHand()); + playToStackCheckBox->setChecked(settings.userInterface().getPlayToStack()); + + notificationsEnabledCheckBox->setChecked(settings.userInterface().getNotificationsEnabled()); + soundEnabledCheckBox->setChecked(settings.sound().getSoundEnabled()); + + startupTabSelector->setCurrentIndex(startupTabSelector->findData(settings.tabs().getStartupTabIndex())); +} + +bool PreferencesSetupPage::isSkippable() const +{ + return true; +} + +QString PreferencesSetupPage::stepTitle() const +{ + return tr("A Few Preferences"); +} + +QString PreferencesSetupPage::stepSubtitle() const +{ + return tr("Defaults are fine — tweak these now or from Settings anytime."); +} + +void PreferencesSetupPage::retranslateUi() +{ + gameplayGroup->setTitle(tr("Gameplay")); + doubleClickToPlayCheckBox->setText(tr("Double-click cards to play them")); + doubleClickToPlayCheckBox->setToolTip(tr("When disabled, a single click plays the selected card onto the table.")); + horizontalHandCheckBox->setText(tr("Display hand horizontally")); + horizontalHandCheckBox->setToolTip( + tr("Shows your hand as a row along the bottom of the table instead of a column beside it.")); + playToStackCheckBox->setText(tr("Play all nonlands onto the stack by default")); + playToStackCheckBox->setToolTip( + tr("Cards you play appear on the stack so other players can respond to them, as in a tabletop game.")); + + notificationsGroup->setTitle(tr("Notifications && Sound")); + notificationsEnabledCheckBox->setText(tr("Show desktop notifications")); + soundEnabledCheckBox->setText(tr("Play sound effects")); + + startupGroup->setTitle(tr("Startup")); + startupTabLabel->setText(tr("Startup tab:")); + const QList tabs = wizardStartupTabOrder(); + for (int i = 0; i < tabs.size(); ++i) { + QString name; + switch (tabs[i]) { + case StartupTabHome: + name = tr("Home"); + break; + case StartupTabVisualDeckStorage: + name = tr("Visual Deck Storage"); + break; + case StartupTabDeckStorage: + name = tr("Deck Storage"); + break; + case StartupTabReplays: + name = tr("Game Replays"); + break; + case StartupTabDeckEditor: + name = tr("Deck Editor"); + break; + case StartupTabVisualDeckEditor: + name = tr("Visual Deck Editor"); + break; + case StartupTabServer: + name = tr("Server"); + break; + case StartupTabServerRoom: + name = tr("Server Room"); + break; + } + startupTabSelector->setItemText(i, name); + } +} diff --git a/cockatrice/src/interface/widgets/onboarding/pages/preferences_setup_page.h b/cockatrice/src/interface/widgets/onboarding/pages/preferences_setup_page.h new file mode 100644 index 000000000..eed1b3aed --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/preferences_setup_page.h @@ -0,0 +1,41 @@ +#ifndef PREFERENCES_SETUP_PAGE_H +#define PREFERENCES_SETUP_PAGE_H + +#include "../first_run_wizard_page.h" + +class QCheckBox; +class QComboBox; +class QGroupBox; +class QLabel; + +/** @brief A curated subset of settings for the user to adjust. + **/ +class PreferencesSetupPage : public FirstRunWizardPage +{ + Q_OBJECT + +public: + explicit PreferencesSetupPage(QWidget *parent = nullptr); + + void initializePage() override; + bool isSkippable() const override; + QString stepTitle() const override; + QString stepSubtitle() const override; + void retranslateUi() override; + +private: + QGroupBox *gameplayGroup; + QCheckBox *doubleClickToPlayCheckBox; + QCheckBox *horizontalHandCheckBox; + QCheckBox *playToStackCheckBox; + + QGroupBox *notificationsGroup; + QCheckBox *notificationsEnabledCheckBox; + QCheckBox *soundEnabledCheckBox; + + QGroupBox *startupGroup; + QLabel *startupTabLabel; + QComboBox *startupTabSelector; +}; + +#endif // PREFERENCES_SETUP_PAGE_H diff --git a/cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.cpp b/cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.cpp new file mode 100644 index 000000000..797fe4425 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.cpp @@ -0,0 +1,276 @@ +#include "theme_setup_page.h" + +#include "../../client/settings/cache_settings.h" +#include "../../interface/palette_editor/palette_generator.h" +#include "../../interface/palette_editor/quick_setup_panel.h" +#include "../../interface/theme_manager.h" +#include "../../interface/widgets/general/background_sources.h" +#include "libcockatrice/settings/appearance_settings.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +/** @brief A theme's shipped identity accent, immune to any user- or auto- + * generated palette that may currently be masking appColor(). */ +QColor themeIdentityAccent(const QString &themeDirPath, const QString &themeName) +{ + for (const QString &scheme : {QStringLiteral("Light"), QStringLiteral("Dark")}) { + const PaletteConfig cfg = ThemeManager::loadDefaultPaletteConfig(themeDirPath, themeName, scheme); + if (cfg.appColors.contains(AppColor::AccentStrong)) { + return cfg.appColors.value(AppColor::AccentStrong); + } + const QColor highlight = cfg.colors.value(QPalette::Active).value(QPalette::Highlight); + if (highlight.isValid()) { + return highlight; + } + } + return {}; +} +} // namespace + +ThemeSetupPage::ThemeSetupPage(QWidget *parent) : FirstRunWizardPage(parent) +{ + themeCombo = new QComboBox(this); + schemeCombo = new QComboBox(this); + schemeCombo->addItem(tr("Light"), QStringLiteral("Light")); + schemeCombo->addItem(tr("Dark"), QStringLiteral("Dark")); +#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0) + schemeCombo->addItem(tr("Match system"), QStringLiteral("System")); +#endif + + quickSetupPanel = new QuickSetupPanel(this); + + // Seed the picker from the current theme's own identity accent rather than + // a hardcoded brand green: Plasma seeds violet, Fusion green, etc., and it + // is immune to stale generated palettes that may mask appColor(). This was + // initially a brand-green workaround from before Fusion became the default. + // setAccentColor blocks signals, so this never triggers a generation. + lastSeededTheme = SettingsCache::instance().getThemeName(); + quickSetupPanel->setAccentColor( + themeIdentityAccent(themeManager->getAvailableThemes().value(lastSeededTheme), lastSeededTheme)); + + connect(themeCombo, QOverload::of(&QComboBox::currentIndexChanged), this, &ThemeSetupPage::onThemeChanged); + connect(schemeCombo, QOverload::of(&QComboBox::currentIndexChanged), this, &ThemeSetupPage::onSchemeChanged); + connect(quickSetupPanel, &QuickSetupPanel::valueChanged, this, &ThemeSetupPage::onGenerateFromAccent); + + homeTabBackgroundCombo = new QComboBox(this); + for (const auto &entry : BackgroundSources::all()) { + homeTabBackgroundCombo->addItem(QObject::tr(entry.trKey), QVariant::fromValue(entry.type)); + } + connect(homeTabBackgroundCombo, QOverload::of(&QComboBox::currentIndexChanged), this, + &ThemeSetupPage::onHomeTabBackgroundChanged); + + // Keep the scheme combo honest when the *theme* changes underneath it + // (switching theme reloads that theme's own stored colorScheme), and + // opportunistically seed a palette for themes that ship none at all. + // Mirrors AppearanceSettingsPage's identical listener for the combo-sync + // half of this. + connect(themeManager, &ThemeManager::themeChanged, this, [this] { + const QString newTheme = SettingsCache::instance().getThemeName(); + const QString newDir = themeManager->getAvailableThemes().value(newTheme); + const ThemeConfig cfg = ThemeConfig::fromThemeDir(newDir); + const QString current = cfg.colorScheme; + + schemeCombo->blockSignals(true); + const int idx = schemeCombo->findData(current); + schemeCombo->setCurrentIndex(idx >= 0 ? idx : 0); + schemeCombo->blockSignals(false); + + // Keep the picker's accent in step with the theme's own identity; the + // swatch seeded at construction would otherwise stay stale (e.g. green + // from a previous theme) when the user toggles themes. + if (newTheme != lastSeededTheme) { + lastSeededTheme = newTheme; + quickSetupPanel->setAccentColor(themeIdentityAccent(newDir, newTheme)); + } + + maybeAutoGeneratePalette(); + }); + + auto *form = new QFormLayout; + form->addRow(tr("Theme:"), themeCombo); + form->addRow(tr("Appearance:"), schemeCombo); + form->addRow(tr("Home screen background:"), homeTabBackgroundCombo); + + accentGroup = new QGroupBox(this); + auto *accentLayout = new QVBoxLayout(accentGroup); + accentLayout->addWidget(quickSetupPanel); + + auto *layout = new QVBoxLayout(this); + layout->addLayout(form); + layout->addWidget(accentGroup); + layout->addStretch(); + + retranslateUi(); +} + +void ThemeSetupPage::initializePage() +{ + themeCombo->blockSignals(true); + themeCombo->clear(); + const QString currentTheme = SettingsCache::instance().getThemeName(); + for (const QString &name : themeManager->getAvailableThemes().keys()) { + themeCombo->addItem(name); + } + const int idx = themeCombo->findText(currentTheme); + themeCombo->setCurrentIndex(idx >= 0 ? idx : 0); + themeCombo->blockSignals(false); + + const QString dirPath = themeManager->getAvailableThemes().value(SettingsCache::instance().getThemeName()); + const ThemeConfig cfg = ThemeConfig::fromThemeDir(dirPath); + schemeCombo->blockSignals(true); + const int schemeIdx = schemeCombo->findData(cfg.colorScheme); + schemeCombo->setCurrentIndex(schemeIdx >= 0 ? schemeIdx : 0); + schemeCombo->blockSignals(false); + + homeTabBackgroundCombo->blockSignals(true); + QString homeTabSource = SettingsCache::instance().appearance().getHomeTabBackgroundSource(); + int homeTabIdx = homeTabBackgroundCombo->findData(BackgroundSources::fromId(homeTabSource)); + homeTabBackgroundCombo->setCurrentIndex(homeTabIdx >= 0 ? homeTabIdx : 0); + homeTabBackgroundCombo->blockSignals(false); + + // Opening the page must not touch the running application's palette: + // previews and auto-generation only happen in response to the user + // actually changing a control, never on mere page visibility. + paletteDirty = false; +} + +QString ThemeSetupPage::currentScheme() const +{ + return schemeCombo->currentData().toString(); +} + +QString ThemeSetupPage::resolvedScheme() const +{ + const QString scheme = currentScheme(); + if (scheme.isEmpty() || scheme == QStringLiteral("System")) { + return themeManager->isDarkMode(themeManager->getCurrentThemePath()) ? "Dark" : "Light"; + } + return scheme; +} + +void ThemeSetupPage::onThemeChanged(int index) +{ + if (index < 0) { + return; + } + paletteDirty = false; + SettingsCache::instance().setThemeName(themeCombo->itemText(index)); + // Scheme-combo sync and auto-generation both happen via the + // ThemeManager::themeChanged listener above, triggered by setThemeName. +} + +void ThemeSetupPage::onSchemeChanged() +{ + themeManager->setColorScheme(currentScheme()); +} + +void ThemeSetupPage::onHomeTabBackgroundChanged(int index) +{ + if (index < 0) { + return; + } + auto type = homeTabBackgroundCombo->currentData().value(); + SettingsCache::instance().appearance().setHomeTabBackgroundSource(BackgroundSources::toId(type)); +} + +void ThemeSetupPage::onGenerateFromAccent(const QColor &accent, int intensity) +{ + PaletteConfig cfg = PaletteGenerator::fromAccent(accent, intensity, resolvedScheme()); + themeManager->previewPalette(cfg, resolvedScheme()); + paletteDirty = true; +} + +void ThemeSetupPage::maybeAutoGeneratePalette() +{ + const QString dirPath = themeManager->getAvailableThemes().value(SettingsCache::instance().getThemeName()); + const QString scheme = resolvedScheme(); + + // The theme dir may resolve to the user profile even for built-in themes + // (getAvailableThemes gives the user copy precedence), so consult the + // shipped palette too -- both via loadDefaultPaletteConfig's system fallback. + // Without it, scheme flips regenerate a fresh palette from the picker accent + // and clobber the curated colours the theme explicitly ships. + if (PaletteConfig::fromScheme(dirPath, scheme).hasPalette() || + ThemeManager::loadDefaultPaletteConfig(dirPath, SettingsCache::instance().getThemeName(), scheme) + .hasPalette()) { + return; // theme already has something real to show -- leave it alone + } + + // The theme+scheme combination has nothing saved and nothing shipped, and + // the user just switched to it. Rather than leaving a flat, unstyled look, + // seed one from whatever accent QuickSetupPanel currently holds and mark + // it dirty so it's written to disk if the user moves on. Only ever reached + // through user interaction (theme/scheme change, accent drag) -- never on + // page open. + PaletteConfig generated = + PaletteGenerator::fromAccent(quickSetupPanel->accentColor(), quickSetupPanel->intensity(), scheme); + themeManager->previewPalette(generated, scheme); + paletteDirty = true; +} + +bool ThemeSetupPage::validatePage() +{ + if (paletteDirty) { + const QString scheme = resolvedScheme(); + PaletteConfig cfg = + PaletteGenerator::fromAccent(quickSetupPanel->accentColor(), quickSetupPanel->intensity(), scheme); + if (!ThemeManager::commitPalette(writableThemeDir(), scheme, cfg)) { + QMessageBox::warning(this, tr("Save failed"), + tr("Could not write the theme palette to:\n%1").arg(writableThemeDir())); + return false; + } + themeManager->reloadCurrentTheme(); + } + return true; +} + +QString ThemeSetupPage::writableThemeDir() const +{ + // Built-in themes resolve to the read-only system themes directory; + // palette edits must go to the user themes directory instead, exactly + // as PaletteEditorDialog does. + const QString dirPath = themeManager->getAvailableThemes().value(SettingsCache::instance().getThemeName()); + if (!dirPath.isEmpty()) { + const QString probe = QDir(dirPath).absoluteFilePath(".cockatrice_write_test"); + QFile f(probe); + if (f.open(QIODevice::WriteOnly)) { + f.close(); + f.remove(); + return dirPath; + } + } + return QDir(SettingsCache::instance().paths().getThemesPath()) + .absoluteFilePath(SettingsCache::instance().getThemeName()); +} + +bool ThemeSetupPage::isSkippable() const +{ + return true; +} + +QString ThemeSetupPage::stepTitle() const +{ + return tr("Pick a Look"); +} + +QString ThemeSetupPage::stepSubtitle() const +{ + return tr("You can fine-tune every colour later from Settings → Appearance."); +} + +void ThemeSetupPage::retranslateUi() +{ + accentGroup->setTitle(tr("Accent colour (optional)")); +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.h b/cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.h new file mode 100644 index 000000000..16a3c9a5d --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.h @@ -0,0 +1,61 @@ +#ifndef THEME_SETUP_PAGE_H +#define THEME_SETUP_PAGE_H + +#include "../first_run_wizard_page.h" + +class QComboBox; +class QGroupBox; +class QuickSetupPanel; + +/** @brief First-run theme step. Reuses the same building blocks as Appearance + * settings and the Palette Editor (ThemeManager, PaletteConfig, + * PaletteGenerator, and the QuickSetupPanel widget itself) rather than + * reimplementing palette generation or preview here. + * + * Behavior specific to this page (deliberately not pushed down into + * ThemeManager, to avoid changing app-wide behaviour for existing installs): + * - Opening the page never changes the running palette; previews and + * auto-generation only happen when the user actually changes a control. + * - If a theme+scheme the user selects has no saved palette and no shipped + * default, one is generated from the QuickSetupPanel's current accent so + * the preview doesn't fall back to a flat, unstyled look. */ +class ThemeSetupPage : public FirstRunWizardPage +{ + Q_OBJECT + +public: + explicit ThemeSetupPage(QWidget *parent = nullptr); + + void initializePage() override; + bool validatePage() override; + bool isSkippable() const override; + QString stepTitle() const override; + QString stepSubtitle() const override; + void retranslateUi() override; + +private slots: + void onThemeChanged(int index); + void onSchemeChanged(); + void onGenerateFromAccent(const QColor &accent, int intensity); + void onHomeTabBackgroundChanged(int index); + +private: + QString currentScheme() const; + QString resolvedScheme() const; // "System" -> actual Light/Dark + void maybeAutoGeneratePalette(); + QString writableThemeDir() const; + + QComboBox *themeCombo; + QComboBox *schemeCombo; + QGroupBox *accentGroup; + QuickSetupPanel *quickSetupPanel; + + QComboBox *homeTabBackgroundCombo; + + bool paletteDirty = false; + + /// Theme whose identity accent currently seeds the picker. + QString lastSeededTheme; +}; + +#endif // THEME_SETUP_PAGE_H diff --git a/cockatrice/src/interface/widgets/onboarding/pages/welcome_page.cpp b/cockatrice/src/interface/widgets/onboarding/pages/welcome_page.cpp new file mode 100644 index 000000000..16e0719d2 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/welcome_page.cpp @@ -0,0 +1,79 @@ +#include "welcome_page.h" + +#include "../../../../main.h" +#include "../../client/settings/cache_settings.h" +#include "../../settings_page/general_settings_page.h" +#include "libcockatrice/settings/personal_settings.h" + +#include +#include +#include +#include +#include +#include +#include + +WelcomePage::WelcomePage(QWidget *parent) : FirstRunWizardPage(parent) +{ + bodyLabel = new QLabel(this); + bodyLabel->setWordWrap(true); + bodyLabel->setAlignment(Qt::AlignCenter); + + languageLabel = new QLabel(this); + langCombo = new QComboBox(this); + for (const QString &code : GeneralSettingsPage::findQmFiles()) { + langCombo->addItem(GeneralSettingsPage::languageName(code), code); + } + + QString current = SettingsCache::instance().personal().getLang(); + if (current.isEmpty()) { + current = QLocale::system().name(); + } + int index = langCombo->findData(current); + if (index < 0) { + index = langCombo->findData(current.section('_', 0, 0)); + } + if (index >= 0) { + langCombo->setCurrentIndex(index); + } + + connect(langCombo, QOverload::of(&QComboBox::currentIndexChanged), this, &WelcomePage::languageChanged); + + auto *languageRow = new QHBoxLayout; + languageRow->addStretch(); + languageRow->addWidget(languageLabel); + languageRow->addWidget(langCombo); + languageRow->addStretch(); + + auto *layout = new QVBoxLayout(this); + layout->addStretch(); + layout->addWidget(bodyLabel); + layout->addStretch(); + layout->addLayout(languageRow); + + retranslateUi(); +} + +void WelcomePage::languageChanged(int index) +{ + if (index < 0) { + return; + } + SettingsCache::instance().personal().setLang(langCombo->itemData(index).toString()); + qApp->removeTranslator(translator); // NOLINT(cppcoreguidelines-pro-type-static-cast-downcast) + installNewTranslator(); +} + +QString WelcomePage::stepTitle() const +{ + return tr("Welcome!"); +} + +void WelcomePage::retranslateUi() +{ + bodyLabel->setText(tr("Let's get you set up. This will only take a minute — " + "we'll grab the card database, pick a look you like, " + "and get you ready to connect to a server.\n\n" + "You can change any of this later from Settings.")); + languageLabel->setText(tr("Language:")); +} diff --git a/cockatrice/src/interface/widgets/onboarding/pages/welcome_page.h b/cockatrice/src/interface/widgets/onboarding/pages/welcome_page.h new file mode 100644 index 000000000..93b24d83d --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/welcome_page.h @@ -0,0 +1,28 @@ +#ifndef WELCOME_PAGE_H +#define WELCOME_PAGE_H + +#include "../first_run_wizard_page.h" + +class QComboBox; +class QLabel; + +class WelcomePage : public FirstRunWizardPage +{ + Q_OBJECT + +public: + explicit WelcomePage(QWidget *parent = nullptr); + + QString stepTitle() const override; + void retranslateUi() override; + +private slots: + void languageChanged(int index); + +private: + QLabel *bodyLabel; + QLabel *languageLabel; + QComboBox *langCombo; +}; + +#endif // WELCOME_PAGE_H diff --git a/cockatrice/src/interface/widgets/onboarding/qml/BrandBanner.qml b/cockatrice/src/interface/widgets/onboarding/qml/BrandBanner.qml new file mode 100644 index 000000000..d94e15280 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/qml/BrandBanner.qml @@ -0,0 +1,96 @@ +import QtQuick + +Item { + id: root + + ShaderEffect { + id: effectA + anchors.fill: parent + opacity: bannerConfig.frontIsA ? 1.0 : 0.0 + Behavior on opacity { NumberAnimation { duration: 450; easing.type: Easing.InOutCubic } } + property real iTime: bannerConfig.time + property real uAspect: bannerConfig.aspect + property real uMode: bannerConfig.modeA + property real uSpeed: bannerConfig.speedA + property real uSeed: bannerConfig.seedA + property vector4d uColorA: Qt.vector4d(bannerConfig.colorA.r, bannerConfig.colorA.g, bannerConfig.colorA.b, 1.0) + property vector4d uColorB: Qt.vector4d(bannerConfig.colorB.r, bannerConfig.colorB.g, bannerConfig.colorB.b, 1.0) + property vector4d uAccent: Qt.vector4d(bannerConfig.accent.r, bannerConfig.accent.g, bannerConfig.accent.b, 1.0) + property vector4d uGlowColor: Qt.vector4d(bannerConfig.glowColor.r, bannerConfig.glowColor.g, bannerConfig.glowColor.b, 1.0) + property real uVignetteMin: bannerConfig.vignetteMin + property real uLogoGlow: bannerConfig.logoGlow + fragmentShader: "qrc:/onboarding/shaders/brand_banner.frag.qsb" + } + + ShaderEffect { + id: effectB + anchors.fill: parent + opacity: bannerConfig.frontIsA ? 0.0 : 1.0 + Behavior on opacity { NumberAnimation { duration: 450; easing.type: Easing.InOutCubic } } + property real iTime: bannerConfig.time + property real uAspect: bannerConfig.aspect + property real uMode: bannerConfig.modeB + property real uSpeed: bannerConfig.speedB + property real uSeed: bannerConfig.seedB + property vector4d uColorA: Qt.vector4d(bannerConfig.colorA.r, bannerConfig.colorA.g, bannerConfig.colorA.b, 1.0) + property vector4d uColorB: Qt.vector4d(bannerConfig.colorB.r, bannerConfig.colorB.g, bannerConfig.colorB.b, 1.0) + property vector4d uAccent: Qt.vector4d(bannerConfig.accent.r, bannerConfig.accent.g, bannerConfig.accent.b, 1.0) + property vector4d uGlowColor: Qt.vector4d(bannerConfig.glowColor.r, bannerConfig.glowColor.g, bannerConfig.glowColor.b, 1.0) + property real uVignetteMin: bannerConfig.vignetteMin + property real uLogoGlow: bannerConfig.logoGlow + fragmentShader: "qrc:/onboarding/shaders/brand_banner.frag.qsb" + } + + // The white logo sits at full opacity on top of the static gradient plate — + // no glow, no breathing. The plate matches home_widget's QPainter composite. + Item { + id: logoHost + visible: bannerConfig.logoVisible + anchors.centerIn: parent + width: root.height * 0.6 + height: width + + // The full-color logo renders beneath the white mark and is consumed as + // a texture (layer.enabled) by the plate shader's silhouette mask, so + // the gradient is clipped to the bird exactly as the SVG's gradient + // paths are. It is never drawn to the screen itself. + Image { + id: silhouetteMask + anchors.fill: parent + source: "qrc:/resources/cockatrice.svg" + sourceSize: Qt.size(256, 256) + fillMode: Image.PreserveAspectFit + smooth: true + visible: false + layer.enabled: true + layer.smooth: true + } + + // The logo's gradient plate, drawn behind the mark: a linear + // AccentSoft (light) -> AccentStrong (dark) sheet along the same + // top-left -> bottom-right userSpaceOnUse axis the baked-in SVG used, + // clipped to the bird silhouette via uSilhouette. The white highlight + // path above is theme independent. Sized to the logo itself — no + // rounded badge, matching home_widget's QPainter composite. Static. + // Small ShaderEffect, Qt 6.4-safe. + ShaderEffect { + id: brandPlate + anchors.fill: parent + property vector4d uStrong: Qt.vector4d(bannerConfig.brandStrong.r, bannerConfig.brandStrong.g, + bannerConfig.brandStrong.b, 1.0) + property vector4d uSoft: Qt.vector4d(bannerConfig.brandSoft.r, bannerConfig.brandSoft.g, + bannerConfig.brandSoft.b, 1.0) + property var uSilhouette: silhouetteMask + fragmentShader: "qrc:/onboarding/shaders/brand_plate.frag.qsb" + } + + Image { + id: logoImage + anchors.fill: parent + source: "qrc:/resources/cockatrice-logo-white.svg" + sourceSize: Qt.size(256, 256) + fillMode: Image.PreserveAspectFit + smooth: true + } + } +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.cpp b/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.cpp new file mode 100644 index 000000000..0b9f65783 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.cpp @@ -0,0 +1,326 @@ +#include "shader_banner_widget.h" + +#include "../../theme_manager.h" +#include "banner_shader_config.h" +#include "brand_colors.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +// Curated near-black stage -- used only when the active palette resolves no +// usable window colour. Matches the banner's original design (dark and quiet +// so the accent stands out) and satisfies design-plans §2.1's "identity +// survives a bare palette". +constexpr QRgb kFallbackColorA = 0x1A1A20; +constexpr QRgb kFallbackColorB = 0x0E0E12; + +struct SuggestedColors +{ + QColor colorA; + QColor colorB; + QColor accent; + QColor glowColor; + QColor brandStrong; + QColor brandSoft; + qreal vignetteMin = 0.62; +}; + +SuggestedColors suggestedBannerColors() +{ + const QPalette &pal = qApp->palette(); + const QColor window = pal.color(QPalette::Active, QPalette::Window); + // Identity accent: the theme's [AppColors] AccentStrong, which appColor() + // resolves to QPalette::Highlight when a theme doesn't pin AccentStrong. + // Reading bare Highlight ignored curated accent tokens (Plasma's violet + // vs Default's green) whenever a palette didn't set the role itself. + const QColor accentStrong = themeManager->appColor(AppColor::AccentStrong); + if (!window.isValid() || !accentStrong.isValid()) { + return {QColor(kFallbackColorA), + QColor(kFallbackColorB), + kCockatriceBrandGreen, + QColor(Qt::white), + kCockatriceBrandGreen, + QColor(0xC9, 0xFD, 0x62), + 0.62}; + } + + // The theme's brand pair: AccentStrong is the deep green, AccentSoft the + // lime. These two appColors form the logo's "surrounding gradient" (deep + // core grading out to the soft, brand-toned glow) on both the banner and + // the home screen. + const QColor brandStrong = accentStrong; + const QColor brandSoft = themeManager->appColor(AppColor::AccentSoft); + + // Dress the stage for the scheme so the banner never fights the + // surrounding window in either mode. Dark palettes keep the original + // quiet near-black stage (lightness 29 → 16) with the theme's window + // hue; light palettes get a pastel "frosted accent" treatment built from + // the accent hue instead of a plain near-white copy: a coloured wash that + // clearly belongs to the theme. + const qreal luma = 0.299 * window.red() + 0.587 * window.green() + 0.114 * window.blue(); + const bool lightStage = luma > 115.0; + if (lightStage) { + const int hue = accentStrong.hslHue(); + // Achromatic accents (grey) get a neutral near-white stage instead. + const int stageSat = hue < 0 ? 0 : 64; + const int hueSafe = hue < 0 ? 0 : hue; + // Depth is what stops a light stage reading as a washed-out near-white + // copy of the page behind the banner: deepen the lower pastel band and + // raise saturation so the hue is clearly present while staying frosted. + auto pastel = [hueSafe, stageSat](int lightness) { return QColor::fromHsl(hueSafe, stageSat, lightness); }; + auto pastelLower = [hueSafe](int lightness) { return QColor::fromHsl(hueSafe, 76, lightness); }; + // Brightness-lifted accent for additive glows: the raw accent on a + // light stage must be mid-bright to read instead of washing out, so + // lift lightness and saturation together. + const int accentLightness = qBound(158, accentStrong.lightness() + 82, 198); + const int accentSaturation = hue < 0 ? 0 : qMax(accentStrong.hslSaturation(), 180); + const QColor liftedAccent = + hue < 0 ? accentStrong : QColor::fromHsl(hueSafe, accentSaturation, accentLightness); + // The centre glow (and logo tint in QML) uses the deep accent itself: + // a coloured halo/fill behind the logo instead of a white or black one. + return {pastel(214), pastelLower(186), liftedAccent, accentStrong, brandStrong, brandSoft, 0.80}; + } + + // Dark stage: force the window hue down to the banner's curated darkness, + // scaling saturation away so chromatic palettes tint it without going + // muddy. The accent is the bright, brand-driven tone (hue from the accent + // itself, never the -- often grey -- window), and it drives both the + // embers/fog and the logo glow so the mark tints like the light stage. + auto stage = [&window](int lightness) { + const int hue = window.hslHue(); + const int saturation = hue < 0 ? 0 : qBound(0, qRound(window.hslSaturation() * (lightness / 40.0)), 255); + return QColor::fromHsl(hue, saturation, lightness); + }; + const int accentHue = accentStrong.hslHue(); + const int accentHueSafe = accentHue < 0 ? 0 : accentHue; + const int accentLightness = qBound(150, accentStrong.lightness() + 70, 185); + const int accentSaturation = accentHue < 0 ? 0 : qMax(accentStrong.hslSaturation(), 160); + const QColor accent = + accentHue < 0 ? accentStrong : QColor::fromHsl(accentHueSafe, accentSaturation, accentLightness); + return {stage(29), stage(16), accent, accent, brandStrong, brandSoft, 0.62}; +} +} // namespace + +class GradientFallbackWidget : public QWidget +{ +public: + using QWidget::QWidget; + + void setColors(const QColor &a, const QColor &b) + { + if (a != colorA || b != colorB) { + colorA = a; + colorB = b; + } + } + +protected: + void paintEvent(QPaintEvent *) override + { + QPainter painter(this); + QLinearGradient gradient(0, 0, width(), height()); + gradient.setColorAt(0.0, colorA); + gradient.setColorAt(1.0, colorB); + painter.fillRect(rect(), gradient); + } + +private: + QColor colorA{QColor(kFallbackColorA)}; + QColor colorB{QColor(kFallbackColorB)}; +}; + +BannerHost::BannerHost(QWidget *parent) : QWidget(parent) +{ + setFixedHeight(150); + + stack = new QStackedLayout(this); + stack->setContentsMargins(0, 0, 0, 0); + + fallback = new GradientFallbackWidget(this); + stack->addWidget(fallback); + + quickWidget = new QQuickWidget(this); + quickWidget->setResizeMode(QQuickWidget::SizeRootObjectToView); + + config = new BannerShaderConfig(quickWidget->engine()); + quickWidget->rootContext()->setContextProperty("bannerConfig", config); + quickWidget->setSource(QUrl("qrc:/onboarding/qml/BrandBanner.qml")); + + if (quickWidget->status() == QQuickWidget::Error) { + activateFallback(); + } else { + connect(quickWidget, &QQuickWidget::sceneGraphError, this, &BannerHost::onSceneGraphFailed); + stack->addWidget(quickWidget); + stack->setCurrentWidget(quickWidget); + } + + connect(&clock, &QTimer::timeout, this, &BannerHost::tick); + clock.setInterval(16); // ~60fps; the shader itself is cheap, this is just a wall clock + + connect(themeManager, &ThemeManager::themeChanged, this, &BannerHost::applyThemeColors); + applyThemeColors(); + + applyMotifPreset(currentMotif); + updateAspect(); +} + +void BannerHost::activateFallback() +{ + if (usingFallback) { + return; + } + usingFallback = true; + clock.stop(); + stack->setCurrentWidget(fallback); + + if (quickWidget) { + quickWidget->deleteLater(); // takes BannerShaderConfig (parented to its engine) with it + quickWidget = nullptr; + config = nullptr; + } +} + +void BannerHost::onSceneGraphFailed() +{ + activateFallback(); +} + +void BannerHost::setMotif(Motif motif) +{ + currentMotif = motif; + applyMotifPreset(motif); +} + +BannerHost::Preset BannerHost::presetFor(Motif motif) +{ + // speed/seed tuned per motif so e.g. the network "pulse" (Account) reads + // at a deliberately calmer cadence than the data "scan" lines + // (Preferences), even though both come from the same shader. + switch (motif) { + case Motif::Welcome: + return {0.0, 0.6, 0.15}; + case Motif::CardDatabase: + return {1.0, 1.3, 0.42}; + case Motif::Theming: + return {2.0, 1.2, 0.73}; + case Motif::Account: + return {3.0, 0.8, 0.28}; + case Motif::Preferences: + return {4.0, 1.0, 0.61}; + case Motif::Finish: + return {5.0, 1.0, 0.91}; + } + return {0.0, 0.6, 0.15}; +} + +void BannerHost::applyMotifPreset(Motif motif) +{ + if (usingFallback || !config) { + return; + } + + const Preset p = presetFor(motif); + + config->setColorA(bannerColorA); + config->setColorB(bannerColorB); + config->setAccent(bannerAccent); + config->setLogoVisible(motif == Motif::Welcome); + + if (isFirstApply) { + // Nothing on screen yet -- write straight into the front bank, no + // crossfade needed for the very first paint. + config->setModeA(p.mode); + config->setSpeedA(p.speed); + config->setSeedA(p.seed); + config->setFrontIsA(true); + isFirstApply = false; + return; + } + + // Write the new preset into whichever bank is currently hidden, then + // flip which one is front. QML's opacity Behavior does the actual + // crossfade -- BannerHost never animates anything itself. + if (config->frontIsA()) { + config->setModeB(p.mode); + config->setSpeedB(p.speed); + config->setSeedB(p.seed); + config->setFrontIsA(false); + } else { + config->setModeA(p.mode); + config->setSpeedA(p.speed); + config->setSeedA(p.seed); + config->setFrontIsA(true); + } +} + +void BannerHost::updateAspect() +{ + if (config && height() > 0) { + config->setAspect(qreal(width()) / qreal(height())); + } +} + +void BannerHost::resizeEvent(QResizeEvent *event) +{ + QWidget::resizeEvent(event); + updateAspect(); +} + +void BannerHost::showEvent(QShowEvent *event) +{ + QWidget::showEvent(event); + if (!usingFallback) { + elapsed.restart(); + clock.start(); + } +} + +void BannerHost::hideEvent(QHideEvent *event) +{ + QWidget::hideEvent(event); + clock.stop(); +} + +void BannerHost::applyThemeColors() +{ + const SuggestedColors colors = suggestedBannerColors(); + bannerColorA = colors.colorA; + bannerColorB = colors.colorB; + bannerAccent = colors.accent; + + if (usingFallback) { + fallback->setColors(bannerColorA, bannerColorB); + fallback->update(); + } else if (config) { + config->setColorA(bannerColorA); + config->setColorB(bannerColorB); + config->setAccent(bannerAccent); + config->setGlowColor(colors.glowColor); + config->setBrandStrong(colors.brandStrong); + config->setBrandSoft(colors.brandSoft); + config->setVignetteMin(colors.vignetteMin); + } +} + +void BannerHost::tick() +{ + // Palette previews (e.g. accent drags in the wizard's QuickSetupPanel) + // apply qApp->palette() without firing themeChanged, so re-derive here; + // BannerShaderConfig's setters are equality-guarded, so this is a no-op + // unless the colours actually changed. + applyThemeColors(); + if (config) { + qreal t = elapsed.elapsed() / 1000.0; + config->setTime(t); + // Visible breathing for the logo: oscillates between 0.0 and 1.0 + qreal glow = 0.5 + 0.5 * qSin(t * 0.4); + config->setLogoGlow(glow); + } +} diff --git a/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.h b/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.h new file mode 100644 index 000000000..ac47b1141 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.h @@ -0,0 +1,91 @@ +#ifndef SHADER_BANNER_WIDGET_H +#define SHADER_BANNER_WIDGET_H + +#include +#include +#include +#include + +class BannerShaderConfig; +class QQuickWidget; +class GradientFallbackWidget; +class QStackedLayout; + +/** @brief Onboarding banner: a subtle, looping brand-shader animation, one of six + * per-page "motifs" driving the same prebaked fragment shader + * (onboarding/shaders/brand_banner.frag) with different uniform values, so + * every page feels distinct but unmistakably part of the same family. + * + * Motif switches crossfade smoothly (see BrandBanner.qml's two stacked + * ShaderEffect layers + Behavior on opacity) rather than cutting instantly + * -- BannerHost just writes the new preset into whichever layer is + * currently hidden and flips BannerShaderConfig::frontIsA; QML handles the + * actual animation declaratively. + * + * Falls back to a static two-stop gradient (no shader, no QQuickWidget) if + * the platform's Qt Quick scenegraph can't initialize -- e.g. software + * rendering only, or a CI/VM environment with no GPU -- so onboarding + * never blocks or blanks out over a graphics driver problem. The fallback + * is permanent for the lifetime of this widget once triggered. */ +class BannerHost : public QWidget +{ + Q_OBJECT + +public: + enum class Motif + { + Welcome, + CardDatabase, + Theming, + Account, + Preferences, + Finish, + }; + + explicit BannerHost(QWidget *parent = nullptr); + + void setMotif(Motif motif); + +protected: + void showEvent(QShowEvent *event) override; + void hideEvent(QHideEvent *event) override; + void resizeEvent(QResizeEvent *event) override; + +private slots: + void tick(); + void onSceneGraphFailed(); + void applyThemeColors(); + +private: + struct Preset + { + qreal mode; + qreal speed; + qreal seed; + }; + + static Preset presetFor(Motif motif); + + void applyMotifPreset(Motif motif); + void updateAspect(); + void activateFallback(); + + QStackedLayout *stack; + QQuickWidget *quickWidget = nullptr; + BannerShaderConfig *config = nullptr; + GradientFallbackWidget *fallback = nullptr; + + // Palette-derived banner colours -- the theme's window hue forced down to + // the banner's curated darkness, plus the theme's Highlight as accent. + QColor bannerColorA; + QColor bannerColorB; + QColor bannerAccent; + + QTimer clock; + QElapsedTimer elapsed; + Motif currentMotif = Motif::Welcome; + bool usingFallback = false; + bool isFirstApply = true; +}; + +#endif // SHADER_BANNER_WIDGET_H diff --git a/cockatrice/src/interface/widgets/onboarding/shaders/brand_banner.frag b/cockatrice/src/interface/widgets/onboarding/shaders/brand_banner.frag new file mode 100644 index 000000000..2bf6d0abf --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/shaders/brand_banner.frag @@ -0,0 +1,468 @@ +#version 440 + +// ════════════════════════════════════════════════════════════════════════ +// brand_banner.frag +// +// One shader, six motifs (uMode 0..5). All motifs composite over a shared +// backgroundField() whose colour is flow-noise-modulated blend of uColorA +// and uColorB. SDFs operate in aspect-corrected space (ac.x = uv.x * +// uAspect) to preserve shape proportions on the wide banner. +// +// IMPORTANT: the uniform block below must list custom uniforms in EXACTLY +// the order they're declared as properties on each ShaderEffect instance in +// BrandBanner.qml (after the two Qt-supplied members, qt_Matrix/qt_Opacity). +// ════════════════════════════════════════════════════════════════════════ + +layout(location = 0) in vec2 qt_TexCoord0; +layout(location = 0) out vec4 fragColor; + +layout(std140, binding = 0) uniform buf +{ + mat4 qt_Matrix; + float qt_Opacity; + float iTime; + float uAspect; + float uMode; + float uSpeed; + float uSeed; + vec4 uColorA; + vec4 uColorB; + vec4 uAccent; + vec4 uGlowColor; + float uVignetteMin; + float uLogoGlow; +}; + +// ── Primitives ────────────────────────────────────────────────────────── + +float hash21(vec2 p) +{ + p = fract(p * vec2(123.34, 456.21)); + p += dot(p, p + 45.32); + return fract(p.x * p.y); +} + +float valueNoise(vec2 p) +{ + vec2 i = floor(p); + vec2 f = fract(p); + float a = hash21(i); + float b = hash21(i + vec2(1.0, 0.0)); + float c = hash21(i + vec2(0.0, 1.0)); + float d = hash21(i + vec2(1.0, 1.0)); + vec2 u = f * f * (3.0 - 2.0 * f); + return mix(mix(a, b, u.x), mix(c, d, u.x), u.y); +} + +float fbm(vec2 p) +{ + float v = 0.0; + float amp = 0.5; + for (int i = 0; i < 3; i++) { + v += amp * valueNoise(p); + p *= 2.03; + amp *= 0.5; + } + return v; +} + +float flowNoise(vec2 p, float t) +{ + vec2 warp1 = vec2(fbm(p + vec2(0.0, 0.0)), fbm(p + vec2(5.2, 1.3))); + vec2 warp2 = vec2(fbm(p + 4.0 * warp1 + vec2(1.7, 9.2) + t * 0.6), + fbm(p + 4.0 * warp1 + vec2(8.3, 2.8) - t * 0.5)); + return fbm(p + 4.0 * warp2 + t * 0.15); +} + +float bloom(float d, float coreRadius, float haloRadius) +{ + float core = exp(-(d * d) / (coreRadius * coreRadius)); + float halo = exp(-d / haloRadius) * 0.35; + return core + halo; +} + +float roundedBoxSDF(vec2 p, vec2 halfSize, float radius) +{ + vec2 d = abs(p) - halfSize + radius; + return length(max(d, 0.0)) - radius + min(max(d.x, d.y), 0.0); +} + +// Rotated box SDF -- applies 2D rotation to p before evaluating roundedBoxSDF. +float rotatedBoxSDF(vec2 p, vec2 halfSize, float radius, float angle) +{ + float c = cos(angle); + float s = sin(angle); + vec2 rp = vec2(p.x * c - p.y * s, p.x * s + p.y * c); + return roundedBoxSDF(rp, halfSize, radius); +} + +float vignette(vec2 uv) +{ + vec2 c = uv - 0.5; + c.x *= max(uAspect, 0.0001); + return smoothstep(1.0, 0.25, length(c)); +} + +// ── Shared background ─────────────────────────────────────────────────── + +vec3 backgroundField(vec2 uv, float time) +{ + // Diagonal luminance gradient from (0,0) to (1,1) used as blend factor + // between uColorA and uColorB; modulated by flowNoise. + float baseD = smoothstep(0.0, 1.0, uv.y * 0.5 + uv.x * 0.2); + float painted = flowNoise(uv * 1.5, time * 0.04) - 0.5; + baseD = clamp(baseD + painted * 0.12, 0.0, 1.0); + + vec3 col = mix(uColorA.rgb, uColorB.rgb, baseD); + + // Low-frequency fBM noise pushes local colour toward uColorB for depth + float deep = fbm(uv * 1.0 + vec2(37.1, 12.4) + time * 0.015); + col = mix(col, uColorB.rgb, (deep - 0.5) * 0.08); + + // Accent-coloured fog layer: flowNoise peaks above 0.6 contribute accent + float fog = flowNoise(uv * 0.8 + vec2(100.0, 50.0), time * 0.02); + col += uAccent.rgb * max(fog - 0.6, 0.0) * 0.14; + + return col; +} + +// ── Motifs ────────────────────────────────────────────────────────────── + +// Centre bloom, flow-noise shimmer gated to centre, and 48 orbiting ember +// particles that deflect into a tight ring near the centre. +vec3 motifWelcome(vec2 uv, vec3 bg, float t) +{ + vec3 col = bg; + float asp = max(uAspect, 0.001); + vec2 ac = vec2(uv.x * asp, uv.y); + vec2 center = vec2(asp * 0.5, 0.5); + float cDist = length(ac - center); + + // Centre bloom at logo position; intensity scales with uLogoGlow. The + // QML brandGlow halo now supplies the primary logo surround (the two + // brand appColors), so this shader bloom is deliberately kept as a subtle + // ambience rather than a competing glow. + float centreLight = bloom(cDist, 0.08 * asp, 0.40 * asp); + col += uGlowColor.rgb * centreLight * 0.20 * uLogoGlow; + + // Flow-noise shimmer gated by Gaussian mask at centre + float shimmer = flowNoise(ac * 0.8 + vec2(55.0, 33.0), t * 0.05) * 0.5 + 0.5; + float shimmerMask = exp(-(cDist * cDist) / (0.18 * asp * 0.18 * asp)); + col += uGlowColor.rgb * shimmer * shimmerMask * 0.04 * uLogoGlow; + + // 48 ember particles: hash-seeded position, speed, size, brightness. + // Embers within a distance threshold of centre are deflected into an + // orbital ring via tangent displacement perpendicular to the centre vector. + const int EMBERS = 48; + for (int i = 0; i < EMBERS; i++) { + float fi = float(i); + + float baseX = hash21(vec2(fi * 7.31 + uSeed, fi * 3.17)); + float baseY = hash21(vec2(fi * 11.9 + uSeed * 1.4, fi * 5.53)); + + float riseSpeed = 0.025 + hash21(vec2(fi * 1.7, uSeed * 2.1)) * 0.035; + float driftAmp = 0.04 + hash21(vec2(fi * 9.3, uSeed)) * 0.06; + float driftFreq = 0.3 + hash21(vec2(fi * 4.1, uSeed * 3.3)) * 0.5; + + float pX = baseX * asp + sin(t * driftFreq + fi * 1.7) * driftAmp * asp; + float pY = fract(baseY + t * riseSpeed); + + float size = 0.010 + hash21(vec2(fi * 2.9, uSeed * 4.7)) * 0.014; + float bright = 0.18 + hash21(vec2(fi * 6.1, uSeed * 0.9)) * 0.32; + + // Fade out near top/bottom edges + float edgeFade = smoothstep(0.0, 0.12, pY) * smoothstep(1.0, 0.88, pY); + float twinkle = 0.6 + 0.4 * sin(t * (1.2 + fi * 0.37) + fi * 2.9); + + vec2 ePos = vec2(pX, pY); + + // Embers near centre: deflect into orbital ring via tangent displacement + vec2 toCenter = ePos - center; + float distToCenter = length(toCenter); + float ringWeight = smoothstep(0.38 * asp, 0.06 * asp, distToCenter); + + float orbitPhase = t * (0.15 + fi * 0.020) + fi * 2.3; + float orbitAmount = 0.020 + hash21(vec2(fi * 12.3, uSeed * 2.7)) * 0.020; + vec2 tangent = vec2(-toCenter.y, toCenter.x); + vec2 deflected = ePos + tangent * ringWeight * orbitAmount * asp * sin(orbitPhase); + + float pushOut = ringWeight * (0.008 + hash21(vec2(fi * 6.7, uSeed * 1.1)) * 0.012) * asp; + deflected += normalize(toCenter + 0.001) * pushOut; + + float dist = length(ac - deflected); + float intensity = bright * edgeFade * twinkle; + col += uAccent.rgb * bloom(dist, size, size * 4.0) * intensity; + } + + return col; +} + +// 25 card-shaped box SDFs at parallax depths drifting horizontally across +// the banner; each card has a semi-transparent fill, accent outline, and +// card-back diamond pattern. +vec3 motifCardDatabase(vec2 uv, vec3 bg, float t) +{ + vec3 col = bg; + float asp = max(uAspect, 0.001); + vec2 ac = vec2(uv.x * asp, uv.y); + + const int CARDS = 25; + for (int i = 0; i < CARDS; i++) { + float fi = float(i); + + // Parallax depth via hash; used to scale size, speed, brightness + float depth = hash21(vec2(fi * 1.37 + uSeed, fi * 0.91)); + + // Card dimensions in corrected space (portrait: height > width) + float cardH = mix(0.055, 0.15, depth); + cardH *= 0.85 + 0.30 * hash21(vec2(fi * 3.14, uSeed * 2.71)); + float cardW = cardH * 0.71; // 5:7 ratio + + // Horizontal drift; nearer cards (higher depth) move faster + float speed = mix(0.06, 0.18, depth); + float xPhase = hash21(vec2(fi * 7.13, uSeed * 4.37)); + xPhase = fract(xPhase + t * speed); + float x = mix(-1.5, asp + 1.5, xPhase); + + // Vertical position: hash distribution with sinusoidal oscillation + float yBase = hash21(vec2(fi * 2.91, uSeed * 1.63)); + float y = yBase + sin(t * 0.6 + fi * 1.9) * 0.035; + y = clamp(y, cardH + 0.02, 1.0 - cardH - 0.02); + + // Random rotation angle ±4 degrees + float tilt = (hash21(vec2(fi * 5.71, uSeed * 8.29)) - 0.5) * 0.14; + + vec2 p = ac - vec2(x, y); + float d = rotatedBoxSDF(p, vec2(cardW, cardH), cardW * 0.14, tilt); + + // Semi-transparent dark fill + float fill = smoothstep(0.015, -0.005, d); + col = mix(col, uColorB.rgb * 0.60, fill * 0.62); + + // Accent outline + float edge = smoothstep(0.035, 0.0, abs(d)); + col += uAccent.rgb * edge * mix(0.18, 0.50, 1.0 - depth); + + // Card-back diamond: smaller rotated box inset from card edges + float innerD = rotatedBoxSDF(p, vec2(cardW * 0.45, cardH * 0.55), cardW * 0.08, tilt); + float innerEdge = smoothstep(0.012, 0.0, abs(innerD)); + col += uAccent.rgb * innerEdge * fill * 0.12 * (1.0 - depth); + + // Centre dot + float dotDist = length(p); + col += uAccent.rgb * bloom(dotDist, 0.008, 0.02) * fill * 0.15 * (1.0 - depth); + } + return col; +} + +// 4 horizontal bands with multi-frequency sinusoidal warp and pulsing width. +vec3 motifTheming(vec2 uv, vec3 bg, float t) +{ + vec3 col = bg; + + const int BANDS = 4; + for (int i = 0; i < BANDS; i++) { + float fi = float(i); + float yCenter = 0.18 + fi * 0.22; + + // Three summed sinusoids for horizontal undulation + float wave = sin(uv.x * 3.2 + t * 0.5 + fi * 2.1) * 0.08; + wave += sin(uv.x * 7.0 - t * 0.3 + fi * 1.3) * 0.035; + wave += sin(uv.x * 1.6 + t * 0.18 + fi * 3.7) * 0.05; + + float bandDist = abs(uv.y - yCenter - wave); + float bandWidth = 0.04 + sin(t * 0.2 + fi * 0.8) * 0.012; + float band = smoothstep(bandWidth, 0.0, bandDist); + + // Upper bands have higher intensity + float intensity = mix(0.15, 0.38, 1.0 - fi / float(BANDS)); + col += uAccent.rgb * band * intensity; + } + + return col; +} + +// 14 nodes at pseudo-random positions with sinusoidal pulse; edges drawn +// between nodes within a threshold distance; central glow + periodic ring. +vec3 motifAccount(vec2 uv, vec3 bg, float t) +{ + vec3 col = bg; + float asp = max(uAspect, 0.001); + vec2 ac = vec2(uv.x * asp, uv.y); + vec2 center = vec2(asp * 0.5, 0.5); + + const int NODES = 14; + vec2 nodePos[14]; + float nodePulse[14]; + + for (int i = 0; i < NODES; i++) { + float fi = float(i); + + // Hash-seeded position with gentle sinusoidal drift + float nx = hash21(vec2(fi * 3.17 + uSeed, fi * 1.93)) * asp; + float ny = hash21(vec2(fi * 5.41 + uSeed * 1.7, fi * 2.79)); + + float dx = sin(t * 0.12 + fi * 1.7) * 0.08; + float dy = cos(t * 0.09 + fi * 2.3) * 0.04; + vec2 pos = vec2(nx + dx, ny + dy); + nodePos[i] = pos; + + // Per-node pulse phase, normalised to [0, 1] + float pulsePhase = hash21(vec2(fi * 4.31, uSeed * 6.17)); + float pulse = sin(t * 0.8 + pulsePhase * 6.283) * 0.5 + 0.5; + nodePulse[i] = pulse; + + // Node glow via bloom; intensity modulated by pulse + float dist = length(ac - pos); + col += uAccent.rgb * bloom(dist, 0.018, 0.08) * mix(0.30, 0.55, pulse); + } + + // Edges: connect nodes within a radius threshold + float connectDist = asp * 0.22; + for (int i = 0; i < NODES; i++) { + for (int j = i + 1; j < NODES; j++) { + float pairDist = length(nodePos[i] - nodePos[j]); + if (pairDist < connectDist) { + float strength = 1.0 - pairDist / connectDist; + vec2 pa = ac - nodePos[i]; + vec2 ba = nodePos[j] - nodePos[i]; + float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0); + float lineDist = length(pa - ba * h); + col += uAccent.rgb * smoothstep(0.010, 0.0, lineDist) * strength * 0.14; + } + } + } + + // Central bloom at banner centre + float cDist = length(ac - center); + col += uAccent.rgb * bloom(cDist, 0.04, 0.25) * 0.20; + + // Periodic expanding ring from centre + float ripplePhase = t * 0.4; + float rippleDist = abs(cDist - fract(ripplePhase) * asp * 0.7); + col += uAccent.rgb * smoothstep(0.02, 0.0, rippleDist) * 0.14; + + return col; +} + +// 18x5 toggle-grid of rounded boxes with hash-driven on/off per cell; +// a scanning highlight sweeps L-to-R, brightening cells near the scan line. +vec3 motifPreferences(vec2 uv, vec3 bg, float t) +{ + vec3 col = bg; + + float cols = 18.0; + float rows = 5.0; + vec2 gridUV = uv * vec2(cols, rows); + vec2 cell = fract(gridUV) - 0.5; + vec2 cellId = floor(gridUV); + + // On/off state per cell, hash-seeded for pseudo-randomness + float on = step(0.55, hash21(cellId + uSeed * 10.0)); + + float d = roundedBoxSDF(cell, vec2(0.28, 0.32), 0.06); + + // Filled "on" cells + float cellFill = smoothstep(0.04, -0.02, d); + col += uAccent.rgb * cellFill * on * 0.18; + + // Cell borders (drawn on all cells) + float border = smoothstep(0.025, 0.0, abs(d)); + col += uAccent.rgb * border * 0.06; + + // Scanning highlight: thin line + soft glow sweeping L-to-R + float scanX = fract(t * 0.15); + float scanDist = abs(uv.x - scanX); + float scanLine = smoothstep(0.015, 0.0, scanDist); + col += uAccent.rgb * scanLine * 0.40; + + float scanGlow = smoothstep(0.08, 0.0, scanDist); + col += uAccent.rgb * scanGlow * 0.08; + + // "On" cells near the scan line get extra brightness + float scanProximity = smoothstep(0.12, 0.0, scanDist); + col += uAccent.rgb * cellFill * on * scanProximity * 0.15; + + return col; +} + +// Centre radial bloom with sinusoidal pulse, 4 expanding ring halos with +// outer glow falloff, and 35 rising particles. +vec3 motifFinish(vec2 uv, vec3 bg, float t) +{ + vec3 col = bg; + float asp = max(uAspect, 0.001); + vec2 ac = vec2(uv.x * asp, uv.y); + vec2 center = vec2(asp * 0.5, 0.5); + float cDist = length(ac - center); + + // Centre bloom with sinusoidal pulse modulation + float pulse = 0.65 + 0.35 * sin(t * 0.4); + col += uAccent.rgb * bloom(cDist, 0.12, 0.55) * 0.10 * pulse; + + // 4 expanding rings: radius increases via phase; ring width grows with + // expansion; combined with exponential outer glow falloff + for (int i = 0; i < 4; i++) { + float fi = float(i); + float phase = fract(t * 0.06 + fi * 0.25); + float ringRadius = phase * asp * 0.7; + float ringDist = abs(cDist - ringRadius); + float ringWidth = 0.025 + phase * 0.025; + float ring = smoothstep(ringWidth, 0.0, ringDist); + float outerGlow = exp(-ringDist / (0.03 + phase * 0.02)) * 0.3; + float combined = ring + outerGlow; + float fade = 1.0 - phase * 0.5; + col += uAccent.rgb * combined * fade * 0.15; + } + + // 35 particles rising vertically with sinusoidal horizontal drift; + // each particle uses bloom with edge fade and twinkle animation + const int PARTICLES = 35; + for (int i = 0; i < PARTICLES; i++) { + float fi = float(i); + float baseX = hash21(vec2(fi * 13.7 + uSeed, fi * 7.31)); + float baseY = hash21(vec2(fi * 23.1 + uSeed * 1.9, fi * 11.3)); + + float riseSpeed = 0.04 + hash21(vec2(fi * 3.1, uSeed * 2.7)) * 0.06; + float driftAmp = 0.03 + hash21(vec2(fi * 8.9, uSeed)) * 0.05; + float driftFreq = 0.4 + hash21(vec2(fi * 5.3, uSeed * 4.1)) * 0.6; + + float pX = baseX * asp + sin(t * driftFreq + fi * 2.3) * driftAmp * asp; + float pY = fract(baseY + t * riseSpeed); + + float size = 0.005 + hash21(vec2(fi * 4.7, uSeed * 3.9)) * 0.010; + float bright = 0.12 + hash21(vec2(fi * 7.1, uSeed * 1.3)) * 0.25; + + float edgeFade = smoothstep(0.0, 0.1, pY) * smoothstep(1.0, 0.9, pY); + float twinkle = 0.5 + 0.5 * sin(t * (1.8 + fi * 0.43) + fi * 3.1); + + vec2 pPos = vec2(pX, pY); + float dist = length(ac - pPos); + col += uAccent.rgb * bloom(dist, size, size * 3.5) * bright * edgeFade * twinkle; + } + + return col; +} + +// ── Main ──────────────────────────────────────────────────────────────── + +void main() +{ + vec2 uv = qt_TexCoord0; + float t = iTime * uSpeed; + + vec3 bg = backgroundField(uv, iTime); + + vec3 col; + if (uMode < 0.5) col = motifWelcome(uv, bg, t); + else if (uMode < 1.5) col = motifCardDatabase(uv, bg, t); + else if (uMode < 2.5) col = motifTheming(uv, bg, t); + else if (uMode < 3.5) col = motifAccount(uv, bg, t); + else if (uMode < 4.5) col = motifPreferences(uv, bg, t); + else col = motifFinish(uv, bg, t); + + // Corner vignette; uVignetteMin is scheme-driven (0.62 on dark stages, + // gentler on light ones so near-white corners don't go muddy grey). + col *= mix(uVignetteMin, 1.0, vignette(uv)); + fragColor = vec4(col, 1.0) * qt_Opacity; +} diff --git a/cockatrice/src/interface/widgets/onboarding/shaders/brand_plate.frag b/cockatrice/src/interface/widgets/onboarding/shaders/brand_plate.frag new file mode 100644 index 000000000..43b798c9f --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/shaders/brand_plate.frag @@ -0,0 +1,42 @@ +#version 440 + +// The logo's gradient plate, drawn by us rather than the baked-in SVG: a +// linear blend between the two brand appColors (light AccentSoft at the +// top-left grading to dark AccentStrong at the bottom-right, mirroring +// cockatrice.svg's linearGradient4265-7-8 userSpaceOnUse axis), clipped to +// the bird's full silhouette via the full-color logo's alpha (uSilhouette). +// The white highlight path (cockatrice-logo-white) is overlaid in QML on top, +// exactly as the SVG stacks its white path over the gradient paths. Fully +// static: no glow, no breathing — the plate just sits there like the home +// widget's QPainter composite. + +layout(location = 0) in vec2 qt_TexCoord0; +layout(location = 0) out vec4 fragColor; + +layout(std140, binding = 0) uniform buf +{ + mat4 qt_Matrix; + float qt_Opacity; + vec4 uStrong; + vec4 uSoft; +}; + +// The full-color logo's alpha channel acts as the silhouette mask: the +// gradient only appears inside the bird, exactly like the SVG's gradient paths. +layout(binding = 1) uniform sampler2D uSilhouette; + +void main() +{ + // Recreate cockatrice.svg's own gradient geometry (linearGradient4265-7-8, + // userSpaceOnUse): light AccentSoft at the start point S=(-8.097,-97.746), + // dark AccentStrong at the end E=(162.455,295.208), on the SVG's 300x300 + // canvas. Normalized to UV space, V=E-S=(0.5685,1.3098), so + // t = dot(uv - S_norm, V)/|V|^2 with S_norm=(-0.0270,-0.3258). + float t = clamp(dot(qt_TexCoord0 - vec2(-0.02699, -0.32582), vec2(0.56851, 1.30985)) / 2.03891, 0.0, 1.0); + vec3 color = mix(uSoft.rgb, uStrong.rgb, t); + + // Anti-aliased silhouette clip from the full-color logo's alpha. + float alpha = texture(uSilhouette, qt_TexCoord0).a; + + fragColor = vec4(color * alpha, alpha) * qt_Opacity; +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/onboarding/step_indicator_widget.cpp b/cockatrice/src/interface/widgets/onboarding/step_indicator_widget.cpp new file mode 100644 index 000000000..d25e8544b --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/step_indicator_widget.cpp @@ -0,0 +1,84 @@ +#include "step_indicator_widget.h" + +#include +#include + +StepIndicatorWidget::StepIndicatorWidget(QWidget *parent) : QWidget(parent) +{ + setFixedHeight(kDotDiameter + 2 * kVerticalMargin); +} + +void StepIndicatorWidget::setStepCount(int count) +{ + stepCount = qMax(0, count); + currentStep = qBound(0, currentStep, qMax(0, stepCount - 1)); + updateGeometry(); + update(); +} + +void StepIndicatorWidget::setCurrentStep(int index) +{ + if (stepCount == 0) { + return; + } + currentStep = qBound(0, index, stepCount - 1); + update(); +} + +QSize StepIndicatorWidget::sizeHint() const +{ + return minimumSizeHint(); +} + +QSize StepIndicatorWidget::minimumSizeHint() const +{ + if (stepCount == 0) { + return QSize(0, height()); + } + int width = kActiveDotWidth + (stepCount - 1) * kDotDiameter + (stepCount - 1) * kDotSpacing; + return QSize(width, height()); +} + +void StepIndicatorWidget::paintEvent(QPaintEvent * /*event*/) +{ + if (stepCount == 0) { + return; + } + + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing); + + const QColor activeColor = palette().color(QPalette::Highlight); + + // QPalette::Mid alpha-blended against a dark Window background reads as + // near-invisible (Mid is itself a dark grey in dark palettes -- see + // PaletteGenerator's satShadeLo/Dark roles). WindowText is guaranteed to + // contrast against Window in any theme by definition, so alpha-blending + // *that* instead keeps the dots visibly dim-but-present in both light and + // dark schemes. Same trick PaletteGenerator uses for placeholder text. + QColor inactiveColor = palette().color(QPalette::WindowText); + inactiveColor.setAlpha(100); + + int totalWidth = 0; + for (int i = 0; i < stepCount; ++i) { + totalWidth += (i == currentStep) ? kActiveDotWidth : kDotDiameter; + if (i > 0) { + totalWidth += kDotSpacing; + } + } + + int x = (width() - totalWidth) / 2; + const int y = height() / 2; + + for (int i = 0; i < stepCount; ++i) { + const bool active = (i == currentStep); + const int dotWidth = active ? kActiveDotWidth : kDotDiameter; + + QPainterPath path; + QRectF rect(x, y - kDotDiameter / 2.0, dotWidth, kDotDiameter); + path.addRoundedRect(rect, kDotDiameter / 2.0, kDotDiameter / 2.0); + painter.fillPath(path, active ? activeColor : inactiveColor); + + x += dotWidth + kDotSpacing; + } +} diff --git a/cockatrice/src/interface/widgets/onboarding/step_indicator_widget.h b/cockatrice/src/interface/widgets/onboarding/step_indicator_widget.h new file mode 100644 index 000000000..1b85be04f --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/step_indicator_widget.h @@ -0,0 +1,34 @@ +#ifndef STEP_INDICATOR_WIDGET_H +#define STEP_INDICATOR_WIDGET_H + +#include + +/** @brief Row of dots showing progress through a fixed-length sequence of steps, + * in the style of a mobile/OS setup flow. Purely presentational. */ +class StepIndicatorWidget : public QWidget +{ + Q_OBJECT + +public: + explicit StepIndicatorWidget(QWidget *parent = nullptr); + + void setStepCount(int count); + void setCurrentStep(int index); + + QSize sizeHint() const override; + QSize minimumSizeHint() const override; + +protected: + void paintEvent(QPaintEvent *event) override; + +private: + int stepCount = 0; + int currentStep = 0; + + static constexpr int kDotDiameter = 8; + static constexpr int kActiveDotWidth = 22; + static constexpr int kDotSpacing = 10; + static constexpr int kVerticalMargin = 6; +}; + +#endif // STEP_INDICATOR_WIDGET_H diff --git a/cockatrice/src/interface/widgets/playmat/playmat_collection_dialog.cpp b/cockatrice/src/interface/widgets/playmat/playmat_collection_dialog.cpp new file mode 100644 index 000000000..e970f8413 --- /dev/null +++ b/cockatrice/src/interface/widgets/playmat/playmat_collection_dialog.cpp @@ -0,0 +1,188 @@ +#include "playmat_collection_dialog.h" + +#include "../../../client/settings/cache_settings.h" +#include "playmat_settings_dialog.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +PlaymatCollectionDialog::PlaymatCollectionDialog(QWidget *parent) : QDialog(parent) +{ + setMinimumWidth(420); + setupUi(); + retranslateUi(); +} + +void PlaymatCollectionDialog::accept() +{ + auto &interfaceSettings = SettingsCache::instance().userInterface(); + interfaceSettings.setPlaymatFallbackList(playmats); + interfaceSettings.setPlaymatFallbackBehavior(modeCombo->currentData().toInt()); + QDialog::accept(); +} + +int PlaymatCollectionDialog::currentRow() const +{ + return playmatList->currentRow(); +} + +void PlaymatCollectionDialog::setupUi() +{ + auto &interfaceSettings = SettingsCache::instance().userInterface(); + playmats = interfaceSettings.getPlaymatFallbackList(); + + playmatList = new QListWidget; + for (const PlaymatInfo &entry : playmats) { + playmatList->addItem(entry.card.name); + } + connect(playmatList, &QListWidget::itemSelectionChanged, this, &PlaymatCollectionDialog::selectionChanged); + connect(playmatList, &QListWidget::itemDoubleClicked, this, [this](QListWidgetItem *) { editPlaymat(); }); + + addButton = new QPushButton; + editButton = new QPushButton; + removeButton = new QPushButton; + moveUpButton = new QPushButton; + moveDownButton = new QPushButton; + + connect(addButton, &QPushButton::clicked, this, &PlaymatCollectionDialog::addPlaymat); + connect(editButton, &QPushButton::clicked, this, &PlaymatCollectionDialog::editPlaymat); + connect(removeButton, &QPushButton::clicked, this, &PlaymatCollectionDialog::removePlaymat); + connect(moveUpButton, &QPushButton::clicked, this, &PlaymatCollectionDialog::movePlaymatUp); + connect(moveDownButton, &QPushButton::clicked, this, &PlaymatCollectionDialog::movePlaymatDown); + + auto *listButtons = new QVBoxLayout; + listButtons->addWidget(addButton); + listButtons->addWidget(editButton); + listButtons->addWidget(removeButton); + listButtons->addWidget(moveUpButton); + listButtons->addWidget(moveDownButton); + listButtons->addStretch(); + + auto *listRow = new QHBoxLayout; + listRow->addWidget(playmatList, 1); + listRow->addLayout(listButtons); + + modeCombo = new QComboBox; + modeCombo->addItem(QString(), PlaymatFallbackModeFixed); + modeCombo->addItem(QString(), PlaymatFallbackModeRoundRobin); + modeCombo->addItem(QString(), PlaymatFallbackModeRandom); + const int modeIndex = modeCombo->findData(interfaceSettings.getPlaymatFallbackBehavior()); + if (modeIndex >= 0) { + modeCombo->setCurrentIndex(modeIndex); + } + + auto *modeRow = new QHBoxLayout; + modeLabel = new QLabel; + modeLabel->setBuddy(modeCombo); + modeRow->addWidget(modeLabel); + modeRow->addWidget(modeCombo, 1); + + auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); + connect(buttonBox, &QDialogButtonBox::accepted, this, &PlaymatCollectionDialog::accept); + connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); + + auto *root = new QVBoxLayout; + root->addLayout(listRow); + root->addLayout(modeRow); + root->addWidget(buttonBox); + setLayout(root); + + selectionChanged(); +} + +void PlaymatCollectionDialog::selectionChanged() +{ + const bool hasSelection = playmatList->currentRow() >= 0; + editButton->setEnabled(hasSelection); + removeButton->setEnabled(hasSelection); + moveUpButton->setEnabled(hasSelection && playmatList->currentRow() > 0); + moveDownButton->setEnabled(hasSelection && playmatList->currentRow() < playmatList->count() - 1); +} + +void PlaymatCollectionDialog::addPlaymat() +{ + PlaymatSettingsDialog dialog(CardRef{}, PlaymatParams{}, this); + if (dialog.exec() == QDialog::Accepted) { + const CardRef card = dialog.card(); + if (!card.isEmpty()) { + PlaymatInfo res = {card, dialog.params()}; + playmats.append(res); + playmatList->addItem(res.card.name); + playmatList->setCurrentRow(playmatList->count() - 1); + } + } +} + +void PlaymatCollectionDialog::editPlaymat() +{ + const int row = currentRow(); + if (row < 0) { + return; + } + + const PlaymatInfo ¤t = playmats.at(row); + PlaymatSettingsDialog dialog(current.card, current.params, this); + if (dialog.exec() == QDialog::Accepted) { + const CardRef card = dialog.card(); + if (card.isEmpty()) { + return; // Removal is handled by the Remove button + } + playmats[row] = {card, dialog.params()}; + playmatList->item(row)->setText(card.name); + } +} + +void PlaymatCollectionDialog::removePlaymat() +{ + const int row = currentRow(); + if (row < 0) { + return; + } + playmats.removeAt(row); + delete playmatList->takeItem(row); + selectionChanged(); +} + +void PlaymatCollectionDialog::movePlaymatUp() +{ + const int row = currentRow(); + if (row <= 0) { + return; + } + playmats.swapItemsAt(row, row - 1); + playmatList->insertItem(row - 1, playmatList->takeItem(row)); + playmatList->setCurrentRow(row - 1); + selectionChanged(); +} + +void PlaymatCollectionDialog::movePlaymatDown() +{ + const int row = currentRow(); + if (row < 0 || row >= playmats.size() - 1) { + return; + } + playmats.swapItemsAt(row, row + 1); + playmatList->insertItem(row + 1, playmatList->takeItem(row)); + playmatList->setCurrentRow(row + 1); + selectionChanged(); +} + +void PlaymatCollectionDialog::retranslateUi() +{ + setWindowTitle(tr("Default Playmats")); + addButton->setText(tr("Add...")); + editButton->setText(tr("Edit...")); + removeButton->setText(tr("Remove")); + moveUpButton->setText(tr("Move Up")); + moveDownButton->setText(tr("Move Down")); + modeLabel->setText(tr("List mode:")); + modeCombo->setItemText(0, tr("Fixed (always the first entry)")); + modeCombo->setItemText(1, tr("Round-robin (cycle through entries)")); + modeCombo->setItemText(2, tr("Random (pick one per game)")); +} diff --git a/cockatrice/src/interface/widgets/playmat/playmat_collection_dialog.h b/cockatrice/src/interface/widgets/playmat/playmat_collection_dialog.h new file mode 100644 index 000000000..167893f8e --- /dev/null +++ b/cockatrice/src/interface/widgets/playmat/playmat_collection_dialog.h @@ -0,0 +1,54 @@ +#ifndef COCKATRICE_PLAYMAT_COLLECTION_DIALOG_H +#define COCKATRICE_PLAYMAT_COLLECTION_DIALOG_H + +#include +#include + +class QComboBox; +class QLabel; +class QListWidget; +class QListWidgetItem; +class QPushButton; + +/** + * @brief Dialog for editing the user-level playmat collection. + * + * The collection is the fallback used when a deck has no playmat of its own. + * It supports multiple entries and a pick mode (always first / round-robin / + * random). The dialog edits a working copy and writes it to the settings only + * when accepted. + */ +class PlaymatCollectionDialog : public QDialog +{ + Q_OBJECT + +public: + explicit PlaymatCollectionDialog(QWidget *parent = nullptr); + + void accept() override; + +private slots: + void addPlaymat(); + void editPlaymat(); + void removePlaymat(); + void movePlaymatUp(); + void movePlaymatDown(); + void selectionChanged(); + +private: + void setupUi(); + void retranslateUi(); + int currentRow() const; + + QList playmats; ///< Working copy edited by the dialog. + QListWidget *playmatList; + QComboBox *modeCombo; + QLabel *modeLabel; + QPushButton *addButton; + QPushButton *editButton; + QPushButton *removeButton; + QPushButton *moveUpButton; + QPushButton *moveDownButton; +}; + +#endif // COCKATRICE_PLAYMAT_COLLECTION_DIALOG_H diff --git a/cockatrice/src/interface/widgets/playmat/playmat_preview_widget.cpp b/cockatrice/src/interface/widgets/playmat/playmat_preview_widget.cpp new file mode 100644 index 000000000..130daadf4 --- /dev/null +++ b/cockatrice/src/interface/widgets/playmat/playmat_preview_widget.cpp @@ -0,0 +1,381 @@ +#include "playmat_preview_widget.h" + +#include "../cards/art_crop_attribution.h" +#include "playmat_utils.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +// Mirrors the dialog/proto clamps so gestures can never produce an +// out of range parameter. The zoom FLOOR is dynamic: see +// playmatClampedZoom(), zooming out stops where the sampling window would +// exceed the card itself, so there is no dead range at the bottom end. +constexpr qreal MAX_MARGIN = 0.95; +// Range of in game stack+table aspect ratios worth designing for, derived +// from PlayerGraphicsItem::paint()'s combinedArea = stack ∪ table: +// height = 10 + 30 + 3*102 + 2*30 = 406 (TableZone rows) +// width = 1.5*72 + (20 + 5*72 + 15) = 503 (StackZone + TableZone +// at MIN_WIDTH) +// The area's shape depends on GAME CONTENT (played card columns widen the +// table by ~107 px each), not on the window size. Fresh board ≈ 503/406, +// a table grown to roughly double its minimum width ≈ 2.2. +constexpr qreal MIN_TABLE_ASPECT = 503.0 / 406.0; // fresh board: most generous framing +constexpr qreal MAX_TABLE_ASPECT = 2.2; // well developed, wide table +// Keyboard nudge steps (viewport convention: Down looks further down). +constexpr qreal KEY_PAN_MARGIN_STEP = 0.005; +constexpr qreal KEY_PAN_OFFSET_STEP = 0.01; +constexpr qreal KEY_ZOOM_STEP = 1.05; +constexpr qreal WHEEL_ZOOM_BASE = 1.15; // zoom factor per wheel notch +} // namespace + +PlaymatPreviewWidget::PlaymatPreviewWidget(QWidget *parent) : QWidget(parent) +{ + // The crop is square and drawn contain fit, so the height decides its + // on screen size, keep it generous but let the dialog compress on small + // or high DPI screens + setMinimumSize(400, 180); + QSizePolicy sp(QSizePolicy::Expanding, QSizePolicy::Expanding); + setSizePolicy(sp); + setFocusPolicy(Qt::StrongFocus); + setCursor(Qt::OpenHandCursor); + setAccessibleName(tr("Playmat crop")); + setAccessibleDescription(tr("Zoom %1×").arg(QString::number(params.zoom, 'f', 2))); +} + +void PlaymatPreviewWidget::setPixmap(const QPixmap &pixmap) +{ + sourcePixmap = pixmap; + setCursor(sourcePixmap.isNull() ? Qt::ArrowCursor : Qt::OpenHandCursor); + update(); +} + +void PlaymatPreviewWidget::setParams(const PlaymatParams &p) +{ + params = p; + setAccessibleDescription(tr("Zoom %1×").arg(QString::number(params.zoom, 'f', 2))); + update(); +} + +void PlaymatPreviewWidget::setAttribution(const QString &attribution) +{ + attributionText = attribution; + update(); +} + +QRectF PlaymatPreviewWidget::activePlayArea() const +{ + // The viewport is a frame shaped like a fresh board's stack+table area + // (kMinTableAspect): the most generous framing the game will produce. + // Dimmed strips mark where a wider, developed table crops further. + const QRectF cardRect = QRectF(rect()).adjusted(3, 2, -3, -2); + return PlaymatUtils::aspectFitRect(cardRect.adjusted(6, 4, -4, -4), MIN_TABLE_ASPECT); +} + +qreal PlaymatPreviewWidget::samplingWindowSide() const +{ + if (sourcePixmap.isNull()) { + return 0.0; + } + // Same clamped window the render path uses, gestures and painting must + // never disagree about geometry. + return PlaymatUtils::playmatWindowSide(sourcePixmap.size(), params); +} + +qreal PlaymatPreviewWidget::widgetToSourceScale() const +{ + const qreal cropSide = samplingWindowSide(); + const QRectF area = activePlayArea(); + if (cropSide <= 0.0 || area.isEmpty()) { + return 0.0; + } + // Mirror coverFitRect(): the square crop into the (wider) viewport fills + // its width. + return area.width() / cropSide; +} + +void PlaymatPreviewWidget::applyCropDelta(qreal dMarginL, qreal dMarginR, qreal dOffset, qreal zoomFactor) +{ + PlaymatParams next = params; + if (dMarginL + dMarginR == 0.0) { + // Pure horizontal pan rebalances the margins along their + // sum constant segment. Individual bounds must not break that + // invariant, otherwise repeated corner drags let one margin grow + // without end, collapsing the viewing window and desynchronizing the + // visual zoom from the readout. + const qreal sum = params.marginPctL + params.marginPctR; + const qreal lo = qMax(0.0, sum - MAX_MARGIN); + const qreal hi = qMin(sum, MAX_MARGIN); + next.marginPctL = qBound(lo, params.marginPctL + dMarginL, hi); + next.marginPctR = sum - next.marginPctL; + } else { + next.marginPctL = qBound(0.0, params.marginPctL + dMarginL, MAX_MARGIN); + next.marginPctR = qBound(0.0, params.marginPctR + dMarginR, MAX_MARGIN); + } + next.verticalOffset = qBound(0.0, params.verticalOffset + dOffset, 1.0); + // Clamp through the shared helper so the floor tracks the new margins: + // zooming out stops exactly where the window reaches the card bounds. + next.zoom = params.zoom * zoomFactor; + if (!sourcePixmap.isNull()) { + next.zoom = PlaymatUtils::playmatClampedZoom(sourcePixmap.size(), next); + } + + if (sameCrop(next, params)) { + return; + } + + params = next; + setAccessibleDescription(tr("Zoom %1×").arg(QString::number(params.zoom, 'f', 2))); + update(); + emit paramsEdited(params); +} + +bool PlaymatPreviewWidget::sameCrop(const PlaymatParams &a, const PlaymatParams &b) const +{ + // Exact comparison on purpose: clamped assignments yield identical bits, + // while qFuzzyCompare based equality misbehaves around zero + return a.marginPctL == b.marginPctL && a.marginPctR == b.marginPctR && a.verticalOffset == b.verticalOffset && + a.zoom == b.zoom; +} + +void PlaymatPreviewWidget::restoreSnapshot() +{ + // The snapshot only ever holds values that passed the gesture clamps, + // so it is safe to restore verbatim + if (sameCrop(paramsAtFocusIn, params)) { + return; + } + params = paramsAtFocusIn; + setAccessibleDescription(tr("Zoom %1×").arg(QString::number(params.zoom, 'f', 2))); + update(); + emit paramsEdited(params); +} + +void PlaymatPreviewWidget::paintEvent(QPaintEvent *) +{ + QPainter painter(this); + painter.setRenderHints(QPainter::Antialiasing | QPainter::SmoothPixmapTransform | QPainter::TextAntialiasing); + + const QRect rect = this->rect(); + const QColor accentColor(100, 116, 139); + + // Background + const QRectF cardRect = QRectF(rect).adjusted(3, 2, -3, -2); + QLinearGradient bg(cardRect.topLeft(), cardRect.topRight()); + bg.setColorAt(0, accentColor.darker(320)); + bg.setColorAt(1, QColor(18, 22, 30)); + painter.setPen(Qt::NoPen); + painter.setBrush(bg); + painter.drawRoundedRect(cardRect, 6, 6); + painter.setBrush(accentColor); + painter.drawRoundedRect(QRectF(cardRect.left(), cardRect.top(), 3, cardRect.height()), 2, 2); + + if (sourcePixmap.isNull()) { + painter.setPen(QColor(150, 150, 150)); + painter.drawText(rect, Qt::AlignCenter, tr("No card selected")); + return; + } + + const QRectF playArea = activePlayArea(); + + // Exactly the game's pipeline (player_graphics_item): cover fit the crop + // into the table shaped viewport, centered, so the frame shows precisely + // what a minimum aspect window shows, and dragging moves the art behind + // the fixed frame. + const QRectF srcRect = PlaymatUtils::computeArtSourceRect(sourcePixmap.size(), params); + const QRectF dstRect = PlaymatUtils::coverFitRect(playArea, srcRect.size()); + + painter.setClipRect(playArea.toRect()); + painter.drawPixmap(dstRect, sourcePixmap, srcRect); + + // Wider (developed) tables crop further: mark where a kMaxTableAspect + // board stops. Palette driven so theme authors can recolor the markers. + const qreal wideBandHeight = playArea.height() * (MIN_TABLE_ASPECT / MAX_TABLE_ASPECT); + const qreal stripHeight = (playArea.height() - wideBandHeight) / 2.0; + QColor stripColor = palette().color(QPalette::Window); + stripColor.setAlpha(150); + painter.fillRect(QRectF(playArea.left(), playArea.top(), playArea.width(), stripHeight), stripColor); + painter.fillRect(QRectF(playArea.left(), playArea.bottom() - stripHeight, playArea.width(), stripHeight), + stripColor); + QColor hairlineColor = palette().color(QPalette::Highlight); + hairlineColor.setAlpha(110); + painter.setPen(QPen(hairlineColor, 1)); + painter.drawLine(QPointF(playArea.left(), playArea.top() + stripHeight), + QPointF(playArea.right(), playArea.top() + stripHeight)); + painter.drawLine(QPointF(playArea.left(), playArea.bottom() - stripHeight), + QPointF(playArea.right(), playArea.bottom() - stripHeight)); + + // Draw zone divider: stack is roughly the left portion + const double stackWidthRatio = 0.18; // Stack is about 18% of total play area + const double stackDividerX = playArea.left() + playArea.width() * stackWidthRatio; + + // Subtle semi transparent overlays to distinguish zones + // Stack zone overlay (slightly darker) + QRectF stackOverlay(playArea.left(), playArea.top(), playArea.width() * stackWidthRatio, playArea.height()); + painter.fillRect(stackOverlay, QColor(0, 0, 0, 40)); + + // Table zone overlay (very subtle) + QRectF tableOverlay(stackDividerX, playArea.top(), playArea.width() * (1.0 - stackWidthRatio), playArea.height()); + painter.fillRect(tableOverlay, QColor(0, 0, 0, 20)); + + // Zone divider line + painter.setPen(QPen(QColor(255, 255, 255, 50), 1)); + painter.drawLine(QPointF(stackDividerX, playArea.top()), QPointF(stackDividerX, playArea.bottom())); + + // Land divider line (about 60% down the table area) + const double landDividerY = playArea.top() + playArea.height() * 0.65; + painter.setPen(QPen(QColor(255, 255, 255, 30), 1)); + painter.drawLine(QPointF(stackDividerX, landDividerY), QPointF(playArea.right(), landDividerY)); + painter.setClipping(false); + + // Border around the viewport = boundary of every plausible framing. + painter.setPen(QPen(QColor(70, 80, 95, 120), 1)); + painter.setBrush(Qt::NoBrush); + painter.drawRoundedRect(playArea.adjusted(0, 0, -1, -1), 3, 3); + + // Visible keyboard focus per the focus cursor contract, Tab must show + // where the keys land + if (hasFocus()) { + QPen focusPen(palette().color(QPalette::Highlight), 2); + painter.setPen(focusPen); + painter.drawRoundedRect(playArea.adjusted(-1, -1, 1, 1), 3, 3); + } + + paintArtAttribution(painter, playArea, attributionText, Qt::AlignRight | Qt::AlignBottom, 0.8); + + // Zoom readout so the gesture has a visible, stable counterpart. + QColor ink = palette().color(QPalette::WindowText); + ink.setAlpha(160); + painter.setPen(ink); + painter.drawText(QPointF(playArea.left() + 8, playArea.bottom() - 8), + tr("Zoom %1×").arg(QString::number(params.zoom, 'f', 2))); +} + +void PlaymatPreviewWidget::mousePressEvent(QMouseEvent *event) +{ + if (event->button() != Qt::LeftButton || sourcePixmap.isNull() || samplingWindowSide() <= 0.0) { + QWidget::mousePressEvent(event); + return; + } + lastDragPos = event->pos(); + setCursor(Qt::ClosedHandCursor); + event->accept(); +} + +void PlaymatPreviewWidget::mouseMoveEvent(QMouseEvent *event) +{ + if (!(event->buttons() & Qt::LeftButton) || sourcePixmap.isNull()) { + QWidget::mouseMoveEvent(event); + return; + } + + const QPointF delta = QPointF(event->pos() - lastDragPos); + lastDragPos = event->pos(); + + const qreal scale = widgetToSourceScale(); + // Vertical travel of the SAMPLING window: verticalOffset moves its top + // edge by exactly this much per unit, identical to the render path. + // Windows taller than the art (square/landscape sources zoomed out) + // leave no travel, vertical drags are then boundary no ops. + const qreal travel = static_cast(sourcePixmap.height()) - samplingWindowSide(); + if (scale <= 0.0) { + event->accept(); + return; + } + + // Dragging moves the ART with the cursor, so the viewing window slides the + // other way. Horizontal panning rebalances the margins (their sum, hence + // the window width, stays constant), vertical panning moves the window's + // top edge within its available travel. + const qreal sourceW = sourcePixmap.width(); + const qreal dMargin = -(delta.x() / scale) / sourceW; + const qreal dOffset = travel > 0.5 ? -(delta.y() / scale) / travel : 0.0; + + applyCropDelta(dMargin, -dMargin, dOffset, 1.0); + event->accept(); +} + +void PlaymatPreviewWidget::mouseReleaseEvent(QMouseEvent *event) +{ + if (event->button() == Qt::LeftButton) { + setCursor(sourcePixmap.isNull() ? Qt::ArrowCursor : Qt::OpenHandCursor); + event->accept(); + return; + } + QWidget::mouseReleaseEvent(event); +} + +void PlaymatPreviewWidget::wheelEvent(QWheelEvent *event) +{ + if (sourcePixmap.isNull() || samplingWindowSide() <= 0.0) { + QWidget::wheelEvent(event); + return; + } + const qreal notches = static_cast(event->angleDelta().y()) / 120.0; + if (notches == 0.0) { + event->accept(); + return; + } + applyCropDelta(0.0, 0.0, 0.0, std::pow(WHEEL_ZOOM_BASE, notches)); + event->accept(); +} + +void PlaymatPreviewWidget::keyPressEvent(QKeyEvent *event) +{ + if (sourcePixmap.isNull()) { + QWidget::keyPressEvent(event); + return; + } + + switch (event->key()) { + case Qt::Key_Escape: + if (sameCrop(params, paramsAtFocusIn)) { + // Nothing to undo on this surface, let the event reach the + // dialog so Esc keeps its close meaning there + QWidget::keyPressEvent(event); + return; + } + restoreSnapshot(); + break; + case Qt::Key_Backspace: + restoreSnapshot(); + break; + case Qt::Key_Left: + applyCropDelta(-KEY_PAN_MARGIN_STEP, KEY_PAN_MARGIN_STEP, 0.0, 1.0); + break; + case Qt::Key_Right: + applyCropDelta(KEY_PAN_MARGIN_STEP, -KEY_PAN_MARGIN_STEP, 0.0, 1.0); + break; + case Qt::Key_Up: + applyCropDelta(0.0, 0.0, -KEY_PAN_OFFSET_STEP, 1.0); + break; + case Qt::Key_Down: + applyCropDelta(0.0, 0.0, KEY_PAN_OFFSET_STEP, 1.0); + break; + case Qt::Key_Plus: + case Qt::Key_Equal: + applyCropDelta(0.0, 0.0, 0.0, KEY_ZOOM_STEP); + break; + case Qt::Key_Minus: + applyCropDelta(0.0, 0.0, 0.0, 1.0 / KEY_ZOOM_STEP); + break; + default: + QWidget::keyPressEvent(event); + return; + } + event->accept(); +} + +void PlaymatPreviewWidget::focusInEvent(QFocusEvent *event) +{ + // Snapshot for the Esc or Backspace reset, restoring whatever the user + // had when the surface took focus + paramsAtFocusIn = params; + QWidget::focusInEvent(event); +} diff --git a/cockatrice/src/interface/widgets/playmat/playmat_preview_widget.h b/cockatrice/src/interface/widgets/playmat/playmat_preview_widget.h new file mode 100644 index 000000000..88b9ec41d --- /dev/null +++ b/cockatrice/src/interface/widgets/playmat/playmat_preview_widget.h @@ -0,0 +1,61 @@ +#ifndef COCKATRICE_PLAYMAT_PREVIEW_WIDGET_H +#define COCKATRICE_PLAYMAT_PREVIEW_WIDGET_H + +#include +#include +#include +#include + +/** + * @brief Interactive crop surface showing how a playmat card art will appear + * across the combined table + stack play area. + * + * Renders a fixed frame shaped like a fresh board's stack+table area (the + * most generous framing the game produces): it shows the tallest slice of + * the square crop in normal play, with dimmed strips marking where a wider, + * developed table crops further, exactly the game's own render pipeline. + * The widget doubles as the editor's primary crop control: dragging pans the + * art behind the frame, the wheel zooms, and arrow keys nudge, mirroring + * the stored parameters (margins pan horizontally, verticalOffset + * vertically, zoom scales) so no separate numeric controls are needed. + */ +class PlaymatPreviewWidget : public QWidget +{ + Q_OBJECT + +public: + explicit PlaymatPreviewWidget(QWidget *parent = nullptr); + + void setPixmap(const QPixmap &pixmap); + void setParams(const PlaymatParams ¶ms); + void setAttribution(const QString &attribution); + +signals: + /** @brief Emitted whenever direct manipulation (drag, wheel, keys) changes the crop parameters. */ + void paramsEdited(const PlaymatParams ¶ms); + +protected: + void paintEvent(QPaintEvent *event) override; + void mousePressEvent(QMouseEvent *event) override; + void mouseMoveEvent(QMouseEvent *event) override; + void mouseReleaseEvent(QMouseEvent *event) override; + void wheelEvent(QWheelEvent *event) override; + void keyPressEvent(QKeyEvent *event) override; + void focusInEvent(QFocusEvent *event) override; + +private: + QRectF activePlayArea() const; ///< destination rect used for rendering AND gesture math + qreal samplingWindowSide() const; ///< clamped square window side, shared with the render path + qreal widgetToSourceScale() const; + void applyCropDelta(qreal dMarginL, qreal dMarginR, qreal dOffset, qreal zoomFactor); + bool sameCrop(const PlaymatParams &a, const PlaymatParams &b) const; + void restoreSnapshot(); + + QPixmap sourcePixmap; + PlaymatParams params; + PlaymatParams paramsAtFocusIn; ///< crop as of the latest focus gain, restored by Esc or Backspace + QString attributionText; + QPoint lastDragPos; ///< widget space position of the previous mouse move while panning +}; + +#endif // COCKATRICE_PLAYMAT_PREVIEW_WIDGET_H diff --git a/cockatrice/src/interface/widgets/playmat/playmat_settings_dialog.cpp b/cockatrice/src/interface/widgets/playmat/playmat_settings_dialog.cpp new file mode 100644 index 000000000..9459c5ea9 --- /dev/null +++ b/cockatrice/src/interface/widgets/playmat/playmat_settings_dialog.cpp @@ -0,0 +1,325 @@ +#include "playmat_settings_dialog.h" + +#include "../../card_picture_loader/card_picture_loader.h" +#include "../cards/art_crop_attribution.h" +#include "../cards/card_art_utils.h" +#include "../utility/completer_utils.h" +#include "card_database_display_model.h" +#include "card_database_model.h" +#include "playmat_preview_widget.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +PlaymatSettingsDialog::PlaymatSettingsDialog(const CardRef &initialCard, + const PlaymatParams &initialParams, + QWidget *parent) + : QDialog(parent), currentCard(initialCard), currentParams(initialParams) +{ + setMinimumWidth(500); + setupUi(); + + // Seed UI from initial values + if (!initialCard.name.isEmpty()) { + searchBar->setText(initialCard.name); + onCardNameChanged(initialCard.name); + + // onCardNameChanged leaves the printing combo on the first printing in + // the database, which would silently change the deck's stored playmat + // card on accept. Restore the stored printing when it resolves locally. + const int storedPrintingIndex = providerComboBox->findData(initialCard.providerId); + if (storedPrintingIndex != -1) { + providerComboBox->setCurrentIndex(storedPrintingIndex); + } else { + // Stored printing not in the local database: keep it rather than + // silently substituting the first printing. + currentCard.providerId = initialCard.providerId; + reloadPreview(); + } + } + + retranslateUi(); +} + +CardRef PlaymatSettingsDialog::card() const +{ + return currentCard; +} + +PlaymatParams PlaymatSettingsDialog::params() const +{ + return currentParams; +} + +QDoubleSpinBox *PlaymatSettingsDialog::makeSpinBox(double min, double max, double value, double step) +{ + auto *spin = new QDoubleSpinBox; + spin->setRange(min, max); + spin->setSingleStep(step); + spin->setDecimals(3); + spin->setValue(value); + return spin; +} + +void PlaymatSettingsDialog::initializeSearchBar() +{ + searchBar = new QLineEdit; + + cardDatabaseModel = new CardDatabaseModel(CardDatabaseManager::getInstance(), false, this); + cardDatabaseDisplayModel = new CardDatabaseDisplayModel(this); + cardDatabaseDisplayModel->setSourceModel(cardDatabaseModel); + + const CardCompleterSetup cardSetup = createCardCompleter(cardDatabaseDisplayModel, this, 15); + searchModel = cardSetup.searchModel; + proxyModel = cardSetup.proxyModel; + completer = cardSetup.completer; + searchBar->setCompleter(completer); + + connectCardCompleterSearch(searchBar, cardSetup); + + connect(completer, static_cast(&QCompleter::activated), this, + [this](const QString &completion) { + if (searchBar->text() != completion) { + searchBar->setText(completion); + searchBar->setCursorPosition(searchBar->text().length()); + } + onCardNameChanged(completion); + }); + + connect(searchBar, &QLineEdit::returnPressed, this, [this]() { onCardNameChanged(searchBar->text()); }); +} + +void PlaymatSettingsDialog::setupUi() +{ + initializeSearchBar(); + + providerComboBox = new QComboBox; + connect(providerComboBox, &QComboBox::currentIndexChanged, this, [this]() { + currentCard.providerId = providerComboBox->currentData().toString(); + reloadPreview(); + }); + + auto *form = new QFormLayout; + controlsForm = form; + cardNameLabel = new QLabel; + printingLabel = new QLabel; + form->addRow(cardNameLabel, searchBar); + form->addRow(printingLabel, providerComboBox); + + // Numerical editors expose the raw PlaymatParams for precise input. They + // share the same form as the rows above so every field lines up on one + // label column. They stay hidden until requested since the crop surface + // is the primary control. + marginLSpin = makeSpinBox(0.0, 0.95, currentParams.marginPctL, 0.01); + marginRSpin = makeSpinBox(0.0, 0.95, currentParams.marginPctR, 0.01); + verticalOffsetSpin = makeSpinBox(0.0, 1.0, currentParams.verticalOffset, 0.01); + zoomSpin = makeSpinBox(0.1, 4.0, currentParams.zoom, 0.05); + + leftMarginLabel = new QLabel; + rightMarginLabel = new QLabel; + verticalOffsetLabel = new QLabel; + zoomLabel = new QLabel; + + showNumericEditorsCheck = new QCheckBox; + + form->addRow(showNumericEditorsCheck); + form->addRow(leftMarginLabel, marginLSpin); + form->addRow(rightMarginLabel, marginRSpin); + form->addRow(verticalOffsetLabel, verticalOffsetSpin); + form->addRow(zoomLabel, zoomSpin); + + controlsGroup = new QGroupBox; + controlsGroup->setLayout(form); + + preview = new PlaymatPreviewWidget; + preview->setParams(currentParams); + + auto *previewLayout = new QVBoxLayout; + previewLayout->addWidget(preview); + previewCaptionLabel = new QLabel; + previewCaptionLabel->setAlignment(Qt::AlignCenter); + previewCaptionLabel->setWordWrap(true); + previewLayout->addWidget(previewCaptionLabel); + previewGroup = new QGroupBox; + previewGroup->setLayout(previewLayout); + + auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); + removeButton = new QPushButton; + buttons->addButton(removeButton, QDialogButtonBox::ResetRole); + + connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); + connect(removeButton, &QPushButton::clicked, this, [this]() { + currentCard = CardRef{}; // empty signals removal + accept(); + }); + + // The crop surface is the primary control: dragging pans, wheel/keys zoom, + // editing exactly the same stored parameters the numeric fields do. + connect(preview, &PlaymatPreviewWidget::paramsEdited, this, [this](const PlaymatParams &edited) { + currentParams = edited; + + QSignalBlocker blockMarginL(marginLSpin); + QSignalBlocker blockMarginR(marginRSpin); + QSignalBlocker blockOffset(verticalOffsetSpin); + QSignalBlocker blockZoom(zoomSpin); + marginLSpin->setValue(edited.marginPctL); + marginRSpin->setValue(edited.marginPctR); + verticalOffsetSpin->setValue(edited.verticalOffset); + zoomSpin->setValue(edited.zoom); + }); + + connect(showNumericEditorsCheck, &QCheckBox::toggled, this, &PlaymatSettingsDialog::setNumericEditorsVisible); + setNumericEditorsVisible(false); + + connect(marginLSpin, &QDoubleSpinBox::valueChanged, this, &PlaymatSettingsDialog::onParamChanged); + connect(marginRSpin, &QDoubleSpinBox::valueChanged, this, &PlaymatSettingsDialog::onParamChanged); + connect(verticalOffsetSpin, &QDoubleSpinBox::valueChanged, this, &PlaymatSettingsDialog::onParamChanged); + connect(zoomSpin, &QDoubleSpinBox::valueChanged, this, &PlaymatSettingsDialog::onParamChanged); + + // The crop surface leads visually, card selection supports it below. + auto *root = new QVBoxLayout; + root->addWidget(previewGroup); + root->addWidget(controlsGroup); + root->addWidget(buttons); + setLayout(root); +} + +void PlaymatSettingsDialog::populateProviderCombo(const QString &cardName) +{ + providerComboBox->clear(); + + auto card = CardDatabaseManager::query()->getCard({cardName}); + + const auto &sets = card.getInfo().getSets(); + + for (const auto &printings : sets) { + for (const auto &p : printings) { + QString setName = p.getSet()->getLongName(); + QString collector = p.getProperty("num"); + QString uuid = p.getUuid(); + + QString label = setName; + if (!collector.isEmpty()) { + label += " #" + collector; + } + + providerComboBox->addItem(label, uuid); + } + } +} + +void PlaymatSettingsDialog::onCardNameChanged(const QString &name) +{ + if (name.isEmpty()) { + currentPixmap = QPixmap(); + preview->setPixmap(currentPixmap); + return; + } + + const ExactCard card = CardDatabaseManager::query()->getCard({name}); + if (!card) { + currentPixmap = QPixmap(); + preview->setPixmap(currentPixmap); + providerComboBox->clear(); + return; + } + + currentCard.name = name; + + populateProviderCombo(name); + + if (providerComboBox->count() == 0) { + currentPixmap = QPixmap(); + preview->setPixmap(currentPixmap); + currentCard.providerId.clear(); + return; + } + + currentCard.providerId = providerComboBox->currentData().toString(); + reloadPreview(); +} + +void PlaymatSettingsDialog::reloadPreview() +{ + if (currentCard.name.isEmpty()) { + return; + } + + ExactCard card = CardDatabaseManager::query()->getCard({currentCard.name, currentCard.providerId}); + if (!card) { + return; + } + + disconnect(pixmapUpdatedConnection); + + QPixmap fullRes; + CardPictureLoader::getPixmap(fullRes, card, QSize(745, 1040)); + + if (fullRes.isNull()) { + CardInfo *cardInfo = card.getCardPtr().data(); + if (cardInfo) { + pixmapUpdatedConnection = connect(cardInfo, &CardInfo::pixmapUpdated, this, [this]() { reloadPreview(); }); + } + return; + } + + currentPixmap = CardArtUtils::rotateSidewaysLayoutArt(fullRes, card); + preview->setPixmap(currentPixmap); + preview->setParams(currentParams); + preview->setAttribution(buildArtAttribution(card)); +} + +void PlaymatSettingsDialog::onParamChanged() +{ + currentParams.marginPctL = marginLSpin->value(); + currentParams.marginPctR = marginRSpin->value(); + currentParams.verticalOffset = verticalOffsetSpin->value(); + currentParams.zoom = zoomSpin->value(); + preview->setParams(currentParams); +} + +void PlaymatSettingsDialog::setNumericEditorsVisible(bool visible) +{ + controlsForm->setRowVisible(leftMarginLabel, visible); + controlsForm->setRowVisible(rightMarginLabel, visible); + controlsForm->setRowVisible(verticalOffsetLabel, visible); + controlsForm->setRowVisible(zoomLabel, visible); + + // A QDialog never resizes itself when its content requirements change, + // so revealing the editors would squeeze the crop group until the info + // caption ran into the preview. Re-fit the dialog to the new size hint. + adjustSize(); +} + +void PlaymatSettingsDialog::retranslateUi() +{ + setWindowTitle(tr("Playmat Settings")); + searchBar->setPlaceholderText(tr("Type a card name...")); + cardNameLabel->setText(tr("Card name:")); + printingLabel->setText(tr("Printing:")); + showNumericEditorsCheck->setText(tr("Show numerical editors")); + leftMarginLabel->setText(tr("Left margin (%):")); + rightMarginLabel->setText(tr("Right margin (%):")); + verticalOffsetLabel->setText(tr("Vertical offset:")); + zoomLabel->setText(tr("Zoom:")); + controlsGroup->setTitle(tr("Card")); + previewGroup->setTitle(tr("Crop")); + previewCaptionLabel->setText( + tr("Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores. " + "Dimmed strips mark where a wider table crops further.")); + removeButton->setText(tr("Remove Playmat")); +} diff --git a/cockatrice/src/interface/widgets/playmat/playmat_settings_dialog.h b/cockatrice/src/interface/widgets/playmat/playmat_settings_dialog.h new file mode 100644 index 000000000..9ef306a5f --- /dev/null +++ b/cockatrice/src/interface/widgets/playmat/playmat_settings_dialog.h @@ -0,0 +1,94 @@ +#ifndef COCKATRICE_PLAYMAT_SETTINGS_DIALOG_H +#define COCKATRICE_PLAYMAT_SETTINGS_DIALOG_H + +#include +#include +#include + +class QCheckBox; +class QComboBox; +class QCompleter; +class QDoubleSpinBox; +class QFormLayout; +class QGroupBox; +class QLabel; +class QLineEdit; +class QPushButton; +class QWidget; +class CardDatabaseModel; +class CardDatabaseDisplayModel; +class CardSearchModel; +class CardCompleterProxyModel; +class PlaymatPreviewWidget; + +/** + * @brief Dialog for configuring the playmat card art for a deck. + * + * The crop surface is the primary control: drag to pan the visible art, + * scroll (or +/- keys) to zoom, arrow keys to nudge. Card name and printing + * are selected below. A checkbox reveals optional numerical editors for the + * raw PlaymatParams. These controls edit the same stored PlaymatParams that + * ship in deck files and player properties. + */ +class PlaymatSettingsDialog : public QDialog +{ + Q_OBJECT + +public: + explicit PlaymatSettingsDialog(const CardRef &initialCard = {}, + const PlaymatParams &initialParams = {}, + QWidget *parent = nullptr); + + CardRef card() const; + PlaymatParams params() const; + +private slots: + void onCardNameChanged(const QString &name); + void onParamChanged(); + void reloadPreview(); + +private: + void setupUi(); + void populateProviderCombo(const QString &cardName); + void initializeSearchBar(); + void retranslateUi(); + void setNumericEditorsVisible(bool visible); + QDoubleSpinBox *makeSpinBox(double min, double max, double value, double step); + + QLineEdit *searchBar; + QCompleter *completer; + CardDatabaseModel *cardDatabaseModel; + CardDatabaseDisplayModel *cardDatabaseDisplayModel; + CardSearchModel *searchModel; + CardCompleterProxyModel *proxyModel; + + QComboBox *providerComboBox; + + QMetaObject::Connection pixmapUpdatedConnection; + + QLabel *cardNameLabel; + QLabel *printingLabel; + QLabel *previewCaptionLabel; + QCheckBox *showNumericEditorsCheck; + QFormLayout *controlsForm; + QLabel *leftMarginLabel; + QLabel *rightMarginLabel; + QLabel *verticalOffsetLabel; + QLabel *zoomLabel; + QGroupBox *controlsGroup; + QGroupBox *previewGroup; + QPushButton *removeButton; + + QDoubleSpinBox *marginLSpin; + QDoubleSpinBox *marginRSpin; + QDoubleSpinBox *verticalOffsetSpin; + QDoubleSpinBox *zoomSpin; + + PlaymatPreviewWidget *preview; + + QPixmap currentPixmap; + CardRef currentCard; + PlaymatParams currentParams; +}; + +#endif // COCKATRICE_PLAYMAT_SETTINGS_DIALOG_H diff --git a/cockatrice/src/interface/widgets/playmat/playmat_utils.h b/cockatrice/src/interface/widgets/playmat/playmat_utils.h new file mode 100644 index 000000000..0691a9637 --- /dev/null +++ b/cockatrice/src/interface/widgets/playmat/playmat_utils.h @@ -0,0 +1,140 @@ +#ifndef COCKATRICE_PLAYMAT_UTILS_H +#define COCKATRICE_PLAYMAT_UTILS_H + +#include +#include +#include +#include + +namespace PlaymatUtils +{ + +/** @brief Upper bound for zooming into the playmat art. */ +constexpr qreal MAX_ZOOM = 4.0; + +/** + * @brief Width of the outer viewing window: full card width trimmed by the + * horizontal margins. Guarded against margins summing to >= 1. + */ +inline qreal playmatVisibleWidth(const QSize &fullCardSize, const PlaymatParams ¶ms) +{ + const qreal srcW = fullCardSize.width(); + const qreal marginL = params.marginPctL * srcW; + const qreal marginR = params.marginPctR * srcW; + return qMax(0.0, srcW - marginL - marginR); +} + +/** + * @brief Zoom clamped to the range where every step renders differently. + * + * The square sampling window is visibleWidth / zoom, zooming out past + * visibleWidth / min(card width, height) would sample beyond the card itself, + * which both looks broken and makes whole ranges of the parameter dead. The + * floor is therefore derived from the actual image instead of a static value, + * and is shared verbatim by the render path and the editor's gesture math so + * the two can never disagree. + */ +inline qreal playmatClampedZoom(const QSize &fullCardSize, const PlaymatParams ¶ms) +{ + const qreal minDim = qMin(fullCardSize.width(), fullCardSize.height()); + const qreal visibleW = playmatVisibleWidth(fullCardSize, params); + const qreal zoomOutFloor = (minDim > 0.0 && visibleW > 0.0) ? visibleW / minDim : 1.0; + // The floor deliberately bypasses MAX_ZOOM: when the art is much wider + // than tall, keeping the square window inside it requires more than 4x + // zoom-out, and honoring that larger floor keeps side within + // min(card width, height). Zooming IN is still capped at MAX_ZOOM. + return qMin(MAX_ZOOM, qMax(params.zoom, zoomOutFloor)); +} + +/** + * @brief Side of the square sampling window actually rendered for these + * parameters. Never exceeds either card dimension, so the source rect + * always lies within the image (vertical travel remains for panning + * whenever the art is taller than it is wide). + */ +inline qreal playmatWindowSide(const QSize &fullCardSize, const PlaymatParams ¶ms) +{ + const qreal visibleW = playmatVisibleWidth(fullCardSize, params); + if (visibleW <= 0.0) { + return 0.0; + } + return visibleW / playmatClampedZoom(fullCardSize, params); +} + +/** + * @brief Computes the source region of the full resolution card image to use as a playmat. + * + * Parameters are relative to the full card image. horizontal margins trim the + * card borders (shifting them pans the window), verticalOffset places the top + * edge of the sampling window within its available travel, and zoom scales + * into the trimmed span. The result always lies within the card image bounds. + * + * @param fullCardSize Size of the full card image. + * @param params Positioning parameters. + * @return Source rectangle in full card image pixel coordinates. + */ +inline QRectF computeArtSourceRect(const QSize &fullCardSize, const PlaymatParams ¶ms) +{ + const qreal srcW = fullCardSize.width(); + const qreal srcH = fullCardSize.height(); + + // Square sampling window, keeps art unskewed, never exceeds the card on + // either axis thanks to the zoom floor in playmatWindowSide(). + const qreal side = playmatWindowSide(fullCardSize, params); + + // verticalOffset places the TOP edge of the sampling window itself within + // its travel, so the full [0, 1] parameter range is live at every zoom and + // the window can always reach the very top (0.0) and bottom (1.0) of the + // art. + const qreal offset = qBound(0.0, params.verticalOffset, 1.0); + const qreal y = offset * qMax(0.0, srcH - side); + + // Horizontally the sampling window sits centered inside the trimmed span + // (margins pan it), zooming out can make it wider than that span, so it + // is then kept within the image, an edge stop, never an invalid rect. + const qreal outerW = playmatVisibleWidth(fullCardSize, params); + const qreal x = qBound(0.0, params.marginPctL * srcW + (outerW - side) / 2.0, qMax(0.0, srcW - side)); + + return QRectF(x, y, side, side); +} + +/** + * @brief Returns the destination rectangle that fits a source of the given aspect + * ratio into dstArea using "cover" semantics (no distortion, overflows cropped). + * + * @param dstArea Area to fill. + * @param srcSize Size of the source, only its aspect ratio matters. + * @return Destination rectangle centered in dstArea. + */ +inline QRectF coverFitRect(const QRectF &dstArea, const QSizeF &srcSize) +{ + const qreal srcAspect = srcSize.width() / srcSize.height(); + const qreal dstAspect = dstArea.width() / dstArea.height(); + + if (srcAspect > dstAspect) { + const qreal dstW = dstArea.height() * srcAspect; + return QRectF(dstArea.left() + (dstArea.width() - dstW) / 2.0, dstArea.top(), dstW, dstArea.height()); + } + + const qreal dstH = dstArea.width() / srcAspect; + return QRectF(dstArea.left(), dstArea.top() + (dstArea.height() - dstH) / 2.0, dstArea.width(), dstH); +} + +/** + * @brief Fits a rectangle of the given aspect ratio into dstArea, centered, + * touching the constraining dimension ("aspect fit" of the FRAME + * itself, not of a source image). + */ +inline QRectF aspectFitRect(const QRectF &dstArea, qreal aspect) +{ + if (aspect <= 0.0) { + return dstArea; + } + qreal w = qMin(dstArea.width(), dstArea.height() * aspect); + qreal h = w / aspect; + return QRectF(dstArea.left() + (dstArea.width() - w) / 2.0, dstArea.top() + (dstArea.height() - h) / 2.0, w, h); +} + +} // namespace PlaymatUtils + +#endif // COCKATRICE_PLAYMAT_UTILS_H diff --git a/cockatrice/src/interface/widgets/printing_selector/all_zones_card_amount_widget.cpp b/cockatrice/src/interface/widgets/printing_selector/all_zones_card_amount_widget.cpp index 05e269174..579106540 100644 --- a/cockatrice/src/interface/widgets/printing_selector/all_zones_card_amount_widget.cpp +++ b/cockatrice/src/interface/widgets/printing_selector/all_zones_card_amount_widget.cpp @@ -141,11 +141,7 @@ bool AllZonesCardAmountWidget::isNonZero() * * @param event The event information for the mouse entry. */ -#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) void AllZonesCardAmountWidget::enterEvent(QEnterEvent *event) -#else -void AllZonesCardAmountWidget::enterEvent(QEvent *event) -#endif { QWidget::enterEvent(event); update(); diff --git a/cockatrice/src/interface/widgets/printing_selector/all_zones_card_amount_widget.h b/cockatrice/src/interface/widgets/printing_selector/all_zones_card_amount_widget.h index de4a984be..325dd1c1b 100644 --- a/cockatrice/src/interface/widgets/printing_selector/all_zones_card_amount_widget.h +++ b/cockatrice/src/interface/widgets/printing_selector/all_zones_card_amount_widget.h @@ -26,11 +26,7 @@ public: int getTokensboardAmount(); bool isNonZero(); -#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) void enterEvent(QEnterEvent *event) override; -#else - void enterEvent(QEvent *event) override; -#endif public slots: void adjustFontSize(int scalePercentage); diff --git a/cockatrice/src/interface/widgets/printing_selector/printing_selector.cpp b/cockatrice/src/interface/widgets/printing_selector/printing_selector.cpp index 76a416587..0c3bbbe00 100644 --- a/cockatrice/src/interface/widgets/printing_selector/printing_selector.cpp +++ b/cockatrice/src/interface/widgets/printing_selector/printing_selector.cpp @@ -12,6 +12,8 @@ #include #include +#include +#include /** * @brief Constructs a PrintingSelector widget to display and manage card printings. @@ -45,16 +47,17 @@ PrintingSelector::PrintingSelector(QWidget *parent, AbstractTabDeckEditor *_deck // Create the checkbox for navigation buttons visibility navigationCheckBox = new QCheckBox(this); - navigationCheckBox->setChecked(SettingsCache::instance().getPrintingSelectorNavigationButtonsVisible()); + navigationCheckBox->setChecked( + SettingsCache::instance().cardsDisplay().getPrintingSelectorNavigationButtonsVisible()); connect(navigationCheckBox, &QCheckBox::QT_STATE_CHANGED, this, &PrintingSelector::toggleVisibilityNavigationButtons); - connect(navigationCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setPrintingSelectorNavigationButtonsVisible); + connect(navigationCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().cardsDisplay(), + &CardsDisplaySettings::setPrintingSelectorNavigationButtonsVisible); - cardSizeWidget = - new CardSizeWidget(displayOptionsWidget, flowWidget, SettingsCache::instance().getPrintingSelectorCardSize()); - connect(cardSizeWidget, &CardSizeWidget::cardSizeSettingUpdated, &SettingsCache::instance(), - &SettingsCache::setPrintingSelectorCardSize); + cardSizeWidget = new CardSizeWidget(displayOptionsWidget, flowWidget, + SettingsCache::instance().cardsDisplay().getPrintingSelectorCardSize()); + connect(cardSizeWidget, &CardSizeWidget::cardSizeSettingUpdated, &SettingsCache::instance().cardsDisplay(), + &CardsDisplaySettings::setPrintingSelectorCardSize); displayOptionsWidget->addSettingsWidget(sortToolBar); displayOptionsWidget->addSettingsWidget(navigationCheckBox); @@ -81,7 +84,8 @@ PrintingSelector::PrintingSelector(QWidget *parent, AbstractTabDeckEditor *_deck flowWidget->setVisible(false); cardSelectionBar = new PrintingSelectorCardSelectionWidget(this, deckStateManager); - cardSelectionBar->setVisible(SettingsCache::instance().getPrintingSelectorNavigationButtonsVisible()); + cardSelectionBar->setVisible( + SettingsCache::instance().cardsDisplay().getPrintingSelectorNavigationButtonsVisible()); layout->addWidget(cardSelectionBar); // Connect deck model data change signal to update display @@ -98,7 +102,7 @@ void PrintingSelector::retranslateUi() void PrintingSelector::printingsInDeckChanged() { - if (SettingsCache::instance().getBumpSetsWithCardsInDeckToTop()) { + if (SettingsCache::instance().cardsDisplay().getBumpSetsWithCardsInDeckToTop()) { // Delay the update to avoid race conditions QTimer::singleShot(100, this, &PrintingSelector::updateDisplay); } @@ -211,7 +215,7 @@ void PrintingSelector::getAllSetsForCurrentCard() sortToolBar->filterSets(sortedPrintings, searchBar->getSearchText().trimmed().toLower()); QList printingsToUse; - if (SettingsCache::instance().getBumpSetsWithCardsInDeckToTop()) { + if (SettingsCache::instance().cardsDisplay().getBumpSetsWithCardsInDeckToTop()) { printingsToUse = sortToolBar->prependPrintingsInDeck(filteredPrintings, selectedCard, deckStateManager->getModel()); } else { diff --git a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.cpp b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.cpp index dd5f6dd7f..e8816847e 100644 --- a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.cpp +++ b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.cpp @@ -1,18 +1,69 @@ #include "printing_selector_card_overlay_widget.h" #include "../../../client/settings/cache_settings.h" +#include "../../card_picture_loader/card_picture_loader.h" +#include "../cards/card_info_picture_widget.h" #include "printing_selector_card_display_widget.h" +#include +#include #include #include #include +#include #include +#include +#include #include #include #include #include +#include #include +namespace +{ +/** + * @brief Places the preview beside the highlighted action inside the given screen. + * + * Side-aware: hugs the side of the action that has room, aligned with its row, then + * clamps every edge so the preview always lands fully on-screen on first show. + */ +QPoint +previewPositionNear(const QRect &actionRect, const QSize &labelSize, const QRect &screenGeometry, int previewOffset) +{ + const bool rightFits = actionRect.right() + previewOffset + labelSize.width() <= screenGeometry.right(); + const bool leftFits = actionRect.left() - previewOffset - labelSize.width() >= screenGeometry.left(); + + int x; + if (rightFits) { + x = actionRect.right() + previewOffset; + } else if (leftFits) { + x = actionRect.left() - previewOffset - labelSize.width(); + } else { + x = actionRect.left(); + } + x = qMax(screenGeometry.left(), x); + x = qMin(screenGeometry.right() - labelSize.width() + 1, x); + + const bool belowFits = actionRect.bottom() + previewOffset + labelSize.height() <= screenGeometry.bottom(); + const bool aboveFits = actionRect.top() - previewOffset - labelSize.height() >= screenGeometry.top(); + + int y; + if (belowFits) { + y = actionRect.bottom() + previewOffset; + } else if (aboveFits) { + y = actionRect.top() - previewOffset - labelSize.height(); + } else { + y = actionRect.top(); + } + y = qMax(screenGeometry.top(), y); + y = qMin(screenGeometry.bottom() - labelSize.height() + 1, y); + + return {x, y}; +} +} // namespace + /** * @brief Constructs a PrintingSelectorCardOverlayWidget for displaying a card overlay. * @@ -48,6 +99,31 @@ PrintingSelectorCardOverlayWidget::PrintingSelectorCardOverlayWidget(QWidget *pa initializePinBadge(); + // Parent the preview to this overlay so it is destroyed with it (Qt::ToolTip keeps + // it a frameless, non-activating top-level window despite the parent). + cardOverridePreviewLabel = new QLabel(this, Qt::ToolTip); + cardOverridePreviewLabel->setWindowFlag(Qt::FramelessWindowHint); + cardOverridePreviewLabel->setAttribute(Qt::WA_ShowWithoutActivating); + cardOverridePreviewLabel->setScaledContents(true); + cardOverridePreviewLabel->hide(); + + // While the preview is visible, keep it honest: when the hovered printing's art + // resolves (all alternate printings share the root card's CardInfo), redraw it in place. + if (rootCard.getCardPtr()) { + connect(rootCard.getCardPtr().data(), &CardInfo::pixmapUpdated, this, [this] { + if (cardOverridePreviewLabel->isVisible()) { + refreshPreview(); + } + }); + } + + // Alt-Tab / app-inactive must not strand the floating preview. + connect(qApp, &QGuiApplication::applicationStateChanged, this, [this](Qt::ApplicationState state) { + if (state != Qt::ApplicationActive) { + hidePreview(); + } + }); + // Update when this overlay emits cardPreferenceChanged or when size/scale changes connect(this, &PrintingSelectorCardOverlayWidget::cardPreferenceChanged, this, &PrintingSelectorCardOverlayWidget::updatePinBadgeVisibility); @@ -105,11 +181,7 @@ void PrintingSelectorCardOverlayWidget::resizeEvent(QResizeEvent *event) * * @param event The event triggered when the mouse enters the widget. */ -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) void PrintingSelectorCardOverlayWidget::enterEvent(QEnterEvent *event) -#else -void PrintingSelectorCardOverlayWidget::enterEvent(QEvent *event) -#endif { QWidget::enterEvent(event); deckEditor->updateCard(rootCard); @@ -187,19 +259,23 @@ void PrintingSelectorCardOverlayWidget::leaveEvent(QEvent *event) } /** - * @brief Creates and shows a custom context menu when the right mouse button is clicked. + * @brief Creates and shows the card-overlay context menu. * - * The context menu includes an option to show related cards, which displays a submenu with actions - * for each related card. When an action is triggered, the card information is updated, and the - * printing selector is shown. + * The menu includes the card art preference (Pin/Unpin Printing), the Image Overrides + * submenu (Load Custom Image, Clear Custom Image, and one entry per alternate printing with a + * live preview), and the Show Related cards submenu. * - * @param point The position of the mouse when the right-click occurred. + * @param point The local position the menu should pop at. */ void PrintingSelectorCardOverlayWidget::customMenu(QPoint point) { QMenu menu; - auto *preferenceMenu = new QMenu(tr("Preference")); + hidePreview(); // Clear any preview state left over from a previous menu run. + + // Submenus are owned by the stack-allocated top-level menu (addMenu() does not + // transfer ownership). + auto *preferenceMenu = new QMenu(tr("Preference"), &menu); menu.addMenu(preferenceMenu); const auto &preferredProviderId = @@ -221,8 +297,66 @@ void PrintingSelectorCardOverlayWidget::customMenu(QPoint point) }); } + menu.addSeparator(); + + auto *overrideMenu = new QMenu(tr("Image Overrides"), &menu); + + auto *loadCustomAction = overrideMenu->addAction(tr("Load Custom Image...")); + auto *clearOverrideAction = overrideMenu->addAction(tr("Clear Custom Image")); + + // Nothing to clear on a card that has no local override yet. + clearOverrideAction->setEnabled(CardPictureLoader::hasLocalOverrides(rootCard)); + + overrideMenu->addSeparator(); + + const auto &allSets = rootCard.getInfo().getSets(); + + for (const auto &set : allSets) { + for (const auto &printing : set) { + if (printing == rootCard.getPrinting()) { + continue; + } + + // The submenu is already scoped to this card, so the rows lead with set + + // collector; only printings with a distinct display name add their own name. + const CardSetPtr cardSet = printing.getSet(); + if (!cardSet) { + continue; + } + + QString label = tr("%1 %2").arg(cardSet->getCorrectedShortName(), printing.getProperty("num")); + + auto *action = overrideMenu->addAction(label); + + ExactCard overrideCard(rootCard.getCardPtr(), printing); + action->setData(QVariant::fromValue(overrideCard)); + + connect(action, &QAction::triggered, this, [this, overrideCard]() { + CardPictureLoader::getInstance().installPrintingOverride(rootCard, overrideCard); + QPixmapCache::clear(); + rootCard.emitPixmapUpdated(); // refresh the overlay art in place, like the other paths + }); + } + } + + connect(clearOverrideAction, &QAction::triggered, this, [this]() { + CardPictureLoader::deleteAllLocalOverrides(rootCard); + QPixmapCache::clear(); + rootCard.emitPixmapUpdated(); // force UI refresh + }); + + connect(loadCustomAction, &QAction::triggered, this, &PrintingSelectorCardOverlayWidget::loadCustomImage); + + connect(overrideMenu, &QMenu::hovered, this, &PrintingSelectorCardOverlayWidget::showPreviewForAction); + connect(overrideMenu, &QMenu::aboutToHide, this, &PrintingSelectorCardOverlayWidget::hidePreview); + connect(overrideMenu, &QMenu::triggered, this, &PrintingSelectorCardOverlayWidget::hidePreview); + + menu.addMenu(overrideMenu); + + menu.addSeparator(); + // filling out the related cards submenu - auto *relatedMenu = new QMenu(tr("Show Related cards")); + auto *relatedMenu = new QMenu(tr("Show Related cards"), &menu); menu.addMenu(relatedMenu); auto relatedCards = rootCard.getInfo().getAllRelatedCards(); if (relatedCards.isEmpty()) { @@ -237,7 +371,11 @@ void PrintingSelectorCardOverlayWidget::customMenu(QPoint point) }); } } + // The preview anchors itself to this popup's global geometry while it is open, so the + // pointer must stay valid for the whole exec() and be dropped before the stack unwinds. + previewSourceMenu = overrideMenu; menu.exec(this->mapToGlobal(point)); + previewSourceMenu = nullptr; } /** @@ -293,3 +431,136 @@ void PrintingSelectorCardOverlayWidget::initializePinBadge() pinBadge->setVisible(false); pinBadge->raise(); } + +/** + * @brief Asks for an image file and installs it as the card's custom art. + * + * Unreadable files answer with a visible warning instead of a silent no-op. + */ +void PrintingSelectorCardOverlayWidget::loadCustomImage() +{ + QString filePath = QFileDialog::getOpenFileName(this, tr("Select Card Image"), QString(), + tr("Images (*.png *.jpg *.jpeg *.webp)")); + + if (filePath.isEmpty()) { + return; + } + + QPixmap pixmap(filePath); + if (pixmap.isNull()) { + // No silent paths: a file that cannot be read answers visibly instead of a no-op. + QMessageBox::warning(this, tr("Load Custom Image"), tr("The selected file could not be read as an image.")); + return; + } + + CardPictureLoader::getInstance().saveCardImageToLocalStorage(rootCard, pixmap, true); + + QPixmapCache::clear(); + rootCard.emitPixmapUpdated(); +} + +/** + * @brief Shows the hover preview for a highlighted printing entry in the Image Overrides submenu. + * + * QMenu::hovered fires on keyboard highlight too, so the preview appears when arrows walk + * onto a printing entry, not only under the mouse. + * + * Non-printing entries (e.g., Load Custom Image, Clear Custom Image) hide the preview. + * + * @param action The action that was highlighted. + */ +void PrintingSelectorCardOverlayWidget::showPreviewForAction(QAction *action) +{ + if (!action) { + hidePreview(); + return; + } + + const QVariant data = action->data(); + + if (!data.canConvert()) { + hidePreview(); + return; + } + + const ExactCard previewCard = qvariant_cast(data); + if (previewCard.isEmpty()) { + hidePreview(); + return; + } + + hoveredOverrideCard = previewCard; + hoveredOverrideAction = action; + refreshPreview(); +} + +/** + * @brief Renders the hover preview for the currently highlighted printing. + * + * The preview shows the loading placeholder while the art is pending and swaps in the real + * art when it resolves. The label is positioned against its already-resized geometry so the + * first-ever show at the screen's edges stays fully on-screen. + */ +void PrintingSelectorCardOverlayWidget::refreshPreview() +{ + if (hoveredOverrideCard.isEmpty()) { + hidePreview(); + return; + } + + constexpr QSize previewSize(240, 336); + constexpr int previewOffset = 20; + + QPixmap pixmap; + CardPictureLoader::getPixmap(pixmap, hoveredOverrideCard, previewSize); + + if (pixmap.isNull()) { + // Keep the preview honest while loading: show the loading placeholder instead of a void. + // Fetch at the logical size and let the label scale it, so the placeholder matches the + // real art's footprint rather than doubling on HiDPI displays. + CardPictureLoader::getCardBackLoadingInProgressPixmap(pixmap, previewSize); + } + + cardOverridePreviewLabel->setPixmap(pixmap); + // QPixmap::size() is physical pixels; the label layout must use the device-independent size + // so the preview keeps a constant footprint across DPI settings (QScreen geometry is logical). + const QSize labelSize = pixmap.deviceIndependentSize().toSize(); + cardOverridePreviewLabel->resize(labelSize); + + // Anchor the preview to the walked submenu popup rather than QCursor::pos(), which is idle + // under keyboard-only operation: a keyboard-highlighted row must preview at the same place as + // a hovered one. The mouse path is unchanged in effect — the popup sits under the cursor, so + // the preview stays beside the row in both modalities. + const QMenu *popup = previewSourceMenu; + if (!popup || !popup->isVisible() || !hoveredOverrideAction) { + hidePreview(); + return; + } + + const QRect popupGeometry = popup->geometry(); + const QRect actionRectLocal = popup->actionGeometry(hoveredOverrideAction); + const QRect actionRect(popupGeometry.topLeft() + actionRectLocal.topLeft(), actionRectLocal.size()); + + QScreen *screen = QGuiApplication::screenAt(popupGeometry.center()); + if (!screen) { + hidePreview(); + return; + } + const QRect &screenGeometry = screen->geometry(); + + cardOverridePreviewLabel->move(previewPositionNear(actionRect, labelSize, screenGeometry, previewOffset)); + cardOverridePreviewLabel->show(); +} + +/** + * @brief Hides the hover preview and forgets the currently highlighted printing. + */ +void PrintingSelectorCardOverlayWidget::hidePreview() +{ + hoveredOverrideCard = ExactCard(); + hoveredOverrideAction = nullptr; + + if (cardOverridePreviewLabel) { + cardOverridePreviewLabel->hide(); + } +} diff --git a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.h b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.h index 52a43d220..fbcfa9230 100644 --- a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.h +++ b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.h @@ -13,6 +13,9 @@ #include +class QAction; +class QMenu; + class PrintingSelectorCardOverlayWidget : public QWidget { Q_OBJECT @@ -26,11 +29,7 @@ public: protected: void resizeEvent(QResizeEvent *event) override; -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) void enterEvent(QEnterEvent *event) override; -#else - void enterEvent(QEvent *event) override; -#endif void leaveEvent(QEvent *event) override; void mousePressEvent(QMouseEvent *event) override; void customMenu(QPoint point); @@ -47,11 +46,19 @@ private slots: private: void initializePinBadge(); + void loadCustomImage(); + void showPreviewForAction(QAction *action); + void refreshPreview(); + void hidePreview(); CardInfoPictureWidget *cardInfoPicture; AllZonesCardAmountWidget *allZonesCardAmountWidget; QLabel *pinBadge = nullptr; AbstractTabDeckEditor *deckEditor; ExactCard rootCard; + QLabel *cardOverridePreviewLabel = nullptr; + ExactCard hoveredOverrideCard; + QMenu *previewSourceMenu = nullptr; + QAction *hoveredOverrideAction = nullptr; }; #endif // PRINTING_SELECTOR_CARD_OVERLAY_WIDGET_H diff --git a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_sorting_widget.cpp b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_sorting_widget.cpp index c04d6bbf1..f4a05cfc7 100644 --- a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_sorting_widget.cpp +++ b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_sorting_widget.cpp @@ -3,6 +3,9 @@ #include "../../../client/settings/cache_settings.h" #include +#include +#include +#include const QString PrintingSelectorCardSortingWidget::SORT_OPTIONS_ALPHABETICAL = tr("Alphabetical"); const QString PrintingSelectorCardSortingWidget::SORT_OPTIONS_PREFERENCE = tr("Preference"); @@ -26,7 +29,7 @@ PrintingSelectorCardSortingWidget::PrintingSelectorCardSortingWidget(PrintingSel sortOptionsSelector = new QComboBox(this); sortOptionsSelector->setFocusPolicy(Qt::StrongFocus); sortOptionsSelector->addItems(SORT_OPTIONS); - sortOptionsSelector->setCurrentIndex(SettingsCache::instance().getPrintingSelectorSortOrder()); + sortOptionsSelector->setCurrentIndex(SettingsCache::instance().cardsDisplay().getPrintingSelectorSortOrder()); connect(sortOptionsSelector, &QComboBox::currentTextChanged, this, &PrintingSelectorCardSortingWidget::updateSortSetting); connect(sortOptionsSelector, &QComboBox::currentTextChanged, parent, &PrintingSelector::updateDisplay); @@ -62,7 +65,7 @@ void PrintingSelectorCardSortingWidget::updateSortOrder() */ void PrintingSelectorCardSortingWidget::updateSortSetting() { - SettingsCache::instance().setPrintingSelectorSortOrder(sortOptionsSelector->currentIndex()); + SettingsCache::instance().cardsDisplay().setPrintingSelectorSortOrder(sortOptionsSelector->currentIndex()); } /** @@ -90,7 +93,7 @@ QList PrintingSelectorCardSortingWidget::sortSets(const SetToPrint } if (sortedSets.empty()) { - sortedSets << CardSet::newInstance(SettingsCache::instance().cardDatabase(), "", "", "", QDate()); + sortedSets << CardSet::newInstance(&SettingsCache::instance().cardDatabase(), "", "", "", QDate()); } if (sortOptionsSelector->currentText() == SORT_OPTIONS_PREFERENCE) { diff --git a/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.cpp b/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.cpp index badc437ee..881c3e3ac 100644 --- a/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.cpp +++ b/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.cpp @@ -1,5 +1,7 @@ #include "settings_button_widget.h" +#include "../../pixel_map_generator.h" + #include #include #include @@ -8,7 +10,7 @@ SettingsButtonWidget::SettingsButtonWidget(QWidget *parent) : QWidget(parent), button(new QToolButton(this)), popup(new SettingsPopupWidget(nullptr)) { - button->setIcon(QPixmap("theme:icons/cogwheel")); + button->setIcon(themePixmap(QStringLiteral("icons/cogwheel"))); button->setCheckable(true); button->setFixedSize(32, 32); connect(button, &QToolButton::clicked, this, &SettingsButtonWidget::togglePopup); @@ -20,6 +22,13 @@ SettingsButtonWidget::SettingsButtonWidget(QWidget *parent) setLayout(layout); } +SettingsButtonWidget::~SettingsButtonWidget() +{ + // We don't parent the popup because it might lead to better behavior on certain window managers. + // So we have to manually delete it + popup->deleteLater(); +} + void SettingsButtonWidget::addSettingsWidget(QWidget *toAdd) const { popup->addSettingsWidget(toAdd); @@ -103,11 +112,7 @@ void SettingsButtonWidget::onPopupClosed() const void SettingsButtonWidget::mousePressEvent(QMouseEvent *event) { -#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) if (popup->isVisible() && !popup->geometry().contains(event->globalPosition().toPoint())) { -#else - if (popup->isVisible() && !popup->geometry().contains(event->globalPos())) { -#endif popup->close(); } QWidget::mousePressEvent(event); diff --git a/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.h b/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.h index 5dcbe059a..1dc52ac72 100644 --- a/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.h +++ b/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.h @@ -19,6 +19,9 @@ class SettingsButtonWidget : public QWidget public: explicit SettingsButtonWidget(QWidget *parent = nullptr); + + ~SettingsButtonWidget() override; + void addSettingsWidget(QWidget *toAdd) const; void removeSettingsWidget(QWidget *toRemove) const; void setButtonIcon(QPixmap iconMap); diff --git a/cockatrice/src/interface/widgets/replay/replay_manager.cpp b/cockatrice/src/interface/widgets/replay/replay_manager.cpp index a1330a82d..c51b96b6c 100644 --- a/cockatrice/src/interface/widgets/replay/replay_manager.cpp +++ b/cockatrice/src/interface/widgets/replay/replay_manager.cpp @@ -1,152 +1,236 @@ #include "replay_manager.h" -#include "../interface/widgets/tabs/tab_game.h" +#include "../../../client/settings/cache_settings.h" -#include -#include +#include +#include +#include -ReplayManager::ReplayManager(TabGame *parent, GameReplay *_replay) - : QWidget(parent), game(parent), replay(_replay), replayPlayButton(nullptr), replayFastForwardButton(nullptr), - aReplaySkipForward(nullptr), aReplaySkipBackward(nullptr), aReplaySkipForwardBig(nullptr), - aReplaySkipBackwardBig(nullptr) +static constexpr int TIMER_INTERVAL_MS = 200; +static constexpr int EMPTY_SECTION_MARGIN_MS = 500; + +static QList createReplayTimeline(const GameReplay *replay) { - if (replay) { - game->getGame()->loadReplay(replay); + // Create list: event number -> time [ms] + unsigned int lastEventTimestamp = 0; + const int eventCount = replay->event_list_size(); - // Create list: event number -> time [ms] - // Distribute simultaneous events evenly across 1 second. - unsigned int lastEventTimestamp = 0; - const int eventCount = replay->event_list_size(); - for (int i = 0; i < eventCount; ++i) { - int j = i + 1; - while ((j < eventCount) && (replay->event_list(j).seconds_elapsed() == lastEventTimestamp)) { - ++j; - } - - const int numberEventsThisSecond = j - i; - for (int k = 0; k < numberEventsThisSecond; ++k) { - replayTimeline.append(replay->event_list(i + k).seconds_elapsed() * 1000 + - (int)((qreal)k / (qreal)numberEventsThisSecond * 1000)); - } - - if (j < eventCount) { - lastEventTimestamp = replay->event_list(j).seconds_elapsed(); - } - i += numberEventsThisSecond - 1; + QList replayTimeline; + for (int i = 0; i < eventCount; ++i) { + int nextSecondIndex = i + 1; + while (nextSecondIndex < eventCount && + replay->event_list(nextSecondIndex).seconds_elapsed() == lastEventTimestamp) { + ++nextSecondIndex; } + + // Distribute simultaneous events evenly across 1 second. + const int numberEventsThisSecond = nextSecondIndex - i; + for (int k = 0; k < numberEventsThisSecond; ++k) { + int eventMs = replay->event_list(i + k).seconds_elapsed() * 1000; + int distributionMs = static_cast(static_cast(k) / numberEventsThisSecond * 1000); + replayTimeline.append(eventMs + distributionMs); + } + + if (nextSecondIndex < eventCount) { + lastEventTimestamp = replay->event_list(nextSecondIndex).seconds_elapsed(); + } + i += numberEventsThisSecond - 1; } - // timeline widget - timelineWidget = new ReplayTimelineWidget; - timelineWidget->setTimeline(replayTimeline); - connect(timelineWidget, &ReplayTimelineWidget::processNextEvent, this, &ReplayManager::replayNextEvent); - connect(timelineWidget, &ReplayTimelineWidget::replayFinished, this, &ReplayManager::replayFinished); - connect(timelineWidget, &ReplayTimelineWidget::rewound, this, &ReplayManager::replayRewind); - - // timeline skip shortcuts - aReplaySkipForward = new QAction(timelineWidget); - timelineWidget->addAction(aReplaySkipForward); - connect(aReplaySkipForward, &QAction::triggered, this, - [this] { timelineWidget->skipByAmount(ReplayTimelineWidget::SMALL_SKIP_MS); }); - - aReplaySkipBackward = new QAction(timelineWidget); - timelineWidget->addAction(aReplaySkipBackward); - connect(aReplaySkipBackward, &QAction::triggered, this, - [this] { timelineWidget->skipByAmount(-ReplayTimelineWidget::SMALL_SKIP_MS); }); - - aReplaySkipForwardBig = new QAction(timelineWidget); - timelineWidget->addAction(aReplaySkipForwardBig); - connect(aReplaySkipForwardBig, &QAction::triggered, this, - [this] { timelineWidget->skipByAmount(ReplayTimelineWidget::BIG_SKIP_MS); }); - - aReplaySkipBackwardBig = new QAction(timelineWidget); - timelineWidget->addAction(aReplaySkipBackwardBig); - connect(aReplaySkipBackwardBig, &QAction::triggered, this, - [this] { timelineWidget->skipByAmount(-ReplayTimelineWidget::BIG_SKIP_MS); }); - - // buttons - replayPlayButton = new QToolButton; - replayPlayButton->setIconSize(QSize(32, 32)); - QIcon playButtonIcon = QIcon(); - playButtonIcon.addPixmap(QPixmap("theme:replay/start"), QIcon::Normal, QIcon::Off); - playButtonIcon.addPixmap(QPixmap("theme:replay/pause"), QIcon::Normal, QIcon::On); - replayPlayButton->setIcon(playButtonIcon); - replayPlayButton->setCheckable(true); - connect(replayPlayButton, &QToolButton::toggled, this, &ReplayManager::replayPlayButtonToggled); - - replayFastForwardButton = new QToolButton; - replayFastForwardButton->setIconSize(QSize(32, 32)); - replayFastForwardButton->setIcon(QPixmap("theme:replay/fastforward")); - replayFastForwardButton->setCheckable(true); - connect(replayFastForwardButton, &QToolButton::toggled, this, &ReplayManager::replayFastForwardButtonToggled); - - // putting everything together - auto replayControlLayout = new QHBoxLayout; - replayControlLayout->addWidget(timelineWidget, 10); - replayControlLayout->addWidget(replayPlayButton); - replayControlLayout->addWidget(replayFastForwardButton); - - setObjectName("replayControlWidget"); - setLayout(replayControlLayout); - - connect(this, &ReplayManager::requestChatAndPhaseReset, game, &TabGame::resetChatAndPhase); - - connect(&SettingsCache::instance().shortcuts(), &ShortcutsSettings::shortCutChanged, this, - &ReplayManager::refreshShortcuts); - refreshShortcuts(); + return replayTimeline; } -void ReplayManager::replayNextEvent(EventProcessingOptions options) +ReplayManager::ReplayManager(QObject *parent, GameReplay *replay) + : QObject(parent), replay(replay), replayTimeline(createReplayTimeline(replay)) { - emit eventReplayed(replay->event_list(timelineWidget->getCurrentEvent()), options); + maxTime = replayTimeline.isEmpty() ? 0 : replayTimeline.last(); + + replayTimer = new QTimer(this); + replayTimer->setInterval(TIMER_INTERVAL_MS); + connect(replayTimer, &QTimer::timeout, this, &ReplayManager::replayTimerTimeout); + + rewindBufferingTimer = new QTimer(this); + rewindBufferingTimer->setSingleShot(true); + connect(rewindBufferingTimer, &QTimer::timeout, this, &ReplayManager::processRewind); } -void ReplayManager::replayFinished() +ReplayManager::~ReplayManager() { - replayPlayButton->setChecked(false); + delete replay; } -void ReplayManager::replayPlayButtonToggled(bool checked) +void ReplayManager::skipToTime(int newTime, bool doRewindBuffering) { - if (checked) { // start replay - timelineWidget->startReplay(); - } else { // pause replay - timelineWidget->stopReplay(); + // check boundary conditions + if (newTime < 0) { + newTime = 0; + } + if (newTime > maxTime) { + newTime = maxTime; } -} -void ReplayManager::replayFastForwardButtonToggled(bool checked) -{ - timelineWidget->setTimeScaleFactor(checked ? ReplayTimelineWidget::FAST_FORWARD_SCALE_FACTOR : 1.0); + newTime -= newTime % TIMER_INTERVAL_MS; // Time should always be a multiple of the interval + + const bool isBackwardsSkip = newTime < currentProcessedTime; + currentVisualTime = newTime; + + if (isBackwardsSkip) { + handleBackwardsSkip(doRewindBuffering); + } else { + processNewEvents(FORWARD_SKIP); + } + + timeChanged(currentVisualTime); } /** - * @brief Handles everything that needs to be reset when doing a replay rewind. + * @brief Handles a backwards skip in the replay timeline. + * + * @param doRewindBuffering When true, if multiple backward skips are made in quick succession, only a single rewind + * is processed at the end. When false, the backwards skip will always cause an immediate rewind. */ -void ReplayManager::replayRewind() +void ReplayManager::handleBackwardsSkip(bool doRewindBuffering) { - emit requestChatAndPhaseReset(); + if (doRewindBuffering) { + // We use a one-shot timer to implement the rewind buffering. + // The rewind only happens once the timer runs out. + // If another backwards skip happens, the timer will just get reset instead of rewinding. + rewindBufferingTimer->stop(); + rewindBufferingTimer->start(SettingsCache::instance().userInterface().getRewindBufferingMs()); + } else { + // otherwise, process the rewind immediately + processRewind(); + } } -void ReplayManager::refreshShortcuts() +void ReplayManager::processRewind() { - ShortcutsSettings &shortcuts = SettingsCache::instance().shortcuts(); - if (aReplaySkipForward) { - aReplaySkipForward->setShortcuts(shortcuts.getShortcut("Replays/aSkipForward")); + // stop any queued-up rewinds + rewindBufferingTimer->stop(); + + // process the rewind + currentEvent = 0; + emit rewound(); + processNewEvents(BACKWARD_SKIP); +} + +void ReplayManager::replayTimerTimeout() +{ + currentVisualTime += TIMER_INTERVAL_MS; + + processNewEvents(NORMAL_PLAYBACK); + + timeChanged(currentVisualTime); + + if (skipEmptySections) { + handleSkipEmptySection(); } - if (aReplaySkipBackward) { - aReplaySkipBackward->setShortcuts(shortcuts.getShortcut("Replays/aSkipBackward")); +} + +/** @brief Processes all unprocessed events up to the current time. */ +void ReplayManager::processNewEvents(PlaybackMode playbackMode) +{ + currentProcessedTime = currentVisualTime; + + while (currentEvent < replayTimeline.size() && replayTimeline[currentEvent] < currentProcessedTime) { + EventProcessingOptions options; + + // backwards skip => always skip reveal windows + // forwards skip => skip reveal windows that don't happen within a big skip of the target + if (playbackMode == BACKWARD_SKIP || currentProcessedTime - replayTimeline[currentEvent] > BIG_SKIP_MS) { + options |= SKIP_REVEAL_WINDOW; + } + + // backwards skip => always skip tap animation + // backwards skip => always skip damage animation (battlefield shimmer / life counter flash) + if (playbackMode == BACKWARD_SKIP) { + options |= SKIP_TAP_ANIMATION; + options |= SKIP_DAMAGE_ANIMATION; + } + + emit eventReplayed(replay->event_list(currentEvent), options); + ++currentEvent; } - if (aReplaySkipForwardBig) { - aReplaySkipForwardBig->setShortcuts(shortcuts.getShortcut("Replays/aSkipForwardBig")); + if (currentEvent == replayTimeline.size()) { + emit replayFinished(); + replayTimer->stop(); } - if (aReplaySkipBackwardBig) { - aReplaySkipBackwardBig->setShortcuts(shortcuts.getShortcut("Replays/aSkipBackwardBig")); +} + +static bool hasMeaningfulEvent(const GameEventContainer &cont) +{ + const int eventListSize = cont.event_list_size(); + for (int i = 0; i < eventListSize; ++i) { + const GameEvent &event = cont.event_list(i); + const auto eventType = static_cast(getPbExtension(event)); + + if (eventType != GameEvent::PLAYER_PROPERTIES_CHANGED) { + return true; + } } - if (replayPlayButton) { - replayPlayButton->setShortcut(shortcuts.getSingleShortcut("Replays/playButton")); + + return false; +} + +void ReplayManager::handleSkipEmptySection() +{ + if (currentEvent == replayTimeline.size()) { + return; } - if (replayFastForwardButton) { - replayFastForwardButton->setShortcut(shortcuts.getSingleShortcut("Replays/fastForwardButton")); + + // find most recent meaningful event + int prevEvent = std::max(0, currentEvent - 1); + for (; prevEvent > 0 && !hasMeaningfulEvent(replay->event_list(prevEvent)); --prevEvent) { } + + int prevEventTime = replayTimeline.value(prevEvent); + if (currentVisualTime - prevEventTime <= EMPTY_SECTION_MARGIN_MS) { + return; + } + + // find next earliest meaningful event + int nextEvent = currentEvent; + for (; nextEvent < replayTimeline.size() - 1 && !hasMeaningfulEvent(replay->event_list(nextEvent)); ++nextEvent) { + } + + int nextEventTime = replayTimeline.value(nextEvent); + if (nextEventTime - currentVisualTime <= EMPTY_SECTION_MARGIN_MS) { + return; + } + + // skip forward if we're not within margin of either event + skipToTime(nextEventTime - EMPTY_SECTION_MARGIN_MS, false); +} + +void ReplayManager::setTimeScaleFactor(qreal _timeScaleFactor) +{ + timeScaleFactor = _timeScaleFactor; + int interval = std::max(1, qRound(TIMER_INTERVAL_MS / timeScaleFactor)); + replayTimer->setInterval(interval); +} + +void ReplayManager::setSkipEmptySections(bool value) +{ + skipEmptySections = value; +} + +void ReplayManager::startReplay() +{ + replayTimer->start(); +} + +void ReplayManager::stopReplay() +{ + replayTimer->stop(); +} + +void ReplayManager::setTime(int time) +{ + // don't buffer rewinds from clicks, since clicks usually don't happen fast enough to require buffering + skipToTime(time, false); +} + +void ReplayManager::skipByAmount(int amount) +{ + skipToTime(currentVisualTime + amount, amount < 0); } \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/replay/replay_manager.h b/cockatrice/src/interface/widgets/replay/replay_manager.h index a3e0126c7..81e66824d 100644 --- a/cockatrice/src/interface/widgets/replay/replay_manager.h +++ b/cockatrice/src/interface/widgets/replay/replay_manager.h @@ -1,49 +1,83 @@ -/** - * @file replay_manager.h - * @ingroup Core - * @ingroup Replay - */ -//! \todo Document this file. +#ifndef COCKATRICE_REPLAY_MANAGER_H +#define COCKATRICE_REPLAY_MANAGER_H -#ifndef REPLAY_MANAGER_H -#define REPLAY_MANAGER_H +#include "../../../game/player/event_processing_options.h" -#include "replay_timeline_widget.h" - -#include -#include +#include #include -class TabGame; +class GameReplay; +class QTimer; -class ReplayManager : public QWidget +/** + * @brief This class handles all logic to do with playing back replays + */ +class ReplayManager : public QObject { Q_OBJECT -public: - ReplayManager(TabGame *parent, GameReplay *replay); - TabGame *game; + enum PlaybackMode + { + NORMAL_PLAYBACK, + FORWARD_SKIP, + BACKWARD_SKIP + }; + GameReplay *replay; + QList replayTimeline; ///< timestamp of each event, with the indexes corresponding + int maxTime; -signals: - void requestChatAndPhaseReset(); - void eventReplayed(const GameEventContainer &cont, EventProcessingOptions options); + QTimer *replayTimer; + QTimer *rewindBufferingTimer; -private: - // Replay related members - int currentReplayStep = 0; - QList replayTimeline; - ReplayTimelineWidget *timelineWidget; - QToolButton *replayPlayButton, *replayFastForwardButton; - QAction *aReplaySkipForward, *aReplaySkipBackward, *aReplaySkipForwardBig, *aReplaySkipBackwardBig; + qreal timeScaleFactor = 1.0; + bool skipEmptySections = false; + + int currentVisualTime = 0; ///< time currently displayed by the timeline + int currentProcessedTime = 0; ///< time that events are currently processed up to. Could differ from visual time due + ///< to rewind buffering + int currentEvent = 0; ///< current event's index + + void skipToTime(int newTime, bool doRewindBuffering); + void handleBackwardsSkip(bool doRewindBuffering); + void processRewind(); + void processNewEvents(PlaybackMode playbackMode); + void handleSkipEmptySection(); private slots: - void replayNextEvent(EventProcessingOptions options); + void replayTimerTimeout(); + +public: + static constexpr int SMALL_SKIP_MS = 1000; + static constexpr int BIG_SKIP_MS = 10000; + + /** + * @param parent The parent QObject + * @param replay Cannot be null. Takes ownership of the object. + */ + explicit ReplayManager(QObject *parent, GameReplay *replay); + + ~ReplayManager() override; + + const QList &getReplayTimeline() const + { + return replayTimeline; + } + + void setTimeScaleFactor(qreal _timeScaleFactor); + void setSkipEmptySections(bool value); + +public slots: + void startReplay(); + void stopReplay(); + void setTime(int time); + void skipByAmount(int amount); // use a negative amount to skip backwards + +signals: + void timeChanged(int time); + void eventReplayed(const GameEventContainer &cont, EventProcessingOptions options); void replayFinished(); - void replayPlayButtonToggled(bool checked); - void replayFastForwardButtonToggled(bool checked); - void replayRewind(); - void refreshShortcuts(); + void rewound(); }; -#endif // REPLAY_MANAGER_H +#endif // COCKATRICE_REPLAY_MANAGER_H diff --git a/cockatrice/src/interface/widgets/replay/replay_quick_settings_widget.cpp b/cockatrice/src/interface/widgets/replay/replay_quick_settings_widget.cpp new file mode 100644 index 000000000..5d58705d2 --- /dev/null +++ b/cockatrice/src/interface/widgets/replay/replay_quick_settings_widget.cpp @@ -0,0 +1,58 @@ +#include "replay_quick_settings_widget.h" + +#include "../../../client/settings/cache_settings.h" + +#include +#include +#include +#include +#include + +ReplayQuickSettingsWidget::ReplayQuickSettingsWidget(QWidget *parent) : SettingsButtonWidget(parent) +{ + // fast forward speed + fastForwardSpeedBox.setMinimum(1); + fastForwardSpeedBox.setMaximum(99.9); + fastForwardSpeedBox.setDecimals(1); + fastForwardSpeedBox.setValue(SettingsCache::instance().userInterface().getFastForwardSpeed()); + connect(&fastForwardSpeedBox, qOverload(&QDoubleSpinBox::valueChanged), this, + &ReplayQuickSettingsWidget::actUpdateFastForwardSpeed); + + skipEmptyCheckBox.setChecked(SettingsCache::instance().userInterface().getSkipEmptySections()); + connect(&skipEmptyCheckBox, &QCheckBox::QT_STATE_CHANGED, this, + &ReplayQuickSettingsWidget::actUpdateSkipEmptySections); + + // putting it all together + auto *widget = new QWidget; + auto *grid = new QGridLayout(widget); + grid->setContentsMargins(0, 0, 0, 0); + grid->addWidget(&fastForwardSpeedLabel, 0, 0, 1, 1); + grid->addWidget(&fastForwardSpeedBox, 0, 1, 1, 1); + grid->addWidget(&skipEmptyCheckBox, 1, 0, 1, 2); + + this->addSettingsWidget(widget); + + connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, + &ReplayQuickSettingsWidget::retranslateUi); + retranslateUi(); +} + +void ReplayQuickSettingsWidget::retranslateUi() +{ + fastForwardSpeedLabel.setText(tr("Fast forward speed:")); + fastForwardSpeedBox.setSuffix("x"); + + skipEmptyCheckBox.setText(tr("Skip empty sections")); +} + +void ReplayQuickSettingsWidget::actUpdateFastForwardSpeed(qreal value) +{ + SettingsCache::instance().userInterface().setFastForwardSpeed(value); + emit fastForwardSpeedChanged(value); +} + +void ReplayQuickSettingsWidget::actUpdateSkipEmptySections(QT_STATE_CHANGED_T value) +{ + SettingsCache::instance().userInterface().setSkipEmptySections(value); + emit skipEmptySectionsChanged(value); +} diff --git a/cockatrice/src/interface/widgets/replay/replay_quick_settings_widget.h b/cockatrice/src/interface/widgets/replay/replay_quick_settings_widget.h new file mode 100644 index 000000000..a337ea0a6 --- /dev/null +++ b/cockatrice/src/interface/widgets/replay/replay_quick_settings_widget.h @@ -0,0 +1,34 @@ +#ifndef COCKATRICE_REPLAY_QUICK_SETTINGS_WIDGET_H +#define COCKATRICE_REPLAY_QUICK_SETTINGS_WIDGET_H + +#include "../../interface/widgets/quick_settings/settings_button_widget.h" + +#include +#include +#include + +class ReplayQuickSettingsWidget : public SettingsButtonWidget +{ + Q_OBJECT + +public: + explicit ReplayQuickSettingsWidget(QWidget *parent); + + void retranslateUi(); + +signals: + void fastForwardSpeedChanged(qreal speed); + void skipEmptySectionsChanged(bool skip); + +private: + QLabel fastForwardSpeedLabel; + QDoubleSpinBox fastForwardSpeedBox; + + QCheckBox skipEmptyCheckBox; + +private slots: + void actUpdateFastForwardSpeed(qreal value); + void actUpdateSkipEmptySections(QT_STATE_CHANGED_T value); +}; + +#endif // COCKATRICE_REPLAY_QUICK_SETTINGS_WIDGET_H diff --git a/cockatrice/src/interface/widgets/replay/replay_timeline_widget.cpp b/cockatrice/src/interface/widgets/replay/replay_timeline_widget.cpp index 46a3dff40..fe4ac330b 100644 --- a/cockatrice/src/interface/widgets/replay/replay_timeline_widget.cpp +++ b/cockatrice/src/interface/widgets/replay/replay_timeline_widget.cpp @@ -4,24 +4,19 @@ #include #include -#include -ReplayTimelineWidget::ReplayTimelineWidget(QWidget *parent) - : QWidget(parent), maxBinValue(1), maxTime(1), timeScaleFactor(1.0), currentVisualTime(0), currentProcessedTime(0), - currentEvent(0) +static constexpr int BIN_LENGTH = 5000; +static constexpr int MIN_RESOLUTION_MS = 1000; + +ReplayTimelineWidget::ReplayTimelineWidget(QWidget *parent) : QWidget(parent) { - replayTimer = new QTimer(this); - connect(replayTimer, &QTimer::timeout, this, &ReplayTimelineWidget::replayTimerTimeout); - - rewindBufferingTimer = new QTimer(this); - rewindBufferingTimer->setSingleShot(true); - connect(rewindBufferingTimer, &QTimer::timeout, this, &ReplayTimelineWidget::processRewind); } -void ReplayTimelineWidget::setTimeline(const QList &_replayTimeline) +void ReplayTimelineWidget::setTimeline(const QList &replayTimeline) { - replayTimeline = _replayTimeline; histogram.clear(); + currentTime = 0; + int binEndTime = BIN_LENGTH - 1; int binValue = 0; for (int i : replayTimeline) { @@ -64,74 +59,31 @@ void ReplayTimelineWidget::paintEvent(QPaintEvent * /* event */) painter.fillPath(path, Qt::black); const QColor barColor = QColor::fromHsv(120, 255, 255, 100); - quint64 w = (quint64)(width() - 1) * (quint64)currentVisualTime / maxTime; + quint64 w = (quint64)(width() - 1) * (quint64)currentTime / maxTime; painter.fillRect(0, 0, static_cast(w), height() - 1, barColor); } void ReplayTimelineWidget::mousePressEvent(QMouseEvent *event) { -#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) int newTime = static_cast((qint64)maxTime * (qint64)event->position().x() / width()); -#else - int newTime = static_cast((qint64)maxTime * (qint64)event->x() / width()); -#endif - // don't buffer rewinds from clicks, since clicks usually don't happen fast enough to require buffering - skipToTime(newTime, false); + emit timeClicked(newTime); } -void ReplayTimelineWidget::skipToTime(int newTime, bool doRewindBuffering) +void ReplayTimelineWidget::setCurrentTime(int time) { - // check boundary conditions - if (newTime < 0) { - newTime = 0; - } - if (newTime > maxTime) { - newTime = maxTime; + int newTime = qBound(0, time, maxTime); + + if (currentTime == newTime) { + return; } - newTime -= newTime % TIMER_INTERVAL_MS; // Time should always be a multiple of the interval + bool doUpdate = currentTime / MIN_RESOLUTION_MS != newTime / MIN_RESOLUTION_MS; - const bool isBackwardsSkip = newTime < currentProcessedTime; - currentVisualTime = newTime; + currentTime = newTime; - if (isBackwardsSkip) { - handleBackwardsSkip(doRewindBuffering); - } else { - processNewEvents(FORWARD_SKIP); + if (doUpdate) { + update(); } - - update(); -} - -/** - * @brief Handles a backwards skip in the replay timeline. - * - * @param doRewindBuffering When true, if multiple backward skips are made in quick succession, only a single rewind - * is processed at the end. When false, the backwards skip will always cause an immediate rewind. - */ -void ReplayTimelineWidget::handleBackwardsSkip(bool doRewindBuffering) -{ - if (doRewindBuffering) { - // We use a one-shot timer to implement the rewind buffering. - // The rewind only happens once the timer runs out. - // If another backwards skip happens, the timer will just get reset instead of rewinding. - rewindBufferingTimer->stop(); - rewindBufferingTimer->start(SettingsCache::instance().getRewindBufferingMs()); - } else { - // otherwise, process the rewind immediately - processRewind(); - } -} - -void ReplayTimelineWidget::processRewind() -{ - // stop any queued-up rewinds - rewindBufferingTimer->stop(); - - // process the rewind - currentEvent = 0; - emit rewound(); - processNewEvents(BACKWARD_SKIP); } QSize ReplayTimelineWidget::sizeHint() const @@ -143,63 +95,3 @@ QSize ReplayTimelineWidget::minimumSizeHint() const { return {400, 50}; } - -void ReplayTimelineWidget::replayTimerTimeout() -{ - currentVisualTime += TIMER_INTERVAL_MS; - - processNewEvents(NORMAL_PLAYBACK); - - if (!(currentVisualTime % 1000)) { - update(); - } -} - -/** @brief Processes all unprocessed events up to the current time. */ -void ReplayTimelineWidget::processNewEvents(PlaybackMode playbackMode) -{ - currentProcessedTime = currentVisualTime; - - while ((currentEvent < replayTimeline.size()) && (replayTimeline[currentEvent] < currentProcessedTime)) { - EventProcessingOptions options; - - // backwards skip => always skip reveal windows - // forwards skip => skip reveal windows that don't happen within a big skip of the target - if (playbackMode == BACKWARD_SKIP || currentProcessedTime - replayTimeline[currentEvent] > BIG_SKIP_MS) { - options |= SKIP_REVEAL_WINDOW; - } - - // backwards skip => always skip tap animation - if (playbackMode == BACKWARD_SKIP) { - options |= SKIP_TAP_ANIMATION; - } - - emit processNextEvent(options); - ++currentEvent; - } - if (currentEvent == replayTimeline.size()) { - emit replayFinished(); - replayTimer->stop(); - } -} - -void ReplayTimelineWidget::setTimeScaleFactor(qreal _timeScaleFactor) -{ - timeScaleFactor = _timeScaleFactor; - replayTimer->setInterval(static_cast(TIMER_INTERVAL_MS / timeScaleFactor)); -} - -void ReplayTimelineWidget::startReplay() -{ - replayTimer->start(static_cast(TIMER_INTERVAL_MS / timeScaleFactor)); -} - -void ReplayTimelineWidget::stopReplay() -{ - replayTimer->stop(); -} - -void ReplayTimelineWidget::skipByAmount(int amount) -{ - skipToTime(currentVisualTime + amount, amount < 0); -} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/replay/replay_timeline_widget.h b/cockatrice/src/interface/widgets/replay/replay_timeline_widget.h index 418d0c7e0..47d19a741 100644 --- a/cockatrice/src/interface/widgets/replay/replay_timeline_widget.h +++ b/cockatrice/src/interface/widgets/replay/replay_timeline_widget.h @@ -18,58 +18,25 @@ class QTimer; class ReplayTimelineWidget : public QWidget { Q_OBJECT + signals: - void processNextEvent(EventProcessingOptions options); - void replayFinished(); - void rewound(); + void timeClicked(int newTime); private: - enum PlaybackMode - { - NORMAL_PLAYBACK, - FORWARD_SKIP, - BACKWARD_SKIP - }; - - static constexpr int TIMER_INTERVAL_MS = 200; - static constexpr int BIN_LENGTH = 5000; - - QTimer *replayTimer; - QTimer *rewindBufferingTimer; - QList replayTimeline; QList histogram; - int maxBinValue, maxTime; - qreal timeScaleFactor; - int currentVisualTime; // time currently displayed by the timeline - int currentProcessedTime; // time that events are currently processed up to. Could differ from visual time due to - // rewind buffering - int currentEvent; + int maxBinValue = 1; + int maxTime = 1; - void skipToTime(int newTime, bool doRewindBuffering); - void handleBackwardsSkip(bool doRewindBuffering); - void processRewind(); - void processNewEvents(PlaybackMode playbackMode); -private slots: - void replayTimerTimeout(); + int currentTime = 0; public: - static constexpr int SMALL_SKIP_MS = 1000; - static constexpr int BIG_SKIP_MS = 10000; - static constexpr qreal FAST_FORWARD_SCALE_FACTOR = 10.0; - explicit ReplayTimelineWidget(QWidget *parent = nullptr); - void setTimeline(const QList &_replayTimeline); + void setTimeline(const QList &replayTimeline); [[nodiscard]] QSize sizeHint() const override; [[nodiscard]] QSize minimumSizeHint() const override; - void setTimeScaleFactor(qreal _timeScaleFactor); - [[nodiscard]] int getCurrentEvent() const - { - return currentEvent; - } + public slots: - void startReplay(); - void stopReplay(); - void skipByAmount(int amount); // use a negative amount to skip backwards + void setCurrentTime(int time); protected: void paintEvent(QPaintEvent *event) override; diff --git a/cockatrice/src/interface/widgets/replay/replay_widget.cpp b/cockatrice/src/interface/widgets/replay/replay_widget.cpp new file mode 100644 index 000000000..c92771f8c --- /dev/null +++ b/cockatrice/src/interface/widgets/replay/replay_widget.cpp @@ -0,0 +1,131 @@ +#include "replay_widget.h" + +#include "../../../client/settings/cache_settings.h" +#include "../../../client/settings/shortcuts_settings.h" +#include "../../pixel_map_generator.h" +#include "../interface/widgets/tabs/tab_game.h" +#include "replay_manager.h" +#include "replay_quick_settings_widget.h" + +#include +#include + +ReplayWidget::ReplayWidget(QWidget *parent, GameReplay *replay) + : QWidget(parent), replayPlayButton(nullptr), replayFastForwardButton(nullptr), aReplaySkipForward(nullptr), + aReplaySkipBackward(nullptr), aReplaySkipForwardBig(nullptr), aReplaySkipBackwardBig(nullptr) +{ + // replay manager + replayManager = new ReplayManager(this, replay); + connect(replayManager, &ReplayManager::eventReplayed, this, &ReplayWidget::eventReplayed); + connect(replayManager, &ReplayManager::replayFinished, this, &ReplayWidget::replayFinished); + connect(replayManager, &ReplayManager::rewound, this, &ReplayWidget::rewound); + + // timeline widget + timelineWidget = new ReplayTimelineWidget; + timelineWidget->setTimeline(replayManager->getReplayTimeline()); + connect(replayManager, &ReplayManager::timeChanged, timelineWidget, &ReplayTimelineWidget::setCurrentTime); + connect(timelineWidget, &ReplayTimelineWidget::timeClicked, replayManager, &ReplayManager::setTime); + + // timeline skip shortcuts + aReplaySkipForward = new QAction(timelineWidget); + timelineWidget->addAction(aReplaySkipForward); + connect(aReplaySkipForward, &QAction::triggered, this, + [this] { replayManager->skipByAmount(ReplayManager::SMALL_SKIP_MS); }); + + aReplaySkipBackward = new QAction(timelineWidget); + timelineWidget->addAction(aReplaySkipBackward); + connect(aReplaySkipBackward, &QAction::triggered, this, + [this] { replayManager->skipByAmount(-ReplayManager::SMALL_SKIP_MS); }); + + aReplaySkipForwardBig = new QAction(timelineWidget); + timelineWidget->addAction(aReplaySkipForwardBig); + connect(aReplaySkipForwardBig, &QAction::triggered, this, + [this] { replayManager->skipByAmount(ReplayManager::BIG_SKIP_MS); }); + + aReplaySkipBackwardBig = new QAction(timelineWidget); + timelineWidget->addAction(aReplaySkipBackwardBig); + connect(aReplaySkipBackwardBig, &QAction::triggered, this, + [this] { replayManager->skipByAmount(-ReplayManager::BIG_SKIP_MS); }); + + // buttons + replayPlayButton = new QToolButton; + replayPlayButton->setIconSize(QSize(32, 32)); + QIcon playButtonIcon = QIcon(); + playButtonIcon.addPixmap(themePixmap(QStringLiteral("replay/start")), QIcon::Normal, QIcon::Off); + playButtonIcon.addPixmap(themePixmap(QStringLiteral("replay/pause")), QIcon::Normal, QIcon::On); + replayPlayButton->setIcon(playButtonIcon); + replayPlayButton->setCheckable(true); + connect(replayPlayButton, &QToolButton::toggled, this, &ReplayWidget::replayPlayButtonToggled); + + replayFastForwardButton = new QToolButton; + replayFastForwardButton->setIconSize(QSize(32, 32)); + replayFastForwardButton->setIcon(themePixmap(QStringLiteral("replay/fastforward"))); + replayFastForwardButton->setCheckable(true); + connect(replayFastForwardButton, &QToolButton::toggled, this, &ReplayWidget::updateTimeScaleFactor); + + settingsWidget = new ReplayQuickSettingsWidget(this); + settingsWidget->setFixedSize(QSize(32, 32)); + connect(settingsWidget, &ReplayQuickSettingsWidget::fastForwardSpeedChanged, this, + [this] { updateTimeScaleFactor(replayFastForwardButton->isChecked()); }); + connect(settingsWidget, &ReplayQuickSettingsWidget::skipEmptySectionsChanged, replayManager, + &ReplayManager::setSkipEmptySections); + + replayManager->setSkipEmptySections(SettingsCache::instance().userInterface().getSkipEmptySections()); + + // putting everything together + auto replayControlLayout = new QHBoxLayout; + replayControlLayout->addWidget(timelineWidget, 10); + replayControlLayout->addWidget(replayPlayButton); + replayControlLayout->addWidget(replayFastForwardButton); + replayControlLayout->addWidget(settingsWidget); + + setObjectName("replayControlWidget"); + setLayout(replayControlLayout); + + connect(&SettingsCache::instance().shortcuts(), &ShortcutsSettings::shortCutChanged, this, + &ReplayWidget::refreshShortcuts); + refreshShortcuts(); +} + +void ReplayWidget::replayFinished() +{ + replayPlayButton->setChecked(false); +} + +void ReplayWidget::replayPlayButtonToggled(bool checked) +{ + if (checked) { // start replay + replayManager->startReplay(); + } else { // pause replay + replayManager->stopReplay(); + } +} + +void ReplayWidget::updateTimeScaleFactor(bool isFastForward) +{ + qreal factor = isFastForward ? SettingsCache::instance().userInterface().getFastForwardSpeed() : 1.0; + replayManager->setTimeScaleFactor(factor); +} + +void ReplayWidget::refreshShortcuts() +{ + ShortcutsSettings &shortcuts = SettingsCache::instance().shortcuts(); + if (aReplaySkipForward) { + aReplaySkipForward->setShortcuts(shortcuts.getShortcut("Replays/aSkipForward")); + } + if (aReplaySkipBackward) { + aReplaySkipBackward->setShortcuts(shortcuts.getShortcut("Replays/aSkipBackward")); + } + if (aReplaySkipForwardBig) { + aReplaySkipForwardBig->setShortcuts(shortcuts.getShortcut("Replays/aSkipForwardBig")); + } + if (aReplaySkipBackwardBig) { + aReplaySkipBackwardBig->setShortcuts(shortcuts.getShortcut("Replays/aSkipBackwardBig")); + } + if (replayPlayButton) { + replayPlayButton->setShortcut(shortcuts.getSingleShortcut("Replays/playButton")); + } + if (replayFastForwardButton) { + replayFastForwardButton->setShortcut(shortcuts.getSingleShortcut("Replays/fastForwardButton")); + } +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/replay/replay_widget.h b/cockatrice/src/interface/widgets/replay/replay_widget.h new file mode 100644 index 000000000..6d2b7a043 --- /dev/null +++ b/cockatrice/src/interface/widgets/replay/replay_widget.h @@ -0,0 +1,55 @@ +/** + * @file replay_widget.h + * @ingroup Core + * @ingroup Replay + */ +//! \todo Document this file. + +#ifndef REPLAY_WIDGET_H +#define REPLAY_WIDGET_H + +#include "replay_timeline_widget.h" + +#include +#include +#include + +class ReplayManager; +class ReplayQuickSettingsWidget; +class TabGame; + +/** + * @brief The top-level widget that is put in the replay dock widget. + * Contains the replay timeline as well as the buttons. + */ +class ReplayWidget : public QWidget +{ + Q_OBJECT + +public: + /** + * @param parent The parent widget + * @param replay Cannot be null. Takes ownership of the replay. + */ + ReplayWidget(QWidget *parent, GameReplay *replay); + +signals: + void rewound(); + void eventReplayed(const GameEventContainer &cont, EventProcessingOptions options); + +private: + ReplayManager *replayManager; + + ReplayTimelineWidget *timelineWidget; + QToolButton *replayPlayButton, *replayFastForwardButton; + ReplayQuickSettingsWidget *settingsWidget; + QAction *aReplaySkipForward, *aReplaySkipBackward, *aReplaySkipForwardBig, *aReplaySkipBackwardBig; + +private slots: + void replayFinished(); + void replayPlayButtonToggled(bool checked); + void updateTimeScaleFactor(bool checked); + void refreshShortcuts(); +}; + +#endif // REPLAY_WIDGET_H diff --git a/cockatrice/src/interface/widgets/server/chat_view/chat_view.cpp b/cockatrice/src/interface/widgets/server/chat_view/chat_view.cpp index ccbbe9246..bebc2e3c4 100644 --- a/cockatrice/src/interface/widgets/server/chat_view/chat_view.cpp +++ b/cockatrice/src/interface/widgets/server/chat_view/chat_view.cpp @@ -13,7 +13,12 @@ #include #include #include +#include +#include +#include +#include #include +#include const QColor DEFAULT_MENTION_COLOR = QColor(194, 31, 47); @@ -46,9 +51,12 @@ ChatView::ChatView(TabSupervisor *_tabSupervisor, AbstractGame *_game, bool _sho viewport()->setCursor(Qt::IBeamCursor); setReadOnly(true); - setTextInteractionFlags(Qt::TextSelectableByMouse | Qt::LinksAccessibleByMouse); + setTextInteractionFlags(Qt::TextSelectableByMouse | Qt::LinksAccessibleByMouse | Qt::LinksAccessibleByKeyboard); setOpenLinks(false); connect(this, &ChatView::anchorClicked, this, &ChatView::openLink); + + connect(verticalScrollBar(), &QScrollBar::rangeChanged, this, &ChatView::onScrollBarRangeChanged); + connect(verticalScrollBar(), &QScrollBar::valueChanged, this, &ChatView::onScrollBarValueChanged); } void ChatView::adjustColorsToPalette() @@ -61,12 +69,14 @@ void ChatView::adjustColorsToPalette() serverMessageColor = QColor(0xFF, 0x73, 0x83); otherUserColor = otherUserColor.lighter(150); linkColor = QColor(71, 158, 252); + unresolvedCardTagColor = QColor(0xFF, 0xA5, 0x00); } else { document()->setDefaultStyleSheet(R"( a { text-decoration: none; color: blue; } .blue { color: blue } )"); linkColor = palette().link().color(); + unresolvedCardTagColor = QColor(0xA0, 0x52, 0x2D); } QTimer::singleShot(0, this, &ChatView::refreshBlockColors); @@ -147,7 +157,7 @@ void ChatView::appendHtml(const QString &html) bool atBottom = verticalScrollBar()->value() >= verticalScrollBar()->maximum(); prepareBlock().insertHtml(html); if (atBottom) { - verticalScrollBar()->setValue(verticalScrollBar()->maximum()); + scrollToBottom(); } } @@ -165,20 +175,29 @@ void ChatView::appendHtmlServerMessage(const QString &html, bool optionalIsBold, prepareBlock().insertHtml(htmlText); if (atBottom) { - verticalScrollBar()->setValue(verticalScrollBar()->maximum()); + scrollToBottom(); } } void ChatView::appendCardTag(QTextCursor &cursor, const QString &cardName) { QTextCharFormat oldFormat = cursor.charFormat(); - QTextCharFormat anchorFormat = oldFormat; - anchorFormat.setForeground(linkColor); - anchorFormat.setAnchor(true); - anchorFormat.setAnchorHref("card://" + cardName); - anchorFormat.setFontItalic(true); + QTextCharFormat cardFormat = oldFormat; + cardFormat.setFontItalic(true); - cursor.setCharFormat(anchorFormat); + if (!CardDatabaseManager::query()->lookupCardByName(cardName)) { + cardFormat.setForeground(unresolvedCardTagColor); + cursor.setCharFormat(cardFormat); + cursor.insertText(cardName); + cursor.setCharFormat(oldFormat); + return; + } + + cardFormat.setForeground(linkColor); + cardFormat.setAnchor(true); + cardFormat.setAnchorHref("card://" + cardName); + + cursor.setCharFormat(cardFormat); cursor.insertText(cardName); cursor.setCharFormat(oldFormat); } @@ -202,6 +221,46 @@ void ChatView::appendUrlTag(QTextCursor &cursor, QString url) cursor.setCharFormat(oldFormat); } +void ChatView::appendGameLinkTag(QTextCursor &cursor, const QString &url) +{ + const QUrl gameUrl(url); + const QUrlQuery query(gameUrl); + const QString hostname = query.queryItemValue("hostname"); + // FullyDecoded undoes every %XX escape, so a description that itself + // contains "%" cannot end up displayed as "%25" in the label. + const QString description = query.queryItemValue("game", QUrl::FullyDecoded); + const int gameId = query.queryItemValue("gameid").toInt(); + + QString label; + if (gameId > 0 && !hostname.isEmpty()) { + // Links built before the description was embedded stay readable: the + // id + server fallback below is identical to the old anchor text. + if (!description.isEmpty()) { + // Multi-arg .arg() replaces all placeholders in a single pass, so a + // description containing "%…" cannot corrupt later placeholders. + label = tr("Join game \"%1\" (#%2) on %3").arg(description, QString::number(gameId), hostname); + } else { + label = tr("Join game #%1 on %2").arg(QString::number(gameId), hostname); + } + } else { + label = tr("Join game"); + } + + QTextCharFormat oldFormat = cursor.charFormat(); + QTextCharFormat gameLinkFormat = oldFormat; + gameLinkFormat.setForeground(linkColor); + gameLinkFormat.setFontWeight(QFont::Bold); + gameLinkFormat.setAnchor(true); + gameLinkFormat.setAnchorHref(url); + QColor background = palette().highlight().color(); + background.setAlpha(40); + gameLinkFormat.setBackground(background); + + cursor.setCharFormat(gameLinkFormat); + cursor.insertText(label); + cursor.setCharFormat(oldFormat); +} + void ChatView::appendMessage(QString message, RoomMessageTypeFlags messageType, const ServerInfo_User &userInfo, @@ -214,6 +273,14 @@ void ChatView::appendMessage(QString message, // messageType should be Event_RoomSay::UserMessage though we don't actually check bool isUserMessage = !(userName.toLower() == "servatrice" || userName.isEmpty()); bool sameSender = isUserMessage && userName == lastSender; + + if (isUserMessage) { + chatHistory.append({userName, message, QDateTime::currentDateTime()}); + while (chatHistory.size() > MAX_CHAT_HISTORY) { + chatHistory.removeFirst(); + } + } + QTextCursor cursor = prepareBlock(sameSender); lastSender = userName; @@ -292,8 +359,8 @@ void ChatView::appendMessage(QString message, } cursor.setCharFormat(defaultFormat); - bool mentionEnabled = SettingsCache::instance().getChatMention(); - highlightedWords = SettingsCache::instance().getHighlightWords().split(' ', Qt::SkipEmptyParts); + bool mentionEnabled = SettingsCache::instance().chat().getChatMention(); + highlightedWords = SettingsCache::instance().chat().getHighlightWords().split(' ', Qt::SkipEmptyParts); // parse the message while (message.size()) { @@ -325,11 +392,36 @@ void ChatView::appendMessage(QString message, } } - if (atBottom) { + // ChatHistory messages are only ever sent once per room, right after joining, before the user can + // interact with the view. Always scroll to the bottom so the whole history is visible on join. + if (atBottom || messageType.testFlag(Event_RoomSay::ChatHistory)) { + scrollToBottom(); + } +} + +void ChatView::scrollToBottom() +{ + // The document layout, and therefore the scrollbar range, may be updated asynchronously (e.g. while + // the chat history is loaded into a view that has not been laid out yet). Setting the value once is + // not enough: keep stickToBottom set so any later range change scrolls to the new maximum as well. + stickToBottom = true; + verticalScrollBar()->setValue(verticalScrollBar()->maximum()); +} + +void ChatView::onScrollBarRangeChanged() +{ + if (stickToBottom) { verticalScrollBar()->setValue(verticalScrollBar()->maximum()); } } +void ChatView::onScrollBarValueChanged(int value) +{ + if (value < verticalScrollBar()->maximum()) { + stickToBottom = false; + } +} + void ChatView::checkTag(QTextCursor &cursor, QString &message) { if (message.startsWith("[card]")) { @@ -395,8 +487,9 @@ void ChatView::checkMention(QTextCursor &cursor, QString &message, const QString // You have received a valid mention!! soundEngine->playSound("chat_mention"); mentionFormat.setBackground(QBrush(getCustomMentionColor())); - mentionFormat.setForeground(SettingsCache::instance().getChatMentionForeground() ? QBrush(Qt::white) - : QBrush(Qt::black)); + mentionFormat.setForeground(SettingsCache::instance().chat().getChatMentionForeground() + ? QBrush(Qt::white) + : QBrush(Qt::black)); cursor.insertText(mention, mentionFormat); message = message.mid(mention.size()); showSystemPopup(userName); @@ -417,8 +510,8 @@ void ChatView::checkMention(QTextCursor &cursor, QString &message, const QString // Moderator Sending Global Message soundEngine->playSound("all_mention"); mentionFormat.setBackground(QBrush(getCustomMentionColor())); - mentionFormat.setForeground(SettingsCache::instance().getChatMentionForeground() ? QBrush(Qt::white) - : QBrush(Qt::black)); + mentionFormat.setForeground( + SettingsCache::instance().chat().getChatMentionForeground() ? QBrush(Qt::white) : QBrush(Qt::black)); cursor.insertText("@" + fullMentionUpToSpaceOrEnd, mentionFormat); message = message.mid(fullMentionUpToSpaceOrEnd.size() + 1); showSystemPopup(userName); @@ -460,13 +553,24 @@ void ChatView::checkWord(QTextCursor &cursor, QString &message) } } + if (fullWordUpToSpaceOrEnd.startsWith("cockatrice://", Qt::CaseInsensitive)) { + // Only links to a game (cockatrice://joingame) become invite buttons; + // any other cockatrice:// scheme falls through to plain text below. + const QUrl gameLink(fullWordUpToSpaceOrEnd); + if (gameLink.host().compare("joingame", Qt::CaseInsensitive) == 0) { + appendGameLinkTag(cursor, fullWordUpToSpaceOrEnd); + cursor.insertText(rest, defaultFormat); + return; + } + } + // check word mentions for (const QString &word : highlightedWords) { if (fullWordUpToSpaceOrEnd.compare(word, Qt::CaseInsensitive) == 0) { // You have received a valid mention of custom word!! highlightFormat.setBackground(QBrush(getCustomHighlightColor())); - highlightFormat.setForeground(SettingsCache::instance().getChatHighlightForeground() ? QBrush(Qt::white) - : QBrush(Qt::black)); + highlightFormat.setForeground( + SettingsCache::instance().chat().getChatHighlightForeground() ? QBrush(Qt::white) : QBrush(Qt::black)); cursor.insertText(fullWordUpToSpaceOrEnd, highlightFormat); cursor.insertText(rest, defaultFormat); QApplication::alert(this); @@ -522,7 +626,7 @@ void ChatView::actMessageClicked() void ChatView::showSystemPopup(const QString &userName) { QApplication::alert(this); - if (SettingsCache::instance().getShowMentionPopup()) { + if (SettingsCache::instance().chat().getShowMentionPopup()) { emit showMentionPopup(userName); } } @@ -530,10 +634,10 @@ void ChatView::showSystemPopup(const QString &userName) QColor ChatView::getCustomMentionColor() { #if (QT_VERSION >= QT_VERSION_CHECK(6, 4, 0)) - QColor customColor = QColor::fromString("#" + SettingsCache::instance().getChatMentionColor()); + QColor customColor = QColor::fromString("#" + SettingsCache::instance().chat().getChatMentionColor()); #else QColor customColor; - customColor.setNamedColor("#" + SettingsCache::instance().getChatMentionColor()); + customColor.setNamedColor("#" + SettingsCache::instance().chat().getChatMentionColor()); #endif return customColor.isValid() ? customColor : DEFAULT_MENTION_COLOR; } @@ -541,10 +645,10 @@ QColor ChatView::getCustomMentionColor() QColor ChatView::getCustomHighlightColor() { #if (QT_VERSION >= QT_VERSION_CHECK(6, 4, 0)) - QColor customColor = QColor::fromString("#" + SettingsCache::instance().getChatMentionColor()); + QColor customColor = QColor::fromString("#" + SettingsCache::instance().chat().getChatMentionColor()); #else QColor customColor; - customColor.setNamedColor("#" + SettingsCache::instance().getChatMentionColor()); + customColor.setNamedColor("#" + SettingsCache::instance().chat().getChatMentionColor()); #endif return customColor.isValid() ? customColor : DEFAULT_MENTION_COLOR; } @@ -554,6 +658,19 @@ void ChatView::clearChat() document()->clear(); lastSender = ""; evenNumber = true; + chatHistory.clear(); +} + +QString ChatView::getRecentChatLog(int maxMessages) const +{ + QStringList lines; + int start = qMax(0, chatHistory.size() - maxMessages); + for (int i = start; i < chatHistory.size(); ++i) { + const ChatLogEntry &entry = chatHistory.at(i); + lines.append( + QString("[%1] %2: %3").arg(entry.timestamp.toString("hh:mm:ss")).arg(entry.userName).arg(entry.message)); + } + return lines.join("\n"); } void ChatView::redactMessages(const QString &userName, int amount) @@ -578,11 +695,7 @@ void ChatView::redactMessages(const QString &userName, int amount) } } -#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) void ChatView::enterEvent(QEnterEvent * /*event*/) -#else -void ChatView::enterEvent(QEvent * /*event*/) -#endif { setMouseTracking(true); } @@ -639,12 +752,9 @@ void ChatView::mousePressEvent(QMouseEvent *event) { switch (hoveredItemType) { case HoveredCard: { - if ((event->button() == Qt::MiddleButton) || (event->button() == Qt::LeftButton)) -#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) + if ((event->button() == Qt::MiddleButton) || (event->button() == Qt::LeftButton)) { emit showCardInfoPopup(event->globalPosition().toPoint(), {hoveredContent}); -#else - emit showCardInfoPopup(event->globalPos(), {hoveredContent}); -#endif + } break; } case HoveredUser: { @@ -654,11 +764,7 @@ void ChatView::mousePressEvent(QMouseEvent *event) switch (event->button()) { case Qt::RightButton: { UserLevelFlags userLevel(hoveredContent.left(delimiterIndex).toInt()); -#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) userContextMenu->showContextMenu(event->globalPosition().toPoint(), userName, userLevel, this); -#else - userContextMenu->showContextMenu(event->globalPos(), userName, userLevel, this); -#endif break; } case Qt::LeftButton: { @@ -692,6 +798,11 @@ void ChatView::mouseReleaseEvent(QMouseEvent *event) void ChatView::openLink(const QUrl &link) { + if (link.scheme() == "cockatrice") { + emit cockatriceLinkActivated(link.toString(QUrl::FullyEncoded)); + return; + } + if ((link.scheme() == "card") || (link.scheme() == "user")) { return; } diff --git a/cockatrice/src/interface/widgets/server/chat_view/chat_view.h b/cockatrice/src/interface/widgets/server/chat_view/chat_view.h index c5ae2b81a..671b35163 100644 --- a/cockatrice/src/interface/widgets/server/chat_view/chat_view.h +++ b/cockatrice/src/interface/widgets/server/chat_view/chat_view.h @@ -33,6 +33,13 @@ public: QTextBlock block; }; +struct ChatLogEntry +{ + QString userName; + QString message; + QDateTime timestamp; +}; + class ChatView : public QTextBrowser { Q_OBJECT @@ -60,15 +67,20 @@ private: QStringList highlightedWords; bool evenNumber; bool showTimestamps; + bool stickToBottom = false; HoveredItemType hoveredItemType; QString hoveredContent; QAction *messageClicked; QMap> userMessagePositions; + QList chatHistory; + static constexpr int MAX_CHAT_HISTORY = 200; [[nodiscard]] QTextFragment getFragmentUnderMouse(const QPoint &pos) const; QTextCursor prepareBlock(bool same = false); + void scrollToBottom(); void appendCardTag(QTextCursor &cursor, const QString &cardName); void appendUrlTag(QTextCursor &cursor, QString url); + void appendGameLinkTag(QTextCursor &cursor, const QString &url); static QColor getCustomMentionColor(); static QColor getCustomHighlightColor(); void showSystemPopup(const QString &userName); @@ -81,12 +93,15 @@ private: QColor otherUserColor = QColor(0, 65, 255); // dark blue QColor serverMessageColor = QColor(0x85, 0x15, 0x15); QColor linkColor; + QColor unresolvedCardTagColor; private slots: void openLink(const QUrl &link); void actMessageClicked(); void adjustColorsToPalette(); void refreshBlockColors(); + void onScrollBarRangeChanged(); + void onScrollBarValueChanged(int value); public: ChatView(TabSupervisor *_tabSupervisor, AbstractGame *_game, bool _showTimestamps, QWidget *parent = nullptr); @@ -101,13 +116,10 @@ public: bool playerBold = false); void clearChat(); void redactMessages(const QString &userName, int amount); + QString getRecentChatLog(int maxMessages = 50) const; protected: -#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) void enterEvent(QEnterEvent *event) override; -#else - void enterEvent(QEvent *event) override; -#endif void leaveEvent(QEvent *event) override; void mouseMoveEvent(QMouseEvent *event) override; void mousePressEvent(QMouseEvent *event) override; @@ -120,6 +132,7 @@ signals: void addMentionTag(QString mentionTag); void messageClickedSignal(); void showMentionPopup(const QString &userName); + void cockatriceLinkActivated(const QString &url); }; #endif diff --git a/cockatrice/src/interface/widgets/server/game_filter_configs.h b/cockatrice/src/interface/widgets/server/game_filter_configs.h index 0ece7e00c..5cb048669 100644 --- a/cockatrice/src/interface/widgets/server/game_filter_configs.h +++ b/cockatrice/src/interface/widgets/server/game_filter_configs.h @@ -19,7 +19,7 @@ struct GameFilterConfigs bool hideNotBuddyCreatedGames = false; bool hideOpenDecklistGames = false; QString gameNameFilter = ""; - QStringList creatorNameFilters = {}; + QStringList hostNameFilters = {}; QSet gameTypeFilter = {}; int maxPlayersFilterMin = DEFAULT_MAX_PLAYERS_MIN; int maxPlayersFilterMax = DEFAULT_MAX_PLAYERS_MAX; diff --git a/cockatrice/src/interface/widgets/server/game_link.cpp b/cockatrice/src/interface/widgets/server/game_link.cpp new file mode 100644 index 000000000..c866f6571 --- /dev/null +++ b/cockatrice/src/interface/widgets/server/game_link.cpp @@ -0,0 +1,23 @@ +#include "game_link.h" + +#include +#include + +QString makeGameJoinLink(const QString &hostname, int port, int roomId, int gameId, const QString &description) +{ + QUrl url; + url.setScheme("cockatrice"); + url.setHost("joingame"); + QUrlQuery query; + query.addQueryItem("hostname", hostname); + query.addQueryItem("port", QString::number(port)); + query.addQueryItem("roomid", QString::number(roomId)); + query.addQueryItem("gameid", QString::number(gameId)); + if (!description.isEmpty()) { + // addQueryItem percent-encodes, so arbitrary descriptions (quotes, + // ampersands, non-ASCII…) survive the trip through chat. + query.addQueryItem("game", description); + } + url.setQuery(query); + return url.toString(QUrl::FullyEncoded); +} diff --git a/cockatrice/src/interface/widgets/server/game_link.h b/cockatrice/src/interface/widgets/server/game_link.h new file mode 100644 index 000000000..d57bbd6d2 --- /dev/null +++ b/cockatrice/src/interface/widgets/server/game_link.h @@ -0,0 +1,41 @@ +/** + * @file game_link.h + * @ingroup UI + * @brief Builds cockatrice://joingame links that let another user join a server game. + */ + +#ifndef GAME_LINK_H +#define GAME_LINK_H + +#include + +/** + * Builds a cockatrice://joingame link for the given server game. The receiver's + * client opens it through the intent chain (connect -> join room -> join game). + * @p description, when non-empty, is embedded in the link as the URL-encoded + * "game" query item so the receiving client can name the game in its confirm + * prompt and chat anchor instead of only its numeric id. Links built without it + * stay valid: the parser and chat renderer fall back to the id alone. + */ +QString +makeGameJoinLink(const QString &hostname, int port, int roomId, int gameId, const QString &description = QString()); + +/** + * One game the inviter is currently in and can invite another user to. + * @p label is meant for display in menus, @p url is the ready-made invite link. + * @p description is the raw game description for building tr()-wrapped invite + * messages (the label already embeds it, but the send sites need the raw value). + * @p onlyBuddies and @p creatorName mirror the server game's room settings so + * callers can gate the invite to the creator's buddies. + */ +struct GameInviteOption +{ + int gameId = 0; + QString label; + QString url; + QString description; + bool onlyBuddies = false; + QString creatorName; +}; + +#endif // GAME_LINK_H diff --git a/cockatrice/src/interface/widgets/server/game_selector.cpp b/cockatrice/src/interface/widgets/server/game_selector.cpp index 9a41ca6ce..659325987 100644 --- a/cockatrice/src/interface/widgets/server/game_selector.cpp +++ b/cockatrice/src/interface/widgets/server/game_selector.cpp @@ -1,5 +1,6 @@ #include "game_selector.h" +#include "../../pixel_map_generator.h" #include "../interface/widgets/dialogs/dlg_create_game.h" #include "../interface/widgets/dialogs/dlg_filter_games.h" #include "../interface/widgets/tabs/tab_account.h" @@ -7,10 +8,13 @@ #include "../interface/widgets/tabs/tab_room.h" #include "../interface/widgets/tabs/tab_supervisor.h" #include "../interface/widgets/utility/get_text_with_max.h" +#include "game_link.h" #include "games_model.h" #include "user/user_list_manager.h" +#include #include +#include #include #include #include @@ -21,6 +25,8 @@ #include #include #include +#include +#include GameSelector::GameSelector(AbstractClient *_client, TabSupervisor *_tabSupervisor, @@ -78,20 +84,22 @@ GameSelector::GameSelector(AbstractClient *_client, if (showFilters && restoresettings) { quickFilterToolBar = new GameSelectorQuickFilterToolBar(this, tabSupervisor, gameListProxyModel, gameTypeMap); quickFilterToolBar->setVisible(showFilters && restoresettings && - SettingsCache::instance().getShowGameSelectorFilterToolbar()); + SettingsCache::instance().userInterface().getShowGameSelectorFilterToolbar()); - connect(&SettingsCache::instance(), &SettingsCache::showGameSelectorFilterToolbarChanged, this, [this] { - quickFilterToolBar->setVisible(SettingsCache::instance().getShowGameSelectorFilterToolbar()); - }); + connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::showGameSelectorFilterToolbarChanged, + this, [this] { + quickFilterToolBar->setVisible( + SettingsCache::instance().userInterface().getShowGameSelectorFilterToolbar()); + }); } else { quickFilterToolBar = nullptr; } filterButton = new QPushButton; - filterButton->setIcon(QPixmap("theme:icons/search")); + filterButton->setIcon(themePixmap(QStringLiteral("icons/search"))); connect(filterButton, &QPushButton::clicked, this, &GameSelector::actSetFilter); clearFilterButton = new QPushButton; - clearFilterButton->setIcon(QPixmap("theme:icons/clearsearch")); + clearFilterButton->setIcon(themePixmap(QStringLiteral("icons/clearsearch"))); bool filtersSetToDefault = showFilters && gameListProxyModel->areFilterParametersSetToDefaults(); clearFilterButton->setEnabled(!filtersSetToDefault); connect(clearFilterButton, &QPushButton::clicked, this, &GameSelector::actClearFilter); @@ -312,6 +320,14 @@ void GameSelector::customContextMenu(const QPoint &point) dlg.exec(); }); + QAction copyLink(tr("Cop&y game link")); + connect(©Link, &QAction::triggered, this, [=, this]() { + const ServerInfo_Game &gameInfo = gameListModel->getGame(index.data(Qt::UserRole).toInt()); + QGuiApplication::clipboard()->setText(makeGameJoinLink(client->serverName(), client->serverPort(), + gameInfo.room_id(), gameInfo.game_id(), + QString::fromStdString(gameInfo.description()))); + }); + QMenu menu; menu.addAction(&joinGame); @@ -329,6 +345,11 @@ void GameSelector::customContextMenu(const QPoint &point) menu.addAction(&spectateGame); menu.addAction(&getGameInfo); + + if (!client->serverName().isEmpty()) { + menu.addAction(©Link); + } + menu.exec(gameListView->mapToGlobal(point)); } @@ -339,18 +360,40 @@ void GameSelector::joinGame(const bool asSpectator, const bool asJudge) return; } - const ServerInfo_Game &game = gameListModel->getGame(ind.data(Qt::UserRole).toInt()); + joinGame(gameListModel->getGame(ind.data(Qt::UserRole).toInt()), asSpectator, asJudge); +} + +void GameSelector::joinGame(const ServerInfo_Game &game, const bool asSpectator, const bool asJudge) +{ if (tabSupervisor->switchToGameTabIfAlreadyExists(game.game_id())) { return; } - bool spectator = asSpectator || game.player_count() == game.max_players(); + bool overrideRestrictions = tabSupervisor->canOverrideGameRestrictions(); + + // Joining a full game without override privileges silently becomes a + // spectator join, so ask first instead of surprising the player. + const bool gameFull = game.player_count() == game.max_players(); + if (gameFull && !asSpectator && !asJudge && !overrideRestrictions) { + const QMessageBox::StandardButton answer = + QMessageBox::question(this, tr("Join game"), tr("The game is full. Join as a spectator instead?"), + QMessageBox::Yes | QMessageBox::No, QMessageBox::No); + if (answer != QMessageBox::Yes) { + return; + } + } + + bool spectator = asSpectator || gameFull; - bool overrideRestrictions = !tabSupervisor->getAdminLocked(); QString password; if (game.with_password() && !(spectator && !game.spectators_need_password()) && !overrideRestrictions) { bool ok; - password = getTextWithMax(this, tr("Join game"), tr("Password:"), QLineEdit::Password, QString(), &ok); + // Games without a description have no sensible label — fall back to the + // game id so the prompt still tells the user which game they're entering. + const QString gameLabel = QString::fromStdString(game.description()); + const QString prompt = gameLabel.isEmpty() ? tr("Password for game #%1:").arg(game.game_id()) + : tr("Password for \"%1\":").arg(gameLabel); + password = getTextWithMax(this, tr("Join game"), prompt, QLineEdit::Password, QString(), &ok); if (!ok) { return; } @@ -376,6 +419,22 @@ void GameSelector::joinGame(const bool asSpectator, const bool asJudge) disableButtons(); } +bool GameSelector::joinGameById(const int gameId, const bool asSpectator) +{ + for (int row = 0; row < gameListModel->rowCount(); ++row) { + const ServerInfo_Game &game = gameListModel->getGame(row); + if (game.game_id() != gameId) { + continue; + } + + joinGame(game, asSpectator); + return true; + } + + qWarning() << "Game" << gameId << "not found"; + return false; +} + void GameSelector::disableButtons() { if (createButton) { @@ -403,7 +462,7 @@ void GameSelector::enableButtonsForIndex(const QModelIndex ¤t) } const ServerInfo_Game &game = gameListModel->getGame(current.data(Qt::UserRole).toInt()); - bool overrideRestrictions = !tabSupervisor->getAdminLocked(); + bool overrideRestrictions = tabSupervisor->canOverrideGameRestrictions(); spectateButton->setEnabled(game.spectators_allowed() || overrideRestrictions); joinButton->setEnabled(game.player_count() < game.max_players() || overrideRestrictions); diff --git a/cockatrice/src/interface/widgets/server/game_selector.h b/cockatrice/src/interface/widgets/server/game_selector.h index fa91e5f96..9af39cf50 100644 --- a/cockatrice/src/interface/widgets/server/game_selector.h +++ b/cockatrice/src/interface/widgets/server/game_selector.h @@ -171,6 +171,17 @@ private: */ void joinGame(bool asSpectator = false, bool asJudge = false); + /** + * @brief Performs the join or spectate action for a specific game. + * @param game The game to join. + * @param asSpectator True to join as a spectator, false to join as a player. + * @param asJudge True to join as a judge, false to join as a player. + * + * Unlike the selection-based overload, this does not depend on the game being + * visible in the filtered game list. + */ + void joinGame(const ServerInfo_Game &game, bool asSpectator = false, bool asJudge = false); + public: /** * @brief Constructs a GameSelector widget. @@ -202,6 +213,16 @@ public: * @param info The ServerInfo_Game object containing information about the game to update. */ void processGameInfo(const ServerInfo_Game &info); + /** + * @brief Finds a game by ID and joins or spectates it. + * @param gameId The ID of the game to join. + * @param asSpectator True to join as a spectator, false to join as a player. + * @return True if the game was found and joined, false otherwise. + * + * Unlike the selection-based overload, this does not depend on the game + * being visible in the filtered game list. + */ + bool joinGameById(int gameId, bool asSpectator = false); }; #endif diff --git a/cockatrice/src/interface/widgets/server/games_model.cpp b/cockatrice/src/interface/widgets/server/games_model.cpp index 1d30aa7ac..127badbb2 100644 --- a/cockatrice/src/interface/widgets/server/games_model.cpp +++ b/cockatrice/src/interface/widgets/server/games_model.cpp @@ -10,19 +10,34 @@ #include #include #include +#include enum GameListColumn { ROOM, CREATED, DESCRIPTION, - CREATOR, + HOST, GAME_TYPE, RESTRICTIONS, PLAYERS, SPECTATORS }; +namespace +{ +/** + * @brief Returns the user info of the game's current host, falling back to the creator. + * + * The server only sends host_info once a host transfer has happened, so older + * servers and freshly created games fall back to the original creator. + */ +const ServerInfo_User &getGameHost(const ServerInfo_Game &game) +{ + return game.has_host_info() ? game.host_info() : game.creator_info(); +} +} // namespace + const QString GamesModel::getGameCreatedString(const int secs) { static const QTime zeroTime{0, 0}; @@ -109,16 +124,16 @@ QVariant GamesModel::data(const QModelIndex &index, int role) const default: return QVariant(); } - case CREATOR: { + case HOST: { switch (role) { case SORT_ROLE: case Qt::DisplayRole: - return QString::fromStdString(gameentry.creator_info().name()); + return QString::fromStdString(getGameHost(gameentry).name()); case Qt::DecorationRole: { - return UserLevelPixmapGenerator::generateIcon( - 13, UserLevelFlags(gameentry.creator_info().user_level()), - gameentry.creator_info().pawn_colors(), false, - QString::fromStdString(gameentry.creator_info().privlevel())); + const ServerInfo_User &host = getGameHost(gameentry); + return UserLevelPixmapGenerator::generateIcon(13, UserLevelFlags(host.user_level()), + host.pawn_colors(), false, + QString::fromStdString(host.privlevel())); } default: return QVariant(); @@ -232,8 +247,8 @@ QVariant GamesModel::headerData(int section, Qt::Orientation /*orientation*/, in } case DESCRIPTION: return tr("Description"); - case CREATOR: - return tr("Creator"); + case HOST: + return tr("Host"); case GAME_TYPE: return tr("Type"); case RESTRICTIONS: @@ -270,6 +285,9 @@ void GamesModel::updateGameList(const ServerInfo_Game &game) gameList.removeAt(i); endRemoveRows(); } else { + // MergeFrom concatenates repeated fields instead of replacing them, + // so clear game_types first to avoid duplicated entries. + gameList[i].clear_game_types(); gameList[i].MergeFrom(game); emit dataChanged(index(i, 0), index(i, NUM_COLS - 1)); } @@ -346,7 +364,7 @@ void GamesProxyModel::loadFilterParameters(const QMap &allGameType gameFilters.isHideFullGames(), gameFilters.isHideGamesThatStarted(), gameFilters.isHidePasswordProtectedGames(), gameFilters.isHideNotBuddyCreatedGames(), gameFilters.isHideOpenDecklistGames(), gameFilters.getGameNameFilter(), - gameFilters.getCreatorNameFilters(), newGameTypeFilter, gameFilters.getMinPlayers(), + gameFilters.getHostNameFilters(), newGameTypeFilter, gameFilters.getMinPlayers(), gameFilters.getMaxPlayers(), gameFilters.getMaxGameAge(), gameFilters.isShowOnlyIfSpectatorsCanWatch(), gameFilters.isShowSpectatorPasswordProtected(), gameFilters.isShowOnlyIfSpectatorsCanChat(), gameFilters.isShowOnlyIfSpectatorsCanSeeHands()}); @@ -363,7 +381,7 @@ void GamesProxyModel::saveFilterParameters(const QMap &allGameType gameFilters.setHideNotBuddyCreatedGames(filters.hideNotBuddyCreatedGames); gameFilters.setHideOpenDecklistGames(filters.hideOpenDecklistGames); gameFilters.setGameNameFilter(filters.gameNameFilter); - gameFilters.setCreatorNameFilters(filters.creatorNameFilters); + gameFilters.setHostNameFilters(filters.hostNameFilters); QMapIterator gameTypeIterator(allGameTypes); while (gameTypeIterator.hasNext()) { @@ -408,11 +426,11 @@ bool GamesProxyModel::filterAcceptsRow(int sourceRow) const return false; } if (filters.hideIgnoredUserGames && - userListProxy->isUserIgnored(QString::fromStdString(game.creator_info().name()))) { + userListProxy->isUserIgnored(QString::fromStdString(getGameHost(game).name()))) { return false; } if (filters.hideNotBuddyCreatedGames && - !userListProxy->isUserBuddy(QString::fromStdString(game.creator_info().name()))) { + !userListProxy->isUserBuddy(QString::fromStdString(getGameHost(game).name()))) { return false; } if (filters.hideFullGames && game.player_count() == game.max_players()) { @@ -434,10 +452,10 @@ bool GamesProxyModel::filterAcceptsRow(int sourceRow) const return false; } } - if (!filters.creatorNameFilters.isEmpty()) { + if (!filters.hostNameFilters.isEmpty()) { bool found = false; - for (const auto &createNameFilter : filters.creatorNameFilters) { - if (QString::fromStdString(game.creator_info().name()).contains(createNameFilter, Qt::CaseInsensitive)) { + for (const auto &hostNameFilter : filters.hostNameFilters) { + if (QString::fromStdString(getGameHost(game).name()).contains(hostNameFilter, Qt::CaseInsensitive)) { found = true; } } diff --git a/cockatrice/src/interface/widgets/server/handle_public_servers.cpp b/cockatrice/src/interface/widgets/server/handle_public_servers.cpp index 8ebb70e83..f5d682c36 100644 --- a/cockatrice/src/interface/widgets/server/handle_public_servers.cpp +++ b/cockatrice/src/interface/widgets/server/handle_public_servers.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #define PUBLIC_SERVERS_JSON "https://cockatrice.github.io/public-servers.json" diff --git a/cockatrice/src/interface/widgets/server/remote/remote_replay_list_tree_widget.cpp b/cockatrice/src/interface/widgets/server/remote/remote_replay_list_tree_widget.cpp index 1f034b767..d62bf81aa 100644 --- a/cockatrice/src/interface/widgets/server/remote/remote_replay_list_tree_widget.cpp +++ b/cockatrice/src/interface/widgets/server/remote/remote_replay_list_tree_widget.cpp @@ -1,5 +1,7 @@ #include "remote_replay_list_tree_widget.h" +#include "../../../pixel_map_generator.h" + #include #include #include @@ -37,7 +39,7 @@ RemoteReplayList_TreeModel::RemoteReplayList_TreeModel(AbstractClient *_client, QFileIconProvider fip; dirIcon = fip.icon(QFileIconProvider::Folder); fileIcon = fip.icon(QFileIconProvider::File); - lockIcon = QPixmap("theme:icons/lock"); + lockIcon = themePixmap(QStringLiteral("icons/lock")); } RemoteReplayList_TreeModel::~RemoteReplayList_TreeModel() diff --git a/cockatrice/src/interface/widgets/server/user/user_avatar_provider.cpp b/cockatrice/src/interface/widgets/server/user/user_avatar_provider.cpp new file mode 100644 index 000000000..c115caa47 --- /dev/null +++ b/cockatrice/src/interface/widgets/server/user/user_avatar_provider.cpp @@ -0,0 +1,48 @@ +#include "user_avatar_provider.h" + +#include +#include +#include + +UserAvatarProvider::UserAvatarProvider(AbstractClient *client, QObject *parent) : QObject(parent), client(client) +{ +} + +const QMap &UserAvatarProvider::cache() const +{ + return avatarCache; +} + +void UserAvatarProvider::requestAvatar(const QString &userName) +{ + if (avatarCache.contains(userName) || pending.contains(userName)) { + return; + } + + pending.insert(userName); + + Command_GetUserInfo cmd; + cmd.set_user_name(userName.toStdString()); + + PendingCommand *pend = client->prepareSessionCommand(cmd); + + connect(pend, &PendingCommand::finished, this, [this, userName](const Response &r) { + pending.remove(userName); + + const auto &response = r.GetExtension(Response_GetUserInfo::ext); + const auto &user = response.user_info(); + const std::string &bmp = user.avatar_bmp(); + + QPixmap avatar; + if (!bmp.empty() && + avatar.loadFromData(reinterpret_cast(bmp.data()), static_cast(bmp.size()))) { + avatarCache.insert(userName, avatar); + } else { + avatarCache.insert(userName, QPixmap()); + } + + emit avatarUpdated(userName); + }); + + client->sendCommand(pend); +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/server/user/user_avatar_provider.h b/cockatrice/src/interface/widgets/server/user/user_avatar_provider.h new file mode 100644 index 000000000..44491e544 --- /dev/null +++ b/cockatrice/src/interface/widgets/server/user/user_avatar_provider.h @@ -0,0 +1,30 @@ +#ifndef COCKATRICE_USER_AVATAR_PROVIDER_H +#define COCKATRICE_USER_AVATAR_PROVIDER_H + +#include +#include +#include +#include + +class AbstractClient; + +class UserAvatarProvider : public QObject +{ + Q_OBJECT + +public: + explicit UserAvatarProvider(AbstractClient *client, QObject *parent = nullptr); + + void requestAvatar(const QString &userName); + const QMap &cache() const; + +signals: + void avatarUpdated(const QString &userName); + +private: + AbstractClient *client; + QMap avatarCache; + QSet pending; +}; + +#endif // COCKATRICE_USER_AVATAR_PROVIDER_H \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/server/user/user_card_art_provider.cpp b/cockatrice/src/interface/widgets/server/user/user_card_art_provider.cpp new file mode 100644 index 000000000..2ba745715 --- /dev/null +++ b/cockatrice/src/interface/widgets/server/user/user_card_art_provider.cpp @@ -0,0 +1,162 @@ +#include "user_card_art_provider.h" + +#include "../../../card_picture_loader/card_picture_loader.h" +#include "../../cards/card_art_utils.h" + +#include +#include + +static QString makeKey(const QString &user, const QString &card, const QString &providerId) +{ + return user + u'|' + card + u'|' + providerId; +} + +UserCardArtProvider::UserCardArtProvider(QObject *parent) : QObject(parent) +{ + dbReady = (CardDatabaseManager::getInstance()->getLoadStatus() == LoadStatus::Ok); + + if (!dbReady) { + connect(CardDatabaseManager::getInstance(), &CardDatabase::cardDatabaseLoadingFinished, this, + &UserCardArtProvider::onDatabaseReady); + } +} + +void UserCardArtProvider::onDatabaseReady() +{ + dbReady = true; + processQueue(); +} + +const QMap &UserCardArtProvider::cache() const +{ + return cardArtCache; +} + +void UserCardArtProvider::requestCardArt(const QString &userName, const QString &cardName, const QString &providerId) +{ + if (cardName.isEmpty()) { + return; + } + + const QString key = makeKey(userName, cardName, providerId); + + if (pending.contains(key)) { + return; + } + if (cardArtCache.contains(key) && !cardArtCache.value(key).isNull()) { + return; + } + + pending.insert(key); + queue.enqueue(key); + + processQueue(); +} + +QPixmap UserCardArtProvider::cropCardArt(const QPixmap &fullRes, const ExactCard &card) +{ + QPixmap source = fullRes; + + // Sideways-layout cards (plane, siege/battle, split) store their landscape + // artwork rotated 90° inside a portrait frame. Rotate it upright first so + // the crop below lands on the horizontal art, mirroring the way + // CardInfoPictureWidget displays these cards. + const bool landscape = card.getInfo().getUiAttributes().landscapeOrientation; + source = CardArtUtils::rotateSidewaysLayoutArt(source, card); + + const QSize sz = source.size(); + const int marginX = sz.width() * 0.07; + const int topMargin = landscape ? sz.height() * 0.05 : sz.height() * 0.11; + const int bottomMargin = landscape ? sz.height() * 0.42 : sz.height() * 0.45; + + const QRect artRect(marginX, topMargin, sz.width() - 2 * marginX, sz.height() - topMargin - bottomMargin); + + return source.copy(artRect.intersected(source.rect())); +} + +void UserCardArtProvider::insertIntoCache(const QString &key, const QPixmap &pixmap) +{ + if (pixmap.isNull()) { + return; + } + + if (!cardArtCache.contains(key)) { + cacheInsertionOrder.append(key); + while (cacheInsertionOrder.size() > MaxCacheEntries) { + const QString evicted = cacheInsertionOrder.takeFirst(); + cardArtCache.remove(evicted); + } + } + cardArtCache.insert(key, pixmap); +} + +void UserCardArtProvider::processQueue() +{ + if (!dbReady) { + return; + } + + while (!queue.isEmpty()) { + const QString key = queue.dequeue(); + + const QStringList parts = key.split(u'|'); + if (parts.size() != 3) { + pending.remove(key); + continue; + } + + const QString userName = parts.at(0); + const QString cardName = parts.at(1); + const QString providerId = parts.at(2); + + ExactCard card = CardDatabaseManager::query()->getCard({cardName, providerId}); + + if (!card) { + pending.remove(key); + continue; + } + + QPixmap fullRes; + CardPictureLoader::getPixmap(fullRes, card, QSize(745, 1040)); + + // Synchronous hit (already loaded/on disk) + if (!fullRes.isNull()) { + insertIntoCache(key, cropCardArt(fullRes, card)); + pending.remove(key); + + emit cardArtUpdated(userName); + continue; + } + + // Async load required. + QPointer self(this); + + auto conn = std::make_shared(); + + *conn = connect(card.getCardPtr().data(), &CardInfo::pixmapUpdated, this, + [self, key, userName, card, conn]() mutable { + if (!self) { + return; + } + + QObject::disconnect(*conn); + + QPixmap fullRes; + CardPictureLoader::getPixmap(fullRes, card, QSize(745, 1040)); + + if (!fullRes.isNull()) { + self->insertIntoCache(key, self->cropCardArt(fullRes, card)); + } + + self->pending.remove(key); + + emit self->cardArtUpdated(userName); + + // Resume processing remaining queued items. + self->processQueue(); + }); + + // Stop here. We'll continue when the async load finishes. + return; + } +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/server/user/user_card_art_provider.h b/cockatrice/src/interface/widgets/server/user/user_card_art_provider.h new file mode 100644 index 000000000..e8283a891 --- /dev/null +++ b/cockatrice/src/interface/widgets/server/user/user_card_art_provider.h @@ -0,0 +1,40 @@ +#ifndef COCKATRICE_USER_CARD_ART_PROVIDER_H +#define COCKATRICE_USER_CARD_ART_PROVIDER_H + +#include +#include +#include +#include +#include +#include + +class UserCardArtProvider : public QObject +{ + Q_OBJECT + +public: + explicit UserCardArtProvider(QObject *parent = nullptr); + + void requestCardArt(const QString &userName, const QString &cardName, const QString &providerId); + const QMap &cache() const; + static QPixmap cropCardArt(const QPixmap &fullRes, const ExactCard &card); + +signals: + void cardArtUpdated(const QString &userName); + +public slots: + void onDatabaseReady(); + +private: + bool dbReady = false; + static constexpr int MaxCacheEntries = 1024; + QList cacheInsertionOrder; // FIFO eviction + QMap cardArtCache; + QSet pending; + QQueue queue; + + void processQueue(); + void insertIntoCache(const QString &key, const QPixmap &pixmap); +}; + +#endif // COCKATRICE_USER_CARD_ART_PROVIDER_H \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.cpp b/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.cpp new file mode 100644 index 000000000..d49e3d540 --- /dev/null +++ b/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.cpp @@ -0,0 +1,578 @@ +#include "user_card_settings_dialog.h" + +#include "../../../card_picture_loader/card_picture_loader.h" +#include "../../cards/art_crop_attribution.h" +#include "../../utility/completer_utils.h" +#include "card/card_search_model.h" +#include "card_database_display_model.h" +#include "card_database_model.h" +#include "user_card_art_provider.h" +#include "user_list_painter.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +// Gesture clamps and step sizes for this direct manipulation surface. The +// gesture zoom floor is 1.0: basescale already cover fits the art, so any +// smaller scale would underfill the strip. +constexpr qreal kMinGestureZoom = 1.0; +constexpr qreal kMaxZoom = 4.0; +constexpr qreal kKeyPanOffsetStep = 0.01; +constexpr qreal kKeyZoomStep = 1.05; +constexpr qreal kWheelZoomBase = 1.15; // zoom factor per wheel notch +} // namespace + +CardArtPreviewWidget::CardArtPreviewWidget(QWidget *parent) : QWidget(parent) +{ + setMinimumSize(400, 72); + setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + setFocusPolicy(Qt::StrongFocus); + setAccessibleName(tr("Banner preview")); + setAccessibleDescription(tr("Zoom %1×").arg(QString::number(params.zoom, 'f', 2))); +} + +void CardArtPreviewWidget::focusInEvent(QFocusEvent *event) +{ + // Snapshot for the Esc or Backspace reset, restoring whatever the user + // had when the surface took focus + paramsAtFocusIn = params; + QWidget::focusInEvent(event); +} + +void CardArtPreviewWidget::setPixmap(const QPixmap &pixmap) +{ + sourcePixmap = pixmap; + update(); +} + +void CardArtPreviewWidget::setParams(const CardArtParams &p) +{ + params = p; + setAccessibleDescription(tr("Zoom %1×").arg(QString::number(params.zoom, 'f', 2))); + update(); +} + +void CardArtPreviewWidget::setAttribution(const QString &attribution) +{ + attributionText = attribution; + update(); +} + +void CardArtPreviewWidget::paintEvent(QPaintEvent *) +{ + QPainter painter(this); + painter.setRenderHints(QPainter::Antialiasing | QPainter::SmoothPixmapTransform | QPainter::TextAntialiasing); + + const QRect rect = this->rect(); + + const QColor accentColor(100, 116, 139); + const QRectF cardRect = QRectF(rect).adjusted(3, 2, -3, -2); + + QLinearGradient bg(cardRect.topLeft(), cardRect.topRight()); + bg.setColorAt(0, accentColor.darker(320)); + bg.setColorAt(1, QColor(18, 22, 30)); + painter.setPen(Qt::NoPen); + painter.setBrush(bg); + painter.drawRoundedRect(cardRect, 6, 6); + painter.setBrush(accentColor); + painter.drawRoundedRect(QRectF(cardRect.left(), cardRect.top(), 3, cardRect.height()), 2, 2); + + // Visible keyboard focus per the focus cursor contract, Tab must show + // where the keys land, including in the empty state + const auto paintFocusRing = [&painter, &cardRect, this]() { + if (!hasFocus()) { + return; + } + QPen focusPen(palette().color(QPalette::Highlight), 2); + painter.setPen(focusPen); + painter.setBrush(Qt::NoBrush); + painter.drawRoundedRect(cardRect.adjusted(-1, -1, 1, 1), 6, 6); + }; + + if (sourcePixmap.isNull()) { + painter.setPen(QColor(150, 150, 150)); + painter.drawText(rect, Qt::AlignCenter, tr("No card selected")); + paintFocusRing(); + return; + } + + UserListPainter::drawCardArt(&painter, rect, rect.right() - 4, + QString(), // userName not needed for override path + nullptr, // no cache + params, + &sourcePixmap // direct pixmap + ); + + // Avatar placeholder so the left margin interaction is visible + const int avatarX = rect.left() + 14; + const int avatarY = rect.top() + (rect.height() - 36) / 2; + const QRect avatarRect(avatarX, avatarY, 36, 36); + + QPainterPath clip; + clip.addEllipse(avatarRect); + painter.save(); + painter.setClipPath(clip); + painter.setBrush(accentColor.darker(200)); + painter.setPen(Qt::NoPen); + painter.drawEllipse(avatarRect); + painter.restore(); + + painter.setPen(QPen(QColor(70, 80, 95), 2)); + painter.setBrush(Qt::NoBrush); + painter.drawEllipse(avatarRect.adjusted(-1, -1, 1, 1)); + + paintArtAttribution(painter, cardRect, attributionText); + + paintFocusRing(); +} + +qreal CardArtPreviewWidget::bannerTravel() const +{ + if (sourcePixmap.isNull()) { + return 0.0; + } + // Mirror UserListPainter::drawCardArt() exactly: same strip metrics, the + // copy is drawn 1:1, so output pixels equal widget pixels here. + const int cardH = rect().height() - 4; + const int totalW = (rect().right() - 4) - rect().left(); + const int marginL = qRound(totalW * params.marginPctL); + const int marginR = qRound(totalW * params.marginPctR); + const int drawW = totalW - marginL - marginR; + const double basescale = qMax(double(drawW) / sourcePixmap.width(), double(cardH) / sourcePixmap.height()); + // qRound for literal parity with drawCardArt, which rounds the scaled + // height before computing travel + const double scaledH = qRound(sourcePixmap.height() * basescale * params.zoom); + return scaledH - cardH; +} + +void CardArtPreviewWidget::applyCropDelta(qreal dOffset, qreal zoomFactor) +{ + CardArtParams next = params; + next.verticalOffset = qBound(0.0, params.verticalOffset + dOffset, 1.0); + next.zoom = qBound(kMinGestureZoom, params.zoom * zoomFactor, kMaxZoom); + + if (sameCrop(next, params)) { + return; + } + + params = next; + setAccessibleDescription(tr("Zoom %1×").arg(QString::number(params.zoom, 'f', 2))); + update(); + emit paramsEdited(params); +} + +bool CardArtPreviewWidget::sameCrop(const CardArtParams &a, const CardArtParams &b) const +{ + // Exact comparison on purpose: clamped assignments yield identical bits, + // while qFuzzyCompare based equality misbehaves around zero + return a.verticalOffset == b.verticalOffset && a.zoom == b.zoom; +} + +void CardArtPreviewWidget::restoreSnapshot() +{ + // The snapshot only ever holds values that passed the gesture clamps, + // so it is safe to restore verbatim + if (sameCrop(paramsAtFocusIn, params)) { + return; + } + params = paramsAtFocusIn; + setAccessibleDescription(tr("Zoom %1×").arg(QString::number(params.zoom, 'f', 2))); + update(); + emit paramsEdited(params); +} + +void CardArtPreviewWidget::mousePressEvent(QMouseEvent *event) +{ + if (event->button() != Qt::LeftButton || sourcePixmap.isNull() || bannerTravel() <= 0.5) { + QWidget::mousePressEvent(event); + return; + } + dragging = true; + lastDragPos = event->pos(); + setCursor(Qt::ClosedHandCursor); + event->accept(); +} + +void CardArtPreviewWidget::mouseMoveEvent(QMouseEvent *event) +{ + if (!dragging || sourcePixmap.isNull()) { + QWidget::mouseMoveEvent(event); + return; + } + + const qreal dy = event->pos().y() - lastDragPos.y(); + lastDragPos = event->pos(); + + const qreal travel = bannerTravel(); + if (travel <= 0.5) { + event->accept(); + return; + } + + // Dragging moves the ART with the cursor, so the crop window slides the + // other way through the available travel. + applyCropDelta(-dy / travel, 1.0); + event->accept(); +} + +void CardArtPreviewWidget::mouseReleaseEvent(QMouseEvent *event) +{ + if (event->button() == Qt::LeftButton) { + dragging = false; + unsetCursor(); + event->accept(); + return; + } + QWidget::mouseReleaseEvent(event); +} + +void CardArtPreviewWidget::wheelEvent(QWheelEvent *event) +{ + if (sourcePixmap.isNull()) { + QWidget::wheelEvent(event); + return; + } + const qreal notches = static_cast(event->angleDelta().y()) / 120.0; + if (notches == 0.0) { + event->accept(); + return; + } + applyCropDelta(0.0, std::pow(kWheelZoomBase, notches)); + event->accept(); +} + +void CardArtPreviewWidget::keyPressEvent(QKeyEvent *event) +{ + if (sourcePixmap.isNull()) { + QWidget::keyPressEvent(event); + return; + } + + switch (event->key()) { + case Qt::Key_Escape: + if (sameCrop(params, paramsAtFocusIn)) { + // Nothing to undo on this surface, let the event reach the + // dialog so Esc keeps its close meaning there + QWidget::keyPressEvent(event); + return; + } + restoreSnapshot(); + break; + case Qt::Key_Backspace: + restoreSnapshot(); + break; + case Qt::Key_Up: + applyCropDelta(-kKeyPanOffsetStep, 1.0); + break; + case Qt::Key_Down: + applyCropDelta(kKeyPanOffsetStep, 1.0); + break; + case Qt::Key_Plus: + case Qt::Key_Equal: + applyCropDelta(0.0, kKeyZoomStep); + break; + case Qt::Key_Minus: + applyCropDelta(0.0, 1.0 / kKeyZoomStep); + break; + default: + QWidget::keyPressEvent(event); + return; + } + event->accept(); +} + +UserCardArtSettingsDialog::UserCardArtSettingsDialog(const CardArtParams &initial, QWidget *parent) + : QDialog(parent), currentParams(initial) +{ + // Legacy stored banners may carry zoom below the gesture floor or an out + // of range offset. Normalize once on open so the preview renders filled + // and Ok saves a state the gestures can reach again + currentParams.zoom = qBound(kMinGestureZoom, currentParams.zoom, kMaxZoom); + currentParams.verticalOffset = qBound(0.0, currentParams.verticalOffset, 1.0); + + setMinimumWidth(500); + setupUi(); + + // Seed UI from initial params + if (!currentParams.cardName.isEmpty()) { + // onCardNameChanged overwrites cardProviderId with the first printing, + // so remember the stored one before it runs + const QString storedProviderId = currentParams.cardProviderId; + searchBar->setText(currentParams.cardName); + onCardNameChanged(currentParams.cardName); + + // onCardNameChanged leaves the printing combo on the first printing in + // the database, which would silently change the stored banner card on + // accept. Restore the stored printing when it resolves locally. + const int storedPrintingIndex = providerComboBox->findData(storedProviderId); + if (storedPrintingIndex != -1) { + providerComboBox->setCurrentIndex(storedPrintingIndex); + } else if (!storedProviderId.isEmpty()) { + // Stored printing not in the local database: keep it rather than + // silently substituting the first printing. + currentParams.cardProviderId = storedProviderId; + reloadPreview(); + } + } + marginLSpin->setValue(currentParams.marginPctL); + marginRSpin->setValue(currentParams.marginPctR); +} + +CardArtParams UserCardArtSettingsDialog::params() const +{ + return currentParams; +} + +QDoubleSpinBox *UserCardArtSettingsDialog::makeSpinBox(double min, double max, double value, double step) +{ + auto *spin = new QDoubleSpinBox; + spin->setRange(min, max); + spin->setSingleStep(step); + spin->setDecimals(3); + spin->setValue(value); + return spin; +} + +void UserCardArtSettingsDialog::initializeSearchBar() +{ + searchBar = new QLineEdit; + + cardDatabaseModel = new CardDatabaseModel(CardDatabaseManager::getInstance(), false, this); + cardDatabaseDisplayModel = new CardDatabaseDisplayModel(this); + cardDatabaseDisplayModel->setSourceModel(cardDatabaseModel); + + const CardCompleterSetup cardSetup = createCardCompleter(cardDatabaseDisplayModel, this, 15); + searchModel = cardSetup.searchModel; + proxyModel = cardSetup.proxyModel; + completer = cardSetup.completer; + searchBar->setCompleter(completer); + + connectCardCompleterSearch(searchBar, cardSetup); + + connect(completer, static_cast(&QCompleter::activated), this, + [this](const QString &completion) { + if (searchBar->text() != completion) { + searchBar->setText(completion); + searchBar->setCursorPosition(searchBar->text().length()); + } + onCardNameChanged(completion); + }); + + // Also trigger a load when the user hits Return on a typed name + connect(searchBar, &QLineEdit::returnPressed, this, [this]() { onCardNameChanged(searchBar->text()); }); +} + +void UserCardArtSettingsDialog::setupUi() +{ + initializeSearchBar(); + + providerComboBox = new QComboBox; + connect(providerComboBox, &QComboBox::currentIndexChanged, this, [this]() { + currentParams.cardProviderId = providerComboBox->currentData().toString(); + reloadPreview(); + onParamChanged(); + }); + + marginLSpin = makeSpinBox(0.0, 0.95, currentParams.marginPctL, 0.01); + marginRSpin = makeSpinBox(0.0, 0.95, currentParams.marginPctR, 0.01); + + auto *form = new QFormLayout; + cardNameLabel = new QLabel; + printingLabel = new QLabel; + marginLLabel = new QLabel; + marginRLabel = new QLabel; + form->addRow(cardNameLabel, searchBar); + form->addRow(printingLabel, providerComboBox); + form->addRow(marginLLabel, marginLSpin); + form->addRow(marginRLabel, marginRSpin); + + controlsGroup = new QGroupBox; + controlsGroup->setLayout(form); + + preview = new CardArtPreviewWidget; + + auto *previewLayout = new QVBoxLayout; + previewLayout->addWidget(preview); + previewCaptionLabel = new QLabel; + previewCaptionLabel->setAlignment(Qt::AlignCenter); + previewCaptionLabel->setWordWrap(true); + previewLayout->addWidget(previewCaptionLabel); + previewGroup = new QGroupBox; + previewGroup->setLayout(previewLayout); + + auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); + removeBtn = new QPushButton; + buttons->addButton(removeBtn, QDialogButtonBox::ResetRole); + + connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); + connect(removeBtn, &QPushButton::clicked, this, [this]() { + currentParams = CardArtParams{}; // empty cardName signals removal + accept(); + }); + + // The banner leads visually, card selection and margins support it below. + auto *root = new QVBoxLayout; + root->addWidget(previewGroup); + root->addWidget(controlsGroup); + root->addWidget(buttons); + setLayout(root); + + connect(marginLSpin, &QDoubleSpinBox::valueChanged, this, &UserCardArtSettingsDialog::onParamChanged); + connect(marginRSpin, &QDoubleSpinBox::valueChanged, this, &UserCardArtSettingsDialog::onParamChanged); + + // Gestures are the only editors of offset and zoom on this surface. + // Margins stay explicit numeric controls: they trim the strip's + // sides and have no natural drag mapping. + connect(preview, &CardArtPreviewWidget::paramsEdited, this, + [this](const CardArtParams &edited) { currentParams = edited; }); + + retranslateUi(); +} + +void UserCardArtSettingsDialog::retranslateUi() +{ + setWindowTitle(tr("Card Art Settings")); + searchBar->setPlaceholderText(tr("Type a card name...")); + cardNameLabel->setText(tr("Card name:")); + printingLabel->setText(tr("Printing:")); + marginLLabel->setText(tr("Left margin (%):")); + marginRLabel->setText(tr("Right margin (%):")); + controlsGroup->setTitle(tr("Card")); + previewCaptionLabel->setText( + tr("Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores.")); + previewGroup->setTitle(tr("Banner")); + removeBtn->setText(tr("Remove Banner Card")); +} + +void UserCardArtSettingsDialog::populateProviderCombo(const QString &cardName) +{ + providerComboBox->clear(); + + auto card = CardDatabaseManager::query()->getCard({cardName}); + + const auto &sets = card.getInfo().getSets(); + + for (const auto &printings : sets) { + for (const auto &p : printings) { + + QString setName = p.getSet()->getLongName(); + QString collector = p.getProperty("num"); + QString uuid = p.getUuid(); + + QString label = setName; + + if (!collector.isEmpty()) { + label += " #" + collector; + } + + providerComboBox->addItem(label, uuid); + } + } +} + +void UserCardArtSettingsDialog::onCardNameChanged(const QString &name) +{ + if (name.isEmpty()) { + currentPixmap = QPixmap(); + preview->setPixmap(currentPixmap); + return; + } + + const ExactCard card = CardDatabaseManager::query()->getCard({name}); + if (!card) { + currentPixmap = QPixmap(); + preview->setPixmap(currentPixmap); + providerComboBox->clear(); + return; + } + + currentParams.cardName = name; + + populateProviderCombo(name); + + if (providerComboBox->count() == 0) { + // No printings found for this card, nothing to preview. + currentPixmap = QPixmap(); + preview->setPixmap(currentPixmap); + currentParams.cardProviderId.clear(); + return; + } + + currentParams.cardProviderId = providerComboBox->currentData().toString(); + reloadPreview(); +} + +void UserCardArtSettingsDialog::reloadPreview() +{ + if (currentParams.cardName.isEmpty()) { + return; + } + + ExactCard card = CardDatabaseManager::query()->getCard({currentParams.cardName, currentParams.cardProviderId}); + if (!card) { + return; + } + + // CardPictureLoader::getPixmap() is async on a cache miss: it enqueues a + // background download and returns a null pixmap immediately. When that + // download finishes, CardPictureLoader::imageLoaded() caches the result + // and calls card.emitPixmapUpdated(), which emits pixmapUpdated() on the + // underlying CardInfo (see exact_card.h). Listen for that, scoped to + // whichever CardInfo we just asked for, so the preview catches up once + // the image actually arrives instead of staying on the placeholder. + // + // Disconnect any previous listener first, otherwise switching cards + // repeatedly stacks up connections to old CardInfo objects, each of + // which would still fire reloadPreview() (harmlessly, but wastefully) + // whenever ITS art finishes loading later. + disconnect(pixmapUpdatedConnection); + + QPixmap fullRes; + CardPictureLoader::getPixmap(fullRes, card, QSize(745, 1040)); + + if (fullRes.isNull()) { + // Not loaded yet, wait for the signal instead of giving up. + // card.getCardPtr() is a CardInfoPtr (QSharedPointer), + // .data() gives the raw QObject* needed for connect(). + CardInfo *cardInfo = card.getCardPtr().data(); + if (cardInfo) { + pixmapUpdatedConnection = connect(cardInfo, &CardInfo::pixmapUpdated, this, [this]() { reloadPreview(); }); + } + return; + } + + currentPixmap = UserCardArtProvider::cropCardArt(fullRes, card); + preview->setPixmap(currentPixmap); + preview->setParams(currentParams); + + // Only attribute the art once the new pixmap is actually displayed, so a + // cache miss (which keeps the previous pixmap on screen) doesn't pair the + // new card's attribution with the old card's art. + preview->setAttribution(buildArtAttribution(card)); +} + +void UserCardArtSettingsDialog::onParamChanged() +{ + currentParams.marginPctL = marginLSpin->value(); + currentParams.marginPctR = marginRSpin->value(); + preview->setParams(currentParams); +} diff --git a/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.h b/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.h new file mode 100644 index 000000000..24e14dce5 --- /dev/null +++ b/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.h @@ -0,0 +1,120 @@ +#ifndef COCKATRICE_USER_CARD_ART_SETTINGS_DIALOG_H +#define COCKATRICE_USER_CARD_ART_SETTINGS_DIALOG_H + +#include "user_list_painter.h" + +#include +#include +#include + +class QCompleter; +class QFocusEvent; +class QGroupBox; +class QKeyEvent; +class QMouseEvent; +class QLineEdit; +class QDoubleSpinBox; +class QLabel; +class QPushButton; +class QWheelEvent; +class CardDatabaseModel; +class CardDatabaseDisplayModel; +class CardSearchModel; +class CardCompleterProxyModel; + +/** + * @brief Interactive preview of the user list banner art. + * + * Renders the banner strip with the given CardArtParams through the same + * UserListPainter::drawCardArt() the live delegate uses, including the + * avatar placeholder and fade masks. Dragging pans the art vertically at + * output scale, the wheel zooms, arrow keys nudge, Backspace or Esc + * restores the parameters as of focus gain. + */ +class CardArtPreviewWidget : public QWidget +{ + Q_OBJECT + +public: + explicit CardArtPreviewWidget(QWidget *parent = nullptr); + + void setPixmap(const QPixmap &pixmap); + void setParams(const CardArtParams ¶ms); + void setAttribution(const QString &attribution); + +signals: + /** @brief Emitted whenever direct manipulation (drag, wheel, keys) changes the parameters. */ + void paramsEdited(const CardArtParams ¶ms); + +protected: + void paintEvent(QPaintEvent *event) override; + void mousePressEvent(QMouseEvent *event) override; + void mouseMoveEvent(QMouseEvent *event) override; + void mouseReleaseEvent(QMouseEvent *event) override; + void wheelEvent(QWheelEvent *event) override; + void keyPressEvent(QKeyEvent *event) override; + void focusInEvent(QFocusEvent *event) override; + +private: + qreal bannerTravel() const; ///< vertical travel of the art behind the strip, in output pixels + void applyCropDelta(qreal dOffset, qreal zoomFactor); + bool sameCrop(const CardArtParams &a, const CardArtParams &b) const; + void restoreSnapshot(); + + QPixmap sourcePixmap; + CardArtParams params; + CardArtParams paramsAtFocusIn; ///< crop as of the latest focus gain, restored by Esc or Backspace + QString attributionText; + QPoint lastDragPos; ///< widget space position of the previous mouse move while panning + bool dragging{false}; ///< true between an accepted press and its release, guards stale drag positions +}; + +class UserCardArtSettingsDialog : public QDialog +{ + Q_OBJECT + +public: + explicit UserCardArtSettingsDialog(const CardArtParams &initial = {}, QWidget *parent = nullptr); + + CardArtParams params() const; + +private slots: + void onCardNameChanged(const QString &name); + void reloadPreview(); + void onParamChanged(); + +private: + void setupUi(); + void retranslateUi(); + void populateProviderCombo(const QString &cardName); + void initializeSearchBar(); + QDoubleSpinBox *makeSpinBox(double min, double max, double value, double step); + + QLineEdit *searchBar; + QCompleter *completer; + CardDatabaseModel *cardDatabaseModel; + CardDatabaseDisplayModel *cardDatabaseDisplayModel; + CardSearchModel *searchModel; + CardCompleterProxyModel *proxyModel; + + QComboBox *providerComboBox; + + QMetaObject::Connection pixmapUpdatedConnection; + + QLabel *cardNameLabel; + QLabel *printingLabel; + QLabel *marginLLabel; + QLabel *marginRLabel; + QGroupBox *controlsGroup; + QLabel *previewCaptionLabel; + QGroupBox *previewGroup; + QPushButton *removeBtn; + QDoubleSpinBox *marginLSpin; + QDoubleSpinBox *marginRSpin; + CardArtPreviewWidget *preview; + + QPixmap currentPixmap; + CardArtParams currentParams; +}; + +#endif // COCKATRICE_USER_CARD_ART_SETTINGS_DIALOG_H diff --git a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp index faa96fa1f..0d2267a63 100644 --- a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp @@ -1,14 +1,15 @@ #include "user_context_menu.h" +#include "../../dialogs/dlg_report_user.h" #include "../../interface/widgets/tabs/tab_account.h" #include "../../interface/widgets/tabs/tab_game.h" #include "../../interface/widgets/tabs/tab_supervisor.h" #include "../chat_view/chat_view.h" #include "../game_selector.h" #include "user_info_box.h" +#include "user_list_dialog.h" #include "user_list_manager.h" #include "user_list_proxy.h" -#include "user_list_widget.h" #include #include @@ -41,6 +42,7 @@ UserContextMenu::UserContextMenu(TabSupervisor *_tabSupervisor, QWidget *parent, aAddToIgnoreList = new QAction(QString(), this); aRemoveFromIgnoreList = new QAction(QString(), this); aKick = new QAction(QString(), this); + aReport = new QAction(QString(), this); aWarnUser = new QAction(QString(), this); aWarnHistory = new QAction(QString(), this); aBan = new QAction(QString(), this); @@ -49,7 +51,10 @@ UserContextMenu::UserContextMenu(TabSupervisor *_tabSupervisor, QWidget *parent, aDemoteFromMod = new QAction(QString(), this); aPromoteToJudge = new QAction(QString(), this); aDemoteFromJudge = new QAction(QString(), this); + aPromoteToDeveloper = new QAction(QString(), this); + aDemoteFromDeveloper = new QAction(QString(), this); aGetAdminNotes = new QAction(QString(), this); + aInvestigateUser = new QAction(QString(), this); retranslateUi(); } @@ -64,6 +69,7 @@ void UserContextMenu::retranslateUi() aAddToIgnoreList->setText(tr("Add to &ignore list")); aRemoveFromIgnoreList->setText(tr("Remove from &ignore list")); aKick->setText(tr("Kick from &game")); + aReport->setText(tr("Report user")); aWarnUser->setText(tr("Warn user")); aWarnHistory->setText(tr("View user's war&n history")); aBan->setText(tr("Ban from &server")); @@ -72,7 +78,10 @@ void UserContextMenu::retranslateUi() aDemoteFromMod->setText(tr("Dem&ote user from moderator")); aPromoteToJudge->setText(tr("Promote user to &judge")); aDemoteFromJudge->setText(tr("Demote user from judge")); + aPromoteToDeveloper->setText(tr("Promote user to &developer")); + aDemoteFromDeveloper->setText(tr("Demote user from de&veloper")); aGetAdminNotes->setText(tr("View admin notes")); + aInvestigateUser->setText(tr("Investigate user")); } void UserContextMenu::gamesOfUserReceived(const Response &resp, const CommandContainer &commandContainer) @@ -144,7 +153,8 @@ void UserContextMenu::warnUser_processGetWarningsListResponse(const Response &r) if (response.warning_size() > 0) { for (int i = 0; i < response.warning_size(); ++i) { - dlg->addWarningOption(QString::fromStdString(response.warning(i)).simplified()); + int startingIl = i < response.warning_il_size() ? response.warning_il(i) : 1; + dlg->addWarningOption(QString::fromStdString(response.warning(i)).simplified(), startingIl); } } dlg->show(); @@ -262,7 +272,7 @@ void UserContextMenu::adjustMod_processUserResponse(const Response &resp, const const Command_AdjustMod &cmd = commandContainer.admin_command(0).GetExtension(Command_AdjustMod::ext); if (resp.response_code() == Response::RespOk) { - if (cmd.should_be_mod() || cmd.should_be_judge()) { + if (cmd.should_be_mod() || cmd.should_be_judge() || cmd.should_be_developer()) { QMessageBox::information(static_cast(parent()), tr("Success"), tr("Successfully promoted user.")); } else { @@ -270,7 +280,7 @@ void UserContextMenu::adjustMod_processUserResponse(const Response &resp, const } } else { - if (cmd.should_be_mod() || cmd.should_be_judge()) { + if (cmd.should_be_mod() || cmd.should_be_judge() || cmd.should_be_developer()) { QMessageBox::information(static_cast(parent()), tr("Failed"), tr("Failed to promote user.")); } else { QMessageBox::information(static_cast(parent()), tr("Failed"), tr("Failed to demote user.")); @@ -355,6 +365,7 @@ void UserContextMenu::showContextMenu(const QPoint &pos, { QAction *aCopyToClipBoard = nullptr, *aRemoveMessages = nullptr; aUserName->setText(userName); + const bool anotherUser = userName != userListProxy->getOwnUsername(); auto *menu = new QMenu(static_cast(parent())); menu->addAction(aUserName); @@ -366,6 +377,17 @@ void UserContextMenu::showContextMenu(const QPoint &pos, menu->addAction(aDetails); menu->addAction(aShowGames); menu->addAction(aChat); + const QList inviteOptions = inviteOptionsForUser(userName); + if (!inviteOptions.isEmpty()) { + auto *inviteMenu = new QMenu(tr("&Invite to Game"), menu); + for (const GameInviteOption &option : inviteOptions) { + QAction *inviteAction = inviteMenu->addAction(option.label); + inviteAction->setEnabled(anotherUser && online); + connect(inviteAction, &QAction::triggered, this, + [this, userName, option] { execInvite(userName, option); }); + } + menu->addMenu(inviteMenu); + } if (userLevel.testFlag(ServerInfo_User::IsRegistered) && userListProxy->isOwnUserRegistered()) { menu->addSeparator(); if (userListProxy->isUserBuddy(userName)) { @@ -383,6 +405,9 @@ void UserContextMenu::showContextMenu(const QPoint &pos, aRemoveMessages = new QAction(tr("Remove this user's messages"), this); menu->addAction(aRemoveMessages); } + if (userListProxy->isOwnUserRegistered()) { + menu->addAction(aReport); + } if (game && (game->isHost() || !tabSupervisor->getAdminLocked())) { menu->addSeparator(); menu->addAction(aKick); @@ -396,6 +421,7 @@ void UserContextMenu::showContextMenu(const QPoint &pos, menu->addAction(aBanHistory); menu->addSeparator(); menu->addAction(aGetAdminNotes); + menu->addAction(aInvestigateUser); menu->addSeparator(); if (userLevel.testFlag(ServerInfo_User::IsModerator) && @@ -415,11 +441,20 @@ void UserContextMenu::showContextMenu(const QPoint &pos, (tabSupervisor->getUserInfo()->user_level() & ServerInfo_User::IsAdmin)) { menu->addAction(aPromoteToJudge); } + + if (userLevel.testFlag(ServerInfo_User::IsDeveloper) && + (tabSupervisor->getUserInfo()->user_level() & ServerInfo_User::IsAdmin)) { + menu->addAction(aDemoteFromDeveloper); + + } else if (userLevel.testFlag(ServerInfo_User::IsRegistered) && + (tabSupervisor->getUserInfo()->user_level() & ServerInfo_User::IsAdmin)) { + menu->addAction(aPromoteToDeveloper); + } } - bool anotherUser = userName != userListProxy->getOwnUsername(); aDetails->setEnabled(true); - aChat->setEnabled(anotherUser && online); + aChat->setEnabled(anotherUser && online && !userListProxy->isUserIgnored(userName)); aShowGames->setEnabled(online); + aReport->setEnabled(anotherUser); aAddToBuddyList->setEnabled(anotherUser); aRemoveFromBuddyList->setEnabled(anotherUser); aAddToIgnoreList->setEnabled(anotherUser); @@ -430,109 +465,59 @@ void UserContextMenu::showContextMenu(const QPoint &pos, aBan->setEnabled(anotherUser); aBanHistory->setEnabled(anotherUser); aGetAdminNotes->setEnabled(anotherUser); + aInvestigateUser->setEnabled(anotherUser); aPromoteToMod->setEnabled(anotherUser); aDemoteFromMod->setEnabled(anotherUser); + aPromoteToJudge->setEnabled(anotherUser); + aDemoteFromJudge->setEnabled(anotherUser); + aPromoteToDeveloper->setEnabled(anotherUser); + aDemoteFromDeveloper->setEnabled(anotherUser); QAction *actionClicked = menu->exec(pos); if (actionClicked == nullptr) { } else if (actionClicked == aDetails) { - auto *infoWidget = - new UserInfoBox(client, false, static_cast(parent()), - Qt::Dialog | Qt::WindowTitleHint | Qt::CustomizeWindowHint | Qt::WindowCloseButtonHint); - infoWidget->setAttribute(Qt::WA_DeleteOnClose); - infoWidget->updateInfo(userName); + execDetails(userName); } else if (actionClicked == aChat) { - emit openMessageDialog(userName, true); + execChat(userName); } else if (actionClicked == aShowGames) { - Command_GetGamesOfUser cmd; - cmd.set_user_name(userName.toStdString()); - - PendingCommand *pend = client->prepareSessionCommand(cmd); - connect(pend, &PendingCommand::finished, this, &UserContextMenu::gamesOfUserReceived); - - client->sendCommand(pend); + execShowGames(userName); } else if (actionClicked == aAddToBuddyList) { - Command_AddToList cmd; - cmd.set_list("buddy"); - cmd.set_user_name(userName.toStdString()); - - client->sendCommand(client->prepareSessionCommand(cmd)); + execAddToBuddy(userName); } else if (actionClicked == aRemoveFromBuddyList) { - Command_RemoveFromList cmd; - cmd.set_list("buddy"); - cmd.set_user_name(userName.toStdString()); - - client->sendCommand(client->prepareSessionCommand(cmd)); + execRemoveFromBuddy(userName); } else if (actionClicked == aAddToIgnoreList) { - Command_AddToList cmd; - cmd.set_list("ignore"); - cmd.set_user_name(userName.toStdString()); - - client->sendCommand(client->prepareSessionCommand(cmd)); + execAddToIgnore(userName); } else if (actionClicked == aRemoveFromIgnoreList) { - Command_RemoveFromList cmd; - cmd.set_list("ignore"); - cmd.set_user_name(userName.toStdString()); - - client->sendCommand(client->prepareSessionCommand(cmd)); + execRemoveFromIgnore(userName); } else if (actionClicked == aKick) { - auto result = QMessageBox::question(static_cast(parent()), tr("Kick Player"), - tr("Are you sure you want to kick this player from the game?"), - QMessageBox::Yes | QMessageBox::No, QMessageBox::No); - if (result == QMessageBox::Yes) { - Command_KickFromGame cmd; - cmd.set_player_id(playerId); - - game->getGameEventHandler()->sendGameCommand(cmd); + execKick(playerId); + } else if (actionClicked == aReport) { + int gameId = game ? game->getGameMetaInfo()->gameId() : -1; + QString autoChatLog; + if (chatView) { + autoChatLog = chatView->getRecentChatLog(50); } + auto dlgReport = new DlgReportUser(client, userName, gameId, autoChatLog, static_cast(parent())); + dlgReport->setAttribute(Qt::WA_DeleteOnClose); + dlgReport->exec(); } else if (actionClicked == aBan) { - Command_GetUserInfo cmd; - cmd.set_user_name(userName.toStdString()); - - PendingCommand *pend = client->prepareSessionCommand(cmd); - connect(pend, &PendingCommand::finished, this, &UserContextMenu::banUser_processUserInfoResponse); - client->sendCommand(pend); + execBan(userName); } else if (actionClicked == aPromoteToMod || actionClicked == aDemoteFromMod) { - Command_AdjustMod cmd; - cmd.set_user_name(userName.toStdString()); - cmd.set_should_be_mod(actionClicked == aPromoteToMod); - - PendingCommand *pend = client->prepareAdminCommand(cmd); - connect(pend, &PendingCommand::finished, this, &UserContextMenu::adjustMod_processUserResponse); - client->sendCommand(pend); + execAdjustMod(userName, actionClicked == aPromoteToMod); } else if (actionClicked == aPromoteToJudge || actionClicked == aDemoteFromJudge) { - Command_AdjustMod cmd; - cmd.set_user_name(userName.toStdString()); - cmd.set_should_be_judge(actionClicked == aPromoteToJudge); - - PendingCommand *pend = client->prepareAdminCommand(cmd); - connect(pend, &PendingCommand::finished, this, &UserContextMenu::adjustMod_processUserResponse); - client->sendCommand(pend); + execAdjustJudge(userName, actionClicked == aPromoteToJudge); + } else if (actionClicked == aPromoteToDeveloper || actionClicked == aDemoteFromDeveloper) { + execAdjustDeveloper(userName, actionClicked == aPromoteToDeveloper); } else if (actionClicked == aBanHistory) { - Command_GetBanHistory cmd; - cmd.set_user_name(userName.toStdString()); - PendingCommand *pend = client->prepareModeratorCommand(cmd); - connect(pend, &PendingCommand::finished, this, &UserContextMenu::banUserHistory_processResponse); - client->sendCommand(pend); + execBanHistory(userName); } else if (actionClicked == aWarnUser) { - Command_GetUserInfo cmd; - cmd.set_user_name(userName.toStdString()); - PendingCommand *pend = client->prepareSessionCommand(cmd); - connect(pend, &PendingCommand::finished, this, &UserContextMenu::warnUser_processUserInfoResponse); - client->sendCommand(pend); + execWarn(userName); } else if (actionClicked == aWarnHistory) { - Command_GetWarnHistory cmd; - cmd.set_user_name(userName.toStdString()); - PendingCommand *pend = client->prepareModeratorCommand(cmd); - connect(pend, &PendingCommand::finished, this, &UserContextMenu::warnUserHistory_processResponse); - client->sendCommand(pend); + execWarnHistory(userName); } else if (actionClicked == aGetAdminNotes) { - Command_GetAdminNotes cmd; - cmd.set_user_name(userName.toStdString()); - auto *pend = client->prepareModeratorCommand(cmd); - connect(pend, &PendingCommand::finished, this, &UserContextMenu::getAdminNotes_processResponse); - client->sendCommand(pend); - + execAdminNotes(userName); + } else if (actionClicked == aInvestigateUser) { + execInvestigateUser(userName); } else if (actionClicked == aCopyToClipBoard) { QClipboard *clipboard = QGuiApplication::clipboard(); clipboard->setText(deckHash); @@ -542,3 +527,212 @@ void UserContextMenu::showContextMenu(const QPoint &pos, delete menu; } + +void UserContextMenu::execChat(const QString &userName) +{ + emit openMessageDialog(userName, true); +} + +QList UserContextMenu::inviteOptionsForUser(const QString &userName) const +{ + if (!gameInviteLinkProvider) { + return {}; + } + const QList options = gameInviteLinkProvider(); + QList result; + for (const GameInviteOption &option : options) { + // Buddy-only games accept invites only from their creator, and only to + // users on the creator's buddy list. + if (option.onlyBuddies && + (option.creatorName != userListProxy->getOwnUsername() || !userListProxy->isUserBuddy(userName))) { + continue; + } + result.append(option); + } + return result; +} + +void UserContextMenu::execInvite(const QString &userName) +{ + const QList options = inviteOptionsForUser(userName); + if (options.isEmpty()) { + return; + } + + if (options.size() == 1) { + execInvite(userName, options.first()); + return; + } + + // More than one game in the room — let the user pick which one to invite to. + auto *menu = new QMenu(static_cast(parent())); + for (const GameInviteOption &option : options) { + QAction *action = menu->addAction(option.label); + connect(action, &QAction::triggered, this, [this, userName, option] { execInvite(userName, option); }); + } + menu->setAttribute(Qt::WA_DeleteOnClose); + menu->popup(QCursor::pos()); +} + +void UserContextMenu::execInvite(const QString &userName, const GameInviteOption &option) +{ + // Name the game by description first, then its id — "Join my game 'Magic' + // (#123)" — so a description-less fallback still identifies the game. + // The multi-arg .arg() overloads replace in a single pass, so a description + // containing "%…" cannot corrupt later placeholders. + const QString prefix = + option.description.isEmpty() + ? tr("Join my game (#%1):").arg(option.gameId) + : tr("Join my game \"%1\" (#%2):").arg(option.description, QString::number(option.gameId)); + tabSupervisor->sendInviteToUser(userName, prefix + " " + option.url); +} + +void UserContextMenu::execDetails(const QString &userName) +{ + auto *w = new UserInfoBox(client, false, static_cast(parent()), + Qt::Dialog | Qt::WindowTitleHint | Qt::CustomizeWindowHint | Qt::WindowCloseButtonHint); + w->setAttribute(Qt::WA_DeleteOnClose); + w->updateInfo(userName); +} + +void UserContextMenu::execShowGames(const QString &userName) +{ + Command_GetGamesOfUser cmd; + cmd.set_user_name(userName.toStdString()); + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &UserContextMenu::gamesOfUserReceived); + client->sendCommand(pend); +} + +void UserContextMenu::execAddToBuddy(const QString &userName) +{ + Command_AddToList cmd; + cmd.set_list("buddy"); + cmd.set_user_name(userName.toStdString()); + client->sendCommand(client->prepareSessionCommand(cmd)); +} + +void UserContextMenu::execRemoveFromBuddy(const QString &userName) +{ + Command_RemoveFromList cmd; + cmd.set_list("buddy"); + cmd.set_user_name(userName.toStdString()); + client->sendCommand(client->prepareSessionCommand(cmd)); +} + +void UserContextMenu::execAddToIgnore(const QString &userName) +{ + Command_AddToList cmd; + cmd.set_list("ignore"); + cmd.set_user_name(userName.toStdString()); + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, + [this, userName](const Response &response, const CommandContainer &, const QVariant &) { + if (response.response_code() == Response::RespOk) { + QMessageBox::information(static_cast(parent()), tr("Ignore list"), + tr("%1 has been added to your ignore list.").arg(userName)); + } + }); + client->sendCommand(pend); +} + +void UserContextMenu::execRemoveFromIgnore(const QString &userName) +{ + Command_RemoveFromList cmd; + cmd.set_list("ignore"); + cmd.set_user_name(userName.toStdString()); + client->sendCommand(client->prepareSessionCommand(cmd)); +} + +void UserContextMenu::execKick(int playerId) +{ + auto result = QMessageBox::question(static_cast(parent()), tr("Kick Player"), + tr("Are you sure you want to kick this player from the game?"), + QMessageBox::Yes | QMessageBox::No, QMessageBox::No); + if (result == QMessageBox::Yes) { + Command_KickFromGame cmd; + cmd.set_player_id(playerId); + + game->getGameEventHandler()->sendGameCommand(cmd); + } +} + +void UserContextMenu::execBan(const QString &userName) +{ + Command_GetUserInfo cmd; + cmd.set_user_name(userName.toStdString()); + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &UserContextMenu::banUser_processUserInfoResponse); + client->sendCommand(pend); +} + +void UserContextMenu::execWarn(const QString &userName) +{ + Command_GetUserInfo cmd; + cmd.set_user_name(userName.toStdString()); + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &UserContextMenu::warnUser_processUserInfoResponse); + client->sendCommand(pend); +} + +void UserContextMenu::execBanHistory(const QString &userName) +{ + Command_GetBanHistory cmd; + cmd.set_user_name(userName.toStdString()); + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &UserContextMenu::banUserHistory_processResponse); + client->sendCommand(pend); +} + +void UserContextMenu::execWarnHistory(const QString &userName) +{ + Command_GetWarnHistory cmd; + cmd.set_user_name(userName.toStdString()); + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &UserContextMenu::warnUserHistory_processResponse); + client->sendCommand(pend); +} + +void UserContextMenu::execAdminNotes(const QString &userName) +{ + Command_GetAdminNotes cmd; + cmd.set_user_name(userName.toStdString()); + auto *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &UserContextMenu::getAdminNotes_processResponse); + client->sendCommand(pend); +} + +void UserContextMenu::execInvestigateUser(const QString &userName) +{ + tabSupervisor->openTabModeration(userName); +} + +void UserContextMenu::execAdjustMod(const QString &userName, bool shouldBeMod) +{ + Command_AdjustMod cmd; + cmd.set_user_name(userName.toStdString()); + cmd.set_should_be_mod(shouldBeMod); + PendingCommand *pend = client->prepareAdminCommand(cmd); + connect(pend, &PendingCommand::finished, this, &UserContextMenu::adjustMod_processUserResponse); + client->sendCommand(pend); +} + +void UserContextMenu::execAdjustJudge(const QString &userName, bool shouldBeJudge) +{ + Command_AdjustMod cmd; + cmd.set_user_name(userName.toStdString()); + cmd.set_should_be_judge(shouldBeJudge); + PendingCommand *pend = client->prepareAdminCommand(cmd); + connect(pend, &PendingCommand::finished, this, &UserContextMenu::adjustMod_processUserResponse); + client->sendCommand(pend); +} + +void UserContextMenu::execAdjustDeveloper(const QString &userName, bool shouldBeDeveloper) +{ + Command_AdjustMod cmd; + cmd.set_user_name(userName.toStdString()); + cmd.set_should_be_developer(shouldBeDeveloper); + PendingCommand *pend = client->prepareAdminCommand(cmd); + connect(pend, &PendingCommand::finished, this, &UserContextMenu::adjustMod_processUserResponse); + client->sendCommand(pend); +} diff --git a/cockatrice/src/interface/widgets/server/user/user_context_menu.h b/cockatrice/src/interface/widgets/server/user/user_context_menu.h index b0ff89816..6abbc057a 100644 --- a/cockatrice/src/interface/widgets/server/user/user_context_menu.h +++ b/cockatrice/src/interface/widgets/server/user/user_context_menu.h @@ -7,9 +7,12 @@ #ifndef USER_CONTEXT_MENU_H #define USER_CONTEXT_MENU_H -#include -#include +#include "../../interface/widgets/server/game_link.h" +#include +#include +#include +#include class AbstractGame; class UserListProxy; class AbstractClient; @@ -38,11 +41,15 @@ private: QAction *aAddToBuddyList, *aRemoveFromBuddyList; QAction *aAddToIgnoreList, *aRemoveFromIgnoreList; QAction *aKick; + QAction *aReport; QAction *aBan, *aBanHistory; QAction *aPromoteToMod, *aDemoteFromMod; QAction *aPromoteToJudge, *aDemoteFromJudge; + QAction *aPromoteToDeveloper, *aDemoteFromDeveloper; QAction *aWarnUser, *aWarnHistory; QAction *aGetAdminNotes; + std::function()> gameInviteLinkProvider; + QAction *aInvestigateUser; signals: void openMessageDialog(const QString &userName, bool focus); private slots: @@ -74,6 +81,53 @@ public: int playerId, const QString &deckHash, ChatView *chatView = nullptr); + + const UserListProxy *getUserListProxy() const + { + return userListProxy; + } + + void setGameInviteLinkProvider(std::function()> provider) + { + gameInviteLinkProvider = std::move(provider); + } + + /** + * The games currently inviteable for @p userName, honoring the room's + * buddy-only setting (the inviter must be the game's creator and the + * target a buddy of theirs). Empty when there is no live provider. + */ + QList inviteOptionsForUser(const QString &userName) const; + + /** Whether at least one invite link is currently available for @p userName. */ + bool hasGameInviteLink(const QString &userName) const + { + return !inviteOptionsForUser(userName).isEmpty(); + } + + // Individual action entry points — used by UserInfoPopup to trigger + // actions without re-running the full context menu flow. + void execChat(const QString &userName); + void execInvite(const QString &userName); + void execDetails(const QString &userName); + void execShowGames(const QString &userName); + void execAddToBuddy(const QString &userName); + void execRemoveFromBuddy(const QString &userName); + void execAddToIgnore(const QString &userName); + void execRemoveFromIgnore(const QString &userName); + void execKick(int playerId); + void execBan(const QString &userName); + void execWarn(const QString &userName); + void execBanHistory(const QString &userName); + void execWarnHistory(const QString &userName); + void execAdminNotes(const QString &userName); + void execInvestigateUser(const QString &userName); + void execAdjustMod(const QString &userName, bool shouldBeMod); + void execAdjustJudge(const QString &userName, bool shouldBeJudge); + void execAdjustDeveloper(const QString &userName, bool shouldBeDeveloper); + +private: + void execInvite(const QString &userName, const GameInviteOption &option); }; #endif diff --git a/cockatrice/src/interface/widgets/server/user/user_info_box.cpp b/cockatrice/src/interface/widgets/server/user/user_info_box.cpp index e41ae6e75..3d89cecf5 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_box.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_info_box.cpp @@ -5,6 +5,7 @@ #include "../../interface/widgets/dialogs/dlg_edit_password.h" #include "../../interface/widgets/dialogs/dlg_edit_user.h" #include "../../interface/widgets/utility/get_text_with_max.h" +#include "user_card_settings_dialog.h" #include #include @@ -61,11 +62,13 @@ UserInfoBox::UserInfoBox(AbstractClient *_client, bool _editable, QWidget *paren buttonsLayout->addWidget(&editButton); buttonsLayout->addWidget(&passwordButton); buttonsLayout->addWidget(&avatarButton); + buttonsLayout->addWidget(&bannerCardButton); mainLayout->addLayout(buttonsLayout, 7, 0, 1, 3); connect(&editButton, &QPushButton::clicked, this, &UserInfoBox::actEdit); connect(&passwordButton, &QPushButton::clicked, this, &UserInfoBox::actPassword); connect(&avatarButton, &QPushButton::clicked, this, &UserInfoBox::actAvatar); + connect(&bannerCardButton, &QPushButton::clicked, this, &UserInfoBox::actBannerCard); } setWindowTitle(tr("User Information")); @@ -83,11 +86,15 @@ void UserInfoBox::retranslateUi() editButton.setText(tr("Edit")); passwordButton.setText(tr("Change password")); avatarButton.setText(tr("Change avatar")); + bannerCardButton.setText(tr("Edit Banner Card")); } void UserInfoBox::updateInfo(const ServerInfo_User &user) { - userLevel = UserLevelFlags(user.user_level()); + currentUserInfo = user; + hasUserInfo = true; + + const UserLevelFlags userLevel(user.user_level()); pawnColors = user.pawn_colors(); privLevel = QString::fromStdString(user.privlevel()); @@ -115,6 +122,8 @@ void UserInfoBox::updateInfo(const ServerInfo_User &user) QString userLevelText; if (userLevel.testFlag(ServerInfo_User::IsAdmin)) { userLevelText = tr("Administrator"); + } else if (userLevel.testFlag(ServerInfo_User::IsDeveloper)) { + userLevelText = tr("Developer"); } else if (userLevel.testFlag(ServerInfo_User::IsModerator)) { userLevelText = tr("Moderator"); } else if (userLevel.testFlag(ServerInfo_User::IsRegistered)) { @@ -306,6 +315,50 @@ void UserInfoBox::actAvatar() client->sendCommand(pend); } +void UserInfoBox::actBannerCard() +{ + CardArtParams initial; + if (hasUserInfo && currentUserInfo.has_card_art_params()) { + const auto &cap = currentUserInfo.card_art_params(); + initial.cardName = QString::fromStdString(cap.card_name()); + initial.cardProviderId = QString::fromStdString(cap.card_provider_id()); + initial.marginPctL = cap.margin_pct_l(); + initial.marginPctR = cap.margin_pct_r(); + initial.verticalOffset = cap.vertical_offset(); + initial.zoom = cap.zoom(); + } + + UserCardArtSettingsDialog dlg(initial, this); + if (dlg.exec() != QDialog::Accepted) { + return; + } + + const CardArtParams p = dlg.params(); + + Command_SetCardArtParams cmd; + cmd.set_card_name(p.cardName.toStdString()); + if (!p.cardName.isEmpty()) { + cmd.set_card_provider_id(p.cardProviderId.toStdString()); + cmd.set_margin_pct_l(p.marginPctL); + cmd.set_margin_pct_r(p.marginPctR); + cmd.set_vertical_offset(p.verticalOffset); + cmd.set_zoom(p.zoom); + } + + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, [p, this](const Response &r) { + if (r.response_code() != Response::RespOk) { + QMessageBox::critical(this, tr("Error"), + tr("The selected card is blacklisted on this server or another error occurred.")); + } else { + updateInfo(nameLabel.text()); // re-fetch so currentUserInfo reflects the change + QMessageBox::information(this, tr("Information"), + p.cardName.isEmpty() ? tr("Banner card removed.") : tr("Banner card updated.")); + } + }); + client->sendCommand(pend); +} + void UserInfoBox::processEditResponse(const Response &r) { switch (r.response_code()) { diff --git a/cockatrice/src/interface/widgets/server/user/user_info_box.h b/cockatrice/src/interface/widgets/server/user/user_info_box.h index 055ac0096..955cb9d3d 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_box.h +++ b/cockatrice/src/interface/widgets/server/user/user_info_box.h @@ -12,6 +12,7 @@ #include #include #include +#include #include class AbstractClient; @@ -25,9 +26,11 @@ private: bool editable; QLabel avatarPic, userLevelIcon, nameLabel, realNameLabel1, realNameLabel2, countryLabel1, countryLabel2, countryLabel3, userLevelLabel1, userLevelLabel2, accountAgeLabel1, accountAgeLabel2; - QPushButton editButton, passwordButton, avatarButton; + QPushButton editButton, passwordButton, avatarButton, bannerCardButton; QPixmap avatarPixmap; bool hasAvatar; + ServerInfo_User currentUserInfo; + bool hasUserInfo = false; UserLevelFlags userLevel; ServerInfo_User::PawnColorsOverride pawnColors; QString privLevel; @@ -37,6 +40,7 @@ private: public: UserInfoBox(AbstractClient *_client, bool editable, QWidget *parent = nullptr, Qt::WindowFlags flags = {}); void retranslateUi(); + private slots: void processResponse(const Response &r); void processEditResponse(const Response &r); @@ -47,6 +51,7 @@ private slots: void actEditInternal(const Response &r); void actPassword(); void actAvatar(); + void actBannerCard(); public slots: void updateInfo(const ServerInfo_User &user); void updateInfo(const QString &userName); diff --git a/cockatrice/src/interface/widgets/server/user/user_info_connection.cpp b/cockatrice/src/interface/widgets/server/user/user_info_connection.cpp index 919bc2707..f934960f8 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_connection.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_info_connection.cpp @@ -3,6 +3,7 @@ #include "../../../../client/settings/cache_settings.h" #include +#include #include UserConnection_Information::UserConnection_Information() = default; diff --git a/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp b/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp new file mode 100644 index 000000000..8be76eea0 --- /dev/null +++ b/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp @@ -0,0 +1,787 @@ +#include "user_info_popup.h" + +#include "../../cards/art_crop_attribution.h" +#include "../../interface/pixel_map_generator.h" +#include "../../interface/theme_manager.h" +#include "../../interface/widgets/tabs/tab_supervisor.h" +#include "user_list_painter.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/// Qt stylesheets accept #aarrggbb, which is QColor::name(QColor::HexArgb). +static QString colorStr(const QColor &color) +{ + return color.name(QColor::HexArgb); +} + +PopupTheme PopupTheme::fromPalette(const QPalette &palette, bool dark) +{ + PopupTheme t; + t.dark = dark; + const QColor window = palette.color(QPalette::Window); + const QColor base = palette.color(QPalette::Base); + const QColor mid = palette.color(QPalette::Mid); + const QColor text = palette.color(QPalette::Text); + const QColor disabledText = palette.color(QPalette::Disabled, QPalette::Text); + const QColor highlight = palette.color(QPalette::Highlight); + + t.bg = window; + t.border = mid; + t.text = text; + t.subText = disabledText; + t.statusText = disabledText; + t.buttonBg = base; + t.buttonBorder = mid; + t.buttonHover = UserListPainter::blend(base, highlight, dark ? 0.30 : 0.12); + t.buttonPressed = UserListPainter::blend(base, highlight, dark ? 0.50 : 0.25); + t.buttonDisabled = disabledText; + t.closeBg = UserListPainter::blend(base, window, 0.5); + t.closeHover = dark ? QColor(200, 50, 50) : UserListPainter::blend(QColor(200, 50, 50), base, 0.45); + t.gamesRow = base; + t.gamesSelected = UserListPainter::blend(base, highlight, dark ? 0.45 : 0.30); + t.gamesSeparator = mid; + t.gamesSeparator.setAlpha(90); + return t; +} + +// ── Compact game row delegate ───────────────────────────────────────────────── + +class PopupGameDelegate : public QStyledItemDelegate +{ +public: + using QStyledItemDelegate::QStyledItemDelegate; + + QSize sizeHint(const QStyleOptionViewItem &, const QModelIndex &) const override + { + return QSize(0, 38); + } + + void paint(QPainter *p, const QStyleOptionViewItem &option, const QModelIndex &index) const override + { + const QVariant var = index.data(PopupRoles::GameData); + if (!var.isValid()) { + QStyledItemDelegate::paint(p, option, index); + return; + } + + p->save(); + p->setRenderHints(QPainter::Antialiasing | QPainter::TextAntialiasing); + + const QRect rect = option.rect; + const ServerInfo_Game game = var.value(); + const bool selected = option.state & QStyle::State_Selected; + const bool dark = themeManager && themeManager->isDarkModeActive(); + // The widget palette can be stale after a runtime theme change, so the + // rows are styled from the application palette (always current). + const QPalette pal = qApp->palette(); + const QColor base = pal.color(QPalette::Base); + const QColor highlight = pal.color(QPalette::Highlight); + + p->fillRect(rect, selected ? UserListPainter::blend(base, highlight, dark ? 0.45 : 0.30) : base); + + // State colour dot + const QColor dot = game.started() ? QColor(239, 68, 68) + : (game.player_count() >= game.max_players()) ? QColor(249, 115, 22) + : game.with_password() ? QColor(59, 130, 246) + : QColor(34, 197, 94); + p->setPen(Qt::NoPen); + p->setBrush(dot); + p->drawEllipse(QRectF(rect.left() + 9, rect.top() + (rect.height() - 8) / 2.0, 8, 8)); + + // Game title (bold, elided) + QFont tf = option.font; + tf.setBold(true); + p->setFont(tf); + p->setPen(pal.color(QPalette::Text)); + const int textX = rect.left() + 26; + const int countW = 52; + const int titleW = rect.width() - textX - countW - 6; + p->drawText(QRect(textX, rect.top(), titleW, rect.height()), Qt::AlignVCenter | Qt::AlignLeft, + QFontMetrics(tf).elidedText(QString::fromStdString(game.description()), Qt::ElideRight, titleW)); + + // Player count + const bool full = game.player_count() >= game.max_players(); + p->setFont(option.font); + p->setPen(full ? QColor(249, 115, 22) : pal.color(QPalette::Disabled, QPalette::Text)); + p->drawText(QRect(rect.right() - countW - 4, rect.top(), countW, rect.height()), + Qt::AlignVCenter | Qt::AlignRight, + QStringLiteral("%1/%2").arg(game.player_count()).arg(game.max_players())); + + // Row separator + QColor separator = pal.color(QPalette::Mid); + separator.setAlpha(90); + p->setPen(separator); + p->drawLine(rect.bottomLeft(), rect.bottomRight()); + + p->restore(); + } +}; + +// ── UserInfoHeaderWidget ────────────────────────────────────────────────────── + +UserInfoHeaderWidget::UserInfoHeaderWidget(QWidget *parent) : QWidget(parent) +{ + setFixedHeight(HeaderHeight); + setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); +} + +void UserInfoHeaderWidget::setUserData(const ServerInfo_User &_user, + bool _online, + const QPixmap &_avatar, + const QPixmap &_cardArt, + const CardArtParams &_params) +{ + user = _user; + online = _online; + avatar = _avatar; + cardArt = _cardArt; + params = _params; + + attribution.clear(); + if (user.has_card_art_params()) { + const ExactCard card = + CardDatabaseManager::query()->getCard({QString::fromStdString(user.card_art_params().card_name()), + QString::fromStdString(user.card_art_params().card_provider_id())}); + if (card) { + attribution = buildArtAttribution(card); + } + } + update(); +} + +void UserInfoHeaderWidget::paintEvent(QPaintEvent *) +{ + QPainter p(this); + p.setRenderHints(QPainter::Antialiasing | QPainter::SmoothPixmapTransform | QPainter::TextAntialiasing); + + const QRect rect = this->rect(); + const UserLevelFlags level(user.user_level()); + const QString userName = QString::fromStdString(user.name()); + const QString privLevel = QString::fromStdString(user.privlevel()); + + const bool dark = themeManager && themeManager->isDarkModeActive(); + const UserListPainter::Style style = UserListPainter::resolveStyle(qApp->palette(), dark); + + // Palette surface + { + QLinearGradient bg(0, 0, rect.width(), 0); + bg.setColorAt(0, style.cardStart); + bg.setColorAt(1, style.cardEnd); + p.fillRect(rect, bg); + } + + // ── Card art background ─────────────────────────────────────────────────── + if (!cardArt.isNull()) { + // Same DPR normalization as UserListPainter::drawCardArt: the cache + // carries screen scaled pixmaps on HiDPI displays, the math below is + // in raw pixels. + QPixmap art = cardArt; + art.setDevicePixelRatio(1.0); + + const int w = rect.width(); + const int h = rect.height(); + const int mL = qRound(w * params.marginPctL); + const int mR = qRound(w * params.marginPctR); + const int dW = w - mL - mR; + + const double base = qMax(double(dW) / art.width(), double(h) / art.height()); + const double scale = base * params.zoom; + const int sW = qRound(art.width() * scale); + const int sH = qRound(art.height() * scale); + + const QPixmap scaled = art.scaled(sW, sH, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + // Clamp against stored zoom < 1, which can push srcX negative and silently + // underfill the strip with transparent padding + const int safeSrcX = qBound(0, (sW - dW) / 2, qMax(0, sW - dW)); + const int safeSrcY = qBound(0, qRound((sH - h) * params.verticalOffset), qMax(0, sH - h)); + + QImage img = + scaled.copy(safeSrcX, safeSrcY, dW, h).toImage().convertToFormat(QImage::Format_ARGB32_Premultiplied); + { + QPainter mask(&img); + mask.setCompositionMode(QPainter::CompositionMode_DestinationIn); + QLinearGradient g(0, 0, img.width(), 0); + g.setColorAt(0.00, Qt::transparent); + g.setColorAt(0.18, Qt::white); + g.setColorAt(0.82, Qt::white); + g.setColorAt(1.00, Qt::transparent); + mask.fillRect(img.rect(), g); + } + p.setOpacity(0.48); + p.drawImage(mL, 0, img); + p.setOpacity(1.0); + } + + // Bottom gradient overlay so avatar and text are always legible. The scrim + // is the palette's Window color so it reads naturally in either scheme. + { + const QColor scrim = qApp->palette().color(QPalette::Window); + QLinearGradient ov(0, 0, 0, rect.height()); + ov.setColorAt(0.0, QColor(scrim.red(), scrim.green(), scrim.blue(), 0)); + ov.setColorAt(0.55, QColor(scrim.red(), scrim.green(), scrim.blue(), 110)); + ov.setColorAt(1.0, QColor(scrim.red(), scrim.green(), scrim.blue(), 230)); + p.fillRect(rect, ov); + } + + // ── Avatar ──────────────────────────────────────────────────────────────── + const QColor accent = [&]() -> QColor { + if (level.testFlag(ServerInfo_User::IsAdmin)) { + return QColor(245, 158, 11); + } + if (level.testFlag(ServerInfo_User::IsDeveloper)) { + return QColor(185, 28, 28); + } + if (level.testFlag(ServerInfo_User::IsModerator)) { + return QColor(59, 130, 246); + } + if (level.testFlag(ServerInfo_User::IsJudge)) { + return QColor(168, 85, 247); + } + return QColor(100, 116, 139); + }(); + + const int ax = LeftPad; + const int ay = rect.height() - AvatarSize - 10; + const QRect ar(ax, ay, AvatarSize, AvatarSize); + + QPainterPath clip; + clip.addEllipse(ar); + p.save(); + p.setClipPath(clip); + + if (!avatar.isNull()) { + p.drawPixmap(ar, avatar.scaled(ar.size(), Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation)); + } else { + p.setPen(Qt::NoPen); + p.setBrush(UserListPainter::blend(accent, style.base, dark ? 0.45 : 0.72)); + p.drawEllipse(ar); + const QPixmap pawn = + UserLevelPixmapGenerator::generatePixmap(AvatarPawnSize, level, user.pawn_colors(), false, privLevel); + p.drawPixmap(ar.center().x() - AvatarPawnSize / 2, ar.center().y() - AvatarPawnSize / 2, pawn); + } + p.restore(); + + // Status ring + p.setPen(QPen(online ? QColor(34, 197, 94) : style.ringOffline, 2.5)); + p.setBrush(Qt::NoBrush); + p.drawEllipse(QRectF(ar).adjusted(-1.25, -1.25, 1.25, 1.25)); + + // ── Username + badge ────────────────────────────────────────────────────── + const int tx = ax + AvatarSize + AvatarToTextGap; + const int tw = rect.width() - tx - 8; + + QFont nf = font(); + nf.setBold(true); + nf.setPointSizeF(nf.pointSizeF() * 1.12); + p.setFont(nf); + p.setPen(online ? style.textOnline : style.textOffline); + p.drawText(QRect(tx, ay, tw, AvatarSize / 2 + 4), Qt::AlignBottom | Qt::AlignLeft, + QFontMetrics(nf).elidedText(userName, Qt::ElideRight, tw)); + + // Level / priv badge + struct + { + QString text; + QColor color; + } badge; + if (level.testFlag(ServerInfo_User::IsAdmin)) { + badge = {"ADMIN", QColor(245, 158, 11)}; + } else if (level.testFlag(ServerInfo_User::IsDeveloper)) { + badge = {"DEV", QColor(185, 28, 28)}; + } else if (level.testFlag(ServerInfo_User::IsModerator)) { + badge = {"MOD", QColor(59, 130, 246)}; + } else if (level.testFlag(ServerInfo_User::IsJudge)) { + badge = {"JUDGE", QColor(168, 85, 247)}; + } else if (privLevel == "VIP") { + badge = {"VIP", QColor(20, 184, 166)}; + } else if (privLevel == "DONATOR") { + badge = {"DONATOR", QColor(249, 115, 22)}; + } + + if (!badge.text.isEmpty()) { + QFont bf = font(); + bf.setPointSizeF(bf.pointSizeF() * 0.70); + bf.setBold(true); + p.setFont(bf); + const QFontMetrics bfm(bf); + const int bw = bfm.horizontalAdvance(badge.text) + 10; + const QRect br(tx, ay + AvatarSize / 2 + 6, bw, 15); + p.setPen(Qt::NoPen); + p.setBrush(UserListPainter::blend(badge.color, style.base, dark ? 0.55 : 0.78)); + p.drawRoundedRect(br, 3, 3); + p.setPen(dark ? UserListPainter::blend(badge.color, Qt::white, 0.5) + : UserListPainter::blend(badge.color, Qt::black, 0.35)); + p.drawText(br, Qt::AlignCenter, badge.text); + } + + // The painter font at this point depends on whether a badge was drawn + // (badge font vs username font), so pin an explicit font for the pill. + p.setFont(font()); + + // Only show the attribution when there is actually art on screen: on a + // cache miss cardArt is null and the pill would float over the plain + // header with no art behind it. + if (!cardArt.isNull()) { + paintArtAttribution(p, rect, attribution, Qt::AlignRight | Qt::AlignBottom, 0.8); + } +} + +// ── UserInfoPopup ───────────────────────────────────────────────────────────── + +UserInfoPopup::UserInfoPopup(TabSupervisor *_ts, + AbstractClient *_client, + const QMap *_avatarCache, + const QMap *_cardArtCache, + const QMap *_cardArtParamsMap, + QWidget *parent) + : QFrame(parent, Qt::Tool | Qt::FramelessWindowHint), ts(_ts), client(_client), avatarCache(_avatarCache), + cardArtCache(_cardArtCache), cardArtParamsMap(_cardArtParamsMap) +{ + setAttribute(Qt::WA_ShowWithoutActivating); + setFixedWidth(PopupWidth); + setFrameShape(QFrame::NoFrame); + buildUi(); + + // Restyle the popup chrome when the theme or its color scheme changes. + if (themeManager) { + connect(themeManager, &ThemeManager::themeChanged, this, &UserInfoPopup::applyTheme); + } +} + +void UserInfoPopup::buildUi() +{ + auto *root = new QVBoxLayout(this); + root->setContentsMargins(0, 0, 0, 0); + root->setSpacing(0); + + // Header + header = new UserInfoHeaderWidget(this); + root->addWidget(header); + + // Action area, rebuilt per user + actionArea = new QWidget(this); + root->addWidget(actionArea); + + // Thin separator + separator = new QFrame(this); + separator->setFrameShape(QFrame::HLine); + root->addWidget(separator); + + // Games header row + auto *gh = new QHBoxLayout; + gh->setContentsMargins(10, 4, 8, 2); + gamesLabel = new QLabel(tr("Games"), this); + gh->addWidget(gamesLabel); + gh->addStretch(); + refreshBtn = new QPushButton(QStringLiteral("↻"), this); + refreshBtn->setFixedSize(20, 20); + refreshBtn->setFlat(true); + connect(refreshBtn, &QPushButton::clicked, this, &UserInfoPopup::refreshGames); + gh->addWidget(refreshBtn); + root->addLayout(gh); + + // Status label + gamesStatus = new QLabel(this); + gamesStatus->setAlignment(Qt::AlignCenter); + root->addWidget(gamesStatus); + + // Games list + gamesModel = new QStandardItemModel(this); + gamesView = new QListView(this); + gamesView->setModel(gamesModel); + gamesView->setItemDelegate(new PopupGameDelegate(gamesView)); + gamesView->setFrameShape(QFrame::NoFrame); + gamesView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + gamesView->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + gamesView->setMaximumHeight(220); + gamesView->setContextMenuPolicy(Qt::CustomContextMenu); + connect(gamesView, &QListView::customContextMenuRequested, this, &UserInfoPopup::onGamesContextMenu); + + root->addWidget(gamesView); + + // Close button, positioned absolutely in the top right corner + closeBtn = new QPushButton(QStringLiteral("✕"), this); + closeBtn->setFixedSize(22, 22); + closeBtn->setFlat(true); + connect(closeBtn, &QPushButton::clicked, this, &UserInfoPopup::closeRequested); + + applyTheme(); +} + +void UserInfoPopup::applyTheme() +{ + const bool dark = themeManager && themeManager->isDarkModeActive(); + theme = PopupTheme::fromPalette(qApp->palette(), dark); + + setStyleSheet(QStringLiteral("UserInfoPopup {" + " background:%1;" + " border:1px solid %2;" + " border-radius:8px;" + "}") + .arg(colorStr(theme.bg), colorStr(theme.border))); + + actionArea->setStyleSheet(QStringLiteral("background:%1;").arg(colorStr(theme.bg))); + + separator->setStyleSheet(QStringLiteral("color:%1; margin: 0 8px;").arg(colorStr(theme.border))); + + gamesLabel->setStyleSheet(QStringLiteral("color:%1; font-size:11px; font-weight:bold; background:transparent;") + .arg(colorStr(theme.subText))); + + refreshBtn->setStyleSheet(QStringLiteral("QPushButton{color:%1;border:none;font-size:14px;background:transparent;}" + "QPushButton:hover{color:%2;}") + .arg(colorStr(theme.subText), colorStr(theme.text))); + + gamesStatus->setStyleSheet(QStringLiteral("color:%1; font-size:11px; padding:10px; background:transparent;") + .arg(colorStr(theme.statusText))); + + gamesView->setStyleSheet(QStringLiteral("QListView{background:%1;border:none;}" + "QListView::item:selected{background:%2;}") + .arg(colorStr(theme.gamesRow), colorStr(theme.gamesSelected))); + + closeBtn->setStyleSheet( + QStringLiteral("QPushButton{background:%1;color:%2;" + "border:none;border-radius:11px;font-size:10px;}" + "QPushButton:hover{color:%3;background:%4;}") + .arg(colorStr(theme.closeBg), colorStr(theme.subText), colorStr(theme.text), colorStr(theme.closeHover))); + + header->update(); +} + +// ── Action button factory ───────────────────────────────────────────────────── + +static QPushButton *makeBtn(const QString &label, const QString &tip, QWidget *p, const PopupTheme &t) +{ + auto *b = new QPushButton(label, p); + b->setToolTip(tip); + b->setFixedHeight(26); + b->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + b->setStyleSheet(QStringLiteral("QPushButton{" + " background:%1;color:%2;border:1px solid %3;" + " border-radius:4px;font-size:11px;padding:0 4px;" + "}" + "QPushButton:hover{background:%4;color:%5;}" + "QPushButton:pressed{background:%6;}" + "QPushButton:disabled{color:%7;border-color:%3;}") + .arg(colorStr(t.buttonBg), colorStr(t.text), colorStr(t.buttonBorder), colorStr(t.buttonHover), + colorStr(t.text), colorStr(t.buttonPressed), colorStr(t.buttonDisabled))); + return b; +} + +void UserInfoPopup::rebuildActionButtons(const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored) +{ + // Clear previous contents + delete actionArea->layout(); + const auto old = actionArea->findChildren(QString{}, Qt::FindDirectChildrenOnly); + for (auto *w : old) { + w->deleteLater(); + } + + const QString name = QString::fromStdString(userInfo.name()); + const auto ownLevel = UserLevelFlags(ts->getUserInfo()->user_level()); + const bool isSelf = (name == QString::fromStdString(ts->getUserInfo()->name())); + const bool isMod = ownLevel.testFlag(ServerInfo_User::IsModerator); + const bool isAdmin = ownLevel.testFlag(ServerInfo_User::IsAdmin); + const auto their = UserLevelFlags(userInfo.user_level()); + const bool isReg = their.testFlag(ServerInfo_User::IsRegistered); + + auto *grid = new QGridLayout(actionArea); + grid->setContentsMargins(8, 6, 8, 6); + grid->setSpacing(4); + + int row = 0, col = 0; + const int cols = 3; + auto add = [&](QPushButton *btn) { + grid->addWidget(btn, row, col); + if (++col == cols) { + col = 0; + ++row; + } + }; + + // ── Always visible ──────────────────────────────────────────────────────── + auto *chat = makeBtn(tr("Chat"), tr("Open private chat"), actionArea, theme); + chat->setEnabled(!isSelf && online); + connect(chat, &QPushButton::clicked, this, [this, name] { emit chatRequested(name); }); + add(chat); + + auto *prof = makeBtn(tr("Profile"), tr("View user profile"), actionArea, theme); + connect(prof, &QPushButton::clicked, this, [this, name] { emit detailsRequested(name); }); + add(prof); + + auto *games = makeBtn(tr("Games"), tr("Show this user's games"), actionArea, theme); + games->setEnabled(!isSelf && online); + connect(games, &QPushButton::clicked, this, [this, name] { emit showGamesRequested(name); }); + add(games); + + // ── Invite (only while the inviter has a joinable game for this user) ──── + if (!isSelf && online && gameInviteAvailable && gameInviteAvailable(name)) { + auto *invite = makeBtn(tr("Invite"), tr("Invite to your game"), actionArea, theme); + connect(invite, &QPushButton::clicked, this, [this, name] { emit inviteRequested(name); }); + add(invite); + } + + // ── Buddy / ignore (registered users only) ──────────────────────────────── + if (!isSelf && isReg) { + if (isBuddy) { + auto *b = makeBtn(tr("− Buddy"), tr("Remove from buddy list"), actionArea, theme); + connect(b, &QPushButton::clicked, this, [this, name] { emit removeBuddyRequested(name); }); + add(b); + } else { + auto *b = makeBtn(tr("+ Buddy"), tr("Add to buddy list"), actionArea, theme); + connect(b, &QPushButton::clicked, this, [this, name] { emit addBuddyRequested(name); }); + add(b); + } + if (isIgnored) { + auto *b = makeBtn(tr("− Ignore"), tr("Remove from ignore list"), actionArea, theme); + connect(b, &QPushButton::clicked, this, [this, name] { emit removeIgnoreRequested(name); }); + add(b); + } else { + auto *b = makeBtn(tr("+ Ignore"), tr("Add to ignore list"), actionArea, theme); + connect(b, &QPushButton::clicked, this, [this, name] { emit addIgnoreRequested(name); }); + add(b); + } + } + + // ── Moderator actions ───────────────────────────────────────────────────── + if (!isSelf && (isMod || isAdmin)) { + if (col != 0) { + ++row; + col = 0; + } // start mod section on a fresh row + + auto *ban = makeBtn(tr("Ban"), tr("Ban from server"), actionArea, theme); + auto *warn = makeBtn(tr("Warn"), tr("Warn user"), actionArea, theme); + auto *bLog = makeBtn(tr("Ban log"), tr("View ban history"), actionArea, theme); + auto *wLog = makeBtn(tr("Warn log"), tr("View warning history"), actionArea, theme); + connect(ban, &QPushButton::clicked, this, [this, name] { emit banRequested(name); }); + connect(warn, &QPushButton::clicked, this, [this, name] { emit warnRequested(name); }); + connect(bLog, &QPushButton::clicked, this, [this, name] { emit banHistoryRequested(name); }); + connect(wLog, &QPushButton::clicked, this, [this, name] { emit warnHistoryRequested(name); }); + add(ban); + add(warn); + add(bLog); + add(wLog); + } + + // ── Admin actions ───────────────────────────────────────────────────────── + if (!isSelf && isAdmin) { + auto *notes = makeBtn(tr("Notes"), tr("View admin notes"), actionArea, theme); + connect(notes, &QPushButton::clicked, this, [this, name] { emit adminNotesRequested(name); }); + add(notes); + + if (their.testFlag(ServerInfo_User::IsModerator)) { + auto *b = makeBtn(tr("− Mod"), tr("Demote from moderator"), actionArea, theme); + connect(b, &QPushButton::clicked, this, [this, name] { emit demoteFromModRequested(name); }); + add(b); + } else if (isReg) { + auto *b = makeBtn(tr("+ Mod"), tr("Promote to moderator"), actionArea, theme); + connect(b, &QPushButton::clicked, this, [this, name] { emit promoteToModRequested(name); }); + add(b); + } + if (their.testFlag(ServerInfo_User::IsJudge)) { + auto *b = makeBtn(tr("− Judge"), tr("Demote from judge"), actionArea, theme); + connect(b, &QPushButton::clicked, this, [this, name] { emit demoteFromJudgeRequested(name); }); + add(b); + } else if (isReg) { + auto *b = makeBtn(tr("+ Judge"), tr("Promote to judge"), actionArea, theme); + connect(b, &QPushButton::clicked, this, [this, name] { emit promoteToJudgeRequested(name); }); + add(b); + } + } + + actionArea->adjustSize(); +} + +void UserInfoPopup::updateActionButtons(const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored) +{ + rebuildActionButtons(userInfo, online, isBuddy, isIgnored); + adjustSize(); +} + +void UserInfoPopup::onGamesContextMenu(const QPoint &pos) +{ + const QModelIndex idx = gamesView->indexAt(pos); + if (!idx.isValid()) { + return; + } + + const QVariant var = idx.data(PopupRoles::GameData); + if (!var.isValid()) { + return; + } + const ServerInfo_Game game = var.value(); + + QMenu menu(this); + menu.setStyleSheet( + QStringLiteral("QMenu{background:%1;color:%2;border:1px solid %3;border-radius:4px;}" + "QMenu::item:selected{background:%4;}") + .arg(colorStr(theme.bg), colorStr(theme.text), colorStr(theme.border), colorStr(theme.buttonHover))); + + const bool canJoin = !game.started() && game.player_count() < game.max_players(); + QAction *join = menu.addAction(tr("Join game")); + join->setEnabled(canJoin); + + QAction *spec = nullptr; + if (game.spectators_allowed()) { + spec = menu.addAction(tr("Spectate")); + } + + const QAction *chosen = menu.exec(gamesView->viewport()->mapToGlobal(pos)); + if (!chosen) { + return; + } + + if (chosen == join) { + emit joinGameRequested(game.game_id(), game.room_id(), false); + } else if (spec && chosen == spec) { + emit joinGameRequested(game.game_id(), game.room_id(), true); + } +} + +// ── showForUser ─────────────────────────────────────────────────────────────── + +void UserInfoPopup::refreshHeader() +{ + if (currentUser.isEmpty()) { + return; + } + + const QPixmap avatar = avatarCache ? avatarCache->value(currentUser) : QPixmap{}; + const CardArtParams params = (cardArtParamsMap && cardArtParamsMap->contains(currentUser)) + ? cardArtParamsMap->value(currentUser) + : CardArtParams{}; + const QString artKey = currentUser + u'|' + params.cardName + u'|' + params.cardProviderId; + const QPixmap cardArt = (cardArtCache && !params.cardName.isEmpty()) ? cardArtCache->value(artKey) : QPixmap{}; + header->setUserData(currentUserInfo, currentOnline, avatar, cardArt, params); +} + +void UserInfoPopup::showForUser(const QString &userName, + const ServerInfo_User &userInfo, + bool online, + bool isBuddy, + bool isIgnored) +{ + currentUser = userName; + currentUserInfo = userInfo; + currentOnline = online; + + // Header + refreshHeader(); + + // Actions + rebuildActionButtons(userInfo, online, isBuddy, isIgnored); + + // Games list reset + gamesModel->clear(); + gamesView->hide(); + gamesStatus->setText(tr("Loading games…")); + gamesStatus->show(); + + // Close button, top right corner, above everything + closeBtn->move(PopupWidth - closeBtn->width() - 6, 6); + closeBtn->raise(); + + adjustSize(); + fetchGames(); +} + +// ── Games fetch ─────────────────────────────────────────────────────────────── + +void UserInfoPopup::fetchGames() +{ + if (!client || currentUser.isEmpty()) { + return; + } + + Command_GetGamesOfUser cmd; + cmd.set_user_name(currentUser.toStdString()); + + const QString snapshot = currentUser; + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, + [this, snapshot](const Response &r) { onGamesReceived(r, snapshot); }); + client->sendCommand(pend); +} + +void UserInfoPopup::onGamesReceived(const Response &r, const QString &forUser) +{ + if (forUser != currentUser) { + return; // stale response, different user showing now + } + + gamesModel->clear(); + + if (r.response_code() != Response::RespOk) { + gamesStatus->setText(tr("Could not load games.")); + gamesStatus->show(); + gamesView->hide(); + return; + } + + const auto &resp = r.GetExtension(Response_GetGamesOfUser::ext); + if (resp.game_list_size() == 0) { + gamesStatus->setText(tr("No active games.")); + gamesStatus->show(); + gamesView->hide(); + return; + } + + for (int i = 0; i < resp.game_list_size(); ++i) { + auto *item = new QStandardItem; + item->setData(QVariant::fromValue(resp.game_list(i)), PopupRoles::GameData); + item->setEditable(false); + gamesModel->appendRow(item); + } + + gamesStatus->hide(); + gamesView->show(); + + // Fit exactly to the number of visible rows, scroll when more than 5 + constexpr int rowH = 38; // must match PopupGameDelegate::sizeHint + constexpr int maxRows = 5; + const int count = gamesModel->rowCount(); + const int visible = qMin(count, maxRows); + gamesView->setFixedHeight(visible * rowH + 2); + gamesView->setVerticalScrollBarPolicy(count > maxRows ? Qt::ScrollBarAlwaysOn : Qt::ScrollBarAlwaysOff); + + adjustSize(); +} + +void UserInfoPopup::refreshGames() +{ + gamesModel->clear(); + gamesView->hide(); + gamesStatus->setText(tr("Loading games…")); + gamesStatus->show(); + fetchGames(); +} + +// ── Mouse events ────────────────────────────────────────────────────────────── + +void UserInfoPopup::enterEvent(QEnterEvent *e) +{ + QFrame::enterEvent(e); + emit mouseEnteredPopup(); +} +void UserInfoPopup::leaveEvent(QEvent *e) +{ + QFrame::leaveEvent(e); + emit mouseLeftPopup(); +} diff --git a/cockatrice/src/interface/widgets/server/user/user_info_popup.h b/cockatrice/src/interface/widgets/server/user/user_info_popup.h new file mode 100644 index 000000000..ed7320fba --- /dev/null +++ b/cockatrice/src/interface/widgets/server/user/user_info_popup.h @@ -0,0 +1,229 @@ +#ifndef COCKATRICE_USER_INFO_POPUP_H +#define COCKATRICE_USER_INFO_POPUP_H + +#include "../../interface/widgets/server/game_type_map.h" +#include "user_list_painter.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class AbstractClient; +class QLabel; +class QPushButton; +class TabSupervisor; + +// ── Roles ───────────────────────────────────────────────────────────────────── + +namespace PopupRoles +{ +constexpr int GameData = Qt::UserRole + 10; +} + +// Popup theme + +/** + * Palette-derived colors for the popup chrome. Both color schemes read from + * the active QPalette so custom palettes are respected. @c dark only tunes the + * blend strengths. + */ +struct PopupTheme +{ + bool dark = false; + QColor bg; + QColor border; + QColor text; + QColor subText; + QColor buttonBg; + QColor buttonBorder; + QColor buttonHover; + QColor buttonPressed; + QColor buttonDisabled; + QColor closeBg; + QColor closeHover; + QColor gamesRow; + QColor gamesSelected; + QColor gamesSeparator; + QColor statusText; + + static PopupTheme fromPalette(const QPalette &palette, bool dark); +}; + +// ── Header widget ───────────────────────────────────────────────────────────── + +/** + * @class UserInfoHeaderWidget + * @brief Paints the enlarged banner card art + circular avatar section at the + * top of the UserInfoPopup. + * + * Layout mirrors UserListPainter but at a larger scale: the card art fills the + * full width as a semi-transparent background, a bottom gradient ensures the + * avatar and username text remain legible, and the status ring colour matches + * the UserListPainter convention. + */ +class UserInfoHeaderWidget : public QWidget +{ + Q_OBJECT + + static constexpr int HeaderHeight = 130; + static constexpr int AvatarSize = 68; + static constexpr int AvatarPawnSize = 46; + static constexpr int LeftPad = 14; + static constexpr int AvatarToTextGap = 10; + +public: + explicit UserInfoHeaderWidget(QWidget *parent = nullptr); + + void setUserData(const ServerInfo_User &_user, + bool _online, + const QPixmap &_avatar, + const QPixmap &_cardArt, + const CardArtParams &_params); + +protected: + void paintEvent(QPaintEvent *e) override; + +private: + ServerInfo_User user; + bool online = false; + QPixmap avatar; + QPixmap cardArt; + CardArtParams params; + QString attribution; +}; + +// ── Main popup ──────────────────────────────────────────────────────────────── + +/** + * @class UserInfoPopup + * @brief Floating panel showing an enlarged user card, quick action buttons, + * and a live scrollable games list. + * + * Lifecycle (mirrors DeckEditorDeckDockWidget): + * - showForUser() — populate, position externally, call show() + * - mouseEnteredPopup / mouseLeftPopup — caller manages hide timer + * - closeRequested() — emitted by the internal close button + * + * The popup is a Qt::Tool frameless child so windowOpacity animations and + * move() in screen coordinates work identically to CardInfoPictureEnlargedWidget. + * + * Action signals map 1-to-1 to UserContextMenu::exec*() methods so all action + * logic stays in one place. + */ +class UserInfoPopup : public QFrame +{ + Q_OBJECT + + static constexpr int PopupWidth = 316; + +public: + explicit UserInfoPopup(TabSupervisor *_ts, + AbstractClient *_client, + const QMap *_avatarCache, + const QMap *_cardArtCache, + const QMap *_cardArtParamsMap, + QWidget *parent); + + /** + * Populate the popup for @p userName and kick off a game list fetch. + * Call show() / move() externally after this. + */ + void + showForUser(const QString &userName, const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored); + void fetchGames(); + + [[nodiscard]] QString getCurrentUser() const + { + return currentUser; + } + + /** Called when buddy/ignore status changes externally while popup is open. */ + void updateActionButtons(const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored); + + /** Re-pulls the avatar/card art for the currently shown user (e.g. after it loads). */ + void refreshHeader(); + + /** + * Sets a predicate evaluated on every action-button rebuild. It receives + * the name of the user the popup currently shows; when it returns true an + * "Invite" button is shown. The popup itself never resolves the invite + * link, it just forwards the request. + */ + void setGameInviteAvailable(std::function available) + { + gameInviteAvailable = std::move(available); + } + +signals: + void mouseEnteredPopup(); + void mouseLeftPopup(); + void closeRequested(); + + /** Emitted when the user requests joining or spectating a game in the list. */ + void joinGameRequested(int gameId, int roomId, bool asSpectator); + + // ── Action signals — connect to UserContextMenu::exec*() ────────────────── + void chatRequested(const QString &userName); + void inviteRequested(const QString &userName); + void detailsRequested(const QString &userName); + void showGamesRequested(const QString &userName); + void addBuddyRequested(const QString &userName); + void removeBuddyRequested(const QString &userName); + void addIgnoreRequested(const QString &userName); + void removeIgnoreRequested(const QString &userName); + void banRequested(const QString &userName); + void warnRequested(const QString &userName); + void banHistoryRequested(const QString &userName); + void warnHistoryRequested(const QString &userName); + void adminNotesRequested(const QString &userName); + void promoteToModRequested(const QString &userName); + void demoteFromModRequested(const QString &userName); + void promoteToJudgeRequested(const QString &userName); + void demoteFromJudgeRequested(const QString &userName); + +protected: + void enterEvent(QEnterEvent *e) override; + void leaveEvent(QEvent *e) override; + +private slots: + void refreshGames(); + void onGamesReceived(const Response &r, const QString &forUser); + void onGamesContextMenu(const QPoint &pos); + +private: + void buildUi(); + void applyTheme(); + void rebuildActionButtons(const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored); + + TabSupervisor *ts; + AbstractClient *client; + const QMap *avatarCache; + const QMap *cardArtCache; + const QMap *cardArtParamsMap; + + PopupTheme theme; + + QString currentUser; + ServerInfo_User currentUserInfo; + bool currentOnline = false; + std::function gameInviteAvailable; + + UserInfoHeaderWidget *header; + QWidget *actionArea; ///< rebuilt per user + QLabel *gamesLabel; + QFrame *separator; + QListView *gamesView; + QStandardItemModel *gamesModel; + QLabel *gamesStatus; + QPushButton *closeBtn; + QPushButton *refreshBtn; +}; + +#endif // COCKATRICE_USER_INFO_POPUP_H \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/server/user/user_list_dialog.cpp b/cockatrice/src/interface/widgets/server/user/user_list_dialog.cpp new file mode 100644 index 000000000..fc5b520ad --- /dev/null +++ b/cockatrice/src/interface/widgets/server/user/user_list_dialog.cpp @@ -0,0 +1,302 @@ +#include "user_list_dialog.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +BanDialog::BanDialog(const ServerInfo_User &info, QWidget *parent) : QDialog(parent) +{ + setAttribute(Qt::WA_DeleteOnClose); + + nameBanCheckBox = new QCheckBox(tr("ban &user name")); + nameBanCheckBox->setChecked(true); + nameBanEdit = new QLineEdit(QString::fromStdString(info.name())); + nameBanEdit->setMaxLength(MAX_NAME_LENGTH); + ipBanCheckBox = new QCheckBox(tr("ban &IP address")); + ipBanCheckBox->setChecked(true); + ipBanEdit = new QLineEdit(QString::fromStdString(info.address())); + ipBanEdit->setMaxLength(MAX_NAME_LENGTH); + idBanCheckBox = new QCheckBox(tr("ban client I&D")); + idBanCheckBox->setChecked(true); + idBanEdit = new QLineEdit(QString::fromStdString(info.clientid())); + idBanEdit->setMaxLength(MAX_NAME_LENGTH); + if (QString::fromStdString(info.clientid()).isEmpty()) { + idBanCheckBox->setChecked(false); + } + + QGridLayout *banTypeGrid = new QGridLayout; + banTypeGrid->addWidget(nameBanCheckBox, 0, 0); + banTypeGrid->addWidget(nameBanEdit, 0, 1); + banTypeGrid->addWidget(ipBanCheckBox, 1, 0); + banTypeGrid->addWidget(ipBanEdit, 1, 1); + banTypeGrid->addWidget(idBanCheckBox, 2, 0); + banTypeGrid->addWidget(idBanEdit, 2, 1); + QGroupBox *banTypeGroupBox = new QGroupBox(tr("Ban type")); + banTypeGroupBox->setLayout(banTypeGrid); + + permanentRadio = new QRadioButton(tr("&permanent ban")); + temporaryRadio = new QRadioButton(tr("&temporary ban")); + temporaryRadio->setChecked(true); + connect(temporaryRadio, &QRadioButton::toggled, this, &BanDialog::enableTemporaryEdits); + daysLabel = new QLabel(tr("&Days:")); + daysEdit = new QSpinBox; + daysEdit->setMinimum(0); + daysEdit->setValue(0); + daysEdit->setMaximum(10000); + daysLabel->setBuddy(daysEdit); + hoursLabel = new QLabel(tr("&Hours:")); + hoursEdit = new QSpinBox; + hoursEdit->setMinimum(0); + hoursEdit->setValue(0); + hoursEdit->setMaximum(24); + hoursLabel->setBuddy(hoursEdit); + minutesLabel = new QLabel(tr("&Minutes:")); + minutesEdit = new QSpinBox; + minutesEdit->setMinimum(0); + minutesEdit->setValue(5); + minutesEdit->setMaximum(60); + minutesLabel->setBuddy(minutesEdit); + QGridLayout *durationLayout = new QGridLayout; + durationLayout->addWidget(permanentRadio, 0, 0, 1, 6); + durationLayout->addWidget(temporaryRadio, 1, 0, 1, 6); + durationLayout->addWidget(daysLabel, 2, 0); + durationLayout->addWidget(daysEdit, 2, 1); + durationLayout->addWidget(hoursLabel, 2, 2); + durationLayout->addWidget(hoursEdit, 2, 3); + durationLayout->addWidget(minutesLabel, 2, 4); + durationLayout->addWidget(minutesEdit, 2, 5); + QGroupBox *durationGroupBox = new QGroupBox(tr("Duration of the ban")); + durationGroupBox->setLayout(durationLayout); + + QLabel *reasonLabel = new QLabel(tr("Please enter the reason for the ban.\n" + "This is only saved for moderators and cannot be seen by the banned person.")); + reasonEdit = new QPlainTextEdit; + + QLabel *visibleReasonLabel = + new QLabel(tr("Please enter the reason for the ban that will be visible to the banned person.")); + visibleReasonEdit = new QPlainTextEdit; + + deleteMessages = new QCheckBox(tr("Redact all messages from this user in all rooms")); + + QPushButton *okButton = new QPushButton(tr("&OK")); + okButton->setAutoDefault(true); + connect(okButton, &QPushButton::clicked, this, &BanDialog::okClicked); + QPushButton *cancelButton = new QPushButton(tr("&Cancel")); + connect(cancelButton, &QPushButton::clicked, this, &BanDialog::reject); + + QHBoxLayout *buttonLayout = new QHBoxLayout; + buttonLayout->addStretch(); + buttonLayout->addWidget(okButton); + buttonLayout->addWidget(cancelButton); + + QVBoxLayout *vbox = new QVBoxLayout; + vbox->addWidget(banTypeGroupBox); + vbox->addWidget(durationGroupBox); + vbox->addWidget(reasonLabel); + vbox->addWidget(reasonEdit); + vbox->addWidget(visibleReasonLabel); + vbox->addWidget(visibleReasonEdit); + vbox->addWidget(deleteMessages); + vbox->addLayout(buttonLayout); + + setLayout(vbox); + setWindowTitle(tr("Ban user from server")); +} + +WarningDialog::WarningDialog(const QString &userName, const QString &clientID, QWidget *parent) : QDialog(parent) +{ + setAttribute(Qt::WA_DeleteOnClose); + descriptionLabel = new QLabel(tr("Which warning would you like to send?")); + nameWarning = new QLineEdit(userName); + nameWarning->setMaxLength(MAX_NAME_LENGTH); + warnClientID = new QLineEdit(clientID); + warnClientID->setMaxLength(MAX_NAME_LENGTH); + warningOption = new QComboBox(); + warningOption->addItem("", ""); + + deleteMessages = new QCheckBox(tr("Redact all messages from this user in all rooms")); + + QPushButton *okButton = new QPushButton(tr("&OK")); + okButton->setAutoDefault(true); + connect(okButton, &QPushButton::clicked, this, &WarningDialog::okClicked); + QPushButton *cancelButton = new QPushButton(tr("&Cancel")); + connect(cancelButton, &QPushButton::clicked, this, &WarningDialog::reject); + + QHBoxLayout *buttonLayout = new QHBoxLayout; + buttonLayout->addStretch(); + buttonLayout->addWidget(okButton); + buttonLayout->addWidget(cancelButton); + + QVBoxLayout *vbox = new QVBoxLayout; + vbox->addWidget(descriptionLabel); + vbox->addWidget(nameWarning); + vbox->addWidget(warningOption); + vbox->addWidget(deleteMessages); + vbox->addLayout(buttonLayout); + setLayout(vbox); + setWindowTitle(tr("Warn user for misconduct")); +} + +void WarningDialog::okClicked() +{ + if (nameWarning->text().simplified().isEmpty()) { + QMessageBox::critical(this, tr("Error"), + tr("User name to send a warning to can not be blank, please specify a user to warn.")); + return; + } + + if (warningOption->currentData().toString().simplified().isEmpty()) { + QMessageBox::critical(this, tr("Error"), + tr("Warning to use can not be blank, please select a valid warning to send.")); + return; + } + + accept(); +} + +QString WarningDialog::getName() const +{ + return nameWarning->text().simplified(); +} + +QString WarningDialog::getWarnID() const +{ + return warnClientID->text().simplified(); +} + +QString WarningDialog::getReason() const +{ + return warningOption->currentData().toString().simplified(); +} + +int WarningDialog::getDeleteMessages() const +{ + return deleteMessages->isChecked() ? -1 : 0; +} + +void WarningDialog::addWarningOption(const QString &warning, int startingIl) +{ + if (startingIl > 1) { + warningOption->addItem(tr("%1 (IL %2)").arg(warning).arg(startingIl), warning); + } else { + warningOption->addItem(warning, warning); + } +} + +void BanDialog::okClicked() +{ + if (!nameBanCheckBox->isChecked() && !ipBanCheckBox->isChecked() && !idBanCheckBox->isChecked()) { + QMessageBox::critical(this, tr("Error"), + tr("You have to select a name-based, IP-based, clientId based, or some combination of " + "the three to place a ban.")); + return; + } + + if (nameBanCheckBox->isChecked()) { + if (nameBanEdit->text().simplified() == "") { + QMessageBox::critical(this, tr("Error"), + tr("You must have a value in the name ban when selecting the name ban checkbox.")); + return; + } + } + + if (ipBanCheckBox->isChecked()) { + if (ipBanEdit->text().simplified() == "") { + QMessageBox::critical(this, tr("Error"), + tr("You must have a value in the ip ban when selecting the ip ban checkbox.")); + return; + } + } + + if (idBanCheckBox->isChecked()) { + if (idBanEdit->text().simplified() == "") { + QMessageBox::critical( + this, tr("Error"), + tr("You must have a value in the clientid ban when selecting the clientid ban checkbox.")); + return; + } + } + + accept(); +} + +void BanDialog::enableTemporaryEdits(bool enabled) +{ + daysLabel->setEnabled(enabled); + daysEdit->setEnabled(enabled); + hoursLabel->setEnabled(enabled); + hoursEdit->setEnabled(enabled); + minutesLabel->setEnabled(enabled); + minutesEdit->setEnabled(enabled); +} + +QString BanDialog::getBanId() const +{ + return idBanCheckBox->isChecked() ? idBanEdit->text() : QString(); +} + +QString BanDialog::getBanName() const +{ + return nameBanCheckBox->isChecked() ? nameBanEdit->text() : QString(); +} + +QString BanDialog::getBanIP() const +{ + return ipBanCheckBox->isChecked() ? ipBanEdit->text() : QString(); +} + +int BanDialog::getMinutes() const +{ + return permanentRadio->isChecked() ? 0 + : (daysEdit->value() * 24 * 60 + hoursEdit->value() * 60 + minutesEdit->value()); +} + +QString BanDialog::getReason() const +{ + return reasonEdit->toPlainText(); +} + +QString BanDialog::getVisibleReason() const +{ + return visibleReasonEdit->toPlainText(); +} + +int BanDialog::getDeleteMessages() const +{ + return deleteMessages->isChecked() ? -1 : 0; +} + +AdminNotesDialog::AdminNotesDialog(const QString &_userName, const QString &_notes, QWidget *_parent) + : QDialog(_parent), userName(_userName) +{ + setAttribute(Qt::WA_DeleteOnClose); + + auto *updateButton = new QPushButton(tr("Update Notes")); + updateButton->setEnabled(false); + connect(updateButton, &QPushButton::clicked, this, &AdminNotesDialog::accept); + + notes = new QPlainTextEdit(_notes); + notes->setMinimumWidth(500); + connect(notes, &QPlainTextEdit::textChanged, this, [=]() { updateButton->setEnabled(true); }); + + auto *vbox = new QVBoxLayout; + vbox->addWidget(notes); + vbox->addWidget(updateButton); + + setLayout(vbox); + setWindowTitle(tr("Admin Notes for %1").arg(_userName)); +} + +QString AdminNotesDialog::getNotes() const +{ + return notes->toPlainText(); +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/server/user/user_list_dialog.h b/cockatrice/src/interface/widgets/server/user/user_list_dialog.h new file mode 100644 index 000000000..fa7d5de92 --- /dev/null +++ b/cockatrice/src/interface/widgets/server/user/user_list_dialog.h @@ -0,0 +1,79 @@ +#ifndef COCKATRICE_USER_LIST_DIALOG_H +#define COCKATRICE_USER_LIST_DIALOG_H + +#include +#include + +class QComboBox; +class QLabel; +class QPlainTextEdit; +class QRadioButton; +class QSpinBox; +class QLineEdit; +class QCheckBox; + +class BanDialog : public QDialog +{ + Q_OBJECT + + QLabel *daysLabel, *hoursLabel, *minutesLabel; + QCheckBox *nameBanCheckBox, *ipBanCheckBox, *idBanCheckBox, *deleteMessages; + QLineEdit *nameBanEdit, *ipBanEdit, *idBanEdit; + QSpinBox *daysEdit, *hoursEdit, *minutesEdit; + QRadioButton *permanentRadio, *temporaryRadio; + QPlainTextEdit *reasonEdit, *visibleReasonEdit; + +private slots: + void okClicked(); + void enableTemporaryEdits(bool enabled); + +public: + explicit BanDialog(const ServerInfo_User &info, QWidget *parent = nullptr); + [[nodiscard]] QString getBanName() const; + [[nodiscard]] QString getBanIP() const; + [[nodiscard]] QString getBanId() const; + [[nodiscard]] int getMinutes() const; + [[nodiscard]] QString getReason() const; + [[nodiscard]] QString getVisibleReason() const; + [[nodiscard]] int getDeleteMessages() const; +}; + +class WarningDialog : public QDialog +{ + Q_OBJECT + + QLabel *descriptionLabel; + QLineEdit *nameWarning; + QComboBox *warningOption; + QLineEdit *warnClientID; + QCheckBox *deleteMessages; + +private slots: + void okClicked(); + +public: + WarningDialog(const QString &userName, const QString &clientID, QWidget *parent = nullptr); + [[nodiscard]] QString getName() const; + [[nodiscard]] QString getWarnID() const; + [[nodiscard]] QString getReason() const; + [[nodiscard]] int getDeleteMessages() const; + void addWarningOption(const QString &warning, int startingIl = 1); +}; + +class AdminNotesDialog : public QDialog +{ + Q_OBJECT + + QString userName; + QPlainTextEdit *notes; + +public: + explicit AdminNotesDialog(const QString &_userName, const QString &_notes, QWidget *_parent = nullptr); + [[nodiscard]] QString getName() const + { + return userName; + } + [[nodiscard]] QString getNotes() const; +}; + +#endif // COCKATRICE_USER_LIST_DIALOG_H diff --git a/cockatrice/src/interface/widgets/server/user/user_list_manager.cpp b/cockatrice/src/interface/widgets/server/user/user_list_manager.cpp index 4bc2c84d6..216420006 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_manager.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_manager.cpp @@ -42,6 +42,9 @@ void UserListManager::handleDisconnect() delete ownUserInfo; ownUserInfo = nullptr; + + // Full rebuild — all lists are gone + emit listReset(); } void UserListManager::setOwnUserInfo(const ServerInfo_User &userInfo) @@ -63,74 +66,77 @@ void UserListManager::processListUsersResponse(const Response &response) const int userListSize = resp.user_list_size(); for (int i = 0; i < userListSize; ++i) { const ServerInfo_User &info = resp.user_list(i); - const QString &userName = QString::fromStdString(info.name()); - onlineUsers.insert(userName, info); + onlineUsers.insert(QString::fromStdString(info.name()), info); } + + // Bulk load complete — widgets rebuild once from the now-populated map + emit listReset(); } void UserListManager::processUserJoinedEvent(const Event_UserJoined &event) { const auto &info = event.user_info(); - const QString &userName = QString::fromStdString(info.name()); - onlineUsers.insert(userName, info); + const QString name = QString::fromStdString(info.name()); + onlineUsers.insert(name, info); + + emit userJoinedOnline(info); } void UserListManager::processUserLeftEvent(const Event_UserLeft &event) { - const auto &userName = QString::fromStdString(event.name()); - onlineUsers.remove(userName); + const QString name = QString::fromStdString(event.name()); + onlineUsers.remove(name); + + emit userLeftOnline(name); } void UserListManager::buddyListReceived(const QList &_buddyList) { for (const auto &user : _buddyList) { - const auto &userName = QString::fromStdString(user.name()); - buddyUsers.insert(userName, user); + buddyUsers.insert(QString::fromStdString(user.name()), user); } + + // Bulk load — one reset covers all newly added entries + emit listReset(); } void UserListManager::ignoreListReceived(const QList &_ignoreList) { for (const auto &user : _ignoreList) { - const auto &userName = QString::fromStdString(user.name()); - ignoredUsers.insert(userName, user); + ignoredUsers.insert(QString::fromStdString(user.name()), user); } + + // Bulk load — one reset covers all newly added entries + emit listReset(); } void UserListManager::processAddToListEvent(const Event_AddToList &event) { const auto &user = event.user_info(); - const auto &userName = QString::fromStdString(user.name()); + const QString userName = QString::fromStdString(user.name()); + const QString listType = QString::fromStdString(event.list_name()); - const auto &userListType = QString::fromStdString(event.list_name()); - - QMap *userMap; - if (userListType == "buddy") { - userMap = &buddyUsers; - } else if (userListType == "ignore") { - userMap = &ignoredUsers; - } else { - return; + if (listType == "buddy") { + buddyUsers.insert(userName, user); + emit addedToBuddyList(user); + } else if (listType == "ignore") { + ignoredUsers.insert(userName, user); + emit addedToIgnoreList(user); } - - userMap->insert(userName, user); } void UserListManager::processRemoveFromListEvent(const Event_RemoveFromList &event) { - const auto &userListType = QString::fromStdString(event.list_name()); - const auto &userName = QString::fromStdString(event.user_name()); + const QString listType = QString::fromStdString(event.list_name()); + const QString userName = QString::fromStdString(event.user_name()); - QMap *userMap; - if (userListType == "buddy") { - userMap = &buddyUsers; - } else if (userListType == "ignore") { - userMap = &ignoredUsers; - } else { - return; + if (listType == "buddy") { + buddyUsers.remove(userName); + emit removedFromBuddyList(userName); + } else if (listType == "ignore") { + ignoredUsers.remove(userName); + emit removedFromIgnoreList(userName); } - - userMap->remove(userName); } bool UserListManager::isOwnUserRegistered() const @@ -155,16 +161,9 @@ bool UserListManager::isUserIgnored(const QString &userName) const const ServerInfo_User *UserListManager::getOnlineUser(const QString &userName) const { - const QString &userNameToMatchLower = userName.toLower(); - - const auto it = - std::find_if(onlineUsers.begin(), onlineUsers.end(), [&userNameToMatchLower](const ServerInfo_User &user) { - return userNameToMatchLower == QString::fromStdString(user.name()).toLower(); - }); - - if (it != onlineUsers.end()) { - return &*it; - } - - return nullptr; + const QString lower = userName.toLower(); + const auto it = std::find_if(onlineUsers.begin(), onlineUsers.end(), [&lower](const ServerInfo_User &user) { + return lower == QString::fromStdString(user.name()).toLower(); + }); + return it != onlineUsers.end() ? &*it : nullptr; } \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/server/user/user_list_manager.h b/cockatrice/src/interface/widgets/server/user/user_list_manager.h index f09284bd0..6238f0799 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_manager.h +++ b/cockatrice/src/interface/widgets/server/user/user_list_manager.h @@ -47,15 +47,17 @@ public: explicit UserListManager(AbstractClient *_client, QObject *parent = nullptr); ~UserListManager() override; - [[nodiscard]] QMap getAllUsersList() const + [[nodiscard]] const QMap &getAllUsersList() const { return onlineUsers; } - [[nodiscard]] QMap getBuddyList() const + + [[nodiscard]] const QMap &getBuddyList() const { return buddyUsers; } - [[nodiscard]] QMap getIgnoreList() const + + [[nodiscard]] const QMap &getIgnoreList() const { return ignoredUsers; } @@ -71,8 +73,26 @@ public slots: void handleDisconnect(); signals: - void userLeft(const QString &userName); - void userJoined(const ServerInfo_User &userInfo); + /** + * The entire list needs to be rebuilt from scratch. + * Fired on disconnect, reconnect, and initial bulk loads + * (Command_ListUsers response, initial buddy/ignore lists). + */ + void listReset(); + + // ── Online user presence ────────────────────────────────────────────────── + /** A user came online (or joined the room). Full ServerInfo_User available. */ + void userJoinedOnline(const ServerInfo_User &user); + /** A user went offline (or left the room). */ + void userLeftOnline(const QString &userName); + + // ── Buddy list mutations (individual, post-login) ───────────────────────── + void addedToBuddyList(const ServerInfo_User &user); + void removedFromBuddyList(const QString &userName); + + // ── Ignore list mutations (individual, post-login) ──────────────────────── + void addedToIgnoreList(const ServerInfo_User &user); + void removedFromIgnoreList(const QString &userName); }; #endif // COCKATRICE_USER_LIST_MANAGER_H diff --git a/cockatrice/src/interface/widgets/server/user/user_list_painter.cpp b/cockatrice/src/interface/widgets/server/user/user_list_painter.cpp new file mode 100644 index 000000000..34a3d6ae1 --- /dev/null +++ b/cockatrice/src/interface/widgets/server/user/user_list_painter.cpp @@ -0,0 +1,417 @@ +#include "user_list_painter.h" + +#include "../../interface/pixel_map_generator.h" + +#include +#include +#include +#include +#include +#include +#include + +static constexpr int RowHeight = 72; +static constexpr int AvatarSize = 36; +static constexpr int LeftPadding = 14; +static constexpr int TextSpacing = 10; + +QSize UserListPainter::sizeHint() +{ + return QSize(0, RowHeight); +} + +UserListPainter::Style UserListPainter::resolveStyle(const QPalette &palette, bool dark) +{ + Style style; + style.dark = dark; + const QColor base = palette.color(QPalette::Base); + const QColor alt = palette.color(QPalette::AlternateBase); + style.cardStart = base; + style.cardEnd = (alt != base) ? alt : palette.color(QPalette::Midlight); + style.base = base; + style.textOnline = palette.color(QPalette::Text); + style.textOffline = palette.color(QPalette::Disabled, QPalette::Text); + style.ringOffline = palette.color(QPalette::Disabled, QPalette::Text); + style.dropShadow = dark; + return style; +} + +QColor UserListPainter::blend(const QColor &a, const QColor &b, qreal t) +{ + const qreal u = 1.0 - t; + return QColor(qRound(a.red() * u + b.red() * t), qRound(a.green() * u + b.green() * t), + qRound(a.blue() * u + b.blue() * t), qRound(a.alpha() * u + b.alpha() * t)); +} + +QColor UserListPainter::getAccentColor(const UserLevelFlags &userLevel, bool online) +{ + QColor accentColor; + + if (userLevel.testFlag(ServerInfo_User::IsAdmin)) { + accentColor = QColor(245, 158, 11); + } else if (userLevel.testFlag(ServerInfo_User::IsDeveloper)) { + accentColor = QColor(185, 28, 28); + } else if (userLevel.testFlag(ServerInfo_User::IsModerator)) { + accentColor = QColor(59, 130, 246); + } else if (userLevel.testFlag(ServerInfo_User::IsJudge)) { + accentColor = QColor(168, 85, 247); + } else { + accentColor = QColor(100, 116, 139); + } + + if (!online) { + accentColor = accentColor.darker(160); + } + + return accentColor; +} + +int UserListPainter::getCardRight(const QStyleOptionViewItem &option, const QRect &rect) +{ + int scrollBarWidth = 0; + + if (const auto *scrollArea = qobject_cast(option.widget)) { + const QScrollBar *sb = scrollArea->verticalScrollBar(); + if (sb && sb->isVisible()) { + scrollBarWidth = sb->width(); + } + } + + const int viewportRight = option.widget ? option.widget->width() - scrollBarWidth : rect.right(); + + return qMin(rect.right(), viewportRight - 4); +} + +void UserListPainter::drawBackground(QPainter *painter, + const QRectF &cardRect, + const QColor &accentColor, + bool selected, + const Style &style, + bool hasRole) +{ + QLinearGradient bg(cardRect.topLeft(), cardRect.topRight()); + if (style.dark) { + // Dark mode darkens the role color to fit the dark surface and fades + // it into the deep navy surface on the right. The text drop shadow + // keeps the username legible over the colored edge. + bg.setColorAt(0, selected ? accentColor.darker(130) : accentColor.darker(320)); + bg.setColorAt(1, selected ? QColor(40, 48, 60) : QColor(18, 22, 30)); + } else if (hasRole) { + // Light mode pegs the role color on the left at near full strength + // and fades it into the white surface on the right. The tint stays + // bright enough that the dark text remains legible without a shadow. + bg.setColorAt(0, blend(style.cardStart, accentColor, selected ? 0.75 : 0.65)); + bg.setColorAt(1, blend(style.cardEnd, accentColor, selected ? 0.18 : 0.10)); + } else { + // Regular users keep a scaled-down accent tint so the banner card art + // stays legible over a colored backdrop (the pre-branch painter was + // always dark-styled) while the role hierarchy still reads. + bg.setColorAt(0, blend(style.cardStart, accentColor, (selected ? 0.75 : 0.65) * 0.7)); + bg.setColorAt(1, blend(style.cardEnd, accentColor, (selected ? 0.18 : 0.10) * 0.7)); + } + + painter->setPen(Qt::NoPen); + painter->setBrush(bg); + painter->drawRoundedRect(cardRect, 6, 6); + + // The 3px accent bar anchors every row so the banner card art reads as a + // consistent strip in either scheme (pre-branch parity). + painter->setBrush(accentColor); + painter->drawRoundedRect(QRectF(cardRect.left(), cardRect.top(), 3, cardRect.height()), 2, 2); +} + +static QString makeKey(const QString &user, const QString &card, const QString &providerId) +{ + return user + u'|' + card + u'|' + providerId; +} + +void UserListPainter::drawCardArt(QPainter *painter, + const QRect &rect, + int cardRight, + const QString &userName, + const QMap *cardArtCache, + const CardArtParams ¶ms, + const QPixmap *overridePixmap = nullptr) +{ + QPixmap art; + + if (overridePixmap && !overridePixmap->isNull()) { + art = *overridePixmap; + } else { + if (!cardArtCache) { + return; + } + + const QString key = makeKey(userName, params.cardName, params.cardProviderId); + + if (!cardArtCache->contains(key)) { + return; + } + + art = cardArtCache->value(key); + } + + if (art.isNull()) { + return; + } + + // CardPictureLoader::getPixmap tags its output with the screen's + // devicePixelRatio on HiDPI displays. Every calculation below is in raw + // pixels, so normalize to 1.0 or the crop renders at 1/dpr scale anchored + // to the top left corner of the row. + art.setDevicePixelRatio(1.0); + + const int cardH = rect.height() - 4; + const int totalW = cardRight - rect.left(); + const int marginL = qRound(totalW * params.marginPctL); + const int marginR = qRound(totalW * params.marginPctR); + const int drawW = totalW - marginL - marginR; + + const double basescale = qMax(double(drawW) / art.width(), double(cardH) / art.height()); + const double scale = basescale * params.zoom; + + const int scaledW = qRound(art.width() * scale); + const int scaledH = qRound(art.height() * scale); + + const QPixmap scaled = art.scaled(scaledW, scaledH, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + + const int srcX = (scaledW - drawW) / 2; + const int srcY = qRound((scaledH - cardH) * params.verticalOffset); + + // Clamp so we never copy outside the pixmap bounds. srcX can go negative + // for stored zoom values below 1, which would silently underfill the + // strip with transparent padding. + const int safeSrcX = qBound(0, srcX, qMax(0, scaledW - drawW)); + const int safeSrcY = qBound(0, srcY, qMax(0, scaledH - cardH)); + + QImage img = + scaled.copy(safeSrcX, safeSrcY, drawW, cardH).toImage().convertToFormat(QImage::Format_ARGB32_Premultiplied); + + { + QPainter mask(&img); + mask.setCompositionMode(QPainter::CompositionMode_DestinationIn); + + QLinearGradient grad(0, 0, img.width(), 0); + grad.setColorAt(0.00, Qt::transparent); + grad.setColorAt(0.22, Qt::white); + grad.setColorAt(0.78, Qt::white); + grad.setColorAt(1.00, Qt::transparent); + + mask.fillRect(img.rect(), grad); + } + + painter->setOpacity(0.55); + painter->drawImage(rect.left() + marginL, rect.top() + 2, img); + painter->setOpacity(1.0); +} + +QRect UserListPainter::getAvatarRect(const QRect &rect) +{ + const int avatarX = rect.left() + LeftPadding; + const int avatarY = rect.top() + (rect.height() - AvatarSize) / 2; + return QRect(avatarX, avatarY, AvatarSize, AvatarSize); +} + +void UserListPainter::drawAvatar(QPainter *painter, + const QRect &avatarRect, + const QString &userName, + const QColor &accentColor, + const UserLevelFlags &userLevel, + const ServerInfo_User &userInfo, + const QString &privLevel, + const QMap *avatarCache, + const Style &style) +{ + QPainterPath clipPath; + clipPath.addEllipse(avatarRect); + + painter->save(); + painter->setClipPath(clipPath); + + bool drewAvatar = false; + + if (avatarCache && avatarCache->contains(userName)) { + const QPixmap &avatar = avatarCache->value(userName); + if (!avatar.isNull()) { + painter->drawPixmap( + avatarRect, avatar.scaled(avatarRect.size(), Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation)); + drewAvatar = true; + } + } + + if (!drewAvatar) { + painter->setBrush(blend(accentColor, style.base, style.dark ? 0.45 : 0.72)); + painter->setPen(Qt::NoPen); + painter->drawEllipse(avatarRect); + + const QPixmap pawn = + UserLevelPixmapGenerator::generatePixmap(24, userLevel, userInfo.pawn_colors(), false, privLevel); + + painter->drawPixmap(avatarRect.center().x() - 12, avatarRect.center().y() - 12, pawn); + } + + painter->restore(); +} + +void UserListPainter::drawStatusRing(QPainter *painter, const QRect &avatarRect, bool online, const Style &style) +{ + const QColor statusColor = online ? QColor(34, 197, 94) : style.ringOffline; + + painter->setPen(QPen(statusColor, 2)); + painter->setBrush(Qt::NoBrush); + painter->drawEllipse(avatarRect.adjusted(-1, -1, 1, 1)); +} + +void UserListPainter::drawUserName(QPainter *painter, + const QStyleOptionViewItem &option, + const QRect &rect, + int cardRight, + int textX, + const QString &userName, + bool online, + const Style &style) +{ + QFont nameFont = option.font; + nameFont.setBold(true); + painter->setFont(nameFont); + + const QRect nameRect(textX, rect.top() + 8, cardRight - textX - 10, 20); + const QString elidedName = QFontMetrics(nameFont).elidedText(userName, Qt::ElideRight, cardRight - textX - 10); + + if (style.dropShadow) { + painter->setPen(QColor(0, 0, 0, 200)); + painter->drawText(nameRect.translated(1, 1), Qt::AlignVCenter | Qt::AlignLeft, elidedName); + } + + painter->setPen(online ? style.textOnline : style.textOffline); + painter->drawText(nameRect, Qt::AlignVCenter | Qt::AlignLeft, elidedName); +} + +void UserListPainter::drawCountryFlag(QPainter *painter, const QRect &rect, int textX, const ServerInfo_User &userInfo) +{ + const QPixmap flag = CountryPixmapGenerator::generatePixmap(13, QString::fromStdString(userInfo.country())); + if (!flag.isNull()) { + painter->drawPixmap(textX, rect.top() + 46, flag); + } +} + +QList UserListPainter::buildBadges(const UserLevelFlags &userLevel, const QString &privLevel) +{ + QList badges; + + if (userLevel.testFlag(ServerInfo_User::IsAdmin)) { + badges << Badge{"ADMIN", QColor(245, 158, 11)}; + } else if (userLevel.testFlag(ServerInfo_User::IsDeveloper)) { + badges << Badge{"DEV", QColor(185, 28, 28)}; + } else if (userLevel.testFlag(ServerInfo_User::IsModerator)) { + badges << Badge{"MOD", QColor(59, 130, 246)}; + } else if (userLevel.testFlag(ServerInfo_User::IsJudge)) { + badges << Badge{"JUDGE", QColor(168, 85, 247)}; + } + + if (privLevel == "VIP") { + badges << Badge{"VIP", QColor(20, 184, 166)}; + } else if (privLevel == "DONATOR") { + badges << Badge{"DONATOR", QColor(249, 115, 22)}; + } + + return badges; +} + +void UserListPainter::drawBadges(QPainter *painter, + const QStyleOptionViewItem &option, + const QRect &rect, + int cardRight, + const QList &badges, + bool online, + const Style &style) +{ + if (badges.isEmpty()) { + return; + } + + QFont badgeFont = option.font; + badgeFont.setPointSizeF(badgeFont.pointSizeF() * 0.68); + badgeFont.setBold(true); + painter->setFont(badgeFont); + + QFontMetrics fm(badgeFont); + + int totalBadgeW = 0; + for (const Badge &b : badges) { + totalBadgeW += fm.horizontalAdvance(b.text) + 8 + 4; + } + totalBadgeW -= 4; + + int bx = cardRight - 6 - totalBadgeW; + + for (const Badge &b : badges) { + const QColor col = online ? b.color : blend(b.color, style.base, 0.55); + const QColor surface = blend(col, style.base, style.dark ? 0.55 : 0.78); + const QColor text = style.dark ? blend(col, Qt::white, 0.5) : blend(col, Qt::black, 0.35); + const int bw = fm.horizontalAdvance(b.text) + 8; + const QRect br(bx, rect.top() + 44, bw, 13); + + painter->setPen(Qt::NoPen); + painter->setBrush(surface); + painter->drawRoundedRect(br, 3, 3); + + painter->setPen(text); + painter->drawText(br, Qt::AlignCenter, b.text); + + bx += bw + 4; + } +} + +void UserListPainter::paint(QPainter *painter, + const QStyleOptionViewItem &option, + const QModelIndex &index, + const ServerInfo_User &userInfo, + const QMap *avatarCache, + const QMap *cardArtCache, + const QMap *cardArtParamsMap, + bool dark) +{ + painter->save(); + painter->setRenderHints(QPainter::Antialiasing | QPainter::SmoothPixmapTransform | QPainter::TextAntialiasing); + + // The delegate supplies the application palette in option.palette, which + // always reflects the active theme. The widget palette can be stale after + // a runtime theme change, so it is only used as a defensive fallback. + const QPalette pal = + option.palette == QPalette() ? (option.widget ? option.widget->palette() : qApp->palette()) : option.palette; + const Style style = resolveStyle(pal, dark); + + const QRect rect = option.rect; + const bool online = index.data(Qt::UserRole + 1).toBool(); + const bool selected = option.state & QStyle::State_Selected; + const UserLevelFlags userLevel(userInfo.user_level()); + const QString userName = QString::fromStdString(userInfo.name()); + const QString privLevel = QString::fromStdString(userInfo.privlevel()); + const QColor accentColor = getAccentColor(userLevel, online); + const bool hasRole = + userLevel.testFlag(ServerInfo_User::IsAdmin) || userLevel.testFlag(ServerInfo_User::IsDeveloper) || + userLevel.testFlag(ServerInfo_User::IsModerator) || userLevel.testFlag(ServerInfo_User::IsJudge); + const QRectF cardRect = QRectF(rect).adjusted(3, 2, -3, -2); + const int cardRight = getCardRight(option, rect); + + const CardArtParams params = (cardArtParamsMap && cardArtParamsMap->contains(userName)) + ? cardArtParamsMap->value(userName) + : CardArtParams{}; + + drawBackground(painter, cardRect, accentColor, selected, style, hasRole); + drawCardArt(painter, rect, cardRight, userName, cardArtCache, params); + + const QRect avatarRect = getAvatarRect(rect); + drawAvatar(painter, avatarRect, userName, accentColor, userLevel, userInfo, privLevel, avatarCache, style); + drawStatusRing(painter, avatarRect, online, style); + + const int textX = avatarRect.right() + TextSpacing; + drawUserName(painter, option, rect, cardRight, textX, userName, online, style); + drawCountryFlag(painter, rect, textX, userInfo); + + const QList badges = buildBadges(userLevel, privLevel); + drawBadges(painter, option, rect, cardRight, badges, online, style); + + painter->restore(); +} diff --git a/cockatrice/src/interface/widgets/server/user/user_list_painter.h b/cockatrice/src/interface/widgets/server/user/user_list_painter.h new file mode 100644 index 000000000..352a01f6b --- /dev/null +++ b/cockatrice/src/interface/widgets/server/user/user_list_painter.h @@ -0,0 +1,118 @@ +#ifndef COCKATRICE_USER_LIST_PAINTER_H +#define COCKATRICE_USER_LIST_PAINTER_H + +#include "user_level.h" + +#include +#include +#include +#include +#include +#include +#include + +class QPainter; +class QModelIndex; +class QStyleOptionViewItem; +class ServerInfo_User; + +struct CardArtParams +{ + QString cardName = ""; + QString cardProviderId = ""; + double marginPctL = 0.33; + double marginPctR = 0.02; + double verticalOffset = 0.35; + double zoom = 1.0; +}; + +class UserListPainter +{ +public: + /** + * Palette-derived surface colors for the current color scheme. Both the + * light and the dark scheme read from the active QPalette so custom + * palettes are respected. @c dark only tunes the blend strengths (and + * whether the name text keeps its drop shadow). + */ + struct Style + { + bool dark = true; + QColor cardStart; ///< row fill, left edge (normal) + QColor cardEnd; ///< row fill, right edge (normal) + QColor base; ///< lightest surface, used for blending accent hues + QColor textOnline; + QColor textOffline; + QColor ringOffline; + bool dropShadow = false; + }; + + static Style resolveStyle(const QPalette &palette, bool dark); + /// Linear interpolation: @p t = 0 returns @p a, @p t = 1 returns @p b. + static QColor blend(const QColor &a, const QColor &b, qreal t); + + static void paint(QPainter *painter, + const QStyleOptionViewItem &option, + const QModelIndex &index, + const ServerInfo_User &userInfo, + const QMap *avatarCache, + const QMap *cardArtCache, + const QMap *cardArtParamsMap, + bool dark); + + static QSize sizeHint(); + + static void drawCardArt(QPainter *painter, + const QRect &rect, + int cardRight, + const QString &userName, + const QMap *cardArtCache, + const CardArtParams ¶ms, + const QPixmap *overridePixmap); + +private: + struct Badge + { + QString text; + QColor color; + }; + + static QColor getAccentColor(const UserLevelFlags &userLevel, bool online); + static int getCardRight(const QStyleOptionViewItem &option, const QRect &rect); + static void drawBackground(QPainter *painter, + const QRectF &cardRect, + const QColor &accentColor, + bool selected, + const Style &style, + bool hasRole); + static QRect getAvatarRect(const QRect &rect); + static void drawAvatar(QPainter *painter, + const QRect &avatarRect, + const QString &userName, + const QColor &accentColor, + const UserLevelFlags &userLevel, + const ServerInfo_User &userInfo, + const QString &privLevel, + const QMap *avatarCache, + const Style &style); + static void drawStatusRing(QPainter *painter, const QRect &avatarRect, bool online, const Style &style); + static void drawUserName(QPainter *painter, + const QStyleOptionViewItem &option, + const QRect &rect, + int cardRight, + int textX, + const QString &userName, + bool online, + const Style &style); + static void drawCountryFlag(QPainter *painter, const QRect &rect, int textX, const ServerInfo_User &userInfo); + static QList buildBadges(const UserLevelFlags &userLevel, const QString &privLevel); + static void drawBadges(QPainter *painter, + const QStyleOptionViewItem &option, + const QRect &rect, + int cardRight, + const QList &badges, + bool online, + const Style &style); +}; + +#endif // COCKATRICE_USER_LIST_PAINTER_H diff --git a/cockatrice/src/interface/widgets/server/user/user_list_panel_widget.cpp b/cockatrice/src/interface/widgets/server/user/user_list_panel_widget.cpp new file mode 100644 index 000000000..937058024 --- /dev/null +++ b/cockatrice/src/interface/widgets/server/user/user_list_panel_widget.cpp @@ -0,0 +1,88 @@ +#include "user_list_panel_widget.h" + +#include "../../../../client/settings/cache_settings.h" +#include "user_list_manager.h" +#include "user_list_widget.h" + +#include +#include +#include +#include + +namespace +{ +// The persisted section keys are the serialization contract with the user's +// settings file, so the values must stay stable across versions. +QString sectionKey(UserListWidget::Section section) +{ + switch (section) { + case UserListWidget::Section::Buddy: + return QStringLiteral("buddy"); + case UserListWidget::Section::Online: + return QStringLiteral("online"); + case UserListWidget::Section::Ignore: + return QStringLiteral("ignore"); + } + return {}; +} +} // namespace + +UserListPanelWidget::UserListPanelWidget(TabSupervisor *_tabSupervisor, AbstractClient *_client, QWidget *parent) + : QWidget(parent) +{ + auto *mainLayout = new QVBoxLayout(this); + mainLayout->setContentsMargins(0, 0, 0, 0); + mainLayout->setSpacing(2); + + searchBar = new QLineEdit(this); + searchBar->setClearButtonEnabled(true); + mainLayout->addWidget(searchBar); + + userList = new UserListWidget(_tabSupervisor, _client, UserListWidget::RoomList, this); + userList->setSectioned( + {UserListWidget::Section::Buddy, UserListWidget::Section::Online, UserListWidget::Section::Ignore}); + mainLayout->addWidget(userList, 1); + + connect(searchBar, &QLineEdit::textChanged, userList, &UserListWidget::setFilterText); + + connect(userList, &UserListWidget::sectionExpanded, this, &UserListPanelWidget::persistExpandedSections); + connect(userList, &UserListWidget::openMessageDialog, this, &UserListPanelWidget::openMessageDialog); + + // Restore the persisted expansion state, then apply it to the tree. + const QStringList expandedSections = SettingsCache::instance().userInterface().getUserListExpandedSections(); + for (const UserListWidget::Section section : userList->getSectionIds()) { + userList->setSectionExpanded(section, expandedSections.contains(sectionKey(section))); + } + + retranslateUi(); +} + +void UserListPanelWidget::bind(UserListManager *manager) +{ + userList->bind(manager); +} + +void UserListPanelWidget::persistExpandedSections(UserListWidget::Section section, bool expanded) +{ + const QString key = sectionKey(section); + QStringList expandedSections = SettingsCache::instance().userInterface().getUserListExpandedSections(); + if (expanded) { + if (!expandedSections.contains(key)) { + expandedSections.append(key); + } + } else { + expandedSections.removeAll(key); + } + SettingsCache::instance().userInterface().setUserListExpandedSections(expandedSections); +} + +void UserListPanelWidget::retranslateUi() +{ + searchBar->setPlaceholderText(tr("Search users...")); + userList->retranslateUi(); +} + +UserListWidget *UserListPanelWidget::getUserList() const +{ + return userList; +} diff --git a/cockatrice/src/interface/widgets/server/user/user_list_panel_widget.h b/cockatrice/src/interface/widgets/server/user/user_list_panel_widget.h new file mode 100644 index 000000000..7ae14dfcf --- /dev/null +++ b/cockatrice/src/interface/widgets/server/user/user_list_panel_widget.h @@ -0,0 +1,43 @@ +/** + * @file user_list_panel_widget.h + * @ingroup Lobby + */ + +#ifndef COCKATRICE_USER_LIST_PANEL_WIDGET_H +#define COCKATRICE_USER_LIST_PANEL_WIDGET_H + +#include "user_list_widget.h" + +#include + +class AbstractClient; +class QLineEdit; +class TabSupervisor; +class UserListManager; + +/** + * A unified user list: a search bar above a single tree whose section headers + * (buddy, online, ignored) are inline dividers. The tree owns the scrolling. + */ +class UserListPanelWidget : public QWidget +{ + Q_OBJECT + +public: + explicit UserListPanelWidget(TabSupervisor *tabSupervisor, AbstractClient *client, QWidget *parent = nullptr); + void bind(UserListManager *manager); + void retranslateUi(); + + [[nodiscard]] UserListWidget *getUserList() const; + +signals: + void openMessageDialog(const QString &userName, bool focus); + +private: + void persistExpandedSections(UserListWidget::Section section, bool expanded); + + QLineEdit *searchBar = nullptr; + UserListWidget *userList = nullptr; +}; + +#endif // COCKATRICE_USER_LIST_PANEL_WIDGET_H diff --git a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp index 11c9b60eb..1bb7c5288 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp @@ -1,314 +1,39 @@ #include "user_list_widget.h" +#include "../../../../client/settings/cache_settings.h" #include "../../interface/pixel_map_generator.h" -#include "../../interface/widgets/tabs/tab_account.h" +#include "../../interface/theme_manager.h" #include "../../interface/widgets/tabs/tab_supervisor.h" -#include "../game_selector.h" #include "user_context_menu.h" +#include "user_list_painter.h" -#include -#include -#include +#include #include -#include -#include -#include -#include +#include #include -#include -#include -#include -#include -#include -#include -#include -#include +#include +#include +#include +#include +#include -BanDialog::BanDialog(const ServerInfo_User &info, QWidget *parent) : QDialog(parent) +namespace UserListRoles { - setAttribute(Qt::WA_DeleteOnClose); +constexpr int Online = Qt::UserRole + 1; +constexpr int UserInfo = Qt::UserRole + 2; +} // namespace UserListRoles - nameBanCheckBox = new QCheckBox(tr("ban &user name")); - nameBanCheckBox->setChecked(true); - nameBanEdit = new QLineEdit(QString::fromStdString(info.name())); - nameBanEdit->setMaxLength(MAX_NAME_LENGTH); - ipBanCheckBox = new QCheckBox(tr("ban &IP address")); - ipBanCheckBox->setChecked(true); - ipBanEdit = new QLineEdit(QString::fromStdString(info.address())); - ipBanEdit->setMaxLength(MAX_NAME_LENGTH); - idBanCheckBox = new QCheckBox(tr("ban client I&D")); - idBanCheckBox->setChecked(true); - idBanEdit = new QLineEdit(QString::fromStdString(info.clientid())); - idBanEdit->setMaxLength(MAX_NAME_LENGTH); - if (QString::fromStdString(info.clientid()).isEmpty()) { - idBanCheckBox->setChecked(false); - } +// Divider items (section headers) in sectioned mode are distinguished from user +// rows (UserListTWI, which uses QTreeWidgetItem::Type) by this item type. +constexpr int SectionItemType = QTreeWidgetItem::UserType + 1; - QGridLayout *banTypeGrid = new QGridLayout; - banTypeGrid->addWidget(nameBanCheckBox, 0, 0); - banTypeGrid->addWidget(nameBanEdit, 0, 1); - banTypeGrid->addWidget(ipBanCheckBox, 1, 0); - banTypeGrid->addWidget(ipBanEdit, 1, 1); - banTypeGrid->addWidget(idBanCheckBox, 2, 0); - banTypeGrid->addWidget(idBanEdit, 2, 1); - QGroupBox *banTypeGroupBox = new QGroupBox(tr("Ban type")); - banTypeGroupBox->setLayout(banTypeGrid); - - permanentRadio = new QRadioButton(tr("&permanent ban")); - temporaryRadio = new QRadioButton(tr("&temporary ban")); - temporaryRadio->setChecked(true); - connect(temporaryRadio, &QRadioButton::toggled, this, &BanDialog::enableTemporaryEdits); - daysLabel = new QLabel(tr("&Days:")); - daysEdit = new QSpinBox; - daysEdit->setMinimum(0); - daysEdit->setValue(0); - daysEdit->setMaximum(10000); - daysLabel->setBuddy(daysEdit); - hoursLabel = new QLabel(tr("&Hours:")); - hoursEdit = new QSpinBox; - hoursEdit->setMinimum(0); - hoursEdit->setValue(0); - hoursEdit->setMaximum(24); - hoursLabel->setBuddy(hoursEdit); - minutesLabel = new QLabel(tr("&Minutes:")); - minutesEdit = new QSpinBox; - minutesEdit->setMinimum(0); - minutesEdit->setValue(5); - minutesEdit->setMaximum(60); - minutesLabel->setBuddy(minutesEdit); - QGridLayout *durationLayout = new QGridLayout; - durationLayout->addWidget(permanentRadio, 0, 0, 1, 6); - durationLayout->addWidget(temporaryRadio, 1, 0, 1, 6); - durationLayout->addWidget(daysLabel, 2, 0); - durationLayout->addWidget(daysEdit, 2, 1); - durationLayout->addWidget(hoursLabel, 2, 2); - durationLayout->addWidget(hoursEdit, 2, 3); - durationLayout->addWidget(minutesLabel, 2, 4); - durationLayout->addWidget(minutesEdit, 2, 5); - QGroupBox *durationGroupBox = new QGroupBox(tr("Duration of the ban")); - durationGroupBox->setLayout(durationLayout); - - QLabel *reasonLabel = new QLabel(tr("Please enter the reason for the ban.\nThis is only saved for moderators and " - "cannot be seen by the banned person.")); - reasonEdit = new QPlainTextEdit; - - QLabel *visibleReasonLabel = - new QLabel(tr("Please enter the reason for the ban that will be visible to the banned person.")); - visibleReasonEdit = new QPlainTextEdit; - - deleteMessages = new QCheckBox(tr("Redact all messages from this user in all rooms")); - - QPushButton *okButton = new QPushButton(tr("&OK")); - okButton->setAutoDefault(true); - connect(okButton, &QPushButton::clicked, this, &BanDialog::okClicked); - QPushButton *cancelButton = new QPushButton(tr("&Cancel")); - connect(cancelButton, &QPushButton::clicked, this, &BanDialog::reject); - - QHBoxLayout *buttonLayout = new QHBoxLayout; - buttonLayout->addStretch(); - buttonLayout->addWidget(okButton); - buttonLayout->addWidget(cancelButton); - - QVBoxLayout *vbox = new QVBoxLayout; - vbox->addWidget(banTypeGroupBox); - vbox->addWidget(durationGroupBox); - vbox->addWidget(reasonLabel); - vbox->addWidget(reasonEdit); - vbox->addWidget(visibleReasonLabel); - vbox->addWidget(visibleReasonEdit); - vbox->addWidget(deleteMessages); - vbox->addLayout(buttonLayout); - - setLayout(vbox); - setWindowTitle(tr("Ban user from server")); -} - -WarningDialog::WarningDialog(const QString userName, const QString clientID, QWidget *parent) : QDialog(parent) -{ - setAttribute(Qt::WA_DeleteOnClose); - descriptionLabel = new QLabel(tr("Which warning would you like to send?")); - nameWarning = new QLineEdit(userName); - nameWarning->setMaxLength(MAX_NAME_LENGTH); - warnClientID = new QLineEdit(clientID); - warnClientID->setMaxLength(MAX_NAME_LENGTH); - warningOption = new QComboBox(); - warningOption->addItem(""); - - deleteMessages = new QCheckBox(tr("Redact all messages from this user in all rooms")); - - QPushButton *okButton = new QPushButton(tr("&OK")); - okButton->setAutoDefault(true); - connect(okButton, &QPushButton::clicked, this, &WarningDialog::okClicked); - QPushButton *cancelButton = new QPushButton(tr("&Cancel")); - connect(cancelButton, &QPushButton::clicked, this, &WarningDialog::reject); - - QHBoxLayout *buttonLayout = new QHBoxLayout; - buttonLayout->addStretch(); - buttonLayout->addWidget(okButton); - buttonLayout->addWidget(cancelButton); - - QVBoxLayout *vbox = new QVBoxLayout; - vbox->addWidget(descriptionLabel); - vbox->addWidget(nameWarning); - vbox->addWidget(warningOption); - vbox->addWidget(deleteMessages); - vbox->addLayout(buttonLayout); - setLayout(vbox); - setWindowTitle(tr("Warn user for misconduct")); -} - -void WarningDialog::okClicked() -{ - if (nameWarning->text().simplified().isEmpty()) { - QMessageBox::critical(this, tr("Error"), - tr("User name to send a warning to can not be blank, please specify a user to warn.")); - return; - } - - if (warningOption->currentText().simplified().isEmpty()) { - QMessageBox::critical(this, tr("Error"), - tr("Warning to use can not be blank, please select a valid warning to send.")); - return; - } - - accept(); -} - -QString WarningDialog::getName() const -{ - return nameWarning->text().simplified(); -} - -QString WarningDialog::getWarnID() const -{ - return warnClientID->text().simplified(); -} - -QString WarningDialog::getReason() const -{ - return warningOption->currentText().simplified(); -} - -int WarningDialog::getDeleteMessages() const -{ - return deleteMessages->isChecked() ? -1 : 0; -} - -void WarningDialog::addWarningOption(const QString warning) -{ - warningOption->addItem(warning); -} - -void BanDialog::okClicked() -{ - if (!nameBanCheckBox->isChecked() && !ipBanCheckBox->isChecked() && !idBanCheckBox->isChecked()) { - QMessageBox::critical(this, tr("Error"), - tr("You have to select a name-based, IP-based, clientId based, or some combination of " - "the three to place a ban.")); - return; - } - - if (nameBanCheckBox->isChecked()) { - if (nameBanEdit->text().simplified() == "") { - QMessageBox::critical(this, tr("Error"), - tr("You must have a value in the name ban when selecting the name ban checkbox.")); - return; - } - } - - if (ipBanCheckBox->isChecked()) { - if (ipBanEdit->text().simplified() == "") { - QMessageBox::critical(this, tr("Error"), - tr("You must have a value in the ip ban when selecting the ip ban checkbox.")); - return; - } - } - - if (idBanCheckBox->isChecked()) { - if (idBanEdit->text().simplified() == "") { - QMessageBox::critical( - this, tr("Error"), - tr("You must have a value in the clientid ban when selecting the clientid ban checkbox.")); - return; - } - } - - accept(); -} - -void BanDialog::enableTemporaryEdits(bool enabled) -{ - daysLabel->setEnabled(enabled); - daysEdit->setEnabled(enabled); - hoursLabel->setEnabled(enabled); - hoursEdit->setEnabled(enabled); - minutesLabel->setEnabled(enabled); - minutesEdit->setEnabled(enabled); -} - -QString BanDialog::getBanId() const -{ - return idBanCheckBox->isChecked() ? idBanEdit->text() : QString(); -} - -QString BanDialog::getBanName() const -{ - return nameBanCheckBox->isChecked() ? nameBanEdit->text() : QString(); -} - -QString BanDialog::getBanIP() const -{ - return ipBanCheckBox->isChecked() ? ipBanEdit->text() : QString(); -} - -int BanDialog::getMinutes() const -{ - return permanentRadio->isChecked() ? 0 - : (daysEdit->value() * 24 * 60 + hoursEdit->value() * 60 + minutesEdit->value()); -} - -QString BanDialog::getReason() const -{ - return reasonEdit->toPlainText(); -} - -QString BanDialog::getVisibleReason() const -{ - return visibleReasonEdit->toPlainText(); -} - -int BanDialog::getDeleteMessages() const -{ - return deleteMessages->isChecked() ? -1 : 0; -} - -AdminNotesDialog::AdminNotesDialog(const QString &_userName, const QString &_notes, QWidget *_parent) - : QDialog(_parent), userName(_userName) -{ - setAttribute(Qt::WA_DeleteOnClose); - - auto *updateButton = new QPushButton(tr("Update Notes")); - updateButton->setEnabled(false); - connect(updateButton, &QPushButton::clicked, this, &AdminNotesDialog::accept); - - notes = new QPlainTextEdit(_notes); - notes->setMinimumWidth(500); - connect(notes, &QPlainTextEdit::textChanged, this, [=]() { updateButton->setEnabled(true); }); - - auto *vbox = new QVBoxLayout; - vbox->addWidget(notes); - vbox->addWidget(updateButton); - - setLayout(vbox); - setWindowTitle(tr("Admin Notes for %1").arg(_userName)); -} - -QString AdminNotesDialog::getNotes() const -{ - return notes->toPlainText(); -} - -UserListItemDelegate::UserListItemDelegate(QObject *const parent) : QStyledItemDelegate(parent) +UserListItemDelegate::UserListItemDelegate(UserListWidget *owner, + QTreeWidget *tree, + const QMap *avatarCache, + const QMap *cardArtCache, + const QMap *cardArtParamsMap) + : QStyledItemDelegate(tree), tree(tree), owner(owner), avatarCache(avatarCache), cardArtCache(cardArtCache), + cardArtParamsMap(cardArtParamsMap) { } @@ -320,17 +45,146 @@ bool UserListItemDelegate::editorEvent(QEvent *event, if ((event->type() == QEvent::MouseButtonPress) && index.isValid()) { QMouseEvent *const mouseEvent = static_cast(event); if (mouseEvent->button() == Qt::RightButton) { -#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) - static_cast(parent())->showContextMenu(mouseEvent->globalPosition().toPoint(), index); -#else - static_cast(parent())->showContextMenu(mouseEvent->globalPos(), index); -#endif + // Dialog mode has no context menu: consume the press, show nothing. + if (owner->getHasUserInfoPopup()) { + owner->showContextMenu(mouseEvent->globalPosition().toPoint(), index); + } return true; } } return QStyledItemDelegate::editorEvent(event, model, option, index); } +QSize UserListItemDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const +{ + if (!SettingsCache::instance().appearance().getStyleUserList()) { + return QStyledItemDelegate::sizeHint(option, index); + } + if (!index.data(UserListRoles::UserInfo).isValid()) { + return QStyledItemDelegate::sizeHint(option, index); // section dividers stay compact + } + return UserListPainter::sizeHint(); +} + +void UserListItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const +{ + const bool styled = SettingsCache::instance().appearance().getStyleUserList(); + // UserInfo/Online are stored on column 0 only. The name lives on column 2, + // so resolve the user data against column 0 no matter which cell is painted. + const QModelIndex userIndex = index.siblingAtColumn(0).isValid() ? index.siblingAtColumn(0) : index; + const QVariant var = userIndex.data(UserListRoles::UserInfo); + + if (styled && var.isValid()) { + // Styled card rows: the tree's cached palette can be stale after a + // runtime theme change, so paint from the application palette (always + // current) instead of option.palette (frozen at the last style switch). + QStyleOptionViewItem opt = option; + opt.palette = qApp->palette(); + + UserListPainter::paint(painter, opt, index, var.value(), avatarCache, cardArtCache, + cardArtParamsMap, themeManager && themeManager->isDarkModeActive()); + return; + } + + // Unstyled rows and section dividers are painted manually so every color + // is derived from the current application palette at paint time. The widget + // palette, the view's alternation, and the stored brushes (one per item) + // all go stale after a runtime theme change. + const QPalette appPal = qApp->palette(); + const bool selected = option.state & QStyle::State_Selected; + const bool hovered = option.state & QStyle::State_MouseOver; + + // Row background: selection, hover, zebra striping, plain base. + QColor bg = appPal.color(QPalette::Base); + if (selected) { + bg = appPal.color(QPalette::Highlight); + } else if (hovered) { + bg = UserListPainter::blend(appPal.color(QPalette::Base), appPal.color(QPalette::Highlight), 0.12); + } else if (var.isValid()) { + // Zebra alternation per section. The dividers are top level rows too, + // so the view's own alternation would drift across sections. Count the + // visible user rows back to the previous divider within the same parent + // (sectioned mode stores users as children of the dividers, flat mode + // as top level rows), so the stripe restarts at every divider and at + // the tree top. Hidden filter matches are skipped the same way the view + // skips them, so adjacent visible rows always alternate. + int usersSinceDivider = 0; + const QModelIndex parent = userIndex.parent(); + for (int r = userIndex.row() - 1; r >= 0; --r) { + if (tree->isRowHidden(r, parent)) { + continue; + } + const QModelIndex above = userIndex.model()->index(r, 0, parent); + if (above.isValid() && above.data(UserListRoles::UserInfo).isValid()) { + ++usersSinceDivider; + } else { + break; + } + } + if (usersSinceDivider % 2 == 1) { + bg = appPal.color(QPalette::AlternateBase); + } + } + // Paint the row background. In the column 0 pass the fill spans the full + // viewport width so stripes, hover and selection cover the whole row (the + // name column is content sized in unstyled mode). Later column passes fill + // only their own cell, which is the same color and cannot cover the icons. + QRect bgRect = option.rect; + if (index.column() == 0) { + bgRect = QRect(0, option.rect.top(), tree->viewport()->width(), option.rect.height()); + } + painter->fillRect(bgRect, bg); + + // Text color. + QColor fg = appPal.color(QPalette::Text); + if (selected) { + fg = appPal.color(QPalette::HighlightedText); + } else if (!var.isValid()) { + // Section divider: muted application text color. + fg = appPal.color(QPalette::WindowText); + fg.setAlpha(170); + } else if (index.column() == 2) { + // Name column: online/offline color recomputed at paint time instead + // of trusting the brush stored at login time. + QTreeWidgetItem *item = tree->itemFromIndex(index); + const bool online = item && item->data(0, UserListRoles::Online).toBool(); + if (online) { + fg = appPal.color(QPalette::WindowText); + } else { + fg = (themeManager && themeManager->isDarkModeActive()) + ? QColor(Qt::gray) + : UserListPainter::blend(appPal.color(QPalette::Text), appPal.color(QPalette::Mid), 0.5); + } + } + + // Icon (level badge in column 0, country flag in column 1). + QRect textRect = option.rect; + const QIcon icon = index.data(Qt::DecorationRole).value(); + if (!icon.isNull()) { + const QSize iconSize = icon.actualSize(QSize(18, 18)); + const QRect iconRect(option.rect.left() + 2, option.rect.center().y() - iconSize.height() / 2, iconSize.width(), + iconSize.height()); + icon.paint(painter, iconRect); + textRect.setLeft(iconRect.right() + 4); + } + + // Text (name column / divider title), elided to the row width. + painter->save(); + painter->setPen(fg); + const QFont itemFont = index.data(Qt::FontRole).value(); + painter->setFont(itemFont.isCopyOf(QFont()) ? option.font : itemFont); + const QString text = index.data(Qt::DisplayRole).toString(); + const QString elided = painter->fontMetrics().elidedText(text, Qt::ElideRight, textRect.width() - 4); + painter->drawText(textRect.adjusted(2, 0, -2, 0), Qt::AlignLeft | Qt::AlignVCenter, elided); + painter->restore(); + + // Focus indicator for the current item. + if (option.state & QStyle::State_HasFocus) { + painter->setPen(appPal.color(QPalette::Highlight)); + painter->drawRect(option.rect.adjusted(0, 0, -1, -1)); + } +} + UserListTWI::UserListTWI(const ServerInfo_User &_userInfo) : QTreeWidgetItem(Type) { setUserInfo(_userInfo); @@ -347,12 +201,15 @@ void UserListTWI::setUserInfo(const ServerInfo_User &_userInfo) setData(2, Qt::UserRole, QString::fromStdString(userInfo.name())); setData(2, Qt::DisplayRole, QString::fromStdString(userInfo.name())); setData(3, Qt::InitialSortOrderRole, QString::fromStdString(userInfo.privlevel())); + setData(0, UserListRoles::UserInfo, QVariant::fromValue(userInfo)); } void UserListTWI::setOnline(bool online) { - setData(0, Qt::UserRole + 1, online); - setData(2, Qt::ForegroundRole, online ? qApp->palette().brush(QPalette::WindowText) : QBrush(Qt::gray)); + // Only the online state is stored here: the delegate derives the + // online/offline text color at paint time from the current application + // palette, so no brush is cached (it would go stale on theme change). + setData(0, UserListRoles::Online, online); } /** @@ -370,16 +227,18 @@ void UserListTWI::setOnline(bool online) bool UserListTWI::operator<(const QTreeWidgetItem &other) const { // Sort by online/offline - if (data(0, Qt::UserRole + 1) != other.data(0, Qt::UserRole + 1)) { - return data(0, Qt::UserRole + 1).toBool(); + if (data(0, UserListRoles::Online) != other.data(0, UserListRoles::Online)) { + return data(0, UserListRoles::Online).toBool(); } const auto &lhsUserLevelFlags = UserLevelFlags(data(0, Qt::UserRole).toInt()); const auto &rhsUserLevelFlags = UserLevelFlags(other.data(0, Qt::UserRole).toInt()); - // Admins & Mods need no additional comparison checks, just to see if they're an admin or a moderator + // Admins, Developers & Mods need no additional comparison checks, just to see if they're an admin, a developer + // or a moderator static const QList userLevelWithNoOtherPrefOrder = { - ServerInfo_User_UserLevelFlag_IsAdmin, ServerInfo_User_UserLevelFlag_IsModerator}; + ServerInfo_User_UserLevelFlag_IsAdmin, ServerInfo_User_UserLevelFlag_IsDeveloper, + ServerInfo_User_UserLevelFlag_IsModerator}; for (const auto &userLevelEntry : userLevelWithNoOtherPrefOrder) { if (lhsUserLevelFlags.testFlag(userLevelEntry) && lhsUserLevelFlags.testFlag(userLevelEntry) == rhsUserLevelFlags.testFlag(userLevelEntry)) { @@ -415,23 +274,204 @@ bool UserListTWI::operator<(const QTreeWidgetItem &other) const UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor, AbstractClient *_client, UserListType _type, - QWidget *parent) - : QGroupBox(parent), tabSupervisor(_tabSupervisor), client(_client), type(_type), onlineCount(0) + QWidget *parent, + bool _hasUserInfoPopup) + : QGroupBox(parent), hasUserInfoPopup(_hasUserInfoPopup), tabSupervisor(_tabSupervisor), client(_client), + type(_type), onlineCount(0) { - itemDelegate = new UserListItemDelegate(this); + avatarProvider = new UserAvatarProvider(client, this); + cardArtProvider = new UserCardArtProvider(this); + userContextMenu = new UserContextMenu(tabSupervisor, this); connect(userContextMenu, &UserContextMenu::openMessageDialog, this, &UserListWidget::openMessageDialog); userTree = new QTreeWidget; - userTree->setColumnCount(3); - userTree->header()->setSectionResizeMode(QHeaderView::ResizeToContents); + userTree->setColumnCount(4); // 0=display, 1=flag(hidden), 2=name(hidden), 3=privlevel(hidden) + userTree->header()->setSectionResizeMode(0, QHeaderView::Stretch); userTree->header()->setMinimumSectionSize(0); userTree->setHeaderHidden(true); userTree->setRootIsDecorated(false); userTree->setIconSize(QSize(20, 18)); + itemDelegate = new UserListItemDelegate(this, userTree, &avatarProvider->cache(), &cardArtProvider->cache(), + &cardArtParamsMap); userTree->setItemDelegate(itemDelegate); userTree->setAlternatingRowColors(true); + userTree->hideColumn(1); + userTree->hideColumn(2); + userTree->hideColumn(3); connect(userTree, &QTreeWidget::itemActivated, this, &UserListWidget::userClicked); + userTree->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + // QTreeWidget enables stretchLastSection by default. Left on, the hidden + // last section absorbs viewport resizes, the Stretch sections never + // redistribute, and the header keeps a stale length past the viewport — + // an invisible horizontal pan range under ScrollBarAlwaysOff. Disable it + // so the explicit resize modes in applyDisplayMode() own the geometry. + userTree->header()->setStretchLastSection(false); + + // Always create timers so callers never segfault on a null deref; + // showPopupForUser / hidePopup already guard against a null userInfoPopup. + showPopupTimer = new QTimer(this); + showPopupTimer->setSingleShot(true); + showPopupTimer->setInterval(280); + connect(showPopupTimer, &QTimer::timeout, this, [this] { + if (hoveredUser.isEmpty()) { + return; + } + // Resolve the row under the cursor again. In sectioned mode a user can + // own several rows (online + buddy), so the popup must anchor to the + // exact hovered row instead of a lookup by name. + const QPoint viewportPos = userTree->viewport()->mapFromGlobal(QCursor::pos()); + QTreeWidgetItem *item = userTree->itemAt(viewportPos); + if (item && item->type() == QTreeWidgetItem::Type && + QString::fromStdString(static_cast(item)->getUserInfo().name()) == hoveredUser) { + showPopupForUser(static_cast(item)); + } + }); + + hidePopupTimer = new QTimer(this); + hidePopupTimer->setSingleShot(true); + hidePopupTimer->setInterval(160); + connect(hidePopupTimer, &QTimer::timeout, this, [this] { + // The hover ends when the cursor leaves the user row. Empty list + // space, a section divider and anything outside the tree all close + // the popup, while the popup itself keeps it alive. + if (!popupPinned && userInfoPopup && !userInfoPopup->underMouse() && + (hoveredUser.isEmpty() || !userTree->underMouse())) { + hidePopup(); + } + }); + + if (hasUserInfoPopup) { + // ── Hover popup ─────────────────────────────────────────────────────── + userInfoPopup = new UserInfoPopup(tabSupervisor, tabSupervisor->getClient(), &avatarProvider->cache(), + &cardArtProvider->cache(), &cardArtParamsMap, + window()); // parented to main window so it floats above siblings + + // The invite availability is scoped to the room this list belongs to, + // and gated on the room's buddy-only setting for the hovered user. + userInfoPopup->setGameInviteAvailable( + [this](const QString &userName) { return userContextMenu->hasGameInviteLink(userName); }); + + userInfoPopup->hide(); + userInfoPopup->setWindowOpacity(0.0); + userInfoPopup->installEventFilter(this); + + connectPopupSignals(); + } + + userTree->setMouseTracking(true); + userTree->viewport()->setMouseTracking(true); + userTree->viewport()->installEventFilter(this); + userTree->installEventFilter(this); // keyboard handling for section dividers + + if (hasUserInfoPopup) { + // Clicking anywhere outside the list clears its selection and closes the + // popup. The filter watches all widgets because the press can land on any + // part of the window, on another list or on the popup itself. + qApp->installEventFilter(this); + + // Pin on item click + connect(userTree, &QTreeWidget::itemClicked, this, [this](QTreeWidgetItem *item, int) { + // Clicking a section divider toggles it + if (sectioned && item->type() == SectionItemType) { + setExpandedProgrammatically(item, !item->isExpanded()); + handleSectionExpansion(item, item->isExpanded()); + return; + } + if (!SettingsCache::instance().appearance().getStyleUserList()) { + return; + } + if (item->type() != QTreeWidgetItem::Type) { + return; // divider rows have no user popup + } + popupPinned = false; // reset so showPopupForUser can update + showPopupForUser(static_cast(item)); + popupPinned = true; // pin after showing + }); + + connect(userTree->selectionModel(), &QItemSelectionModel::selectionChanged, this, + [this](const QItemSelection &sel, const QItemSelection &) { + if (sel.isEmpty() && popupPinned) { + popupPinned = false; + hidePopup(); + } + }); + + // Keyboard selection: the popup is a mouse surface, so keyboard + // navigation shows no floating popup. A pinned (clicked) popup still + // follows the selection so it does not strand on a stale user while + // arrows move the cursor. + connect(userTree, &QTreeWidget::currentItemChanged, this, [this](QTreeWidgetItem *current, QTreeWidgetItem *) { + if (!popupPinned) { + return; // keyboard navigation shows no popup + } + if (!isVisible() || !SettingsCache::instance().appearance().getStyleUserList()) { + return; + } + if (current && current->type() == QTreeWidgetItem::Type) { + showPopupForUser(static_cast(current)); + } else { + popupPinned = false; + hidePopup(); + } + }); + + // Section dividers can be collapsed/expanded by the user. Surface those + // changes only from real user interaction. Programmatic expansion is + // applied through setSectionExpanded() / setExpandedProgrammatically(). + connect(userTree, &QTreeWidget::itemExpanded, this, + [this](QTreeWidgetItem *item) { handleSectionExpansion(item, true); }); + connect(userTree, &QTreeWidget::itemCollapsed, this, + [this](QTreeWidgetItem *item) { handleSectionExpansion(item, false); }); + + // Hide popup when list scrolls (reference row has moved) + connect(userTree->verticalScrollBar(), &QScrollBar::valueChanged, this, [this] { + showPopupTimer->stop(); + hidePopup(true); + requestVisibleItemResources(); + }); + + // Forward join requests from popup upward + connect(userInfoPopup, &UserInfoPopup::joinGameRequested, this, &UserListWidget::joinGameRequested); + } else { + // Dialog mode: keyboard selection drives the Invite button. + connect(userTree, &QTreeWidget::currentItemChanged, this, [this](QTreeWidgetItem *current, QTreeWidgetItem *) { + const QString userName = (current && current->type() == QTreeWidgetItem::Type) + ? current->data(2, Qt::UserRole).toString() + : QString(); + emit currentUserChanged(userName); + }); + + // Keep the popup-less scroll path alive for avatar prefetch. + connect(userTree->verticalScrollBar(), &QScrollBar::valueChanged, this, + [this] { requestVisibleItemResources(); }); + } + + // Section dividers can be collapsed/expanded by the user. Surface those + // changes only from real user interaction. Programmatic expansion is + // applied through setSectionExpanded() / setExpandedProgrammatically(). + connect(userTree, &QTreeWidget::itemExpanded, this, + [this](QTreeWidgetItem *item) { handleSectionExpansion(item, true); }); + connect(userTree, &QTreeWidget::itemCollapsed, this, + [this](QTreeWidgetItem *item) { handleSectionExpansion(item, false); }); + + connect(avatarProvider, &UserAvatarProvider::avatarUpdated, this, &UserListWidget::refreshVisibleUserHeader); + connect(cardArtProvider, &UserCardArtProvider::cardArtUpdated, this, &UserListWidget::refreshVisibleUserHeader); + + connect(&SettingsCache::instance().appearance(), &AppearanceSettings::styleUserListChanged, this, + &UserListWidget::applyDisplayMode); + applyDisplayMode(); + + // The tree's cached palette can go stale after a runtime theme change (Qt + // freezes widget palettes when the style is switched), so rows and dividers + // derive all colors from the application palette at paint time. The theme + // change only needs a repaint to pick them up. + if (themeManager) { + connect(themeManager, &ThemeManager::themeChanged, this, [this] { + userTree->viewport()->update(); + userTree->update(); + }); + } QVBoxLayout *vbox = new QVBoxLayout; vbox->addWidget(userTree); @@ -441,6 +481,474 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor, retranslateUi(); } +UserListWidget::~UserListWidget() +{ + qApp->removeEventFilter(this); +} + +void UserListWidget::bind(UserListManager *mgr) +{ + manager = mgr; + + // ── Full rebuild: disconnect / reconnect / bulk initial load ────────────── + connect(manager, &UserListManager::listReset, this, &UserListWidget::rebuild); + + if (!sectioned) { + // Online users list (AllUsersList / RoomList) + if (type == AllUsersList || type == RoomList) { + connect(manager, &UserListManager::userJoinedOnline, this, + [this](const ServerInfo_User &user) { processUserInfo(user, true); }); + connect(manager, &UserListManager::userLeftOnline, this, [this](const QString &name) { deleteUser(name); }); + } + + // Buddy list + if (type == BuddyList) { + connect(manager, &UserListManager::addedToBuddyList, this, [this](const ServerInfo_User &user) { + const QString name = QString::fromStdString(user.name()); + processUserInfo(user, manager->getOnlineUser(name) != nullptr); + }); + connect(manager, &UserListManager::removedFromBuddyList, this, + [this](const QString &name) { deleteUser(name); }); + // Track online presence changes for buddies already in the tree + connect(manager, &UserListManager::userJoinedOnline, this, [this](const ServerInfo_User &user) { + const QString name = QString::fromStdString(user.name()); + if (users.contains(name)) { + users[name]->setUserInfo(user); + setUserOnline(name, true); + } + }); + connect(manager, &UserListManager::userLeftOnline, this, [this](const QString &name) { + if (users.contains(name)) { + setUserOnline(name, false); + } + }); + } + + // Ignore list + if (type == IgnoreList) { + connect(manager, &UserListManager::addedToIgnoreList, this, [this](const ServerInfo_User &user) { + const QString name = QString::fromStdString(user.name()); + processUserInfo(user, manager->getOnlineUser(name) != nullptr); + }); + connect(manager, &UserListManager::removedFromIgnoreList, this, + [this](const QString &name) { deleteUser(name); }); + } + } else { + // Sectioned mode: one tree, every source feeds its own section. + // Sections are pure membership views: the "Online" section holds every + // currently online user, the "Buddy"/"Ignore" sections hold those + // lists. A user can therefore appear in several sections at once (an + // online buddy gets one row in each). + connect(manager, &UserListManager::userJoinedOnline, this, + [this](const ServerInfo_User &user) { handleOnlineChange(user); }); + connect(manager, &UserListManager::userLeftOnline, this, + [this](const QString &name) { handleOnlineChangeLeft(name); }); + connect(manager, &UserListManager::addedToBuddyList, this, + [this](const ServerInfo_User &user) { handleListAdd(Section::Buddy, user); }); + connect(manager, &UserListManager::removedFromBuddyList, this, + [this](const QString &name) { handleListRemove(Section::Buddy, name); }); + connect(manager, &UserListManager::addedToIgnoreList, this, + [this](const ServerInfo_User &user) { handleListAdd(Section::Ignore, user); }); + connect(manager, &UserListManager::removedFromIgnoreList, this, + [this](const QString &name) { handleListRemove(Section::Ignore, name); }); + } + + // ── Popup button refresh ────────────────────────────────────────────────── + // Any buddy/ignore mutation while the popup is open refreshes its buttons + auto refreshIfPopupOpen = [this](const QString &name) { + if (userInfoPopup && userInfoPopup->isVisible() && userInfoPopup->getCurrentUser() == name) { + refreshPopupButtons(name); + } + }; + auto refreshCurrentPopup = [refreshIfPopupOpen](const ServerInfo_User &u) { + refreshIfPopupOpen(QString::fromStdString(u.name())); + }; + + connect(manager, &UserListManager::addedToBuddyList, this, refreshCurrentPopup); + connect(manager, &UserListManager::removedFromBuddyList, this, refreshIfPopupOpen); + connect(manager, &UserListManager::addedToIgnoreList, this, refreshCurrentPopup); + connect(manager, &UserListManager::removedFromIgnoreList, this, refreshIfPopupOpen); + connect(manager, &UserListManager::userJoinedOnline, this, refreshCurrentPopup); + connect(manager, &UserListManager::userLeftOnline, this, refreshIfPopupOpen); + + rebuild(); +} + +void UserListWidget::refreshVisibleUserHeader(const QString &name) +{ + userTree->viewport()->update(); + if (!userInfoPopup) { + return; + } + if (userInfoPopup->isVisible() && userInfoPopup->getCurrentUser() == name) { + userInfoPopup->refreshHeader(); + } +} + +void UserListWidget::refreshPopupButtons(const QString &userName) +{ + if (!userInfoPopup) { + return; + } + UserListTWI *item = users.value(userName); + if (!item) { + return; + } + + const UserListProxy *proxy = tabSupervisor->getUserListManager(); + const bool online = item->data(0, UserListRoles::Online).toBool(); + const bool isBuddy = proxy->isUserBuddy(userName); + const bool isIgn = proxy->isUserIgnored(userName); + + userInfoPopup->updateActionButtons(item->getUserInfo(), online, isBuddy, isIgn); + positionPopup(item); // height may have changed, reposition +} + +void UserListWidget::hideEvent(QHideEvent *e) +{ + QGroupBox::hideEvent(e); + if (!userInfoPopup) { + return; + } + showPopupTimer->stop(); + hidePopupTimer->stop(); + hidePopup(true); +} + +void UserListWidget::showEvent(QShowEvent *e) +{ + QGroupBox::showEvent(e); + if (!userInfoPopup) { + return; + } + requestVisibleItemResources(); +} + +void UserListWidget::applyDisplayMode() +{ + const bool styled = SettingsCache::instance().appearance().getStyleUserList(); + + // Both modes must keep the header length at the viewport width: with + // ScrollBarAlwaysOff a nonzero horizontal range is invisible but still + // pans via trackpad gestures, which reads as janky random drift. + if (styled) { + userTree->header()->setSectionResizeMode(0, QHeaderView::Stretch); + userTree->hideColumn(1); + userTree->hideColumn(2); + userTree->hideColumn(3); + } else { + // Bounded widths instead of ResizeToContents: content sizing measures + // the FULL text width while the delegate elides afterwards, so long + // names widened the header past the viewport. Fixed icon columns plus + // a stretched name column keep the range at zero, eliding trims. + userTree->header()->setSectionResizeMode(0, QHeaderView::Fixed); + userTree->header()->resizeSection(0, 24); + userTree->header()->setSectionResizeMode(1, QHeaderView::Fixed); + userTree->header()->resizeSection(1, 22); + userTree->header()->setSectionResizeMode(2, QHeaderView::Stretch); + userTree->showColumn(1); + userTree->showColumn(2); + userTree->hideColumn(3); + } + + userTree->viewport()->update(); +} + +void UserListWidget::connectPopupSignals() +{ + connect(userInfoPopup, &UserInfoPopup::closeRequested, this, [this] { + popupPinned = false; + hidePopup(true); + }); + connect(userInfoPopup, &UserInfoPopup::mouseEnteredPopup, hidePopupTimer, &QTimer::stop); + connect(userInfoPopup, &UserInfoPopup::mouseLeftPopup, this, [this] { + if (!popupPinned) { + hidePopupTimer->start(); + } + }); + + // Wire all action signals to UserContextMenu::exec*() + connect(userInfoPopup, &UserInfoPopup::chatRequested, userContextMenu, &UserContextMenu::execChat); + connect(userInfoPopup, &UserInfoPopup::inviteRequested, this, + [this](const QString &userName) { userContextMenu->execInvite(userName); }); + connect(userInfoPopup, &UserInfoPopup::detailsRequested, userContextMenu, &UserContextMenu::execDetails); + connect(userInfoPopup, &UserInfoPopup::showGamesRequested, userContextMenu, &UserContextMenu::execShowGames); + connect(userInfoPopup, &UserInfoPopup::addBuddyRequested, userContextMenu, &UserContextMenu::execAddToBuddy); + connect(userInfoPopup, &UserInfoPopup::removeBuddyRequested, userContextMenu, + &UserContextMenu::execRemoveFromBuddy); + connect(userInfoPopup, &UserInfoPopup::addIgnoreRequested, userContextMenu, &UserContextMenu::execAddToIgnore); + connect(userInfoPopup, &UserInfoPopup::removeIgnoreRequested, userContextMenu, + &UserContextMenu::execRemoveFromIgnore); + connect(userInfoPopup, &UserInfoPopup::banRequested, userContextMenu, &UserContextMenu::execBan); + connect(userInfoPopup, &UserInfoPopup::warnRequested, userContextMenu, &UserContextMenu::execWarn); + connect(userInfoPopup, &UserInfoPopup::banHistoryRequested, userContextMenu, &UserContextMenu::execBanHistory); + connect(userInfoPopup, &UserInfoPopup::warnHistoryRequested, userContextMenu, &UserContextMenu::execWarnHistory); + connect(userInfoPopup, &UserInfoPopup::adminNotesRequested, userContextMenu, &UserContextMenu::execAdminNotes); + connect(userInfoPopup, &UserInfoPopup::promoteToModRequested, this, + [this](const QString &n) { userContextMenu->execAdjustMod(n, true); }); + connect(userInfoPopup, &UserInfoPopup::demoteFromModRequested, this, + [this](const QString &n) { userContextMenu->execAdjustMod(n, false); }); + connect(userInfoPopup, &UserInfoPopup::promoteToJudgeRequested, this, + [this](const QString &n) { userContextMenu->execAdjustJudge(n, true); }); + connect(userInfoPopup, &UserInfoPopup::demoteFromJudgeRequested, this, + [this](const QString &n) { userContextMenu->execAdjustJudge(n, false); }); +} + +bool UserListWidget::eventFilter(QObject *obj, QEvent *event) +{ + // A press outside the tree, the popup and any open menu deselects the + // list and closes the popup. The filter is installed application-wide, so + // the target can be any widget in the window or another list. + if (event->type() == QEvent::MouseButtonPress) { + auto *pressTarget = qobject_cast(obj); + if (pressTarget && !isPressInsideListUi(pressTarget)) { + clearSelectionAndClosePopup(); + } + } + + // Keyboard entry to the user context menu: the Menu key (or Shift+F10) + // pops the same menu the right-click shows, anchored to the focused row. + // Divider rows have no menu. Mouse-triggered context events are NOT handled + // here — the delegate's right-press path already pops the menu, and + // handling both would open two menus on one right-click. + if (hasUserInfoPopup && (obj == userTree || obj == userTree->viewport()) && event->type() == QEvent::ContextMenu) { + auto *contextEvent = static_cast(event); + if (contextEvent->reason() == QContextMenuEvent::Keyboard) { + QTreeWidgetItem *current = userTree->currentItem(); + if (current && current->type() == QTreeWidgetItem::Type) { + const QPoint globalPos = userTree->viewport()->mapToGlobal(userTree->visualItemRect(current).center()); + showContextMenu(globalPos, userTree->indexFromItem(current)); + return true; + } + return false; // divider rows: no menu + } + } + + // Keyboard navigation of the section dividers. + // The dividers are selectable so arrow keys land on them. When one is the + // current item, Enter/Space toggle it (like a button) and Left/Right follow + // the tree convention (Left collapses, Right expands). + if (obj == userTree && event->type() == QEvent::KeyPress) { + auto *keyEvent = static_cast(event); + QTreeWidgetItem *current = userTree->currentItem(); + if (sectioned && current && current->type() == SectionItemType) { + const bool toggle = keyEvent->key() == Qt::Key_Return || keyEvent->key() == Qt::Key_Enter || + keyEvent->key() == Qt::Key_Space; + const bool collapse = keyEvent->key() == Qt::Key_Left && current->isExpanded(); + const bool expand = keyEvent->key() == Qt::Key_Right && !current->isExpanded(); + if (toggle || collapse || expand) { + const bool expanded = toggle ? !current->isExpanded() : expand; + setExpandedProgrammatically(current, expanded); + handleSectionExpansion(current, expanded); + return true; + } + } + } + + if (hasUserInfoPopup && obj == userTree->viewport()) { + if (event->type() == QEvent::MouseMove) { + if (!SettingsCache::instance().appearance().getStyleUserList()) { + return QGroupBox::eventFilter(obj, event); + } + auto *me = static_cast(event); + QTreeWidgetItem *hoveredItem = userTree->itemAt(me->pos()); + QString hovName; + if (hoveredItem && hoveredItem->type() == QTreeWidgetItem::Type) { + hovName = QString::fromStdString(static_cast(hoveredItem)->getUserInfo().name()); + } + + if (hovName != hoveredUser) { + hoveredUser = hovName; + if (!hovName.isEmpty()) { + hidePopupTimer->stop(); + if (!popupPinned) { + showPopupTimer->start(); + } + } else { + showPopupTimer->stop(); + if (!popupPinned) { + hidePopupTimer->start(); + } + } + } + } else if (event->type() == QEvent::Leave) { + hoveredUser.clear(); + showPopupTimer->stop(); + if (!popupPinned) { + hidePopupTimer->start(); + } + } + } + + return QGroupBox::eventFilter(obj, event); +} + +void UserListWidget::showPopupForUser(UserListTWI *item) +{ + if (!userInfoPopup) { + return; + } + if (!item) { + return; + } + + const QString userName = QString::fromStdString(item->getUserInfo().name()); + avatarProvider->requestAvatar(userName); // ensure the hovered user's avatar is fetched promptly + if (cardArtParamsMap.contains(userName)) { + const CardArtParams ¶ms = cardArtParamsMap.value(userName); + cardArtProvider->requestCardArt(userName, params.cardName, params.cardProviderId); + } + + const ServerInfo_User &info = item->getUserInfo(); + const bool online = item->data(0, UserListRoles::Online).toBool(); + const bool isBuddy = userContextMenu->getUserListProxy()->isUserBuddy(userName); + const bool isIgn = userContextMenu->getUserListProxy()->isUserIgnored(userName); + + // The popup is already showing this user (e.g. arrow key navigation between + // the online/buddy rows of the same user): just reposition it. + if (userInfoPopup->isVisible() && userInfoPopup->getCurrentUser() == userName) { + positionPopup(item); + return; + } + + // Cancel any pending show/hide so a hover timer that armed before a row + // selected via the keyboard cannot override it, and a pending hide cannot + // kill the popup right after it appears. + showPopupTimer->stop(); + hidePopupTimer->stop(); + + userInfoPopup->showForUser(userName, info, online, isBuddy, isIgn); + + const bool wasVisible = userInfoPopup->isVisible(); + if (!wasVisible) { + // Realize the native window at opacity 0 before positioning so that: + // 1) move() applies to an existing native handle (not overridden by + // Qt's default centering logic on first show) + // 2) adjustSize() inside positionPopup() can measure the final + // laid out geometry correctly + userInfoPopup->setWindowOpacity(0.0); + } + userInfoPopup->show(); + userInfoPopup->raise(); + + positionPopup(item); // geometry is accurate after show, so move() is not overridden + + if (wasVisible) { + // Content swap while already open (hover or arrow key navigation): + // keep the popup opaque instead of flashing through a fade on every + // step. + userInfoPopup->setWindowOpacity(1.0); + return; + } + + auto *fade = new QPropertyAnimation(userInfoPopup, "windowOpacity", userInfoPopup); + fade->setDuration(120); + fade->setStartValue(0.0); + fade->setEndValue(1.0); + fade->start(QAbstractAnimation::DeleteWhenStopped); +} + +void UserListWidget::positionPopup(UserListTWI *item) +{ + if (!userInfoPopup) { + return; + } + if (!item) { + return; + } + + QWidget *vp = userTree->viewport(); + const QRect itemR = userTree->visualItemRect(item); + const QPoint itemTL = vp->mapToGlobal(itemR.topLeft()); + const QPoint vpTL = vp->mapToGlobal(vp->rect().topLeft()); + const QPoint vpTR = vp->mapToGlobal(vp->rect().topRight()); + + userInfoPopup->adjustSize(); + const int popW = userInfoPopup->width(); + const int popH = userInfoPopup->height(); + const int margin = 12; + + QScreen *activeScreen = QGuiApplication::screenAt(itemTL); + if (!activeScreen) { + activeScreen = window()->screen(); + } + const QRect screen = + activeScreen ? activeScreen->availableGeometry() : QGuiApplication::primaryScreen()->availableGeometry(); + + // ── X: prefer the side with more space ─────────────────────────────────── + const int spaceLeft = vpTL.x() - screen.left() - margin; + const int spaceRight = screen.right() - vpTR.x() - margin; + int x; + if (spaceLeft >= spaceRight) { + x = (spaceLeft >= popW) ? (vpTL.x() - margin - popW) : (vpTR.x() + margin); + } else { + x = (spaceRight >= popW) ? (vpTR.x() + margin) : (vpTL.x() - margin - popW); + } + x = qBound(screen.left() + margin, x, screen.right() - popW - margin); + + // ── Y: grow down if there's room, otherwise grow up ─────────────────────── + const int itemTopY = itemTL.y(); + const int spaceBelow = screen.bottom() - itemTopY - margin; + const int spaceAbove = itemTopY - screen.top() - margin; + + int y; + if (spaceBelow >= popH) { + y = itemTopY; // top edges align, popup grows downward + } else if (spaceAbove >= popH) { + y = itemTopY - popH; // bottom of popup meets top of item, grows upward + } else { + // Neither side fits cleanly — pick the roomier side and let clamp handle the rest + y = (spaceBelow >= spaceAbove) ? itemTopY : (itemTopY - popH); + } + y = qBound(screen.top() + margin, y, screen.bottom() - popH - margin); + + userInfoPopup->move(x, y); +} + +void UserListWidget::hidePopup(bool immediate) +{ + if (!userInfoPopup) { + return; + } + showPopupTimer->stop(); + hidePopupTimer->stop(); + if (!userInfoPopup->isVisible()) { + return; + } + + if (immediate) { + userInfoPopup->hide(); + return; + } + + // Fade out + auto *fade = new QPropertyAnimation(userInfoPopup, "windowOpacity", userInfoPopup); + fade->setDuration(100); + fade->setStartValue(userInfoPopup->windowOpacity()); + fade->setEndValue(0.0); + connect(fade, &QPropertyAnimation::finished, userInfoPopup, &QWidget::hide); + fade->start(QAbstractAnimation::DeleteWhenStopped); +} + +bool UserListWidget::isPressInsideListUi(const QWidget *widget) const +{ + const QWidget *w = widget; + while (w) { + if (w == userTree || w == userInfoPopup || qobject_cast(w)) { + return true; + } + w = w->parentWidget(); + } + return false; +} + +void UserListWidget::clearSelectionAndClosePopup() +{ + popupPinned = false; + hidePopup(true); + userTree->clearSelection(); +} + void UserListWidget::retranslateUi() { userContextMenu->retranslateUi(); @@ -461,9 +969,153 @@ void UserListWidget::retranslateUi() updateCount(); } +void UserListWidget::beginBulkLoad() +{ + bulkLoading = true; +} + +void UserListWidget::endBulkLoad() +{ + bulkLoading = false; + sortItems(); + updateCount(); // divider counts were deferred during the bulk build + requestVisibleItemResources(); + userTree->viewport()->update(); +} + +bool UserListWidget::isItemNearViewport(const UserListTWI *item) const +{ + // Prefetch a full viewport of rows above and below so scrolling never shows + // an unloaded row. + const QRect nearView = + userTree->viewport()->rect().adjusted(0, -userTree->viewport()->height(), 0, userTree->viewport()->height()); + return userTree->visualItemRect(item).intersects(nearView); +} + +void UserListWidget::requestVisibleItemResources() +{ + const auto requestResources = [this](UserListTWI *twi) { + if (!isItemNearViewport(twi)) { + return; + } + const QString userName = QString::fromStdString(twi->getUserInfo().name()); + avatarProvider->requestAvatar(userName); + if (cardArtParamsMap.contains(userName)) { + const CardArtParams ¶ms = cardArtParamsMap.value(userName); + cardArtProvider->requestCardArt(userName, params.cardName, params.cardProviderId); + } + }; + + if (sectioned) { + // Top level items are dividers, user rows hang below them. + for (const Section section : sectionIds) { + QTreeWidgetItem *divider = sectionItems.value(section); + if (!divider) { + continue; + } + for (int i = 0; i < divider->childCount(); ++i) { + requestResources(static_cast(divider->child(i))); + } + } + return; + } + + for (int i = 0; i < userTree->topLevelItemCount(); ++i) { + requestResources(static_cast(userTree->topLevelItem(i))); + } +} + +void UserListWidget::rebuild() +{ + userTree->clear(); + users.clear(); + sectionUsers.clear(); + cardArtParamsMap.clear(); + onlineCount = 0; + + if (sectioned) { + createSectionItems(); + } + + if (!manager) { + return; + } + + if (sectioned) { + // Every source feeds its own section. Users that belong to several + // sources (an online buddy) get one row per section because + // ensureSectionMembership() creates the row when it is missing. + beginBulkLoad(); + const auto &onlineUsers = manager->getAllUsersList(); + for (auto it = onlineUsers.cbegin(); it != onlineUsers.cend(); ++it) { + processUserInfo(Section::Online, it.value(), true); + } + const auto &buddyUsers = manager->getBuddyList(); + for (auto it = buddyUsers.cbegin(); it != buddyUsers.cend(); ++it) { + processUserInfo(Section::Buddy, it.value(), manager->getOnlineUser(it.key()) != nullptr); + } + const auto &ignoreUsers = manager->getIgnoreList(); + for (auto it = ignoreUsers.cbegin(); it != ignoreUsers.cend(); ++it) { + processUserInfo(Section::Ignore, it.value(), manager->getOnlineUser(it.key()) != nullptr); + } + endBulkLoad(); + applyFilter(); + return; + } + + const QMap *source = nullptr; + + switch (type) { + case AllUsersList: + case RoomList: + source = &manager->getAllUsersList(); + break; + case BuddyList: + source = &manager->getBuddyList(); + break; + case IgnoreList: + source = &manager->getIgnoreList(); + break; + } + + beginBulkLoad(); + for (auto it = source->cbegin(); it != source->cend(); ++it) { + processUserInfo(it.value(), manager->getOnlineUser(it.key()) != nullptr); + } + endBulkLoad(); + applyFilter(); +} + +void UserListWidget::updateCardArtParams(const ServerInfo_User &user, const QString &userName) +{ + // Always update params from the latest ServerInfo_User, whether the + // item is new or existing, so a live server push refreshes the rendering. + if (user.has_card_art_params()) { + const auto &cap = user.card_art_params(); + CardArtParams params; + params.cardName = QString::fromStdString(cap.card_name()); + params.cardProviderId = QString::fromStdString(cap.card_provider_id()); + params.marginPctL = cap.margin_pct_l(); + params.marginPctR = cap.margin_pct_r(); + params.verticalOffset = cap.vertical_offset(); + params.zoom = cap.zoom(); + cardArtParamsMap.insert(userName, params); + cardArtProvider->requestCardArt(userName, params.cardName, params.cardProviderId); + } + // Intentionally no removal branch: buddy/ignore list copies never carry + // card_art_params (the server omits the column), so a params-less copy here + // means "this snapshot doesn't include it", not "the banner was removed". + // Removing on such copies would wipe banners that the live online list set. + // The map is rebuilt from scratch (clear() + repopulate) on every rebuild, + // which is what actually drops stale entries. +} + void UserListWidget::processUserInfo(const ServerInfo_User &user, bool online) { const QString userName = QString::fromStdString(user.name()); + + updateCardArtParams(user, userName); + UserListTWI *item = users.value(userName); if (item) { item->setUserInfo(user); @@ -475,34 +1127,92 @@ void UserListWidget::processUserInfo(const ServerInfo_User &user, bool online) ++onlineCount; } updateCount(); + if (!bulkLoading && isItemNearViewport(item)) { + avatarProvider->requestAvatar(userName); + } } item->setOnline(online); + if (!bulkLoading) { + sortItems(); + applyFilter(); + userTree->viewport()->update(); + } +} + +void UserListWidget::processUserInfo(Section section, const ServerInfo_User &user, bool online) +{ + ensureSectionMembership(section, user, online); + if (!bulkLoading) { + sortItems(); + applyFilter(); + userTree->viewport()->update(); + } } bool UserListWidget::deleteUser(const QString &userName) { - UserListTWI *twi = users.value(userName); - if (twi) { - users.remove(userName); - userTree->takeTopLevelItem(userTree->indexOfTopLevelItem(twi)); - if (twi->data(0, Qt::UserRole + 1).toBool()) { - --onlineCount; + if (sectioned) { + // The user may own several rows (one per section). Drop them all. + bool removed = false; + const QList
sections = sectionUsers.keys(); // snapshot: maps mutate + for (const Section section : sections) { + removed = dropSectionMembership(section, userName) || removed; } - delete twi; - updateCount(); - return true; + if (removed && !bulkLoading) { + sortItems(); + applyFilter(); + userTree->viewport()->update(); + } + return removed; } - return false; + UserListTWI *twi = users.value(userName); + if (!twi) { + return false; + } + + users.remove(userName); + if (twi->parent()) { + twi->parent()->removeChild(twi); // sectioned mode: rows hang off a divider + } else { + userTree->takeTopLevelItem(userTree->indexOfTopLevelItem(twi)); + } + if (twi->data(0, UserListRoles::Online).toBool()) { + --onlineCount; + } + delete twi; + updateCount(); + applyFilter(); + return true; } void UserListWidget::setUserOnline(const QString &userName, bool online) { + if (sectioned) { + // The rows in the "Online" section are created/removed by the presence + // handlers. This only keeps the presence flag of the surviving rows + // (e.g. a buddy row after the user went offline) in sync. + for (auto it = sectionUsers.cbegin(); it != sectionUsers.cend(); ++it) { + UserListTWI *item = it.value().value(userName); + if (item) { + item->setOnline(online); + } + } + return; + } + UserListTWI *twi = users.value(userName); if (!twi) { return; } + // No state change: nothing to resort. This also keeps the presence + // broadcasts cheap (userJoinedOnline fires once per online user) when the + // row already carries the right flag. + if (twi->data(0, UserListRoles::Online).toBool() == online) { + return; + } + twi->setOnline(online); if (online) { ++onlineCount; @@ -510,26 +1220,158 @@ void UserListWidget::setUserOnline(const QString &userName, bool online) --onlineCount; } updateCount(); + + // Online users sort above offline users (UserListTWI::operator<), so a + // flag change moves the row. Resort to place the user by the new state. + if (!bulkLoading) { + sortItems(); + applyFilter(); + userTree->viewport()->update(); + } } void UserListWidget::updateCount() { - QString str = titleStr; - if ((type == BuddyList) || (type == IgnoreList)) { - str = str.arg(onlineCount); + if (sectioned) { + // The dividers carry the section titles + setTitle(QString()); + for (const Section section : sectionIds) { + updateSectionDivider(section); + } + return; } - setTitle(str.arg(userTree->topLevelItemCount())); + + if (showTitle) { + QString str = titleStr; + if ((type == BuddyList) || (type == IgnoreList)) { + str = str.arg(onlineCount); + } + setTitle(str.arg(userTree->topLevelItemCount())); + } else { + setTitle(QString()); + } +} + +void UserListWidget::setShowTitle(bool _showTitle) +{ + this->showTitle = _showTitle; + updateCount(); +} + +void UserListWidget::setFilterText(const QString &text) +{ + if (filterText == text) { + return; + } + filterText = text; + applyFilter(); +} + +void UserListWidget::applyFilter() +{ + if (sectioned) { + const bool searching = !filterText.isEmpty(); + const QString lower = filterText.toLower(); + for (const Section section : sectionIds) { + QTreeWidgetItem *divider = sectionItems.value(section); + if (!divider) { + continue; + } + int visible = 0; + for (int i = 0; i < divider->childCount(); ++i) { + auto *child = static_cast(divider->child(i)); + const QString name = QString::fromStdString(child->getUserInfo().name()); + const bool passesFilter = + !userFilter || userFilter(name, child->data(0, UserListRoles::Online).toBool()); + const bool match = passesFilter && (!searching || name.toLower().contains(lower)); + child->setHidden(!match); + if (match) { + ++visible; + } + } + if (searching) { + // During a search the sections with matches stay open and empty + // sections disappear entirely. The persisted expansion state is + // untouched and restored when the search is cleared. + divider->setHidden(visible == 0); + setExpandedProgrammatically(divider, visible > 0); + } else { + divider->setHidden(false); + setExpandedProgrammatically(divider, expandedSections.contains(section)); + } + updateSectionDivider(section); + } + requestVisibleItemResources(); + userTree->viewport()->update(); + emit userListChanged(); + return; + } + + if (filterText.isEmpty()) { + for (auto it = users.cbegin(); it != users.cend(); ++it) { + it.value()->setHidden(false); + } + } else { + const QString lower = filterText.toLower(); + for (auto it = users.cbegin(); it != users.cend(); ++it) { + const bool match = QString::fromStdString(it.value()->getUserInfo().name()).toLower().contains(lower); + it.value()->setHidden(!match); + } + } + + requestVisibleItemResources(); + userTree->viewport()->update(); + emit userListChanged(); } void UserListWidget::userClicked(QTreeWidgetItem *item, int /*column*/) { - emit openMessageDialog(item->data(2, Qt::UserRole).toString(), true); + if (item->type() != QTreeWidgetItem::Type) { + return; // divider rows open no chat + } + const QString userName = item->data(2, Qt::UserRole).toString(); + if (hasUserInfoPopup) { + emit openMessageDialog(userName, true); + } else { + emit userActivated(userName); + } +} + +int UserListWidget::visibleUserRowCount() const +{ + int count = 0; + if (sectioned) { + for (const Section section : sectionIds) { + QTreeWidgetItem *divider = sectionItems.value(section); + if (!divider || divider->isHidden()) { + continue; + } + for (int i = 0; i < divider->childCount(); ++i) { + if (!divider->child(i)->isHidden()) { + ++count; + } + } + } + return count; + } + for (int i = 0; i < userTree->topLevelItemCount(); ++i) { + QTreeWidgetItem *item = userTree->topLevelItem(i); + if (!item->isHidden() && item->type() == QTreeWidgetItem::Type) { + ++count; + } + } + return count; } void UserListWidget::showContextMenu(const QPoint &pos, const QModelIndex &index) { - const ServerInfo_User &userInfo = static_cast(userTree->topLevelItem(index.row()))->getUserInfo(); - bool online = index.sibling(index.row(), 0).data(Qt::UserRole + 1).toBool(); + QTreeWidgetItem *item = userTree->itemFromIndex(index); + if (!item || item->type() != QTreeWidgetItem::Type) { + return; // divider rows have no user menu + } + const auto *userItem = static_cast(item); + const ServerInfo_User &userInfo = userItem->getUserInfo(); + const bool online = userItem->data(0, UserListRoles::Online).toBool(); userContextMenu->showContextMenu(pos, QString::fromStdString(userInfo.name()), UserLevelFlags(userInfo.user_level()), online); @@ -537,5 +1379,313 @@ void UserListWidget::showContextMenu(const QPoint &pos, const QModelIndex &index void UserListWidget::sortItems() { - userTree->sortItems(1, Qt::AscendingOrder); + if (sectioned) { + // Sorting must stay inside each section so the dividers keep their + // places as top level items. + for (auto it = sectionItems.cbegin(); it != sectionItems.cend(); ++it) { + it.value()->sortChildren(0, Qt::AscendingOrder); + } + return; + } + userTree->sortItems(0, Qt::AscendingOrder); +} + +// Sectioned mode + +void UserListWidget::setSectioned(const QList
&ids) +{ + if (sectioned || ids.isEmpty()) { + return; + } + + sectioned = true; + sectionIds = ids; + expandedSections.clear(); + for (const Section section : sectionIds) { + expandedSections.insert(section); // everything starts expanded + } + + // The single tree owns scrolling and the dividers carry the section titles, + // so the group box chrome and tree decorations collapse into a flat list. + setFlat(true); + setShowTitle(false); + userTree->setFrameStyle(QFrame::NoFrame); + // No tree branches: the dividers draw their own arrow glyph, so the rows can + // sit flush with the left border. + userTree->setRootIsDecorated(false); + userTree->setIndentation(0); + userTree->setAlternatingRowColors(false); + if (auto *listLayout = layout()) { + listLayout->setContentsMargins(0, 0, 0, 0); + } + + createSectionItems(); + updateCount(); +} + +void UserListWidget::createSectionItems() +{ + sectionItems.clear(); + QSignalBlocker blocker(userTree); // no expansion signals while building + for (const Section section : sectionIds) { + QTreeWidgetItem *divider = createSectionItem(section); + sectionItems.insert(section, divider); + divider->setExpanded(expandedSections.contains(section)); + } +} + +QTreeWidgetItem *UserListWidget::createSectionItem(Section section) +{ + Q_UNUSED(section); + auto *divider = new QTreeWidgetItem(SectionItemType); + // Selectable so keyboard navigation (Up/Down) can land on the dividers. + // They act as collapsible section headers once they have focus. + divider->setFlags(Qt::ItemIsEnabled | Qt::ItemIsSelectable); + + QFont font = userTree->font(); + font.setBold(true); + divider->setFont(0, font); + // A little taller than a plain text row so the header reads as a section + // separator without matching the full user row height. + divider->setSizeHint(0, QSize(0, QFontMetrics(font).height() + 16)); + + userTree->addTopLevelItem(divider); + // QTreeWidgetItem::setFirstColumnSpanned() does nothing while the item is + // detached from the tree (Qt returns early when treeModel() is null), so it + // must be called after addTopLevelItem(). Without the span the divider text + // is confined to column 0 and gets elided in unstyled mode. + divider->setFirstColumnSpanned(true); + return divider; +} + +QString UserListWidget::sectionTitle(Section section) const +{ + switch (section) { + case Section::Buddy: + return tr("Buddies"); + case Section::Online: + return tr("Online"); + case Section::Ignore: + return tr("Ignored"); + } + return {}; +} + +void UserListWidget::updateSectionDivider(Section section) +{ + QTreeWidgetItem *divider = sectionItems.value(section); + if (!divider) { + return; + } + int visible = 0; + int online = 0; + for (int i = 0; i < divider->childCount(); ++i) { + QTreeWidgetItem *child = divider->child(i); + if (!child->isHidden()) { + ++visible; + if (child->data(0, UserListRoles::Online).toBool()) { + ++online; + } + } + } + // The tree draws no branches (rows are flush), so the divider carries its + // own collapse arrow glyph. + const QString arrow = divider->isExpanded() ? QStringLiteral("\u25BE") : QStringLiteral("\u25B8"); + if (section == Section::Buddy) { + // The buddy divider reports how many of the shown buddies are online, + // mirroring the "Buddies online: %1 / %2" title of the non-sectioned + // buddy list. + divider->setText(0, tr("%1 %2 (%3/%4)").arg(arrow, sectionTitle(section)).arg(online).arg(visible)); + } else { + divider->setText(0, tr("%1 %2 (%3)").arg(arrow, sectionTitle(section)).arg(visible)); + } +} + +void UserListWidget::handleSectionExpansion(QTreeWidgetItem *item, bool expanded) +{ + if (!sectioned || item->type() != SectionItemType) { + return; + } + // Reverse lookup. Only three dividers exist, so a linear scan over the + // section map is cheaper than caching the section on each divider. + auto dividerIt = sectionItems.constBegin(); + while (dividerIt != sectionItems.constEnd() && dividerIt.value() != item) { + ++dividerIt; + } + if (dividerIt == sectionItems.constEnd()) { + return; + } + const Section section = dividerIt.key(); + if (expanded) { + expandedSections.insert(section); + } else { + expandedSections.remove(section); + } + updateSectionDivider(section); // the arrow glyph follows the state + emit sectionExpanded(section, expanded); +} + +void UserListWidget::setExpandedProgrammatically(QTreeWidgetItem *item, bool expanded) +{ + QSignalBlocker blocker(userTree); + item->setExpanded(expanded); +} + +void UserListWidget::setSectionExpanded(Section section, bool expanded) +{ + if (!sectioned) { + return; + } + if (expanded) { + expandedSections.insert(section); + } else { + expandedSections.remove(section); + } + QTreeWidgetItem *divider = sectionItems.value(section); + if (!divider) { + return; + } + QSignalBlocker blocker(userTree); + divider->setExpanded(expanded); + updateSectionDivider(section); // the arrow glyph follows the state + userTree->viewport()->update(); +} + +void UserListWidget::handleOnlineChange(const ServerInfo_User &user) +{ + // A user came online: they get a row in the "Online" section, plus (if + // applicable) a row in the buddy/ignore sections, which flip to online. + const QString name = QString::fromStdString(user.name()); + ensureSectionMembership(Section::Online, user, true); + if (manager->isUserBuddy(name)) { + ensureSectionMembership(Section::Buddy, user, true); + } + if (manager->isUserIgnored(name)) { + ensureSectionMembership(Section::Ignore, user, true); + } + finishSectionedMutation(); +} + +void UserListWidget::handleOnlineChangeLeft(const QString &userName) +{ + // The user is no longer online: their "Online" row disappears. Buddies and + // ignored users keep their own section's row, marked offline. A plain user + // has no rows left. + const bool dropped = dropSectionMembership(Section::Online, userName); + const bool kept = manager->isUserBuddy(userName) || manager->isUserIgnored(userName); + if (kept) { + setUserOnline(userName, false); + } + if (dropped || kept) { + finishSectionedMutation(); + } +} + +void UserListWidget::handleListAdd(Section section, const ServerInfo_User &user) +{ + const QString name = QString::fromStdString(user.name()); + const bool online = manager->getOnlineUser(name) != nullptr; + ensureSectionMembership(section, user, online); + if (online) { + // The user belongs to the "Online" section as well. Make sure the row + // exists even if the join event raced ahead of the list mutation. + ensureSectionMembership(Section::Online, user, true); + } + finishSectionedMutation(); +} + +void UserListWidget::handleListRemove(Section section, const QString &userName) +{ + // Only the row of the removed section disappears: an online user keeps + // their "Online" row, and other list memberships keep theirs. + if (dropSectionMembership(section, userName)) { + finishSectionedMutation(); + } +} + +UserListTWI *UserListWidget::ensureSectionMembership(Section section, const ServerInfo_User &user, bool online) +{ + const QString userName = QString::fromStdString(user.name()); + + updateCardArtParams(user, userName); + + // Dialog mode: rows that fail the user filter never exist. applyFilter() + // re-checks the predicate on every pass so a live state change (e.g. the + // user being ignored mid-dialog) hides an already created row. + if (userFilter && !userFilter(userName, online)) { + return nullptr; + } + + QTreeWidgetItem *divider = sectionItems.value(section); + if (!divider) { + return nullptr; + } + + QMap §ionMap = sectionUsers[section]; + UserListTWI *item = sectionMap.value(userName); + if (!item) { + item = new UserListTWI(user); + sectionMap.insert(userName, item); + divider->addChild(item); + if (!users.contains(userName)) { + users.insert(userName, item); // primary row for lookups by name + } + // The divider counts are refreshed once in endBulkLoad(). Calling + // updateCount() per row during a large rebuild would be quadratic. + if (!bulkLoading) { + updateCount(); // a new row changes the divider's count + } + if (!bulkLoading && isItemNearViewport(item)) { + avatarProvider->requestAvatar(userName); + } + } else { + item->setUserInfo(user); + } + item->setOnline(online); + return item; +} + +bool UserListWidget::dropSectionMembership(Section section, const QString &userName) +{ + QMap §ionMap = sectionUsers[section]; + UserListTWI *item = sectionMap.take(userName); + if (!item) { + return false; + } + + if (item->parent()) { + item->parent()->removeChild(item); + } else { + userTree->takeTopLevelItem(userTree->indexOfTopLevelItem(item)); + } + if (users.value(userName) == item) { + // Repoint the primary row at another surviving row, if any. + UserListTWI *replacement = nullptr; + for (auto it = sectionUsers.cbegin(); it != sectionUsers.cend() && !replacement; ++it) { + replacement = it.value().value(userName); + } + if (replacement) { + users.insert(userName, replacement); + } else { + users.remove(userName); + } + } + delete item; + updateCount(); + return true; +} + +void UserListWidget::finishSectionedMutation() +{ + if (bulkLoading) { + return; + } + sortItems(); + applyFilter(); + userTree->viewport()->update(); +} + +void UserListWidget::setGameInviteLinkProvider(std::function()> provider) +{ + userContextMenu->setGameInviteLinkProvider(std::move(provider)); } diff --git a/cockatrice/src/interface/widgets/server/user/user_list_widget.h b/cockatrice/src/interface/widgets/server/user/user_list_widget.h index 5a8c00d10..412271160 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.h +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.h @@ -7,12 +7,21 @@ #ifndef USERLIST_H #define USERLIST_H +#include "../../cards/card_info_picture_art_crop_widget.h" +#include "../../interface/widgets/server/game_link.h" +#include "user_avatar_provider.h" +#include "user_card_art_provider.h" +#include "user_info_popup.h" +#include "user_list_manager.h" +#include "user_list_painter.h" + #include -#include #include +#include #include #include #include +#include #include #include @@ -20,86 +29,32 @@ class QTreeWidget; class ServerInfo_User; class AbstractClient; class TabSupervisor; -class QLabel; -class QCheckBox; -class QSpinBox; -class QRadioButton; -class QPlainTextEdit; class Response; class CommandContainer; class UserContextMenu; - -class BanDialog : public QDialog -{ - Q_OBJECT -private: - QLabel *daysLabel, *hoursLabel, *minutesLabel; - QCheckBox *nameBanCheckBox, *ipBanCheckBox, *idBanCheckBox, *deleteMessages; - QLineEdit *nameBanEdit, *ipBanEdit, *idBanEdit; - QSpinBox *daysEdit, *hoursEdit, *minutesEdit; - QRadioButton *permanentRadio, *temporaryRadio; - QPlainTextEdit *reasonEdit, *visibleReasonEdit; -private slots: - void okClicked(); - void enableTemporaryEdits(bool enabled); - -public: - explicit BanDialog(const ServerInfo_User &info, QWidget *parent = nullptr); - [[nodiscard]] QString getBanName() const; - [[nodiscard]] QString getBanIP() const; - [[nodiscard]] QString getBanId() const; - [[nodiscard]] int getMinutes() const; - [[nodiscard]] QString getReason() const; - [[nodiscard]] QString getVisibleReason() const; - [[nodiscard]] int getDeleteMessages() const; -}; - -class WarningDialog : public QDialog -{ - Q_OBJECT -private: - QLabel *descriptionLabel; - QLineEdit *nameWarning; - QComboBox *warningOption; - QLineEdit *warnClientID; - QCheckBox *deleteMessages; -private slots: - void okClicked(); - -public: - WarningDialog(const QString userName, const QString clientID, QWidget *parent = nullptr); - [[nodiscard]] QString getName() const; - [[nodiscard]] QString getWarnID() const; - [[nodiscard]] QString getReason() const; - [[nodiscard]] int getDeleteMessages() const; - void addWarningOption(const QString warning); -}; - -class AdminNotesDialog : public QDialog -{ - Q_OBJECT - -private: - QString userName; - QPlainTextEdit *notes; - -public: - explicit AdminNotesDialog(const QString &_userName, const QString &_notes, QWidget *_parent = nullptr); - [[nodiscard]] QString getName() const - { - return userName; - } - [[nodiscard]] QString getNotes() const; -}; +class UserListWidget; +class QShowEvent; class UserListItemDelegate : public QStyledItemDelegate { + QTreeWidget *tree; + UserListWidget *owner; + const QMap *avatarCache; + const QMap *cardArtCache; + const QMap *cardArtParamsMap; + public: - explicit UserListItemDelegate(QObject *const parent); + explicit UserListItemDelegate(UserListWidget *owner, + QTreeWidget *tree, + const QMap *avatarCache, + const QMap *cardArtCache, + const QMap *cardArtParamsMap); bool editorEvent(QEvent *event, QAbstractItemModel *model, const QStyleOptionViewItem &option, const QModelIndex &index) override; + void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override; + QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override; }; class UserListTWI : public QTreeWidgetItem @@ -129,42 +84,149 @@ public: BuddyList, IgnoreList }; + enum class Section + { + Buddy, + Online, + Ignore + }; private: + UserListManager *manager = nullptr; + UserAvatarProvider *avatarProvider = nullptr; + UserCardArtProvider *cardArtProvider = nullptr; + QMap cardArtParamsMap; + // ── Hover popup ─────────────────────────────────────────────────────────── + UserInfoPopup *userInfoPopup = nullptr; + QTimer *showPopupTimer = nullptr; + QTimer *hidePopupTimer = nullptr; + QString hoveredUser; + bool popupPinned = false; + bool bulkLoading = false; + bool hasUserInfoPopup = true; + std::function userFilter; + + /** + * Popup functions are anchored on the row, not the user name. In sectioned + * mode a user can own several rows (online + buddy), and the popup must + * follow the hovered/selected row rather than a lookup by name. + */ + void showPopupForUser(UserListTWI *item); + void hidePopup(bool immediate = false); + void positionPopup(UserListTWI *item); + void connectPopupSignals(); + /** True when @p widget is the tree, the popup or an open menu. */ + bool isPressInsideListUi(const QWidget *widget) const; + void clearSelectionAndClosePopup(); + bool isItemNearViewport(const UserListTWI *item) const; + void requestVisibleItemResources(); + + // Sectioned mode (single tree with inline dividers) + bool sectioned = false; + QList
sectionIds; + QMap sectionItems; + // One row per (section, user): a user that is online AND a buddy appears in + // both the "Online" and the "Buddies" sections, so the same user can own + // several rows, each hanging off its section's divider. + QMap> sectionUsers; + QSet
expandedSections; + void createSectionItems(); + QTreeWidgetItem *createSectionItem(Section section); + [[nodiscard]] QString sectionTitle(Section section) const; + void updateSectionDivider(Section section); + void handleSectionExpansion(QTreeWidgetItem *item, bool expanded); + void setExpandedProgrammatically(QTreeWidgetItem *item, bool expanded); + void handleOnlineChange(const ServerInfo_User &user); + void handleOnlineChangeLeft(const QString &userName); + void handleListAdd(Section section, const ServerInfo_User &user); + void handleListRemove(Section section, const QString &userName); + /** Creates or updates the row for @p user in @p section. */ + UserListTWI *ensureSectionMembership(Section section, const ServerInfo_User &user, bool online); + /** Removes and deletes the row for @p userName in @p section. */ + bool dropSectionMembership(Section section, const QString &userName); + /** Sorts, refilters and repaints after a sectioned mode mutation. */ + void finishSectionedMutation(); + void updateCardArtParams(const ServerInfo_User &user, const QString &userName); + void processUserInfo(Section section, const ServerInfo_User &user, bool online); + QMap users; TabSupervisor *tabSupervisor; AbstractClient *client; UserListType type; - QTreeWidget *userTree; + QTreeWidget *userTree = nullptr; UserListItemDelegate *itemDelegate; UserContextMenu *userContextMenu; int onlineCount; QString titleStr; + QString filterText; + bool showTitle = true; void updateCount(); + void applyFilter(); + void refreshPopupButtons(const QString &userName); private slots: void userClicked(QTreeWidgetItem *item, int column); + void refreshVisibleUserHeader(const QString &name); signals: void openMessageDialog(const QString &userName, bool focus); void addBuddy(const QString &userName); void removeBuddy(const QString &userName); void addIgnore(const QString &userName); void removeIgnore(const QString &userName); + void joinGameRequested(int gameId, int roomId, bool asSpectator); + void sectionExpanded(Section section, bool expanded); + /** Dialog mode: the user activated (Enter/double-click) the given row. */ + void userActivated(const QString &userName); + /** Dialog mode: the current row changed; empty string means no user row. */ + void currentUserChanged(const QString &userName); + /** The set of visible rows changed (filter, search or a live mutation). */ + void userListChanged(); public: UserListWidget(TabSupervisor *_tabSupervisor, AbstractClient *_client, UserListType _type, - QWidget *parent = nullptr); + QWidget *parent = nullptr, + bool hasUserInfoPopup = true); + ~UserListWidget() override; + void bind(UserListManager *mgr); + void applyDisplayMode(); + void beginBulkLoad(); + void endBulkLoad(); + bool eventFilter(QObject *obj, QEvent *event) override; void retranslateUi(); + void rebuild(); void processUserInfo(const ServerInfo_User &user, bool online); bool deleteUser(const QString &userName); void setUserOnline(const QString &userName, bool online); + void setFilterText(const QString &text); + void setShowTitle(bool showTitle); + void setSectioned(const QList
&ids); + void setSectionExpanded(Section section, bool expanded); + /** Dialog mode: rows that fail the predicate are never shown. */ + void setUserFilter(std::function filter) + { + userFilter = std::move(filter); + } + [[nodiscard]] int visibleUserRowCount() const; + [[nodiscard]] bool getHasUserInfoPopup() const + { + return hasUserInfoPopup; + } + [[nodiscard]] const QList
&getSectionIds() const + { + return sectionIds; + } [[nodiscard]] const QMap &getUsers() const { return users; } void showContextMenu(const QPoint &pos, const QModelIndex &index); void sortItems(); + void setGameInviteLinkProvider(std::function()> provider); + +protected: + void hideEvent(QHideEvent *e) override; + void showEvent(QShowEvent *e) override; }; #endif diff --git a/cockatrice/src/interface/widgets/settings_page/abstract_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/abstract_settings_page.cpp new file mode 100644 index 000000000..9a70e94c5 --- /dev/null +++ b/cockatrice/src/interface/widgets/settings_page/abstract_settings_page.cpp @@ -0,0 +1,196 @@ +#include "abstract_settings_page.h" + +#include "settings_search_model.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +/** + * @brief Recursively collects all widgets within a layout + * @param layout The layout to walk + * @param widgets Output list of (widget, containing layout) pairs + */ +static void collectWidgets(QLayout *layout, QList> &widgets) +{ + for (int i = 0; i < layout->count(); ++i) { + QLayoutItem *item = layout->itemAt(i); + if (!item) { + continue; + } + if (QWidget *widget = item->widget()) { + widgets.append({widget, layout}); + } else if (QLayout *subLayout = item->layout()) { + collectWidgets(subLayout, widgets); + } + } +} + +/** + * @brief Rejects QLabels that are not setting names + * + * HTML link labels, path values, and excessively long labels are filtered out. + */ +static bool isValidSettingLabel(const QLabel *label) +{ + const QString &text = label->text(); + if (Qt::mightBeRichText(text)) { + return false; + } + if (text.contains(QLatin1Char('/')) || text.contains(QLatin1Char('\\'))) { + return false; + } + if (text.size() > 60) { + return false; + } + return true; +} + +/** + * @brief Finds the control associated with a setting label + * + * Uses the explicit buddy if set, otherwise the widget in the cell (or slot) + * immediately following the label within the same layout. Returns nullptr when + * no obvious control is found. + */ +static QWidget *controlForLabel(QLabel *label, QLayout *containingLayout) +{ + if (QWidget *buddy = label->buddy()) { + return buddy; + } + + if (auto *grid = qobject_cast(containingLayout)) { + int index = grid->indexOf(label); + if (index != -1) { + int row = 0; + int column = 0; + int rowSpan = 1; + int columnSpan = 1; + grid->getItemPosition(index, &row, &column, &rowSpan, &columnSpan); + if (QLayoutItem *next = grid->itemAtPosition(row, column + columnSpan)) { + if (QWidget *nextWidget = next->widget()) { + return nextWidget; + } + } + } + } else { + int index = containingLayout->indexOf(label); + if (index != -1) { + for (int i = index + 1; i < containingLayout->count(); ++i) { + if (QLayoutItem *next = containingLayout->itemAt(i)) { + if (QWidget *nextWidget = next->widget()) { + return nextWidget; + } + } + } + } + } + + return nullptr; +} + +/** + * @brief Builds the extended search text for an entry + * + * Combines the group title, label, and any extra searchable text derived from + * the associated control (placeholder, prefix/suffix, tooltip). Combo values + * and numeric tooltips are excluded since they change at runtime and would + * make the search index stale. + */ +static QString buildFullSearchText(const QString &groupTitle, const QString &cleanLabel, QWidget *control) +{ + QStringList parts = {groupTitle, cleanLabel}; + if (control) { + if (auto *lineEdit = qobject_cast(control)) { + parts.append(lineEdit->placeholderText()); + } else if (auto *spinBox = qobject_cast(control)) { + parts.append(spinBox->prefix()); + parts.append(spinBox->suffix()); + } + if (!control->toolTip().isEmpty()) { + bool isNumeric = false; + control->toolTip().toInt(&isNumeric); + if (!isNumeric) { + parts.append(control->toolTip()); + } + } + } + parts.removeAll(QString()); + return parts.join(QLatin1Char(' ')); +} + +QList AbstractSettingsPage::getSearchEntries() +{ + return autoDetectSearchEntries(this, -1); +} + +QList AbstractSettingsPage::autoDetectSearchEntries(QWidget *page, int pageIndex) +{ + QList entries; + + const auto children = page->children(); + for (QObject *child : children) { + auto *groupBox = qobject_cast(child); + if (!groupBox) { + continue; + } + + QString groupTitle = groupBox->title(); + if (groupTitle.isEmpty()) { + continue; + } + + QLayout *groupLayout = groupBox->layout(); + if (!groupLayout) { + continue; + } + + QList> widgets; + collectWidgets(groupLayout, widgets); + + for (const auto &pair : widgets) { + QWidget *widget = pair.first; + QString label; + + auto *checkBox = qobject_cast(widget); + if (checkBox) { + label = checkBox->text(); + } else { + auto *labelWidget = qobject_cast(widget); + if (!labelWidget || labelWidget->text().isEmpty() || !isValidSettingLabel(labelWidget)) { + continue; + } + label = labelWidget->text(); + } + + if (label.isEmpty()) { + continue; + } + + // Strip accelerator markers (&) for search + QString cleanLabel = label; + cleanLabel.remove(QLatin1Char('&')); + + QWidget *control = widget; + if (auto *labelWidget = qobject_cast(widget)) { + control = controlForLabel(labelWidget, pair.second); + if (!control) { + continue; + } + } + + entries.append(SettingsSearchEntry{.pageIndex = pageIndex, + .groupTitle = groupTitle, + .widgetLabel = cleanLabel, + .fullSearchText = buildFullSearchText(groupTitle, cleanLabel, control), + .widget = control}); + } + } + + return entries; +} diff --git a/cockatrice/src/interface/widgets/settings_page/abstract_settings_page.h b/cockatrice/src/interface/widgets/settings_page/abstract_settings_page.h index 4cbf2d71a..3140f94f3 100644 --- a/cockatrice/src/interface/widgets/settings_page/abstract_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/abstract_settings_page.h @@ -1,16 +1,24 @@ #ifndef COCKATRICE_ABSTRACT_SETTINGS_PAGE_H #define COCKATRICE_ABSTRACT_SETTINGS_PAGE_H +#include #include #define WIKI_CUSTOM_PIC_URL "https://github.com/Cockatrice/Cockatrice/wiki/Custom-Picture-Download-URLs" #define WIKI_CUSTOM_SHORTCUTS "https://github.com/Cockatrice/Cockatrice/wiki/Custom-Keyboard-Shortcuts" #define WIKI_TRANSLATION_FAQ "https://github.com/Cockatrice/Cockatrice/wiki/Translation-FAQ" +struct SettingsSearchEntry; + class AbstractSettingsPage : public QWidget { + Q_OBJECT public: virtual void retranslateUi() = 0; + virtual QList getSearchEntries(); + +protected: + static QList autoDetectSearchEntries(QWidget *page, int pageIndex); }; #endif // COCKATRICE_ABSTRACT_SETTINGS_PAGE_H diff --git a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp index 2d32f3ce1..f161f0f19 100644 --- a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp @@ -1,17 +1,29 @@ #include "appearance_settings_page.h" #include "../../../client/settings/cache_settings.h" +#include "../../../client/settings/card_counter_settings.h" #include "../../client/settings/card_counter_settings.h" #include "../../palette_editor/palette_editor_dialog.h" #include "../dialogs/override_printing_warning.h" +#include "../general/home_tab_button_color.h" #include "../interface/theme_manager.h" #include "../interface/widgets/general/background_sources.h" +#include "../playmat/playmat_collection_dialog.h" +#include "../playmat/playmat_settings_dialog.h" +#include #include #include #include +#include #include +#include #include +#include +#include +#include +#include +#include AppearanceSettingsPage::AppearanceSettingsPage() { @@ -47,6 +59,19 @@ AppearanceSettingsPage::AppearanceSettingsPage() connect(&schemeCombo, &QComboBox::currentIndexChanged, this, [this] { themeManager->setColorScheme(schemeCombo.currentData().toString()); }); + // Qt widget style; "System" lets the application decide + styleCombo.addItem(tr("System"), QStringLiteral("System")); + for (const QString &key : QStyleFactory::keys()) { + styleCombo.addItem(key, key); + } + + const QString currentStyle = cfg.styleName; + const int styleSeedIdx = currentStyle.isEmpty() ? 0 : styleCombo.findData(currentStyle); + styleCombo.setCurrentIndex(styleSeedIdx >= 0 ? styleSeedIdx : 0); + + connect(&styleCombo, &QComboBox::currentIndexChanged, this, + [this] { themeManager->setStyleName(styleCombo.currentData().toString()); }); + connect(themeManager, &ThemeManager::themeChanged, this, [this, dirPath] { const QString newDir = themeManager->getAvailableThemes().value(SettingsCache::instance().getThemeName()); const ThemeConfig cfg = ThemeConfig::fromThemeDir(newDir); @@ -56,6 +81,12 @@ AppearanceSettingsPage::AppearanceSettingsPage() const int idx = schemeCombo.findData(current); schemeCombo.setCurrentIndex(idx >= 0 ? idx : 0); schemeCombo.blockSignals(false); + + styleCombo.blockSignals(true); + const QString currentStyle = cfg.styleName; + const int styleIdx = currentStyle.isEmpty() ? 0 : styleCombo.findData(currentStyle); + styleCombo.setCurrentIndex(styleIdx >= 0 ? styleIdx : 0); + styleCombo.blockSignals(false); }); connect(&editPaletteButton, &QPushButton::clicked, this, &AppearanceSettingsPage::editPalette); @@ -66,7 +97,9 @@ AppearanceSettingsPage::AppearanceSettingsPage() themeGrid->addWidget(&openThemeButton, 1, 1); themeGrid->addWidget(&schemeComboLabel, 2, 0); themeGrid->addWidget(&schemeCombo, 2, 1); - themeGrid->addWidget(&editPaletteButton, 3, 1); + themeGrid->addWidget(&styleComboLabel, 3, 0); + themeGrid->addWidget(&styleCombo, 3, 1); + themeGrid->addWidget(&editPaletteButton, 4, 1); themeGroupBox = new QGroupBox; themeGroupBox->setLayout(themeGrid); @@ -76,7 +109,7 @@ AppearanceSettingsPage::AppearanceSettingsPage() homeTabBackgroundSourceBox.addItem(QObject::tr(entry.trKey), QVariant::fromValue(entry.type)); } - QString homeTabBackgroundSource = SettingsCache::instance().getHomeTabBackgroundSource(); + QString homeTabBackgroundSource = settings.appearance().getHomeTabBackgroundSource(); int homeTabBackgroundSourceId = homeTabBackgroundSourceBox.findData(BackgroundSources::fromId(homeTabBackgroundSource)); if (homeTabBackgroundSourceId != -1) { @@ -85,19 +118,31 @@ AppearanceSettingsPage::AppearanceSettingsPage() connect(&homeTabBackgroundSourceBox, QOverload::of(&QComboBox::currentIndexChanged), this, [this]() { auto type = homeTabBackgroundSourceBox.currentData().value(); - SettingsCache::instance().setHomeTabBackgroundSource(BackgroundSources::toId(type)); + SettingsCache::instance().appearance().setHomeTabBackgroundSource(BackgroundSources::toId(type)); updateHomeTabSettingsVisibility(); }); homeTabBackgroundShuffleFrequencySpinBox.setRange(0, 3600); homeTabBackgroundShuffleFrequencySpinBox.setSuffix(tr(" seconds")); - homeTabBackgroundShuffleFrequencySpinBox.setValue(SettingsCache::instance().getHomeTabBackgroundShuffleFrequency()); - connect(&homeTabBackgroundShuffleFrequencySpinBox, qOverload(&QSpinBox::valueChanged), - &SettingsCache::instance(), &SettingsCache::setHomeTabBackgroundShuffleFrequency); + homeTabBackgroundShuffleFrequencySpinBox.setValue(settings.appearance().getHomeTabBackgroundShuffleFrequency()); + connect(&homeTabBackgroundShuffleFrequencySpinBox, qOverload(&QSpinBox::valueChanged), &settings.appearance(), + &AppearanceSettings::setHomeTabBackgroundShuffleFrequency); - homeTabDisplayCardNameCheckBox.setChecked(settings.getHomeTabDisplayCardName()); - connect(&homeTabDisplayCardNameCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings, - &SettingsCache::setHomeTabDisplayCardName); + homeTabDisplayCardNameCheckBox.setChecked(settings.appearance().getHomeTabDisplayCardName()); + connect(&homeTabDisplayCardNameCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.appearance(), + &AppearanceSettings::setHomeTabDisplayCardName); + + homeTabBackgroundDimCheckBox.setChecked(settings.appearance().getHomeTabBackgroundDim()); + connect(&homeTabBackgroundDimCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.appearance(), + &AppearanceSettings::setHomeTabBackgroundDim); + + for (const auto &entry : HomeTabButtonColor::all()) { + homeTabButtonColorSourceBox.addItem(QObject::tr(entry.trKey)); + } + + homeTabButtonColorSourceBox.setCurrentIndex(settings.appearance().getHomeTabButtonColorSourceIndex()); + connect(&homeTabButtonColorSourceBox, QOverload::of(&QComboBox::currentIndexChanged), &settings.appearance(), + &AppearanceSettings::setHomeTabButtonColorSourceIndex); updateHomeTabSettingsVisibility(); @@ -107,17 +152,72 @@ AppearanceSettingsPage::AppearanceSettingsPage() homeTabGrid->addWidget(&homeTabBackgroundShuffleFrequencyLabel, 1, 0); homeTabGrid->addWidget(&homeTabBackgroundShuffleFrequencySpinBox, 1, 1); homeTabGrid->addWidget(&homeTabDisplayCardNameCheckBox, 2, 0, 1, 2); + homeTabGrid->addWidget(&homeTabButtonColorSourceLabel, 3, 0); + homeTabGrid->addWidget(&homeTabButtonColorSourceBox, 3, 1); + homeTabGrid->addWidget(&homeTabBackgroundDimCheckBox, 4, 0, 1, 2); homeTabGroupBox = new QGroupBox; homeTabGroupBox->setLayout(homeTabGrid); + // Playmat settings + playmatVisibilityCombo.addItem(tr("Show all playmats"), PlaymatVisibilityAll); + playmatVisibilityCombo.addItem(tr("Show own playmat only"), PlaymatVisibilityOwnOnly); + playmatVisibilityCombo.addItem(tr("Don't use playmats"), PlaymatVisibilityNone); + int visIdx = playmatVisibilityCombo.findData(settings.userInterface().getPlaymatVisibility()); + if (visIdx >= 0) { + playmatVisibilityCombo.setCurrentIndex(visIdx); + } + connect(&playmatVisibilityCombo, qOverload(&QComboBox::currentIndexChanged), this, [this](int index) { + SettingsCache::instance().userInterface().setPlaymatVisibility(playmatVisibilityCombo.itemData(index).toInt()); + }); + playmatVisibilityLabel.setBuddy(&playmatVisibilityCombo); + + // Playmat mode: Override / Fallback / Deck-only + playmatModeCombo.addItem(tr("Override deck playmat"), PlaymatModeOverrideDeck); + playmatModeCombo.addItem(tr("Fallback if deck has none"), PlaymatModeFallback); + playmatModeCombo.addItem(tr("Deck only, ignore collection"), PlaymatModeDeckOnly); + int modeIdx = playmatModeCombo.findData(settings.userInterface().getPlaymatMode()); + if (modeIdx >= 0) { + playmatModeCombo.setCurrentIndex(modeIdx); + } + connect(&playmatModeCombo, qOverload(&QComboBox::currentIndexChanged), this, [this](int index) { + SettingsCache::instance().userInterface().setPlaymatMode(playmatModeCombo.itemData(index).toInt()); + }); + playmatModeLabel.setBuddy(&playmatModeCombo); + + // User-level playmat settings: fallback collection. + connect(&playmatDefaultEditButton, &QPushButton::clicked, this, + &AppearanceSettingsPage::openPlaymatCollectionDialog); + + auto *playmatGrid = new QGridLayout; + playmatGrid->addWidget(&playmatVisibilityLabel, 0, 0, 1, 1); + playmatGrid->addWidget(&playmatVisibilityCombo, 0, 1, 1, 1); + playmatGrid->addWidget(&playmatModeLabel, 1, 0, 1, 1); + playmatGrid->addWidget(&playmatModeCombo, 1, 1, 1, 1); + playmatGrid->addWidget(&playmatDefaultLabel, 2, 0, 1, 1); + playmatGrid->addWidget(&playmatDefaultEditButton, 2, 1, 1, 1); + + playmatGroupBox = new QGroupBox; + playmatGroupBox->setLayout(playmatGrid); + + // Styling settings + styleUserListCheckBox.setChecked(settings.appearance().getStyleUserList()); + connect(&styleUserListCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.appearance(), + &AppearanceSettings::setStyleUserList); + + auto stylingTabGrid = new QGridLayout; + stylingTabGrid->addWidget(&styleUserListCheckBox, 0, 0, 1, 2); + + stylingGroupBox = new QGroupBox; + stylingGroupBox->setLayout(stylingTabGrid); + // Menu settings - showShortcutsCheckBox.setChecked(settings.getShowShortcuts()); + showShortcutsCheckBox.setChecked(settings.userInterface().getShowShortcuts()); connect(&showShortcutsCheckBox, &QCheckBox::QT_STATE_CHANGED, this, &AppearanceSettingsPage::showShortcutsChanged); - showGameSelectorFilterToolbarCheckBox.setChecked(settings.getShowGameSelectorFilterToolbar()); - connect(&showGameSelectorFilterToolbarCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings, - &SettingsCache::setShowGameSelectorFilterToolbar); + showGameSelectorFilterToolbarCheckBox.setChecked(settings.userInterface().getShowGameSelectorFilterToolbar()); + connect(&showGameSelectorFilterToolbarCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.userInterface(), + &InterfaceSettings::setShowGameSelectorFilterToolbar); auto *menuGrid = new QGridLayout; menuGrid->addWidget(&showShortcutsCheckBox, 0, 0); @@ -127,13 +227,14 @@ AppearanceSettingsPage::AppearanceSettingsPage() menuGroupBox->setLayout(menuGrid); // Printings settings - overrideAllCardArtWithPersonalPreferenceCheckBox.setChecked(settings.getOverrideAllCardArtWithPersonalPreference()); + overrideAllCardArtWithPersonalPreferenceCheckBox.setChecked( + settings.cardsDisplay().getOverrideAllCardArtWithPersonalPreference()); connect(&overrideAllCardArtWithPersonalPreferenceCheckBox, &QCheckBox::QT_STATE_CHANGED, this, &AppearanceSettingsPage::overrideAllCardArtWithPersonalPreferenceToggled); - bumpSetsWithCardsInDeckToTopCheckBox.setChecked(settings.getBumpSetsWithCardsInDeckToTop()); - connect(&bumpSetsWithCardsInDeckToTopCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings, - &SettingsCache::setBumpSetsWithCardsInDeckToTop); + bumpSetsWithCardsInDeckToTopCheckBox.setChecked(settings.cardsDisplay().getBumpSetsWithCardsInDeckToTop()); + connect(&bumpSetsWithCardsInDeckToTopCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.cardsDisplay(), + &CardsDisplaySettings::setBumpSetsWithCardsInDeckToTop); auto *printingsGrid = new QGridLayout; printingsGrid->addWidget(&overrideAllCardArtWithPersonalPreferenceCheckBox, 0, 0, 1, 2); @@ -143,22 +244,25 @@ AppearanceSettingsPage::AppearanceSettingsPage() printingsGroupBox->setLayout(printingsGrid); // Card rendering - displayCardNamesCheckBox.setChecked(settings.getDisplayCardNames()); - connect(&displayCardNamesCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings, &SettingsCache::setDisplayCardNames); + displayCardNamesCheckBox.setChecked(settings.cardsDisplay().getDisplayCardNames()); + connect(&displayCardNamesCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.cardsDisplay(), + &CardsDisplaySettings::setDisplayCardNames); - autoRotateSidewaysLayoutCardsCheckBox.setChecked(settings.getAutoRotateSidewaysLayoutCards()); - connect(&autoRotateSidewaysLayoutCardsCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings, - &SettingsCache::setAutoRotateSidewaysLayoutCards); + autoRotateSidewaysLayoutCardsCheckBox.setChecked(settings.cardsDisplay().getAutoRotateSidewaysLayoutCards()); + connect(&autoRotateSidewaysLayoutCardsCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.cardsDisplay(), + &CardsDisplaySettings::setAutoRotateSidewaysLayoutCards); - cardScalingCheckBox.setChecked(settings.getScaleCards()); - connect(&cardScalingCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings, &SettingsCache::setCardScaling); + cardScalingCheckBox.setChecked(settings.cardsDisplay().getScaleCards()); + connect(&cardScalingCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.cardsDisplay(), + &CardsDisplaySettings::setCardScaling); - roundCardCornersCheckBox.setChecked(settings.getRoundCardCorners()); - connect(&roundCardCornersCheckBox, &QAbstractButton::toggled, &settings, &SettingsCache::setRoundCardCorners); + roundCardCornersCheckBox.setChecked(settings.cardsDisplay().getRoundCardCorners()); + connect(&roundCardCornersCheckBox, &QAbstractButton::toggled, &settings.cardsDisplay(), + &CardsDisplaySettings::setRoundCardCorners); - connect(&maxFontSizeForCardsEdit, qOverload(&QSpinBox::valueChanged), &settings, - &SettingsCache::setMaxFontSize); - maxFontSizeForCardsEdit.setValue(settings.getMaxFontSize()); + connect(&maxFontSizeForCardsEdit, qOverload(&QSpinBox::valueChanged), &settings.appearance(), + &AppearanceSettings::setMaxFontSize); + maxFontSizeForCardsEdit.setValue(settings.appearance().getMaxFontSize()); maxFontSizeForCardsLabel.setBuddy(&maxFontSizeForCardsEdit); maxFontSizeForCardsEdit.setMinimum(9); maxFontSizeForCardsEdit.setMaximum(100); @@ -175,18 +279,18 @@ AppearanceSettingsPage::AppearanceSettingsPage() cardsGroupBox->setLayout(cardsGrid); // Card layout - verticalCardOverlapPercentBox.setValue(settings.getStackCardOverlapPercent()); + verticalCardOverlapPercentBox.setValue(settings.cardsDisplay().getStackCardOverlapPercent()); verticalCardOverlapPercentBox.setRange(0, 80); - connect(&verticalCardOverlapPercentBox, qOverload(&QSpinBox::valueChanged), &settings, - &SettingsCache::setStackCardOverlapPercent); + connect(&verticalCardOverlapPercentBox, qOverload(&QSpinBox::valueChanged), &settings.cardsDisplay(), + &CardsDisplaySettings::setStackCardOverlapPercent); cardViewInitialRowsMaxBox.setRange(1, 999); - cardViewInitialRowsMaxBox.setValue(SettingsCache::instance().getCardViewInitialRowsMax()); + cardViewInitialRowsMaxBox.setValue(SettingsCache::instance().userInterface().getCardViewInitialRowsMax()); connect(&cardViewInitialRowsMaxBox, qOverload(&QSpinBox::valueChanged), this, &AppearanceSettingsPage::cardViewInitialRowsMaxChanged); cardViewExpandedRowsMaxBox.setRange(1, 999); - cardViewExpandedRowsMaxBox.setValue(SettingsCache::instance().getCardViewExpandedRowsMax()); + cardViewExpandedRowsMaxBox.setValue(SettingsCache::instance().userInterface().getCardViewExpandedRowsMax()); connect(&cardViewExpandedRowsMaxBox, qOverload(&QSpinBox::valueChanged), this, &AppearanceSettingsPage::cardViewExpandedRowsMaxChanged); @@ -202,7 +306,6 @@ AppearanceSettingsPage::AppearanceSettingsPage() cardLayoutGroupBox->setLayout(cardLayoutGrid); // Card counter colors - auto *cardCounterColorsLayout = new QGridLayout; cardCounterColorsLayout->setColumnStretch(1, 1); cardCounterColorsLayout->setColumnStretch(3, 1); @@ -248,11 +351,13 @@ AppearanceSettingsPage::AppearanceSettingsPage() cardCountersGroupBox->setLayout(cardCountersLayout); // Hand layout - horizontalHandCheckBox.setChecked(settings.getHorizontalHand()); - connect(&horizontalHandCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings, &SettingsCache::setHorizontalHand); + horizontalHandCheckBox.setChecked(settings.userInterface().getHorizontalHand()); + connect(&horizontalHandCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.userInterface(), + &InterfaceSettings::setHorizontalHand); - leftJustifiedHandCheckBox.setChecked(settings.getLeftJustified()); - connect(&leftJustifiedHandCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings, &SettingsCache::setLeftJustified); + leftJustifiedHandCheckBox.setChecked(settings.userInterface().getLeftJustified()); + connect(&leftJustifiedHandCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.userInterface(), + &InterfaceSettings::setLeftJustified); auto *handGrid = new QGridLayout; handGrid->addWidget(&horizontalHandCheckBox, 0, 0, 1, 2); @@ -262,14 +367,14 @@ AppearanceSettingsPage::AppearanceSettingsPage() handGroupBox->setLayout(handGrid); // table grid layout - invertVerticalCoordinateCheckBox.setChecked(settings.getInvertVerticalCoordinate()); - connect(&invertVerticalCoordinateCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings, - &SettingsCache::setInvertVerticalCoordinate); + invertVerticalCoordinateCheckBox.setChecked(settings.userInterface().getInvertVerticalCoordinate()); + connect(&invertVerticalCoordinateCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.userInterface(), + &InterfaceSettings::setInvertVerticalCoordinate); minPlayersForMultiColumnLayoutEdit.setMinimum(2); - minPlayersForMultiColumnLayoutEdit.setValue(settings.getMinPlayersForMultiColumnLayout()); - connect(&minPlayersForMultiColumnLayoutEdit, qOverload(&QSpinBox::valueChanged), &settings, - &SettingsCache::setMinPlayersForMultiColumnLayout); + minPlayersForMultiColumnLayoutEdit.setValue(settings.userInterface().getMinPlayersForMultiColumnLayout()); + connect(&minPlayersForMultiColumnLayoutEdit, qOverload(&QSpinBox::valueChanged), &settings.userInterface(), + &InterfaceSettings::setMinPlayersForMultiColumnLayout); minPlayersForMultiColumnLayoutLabel.setBuddy(&minPlayersForMultiColumnLayoutEdit); auto *tableGrid = new QGridLayout; @@ -284,6 +389,8 @@ AppearanceSettingsPage::AppearanceSettingsPage() auto *mainLayout = new QVBoxLayout; mainLayout->addWidget(themeGroupBox); mainLayout->addWidget(homeTabGroupBox); + mainLayout->addWidget(playmatGroupBox); + mainLayout->addWidget(stylingGroupBox); mainLayout->addWidget(menuGroupBox); mainLayout->addWidget(printingsGroupBox); mainLayout->addWidget(cardsGroupBox); @@ -295,7 +402,8 @@ AppearanceSettingsPage::AppearanceSettingsPage() setLayout(mainLayout); - connect(&SettingsCache::instance(), &SettingsCache::langChanged, this, &AppearanceSettingsPage::retranslateUi); + connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, + &AppearanceSettingsPage::retranslateUi); retranslateUi(); } @@ -309,7 +417,7 @@ void AppearanceSettingsPage::themeBoxChanged(int index) void AppearanceSettingsPage::openThemeLocation() { - QString dir = SettingsCache::instance().getThemesPath(); + QString dir = SettingsCache::instance().paths().getThemesPath(); QDir dirDir = dir; dirDir.cdUp(); // open if dir exists, create if parent dir does exist @@ -328,8 +436,8 @@ void AppearanceSettingsPage::editPalette() void AppearanceSettingsPage::updateHomeTabSettingsVisibility() { - bool visible = - SettingsCache::instance().getHomeTabBackgroundSource() != BackgroundSources::toId(BackgroundSources::Theme); + QString sourceId = SettingsCache::instance().appearance().getHomeTabBackgroundSource(); + bool visible = BackgroundSources::fromId(sourceId) != BackgroundSources::Theme; homeTabBackgroundShuffleFrequencyLabel.setVisible(visible); homeTabBackgroundShuffleFrequencySpinBox.setVisible(visible); @@ -338,7 +446,7 @@ void AppearanceSettingsPage::updateHomeTabSettingsVisibility() void AppearanceSettingsPage::showShortcutsChanged(QT_STATE_CHANGED_T value) { - SettingsCache::instance().setShowShortcuts(value); + SettingsCache::instance().userInterface().setShowShortcuts(value); qApp->setAttribute(Qt::AA_DontShowShortcutsInContextMenus, value == 0); // 0 = unchecked } @@ -365,7 +473,7 @@ void AppearanceSettingsPage::overrideAllCardArtWithPersonalPreferenceToggled(QT_ */ void AppearanceSettingsPage::cardViewInitialRowsMaxChanged(int value) { - SettingsCache::instance().setCardViewInitialRowsMax(value); + SettingsCache::instance().userInterface().setCardViewInitialRowsMax(value); if (cardViewExpandedRowsMaxBox.value() < value) { cardViewExpandedRowsMaxBox.setValue(value); } @@ -378,18 +486,26 @@ void AppearanceSettingsPage::cardViewInitialRowsMaxChanged(int value) */ void AppearanceSettingsPage::cardViewExpandedRowsMaxChanged(int value) { - SettingsCache::instance().setCardViewExpandedRowsMax(value); + SettingsCache::instance().userInterface().setCardViewExpandedRowsMax(value); if (cardViewInitialRowsMaxBox.value() > value) { cardViewInitialRowsMaxBox.setValue(value); } } +void AppearanceSettingsPage::openPlaymatCollectionDialog() +{ + PlaymatCollectionDialog dialog(this); + dialog.exec(); +} + void AppearanceSettingsPage::retranslateUi() { themeGroupBox->setTitle(tr("Theme settings")); themeLabel.setText(tr("Current theme:")); openThemeButton.setText(tr("Open themes folder")); schemeComboLabel.setText(tr("Active theme palette:")); + styleComboLabel.setText(tr("Active theme style:")); + styleCombo.setToolTip(tr("Qt widget style saved to this theme (\"Default\" lets the application decide)")); editPaletteButton.setText(tr("Edit theme palette")); homeTabGroupBox->setTitle(tr("Home tab settings")); @@ -397,6 +513,21 @@ void AppearanceSettingsPage::retranslateUi() homeTabBackgroundShuffleFrequencyLabel.setText(tr("Home tab background shuffle frequency:")); homeTabBackgroundShuffleFrequencySpinBox.setSpecialValueText(tr("Disabled")); homeTabDisplayCardNameCheckBox.setText(tr("Display card name of background in bottom right")); + homeTabBackgroundDimCheckBox.setText(tr("Dim the home tab background")); + homeTabBackgroundDimCheckBox.setToolTip( + tr("Draw a translucent overlay over the home tab background so buttons and text stand out")); + homeTabButtonColorSourceLabel.setText(tr("Home tab button color:")); + homeTabButtonColorSourceBox.setToolTip( + tr("Use the theme's identity accent colors, or extract colors from the background image")); + + playmatGroupBox->setTitle(tr("Playmat settings")); + playmatVisibilityLabel.setText(tr("Playmat visibility:")); + playmatModeLabel.setText(tr("Default collection behavior:")); + playmatDefaultLabel.setText(tr("Default playmat collection:")); + playmatDefaultEditButton.setText(tr("Edit...")); + + stylingGroupBox->setTitle(tr("Styling settings")); + styleUserListCheckBox.setText(tr("Style user list")); menuGroupBox->setTitle(tr("Menu settings")); showShortcutsCheckBox.setText(tr("Show keyboard shortcuts in right-click menus")); @@ -437,4 +568,4 @@ void AppearanceSettingsPage::retranslateUi() tableGroupBox->setTitle(tr("Table grid layout")); invertVerticalCoordinateCheckBox.setText(tr("Invert vertical coordinate")); minPlayersForMultiColumnLayoutLabel.setText(tr("Minimum player count for multi-column layout:")); -} \ No newline at end of file +} diff --git a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.h b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.h index 9ed27be4d..bec4cd72f 100644 --- a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.h @@ -24,6 +24,7 @@ private slots: void cardViewInitialRowsMaxChanged(int value); void cardViewExpandedRowsMaxChanged(int value); + void openPlaymatCollectionDialog(); private: QLabel themeLabel; @@ -31,43 +32,68 @@ private: QPushButton openThemeButton; QLabel schemeComboLabel; QComboBox schemeCombo; + QLabel styleComboLabel; + QComboBox styleCombo; QPushButton editPaletteButton; + QLabel homeTabBackgroundSourceLabel; QComboBox homeTabBackgroundSourceBox; QLabel homeTabBackgroundShuffleFrequencyLabel; QSpinBox homeTabBackgroundShuffleFrequencySpinBox; QCheckBox homeTabDisplayCardNameCheckBox; - QLabel minPlayersForMultiColumnLayoutLabel; - QLabel maxFontSizeForCardsLabel; + QCheckBox homeTabBackgroundDimCheckBox; + QLabel homeTabButtonColorSourceLabel; + QComboBox homeTabButtonColorSourceBox; + + QLabel playmatVisibilityLabel; + QComboBox playmatVisibilityCombo; + QLabel playmatModeLabel; + QComboBox playmatModeCombo; + QLabel playmatDefaultLabel; + QPushButton playmatDefaultEditButton; + + QCheckBox styleUserListCheckBox; + QCheckBox showShortcutsCheckBox; QCheckBox showGameSelectorFilterToolbarCheckBox; + QCheckBox overrideAllCardArtWithPersonalPreferenceCheckBox; QCheckBox bumpSetsWithCardsInDeckToTopCheckBox; + QCheckBox displayCardNamesCheckBox; QCheckBox autoRotateSidewaysLayoutCardsCheckBox; QCheckBox cardScalingCheckBox; QCheckBox roundCardCornersCheckBox; + QLabel maxFontSizeForCardsLabel; + QSpinBox maxFontSizeForCardsEdit; + QLabel verticalCardOverlapPercentLabel; QSpinBox verticalCardOverlapPercentBox; QLabel cardViewInitialRowsMaxLabel; QSpinBox cardViewInitialRowsMaxBox; QLabel cardViewExpandedRowsMaxLabel; QSpinBox cardViewExpandedRowsMaxBox; + + QList cardCounterNames; + QCheckBox horizontalHandCheckBox; QCheckBox leftJustifiedHandCheckBox; + QCheckBox invertVerticalCoordinateCheckBox; + QLabel minPlayersForMultiColumnLayoutLabel; + QSpinBox minPlayersForMultiColumnLayoutEdit; + QGroupBox *themeGroupBox; QGroupBox *homeTabGroupBox; + QGroupBox *playmatGroupBox; + QGroupBox *stylingGroupBox; QGroupBox *menuGroupBox; QGroupBox *printingsGroupBox; QGroupBox *cardsGroupBox; QGroupBox *cardLayoutGroupBox; + QGroupBox *cardCountersGroupBox; QGroupBox *handGroupBox; QGroupBox *tableGroupBox; - QGroupBox *cardCountersGroupBox; - QList cardCounterNames; - QSpinBox minPlayersForMultiColumnLayoutEdit; - QSpinBox maxFontSizeForCardsEdit; public: AppearanceSettingsPage(); diff --git a/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp index b7d699ebc..f3eac05b8 100644 --- a/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp @@ -1,6 +1,7 @@ #include "deck_editor_settings_page.h" #include "../../../client/settings/cache_settings.h" +#include "../../pixel_map_generator.h" #include "update/card_spoiler/spoiler_background_updater.h" #include @@ -10,12 +11,16 @@ #include #include #include +#include +#include +#include +#include DeckEditorSettingsPage::DeckEditorSettingsPage() { - picDownloadCheckBox.setChecked(SettingsCache::instance().getPicDownload()); - connect(&picDownloadCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setPicDownload); + picDownloadCheckBox.setChecked(SettingsCache::instance().downloads().getPicDownload()); + connect(&picDownloadCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().downloads(), + &DownloadSettings::setPicDownload); urlLinkLabel.setTextInteractionFlags(Qt::LinksAccessibleByMouse); urlLinkLabel.setOpenExternalLinks(true); @@ -25,7 +30,7 @@ DeckEditorSettingsPage::DeckEditorSettingsPage() auto *lpGeneralGrid = new QGridLayout; auto *lpSpoilerGrid = new QGridLayout; - mcDownloadSpoilersCheckBox.setChecked(SettingsCache::instance().getDownloadSpoilersStatus()); + mcDownloadSpoilersCheckBox.setChecked(SettingsCache::instance().downloads().getDownloadSpoilersStatus()); mpSpoilerSavePathLineEdit = new QLineEdit(SettingsCache::instance().getSpoilerCardDatabasePath()); mpSpoilerSavePathLineEdit->setReadOnly(true); @@ -49,15 +54,15 @@ DeckEditorSettingsPage::DeckEditorSettingsPage() urlList->addItems(SettingsCache::instance().downloads().getAllURLs()); aAdd = new QAction(this); - aAdd->setIcon(QPixmap("theme:icons/increment")); + aAdd->setIcon(themePixmap(QStringLiteral("icons/increment"))); connect(aAdd, &QAction::triggered, this, &DeckEditorSettingsPage::actAddURL); aEdit = new QAction(this); - aEdit->setIcon(QPixmap("theme:icons/pencil")); + aEdit->setIcon(themePixmap(QStringLiteral("icons/pencil"))); connect(aEdit, &QAction::triggered, this, &DeckEditorSettingsPage::actEditURL); aRemove = new QAction(this); - aRemove->setIcon(QPixmap("theme:icons/decrement")); + aRemove->setIcon(themePixmap(QStringLiteral("icons/decrement"))); connect(aRemove, &QAction::triggered, this, &DeckEditorSettingsPage::actRemoveURL); auto *urlToolBar = new QToolBar; @@ -87,8 +92,8 @@ DeckEditorSettingsPage::DeckEditorSettingsPage() lpSpoilerGrid->addWidget(&infoOnSpoilersLabel, 3, 0, 1, 3, Qt::AlignTop); // On a change to the checkbox, hide/un-hide the other fields - connect(&mcDownloadSpoilersCheckBox, &QCheckBox::toggled, &SettingsCache::instance(), - &SettingsCache::setDownloadSpoilerStatus); + connect(&mcDownloadSpoilersCheckBox, &QCheckBox::toggled, &SettingsCache::instance().downloads(), + &DownloadSettings::setDownloadSpoilerStatus); connect(&mcDownloadSpoilersCheckBox, &QCheckBox::toggled, this, &DeckEditorSettingsPage::setSpoilersEnabled); mpGeneralGroupBox = new QGroupBox; @@ -103,7 +108,8 @@ DeckEditorSettingsPage::DeckEditorSettingsPage() setLayout(lpMainLayout); - connect(&SettingsCache::instance(), &SettingsCache::langChanged, this, &DeckEditorSettingsPage::retranslateUi); + connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, + &DeckEditorSettingsPage::retranslateUi); retranslateUi(); } @@ -203,7 +209,7 @@ void DeckEditorSettingsPage::spoilerPathButtonClicked() } mpSpoilerSavePathLineEdit->setText(lsPath + "/spoiler.xml"); - SettingsCache::instance().setSpoilerDatabasePath(lsPath + "/spoiler.xml"); + SettingsCache::instance().paths().setSpoilerDatabasePath(lsPath + "/spoiler.xml"); } void DeckEditorSettingsPage::setSpoilersEnabled(bool anInput) diff --git a/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp index 9faf67578..36436f8a3 100644 --- a/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp @@ -1,14 +1,26 @@ #include "general_settings_page.h" #include "../../../client/settings/cache_settings.h" +#include "../interface/card_picture_loader/card_picture_loader.h" #include "../main.h" +#include "../server/user/user_info_connection.h" #include "update/client/release_channel.h" #include +#include #include #include #include +#include #include +#include +#include +#include +#include +#include +#include +#include +#include enum startupCardUpdateCheckBehaviorIndex { @@ -40,27 +52,45 @@ GeneralSettingsPage::GeneralSettingsPage() connect(&languageBox, qOverload(&QComboBox::currentIndexChanged), this, &GeneralSettingsPage::languageBoxChanged); + // card text & images language, independent of the UI language + cardLanguageBox.addItem(tr("English"), "en"); + for (const QString &code : CardLocalization::supportedLanguages()) { + cardLanguageBox.addItem(CardLocalization::languageDisplayName(code), code); + } + const int cardLangIndex = cardLanguageBox.findData(SettingsCache::instance().cardsDisplay().getCardLang()); + cardLanguageBox.setCurrentIndex(cardLangIndex < 0 ? 0 : cardLangIndex); + + connect(&cardLanguageBox, qOverload(&QComboBox::currentIndexChanged), this, + &GeneralSettingsPage::cardLanguageBoxChanged); + auto *languageGrid = new QGridLayout; languageGrid->addWidget(&languageLabel, 0, 0); languageGrid->addWidget(&languageBox, 0, 1); - languageGrid->addWidget(&advertiseTranslationPageLabel, 1, 1, Qt::AlignRight); + languageGrid->addWidget(&cardLanguageLabel, 1, 0); + languageGrid->addWidget(&cardLanguageBox, 1, 1); + languageGrid->addWidget(&cardLanguageNoteLabel, 2, 1); + languageGrid->addWidget(&advertiseTranslationPageLabel, 3, 1, Qt::AlignRight); + + cardLanguageNoteLabel.setWordWrap(true); + cardLanguageNoteLabel.setAlignment(Qt::AlignLeft | Qt::AlignVCenter); languageGroupBox = new QGroupBox; languageGroupBox->setLayout(languageGrid); // version settings SettingsCache &settings = SettingsCache::instance(); - startupUpdateCheckCheckBox.setChecked(settings.getCheckUpdatesOnStartup()); + startupUpdateCheckCheckBox.setChecked(settings.updates().getCheckUpdatesOnStartup()); - connect(&startupUpdateCheckCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings, - &SettingsCache::setCheckUpdatesOnStartup); + connect(&startupUpdateCheckCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.updates(), + &UpdatesSettings::setCheckUpdatesOnStartup); updateNotificationCheckBox.setChecked(settings.getNotifyAboutUpdates()); - connect(&updateNotificationCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings, &SettingsCache::setNotifyAboutUpdate); + connect(&updateNotificationCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.updates(), + &UpdatesSettings::setNotifyAboutUpdates); - connect(&newVersionOracleCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings, - &SettingsCache::setNotifyAboutNewVersion); + connect(&newVersionOracleCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.updates(), + &UpdatesSettings::setNotifyAboutNewVersion); auto *versionGrid = new QGridLayout; versionGrid->addWidget(&updateReleaseChannelLabel, 0, 0); @@ -76,9 +106,9 @@ GeneralSettingsPage::GeneralSettingsPage() startupCardUpdateCheckBehaviorSelector.addItem(""); // these will be set in retranslateUI startupCardUpdateCheckBehaviorSelector.addItem(""); startupCardUpdateCheckBehaviorSelector.addItem(""); - if (SettingsCache::instance().getStartupCardUpdateCheckPromptForUpdate()) { + if (SettingsCache::instance().updates().getStartupCardUpdateCheckPromptForUpdate()) { startupCardUpdateCheckBehaviorSelector.setCurrentIndex(startupCardUpdateCheckBehaviorIndexPrompt); - } else if (SettingsCache::instance().getStartupCardUpdateCheckAlwaysUpdate()) { + } else if (SettingsCache::instance().updates().getStartupCardUpdateCheckAlwaysUpdate()) { startupCardUpdateCheckBehaviorSelector.setCurrentIndex(startupCardUpdateCheckBehaviorIndexAlways); } else { startupCardUpdateCheckBehaviorSelector.setCurrentIndex(startupCardUpdateCheckBehaviorIndexNone); @@ -86,20 +116,20 @@ GeneralSettingsPage::GeneralSettingsPage() connect(&startupCardUpdateCheckBehaviorSelector, QOverload::of(&QComboBox::currentIndexChanged), this, [](int index) { - SettingsCache::instance().setStartupCardUpdateCheckPromptForUpdate( + SettingsCache::instance().updates().setStartupCardUpdateCheckPromptForUpdate( index == startupCardUpdateCheckBehaviorIndexPrompt); - SettingsCache::instance().setStartupCardUpdateCheckAlwaysUpdate( + SettingsCache::instance().updates().setStartupCardUpdateCheckAlwaysUpdate( index == startupCardUpdateCheckBehaviorIndexAlways); }); cardUpdateCheckIntervalSpinBox.setMinimum(1); cardUpdateCheckIntervalSpinBox.setMaximum(30); - cardUpdateCheckIntervalSpinBox.setValue(settings.getCardUpdateCheckInterval()); + cardUpdateCheckIntervalSpinBox.setValue(settings.updates().getCardUpdateCheckInterval()); - connect(&cardUpdateCheckIntervalSpinBox, qOverload(&QSpinBox::valueChanged), &settings, - &SettingsCache::setCardUpdateCheckInterval); + connect(&cardUpdateCheckIntervalSpinBox, qOverload(&QSpinBox::valueChanged), &settings.updates(), + &UpdatesSettings::setCardUpdateCheckInterval); - newVersionOracleCheckBox.setChecked(settings.getNotifyAboutNewVersion()); + newVersionOracleCheckBox.setChecked(settings.updates().getNotifyAboutNewVersion()); auto *cardDatabaseGrid = new QGridLayout; cardDatabaseGrid->addWidget(&startupCardUpdateCheckBehaviorLabel, 0, 0); @@ -112,33 +142,86 @@ GeneralSettingsPage::GeneralSettingsPage() cardDatabaseGroupBox->setLayout(cardDatabaseGrid); // startup settings - showTipsOnStartup.setChecked(settings.getShowTipsOnStartup()); + showTipsOnStartup.setChecked(settings.personal().getShowTipsOnStartup()); - connect(&showTipsOnStartup, &QCheckBox::clicked, &settings, &SettingsCache::setShowTipsOnStartup); + connect(&showTipsOnStartup, &QCheckBox::clicked, &settings.personal(), &PersonalSettings::setShowTipsOnStartup); + + // startup destination + for (int i = 0; i < 8; ++i) { + startupTabSelector.addItem(""); // texts set in retranslateUi + } + startupTabSelector.setCurrentIndex(settings.tabs().getStartupTabIndex()); + + connect(&startupTabSelector, qOverload(&QComboBox::currentIndexChanged), &settings.tabs(), + &TabsSettings::setStartupTabIndex); + connect(&startupTabSelector, qOverload(&QComboBox::currentIndexChanged), this, + &GeneralSettingsPage::updateStartupServerControlsVisibility); + + const QString savedHost = settings.tabs().getStartupServerHost(); + const QString savedPort = settings.tabs().getStartupServerPort(); + int startupServerIndex = -1; + UserConnection_Information uci; + for (const auto &savedServer : uci.getServerInfo()) { + const UserConnection_Information &info = savedServer.second; + const QString saveName = info.getSaveName(); + if (saveName.isEmpty()) { + continue; + } + startupServerSelector.addItem(saveName, QVariantList{info.getServer(), info.getPort()}); + if (startupServerIndex == -1 && info.getServer() == savedHost && info.getPort() == savedPort) { + startupServerIndex = startupServerSelector.count() - 1; + } + } + startupServerSelector.setCurrentIndex(startupServerIndex); + + connect(&startupServerSelector, qOverload(&QComboBox::currentIndexChanged), this, [this](int index) { + const QVariantList serverInfo = startupServerSelector.itemData(index).toList(); + if (serverInfo.size() != 2) { + return; + } + TabsSettings &tabs = SettingsCache::instance().tabs(); + tabs.setStartupServerHost(serverInfo[0].toString()); + tabs.setStartupServerPort(serverInfo[1].toString()); + }); + + startupRoomNameEdit = new QLineEdit(settings.tabs().getStartupRoomName()); + // Default (Expanding) would stretch the whole controls column when this row becomes visible, + // so size it like the combo boxes instead: fills the column, never widens it. + startupRoomNameEdit->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); + connect(startupRoomNameEdit, &QLineEdit::editingFinished, this, + [this] { SettingsCache::instance().tabs().setStartupRoomName(startupRoomNameEdit->text().trimmed()); }); auto *startupGrid = new QGridLayout; startupGrid->addWidget(&showTipsOnStartup, 0, 0, 1, 2); + startupGrid->addWidget(&startupTabLabel, 1, 0); + startupGrid->addWidget(&startupTabSelector, 1, 1); + startupGrid->addWidget(&startupServerLabel, 2, 0); + startupGrid->addWidget(&startupServerSelector, 2, 1); + startupGrid->addWidget(&startupRoomLabel, 3, 0); + startupGrid->addWidget(startupRoomNameEdit, 3, 1); + + updateStartupServerControlsVisibility(); startupGroupBox = new QGroupBox; startupGroupBox->setLayout(startupGrid); // paths settings - deckPathEdit = new QLineEdit(settings.getDeckPath()); + deckPathEdit = new QLineEdit(settings.paths().getDeckPath()); deckPathEdit->setReadOnly(true); auto *deckPathButton = new QPushButton("..."); connect(deckPathButton, &QPushButton::clicked, this, &GeneralSettingsPage::deckPathButtonClicked); - filtersPathEdit = new QLineEdit(settings.getFiltersPath()); + filtersPathEdit = new QLineEdit(settings.paths().getFiltersPath()); filtersPathEdit->setReadOnly(true); auto *filtersPathButton = new QPushButton("..."); connect(filtersPathButton, &QPushButton::clicked, this, &GeneralSettingsPage::filtersPathButtonClicked); - replaysPathEdit = new QLineEdit(settings.getReplaysPath()); + replaysPathEdit = new QLineEdit(settings.paths().getReplaysPath()); replaysPathEdit->setReadOnly(true); auto *replaysPathButton = new QPushButton("..."); connect(replaysPathButton, &QPushButton::clicked, this, &GeneralSettingsPage::replaysPathButtonClicked); - picsPathEdit = new QLineEdit(settings.getPicsPath()); + picsPathEdit = new QLineEdit(settings.paths().getPicsPath()); picsPathEdit->setReadOnly(true); auto *picsPathButton = new QPushButton("..."); connect(picsPathButton, &QPushButton::clicked, this, &GeneralSettingsPage::picsPathButtonClicked); @@ -224,13 +307,14 @@ GeneralSettingsPage::GeneralSettingsPage() GeneralSettingsPage::retranslateUi(); // connect the ReleaseChannel combo box only after the entries are inserted in retranslateUi - connect(&updateReleaseChannelBox, qOverload(&QComboBox::currentIndexChanged), &settings, - &SettingsCache::setUpdateReleaseChannelIndex); + connect(&updateReleaseChannelBox, qOverload(&QComboBox::currentIndexChanged), &settings.updates(), + &UpdatesSettings::setUpdateReleaseChannelIndex); updateReleaseChannelBox.setCurrentIndex(settings.getUpdateReleaseChannelIndex()); setLayout(mainLayout); - connect(&SettingsCache::instance(), &SettingsCache::langChanged, this, &GeneralSettingsPage::retranslateUi); + connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, + &GeneralSettingsPage::retranslateUi); retranslateUi(); } @@ -264,7 +348,7 @@ void GeneralSettingsPage::deckPathButtonClicked() } deckPathEdit->setText(path); - SettingsCache::instance().setDeckPath(path); + SettingsCache::instance().paths().setDeckPath(path); } void GeneralSettingsPage::filtersPathButtonClicked() @@ -275,7 +359,7 @@ void GeneralSettingsPage::filtersPathButtonClicked() } filtersPathEdit->setText(path); - SettingsCache::instance().setFiltersPath(path); + SettingsCache::instance().paths().setFiltersPath(path); } void GeneralSettingsPage::replaysPathButtonClicked() @@ -286,7 +370,7 @@ void GeneralSettingsPage::replaysPathButtonClicked() } replaysPathEdit->setText(path); - SettingsCache::instance().setReplaysPath(path); + SettingsCache::instance().paths().setReplaysPath(path); } void GeneralSettingsPage::picsPathButtonClicked() @@ -297,7 +381,7 @@ void GeneralSettingsPage::picsPathButtonClicked() } picsPathEdit->setText(path); - SettingsCache::instance().setPicsPath(path); + SettingsCache::instance().paths().setPicsPath(path); } void GeneralSettingsPage::cardDatabasePathButtonClicked() @@ -308,7 +392,7 @@ void GeneralSettingsPage::cardDatabasePathButtonClicked() } cardDatabasePathEdit->setText(path); - SettingsCache::instance().setCardDatabasePath(path); + SettingsCache::instance().paths().setCardDatabasePath(path); } void GeneralSettingsPage::customCardDatabaseButtonClicked() @@ -319,7 +403,7 @@ void GeneralSettingsPage::customCardDatabaseButtonClicked() } customCardDatabasePathEdit->setText(path); - SettingsCache::instance().setCustomCardDatabasePath(path); + SettingsCache::instance().paths().setCustomCardDatabasePath(path); } void GeneralSettingsPage::tokenDatabasePathButtonClicked() @@ -330,16 +414,16 @@ void GeneralSettingsPage::tokenDatabasePathButtonClicked() } tokenDatabasePathEdit->setText(path); - SettingsCache::instance().setTokenDatabasePath(path); + SettingsCache::instance().paths().setTokenDatabasePath(path); } void GeneralSettingsPage::resetAllPathsClicked() { SettingsCache &settings = SettingsCache::instance(); settings.resetPaths(); - deckPathEdit->setText(settings.getDeckPath()); - replaysPathEdit->setText(settings.getReplaysPath()); - picsPathEdit->setText(settings.getPicsPath()); + deckPathEdit->setText(settings.paths().getDeckPath()); + replaysPathEdit->setText(settings.paths().getReplaysPath()); + picsPathEdit->setText(settings.paths().getPicsPath()); cardDatabasePathEdit->setText(settings.getCardDatabasePath()); customCardDatabasePathEdit->setText(settings.getCustomCardDatabasePath()); tokenDatabasePathEdit->setText(settings.getTokenDatabasePath()); @@ -348,54 +432,84 @@ void GeneralSettingsPage::resetAllPathsClicked() void GeneralSettingsPage::languageBoxChanged(int index) { - SettingsCache::instance().setLang(languageBox.itemData(index).toString()); + SettingsCache::instance().personal().setLang(languageBox.itemData(index).toString()); +} + +void GeneralSettingsPage::cardLanguageBoxChanged(int index) +{ + const QString lang = cardLanguageBox.itemData(index).toString(); + SettingsCache::instance().cardsDisplay().setCardLang(lang); + + // Switching to a non-default language only takes effect after the card + // database is re-imported with that language selected; English data is always + // present, so switching back to English needs no prompt. + if (lang == "en") { + return; + } + + // The binary cache does not track the language its entries were imported in, + // and the downloaded pictures were fetched with English art names, so both are + // stale until Oracle re-imports the database in the new language: drop them. + QFile::remove(SettingsCache::instance().getCardDatabasePath() + ".cache"); + CardPictureLoader::clearNetworkCache(); + CardPictureLoader::clearPixmapCache(); + + // Art is resolved by the translated card name for non-English languages, so the + // matching Scryfall URL is added to the top of the download list. It stays + // visible in the deck editor settings, where it can be removed or reordered. + const bool localizedUrlAdded = SettingsCache::instance().downloads().addLocalizedScryfallUrl(); + + QString message = tr("

The card database only contains English card data. To see cards in %1, " + "Oracle must run once with this language selected and re-import the card " + "database.

" + "

The cached database and the downloaded card pictures have been cleared, so a " + "re-import is picked up without stale entries.

") + .arg(cardLanguageBox.itemText(index)); + if (localizedUrlAdded) { + message += tr("

The Scryfall URL that resolves card art by translated name was added to the top of your " + "download list. You can remove or reorder it any time.

"); + } + message += tr("

Run Oracle now?

"); + + const QMessageBox::StandardButton answer = QMessageBox::question( + this, tr("Card text & images language changed"), message, QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes); + + // The answer only controls whether Oracle starts right away; the caches stay + // cleared so the next import or launch rebuilds them in the new language. + if (answer == QMessageBox::Yes) { + emit cardDatabaseUpdateRequested(); + } +} + +void GeneralSettingsPage::updateStartupServerControlsVisibility() +{ + const int index = startupTabSelector.currentIndex(); + const bool serverNeeded = index == StartupTab::StartupTabServer || index == StartupTab::StartupTabServerRoom; + const bool roomNeeded = index == StartupTab::StartupTabServerRoom; + startupServerLabel.setVisible(serverNeeded); + startupServerSelector.setVisible(serverNeeded); + startupRoomLabel.setVisible(roomNeeded); + startupRoomNameEdit->setVisible(roomNeeded); } void GeneralSettingsPage::retranslateUi() { - languageGroupBox->setTitle(tr("Language settings")); - languageLabel.setText(tr("Language:")); - - versionGroupBox->setTitle(tr("Version settings")); - cardDatabaseGroupBox->setTitle(tr("Card database")); - startupGroupBox->setTitle(tr("Startup settings")); - - if (SettingsCache::instance().getIsPortableBuild()) { - pathsGroupBox->setTitle(tr("Paths (editing disabled in portable mode)")); - } else { - pathsGroupBox->setTitle(tr("Paths")); - } - advertiseTranslationPageLabel.setText( - QString("
%2").arg(WIKI_TRANSLATION_FAQ).arg(tr("How to help with translations"))); - deckPathLabel.setText(tr("Decks directory:")); - filtersPathLabel.setText(tr("Filters directory:")); - replaysPathLabel.setText(tr("Replays directory:")); - picsPathLabel.setText(tr("Pictures directory:")); - cardDatabasePathLabel.setText(tr("Card database:")); - customCardDatabasePathLabel.setText(tr("Custom database directory:")); - tokenDatabasePathLabel.setText(tr("Token database:")); - updateReleaseChannelLabel.setText(tr("Update channel")); - startupUpdateCheckCheckBox.setText(tr("Check for client updates on startup")); - startupCardUpdateCheckBehaviorLabel.setText(tr("Check for card database updates on startup")); - startupCardUpdateCheckBehaviorSelector.setItemText(startupCardUpdateCheckBehaviorIndexNone, tr("Don't check")); - startupCardUpdateCheckBehaviorSelector.setItemText(startupCardUpdateCheckBehaviorIndexPrompt, - tr("Prompt for update")); - startupCardUpdateCheckBehaviorSelector.setItemText(startupCardUpdateCheckBehaviorIndexAlways, - tr("Always update in the background")); - cardUpdateCheckIntervalLabel.setText(tr("Check for card database updates every")); - cardUpdateCheckIntervalSpinBox.setSuffix(tr(" days")); - updateNotificationCheckBox.setText(tr("Notify if a feature supported by the server is missing in my client")); - newVersionOracleCheckBox.setText(tr("Automatically run Oracle when running a new version of Cockatrice")); - showTipsOnStartup.setText(tr("Show tips on startup")); - resetAllPathsButton->setText(tr("Reset all paths")); - const auto &settings = SettingsCache::instance(); - QDate lastCheckDate = settings.getLastCardUpdateCheck(); - int daysAgo = lastCheckDate.daysTo(QDate::currentDate()); + languageGroupBox->setTitle(tr("Language settings")); + languageLabel.setText(tr("Language:")); + cardLanguageBox.setItemText(0, tr("English")); + cardLanguageLabel.setText(tr("Card text & images language:")); + cardLanguageNoteLabel.setText( + tr("Foreign card names, text and art apply after you update the card database (Oracle).")); + advertiseTranslationPageLabel.setText( + QString("%2").arg(WIKI_TRANSLATION_FAQ).arg(tr("How to help with translations"))); - lastCardUpdateCheckDateLabel.setText( - tr("Last update check on %1 (%2 days ago)").arg(lastCheckDate.toString()).arg(daysAgo)); + versionGroupBox->setTitle(tr("Version settings")); + updateReleaseChannelLabel.setText(tr("Update channel")); + startupUpdateCheckCheckBox.setText(tr("Check for client updates on startup")); + updateNotificationCheckBox.setText(tr("Notify if a feature supported by the server is missing in my client")); + newVersionOracleCheckBox.setText(tr("Automatically run Oracle when running a new version of Cockatrice")); // We can't change the strings after they're put into the QComboBox, so this is our workaround int oldIndex = updateReleaseChannelBox.currentIndex(); @@ -404,4 +518,50 @@ void GeneralSettingsPage::retranslateUi() updateReleaseChannelBox.addItem(tr(chan->getName().toUtf8())); } updateReleaseChannelBox.setCurrentIndex(oldIndex); -} \ No newline at end of file + + cardDatabaseGroupBox->setTitle(tr("Card database")); + startupCardUpdateCheckBehaviorLabel.setText(tr("Check for card database updates on startup")); + startupCardUpdateCheckBehaviorSelector.setItemText(startupCardUpdateCheckBehaviorIndexNone, tr("Don't check")); + startupCardUpdateCheckBehaviorSelector.setItemText(startupCardUpdateCheckBehaviorIndexPrompt, + tr("Prompt for update")); + startupCardUpdateCheckBehaviorSelector.setItemText(startupCardUpdateCheckBehaviorIndexAlways, + tr("Always update in the background")); + cardUpdateCheckIntervalLabel.setText(tr("Check for card database updates every")); + cardUpdateCheckIntervalSpinBox.setSuffix(tr(" days")); + + QDate lastCheckDate = settings.updates().getLastCardUpdateCheck(); + int daysAgo = lastCheckDate.daysTo(QDate::currentDate()); + lastCardUpdateCheckDateLabel.setText( + tr("Last update check on %1 (%2 days ago)").arg(lastCheckDate.toString()).arg(daysAgo)); + + startupGroupBox->setTitle(tr("Startup settings")); + showTipsOnStartup.setText(tr("Show tips on startup")); + startupTabLabel.setText(tr("Startup tab:")); + startupTabSelector.setItemText(StartupTab::StartupTabHome, tr("Home")); + startupTabSelector.setItemText(StartupTab::StartupTabVisualDeckStorage, tr("Visual Deck Storage")); + startupTabSelector.setItemText(StartupTab::StartupTabDeckStorage, tr("Deck Storage")); + startupTabSelector.setItemText(StartupTab::StartupTabReplays, tr("Game Replays")); + startupTabSelector.setItemText(StartupTab::StartupTabDeckEditor, tr("Deck Editor")); + startupTabSelector.setItemText(StartupTab::StartupTabVisualDeckEditor, tr("Visual Deck Editor")); + startupTabSelector.setItemText(StartupTab::StartupTabServer, tr("Server")); + startupTabSelector.setItemText(StartupTab::StartupTabServerRoom, tr("Server Room")); + startupTabSelector.setToolTip( + tr("The tab shown when Cockatrice starts. If the chosen tab is not open yet, it is opened.")); + startupServerLabel.setText(tr("Server:")); + startupRoomLabel.setText(tr("Room:")); + startupRoomNameEdit->setPlaceholderText(tr("Room name")); + + if (settings.getIsPortableBuild()) { + pathsGroupBox->setTitle(tr("Paths (editing disabled in portable mode)")); + } else { + pathsGroupBox->setTitle(tr("Paths")); + } + deckPathLabel.setText(tr("Decks directory:")); + filtersPathLabel.setText(tr("Filters directory:")); + replaysPathLabel.setText(tr("Replays directory:")); + picsPathLabel.setText(tr("Pictures directory:")); + cardDatabasePathLabel.setText(tr("Card database:")); + customCardDatabasePathLabel.setText(tr("Custom database directory:")); + tokenDatabasePathLabel.setText(tr("Token database:")); + resetAllPathsButton->setText(tr("Reset all paths")); +} diff --git a/cockatrice/src/interface/widgets/settings_page/general_settings_page.h b/cockatrice/src/interface/widgets/settings_page/general_settings_page.h index 8aa39ff65..7afba158f 100644 --- a/cockatrice/src/interface/widgets/settings_page/general_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/general_settings_page.h @@ -20,6 +20,13 @@ public: GeneralSettingsPage(); void retranslateUi() override; + static QStringList findQmFiles(); + static QString languageName(const QString &lang); + +signals: + /// Request to re-import the card database with the newly selected card language + void cardDatabaseUpdateRequested(); + private slots: void deckPathButtonClicked(); void filtersPathButtonClicked(); @@ -30,17 +37,51 @@ private slots: void tokenDatabasePathButtonClicked(); void resetAllPathsClicked(); void languageBoxChanged(int index); + void cardLanguageBoxChanged(int index); + void updateStartupServerControlsVisibility(); private: - QStringList findQmFiles(); - QString languageName(const QString &lang); - QGroupBox *languageGroupBox; QGroupBox *versionGroupBox; QGroupBox *cardDatabaseGroupBox; QGroupBox *startupGroupBox; QGroupBox *pathsGroupBox; + QLabel languageLabel; + QComboBox languageBox; + QLabel advertiseTranslationPageLabel; + + QLabel cardLanguageLabel; + QComboBox cardLanguageBox; + QLabel cardLanguageNoteLabel; + + QLabel updateReleaseChannelLabel; + QComboBox updateReleaseChannelBox; + QCheckBox startupUpdateCheckCheckBox; + QCheckBox updateNotificationCheckBox; + QCheckBox newVersionOracleCheckBox; + + QLabel startupCardUpdateCheckBehaviorLabel; + QComboBox startupCardUpdateCheckBehaviorSelector; + QLabel cardUpdateCheckIntervalLabel; + QSpinBox cardUpdateCheckIntervalSpinBox; + QLabel lastCardUpdateCheckDateLabel; + + QCheckBox showTipsOnStartup; + QLabel startupTabLabel; + QComboBox startupTabSelector; + QLabel startupServerLabel; + QComboBox startupServerSelector; + QLabel startupRoomLabel; + QLineEdit *startupRoomNameEdit; + + QLabel deckPathLabel; + QLabel filtersPathLabel; + QLabel replaysPathLabel; + QLabel picsPathLabel; + QLabel cardDatabasePathLabel; + QLabel customCardDatabasePathLabel; + QLabel tokenDatabasePathLabel; QLineEdit *deckPathEdit; QLineEdit *filtersPathEdit; QLineEdit *replaysPathEdit; @@ -50,27 +91,6 @@ private: QLineEdit *tokenDatabasePathEdit; QPushButton *resetAllPathsButton; QLabel *allPathsResetLabel; - QComboBox languageBox; - QCheckBox startupUpdateCheckCheckBox; - QLabel startupCardUpdateCheckBehaviorLabel; - QComboBox startupCardUpdateCheckBehaviorSelector; - QLabel cardUpdateCheckIntervalLabel; - QSpinBox cardUpdateCheckIntervalSpinBox; - QLabel lastCardUpdateCheckDateLabel; - QCheckBox updateNotificationCheckBox; - QCheckBox newVersionOracleCheckBox; - QComboBox updateReleaseChannelBox; - QLabel languageLabel; - QLabel deckPathLabel; - QLabel filtersPathLabel; - QLabel replaysPathLabel; - QLabel picsPathLabel; - QLabel cardDatabasePathLabel; - QLabel customCardDatabasePathLabel; - QLabel tokenDatabasePathLabel; - QLabel updateReleaseChannelLabel; - QLabel advertiseTranslationPageLabel; - QCheckBox showTipsOnStartup; }; #endif // COCKATRICE_GENERAL_SETTINGS_PAGE_H diff --git a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp index 1e6f99245..a3b89f8c4 100644 --- a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp @@ -1,62 +1,73 @@ #include "messages_settings_page.h" #include "../../../client/settings/cache_settings.h" +#include "../../pixel_map_generator.h" #include "../interface/widgets/utility/get_text_with_max.h" #include #include #include +#include +#include +#include +#include MessagesSettingsPage::MessagesSettingsPage() { - chatMentionCheckBox.setChecked(SettingsCache::instance().getChatMention()); - connect(&chatMentionCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setChatMention); + chatMentionCheckBox.setChecked(SettingsCache::instance().chat().getChatMention()); + connect(&chatMentionCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().chat(), + &ChatSettings::setChatMention); - chatMentionCompleterCheckbox.setChecked(SettingsCache::instance().getChatMentionCompleter()); - connect(&chatMentionCompleterCheckbox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setChatMentionCompleter); + chatMentionCompleterCheckbox.setChecked(SettingsCache::instance().chat().getChatMentionCompleter()); + connect(&chatMentionCompleterCheckbox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().chat(), + &ChatSettings::setChatMentionCompleter); explainMessagesLabel.setTextInteractionFlags(Qt::LinksAccessibleByMouse); explainMessagesLabel.setOpenExternalLinks(true); - ignoreUnregUsersMainChat.setChecked(SettingsCache::instance().getIgnoreUnregisteredUsers()); - ignoreUnregUserMessages.setChecked(SettingsCache::instance().getIgnoreUnregisteredUserMessages()); - ignoreNonBuddyUserMessages.setChecked(SettingsCache::instance().getIgnoreNonBuddyUserMessages()); + ignoreUnregUsersMainChat.setChecked(SettingsCache::instance().chat().getIgnoreUnregisteredUsers()); + ignoreUnregUserMessages.setChecked(SettingsCache::instance().chat().getIgnoreUnregisteredUserMessages()); + ignoreNonBuddyUserMessages.setChecked(SettingsCache::instance().chat().getIgnoreNonBuddyUserMessages()); - connect(&ignoreUnregUsersMainChat, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setIgnoreUnregisteredUsers); - connect(&ignoreUnregUserMessages, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setIgnoreUnregisteredUserMessages); - connect(&ignoreNonBuddyUserMessages, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setIgnoreNonBuddyUserMessages); + connect(&ignoreUnregUsersMainChat, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().chat(), + &ChatSettings::setIgnoreUnregisteredUsers); + connect(&ignoreUnregUserMessages, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().chat(), + &ChatSettings::setIgnoreUnregisteredUserMessages); + connect(&ignoreNonBuddyUserMessages, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().chat(), + &ChatSettings::setIgnoreNonBuddyUserMessages); - invertMentionForeground.setChecked(SettingsCache::instance().getChatMentionForeground()); + invertMentionForeground.setChecked(SettingsCache::instance().chat().getChatMentionForeground()); connect(&invertMentionForeground, &QCheckBox::QT_STATE_CHANGED, this, &MessagesSettingsPage::updateTextColor); - invertHighlightForeground.setChecked(SettingsCache::instance().getChatHighlightForeground()); + invertHighlightForeground.setChecked(SettingsCache::instance().chat().getChatHighlightForeground()); connect(&invertHighlightForeground, &QCheckBox::QT_STATE_CHANGED, this, &MessagesSettingsPage::updateTextHighlightColor); mentionColor = new QLineEdit(); - mentionColor->setText(SettingsCache::instance().getChatMentionColor()); + mentionColor->setText(SettingsCache::instance().chat().getChatMentionColor()); updateMentionPreview(); connect(mentionColor, &QLineEdit::textChanged, this, &MessagesSettingsPage::updateColor); - messagePopups.setChecked(SettingsCache::instance().getShowMessagePopup()); - connect(&messagePopups, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setShowMessagePopups); + messagePopups.setChecked(SettingsCache::instance().chat().getShowMessagePopup()); + connect(&messagePopups, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().chat(), + &ChatSettings::setShowMessagePopups); - mentionPopups.setChecked(SettingsCache::instance().getShowMentionPopup()); - connect(&mentionPopups, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setShowMentionPopups); + mentionPopups.setChecked(SettingsCache::instance().chat().getShowMentionPopup()); + connect(&mentionPopups, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().chat(), + &ChatSettings::setShowMentionPopups); - roomHistory.setChecked(SettingsCache::instance().getRoomHistory()); - connect(&roomHistory, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), &SettingsCache::setRoomHistory); + roomHistory.setChecked(SettingsCache::instance().chat().getRoomHistory()); + connect(&roomHistory, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().chat(), + &ChatSettings::setRoomHistory); + + ignoreAllPrivateMessagesCheckBox.setChecked(SettingsCache::instance().chat().getIgnoreAllPrivateMessages()); + connect(&ignoreAllPrivateMessagesCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().chat(), + &ChatSettings::setIgnoreAllPrivateMessages); customAlertString = new QLineEdit(); - customAlertString->setText(SettingsCache::instance().getHighlightWords()); - connect(customAlertString, &QLineEdit::textChanged, &SettingsCache::instance(), &SettingsCache::setHighlightWords); + customAlertString->setText(SettingsCache::instance().chat().getHighlightWords()); + connect(customAlertString, &QLineEdit::textChanged, &SettingsCache::instance().chat(), + &ChatSettings::setHighlightWords); auto *chatGrid = new QGridLayout; chatGrid->addWidget(&chatMentionCheckBox, 0, 0); @@ -70,11 +81,12 @@ MessagesSettingsPage::MessagesSettingsPage() chatGrid->addWidget(&messagePopups, 5, 0); chatGrid->addWidget(&mentionPopups, 6, 0); chatGrid->addWidget(&roomHistory, 7, 0); + chatGrid->addWidget(&ignoreAllPrivateMessagesCheckBox, 8, 0); chatGroupBox = new QGroupBox; chatGroupBox->setLayout(chatGrid); highlightColor = new QLineEdit(); - highlightColor->setText(SettingsCache::instance().getChatHighlightColor()); + highlightColor->setText(SettingsCache::instance().chat().getChatHighlightColor()); updateHighlightPreview(); connect(highlightColor, &QLineEdit::textChanged, this, &MessagesSettingsPage::updateHighlightColor); @@ -84,6 +96,7 @@ MessagesSettingsPage::MessagesSettingsPage() highlightNotice->addWidget(&hexHighlightLabel, 1, 2); highlightNotice->addWidget(customAlertString, 0, 0); highlightNotice->addWidget(&customAlertStringLabel, 1, 0); + customAlertStringLabel.setBuddy(customAlertString); highlightGroupBox = new QGroupBox; highlightGroupBox->setLayout(highlightNotice); @@ -95,15 +108,15 @@ MessagesSettingsPage::MessagesSettingsPage() } aAdd = new QAction(this); - aAdd->setIcon(QPixmap("theme:icons/increment")); + aAdd->setIcon(themePixmap(QStringLiteral("icons/increment"))); connect(aAdd, &QAction::triggered, this, &MessagesSettingsPage::actAdd); aEdit = new QAction(this); - aEdit->setIcon(QPixmap("theme:icons/pencil")); + aEdit->setIcon(themePixmap(QStringLiteral("icons/pencil"))); connect(aEdit, &QAction::triggered, this, &MessagesSettingsPage::actEdit); aRemove = new QAction(this); - aRemove->setIcon(QPixmap("theme:icons/decrement")); + aRemove->setIcon(themePixmap(QStringLiteral("icons/decrement"))); connect(aRemove, &QAction::triggered, this, &MessagesSettingsPage::actRemove); auto *messageToolBar = new QToolBar; @@ -131,7 +144,8 @@ MessagesSettingsPage::MessagesSettingsPage() setLayout(mainLayout); - connect(&SettingsCache::instance(), &SettingsCache::langChanged, this, &MessagesSettingsPage::retranslateUi); + connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, + &MessagesSettingsPage::retranslateUi); retranslateUi(); } @@ -144,7 +158,7 @@ void MessagesSettingsPage::updateColor(const QString &value) colorToSet.setNamedColor("#" + value); #endif if (colorToSet.isValid()) { - SettingsCache::instance().setChatMentionColor(value); + SettingsCache::instance().chat().setChatMentionColor(value); updateMentionPreview(); } } @@ -158,35 +172,35 @@ void MessagesSettingsPage::updateHighlightColor(const QString &value) colorToSet.setNamedColor("#" + value); #endif if (colorToSet.isValid()) { - SettingsCache::instance().setChatHighlightColor(value); + SettingsCache::instance().chat().setChatHighlightColor(value); updateHighlightPreview(); } } void MessagesSettingsPage::updateTextColor(QT_STATE_CHANGED_T value) { - SettingsCache::instance().setChatMentionForeground(value); + SettingsCache::instance().chat().setChatMentionForeground(value); updateMentionPreview(); } void MessagesSettingsPage::updateTextHighlightColor(QT_STATE_CHANGED_T value) { - SettingsCache::instance().setChatHighlightForeground(value); + SettingsCache::instance().chat().setChatHighlightForeground(value); updateHighlightPreview(); } void MessagesSettingsPage::updateMentionPreview() { mentionColor->setStyleSheet( - "QLineEdit{background:#" + SettingsCache::instance().getChatMentionColor() + - ";color: " + (SettingsCache::instance().getChatMentionForeground() ? "white" : "black") + ";}"); + "QLineEdit{background:#" + SettingsCache::instance().chat().getChatMentionColor() + + ";color: " + (SettingsCache::instance().chat().getChatMentionForeground() ? "white" : "black") + ";}"); } void MessagesSettingsPage::updateHighlightPreview() { highlightColor->setStyleSheet( - "QLineEdit{background:#" + SettingsCache::instance().getChatHighlightColor() + - ";color: " + (SettingsCache::instance().getChatHighlightForeground() ? "white" : "black") + ";}"); + "QLineEdit{background:#" + SettingsCache::instance().chat().getChatHighlightColor() + + ";color: " + (SettingsCache::instance().chat().getChatHighlightForeground() ? "white" : "black") + ";}"); } void MessagesSettingsPage::storeSettings() @@ -248,6 +262,7 @@ void MessagesSettingsPage::retranslateUi() messagePopups.setText(tr("Enable desktop notifications for private messages")); mentionPopups.setText(tr("Enable desktop notification for mentions")); roomHistory.setText(tr("Enable room message history on join")); + ignoreAllPrivateMessagesCheckBox.setText(tr("Ignore all private messages")); hexLabel.setText(tr("(Color is hexadecimal)")); hexHighlightLabel.setText(tr("(Color is hexadecimal)")); customAlertStringLabel.setText(tr("Separate words with a space, alphanumeric characters only")); diff --git a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.h b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.h index e98ae0592..436ebbad9 100644 --- a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.h @@ -40,6 +40,7 @@ private: QCheckBox messagePopups; QCheckBox mentionPopups; QCheckBox roomHistory; + QCheckBox ignoreAllPrivateMessagesCheckBox; QGroupBox *chatGroupBox; QGroupBox *highlightGroupBox; QGroupBox *messageGroupBox; diff --git a/cockatrice/src/interface/widgets/settings_page/settings_search_delegate.cpp b/cockatrice/src/interface/widgets/settings_page/settings_search_delegate.cpp new file mode 100644 index 000000000..2fdff3bce --- /dev/null +++ b/cockatrice/src/interface/widgets/settings_page/settings_search_delegate.cpp @@ -0,0 +1,125 @@ +/** + * @file settings_search_delegate.cpp + * @brief Implementation of the custom settings search result delegate + * @ingroup Dialogs + */ +#include "settings_search_delegate.h" + +#include "settings_search_model.h" + +#include + +SettingsSearchDelegate::SettingsSearchDelegate(QObject *parent) : QStyledItemDelegate(parent) +{ +} + +void SettingsSearchDelegate::setPageNames(const QStringList &names) +{ + pageNames = names; +} + +void SettingsSearchDelegate::setPageIcons(const QStringList &iconResources) +{ + pageIcons.clear(); + for (const QString &resource : iconResources) { + pageIcons.append(QPixmap(resource)); + } +} + +void SettingsSearchDelegate::paint(QPainter *painter, + const QStyleOptionViewItem &option, + const QModelIndex &index) const +{ + painter->save(); + + SettingsSearchEntry entry = index.data(SettingsSearchModel::EntryRole).value(); + + bool isSelected = option.state & QStyle::State_Selected; + bool isHovered = option.state & QStyle::State_MouseOver; + + // Background + QColor bgColor = isSelected ? option.palette.color(QPalette::Highlight) + : isHovered ? option.palette.color(QPalette::Midlight) + : option.palette.color(QPalette::Base); + painter->fillRect(option.rect, bgColor); + + if (isSelected) { + // Accent bar on the left to make the selection unmistakable + painter->fillRect(QRect(option.rect.left(), option.rect.top(), 4, option.rect.height()), + option.palette.color(QPalette::Highlight).darker(150)); + } + + int leftMargin = 12; + int topMargin = 8; + int rightMargin = 12; + int bottomMargin = 4; + + QRect contentRect = option.rect.adjusted(leftMargin, topMargin, -rightMargin, -bottomMargin); + int yPos = contentRect.top(); + + // Icon of the related settings page + const int iconSize = 24; + QPixmap pageIcon = + (entry.pageIndex >= 0 && entry.pageIndex < pageIcons.size()) ? pageIcons.at(entry.pageIndex) : QPixmap(); + int iconOffset = pageIcon.isNull() ? 0 : iconSize + 8; + if (!pageIcon.isNull()) { + QRect iconRect(contentRect.left(), contentRect.top() + (contentRect.height() - iconSize) / 2, iconSize, + iconSize); + painter->drawPixmap(iconRect, pageIcon); + } + + QRect textRect = contentRect.adjusted(iconOffset, 0, 0, 0); + + // Breadcrumb: "Page > Group" + QFont breadcrumbFont = option.font; + breadcrumbFont.setPointSize(breadcrumbFont.pointSize() - 1); + breadcrumbFont.setBold(true); + + QColor breadcrumbColor = + isSelected ? option.palette.color(QPalette::HighlightedText) : option.palette.color(QPalette::Text); + if (!isSelected) { + breadcrumbColor.setAlpha(180); + } + + QString pageName; + if (entry.pageIndex >= 0 && entry.pageIndex < pageNames.size()) { + pageName = pageNames[entry.pageIndex]; + } else { + pageName = QString::number(entry.pageIndex); + } + + QString breadcrumbText = QStringLiteral("%1 > %2").arg(pageName, entry.groupTitle); + painter->setFont(breadcrumbFont); + painter->setPen(breadcrumbColor); + painter->drawText(QRect(textRect.left(), yPos, textRect.width(), 20), Qt::AlignLeft | Qt::AlignVCenter, + breadcrumbText); + yPos += 20; + + // Setting label + QFont labelFont = option.font; + labelFont.setPointSize(labelFont.pointSize() + 1); + labelFont.setBold(isSelected); + + QColor labelColor = + isSelected ? option.palette.color(QPalette::HighlightedText) : option.palette.color(QPalette::Text); + + painter->setFont(labelFont); + painter->setPen(labelColor); + painter->drawText(QRect(textRect.left(), yPos, textRect.width(), 24), Qt::AlignLeft | Qt::AlignVCenter, + entry.widgetLabel); + yPos += 24; + + // Bottom separator + QPen separatorPen(option.palette.color(QPalette::Mid), 1); + painter->setPen(separatorPen); + painter->drawLine(option.rect.left() + leftMargin, option.rect.bottom(), option.rect.right() - rightMargin, + option.rect.bottom()); + + painter->restore(); +} + +QSize SettingsSearchDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const +{ + Q_UNUSED(index); + return QSize(option.rect.width(), 56); +} diff --git a/cockatrice/src/interface/widgets/settings_page/settings_search_delegate.h b/cockatrice/src/interface/widgets/settings_page/settings_search_delegate.h new file mode 100644 index 000000000..aa296049a --- /dev/null +++ b/cockatrice/src/interface/widgets/settings_page/settings_search_delegate.h @@ -0,0 +1,38 @@ +/** + * @file settings_search_delegate.h + * @brief Custom delegate for rendering settings search results + * @ingroup Dialogs + */ +#ifndef COCKATRICE_SETTINGS_SEARCH_DELEGATE_H +#define COCKATRICE_SETTINGS_SEARCH_DELEGATE_H + +#include +#include + +/** + * @brief Custom paint delegate for settings search result items + * + * Renders each search result with a breadcrumb line ("Page > Group"), + * the setting label, and a subtle separator. Supports selected/hovered states. + */ +class SettingsSearchDelegate : public QStyledItemDelegate +{ + Q_OBJECT +public: + explicit SettingsSearchDelegate(QObject *parent = nullptr); + + void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override; + QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override; + + /** @brief Sets the translated page names for breadcrumb display */ + void setPageNames(const QStringList &names); + + /** @brief Sets the icons shown in front of results, indexed by page position */ + void setPageIcons(const QStringList &iconResources); + +private: + QStringList pageNames; ///< Translated page names indexed by page position + QList pageIcons; ///< Icons of the related settings pages +}; + +#endif // COCKATRICE_SETTINGS_SEARCH_DELEGATE_H diff --git a/cockatrice/src/interface/widgets/settings_page/settings_search_model.cpp b/cockatrice/src/interface/widgets/settings_page/settings_search_model.cpp new file mode 100644 index 000000000..71f859d4b --- /dev/null +++ b/cockatrice/src/interface/widgets/settings_page/settings_search_model.cpp @@ -0,0 +1,145 @@ +/** + * @file settings_search_model.cpp + * @brief Implementation of the settings search list model + * @ingroup Dialogs + */ +#include "settings_search_model.h" + +#include +#include + +SettingsSearchModel::SettingsSearchModel(QObject *parent) : QAbstractListModel(parent) +{ +} + +void SettingsSearchModel::setSourceEntries(const QList &entries) +{ + beginResetModel(); + sourceEntries = entries; + endResetModel(); + rebuildFilter(); +} + +void SettingsSearchModel::setFilterString(const QString &text) +{ + filterActive = !text.trimmed().isEmpty(); + if (filterActive) { + filterQuery = text.trimmed(); + filterRegex = + QRegularExpression(QRegularExpression::escape(filterQuery), QRegularExpression::CaseInsensitiveOption); + } + rebuildFilter(); +} + +bool SettingsSearchModel::isFilterActive() const +{ + return filterActive; +} + +/** + * @brief Calculates a relevance score for a single entry against the query + * + * Scoring priorities (highest to lowest): + * 1. Label starts with query -> 100 + * 2. Label contains query -> 80 + * 3. Group title starts with query -> 60 + * 4. Group title contains query -> 40 + * 5. Full text regex match -> 20 + * 6. No match -> 0 (excluded from results) + */ +static int relevanceScore(const SettingsSearchEntry &entry, const QString &query, const QRegularExpression ®ex) +{ + QString lowerQuery = query.toLower(); + + // Label matches are most relevant + QString label = entry.widgetLabel.toLower(); + if (label.startsWith(lowerQuery)) { + return 100; + } + if (label.contains(lowerQuery)) { + return 80; + } + + // Group title matches are next + QString group = entry.groupTitle.toLower(); + if (group.startsWith(lowerQuery)) { + return 60; + } + if (group.contains(lowerQuery)) { + return 40; + } + + // Full text match is least relevant + if (entry.fullSearchText.contains(regex)) { + return 20; + } + + return 0; +} + +void SettingsSearchModel::rebuildFilter() +{ + beginResetModel(); + filteredIndices.clear(); + + if (!filterActive) { + for (int i = 0; i < sourceEntries.size(); ++i) { + filteredIndices.append(i); + } + } else { + QList> scored; // + for (int i = 0; i < sourceEntries.size(); ++i) { + const SettingsSearchEntry &entry = sourceEntries[i]; + // Skip conditional settings that are currently disabled or hidden + if (entry.widget && (!entry.widget->isEnabled() || entry.widget->isHidden())) { + continue; + } + int score = relevanceScore(entry, filterQuery, filterRegex); + if (score > 0) { + scored.append({-score, i}); // negative for descending sort + } + } + std::sort(scored.begin(), scored.end()); + for (const auto &pair : scored) { + filteredIndices.append(pair.second); + } + } + endResetModel(); +} + +int SettingsSearchModel::rowCount(const QModelIndex &parent) const +{ + if (parent.isValid()) { + return 0; + } + return filteredIndices.size(); +} + +QVariant SettingsSearchModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() >= filteredIndices.size()) { + return {}; + } + + const SettingsSearchEntry &entry = sourceEntries[filteredIndices[index.row()]]; + + switch (role) { + case EntryRole: + return QVariant::fromValue(entry); + case Qt::DisplayRole: + return entry.widgetLabel; + case Qt::ToolTipRole: + return QStringLiteral("%1 > %2 > %3") + .arg(QString::number(entry.pageIndex), entry.groupTitle, entry.widgetLabel); + default: + return {}; + } +} + +SettingsSearchEntry SettingsSearchModel::entryForIndex(const QModelIndex &index) const +{ + if (!index.isValid() || index.row() >= filteredIndices.size()) { + return {}; + } + return sourceEntries[filteredIndices[index.row()]]; +} diff --git a/cockatrice/src/interface/widgets/settings_page/settings_search_model.h b/cockatrice/src/interface/widgets/settings_page/settings_search_model.h new file mode 100644 index 000000000..0b590f180 --- /dev/null +++ b/cockatrice/src/interface/widgets/settings_page/settings_search_model.h @@ -0,0 +1,76 @@ +/** + * @file settings_search_model.h + * @brief Data model for the settings search feature + * @ingroup Dialogs + */ +#ifndef COCKATRICE_SETTINGS_SEARCH_MODEL_H +#define COCKATRICE_SETTINGS_SEARCH_MODEL_H + +#include +#include +#include +#include + +/** + * @brief Represents a single searchable setting entry + * + * Each settings page provides a list of these entries via getSearchEntries(). + * The model uses them for filtering, relevance scoring, and display. + */ +struct SettingsSearchEntry +{ + int pageIndex; ///< Index of the settings page this entry belongs to + QString groupTitle; ///< Title of the group/section within the page + QString widgetLabel; ///< Display label for the setting widget + QString fullSearchText; ///< Extended search text (label, control text, tooltip) for full-text matching + QWidget *widget; ///< Pointer to the setting widget for focus/scrolling +}; + +/** + * @brief List model providing filtered, ranked search results + * + * Manages a list of SettingsSearchEntry items. When a filter string is set, + * entries are scored by relevance and sorted so the best matches appear first. + * Supports custom roles for accessing entry fields from views and delegates. + */ +class SettingsSearchModel : public QAbstractListModel +{ + Q_OBJECT +public: + /** + * @brief Custom data role for accessing the full entry + */ + enum Roles + { + EntryRole = Qt::UserRole + 1, ///< Full SettingsSearchEntry object + }; + + explicit SettingsSearchModel(QObject *parent = nullptr); + + /** @brief Replaces the source entries and rebuilds the filter */ + void setSourceEntries(const QList &entries); + /** @brief Sets the filter string and recalculates the results */ + void setFilterString(const QString &text); + /** @brief Whether a non-empty filter is currently active */ + bool isFilterActive() const; + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + + /** @brief Returns the full entry for a given model index */ + SettingsSearchEntry entryForIndex(const QModelIndex &index) const; + +private: + QList sourceEntries; ///< Complete unfiltered entry list + QList filteredIndices; ///< Indices into sourceEntries matching the filter + QRegularExpression filterRegex; ///< Compiled regex for the current filter + QString filterQuery; ///< Current filter query string + bool filterActive = false; ///< Whether filtering is active + + /** @brief Recalculates the filtered index list and ranking */ + void rebuildFilter(); +}; + +Q_DECLARE_METATYPE(SettingsSearchEntry) + +#endif // COCKATRICE_SETTINGS_SEARCH_MODEL_H diff --git a/cockatrice/src/interface/widgets/settings_page/shortcut_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/shortcut_settings_page.cpp index e7a04ef79..1277d0e49 100644 --- a/cockatrice/src/interface/widgets/settings_page/shortcut_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/shortcut_settings_page.cpp @@ -2,11 +2,14 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcut_treeview.h" +#include "../../../client/settings/shortcuts_settings.h" +#include "../../pixel_map_generator.h" #include "../interface/widgets/utility/custom_line_edit.h" #include "../interface/widgets/utility/sequence_edit.h" #include #include +#include ShortcutSettingsPage::ShortcutSettingsPage() { @@ -45,8 +48,8 @@ ShortcutSettingsPage::ShortcutSettingsPage() btnResetAll = new QPushButton(this); btnClearAll = new QPushButton(this); - btnResetAll->setIcon(QPixmap("theme:icons/update")); - btnClearAll->setIcon(QPixmap("theme:icons/clearsearch")); + btnResetAll->setIcon(themePixmap(QStringLiteral("icons/update"))); + btnClearAll->setIcon(themePixmap(QStringLiteral("icons/clearsearch"))); // layout auto *_editLayout = new QGridLayout; @@ -78,7 +81,8 @@ ShortcutSettingsPage::ShortcutSettingsPage() connect(shortcutsTable, &ShortcutTreeView::currentItemChanged, this, &ShortcutSettingsPage::currentItemChanged); - connect(&SettingsCache::instance(), &SettingsCache::langChanged, this, &ShortcutSettingsPage::retranslateUi); + connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, + &ShortcutSettingsPage::retranslateUi); retranslateUi(); } @@ -120,6 +124,7 @@ void ShortcutSettingsPage::retranslateUi() currentActionGroupLabel->setText(tr("Section:")); currentActionLabel->setText(tr("Action:")); currentShortcutLabel->setText(tr("Shortcut:")); + editShortcutGroupBox->setTitle(tr("Shortcut editor")); editTextBox->retranslateUi(); faqLabel->setText(QString("%2").arg(WIKI_CUSTOM_SHORTCUTS).arg(tr("How to set custom shortcuts"))); btnResetAll->setText(tr("Restore all default shortcuts")); diff --git a/cockatrice/src/interface/widgets/settings_page/sound_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/sound_settings_page.cpp index e7e92ea15..2bd776671 100644 --- a/cockatrice/src/interface/widgets/settings_page/sound_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/sound_settings_page.cpp @@ -4,14 +4,17 @@ #include "../client/sound_engine.h" #include +#include +#include +#include SoundSettingsPage::SoundSettingsPage() { - soundEnabledCheckBox.setChecked(SettingsCache::instance().getSoundEnabled()); - connect(&soundEnabledCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setSoundEnabled); + soundEnabledCheckBox.setChecked(SettingsCache::instance().sound().getSoundEnabled()); + connect(&soundEnabledCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().sound(), + &SoundSettings::setSoundEnabled); - QString themeName = SettingsCache::instance().getSoundThemeName(); + QString themeName = SettingsCache::instance().sound().getSoundThemeName(); QStringList themeDirs = soundEngine->getAvailableThemes().keys(); for (int i = 0; i < themeDirs.size(); i++) { @@ -27,17 +30,18 @@ SoundSettingsPage::SoundSettingsPage() masterVolumeSlider = new QSlider(Qt::Horizontal); masterVolumeSlider->setMinimum(0); masterVolumeSlider->setMaximum(100); - masterVolumeSlider->setValue(SettingsCache::instance().getMasterVolume()); - masterVolumeSlider->setToolTip(QString::number(SettingsCache::instance().getMasterVolume())); - connect(&SettingsCache::instance(), &SettingsCache::masterVolumeChanged, this, + masterVolumeSlider->setValue(SettingsCache::instance().sound().getMasterVolume()); + masterVolumeSlider->setToolTip(QString::number(SettingsCache::instance().sound().getMasterVolume())); + connect(&SettingsCache::instance().sound(), &SoundSettings::masterVolumeChanged, this, &SoundSettingsPage::masterVolumeChanged); connect(masterVolumeSlider, &QSlider::sliderReleased, soundEngine, &SoundEngine::testSound); - connect(masterVolumeSlider, &QSlider::valueChanged, &SettingsCache::instance(), &SettingsCache::setMasterVolume); + connect(masterVolumeSlider, &QSlider::valueChanged, &SettingsCache::instance().sound(), + &SoundSettings::setMasterVolume); masterVolumeSpinBox = new QSpinBox(); masterVolumeSpinBox->setMinimum(0); masterVolumeSpinBox->setMaximum(100); - masterVolumeSpinBox->setValue(SettingsCache::instance().getMasterVolume()); + masterVolumeSpinBox->setValue(SettingsCache::instance().sound().getMasterVolume()); connect(masterVolumeSlider, &QSlider::valueChanged, masterVolumeSpinBox, &QSpinBox::setValue); connect(masterVolumeSpinBox, qOverload(&QSpinBox::valueChanged), masterVolumeSlider, &QSlider::setValue); @@ -59,7 +63,8 @@ SoundSettingsPage::SoundSettingsPage() setLayout(mainLayout); - connect(&SettingsCache::instance(), &SettingsCache::langChanged, this, &SoundSettingsPage::retranslateUi); + connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, + &SoundSettingsPage::retranslateUi); retranslateUi(); } @@ -67,7 +72,7 @@ void SoundSettingsPage::themeBoxChanged(int index) { QStringList themeDirs = soundEngine->getAvailableThemes().keys(); if (index >= 0 && index < themeDirs.count()) { - SettingsCache::instance().setSoundThemeName(themeDirs.at(index)); + SettingsCache::instance().sound().setSoundThemeName(themeDirs.at(index)); } } diff --git a/cockatrice/src/interface/widgets/settings_page/storage_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/storage_settings_page.cpp index c9d3c7789..17838e501 100644 --- a/cockatrice/src/interface/widgets/settings_page/storage_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/storage_settings_page.cpp @@ -1,11 +1,16 @@ #include "storage_settings_page.h" #include "../../../client/settings/cache_settings.h" +#include "../../card_picture_loader/card_picture_loader_cache_method.h" +#include "../../card_picture_loader/card_picture_loader_local_schemes.h" #include "../interface/card_picture_loader/card_picture_loader.h" #include #include #include +#include +#include +#include StorageSettingsPage::StorageSettingsPage() { @@ -27,7 +32,7 @@ StorageSettingsPage::StorageSettingsPage() // 2047 is the max value to avoid overflowing of QPixmapCache::setCacheLimit(int size) pixmapCacheEdit.setMaximum(PIXMAPCACHE_SIZE_MAX); pixmapCacheEdit.setSingleStep(64); - pixmapCacheEdit.setValue(SettingsCache::instance().getPixmapCacheSize()); + pixmapCacheEdit.setValue(SettingsCache::instance().cacheStorage().getPixmapCacheSize()); pixmapCacheEdit.setSuffix(" MB"); // Caching method @@ -37,7 +42,8 @@ StorageSettingsPage::StorageSettingsPage() cardPictureLoaderCacheMethodComboBox->addItem(method.displayName, static_cast(method.id)); } - int currentCacheMethod = static_cast(SettingsCache::instance().getCardPictureLoaderCacheMethod()); + int currentCacheMethod = + static_cast(SettingsCache::instance().cacheStorage().getCardPictureLoaderCacheMethod()); int currentIndex = cardPictureLoaderCacheMethodComboBox->findData(currentCacheMethod); if (currentIndex >= 0) { @@ -60,7 +66,7 @@ StorageSettingsPage::StorageSettingsPage() mpNetworkCacheGroupBox->setEnabled(useNetworkCache); mpImageBackupGroupBox->setEnabled(!useNetworkCache); - SettingsCache::instance().setCardImageCacheMethod(cacheMethod); + SettingsCache::instance().cacheStorage().setCardImageCacheMethod(static_cast(cacheMethod)); }); // Network Cache @@ -68,13 +74,13 @@ StorageSettingsPage::StorageSettingsPage() networkCacheEdit.setMinimum(NETWORK_CACHE_SIZE_MIN); networkCacheEdit.setMaximum(NETWORK_CACHE_SIZE_MAX); networkCacheEdit.setSingleStep(1); - networkCacheEdit.setValue(SettingsCache::instance().getNetworkCacheSizeInMB()); + networkCacheEdit.setValue(SettingsCache::instance().cacheStorage().getNetworkCacheSizeInMB()); networkCacheEdit.setSuffix(" MB"); networkRedirectCacheTtlEdit.setMinimum(NETWORK_REDIRECT_CACHE_TTL_MIN); networkRedirectCacheTtlEdit.setMaximum(NETWORK_REDIRECT_CACHE_TTL_MAX); networkRedirectCacheTtlEdit.setSingleStep(1); - networkRedirectCacheTtlEdit.setValue(SettingsCache::instance().getRedirectCacheTtl()); + networkRedirectCacheTtlEdit.setValue(SettingsCache::instance().cacheStorage().getRedirectCacheTtl()); // Image Backup localCardImageStorageNamingSchemeComboBox = new QComboBox; @@ -82,7 +88,7 @@ StorageSettingsPage::StorageSettingsPage() localCardImageStorageNamingSchemeComboBox->addItem(scheme.displayName, static_cast(scheme.id)); } - int current = static_cast(SettingsCache::instance().getLocalCardImageStorageNamingScheme()); + int current = static_cast(SettingsCache::instance().cacheStorage().getLocalCardImageStorageNamingScheme()); int index = localCardImageStorageNamingSchemeComboBox->findData(current); if (index >= 0) { @@ -93,7 +99,7 @@ StorageSettingsPage::StorageSettingsPage() [this](int index) { auto scheme = static_cast( localCardImageStorageNamingSchemeComboBox->itemData(index).toInt()); - SettingsCache::instance().setLocalCardImageStorageNamingScheme(scheme); + SettingsCache::instance().cacheStorage().setLocalCardImageStorageNamingScheme(static_cast(scheme)); }); connect(&clearBackupsButton, &QPushButton::clicked, this, &StorageSettingsPage::clearImageBackupsButtonClicked); @@ -132,12 +138,12 @@ StorageSettingsPage::StorageSettingsPage() lpPixmapCacheGrid->addWidget(&pixmapCacheExplainerLabel, 0, 0); lpPixmapCacheGrid->addLayout(pixmapCacheLayout, 1, 0); - connect(&pixmapCacheEdit, qOverload(&QSpinBox::valueChanged), &SettingsCache::instance(), - &SettingsCache::setPixmapCacheSize); - connect(&networkCacheEdit, qOverload(&QSpinBox::valueChanged), &SettingsCache::instance(), - &SettingsCache::setNetworkCacheSizeInMB); - connect(&networkRedirectCacheTtlEdit, qOverload(&QSpinBox::valueChanged), &SettingsCache::instance(), - &SettingsCache::setNetworkRedirectCacheTtl); + connect(&pixmapCacheEdit, qOverload(&QSpinBox::valueChanged), &SettingsCache::instance().cacheStorage(), + &CacheStorageSettings::setPixmapCacheSize); + connect(&networkCacheEdit, qOverload(&QSpinBox::valueChanged), &SettingsCache::instance().cacheStorage(), + &CacheStorageSettings::setNetworkCacheSizeInMB); + connect(&networkRedirectCacheTtlEdit, qOverload(&QSpinBox::valueChanged), + &SettingsCache::instance().cacheStorage(), &CacheStorageSettings::setNetworkRedirectCacheTtl); mpCacheMethodGroupBox = new QGroupBox; mpCacheMethodGroupBox->setLayout(cacheMethodLayout); @@ -161,13 +167,15 @@ StorageSettingsPage::StorageSettingsPage() setLayout(lpMainLayout); - bool useNetworkCache = SettingsCache::instance().getCardPictureLoaderCacheMethod() == + bool useNetworkCache = static_cast( + SettingsCache::instance().cacheStorage().getCardPictureLoaderCacheMethod()) == CardPictureLoaderCacheMethod::CacheMethod::NETWORK_CACHE; mpNetworkCacheGroupBox->setEnabled(useNetworkCache); mpImageBackupGroupBox->setEnabled(!useNetworkCache); - connect(&SettingsCache::instance(), &SettingsCache::langChanged, this, &StorageSettingsPage::retranslateUi); + connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, + &StorageSettingsPage::retranslateUi); retranslateUi(); } @@ -180,7 +188,7 @@ void StorageSettingsPage::clearDownloadedPicsButtonClicked() void StorageSettingsPage::clearImageBackupsButtonClicked() { - QString picsPath = SettingsCache::instance().getPicsPath() + "/downloadedPics"; + QString picsPath = SettingsCache::instance().paths().getPicsPath() + "/downloadedPics"; QDir dir(picsPath); bool success = dir.removeRecursively(); diff --git a/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.cpp index 6039e3758..2c6e062da 100644 --- a/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.cpp @@ -2,8 +2,14 @@ #include "../../../client/settings/cache_settings.h" #include "../interface/widgets/tabs/tab_supervisor.h" +#include "../tabs/api/commander_spellbook/commander_spellbook_bracket_explainer.h" #include +#include +#include +#include +#include +#include enum visualDeckStoragePromptForConversionIndex { @@ -14,67 +20,53 @@ enum visualDeckStoragePromptForConversionIndex UserInterfaceSettingsPage::UserInterfaceSettingsPage() { - // general settings and notification settings - notificationsEnabledCheckBox.setChecked(SettingsCache::instance().getNotificationsEnabled()); - connect(¬ificationsEnabledCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setNotificationsEnabled); - connect(¬ificationsEnabledCheckBox, &QCheckBox::QT_STATE_CHANGED, this, - &UserInterfaceSettingsPage::setNotificationEnabled); + // general settings + doubleClickToPlayCheckBox.setChecked(SettingsCache::instance().userInterface().getDoubleClickToPlay()); + connect(&doubleClickToPlayCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(), + &InterfaceSettings::setDoubleClickToPlay); - specNotificationsEnabledCheckBox.setChecked(SettingsCache::instance().getSpectatorNotificationsEnabled()); - specNotificationsEnabledCheckBox.setEnabled(SettingsCache::instance().getNotificationsEnabled()); - connect(&specNotificationsEnabledCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setSpectatorNotificationsEnabled); + clickPlaysAllSelectedCheckBox.setChecked(SettingsCache::instance().userInterface().getClickPlaysAllSelected()); + connect(&clickPlaysAllSelectedCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(), + &InterfaceSettings::setClickPlaysAllSelected); - buddyConnectNotificationsEnabledCheckBox.setChecked( - SettingsCache::instance().getBuddyConnectNotificationsEnabled()); - buddyConnectNotificationsEnabledCheckBox.setEnabled(SettingsCache::instance().getNotificationsEnabled()); - connect(&buddyConnectNotificationsEnabledCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setBuddyConnectNotificationsEnabled); + playToStackCheckBox.setChecked(SettingsCache::instance().userInterface().getPlayToStack()); + connect(&playToStackCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(), + &InterfaceSettings::setPlayToStack); - doubleClickToPlayCheckBox.setChecked(SettingsCache::instance().getDoubleClickToPlay()); - connect(&doubleClickToPlayCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setDoubleClickToPlay); + doNotDeleteArrowsInSubPhasesCheckBox.setChecked( + SettingsCache::instance().userInterface().getDoNotDeleteArrowsInSubPhases()); + connect(&doNotDeleteArrowsInSubPhasesCheckBox, &QCheckBox::QT_STATE_CHANGED, + &SettingsCache::instance().userInterface(), &InterfaceSettings::setDoNotDeleteArrowsInSubPhases); - clickPlaysAllSelectedCheckBox.setChecked(SettingsCache::instance().getClickPlaysAllSelected()); - connect(&clickPlaysAllSelectedCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setClickPlaysAllSelected); + closeEmptyCardViewCheckBox.setChecked(SettingsCache::instance().userInterface().getCloseEmptyCardView()); + connect(&closeEmptyCardViewCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(), + &InterfaceSettings::setCloseEmptyCardView); - playToStackCheckBox.setChecked(SettingsCache::instance().getPlayToStack()); - connect(&playToStackCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setPlayToStack); + focusCardViewSearchBarCheckBox.setChecked(SettingsCache::instance().userInterface().getFocusCardViewSearchBar()); + connect(&focusCardViewSearchBarCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(), + &InterfaceSettings::setFocusCardViewSearchBar); - doNotDeleteArrowsInSubPhasesCheckBox.setChecked(SettingsCache::instance().getDoNotDeleteArrowsInSubPhases()); - connect(&doNotDeleteArrowsInSubPhasesCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setDoNotDeleteArrowsInSubPhases); + annotateTokensCheckBox.setChecked(SettingsCache::instance().userInterface().getAnnotateTokens()); + connect(&annotateTokensCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(), + &InterfaceSettings::setAnnotateTokens); - closeEmptyCardViewCheckBox.setChecked(SettingsCache::instance().getCloseEmptyCardView()); - connect(&closeEmptyCardViewCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setCloseEmptyCardView); + showDragSelectionCountCheckBox.setChecked(SettingsCache::instance().userInterface().getShowDragSelectionCount()); + connect(&showDragSelectionCountCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(), + &InterfaceSettings::setShowDragSelectionCount); - focusCardViewSearchBarCheckBox.setChecked(SettingsCache::instance().getFocusCardViewSearchBar()); - connect(&focusCardViewSearchBarCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setFocusCardViewSearchBar); + showTotalSelectionCountCheckBox.setChecked(SettingsCache::instance().userInterface().getShowTotalSelectionCount()); + connect(&showTotalSelectionCountCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(), + &InterfaceSettings::setShowTotalSelectionCount); - annotateTokensCheckBox.setChecked(SettingsCache::instance().getAnnotateTokens()); - connect(&annotateTokensCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setAnnotateTokens); + useTearOffMenusCheckBox.setChecked(SettingsCache::instance().userInterface().getUseTearOffMenus()); + connect(&useTearOffMenusCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(), + [](const QT_STATE_CHANGED_T state) { + SettingsCache::instance().userInterface().setUseTearOffMenus(state == Qt::Checked); + }); - showDragSelectionCountCheckBox.setChecked(SettingsCache::instance().getShowDragSelectionCount()); - connect(&showDragSelectionCountCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setShowDragSelectionCount); - - showTotalSelectionCountCheckBox.setChecked(SettingsCache::instance().getShowTotalSelectionCount()); - connect(&showTotalSelectionCountCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setShowTotalSelectionCount); - - useTearOffMenusCheckBox.setChecked(SettingsCache::instance().getUseTearOffMenus()); - connect(&useTearOffMenusCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - [](const QT_STATE_CHANGED_T state) { SettingsCache::instance().setUseTearOffMenus(state == Qt::Checked); }); - - keepGameChatFocusCheckBox.setChecked(SettingsCache::instance().getKeepGameChatFocus()); - connect(&keepGameChatFocusCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setKeepGameChatFocus); + keepGameChatFocusCheckBox.setChecked(SettingsCache::instance().userInterface().getKeepGameChatFocus()); + connect(&keepGameChatFocusCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(), + &InterfaceSettings::setKeepGameChatFocus); auto *generalGrid = new QGridLayout; generalGrid->addWidget(&doubleClickToPlayCheckBox, 0, 0); @@ -92,6 +84,26 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage() generalGroupBox = new QGroupBox; generalGroupBox->setLayout(generalGrid); + // notification settings + notificationsEnabledCheckBox.setChecked(SettingsCache::instance().userInterface().getNotificationsEnabled()); + connect(¬ificationsEnabledCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(), + &InterfaceSettings::setNotificationsEnabled); + connect(¬ificationsEnabledCheckBox, &QCheckBox::QT_STATE_CHANGED, this, + &UserInterfaceSettingsPage::setNotificationEnabled); + + specNotificationsEnabledCheckBox.setChecked( + SettingsCache::instance().userInterface().getSpectatorNotificationsEnabled()); + specNotificationsEnabledCheckBox.setEnabled(SettingsCache::instance().userInterface().getNotificationsEnabled()); + connect(&specNotificationsEnabledCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(), + &InterfaceSettings::setSpectatorNotificationsEnabled); + + buddyConnectNotificationsEnabledCheckBox.setChecked( + SettingsCache::instance().userInterface().getBuddyConnectNotificationsEnabled()); + buddyConnectNotificationsEnabledCheckBox.setEnabled( + SettingsCache::instance().userInterface().getNotificationsEnabled()); + connect(&buddyConnectNotificationsEnabledCheckBox, &QCheckBox::QT_STATE_CHANGED, + &SettingsCache::instance().userInterface(), &InterfaceSettings::setBuddyConnectNotificationsEnabled); + auto *notificationsGrid = new QGridLayout; notificationsGrid->addWidget(¬ificationsEnabledCheckBox, 0, 0); notificationsGrid->addWidget(&specNotificationsEnabledCheckBox, 1, 0); @@ -101,53 +113,134 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage() notificationsGroupBox->setLayout(notificationsGrid); // animation settings - tapAnimationCheckBox.setChecked(SettingsCache::instance().getTapAnimation()); - connect(&tapAnimationCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setTapAnimation); + tapAnimationCheckBox.setChecked(SettingsCache::instance().cardsDisplay().getTapAnimation()); + connect(&tapAnimationCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().cardsDisplay(), + &CardsDisplaySettings::setTapAnimation); + + arrowDrawAnimationCheckBox.setChecked(SettingsCache::instance().cardsDisplay().getArrowDrawAnimation()); + connect(&arrowDrawAnimationCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().cardsDisplay(), + &CardsDisplaySettings::setArrowDrawAnimation); + + lifeCounterAnimationsCheckBox.setChecked( + SettingsCache::instance().userInterface().getLifeCounterAnimationsEnabled()); + connect(&lifeCounterAnimationsCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(), + &InterfaceSettings::setLifeCounterAnimationsEnabled); + + battlefieldFlashCheckBox.setChecked(SettingsCache::instance().userInterface().getBattlefieldFlashEnabled()); + connect(&battlefieldFlashCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(), + &InterfaceSettings::setBattlefieldFlashEnabled); + + connect(&enableAllAnimationsButton, &QPushButton::clicked, this, &UserInterfaceSettingsPage::enableAllAnimations); + connect(&disableAllAnimationsButton, &QPushButton::clicked, this, &UserInterfaceSettingsPage::disableAllAnimations); auto *animationGrid = new QGridLayout; - animationGrid->addWidget(&tapAnimationCheckBox, 0, 0); + animationGrid->addWidget(&enableAllAnimationsButton, 0, 0); + animationGrid->addWidget(&disableAllAnimationsButton, 0, 1); + animationGrid->addWidget(&tapAnimationCheckBox, 1, 0); + animationGrid->addWidget(&arrowDrawAnimationCheckBox, 2, 0); + animationGrid->addWidget(&lifeCounterAnimationsCheckBox, 3, 0); + animationGrid->addWidget(&battlefieldFlashCheckBox, 4, 0); animationGroupBox = new QGroupBox; animationGroupBox->setLayout(animationGrid); // deck editor settings - openDeckInNewTabCheckBox.setChecked(SettingsCache::instance().getOpenDeckInNewTab()); - connect(&openDeckInNewTabCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setOpenDeckInNewTab); + openDeckInNewTabCheckBox.setChecked(SettingsCache::instance().deckEditor().getOpenDeckInNewTab()); + connect(&openDeckInNewTabCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().deckEditor(), + &DeckEditorSettings::setOpenDeckInNewTab); - visualDeckStorageInGameCheckBox.setChecked(SettingsCache::instance().getVisualDeckStorageInGame()); - connect(&visualDeckStorageInGameCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setVisualDeckStorageInGame); + visualDeckStorageInGameCheckBox.setChecked( + SettingsCache::instance().visualDeckStorage().getVisualDeckStorageInGame()); + connect(&visualDeckStorageInGameCheckBox, &QCheckBox::QT_STATE_CHANGED, + &SettingsCache::instance().visualDeckStorage(), &VisualDeckStorageSettings::setVisualDeckStorageInGame); visualDeckStorageSelectionAnimationCheckBox.setChecked( - SettingsCache::instance().getVisualDeckStorageSelectionAnimation()); - connect(&visualDeckStorageSelectionAnimationCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setVisualDeckStorageSelectionAnimation); + SettingsCache::instance().visualDeckStorage().getVisualDeckStorageSelectionAnimation()); + connect(&visualDeckStorageSelectionAnimationCheckBox, &QCheckBox::QT_STATE_CHANGED, + &SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::setVisualDeckStorageSelectionAnimation); visualDeckStoragePromptForConversionSelector.addItem(""); // these will be set in retranslateUI visualDeckStoragePromptForConversionSelector.addItem(""); visualDeckStoragePromptForConversionSelector.addItem(""); - if (SettingsCache::instance().getVisualDeckStoragePromptForConversion()) { + if (SettingsCache::instance().visualDeckStorage().getVisualDeckStoragePromptForConversion()) { visualDeckStoragePromptForConversionSelector.setCurrentIndex(visualDeckStoragePromptForConversionIndexPrompt); - } else if (SettingsCache::instance().getVisualDeckStorageAlwaysConvert()) { + } else if (SettingsCache::instance().visualDeckStorage().getVisualDeckStorageAlwaysConvert()) { visualDeckStoragePromptForConversionSelector.setCurrentIndex(visualDeckStoragePromptForConversionIndexAlways); } else { visualDeckStoragePromptForConversionSelector.setCurrentIndex(visualDeckStoragePromptForConversionIndexNone); } connect(&visualDeckStoragePromptForConversionSelector, QOverload::of(&QComboBox::currentIndexChanged), this, [](int index) { - SettingsCache::instance().setVisualDeckStoragePromptForConversion( + SettingsCache::instance().visualDeckStorage().setVisualDeckStoragePromptForConversion( index == visualDeckStoragePromptForConversionIndexPrompt); - SettingsCache::instance().setVisualDeckStorageAlwaysConvert( + SettingsCache::instance().visualDeckStorage().setVisualDeckStorageAlwaysConvert( index == visualDeckStoragePromptForConversionIndexAlways); }); defaultDeckEditorTypeSelector.addItem(""); // these will be set in retranslateUI defaultDeckEditorTypeSelector.addItem(""); - defaultDeckEditorTypeSelector.setCurrentIndex(SettingsCache::instance().getDefaultDeckEditorType()); + defaultDeckEditorTypeSelector.setCurrentIndex(SettingsCache::instance().deckEditor().getDefaultDeckEditorType()); connect(&defaultDeckEditorTypeSelector, QOverload::of(&QComboBox::currentIndexChanged), - &SettingsCache::instance(), &SettingsCache::setDefaultDeckEditorType); + &SettingsCache::instance().deckEditor(), &DeckEditorSettings::setDefaultDeckEditorType); + + vdeStartupTabSelector.addItem(""); // these will be set in retranslateUI + vdeStartupTabSelector.addItem(""); + vdeStartupTabSelector.addItem(""); + vdeStartupTabSelector.setCurrentIndex(SettingsCache::instance().deckEditor().getVdeStartupTab()); + connect(&vdeStartupTabSelector, QOverload::of(&QComboBox::currentIndexChanged), + &SettingsCache::instance().deckEditor(), &DeckEditorSettings::setVdeStartupTab); + + commanderSpellbookIntegrationUseOfficialBracketNamesExplainer.setText("?"); + commanderSpellbookIntegrationUseOfficialBracketNamesExplainer.setAutoRaise(true); + commanderSpellbookIntegrationUseOfficialBracketNamesExplainer.setEnabled(false); + + // Add items with userData = internal enum + commanderSpellbookIntegrationEnabledSelector.addItem(tr("Disabled"), + commanderSpellbookIntegrationEnabledIndexDisabled); + commanderSpellbookIntegrationEnabledSelector.addItem(tr("Enabled"), + commanderSpellbookIntegrationEnabledIndexEnabled); + commanderSpellbookIntegrationEnabledSelector.addItem(tr("Automatic"), + commanderSpellbookIntegrationEnabledIndexAutomatic); + + int storedMode = SettingsCache::instance().deckEditor().getCommanderSpellbookIntegrationEnabled(); + for (int i = 0; i < commanderSpellbookIntegrationEnabledSelector.count(); ++i) { + if (commanderSpellbookIntegrationEnabledSelector.itemData(i).toInt() == storedMode) { + commanderSpellbookIntegrationEnabledSelector.setCurrentIndex(i); + break; + } + } + + connect(&commanderSpellbookIntegrationEnabledSelector, QOverload::of(&QComboBox::currentIndexChanged), this, + [this](int index) { + int mode = commanderSpellbookIntegrationEnabledSelector.itemData(index).toInt(); + SettingsCache::instance().deckEditor().setCommanderSpellbookIntegrationEnabled(mode); + updateCommanderSpellbookUiState(); + }); + + commanderSpellbookIntegrationBracketNamingSelector.addItem( + tr("CommanderSpellbook bracket names")); // index 0 = false + commanderSpellbookIntegrationBracketNamingSelector.addItem( + tr("Official Commander bracket names (approximate)")); // index 1 = true + + commanderSpellbookIntegrationBracketNamingSelector.setCurrentIndex( + SettingsCache::instance().deckEditor().getCommanderSpellbookIntegrationUseOfficialBracketNames() ? 1 : 0); + + connect(&commanderSpellbookIntegrationBracketNamingSelector, QOverload::of(&QComboBox::currentIndexChanged), + &SettingsCache::instance(), [](int index) { + SettingsCache::instance().deckEditor().setCommanderSpellbookIntegrationUseOfficialBracketNames(index == + 1); + }); + + updateCommanderSpellbookUiState(); + + auto *labelLayout = new QHBoxLayout; + labelLayout->setContentsMargins(0, 0, 0, 0); + labelLayout->addWidget(&commanderSpellbookIntegrationUseOfficialBracketNamesLabel); + labelLayout->addWidget(&commanderSpellbookIntegrationUseOfficialBracketNamesExplainer); + + auto *labelWidget = new QWidget; + labelWidget->setLayout(labelLayout); auto *deckEditorGrid = new QGridLayout; deckEditorGrid->addWidget(&openDeckInNewTabCheckBox, 0, 0); @@ -157,15 +250,21 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage() deckEditorGrid->addWidget(&visualDeckStoragePromptForConversionSelector, 3, 1); deckEditorGrid->addWidget(&defaultDeckEditorTypeLabel, 4, 0); deckEditorGrid->addWidget(&defaultDeckEditorTypeSelector, 4, 1); + deckEditorGrid->addWidget(&vdeStartupTabLabel, 5, 0); + deckEditorGrid->addWidget(&vdeStartupTabSelector, 5, 1); + deckEditorGrid->addWidget(&commanderSpellbookIntegrationEnabledLabel, 6, 0); + deckEditorGrid->addWidget(&commanderSpellbookIntegrationEnabledSelector, 6, 1); + deckEditorGrid->addWidget(labelWidget, 7, 0); + deckEditorGrid->addWidget(&commanderSpellbookIntegrationBracketNamingSelector, 7, 1); deckEditorGroupBox = new QGroupBox; deckEditorGroupBox->setLayout(deckEditorGrid); // replay settings rewindBufferingMsBox.setRange(0, 9999); - rewindBufferingMsBox.setValue(SettingsCache::instance().getRewindBufferingMs()); - connect(&rewindBufferingMsBox, qOverload(&QSpinBox::valueChanged), &SettingsCache::instance(), - &SettingsCache::setRewindBufferingMs); + rewindBufferingMsBox.setValue(SettingsCache::instance().userInterface().getRewindBufferingMs()); + connect(&rewindBufferingMsBox, qOverload(&QSpinBox::valueChanged), &SettingsCache::instance().userInterface(), + &InterfaceSettings::setRewindBufferingMs); auto *replayGrid = new QGridLayout; replayGrid->addWidget(&rewindBufferingMsLabel, 0, 0, 1, 1); @@ -185,7 +284,8 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage() setLayout(mainLayout); - connect(&SettingsCache::instance(), &SettingsCache::langChanged, this, &UserInterfaceSettingsPage::retranslateUi); + connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, + &UserInterfaceSettingsPage::retranslateUi); retranslateUi(); } @@ -199,6 +299,43 @@ void UserInterfaceSettingsPage::setNotificationEnabled(QT_STATE_CHANGED_T i) } } +void UserInterfaceSettingsPage::enableAllAnimations() +{ + tapAnimationCheckBox.setChecked(true); + arrowDrawAnimationCheckBox.setChecked(true); + lifeCounterAnimationsCheckBox.setChecked(true); + battlefieldFlashCheckBox.setChecked(true); +} + +void UserInterfaceSettingsPage::disableAllAnimations() +{ + tapAnimationCheckBox.setChecked(false); + arrowDrawAnimationCheckBox.setChecked(false); + lifeCounterAnimationsCheckBox.setChecked(false); + battlefieldFlashCheckBox.setChecked(false); +} + +void UserInterfaceSettingsPage::updateCommanderSpellbookUiState() +{ + const int mode = SettingsCache::instance().deckEditor().getCommanderSpellbookIntegrationEnabled(); + + const bool enabled = mode != commanderSpellbookIntegrationEnabledIndexDisabled && + mode != commanderSpellbookIntegrationEnabledIndexUnprompted; + + commanderSpellbookIntegrationBracketNamingSelector.setEnabled(enabled); + commanderSpellbookIntegrationUseOfficialBracketNamesExplainer.setEnabled(enabled); + commanderSpellbookIntegrationUseOfficialBracketNamesLabel.setVisible(enabled); + commanderSpellbookIntegrationUseOfficialBracketNamesExplainer.setVisible(enabled); + commanderSpellbookIntegrationBracketNamingSelector.setVisible(enabled); + + if (enabled) { + // Sync selector with the current stored bool + const bool useOfficial = + SettingsCache::instance().deckEditor().getCommanderSpellbookIntegrationUseOfficialBracketNames(); + commanderSpellbookIntegrationBracketNamingSelector.setCurrentIndex(useOfficial ? 1 : 0); + } +} + void UserInterfaceSettingsPage::retranslateUi() { generalGroupBox->setTitle(tr("General interface settings")); @@ -209,8 +346,8 @@ void UserInterfaceSettingsPage::retranslateUi() closeEmptyCardViewCheckBox.setText(tr("Close card view window when last card is removed")); focusCardViewSearchBarCheckBox.setText(tr("Auto focus search bar when card view window is opened")); annotateTokensCheckBox.setText(tr("Annotate card text on tokens")); - showDragSelectionCountCheckBox.setText(tr("Show selection counter during drag selection")); - showTotalSelectionCountCheckBox.setText(tr("Show total selection counter")); + showDragSelectionCountCheckBox.setText(tr("Show selection count during drag selection")); + showTotalSelectionCountCheckBox.setText(tr("Show total selection count")); useTearOffMenusCheckBox.setText(tr("Use tear-off menus, allowing right click menus to persist on screen")); keepGameChatFocusCheckBox.setText( tr("Keep game chat focused when clicking in game (Note: disables card view search bar)")); @@ -219,8 +356,15 @@ void UserInterfaceSettingsPage::retranslateUi() notificationsEnabledCheckBox.setText(tr("Enable notifications in taskbar")); specNotificationsEnabledCheckBox.setText(tr("Notify in the taskbar for game events while you are spectating")); buddyConnectNotificationsEnabledCheckBox.setText(tr("Notify in the taskbar when users in your buddy list connect")); + animationGroupBox->setTitle(tr("Animation settings")); + enableAllAnimationsButton.setText(tr("&Enable all animations")); + disableAllAnimationsButton.setText(tr("&Disable all animations")); tapAnimationCheckBox.setText(tr("&Tap/untap animation")); + arrowDrawAnimationCheckBox.setText(tr("&Arrow draw animation")); + lifeCounterAnimationsCheckBox.setText(tr("Life counter flash")); + battlefieldFlashCheckBox.setText(tr("Battlefield flash on damage")); + deckEditorGroupBox->setTitle(tr("Deck editor/storage settings")); openDeckInNewTabCheckBox.setText(tr("Open deck in new tab by default")); visualDeckStorageInGameCheckBox.setText(tr("Use visual deck storage in game lobby")); @@ -228,15 +372,37 @@ void UserInterfaceSettingsPage::retranslateUi() visualDeckStoragePromptForConversionLabel.setText( tr("When adding a tag in the visual deck storage to a .txt deck:")); visualDeckStoragePromptForConversionSelector.setItemText(visualDeckStoragePromptForConversionIndexNone, - tr("do nothing")); + tr("Do nothing")); visualDeckStoragePromptForConversionSelector.setItemText(visualDeckStoragePromptForConversionIndexPrompt, - tr("ask to convert to .cod")); + tr("Ask to convert to .cod")); visualDeckStoragePromptForConversionSelector.setItemText(visualDeckStoragePromptForConversionIndexAlways, - tr("always convert to .cod")); + tr("Always convert to .cod")); defaultDeckEditorTypeLabel.setText(tr("Default deck editor type")); defaultDeckEditorTypeSelector.setItemText(TabSupervisor::ClassicDeckEditor, tr("Classic Deck Editor")); defaultDeckEditorTypeSelector.setItemText(TabSupervisor::VisualDeckEditor, tr("Visual Deck Editor")); + vdeStartupTabLabel.setText(tr("Visual deck editor startup tab")); + vdeStartupTabSelector.setItemText(VdeStartupTabContext, tr("Context")); + vdeStartupTabSelector.setItemText(VdeStartupTabDeckDisplay, tr("Deck display")); + vdeStartupTabSelector.setItemText(VdeStartupTabDatabaseDisplay, tr("Database display")); + vdeStartupTabSelector.setToolTip( + tr("Context mode: New decks open on the database display, existing decks open on the deck view.")); + + commanderSpellbookIntegrationEnabledLabel.setText( + tr("CommanderSpellbook integration to estimate commander bracket")); + commanderSpellbookIntegrationEnabledSelector.setItemText(commanderSpellbookIntegrationEnabledIndexDisabled, + tr("Disabled")); + commanderSpellbookIntegrationEnabledSelector.setItemText(commanderSpellbookIntegrationEnabledIndexEnabled, + tr("Enabled")); + commanderSpellbookIntegrationEnabledSelector.setItemText(commanderSpellbookIntegrationEnabledIndexAutomatic, + tr("Automatic")); + commanderSpellbookIntegrationUseOfficialBracketNamesLabel.setText(tr("Bracket naming")); + commanderSpellbookIntegrationBracketNamingSelector.setItemText( + 0, CommanderBracketNames::CommanderSpellbookBracketNames); + commanderSpellbookIntegrationBracketNamingSelector.setItemText( + 1, CommanderBracketNames::OfficialCommanderBracketNames); + commanderSpellbookIntegrationUseOfficialBracketNamesExplainer.setToolTip(CommanderBracketNames::Explainer); + replayGroupBox->setTitle(tr("Replay settings")); rewindBufferingMsLabel.setText(tr("Buffer time for backwards skip via shortcut:")); rewindBufferingMsBox.setSuffix(" ms"); -} \ No newline at end of file +} diff --git a/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.h b/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.h index e10ed2a06..e8a30fb1f 100644 --- a/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.h @@ -7,7 +7,10 @@ #include #include #include +#include #include +#include +#include #include class UserInterfaceSettingsPage : public AbstractSettingsPage @@ -15,11 +18,11 @@ class UserInterfaceSettingsPage : public AbstractSettingsPage Q_OBJECT private slots: void setNotificationEnabled(QT_STATE_CHANGED_T); + void enableAllAnimations(); + void disableAllAnimations(); + void updateCommanderSpellbookUiState(); private: - QCheckBox notificationsEnabledCheckBox; - QCheckBox specNotificationsEnabledCheckBox; - QCheckBox buddyConnectNotificationsEnabledCheckBox; QCheckBox doubleClickToPlayCheckBox; QCheckBox clickPlaysAllSelectedCheckBox; QCheckBox playToStackCheckBox; @@ -31,7 +34,18 @@ private: QCheckBox showTotalSelectionCountCheckBox; QCheckBox useTearOffMenusCheckBox; QCheckBox keepGameChatFocusCheckBox; + + QCheckBox notificationsEnabledCheckBox; + QCheckBox specNotificationsEnabledCheckBox; + QCheckBox buddyConnectNotificationsEnabledCheckBox; + + QPushButton enableAllAnimationsButton; + QPushButton disableAllAnimationsButton; QCheckBox tapAnimationCheckBox; + QCheckBox arrowDrawAnimationCheckBox; + QCheckBox lifeCounterAnimationsCheckBox; + QCheckBox battlefieldFlashCheckBox; + QCheckBox openDeckInNewTabCheckBox; QLabel visualDeckStoragePromptForConversionLabel; QComboBox visualDeckStoragePromptForConversionSelector; @@ -39,8 +53,17 @@ private: QCheckBox visualDeckStorageSelectionAnimationCheckBox; QLabel defaultDeckEditorTypeLabel; QComboBox defaultDeckEditorTypeSelector; + QLabel vdeStartupTabLabel; + QComboBox vdeStartupTabSelector; + QLabel commanderSpellbookIntegrationEnabledLabel; + QComboBox commanderSpellbookIntegrationEnabledSelector; + QLabel commanderSpellbookIntegrationUseOfficialBracketNamesLabel; + QToolButton commanderSpellbookIntegrationUseOfficialBracketNamesExplainer; + QComboBox commanderSpellbookIntegrationBracketNamingSelector; + QLabel rewindBufferingMsLabel; QSpinBox rewindBufferingMsBox; + QGroupBox *generalGroupBox; QGroupBox *notificationsGroupBox; QGroupBox *animationGroupBox; diff --git a/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.cpp b/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.cpp index a8cc4cee6..f80649eba 100644 --- a/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.cpp +++ b/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.cpp @@ -10,6 +10,7 @@ #include "abstract_tab_deck_editor.h" #include "../../../client/settings/cache_settings.h" +#include "../../../client/settings/shortcuts_settings.h" #include "../client/network/interfaces/deck_stats_interface.h" #include "../client/network/interfaces/tapped_out_interface.h" #include "../deck_editor/deck_state_manager.h" @@ -43,7 +44,10 @@ #include #include #include -#include +#include +#include +#include +#include /** * @brief Constructs the AbstractTabDeckEditor. @@ -199,7 +203,7 @@ void AbstractTabDeckEditor::cleanDeckAndResetModified() */ AbstractTabDeckEditor::DeckOpenLocation AbstractTabDeckEditor::confirmOpen(const bool openInSameTabIfBlank) { - if (SettingsCache::instance().getOpenDeckInNewTab()) { + if (SettingsCache::instance().deckEditor().getOpenDeckInNewTab()) { if (openInSameTabIfBlank && deckStateManager->isBlankNewDeck()) { return SAME_TAB; } else { @@ -350,7 +354,7 @@ bool AbstractTabDeckEditor::actSaveDeckAs() DeckList deckList = deckStateManager->getDeckList(); QFileDialog dialog(this, tr("Save deck")); - dialog.setDirectory(SettingsCache::instance().getDeckPath()); + dialog.setDirectory(SettingsCache::instance().paths().getDeckPath()); dialog.setAcceptMode(QFileDialog::AcceptSave); dialog.setDefaultSuffix("cod"); dialog.setNameFilters(DeckLoader::FILE_NAME_FILTERS); diff --git a/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.h b/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.h index 34c585597..a3cda2bfc 100644 --- a/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.h +++ b/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.h @@ -8,6 +8,7 @@ #ifndef TAB_GENERIC_DECK_EDITOR_H #define TAB_GENERIC_DECK_EDITOR_H +#include "../../deck_loader/deck_loader.h" #include "../interface/widgets/deck_editor/deck_editor_card_database_dock_widget.h" #include "../interface/widgets/deck_editor/deck_editor_card_info_dock_widget.h" #include "../interface/widgets/deck_editor/deck_editor_database_display_widget.h" @@ -263,12 +264,12 @@ protected slots: /** @brief Handles dock close events. */ void closeEvent(QCloseEvent *event) override; -private: /** @brief Sets the deck for this tab. * @param _deck The deck object. */ virtual void setDeck(const LoadedDeck &_deck); +private: /** @brief Helper for editing decks from the clipboard. */ void editDeckInClipboard(bool annotated); diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_display_widget.cpp index 66b68d823..657ef3dbe 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_display_widget.cpp @@ -1,5 +1,6 @@ #include "archidekt_api_response_deck_display_widget.h" +#include "../../../../../../client/settings/cache_settings.h" #include "../../../../../deck_loader/card_node_function.h" #include "../../../../../deck_loader/deck_loader.h" #include "../../../../cards/card_size_widget.h" @@ -10,6 +11,7 @@ #include #include +#include ArchidektApiResponseDeckDisplayWidget::ArchidektApiResponseDeckDisplayWidget(QWidget *parent, ArchidektApiResponseDeck _response, @@ -120,6 +122,9 @@ ArchidektApiResponseDeckDisplayWidget::ArchidektApiResponseDeckDisplayWidget(QWi } model = new DeckListModel(this); + model->setDisplayLanguage(SettingsCache::instance().cardsDisplay().getCardLang()); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, model, + [this](const QString &lang) { model->setDisplayLanguage(lang); }); connect(model, &DeckListModel::modelReset, this, &ArchidektApiResponseDeckDisplayWidget::decklistModelReset); auto decklist = QSharedPointer(new DeckList); diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_entry_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_entry_display_widget.cpp index 70156df79..c35c2d0b8 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_entry_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_entry_display_widget.cpp @@ -137,11 +137,7 @@ void ArchidektApiResponseDeckEntryDisplayWidget::mousePressEvent(QMouseEvent *ev actRequestNavigationToDeck(); } -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) void ArchidektApiResponseDeckEntryDisplayWidget::enterEvent(QEnterEvent *event) -#else -void ArchidektApiResponseDeckEntryDisplayWidget::enterEvent(QEvent *event) -#endif { QWidget::enterEvent(event); backgroundPlateWidget->setFocused(true); diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_entry_display_widget.h b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_entry_display_widget.h index 365a99c9c..575a81b5f 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_entry_display_widget.h +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_entry_display_widget.h @@ -98,11 +98,7 @@ public slots: protected: void mousePressEvent(QMouseEvent *event) override; -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) - void enterEvent(QEnterEvent *event) override; ///< Qt6 hover enter -#else - void enterEvent(QEvent *event) override; ///< Qt5 hover enter -#endif + void enterEvent(QEnterEvent *event) override; ///< Hover enter void leaveEvent(QEvent *event) override; private: diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp index 9cac74d50..888197f53 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp @@ -1,13 +1,14 @@ #include "tab_archidekt.h" #include "../../../../../client/settings/cache_settings.h" +#include "../../../../pixel_map_generator.h" #include "../../../cards/additional_info/mana_symbol_widget.h" +#include "../../../utility/completer_utils.h" #include "../../tab_supervisor.h" #include "api_response/archidekt_deck_listing_api_response.h" #include "display/archidekt_api_response_deck_display_widget.h" #include "display/archidekt_api_response_deck_listings_display_widget.h" -#include #include #include #include @@ -19,7 +20,6 @@ #include #include #include -#include #include #include #include @@ -27,6 +27,7 @@ #include #include #include +#include #include TabArchidekt::TabArchidekt(TabSupervisor *_tabSupervisor) @@ -131,7 +132,8 @@ void TabArchidekt::initializeUi() // Settings settingsButton = new SettingsButtonWidget(primaryToolbar); - cardSizeSlider = new CardSizeWidget(primaryToolbar, nullptr, SettingsCache::instance().getArchidektPreviewSize()); + cardSizeSlider = + new CardSizeWidget(primaryToolbar, nullptr, SettingsCache::instance().cardsDisplay().getArchidektPreviewSize()); settingsButton->addSettingsWidget(cardSizeSlider); // Assemble primary toolbar @@ -212,7 +214,7 @@ void TabArchidekt::setupFilterWidgets() // Format filter (collapsible) formatButton = new SettingsButtonWidget(secondaryToolbar); formatButton->setButtonText(tr("Formats")); - formatButton->setButtonIcon(QPixmap("theme:icons/scale_balanced")); + formatButton->setButtonIcon(themePixmap(QStringLiteral("icons/scale_balanced"))); QWidget *formatContainer = new QWidget(secondaryToolbar); QGridLayout *formatLayout = new QGridLayout(formatContainer); @@ -277,41 +279,14 @@ void TabArchidekt::setupFilterWidgets() auto cardDatabaseModel = new CardDatabaseModel(CardDatabaseManager::getInstance(), false, this); auto displayModel = new CardDatabaseDisplayModel(this); displayModel->setSourceModel(cardDatabaseModel); - auto *searchModel = new CardSearchModel(displayModel, this); - auto *proxyModel = new CardCompleterProxyModel(this); - proxyModel->setSourceModel(searchModel); - proxyModel->setFilterCaseSensitivity(Qt::CaseInsensitive); - proxyModel->setFilterRole(Qt::DisplayRole); - - auto *completer = new QCompleter(proxyModel, this); - completer->setCompletionRole(Qt::DisplayRole); - completer->setCompletionMode(QCompleter::PopupCompletion); - completer->setCaseSensitivity(Qt::CaseInsensitive); - completer->setFilterMode(Qt::MatchContains); - completer->setMaxVisibleItems(10); - - cardsField->setCompleter(completer); - commandersField->setCompleter(completer); + const CardCompleterSetup cardSetup = createCardCompleter(displayModel, this); + cardsField->setCompleter(cardSetup.completer); + commandersField->setCompleter(cardSetup.completer); // Keep autocomplete working for both fields - connect(cardsField, &QLineEdit::textChanged, this, [=](const QString &text) { - searchModel->updateSearchResults(text); - QString pattern = ".*" + QRegularExpression::escape(text) + ".*"; - proxyModel->setFilterRegularExpression(QRegularExpression(pattern, QRegularExpression::CaseInsensitiveOption)); - if (!text.isEmpty()) { - completer->complete(); - } - }); - - connect(commandersField, &QLineEdit::textChanged, this, [=](const QString &text) { - searchModel->updateSearchResults(text); - QString pattern = ".*" + QRegularExpression::escape(text) + ".*"; - proxyModel->setFilterRegularExpression(QRegularExpression(pattern, QRegularExpression::CaseInsensitiveOption)); - if (!text.isEmpty()) { - completer->complete(); - } - }); + connectCardCompleterSearch(cardsField, cardSetup); + connectCardCompleterSearch(commandersField, cardSetup); // Assemble secondary toolbar secondaryToolbarLayout->addWidget(bracketLabel); @@ -337,8 +312,8 @@ void TabArchidekt::connectSignals() doSearch(); }); - connect(cardSizeSlider, &CardSizeWidget::cardSizeSettingUpdated, &SettingsCache::instance(), - &SettingsCache::setArchidektPreviewCardSize); + connect(cardSizeSlider, &CardSizeWidget::cardSizeSettingUpdated, &SettingsCache::instance().cardsDisplay(), + &CardsDisplaySettings::setArchidektPreviewCardSize); // Search button triggers immediate search connect(searchButton, &QPushButton::clicked, this, &TabArchidekt::doSearchImmediate); @@ -639,7 +614,7 @@ void TabArchidekt::processDeckResponse(QJsonObject reply) auto display = new ArchidektApiResponseDeckDisplayWidget(resultsContainer, deckData, cardSizeSlider); connect(display, &ArchidektApiResponseDeckDisplayWidget::requestNavigation, this, &TabArchidekt::actNavigatePage); - connect(display, &ArchidektApiResponseDeckDisplayWidget::requestSearch, this, &TabArchidekt::doSearchImmediate); + connect(display, &ArchidektApiResponseDeckDisplayWidget::requestSearch, this, &TabArchidekt::doSearch); connect(display, &ArchidektApiResponseDeckDisplayWidget::openInDeckEditor, tabSupervisor, &TabSupervisor::openDeckInNewTab); resultsLayout->addWidget(display); @@ -670,4 +645,4 @@ void TabArchidekt::prettyPrintJson(const QJsonValue &value, int indentLevel) } else if (value.isNull()) { qInfo().noquote() << indent + "null"; } -} \ No newline at end of file +} diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/card_in_deck_request.cpp b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/card_in_deck_request.cpp new file mode 100644 index 000000000..3c95a4553 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/card_in_deck_request.cpp @@ -0,0 +1,17 @@ +#include "card_in_deck_request.h" + +CardInDeckRequest CardInDeckRequest::fromJson(const QJsonObject &json) +{ + CardInDeckRequest request; + request.card = json.value("card").toString(); + request.quantity = json.value("quantity").toInt(); + return request; +} + +QJsonObject CardInDeckRequest::toJson() const +{ + QJsonObject json; + json.insert("card", card); + json.insert("quantity", quantity); + return json; +} diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/card_in_deck_request.h b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/card_in_deck_request.h new file mode 100644 index 000000000..b7eb96211 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/card_in_deck_request.h @@ -0,0 +1,14 @@ +#ifndef COCKATRICE_CARD_IN_DECK_REQUEST_H +#define COCKATRICE_CARD_IN_DECK_REQUEST_H +#include + +struct CardInDeckRequest +{ + static CardInDeckRequest fromJson(const QJsonObject &json); + QJsonObject toJson() const; + + QString card; + int quantity; +}; + +#endif // COCKATRICE_CARD_IN_DECK_REQUEST_H diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_card_result.cpp b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_card_result.cpp new file mode 100644 index 000000000..14558d234 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_card_result.cpp @@ -0,0 +1,26 @@ +#include "commander_spellbook_card_result.h" + +CommanderSpellbookCardResult CommanderSpellbookCardResult::fromJson(const QJsonObject &json) +{ + CommanderSpellbookCardResult result; + + result.id = json.value("id").toString(); + result.name = json.value("name").toString(); + result.oracleId = json.value("oracleId").toString(); + result.spoiler = json.value("spoiler").toBool(); + result.typeLine = json.value("typeLine").toString(); + + result.imageUriFrontPng = json.value("imageUriFrontPng").toString(); + result.imageUriFrontLarge = json.value("imageUriFrontLarge").toString(); + result.imageUriFrontNormal = json.value("imageUriFrontNormal").toString(); + result.imageUriFrontSmall = json.value("imageUriFrontSmall").toString(); + result.imageUriFrontArtCrop = json.value("imageUriFrontArtCrop").toString(); + + result.imageUriBackPng = json.value("imageUriBackPng").toString(); + result.imageUriBackLarge = json.value("imageUriBackLarge").toString(); + result.imageUriBackNormal = json.value("imageUriBackNormal").toString(); + result.imageUriBackSmall = json.value("imageUriBackSmall").toString(); + result.imageUriBackArtCrop = json.value("imageUriBackArtCrop").toString(); + + return result; +} diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_card_result.h b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_card_result.h new file mode 100644 index 000000000..eaa474097 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_card_result.h @@ -0,0 +1,29 @@ +#ifndef COCKATRICE_COMMANDER_SPELLBOOK_CARD_RESULT_H +#define COCKATRICE_COMMANDER_SPELLBOOK_CARD_RESULT_H +#include +#include + +struct CommanderSpellbookCardResult +{ + static CommanderSpellbookCardResult fromJson(const QJsonObject &json); + + QString id; + QString name; + QString oracleId; + bool spoiler = false; + QString typeLine; + + QString imageUriFrontPng; + QString imageUriFrontLarge; + QString imageUriFrontNormal; + QString imageUriFrontSmall; + QString imageUriFrontArtCrop; + + QString imageUriBackPng; + QString imageUriBackLarge; + QString imageUriBackNormal; + QString imageUriBackSmall; + QString imageUriBackArtCrop; +}; + +#endif // COCKATRICE_COMMANDER_SPELLBOOK_CARD_RESULT_H diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_deck_request.cpp b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_deck_request.cpp new file mode 100644 index 000000000..166469353 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_deck_request.cpp @@ -0,0 +1,95 @@ +#include "commander_spellbook_deck_request.h" + +#include +#include + +CommanderSpellbookDeckRequest CommanderSpellbookDeckRequest::fromJson(const QJsonObject &json) +{ + CommanderSpellbookDeckRequest request; + + // Main deck + const QJsonArray mainArray = json.value("main").toArray(); + for (const QJsonValue &value : mainArray) { + if (!value.isObject()) { + continue; + } + + request.mainDeck.append(CardInDeckRequest::fromJson(value.toObject())); + + // Max size allowed by commanderspellbook + if (request.mainDeck.size() >= 600) { + break; + } + } + + // Commanders + const QJsonArray commanderArray = json.value("commanders").toArray(); + for (const QJsonValue &value : commanderArray) { + if (!value.isObject()) { + continue; + } + + request.commanderDeck.append(CardInDeckRequest::fromJson(value.toObject())); + + // Max size allowed by commanderspellbook + if (request.commanderDeck.size() >= 12) { + break; + } + } + + return request; +} + +QJsonObject CommanderSpellbookDeckRequest::toJson() const +{ + QJsonObject json; + + QJsonArray mainArray; + for (const CardInDeckRequest &card : mainDeck) { + mainArray.append(card.toJson()); + } + + QJsonArray commanderArray; + for (const CardInDeckRequest &card : commanderDeck) { + commanderArray.append(card.toJson()); + } + + json.insert("main", mainArray); + json.insert("commanders", commanderArray); + + return json; +} + +CommanderSpellbookDeckRequest CommanderSpellbookDeckRequest::fromDeckList(const DeckList &deck) +{ + CommanderSpellbookDeckRequest request; + + // --- Mainboard --- + const auto mainCards = deck.getCardNodes({DECK_ZONE_MAIN}); + for (const DecklistCardNode *node : mainCards) { + if (!node) { + continue; + } + + QJsonObject json; + json.insert("card", node->getName()); + json.insert("quantity", node->getNumber()); + request.mainDeck.append(CardInDeckRequest::fromJson(json)); + + // Max size allowed by commanderspellbook + if (request.mainDeck.size() >= 600) { + break; + } + } + + // --- Commander (bannerCard) --- + const auto &metadata = deck.getMetadata(); + if (!metadata.bannerCard.name.isEmpty()) { + QJsonObject json; + json.insert("card", metadata.bannerCard.name); + json.insert("quantity", 1); + request.commanderDeck.append(CardInDeckRequest::fromJson(json)); + } + + return request; +} diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_deck_request.h b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_deck_request.h new file mode 100644 index 000000000..46d8e053b --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_deck_request.h @@ -0,0 +1,19 @@ +#ifndef COCKATRICE_COMMANDER_SPELLBOOK_DECK_REQUEST_H +#define COCKATRICE_COMMANDER_SPELLBOOK_DECK_REQUEST_H +#include "card_in_deck_request.h" +#include "libcockatrice/deck_list/deck_list.h" + +#include +#include + +struct CommanderSpellbookDeckRequest +{ + static CommanderSpellbookDeckRequest fromJson(const QJsonObject &json); + static CommanderSpellbookDeckRequest fromDeckList(const DeckList &deck); + QJsonObject toJson() const; + + QList mainDeck; // maxItems: 600 + QList commanderDeck; // maxItems: 12 +}; + +#endif // COCKATRICE_COMMANDER_SPELLBOOK_DECK_REQUEST_H diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_estimate_bracket_result.cpp b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_estimate_bracket_result.cpp new file mode 100644 index 000000000..c3640ce79 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_estimate_bracket_result.cpp @@ -0,0 +1,93 @@ +#include "commander_spellbook_estimate_bracket_result.h" + +EstimateBracketResult EstimateBracketResult::fromJson(const QJsonObject &json) +{ + EstimateBracketResult result; + + result.bracketTag = json.value("bracketTag").toString(); + + // + // Cards + // + for (const auto &value : json.value("cards").toArray()) { + if (!value.isObject()) { + continue; + } + + const QJsonObject obj = value.toObject(); + + CommanderSpellbookCardResult card = CommanderSpellbookCardResult::fromJson(obj.value("card").toObject()); + + if (obj.value("gameChanger").toBool()) { + result.gameChangerCards.append(card); + } + + if (obj.value("massLandDenial").toBool()) { + result.massLandDenialCards.append(card); + } + + if (obj.value("extraTurn").toBool()) { + result.extraTurnCards.append(card); + } + } + + // + // Templates + // + for (const auto &value : json.value("templates").toArray()) { + if (!value.isObject()) { + continue; + } + + const QJsonObject obj = value.toObject(); + + CommanderSpellbookVariantResult variant = CommanderSpellbookVariantResult::fromJson(obj); + + if (obj.value("massLandDenial").toBool()) { + result.massLandDenialTemplates.append(variant); + } + + if (obj.value("extraTurn").toBool()) { + result.extraTurnTemplates.append(variant); + } + } + + // + // Combos + // + for (const auto &value : json.value("combos").toArray()) { + if (!value.isObject()) { + continue; + } + + const QJsonObject obj = value.toObject(); + + CommanderSpellbookVariantResult combo = CommanderSpellbookVariantResult::fromJson(obj); + + if (obj.value("massLandDenial").toBool()) { + result.massLandDenialCombos.append(combo); + } + + if (obj.value("extraTurn").toBool()) { + result.extraTurnCombos.append(combo); + } + + if (obj.value("lock").toBool()) { + result.lockCombos.append(combo); + } + + if (obj.value("skipTurns").toBool()) { + result.skipTurnsCombos.append(combo); + } + + if (obj.value("definitelyTwoCard").toBool()) { + result.definitelyTwoCardCombos.append(combo); + } + + if (obj.value("arguablyTwoCard").toBool()) { + result.arguablyTwoCardCombos.append(combo); + } + } + + return result; +} diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_estimate_bracket_result.h b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_estimate_bracket_result.h new file mode 100644 index 000000000..bb589a901 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_estimate_bracket_result.h @@ -0,0 +1,32 @@ +#ifndef COCKATRICE_COMMANDER_SPELLBOOK_ESTIMATE_BRACKET_RESULT_H +#define COCKATRICE_COMMANDER_SPELLBOOK_ESTIMATE_BRACKET_RESULT_H + +#include "commander_spellbook_card_result.h" +#include "commander_spellbook_variant_result.h" + +#include +#include + +struct EstimateBracketResult +{ + static EstimateBracketResult fromJson(const QJsonObject &json); + + QString bracketTag; + + QList gameChangerCards; + QList massLandDenialCards; + QList extraTurnCards; + + QList massLandDenialTemplates; + QList extraTurnTemplates; + + QList massLandDenialCombos; + QList extraTurnCombos; + QList lockCombos; + QList skipTurnsCombos; + + QList definitelyTwoCardCombos; + QList arguablyTwoCardCombos; +}; + +#endif // COCKATRICE_COMMANDER_SPELLBOOK_ESTIMATE_BRACKET_RESULT_H diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_variant_result.cpp b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_variant_result.cpp new file mode 100644 index 000000000..bbc23e15e --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_variant_result.cpp @@ -0,0 +1,35 @@ +#include "commander_spellbook_variant_result.h" + +CommanderSpellbookVariantResult CommanderSpellbookVariantResult::fromJson(const QJsonObject &json) +{ + CommanderSpellbookVariantResult result; + + result.id = json.value("id").toString(); + result.status = json.value("status").toString(); + + result.uses = json.value("uses").toArray(); + result.cardRequires = json.value("requires").toArray(); + result.produces = json.value("produces").toArray(); + result.of = json.value("of").toArray(); + result.includes = json.value("includes").toArray(); + + result.manaNeeded = json.value("manaNeeded").toArray(); + result.manaValueNeeded = json.value("manaValueNeeded").toArray(); + + result.easyPrerequisites = json.value("easyPrerequisites").toArray(); + result.notablePrerequisites = json.value("notablePrerequisites").toArray(); + + result.description = json.value("description").toString(); + result.notes = json.value("notes").toString(); + result.popularity = json.value("popularity").toDouble(); + + result.spoiler = json.value("spoiler").toBool(); + result.bracketTag = json.value("bracketTag").toString(); + + result.legalities = json.value("legalities").toObject(); + result.prices = json.value("prices").toObject(); + + result.variantCount = json.value("variantCount").toInt(); + + return result; +} diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_variant_result.h b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_variant_result.h new file mode 100644 index 000000000..d908f1299 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_variant_result.h @@ -0,0 +1,39 @@ +#ifndef COCKATRICE_COMMANDER_SPELLBOOK_VARIANT_RESULT_H +#define COCKATRICE_COMMANDER_SPELLBOOK_VARIANT_RESULT_H + +#include +#include + +struct CommanderSpellbookVariantResult +{ + static CommanderSpellbookVariantResult fromJson(const QJsonObject &json); + + QString id; + QString status; + + QJsonArray uses; + QJsonArray cardRequires; + QJsonArray produces; + QJsonArray of; + QJsonArray includes; + + QJsonArray manaNeeded; + QJsonArray manaValueNeeded; + + QJsonArray easyPrerequisites; + QJsonArray notablePrerequisites; + + QString description; + QString notes; + double popularity = 0.0; + + bool spoiler = false; + QString bracketTag; + + QJsonObject legalities; + QJsonObject prices; + + int variantCount = 0; +}; + +#endif // COCKATRICE_COMMANDER_SPELLBOOK_VARIANT_RESULT_H diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_service.cpp b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_service.cpp new file mode 100644 index 000000000..b85129a3f --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_service.cpp @@ -0,0 +1,53 @@ +#include "commander_bracket_service.h" + +#include "../../../../../client/settings/cache_settings.h" + +#include + +CommanderBracketService &CommanderBracketService::instance() +{ + static CommanderBracketService service; + return service; +} + +CommanderBracketService::CommanderBracketService(QObject *parent) : QObject(parent) +{ + connect(&CommanderSpellbookApiAccessor::instance(), &CommanderSpellbookApiAccessor::estimateBracketFinished, this, + &CommanderBracketService::onEstimateBracketFinished); + + connect(&CommanderSpellbookApiAccessor::instance(), &CommanderSpellbookApiAccessor::estimateBracketError, this, + &CommanderBracketService::onEstimateBracketError); +} + +quint64 CommanderBracketService::estimateBracket(const DeckList &deck, QObject *requester) +{ + return CommanderSpellbookApiAccessor::instance().estimateBracket(deck, requester); +} + +void CommanderBracketService::onEstimateBracketFinished(CommanderSpellbookApiAccessor::RequestId id, + QObject *requester, + const EstimateBracketResult &result) +{ + CommanderBracketEstimate estimate; + + estimate.bracketTag = result.bracketTag; + + estimate.rawResult = result; + + auto &brackets = SettingsCache::instance().commanderBrackets(); + + estimate.officialName = brackets.officialName(result.bracketTag); + + estimate.displayName = brackets.displayName(result.bracketTag); + + estimate.explanation = brackets.explanation(result.bracketTag); + + emit estimateFinished(id, requester, estimate); +} + +void CommanderBracketService::onEstimateBracketError(CommanderSpellbookApiAccessor::RequestId id, + QObject *requester, + const QString &error) +{ + emit estimateError(id, requester, error); +} diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_service.h b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_service.h new file mode 100644 index 000000000..e0c2a9d55 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_service.h @@ -0,0 +1,45 @@ +#ifndef COCKATRICE_COMMANDER_BRACKET_SERVICE_H +#define COCKATRICE_COMMANDER_BRACKET_SERVICE_H + +#include "commander_spellbook_api_accessor.h" +#include "libcockatrice/deck_list/deck_list.h" + +#include + +struct CommanderBracketEstimate +{ + QString bracketTag; + + QString officialName; + QString displayName; + QString explanation; + + EstimateBracketResult rawResult; +}; + +class CommanderBracketService : public QObject +{ + Q_OBJECT + +public: + static CommanderBracketService &instance(); + + quint64 estimateBracket(const DeckList &deck, QObject *requester); + +signals: + void estimateFinished(quint64 requestId, QObject *requester, const CommanderBracketEstimate &estimate); + + void estimateError(quint64 requestId, QObject *requester, const QString &error); + +private slots: + void onEstimateBracketFinished(CommanderSpellbookApiAccessor::RequestId id, + QObject *requester, + const EstimateBracketResult &result); + + void onEstimateBracketError(CommanderSpellbookApiAccessor::RequestId id, QObject *requester, const QString &error); + +private: + explicit CommanderBracketService(QObject *parent = nullptr); +}; + +#endif diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp new file mode 100644 index 000000000..2cbbedfa4 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp @@ -0,0 +1,270 @@ +#include "commander_bracket_widget.h" + +#include "../../../../../client/settings/cache_settings.h" +#include "../../../../pixel_map_generator.h" +#include "commander_bracket_service.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +CommanderBracketWidget::CommanderBracketWidget(QWidget *parent) : QWidget(parent) +{ + bracketLabel = new QLabel(tr("Bracket:"), this); + + bracketValueLabel = new QLabel(this); + bracketValueLabel->setText("-"); + bracketValueLabel->setObjectName("bracketValueLabel"); + + bracketInfoButton = new QToolButton(this); + bracketInfoButton->setText("?"); + bracketInfoButton->setAutoRaise(true); + bracketInfoButton->setEnabled(false); + + bracketRefreshButton = new QToolButton(this); + bracketRefreshButton->setIcon(themePixmap(QStringLiteral("icons/reload"))); + bracketRefreshButton->setAutoRaise(true); + + connect(bracketRefreshButton, &QToolButton::clicked, this, &CommanderBracketWidget::requestBracketEstimate); + + auto *layout = new QGridLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->addWidget(bracketLabel, 0, 0); + + auto *bracketRow = new QHBoxLayout; + bracketRow->addWidget(bracketValueLabel); + bracketRow->addWidget(bracketInfoButton); + bracketRow->addWidget(bracketRefreshButton); + bracketRow->addStretch(); + + layout->addLayout(bracketRow, 0, 1); + + connect(&CommanderBracketService::instance(), &CommanderBracketService::estimateFinished, this, + &CommanderBracketWidget::onEstimateBracketFinished); + connect(&CommanderBracketService::instance(), &CommanderBracketService::estimateError, this, + &CommanderBracketWidget::onEstimateBracketError); + + connect(&SettingsCache::instance().deckEditor(), &DeckEditorSettings::commanderSpellbookIntegrationEnabledChanged, + this, &CommanderBracketWidget::maybeAutoEstimateBracket); + connect(&SettingsCache::instance().deckEditor(), + &DeckEditorSettings::commanderSpellbookIntegrationUseOfficialBracketNamesChanged, this, + &CommanderBracketWidget::maybeAutoEstimateBracket); + + setVisible(false); +} + +void CommanderBracketWidget::setDeck(const QSharedPointer &_deck) +{ + deck = _deck; + requestId = 0; // invalidate any in-flight estimate for the previous deck + + // Reset the displayed bracket + bracketValueLabel->setText("-"); + bracketInfoButton->setToolTip({}); + bracketInfoButton->setEnabled(false); + bracketRefreshButton->setEnabled(true); + + maybeAutoEstimateBracket(); +} + +bool CommanderBracketWidget::promptCommanderSpellbookIntegration() +{ + QDialog dialog(this); + dialog.setWindowTitle(tr("CommanderSpellbook integration")); + + auto *mainLayout = new QVBoxLayout(&dialog); + + // Main text + auto *label = new QLabel(tr("CommanderSpellbook can analyze your deck and estimate its Commander bracket.\n\n" + "This sends your deck list to an external service.\n\n" + "CommanderSpellbook uses its own bracket naming system based on their own algorithm. " + "These names can be mapped to the official Commander brackets, but the mapping " + "is only an approximation.")); + label->setWordWrap(true); + mainLayout->addWidget(label); + + // Naming selector + auto *formLayout = new QFormLayout; + auto *namingCombo = new QComboBox(&dialog); + namingCombo->addItem(tr("CommanderSpellbook bracket names")); + namingCombo->addItem(tr("Official Commander bracket names (approximate)")); + namingCombo->setCurrentIndex( + SettingsCache::instance().deckEditor().getCommanderSpellbookIntegrationUseOfficialBracketNames() ? 1 : 0); + + // Create label + explainer button + auto *labelWidget = new QWidget(&dialog); + auto *labelLayout = new QHBoxLayout(labelWidget); + labelLayout->setContentsMargins(0, 0, 0, 0); + + auto *namingLabel = new QLabel(tr("Bracket naming:"), labelWidget); + auto *explainerButton = new QToolButton(labelWidget); + explainerButton->setText("?"); + explainerButton->setAutoRaise(true); + explainerButton->setEnabled(false); + explainerButton->setToolTip(CommanderBracketNames::Explainer); + + labelLayout->addWidget(namingLabel); + labelLayout->addWidget(explainerButton); + labelLayout->addStretch(); // push the button next to label, combo stays aligned + + // Add row with the custom label widget + formLayout->addRow(labelWidget, namingCombo); + mainLayout->addLayout(formLayout); + + // Buttons + auto *buttonBox = new QDialogButtonBox(&dialog); + auto *enableBtn = buttonBox->addButton(tr("Enable"), QDialogButtonBox::AcceptRole); + auto *automaticBtn = buttonBox->addButton(tr("Automatic"), QDialogButtonBox::ApplyRole); + auto *disableBtn = buttonBox->addButton(tr("Disable"), QDialogButtonBox::RejectRole); + mainLayout->addWidget(buttonBox); + + // Track which button was clicked + QAbstractButton *clickedButton = nullptr; + QObject::connect(buttonBox, &QDialogButtonBox::clicked, &dialog, [&](QAbstractButton *btn) { + clickedButton = btn; + dialog.accept(); + }); + + dialog.exec(); + + // Persist naming choice (if not disabled) + if (clickedButton != disableBtn) { + bool useOfficial = namingCombo->currentIndex() == 1; + SettingsCache::instance().deckEditor().setCommanderSpellbookIntegrationUseOfficialBracketNames(useOfficial); + } + + // Persist integration mode + if (clickedButton == disableBtn) { + SettingsCache::instance().deckEditor().setCommanderSpellbookIntegrationEnabled( + commanderSpellbookIntegrationEnabledIndexDisabled); + return false; + } + if (clickedButton == enableBtn) { + SettingsCache::instance().deckEditor().setCommanderSpellbookIntegrationEnabled( + commanderSpellbookIntegrationEnabledIndexEnabled); + return true; + } + if (clickedButton == automaticBtn) { + SettingsCache::instance().deckEditor().setCommanderSpellbookIntegrationEnabled( + commanderSpellbookIntegrationEnabledIndexAutomatic); + return true; + } + + return false; +} + +void CommanderBracketWidget::updateBracketVisibility(bool visible) +{ + setVisible(visible); +} + +void CommanderBracketWidget::requestBracketEstimate() +{ + bracketRefreshButton->setEnabled(false); + bracketInfoButton->setEnabled(false); + bracketValueLabel->setText(tr("Calculating…")); + + requestId = CommanderBracketService::instance().estimateBracket(*deck, this); +} + +void CommanderBracketWidget::onEstimateBracketFinished(quint64 id, + QObject *requester, + const CommanderBracketEstimate &result) +{ + if (requester != this || id != requestId) { + return; + } + + BracketExplainer explainer; + lastBracketExplanation = explainer.explain(result.rawResult); + + // Display bracket + bracketValueLabel->setText( + SettingsCache::instance().deckEditor().getCommanderSpellbookIntegrationUseOfficialBracketNames() + ? result.officialName + : result.displayName); + bracketRefreshButton->setEnabled(true); + + // Build tooltip + QString tooltip; + for (const auto §ion : lastBracketExplanation.sections) { + tooltip += "" + section.title + "
"; + for (const auto &line : section.bulletPoints) { + tooltip += "• " + line + "
"; + } + tooltip += "
"; + } + + bracketInfoButton->setToolTip(tooltip); + bracketInfoButton->setEnabled(!tooltip.isEmpty()); +} + +void CommanderBracketWidget::onEstimateBracketError(quint64 id, QObject *requester, const QString & /*error*/) +{ + if (requester != this || id != requestId) { + return; + } + + bracketValueLabel->setText("-"); + bracketRefreshButton->setEnabled(true); + bracketInfoButton->setToolTip({}); + bracketInfoButton->setEnabled(false); +} + +void CommanderBracketWidget::maybeAutoEstimateBracket() +{ + const QString formatKey = deck->getGameFormat(); + + const bool isCommander = (formatKey.compare("commander", Qt::CaseInsensitive) == 0); + + int mode = SettingsCache::instance().deckEditor().getCommanderSpellbookIntegrationEnabled(); + + if (!isCommander || mode == commanderSpellbookIntegrationEnabledIndexDisabled) { + updateBracketVisibility(false); + return; + } + + if (mode == commanderSpellbookIntegrationEnabledIndexUnprompted) { + if (prompting) { + return; + } + prompting = true; + const bool accepted = promptCommanderSpellbookIntegration(); + prompting = false; + if (!accepted) { + updateBracketVisibility(false); + return; + } + } + + updateBracketVisibility(true); + + mode = SettingsCache::instance().deckEditor().getCommanderSpellbookIntegrationEnabled(); + if (mode != commanderSpellbookIntegrationEnabledIndexAutomatic) { + return; + } + + // Avoid firing if we already have a result or a request in flight + if (!bracketRefreshButton->isEnabled()) { + return; + } + + // Defer to avoid races during init / model rebuild + QTimer::singleShot(0, this, &CommanderBracketWidget::requestBracketEstimate); +} + +void CommanderBracketWidget::retranslateUi() +{ + bracketLabel->setText(tr("Bracket:")); + bracketInfoButton->setToolTip(tr("Why this bracket?")); + bracketRefreshButton->setToolTip(tr("Recalculate bracket")); +} diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.h b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.h new file mode 100644 index 000000000..a45610fcf --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.h @@ -0,0 +1,46 @@ +#ifndef COCKATRICE_COMMANDER_BRACKET_WIDGET_H +#define COCKATRICE_COMMANDER_BRACKET_WIDGET_H + +#include "commander_spellbook_bracket_explainer.h" + +#include +#include +#include + +class QLabel; +class QToolButton; +struct CommanderBracketEstimate; + +class CommanderBracketWidget : public QWidget +{ + Q_OBJECT + +public: + explicit CommanderBracketWidget(QWidget *parent = nullptr); + + void setDeck(const QSharedPointer &_deck); + void retranslateUi(); + +private slots: + void requestBracketEstimate(); + void onEstimateBracketFinished(quint64 id, QObject *requester, const CommanderBracketEstimate &result); + void onEstimateBracketError(quint64 id, QObject *requester, const QString &error); + void maybeAutoEstimateBracket(); + +private: + bool promptCommanderSpellbookIntegration(); + void updateBracketVisibility(bool visible); + + QSharedPointer deck; + bool prompting = false; + quint64 requestId = 0; + + QLabel *bracketLabel; + QLabel *bracketValueLabel; + QToolButton *bracketInfoButton; + QToolButton *bracketRefreshButton; + + BracketExplanation lastBracketExplanation; +}; + +#endif // COCKATRICE_COMMANDER_BRACKET_WIDGET_H diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_api_accessor.cpp b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_api_accessor.cpp new file mode 100644 index 000000000..590692d98 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_api_accessor.cpp @@ -0,0 +1,75 @@ +#include "commander_spellbook_api_accessor.h" + +#include "api_response/commander_spellbook_deck_request.h" + +#include +#include +#include +#include +#include + +static const QUrl ESTIMATE_BRACKET_URL(QStringLiteral("https://backend.commanderspellbook.com/estimate-bracket")); + +CommanderSpellbookApiAccessor &CommanderSpellbookApiAccessor::instance() +{ + static CommanderSpellbookApiAccessor instance; + return instance; +} + +CommanderSpellbookApiAccessor::CommanderSpellbookApiAccessor(QObject *parent) : QObject(parent) +{ +} + +CommanderSpellbookApiAccessor::RequestId CommanderSpellbookApiAccessor::estimateBracket(const DeckList &deck, + QObject *requester) +{ + CommanderSpellbookDeckRequest deckRequest = CommanderSpellbookDeckRequest::fromDeckList(deck); + + QJsonDocument doc(deckRequest.toJson()); + QByteArray body = doc.toJson(QJsonDocument::Compact); + + QNetworkRequest req(ESTIMATE_BRACKET_URL); + req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + req.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING)); + + QNetworkReply *reply = network.post(req, body); + + const RequestId id = nextRequestId++; + + reply->setProperty("requestId", QVariant::fromValue(id)); + reply->setProperty("requester", QVariant::fromValue(requester)); + + connect(reply, &QNetworkReply::finished, this, [this, reply]() { onEstimateReplyFinished(reply); }); + + return id; +} + +void CommanderSpellbookApiAccessor::onEstimateReplyFinished(QNetworkReply *reply) +{ + reply->deleteLater(); + + const RequestId id = reply->property("requestId").toULongLong(); + QObject *requester = reply->property("requester").value(); + + if (!requester) { + // Requester died — silently drop + return; + } + + if (reply->error() != QNetworkReply::NoError) { + emit estimateBracketError(id, requester, reply->errorString()); + return; + } + + QJsonParseError err; + QJsonDocument doc = QJsonDocument::fromJson(reply->readAll(), &err); + + if (err.error != QJsonParseError::NoError || !doc.isObject()) { + emit estimateBracketError(id, requester, QStringLiteral("Invalid JSON response")); + return; + } + + EstimateBracketResult result = EstimateBracketResult::fromJson(doc.object()); + + emit estimateBracketFinished(id, requester, result); +} diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_api_accessor.h b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_api_accessor.h new file mode 100644 index 000000000..682a48524 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_api_accessor.h @@ -0,0 +1,37 @@ +#ifndef COCKATRICE_COMMANDER_SPELLBOOK_API_ACCESSOR_H +#define COCKATRICE_COMMANDER_SPELLBOOK_API_ACCESSOR_H + +#include "api_response/commander_spellbook_estimate_bracket_result.h" + +#include +#include +#include +#include + +class CommanderSpellbookApiAccessor final : public QObject +{ + Q_OBJECT + +public: + static CommanderSpellbookApiAccessor &instance(); + + using RequestId = quint64; + + RequestId estimateBracket(const DeckList &deck, QObject *requester); + +signals: + void estimateBracketFinished(RequestId id, QObject *requester, const EstimateBracketResult &result); + + void estimateBracketError(RequestId id, QObject *requester, const QString &errorMessage); + +private: + explicit CommanderSpellbookApiAccessor(QObject *parent = nullptr); + Q_DISABLE_COPY_MOVE(CommanderSpellbookApiAccessor) + + void onEstimateReplyFinished(QNetworkReply *reply); + + QNetworkAccessManager network; + RequestId nextRequestId = 1; +}; + +#endif // COCKATRICE_COMMANDER_SPELLBOOK_API_ACCESSOR_H diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_bracket_explainer.cpp b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_bracket_explainer.cpp new file mode 100644 index 000000000..fc308ed60 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_bracket_explainer.cpp @@ -0,0 +1,120 @@ +#include "commander_spellbook_bracket_explainer.h" + +static QString cardList(const QList &cards, int max = 5) +{ + QStringList names; + for (int i = 0; i < cards.size() && i < max; ++i) { + names << cards[i].name; + } + + if (cards.size() > max) { + names << QString("and %1 more").arg(cards.size() - max); + } + + return names.join(", "); +} + +static QString comboCount(const QList &variants) +{ + return QString::number(variants.size()); +} + +BracketExplanation BracketExplainer::explain(const EstimateBracketResult &r) +{ + BracketExplanation out; + out.bracket = r.bracketTag; + + if (!r.gameChangerCards.isEmpty()) { + BracketExplanationSection s; + s.title = "Game-changing cards"; + + s.bulletPoints << QString("Your deck contains %1 game-changing cards, such as %2.") + .arg(r.gameChangerCards.size()) + .arg(cardList(r.gameChangerCards)); + + out.sections << s; + } + + if (!r.extraTurnCards.isEmpty() || !r.extraTurnTemplates.isEmpty() || !r.extraTurnCombos.isEmpty()) { + + BracketExplanationSection s; + s.title = "Extra turns"; + + if (!r.extraTurnCards.isEmpty()) { + s.bulletPoints << QString("The deck contains %1 extra-turn cards (%2).") + .arg(r.extraTurnCards.size()) + .arg(cardList(r.extraTurnCards)); + } + + if (!r.extraTurnTemplates.isEmpty()) { + s.bulletPoints << QString("%1 extra-turn templates were identified.").arg(comboCount(r.extraTurnTemplates)); + } + + if (!r.extraTurnCombos.isEmpty()) { + s.bulletPoints + << QString("%1 extra-turn combo variants were identified.").arg(comboCount(r.extraTurnCombos)); + } + + out.sections << s; + } + + if (!r.massLandDenialCards.isEmpty() || !r.massLandDenialTemplates.isEmpty() || !r.massLandDenialCombos.isEmpty()) { + + BracketExplanationSection s; + s.title = "Mass land denial"; + + if (!r.massLandDenialCards.isEmpty()) { + s.bulletPoints << QString("The deck contains %1 mass land denial cards (%2).") + .arg(r.massLandDenialCards.size()) + .arg(cardList(r.massLandDenialCards)); + } + + if (!r.massLandDenialTemplates.isEmpty()) { + s.bulletPoints + << QString("%1 mass land denial templates were identified.").arg(comboCount(r.massLandDenialTemplates)); + } + + if (!r.massLandDenialCombos.isEmpty()) { + s.bulletPoints << QString("%1 mass land denial combo variants were identified.") + .arg(comboCount(r.massLandDenialCombos)); + } + + out.sections << s; + } + + if (!r.lockCombos.isEmpty() || !r.skipTurnsCombos.isEmpty()) { + + BracketExplanationSection s; + s.title = "Lock pieces"; + + if (!r.lockCombos.isEmpty()) { + s.bulletPoints << QString("%1 lock combo variants were detected.").arg(comboCount(r.lockCombos)); + } + + if (!r.skipTurnsCombos.isEmpty()) { + s.bulletPoints << QString("%1 skip-turn combo variants were detected.").arg(comboCount(r.skipTurnsCombos)); + } + + out.sections << s; + } + + if (!r.definitelyTwoCardCombos.isEmpty() || !r.arguablyTwoCardCombos.isEmpty()) { + + BracketExplanationSection s; + s.title = "Two-card combos"; + + if (!r.definitelyTwoCardCombos.isEmpty()) { + s.bulletPoints << QString("%1 definite two-card combo variants were identified.") + .arg(comboCount(r.definitelyTwoCardCombos)); + } + + if (!r.arguablyTwoCardCombos.isEmpty()) { + s.bulletPoints << QString("%1 arguable two-card combo variants were identified.") + .arg(comboCount(r.arguablyTwoCardCombos)); + } + + out.sections << s; + } + + return out; +} diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_bracket_explainer.h b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_bracket_explainer.h new file mode 100644 index 000000000..9ed499742 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_bracket_explainer.h @@ -0,0 +1,43 @@ +#ifndef COCKATRICE_COMMANDER_SPELLBOOK_BRACKET_EXPLAINER_H +#define COCKATRICE_COMMANDER_SPELLBOOK_BRACKET_EXPLAINER_H +#include "api_response/commander_spellbook_estimate_bracket_result.h" + +namespace CommanderBracketNames +{ +inline const char *CommanderSpellbookBracketNames = QT_TR_NOOP("CommanderSpellbook"); +inline const char *OfficialCommanderBracketNames = QT_TR_NOOP("Official (approximate)"); +inline const char *Explainer = QT_TR_NOOP( + "The bracket system combines both objective data, as well as subjective play experience to estimate a " + "bracket for a deck.\nCommanderSpellbook's estimation is algorithmical, which means that it can only operate " + "on the objective data, not the subjective intent. \nThey have chosen to represent this by defining their " + "own bracket system which matches their algorithm.\n" + "This custom bracket system maps loosely to the standard system. \nYou may choose to use these mapped " + "standardized names if these are more familiar to you, however, you should keep in mind that these are just " + "rough estimations.\n\nAlways consider the subjective factors of the bracket system when determing a deck's " + "final bracket!"); +} // namespace CommanderBracketNames + +struct BracketExplanationSection +{ + QString title; + QStringList bulletPoints; +}; + +struct BracketExplanation +{ + QString bracket; + QList sections; + + bool isEmpty() const + { + return sections.isEmpty(); + } +}; + +class BracketExplainer +{ +public: + static BracketExplanation explain(const EstimateBracketResult &result); +}; + +#endif // COCKATRICE_COMMANDER_SPELLBOOK_BRACKET_EXPLAINER_H diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.cpp b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.cpp new file mode 100644 index 000000000..90b490efe --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.cpp @@ -0,0 +1,62 @@ +#include "handle_commander_brackets.h" + +#include "../../../../../client/settings/cache_settings.h" + +#include +#include +#include +#include + +static const QUrl COMMANDER_BRACKET_JSON_URL(QStringLiteral("https://cockatrice.github.io/commander-brackets.json")); + +HandleCommanderBrackets::HandleCommanderBrackets(QObject *parent) + : QObject(parent), nam(new QNetworkAccessManager(this)), reply(nullptr) +{ +} + +void HandleCommanderBrackets::downloadBracketDefinitions() +{ + if (reply) { + return; + } + + reply = nam->get(QNetworkRequest(COMMANDER_BRACKET_JSON_URL)); + + connect(reply, &QNetworkReply::finished, this, &HandleCommanderBrackets::actFinishParsingDownloadedData); +} + +void HandleCommanderBrackets::actFinishParsingDownloadedData() +{ + reply = qobject_cast(sender()); + + if (reply->error() != QNetworkReply::NoError) { + emit sigBracketDefinitionsDownloadFailed(reply->error()); + + reply->deleteLater(); + return; + } + + QJsonParseError parseError; + + auto document = QJsonDocument::fromJson(reply->readAll(), &parseError); + + if (parseError.error != QJsonParseError::NoError) { + emit sigBracketDefinitionsDownloadFailed(QNetworkReply::UnknownContentError); + + reply->deleteLater(); + return; + } + + updateBracketDefinitions(document.toVariant().toMap()); + + emit sigBracketDefinitionsDownloaded(); + + reply->deleteLater(); +} + +void HandleCommanderBrackets::updateBracketDefinitions(const QVariantMap &jsonMap) +{ + const auto bracketList = jsonMap.value("brackets").toList(); + SettingsCache::instance().commanderBrackets().saveDefinitions(bracketList); + SettingsCache::instance().commanderBrackets().reloadDefinitions(bracketList); +} diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.h b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.h new file mode 100644 index 000000000..b8348b13c --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.h @@ -0,0 +1,31 @@ +#ifndef COCKATRICE_HANDLE_COMMANDER_BRACKETS_H +#define COCKATRICE_HANDLE_COMMANDER_BRACKETS_H + +#include +#include +#include + +class HandleCommanderBrackets : public QObject +{ + Q_OBJECT + +public: + explicit HandleCommanderBrackets(QObject *parent = nullptr); + + void downloadBracketDefinitions(); + +signals: + void sigBracketDefinitionsDownloaded(); + void sigBracketDefinitionsDownloadFailed(QNetworkReply::NetworkError error); + +private slots: + void actFinishParsingDownloadedData(); + +private: + void updateBracketDefinitions(const QVariantMap &jsonMap); + + QNetworkAccessManager *nam; + QNetworkReply *reply; +}; + +#endif // COCKATRICE_HANDLE_COMMANDER_BRACKETS_H diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.cpp index 9d45f254b..9d294f10c 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.cpp @@ -65,11 +65,7 @@ void EdhrecApiResponseCardDetailsDisplayWidget::mousePressEvent(QMouseEvent *eve } } -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) void EdhrecApiResponseCardDetailsDisplayWidget::enterEvent(QEnterEvent *event) -#else -void EdhrecApiResponseCardDetailsDisplayWidget::enterEvent(QEvent *event) -#endif { QWidget::enterEvent(event); backgroundPlateWidget->setFocused(true); diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.h b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.h index 5daf32412..b09cc4e04 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.h +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.h @@ -39,11 +39,7 @@ private: protected slots: void mousePressEvent(QMouseEvent *event) override; -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) - void enterEvent(QEnterEvent *event) override; ///< Qt6 hover enter -#else - void enterEvent(QEvent *event) override; ///< Qt5 hover enter -#endif + void enterEvent(QEnterEvent *event) override; ///< Hover enter void leaveEvent(QEvent *event) override; }; diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec_main.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec_main.cpp index f861d8afd..500eab13a 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec_main.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec_main.cpp @@ -1,6 +1,7 @@ #include "tab_edhrec_main.h" #include "../../../../../client/settings/cache_settings.h" +#include "../../../utility/completer_utils.h" #include "../../tab_supervisor.h" #include "api_response/average_deck/edhrec_average_deck_api_response.h" #include "api_response/commander/edhrec_commander_api_response.h" @@ -12,7 +13,6 @@ #include "display/top_commander/edhrec_top_commanders_api_response_display_widget.h" #include "display/top_tags/edhrec_top_tags_api_response_display_widget.h" -#include #include #include #include @@ -25,6 +25,7 @@ #include #include #include +#include #include static bool canBeCommander(const CardInfoPtr &cardInfo) @@ -62,41 +63,21 @@ TabEdhRecMain::TabEdhRecMain(TabSupervisor *_tabSupervisor) : Tab(_tabSupervisor auto cardDatabaseModel = new CardDatabaseModel(CardDatabaseManager::getInstance(), false, this); auto displayModel = new CardDatabaseDisplayModel(this); displayModel->setSourceModel(cardDatabaseModel); - auto *searchModel = new CardSearchModel(displayModel, this); - auto *proxyModel = new CardCompleterProxyModel(this); - proxyModel->setSourceModel(searchModel); - proxyModel->setFilterCaseSensitivity(Qt::CaseInsensitive); - proxyModel->setFilterRole(Qt::DisplayRole); - - auto *completer = new QCompleter(proxyModel, this); - completer->setCompletionRole(Qt::DisplayRole); - completer->setCompletionMode(QCompleter::PopupCompletion); - completer->setCaseSensitivity(Qt::CaseInsensitive); - completer->setFilterMode(Qt::MatchContains); - completer->setMaxVisibleItems(10); - searchBar->setCompleter(completer); + const CardCompleterSetup cardSetup = createCardCompleter(displayModel, this); + searchBar->setCompleter(cardSetup.completer); // Update suggestions dynamically - connect(searchBar, &QLineEdit::textChanged, searchModel, &CardSearchModel::updateSearchResults); - connect(searchBar, &QLineEdit::textChanged, this, [=](const QString &text) { - // Ensure substring matching - QString pattern = ".*" + QRegularExpression::escape(text) + ".*"; - proxyModel->setFilterRegularExpression(QRegularExpression(pattern, QRegularExpression::CaseInsensitiveOption)); - - if (!text.isEmpty()) { - completer->complete(); // Force the dropdown to appear - } - }); + connectCardCompleterSearch(searchBar, cardSetup); searchPushButton = new QPushButton(navigationContainer); connect(searchPushButton, &QPushButton::clicked, this, [=, this]() { doSearch(); }); settingsButton = new SettingsButtonWidget(this); - cardSizeSlider = new CardSizeWidget(this, nullptr, SettingsCache::instance().getEDHRecCardSize()); - connect(cardSizeSlider, &CardSizeWidget::cardSizeSettingUpdated, &SettingsCache::instance(), - &SettingsCache::setEDHRecCardSize); + cardSizeSlider = new CardSizeWidget(this, nullptr, SettingsCache::instance().cardsDisplay().getEDHRecCardSize()); + connect(cardSizeSlider, &CardSizeWidget::cardSizeSettingUpdated, &SettingsCache::instance().cardsDisplay(), + &CardsDisplaySettings::setEDHRecCardSize); settingsButton->addSettingsWidget(cardSizeSlider); diff --git a/cockatrice/src/interface/widgets/tabs/tab.h b/cockatrice/src/interface/widgets/tabs/tab.h index 6ea1f5077..bddf325e2 100644 --- a/cockatrice/src/interface/widgets/tabs/tab.h +++ b/cockatrice/src/interface/widgets/tabs/tab.h @@ -20,6 +20,7 @@ class Tab : public QMainWindow signals: void userEvent(bool globalEvent = true); void tabTextChanged(Tab *tab, const QString &newTabText); + void cockatriceLinkActivated(const QString &url); protected: TabSupervisor *tabSupervisor; diff --git a/cockatrice/src/interface/widgets/tabs/tab_account.cpp b/cockatrice/src/interface/widgets/tabs/tab_account.cpp index e61732f90..dbcf50966 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_account.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_account.cpp @@ -1,6 +1,7 @@ #include "tab_account.h" #include "../client/sound_engine.h" +#include "../interface/widgets/dialogs/dlg_my_reports.h" #include "../interface/widgets/server/user/user_info_box.h" #include "../interface/widgets/server/user/user_list_manager.h" #include "../interface/widgets/server/user/user_list_widget.h" @@ -17,7 +18,7 @@ #include #include #include -#include +#include TabAccount::TabAccount(TabSupervisor *_tabSupervisor, AbstractClient *_client, const ServerInfo_User &userInfo) : Tab(_tabSupervisor), client(_client) @@ -49,6 +50,11 @@ TabAccount::TabAccount(TabSupervisor *_tabSupervisor, AbstractClient *_client, c auto *vbox = new QVBoxLayout; vbox->addWidget(userInfoBox); + + myReportsButton = new QPushButton(tr("My Reports")); + connect(myReportsButton, &QPushButton::clicked, this, &TabAccount::openMyReports); + vbox->addWidget(myReportsButton); + vbox->addWidget(allUsersList); auto *addToBuddyList = new QHBoxLayout; @@ -126,15 +132,22 @@ void TabAccount::addToList(const std::string &listName, const QString &userName) void TabAccount::retranslateUi() { + myReportsButton->setText(tr("My Reports")); allUsersList->retranslateUi(); buddyList->retranslateUi(); ignoreList->retranslateUi(); userInfoBox->retranslateUi(); + + buddyList->setToolTip(tr("Buddies are marked with a star in chat, a sound plays when they join or leave the " + "server, and they can be invited to buddy-only games.")); + ignoreList->setToolTip(tr("Ignored users' chat messages are hidden from you, and they cannot send you private " + "messages or join your games.")); } void TabAccount::processListUsersResponse(const Response &response) { const Response_ListUsers &resp = response.GetExtension(Response_ListUsers::ext); + allUsersList->beginBulkLoad(); for (int i = 0; i < resp.user_list_size(); ++i) { const ServerInfo_User &info = resp.user_list(i); const QString &userName = QString::fromStdString(info.name()); @@ -142,8 +155,8 @@ void TabAccount::processListUsersResponse(const Response &response) ignoreList->setUserOnline(userName, true); buddyList->setUserOnline(userName, true); } + allUsersList->endBulkLoad(); - allUsersList->sortItems(); ignoreList->sortItems(); buddyList->sortItems(); } @@ -188,18 +201,20 @@ void TabAccount::processUserLeftEvent(const Event_UserLeft &event) void TabAccount::buddyListReceived(const QList &_buddyList) { + buddyList->beginBulkLoad(); for (const auto &user : _buddyList) { buddyList->processUserInfo(user, false); } - buddyList->sortItems(); + buddyList->endBulkLoad(); } void TabAccount::ignoreListReceived(const QList &_ignoreList) { + ignoreList->beginBulkLoad(); for (const auto &user : _ignoreList) { ignoreList->processUserInfo(user, false); } - ignoreList->sortItems(); + ignoreList->endBulkLoad(); } void TabAccount::processAddToListEvent(const Event_AddToList &event) @@ -237,3 +252,10 @@ void TabAccount::processRemoveFromListEvent(const Event_RemoveFromList &event) userList->deleteUser(user); } + +void TabAccount::openMyReports() +{ + auto *dlg = new DlgMyReports(client, this); + dlg->setAttribute(Qt::WA_DeleteOnClose); + dlg->exec(); +} diff --git a/cockatrice/src/interface/widgets/tabs/tab_account.h b/cockatrice/src/interface/widgets/tabs/tab_account.h index 887038ebb..68054c7a1 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_account.h +++ b/cockatrice/src/interface/widgets/tabs/tab_account.h @@ -18,6 +18,7 @@ class Event_RemoveFromList; class Event_UserJoined; class Event_UserLeft; class LineEditUnfocusable; +class QPushButton; class Response; class ServerInfo_User; class UserInfoBox; @@ -41,6 +42,7 @@ private slots: void processRemoveFromListEvent(const Event_RemoveFromList &event); void addToIgnoreList(); void addToBuddyList(); + void openMyReports(); private: AbstractClient *client; @@ -50,6 +52,7 @@ private: UserInfoBox *userInfoBox; LineEditUnfocusable *addBuddyEdit; LineEditUnfocusable *addIgnoreEdit; + QPushButton *myReportsButton; void addToList(const std::string &listName, const QString &userName); public: diff --git a/cockatrice/src/interface/widgets/tabs/tab_admin.cpp b/cockatrice/src/interface/widgets/tabs/tab_admin.cpp index 533e1cc83..16f17e03e 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_admin.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_admin.cpp @@ -13,7 +13,7 @@ #include #include #include -#include +#include ShutdownDialog::ShutdownDialog(QWidget *parent) : QDialog(parent) { diff --git a/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.cpp b/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.cpp new file mode 100644 index 000000000..999bc3db0 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.cpp @@ -0,0 +1,279 @@ +#include "tab_card_art_rules.h" + +#include "../utility/completer_utils.h" +#include "libcockatrice/card/database/card_database_manager.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +CardArtRulesModel::CardArtRulesModel(AbstractClient *client, QObject *parent) + : QAbstractTableModel(parent), client(client) +{ +} + +int CardArtRulesModel::rowCount(const QModelIndex &parent) const +{ + if (parent.isValid()) { + return 0; + } + return static_cast(entries.size()); +} + +int CardArtRulesModel::columnCount(const QModelIndex &parent) const +{ + Q_UNUSED(parent); + return 4; +} + +QVariant CardArtRulesModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid()) { + return {}; + } + + const auto &e = entries.at(index.row()); + + if (role == Qt::DisplayRole) { + switch (index.column()) { + case 0: + return e.cardName; + case 1: + return e.cardProviderId; + case 2: + return e.mode; + case 3: + return e.reason; + } + } + + return {}; +} + +QVariant CardArtRulesModel::headerData(int section, Qt::Orientation orientation, int role) const +{ + if (orientation != Qt::Horizontal || role != Qt::DisplayRole) { + return {}; + } + + switch (section) { + case 0: + return tr("Card"); + case 1: + return tr("ProviderId"); + case 2: + return tr("Mode"); + case 3: + return tr("Reason"); + default: + return {}; + } +} + +void CardArtRulesModel::refresh() +{ + Command_ListCardArtRules cmd; + + PendingCommand *pend = client->prepareModeratorCommand(cmd); + + connect(pend, &PendingCommand::finished, this, &CardArtRulesModel::onRefreshFinished); + + client->sendCommand(pend); +} + +void CardArtRulesModel::clear() +{ + beginResetModel(); + entries.clear(); + endResetModel(); +} + +QString CardArtRulesModel::cardAt(int row) const +{ + if (row < 0 || row >= static_cast(entries.size())) { + return {}; + } + + return entries[row].cardName; +} + +const CardArtRulesModel::Entry *CardArtRulesModel::entryAt(int row) const +{ + if (row < 0 || row >= static_cast(entries.size())) { + return nullptr; + } + + return &entries[row]; +} + +void CardArtRulesModel::onRefreshFinished(const Response &r) +{ + if (r.response_code() != Response::RespOk) { + return; + } + + const auto &resp = r.GetExtension(Response_ListCardArtRules::ext); + + beginResetModel(); + entries.clear(); + + for (const auto &e : resp.entries()) { + entries.push_back({QString::fromStdString(e.card_name()), QString::fromStdString(e.card_provider_id()), + QString::fromStdString(e.mode()), QString::fromStdString(e.reason())}); + } + + endResetModel(); +} + +TabCardArtRules::TabCardArtRules(TabSupervisor *parent, AbstractClient *_client) : Tab(parent), client(_client) +{ + setupUi(); + refresh(); +} + +void TabCardArtRules::setupUi() +{ + auto *central = new QWidget(this); + + initSearchBar(); + + providerComboBox = new QComboBox; + modeBox = new QComboBox; + reasonEdit = new QLineEdit; + + addBtn = new QPushButton; + removeBtn = new QPushButton; + refreshBtn = new QPushButton; + + modeBox->addItems({"ALLOW", "DENY"}); + + tableModel = new CardArtRulesModel(client, this); + + table = new QTableView; + table->setModel(tableModel); + table->setSelectionBehavior(QAbstractItemView::SelectRows); + table->setSelectionMode(QAbstractItemView::SingleSelection); + + auto *form = new QFormLayout; + form->addRow(tr("Card:"), searchEdit); + form->addRow(tr("ProviderId:"), providerComboBox); + form->addRow(tr("Mode:"), modeBox); + form->addRow(tr("Reason:"), reasonEdit); + + auto *buttons = new QHBoxLayout; + buttons->addWidget(addBtn); + buttons->addWidget(removeBtn); + buttons->addWidget(refreshBtn); + + auto *layout = new QVBoxLayout; + layout->addLayout(form); + layout->addLayout(buttons); + layout->addWidget(table); + + central->setLayout(layout); + setCentralWidget(central); + + connect(addBtn, &QPushButton::clicked, this, &TabCardArtRules::addRule); + + connect(removeBtn, &QPushButton::clicked, this, &TabCardArtRules::removeSelected); + + connect(refreshBtn, &QPushButton::clicked, this, &TabCardArtRules::refresh); + + retranslateUi(); +} + +void TabCardArtRules::initSearchBar() +{ + searchEdit = new QLineEdit; + searchEdit->setPlaceholderText(tr("Type a card name...")); + + cardDbModel = new CardDatabaseModel(CardDatabaseManager::getInstance(), false, this); + cardDbDisplayModel = new CardDatabaseDisplayModel(this); + cardDbDisplayModel->setSourceModel(cardDbModel); + + const CardCompleterSetup cardSetup = createCardCompleter(cardDbDisplayModel, this, 15); + cardSearchModel = cardSetup.searchModel; + cardProxyModel = cardSetup.proxyModel; + searchCompleter = cardSetup.completer; + searchEdit->setCompleter(searchCompleter); + + connectCardCompleterSearch(searchEdit, cardSetup); + connect(searchCompleter, static_cast(&QCompleter::activated), this, + [this](const QString &name) { searchEdit->setText(name); }); + connect(searchEdit, &QLineEdit::editingFinished, this, + [this]() { populateProviderCombo(searchEdit->text().trimmed()); }); +} + +void TabCardArtRules::populateProviderCombo(const QString &cardName) +{ + providerComboBox->clear(); + + auto card = CardDatabaseManager::query()->getCard({cardName}); + + const auto &sets = card.getInfo().getSets(); + + for (const auto &printings : sets) { + for (const auto &p : printings) { + + QString setName = p.getSet()->getLongName(); + QString collector = p.getProperty("num"); + QString uuid = p.getUuid(); + + QString label = setName; + + if (!collector.isEmpty()) { + label += " #" + collector; + } + + providerComboBox->addItem(label, uuid); + } + } +} + +void TabCardArtRules::retranslateUi() +{ + addBtn->setText(tr("Add rule")); + removeBtn->setText(tr("Remove rule")); + refreshBtn->setText(tr("Refresh")); +} + +void TabCardArtRules::refresh() +{ + tableModel->refresh(); +} + +void TabCardArtRules::addRule() +{ + Command_AddCardArtRule cmd; + cmd.set_card_name(searchEdit->text().toStdString()); + cmd.set_card_provider_id(providerComboBox->currentData().toString().toStdString()); + cmd.set_mode(modeBox->currentText().toStdString()); + cmd.set_reason(reasonEdit->text().toStdString()); + + client->sendCommand(client->prepareModeratorCommand(cmd)); + + refresh(); +} + +void TabCardArtRules::removeSelected() +{ + QModelIndex idx = table->currentIndex(); + if (!idx.isValid()) { + return; + } + + Command_RemoveCardArtRule cmd; + const auto e = tableModel->entryAt(idx.row()); + + cmd.set_card_name(e->cardName.toStdString()); + cmd.set_card_provider_id(e->cardProviderId.toStdString()); + + client->sendCommand(client->prepareModeratorCommand(cmd)); + + refresh(); +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.h b/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.h new file mode 100644 index 000000000..b9ea2ca83 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.h @@ -0,0 +1,93 @@ +#ifndef COCKATRICE_DLG_CARD_ART_RULES_H +#define COCKATRICE_DLG_CARD_ART_RULES_H + +#include "card/card_search_model.h" +#include "tab_supervisor.h" + +#include +#include +#include +#include +#include + +class AbstractClient; + +class CardArtRulesModel : public QAbstractTableModel +{ + Q_OBJECT + +public: + struct Entry + { + QString cardName; + QString cardProviderId; + QString mode; + QString reason; + }; + + explicit CardArtRulesModel(AbstractClient *client, QObject *parent = nullptr); + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + int columnCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &index, int role) const override; + QVariant headerData(int section, Qt::Orientation orientation, int role) const override; + + void refresh(); + void clear(); + + QString cardAt(int row) const; + const Entry *entryAt(int row) const; + +private slots: + void onRefreshFinished(const Response &r); + +private: + AbstractClient *client; + std::vector entries; +}; + +class TabCardArtRules : public Tab +{ + Q_OBJECT + +public: + TabCardArtRules(TabSupervisor *parent, AbstractClient *client); + + QString getTabText() const override + { + return tr("Card Art Rules"); + } + void retranslateUi() override; + +private: + void setupUi(); + +private slots: + void addRule(); + void removeSelected(); + void refresh(); + +private: + AbstractClient *client; + + QLineEdit *searchEdit; + void initSearchBar(); + void populateProviderCombo(const QString &cardName); + QCompleter *searchCompleter; + CardDatabaseModel *cardDbModel; + CardDatabaseDisplayModel *cardDbDisplayModel; + CardSearchModel *cardSearchModel; + CardCompleterProxyModel *cardProxyModel; + QComboBox *providerComboBox; + QComboBox *modeBox; + QLineEdit *reasonEdit; + + QPushButton *addBtn; + QPushButton *removeBtn; + QPushButton *refreshBtn; + + QTableView *table; + CardArtRulesModel *tableModel; +}; + +#endif // COCKATRICE_DLG_CARD_ART_RULES_H diff --git a/cockatrice/src/interface/widgets/tabs/tab_deck_editor.cpp b/cockatrice/src/interface/widgets/tabs/tab_deck_editor.cpp index 4e7cbfecf..8439aef70 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_editor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_editor.cpp @@ -1,6 +1,7 @@ #include "tab_deck_editor.h" #include "../../../client/settings/cache_settings.h" +#include "../../../client/settings/shortcuts_settings.h" #include "../deck_editor/deck_state_manager.h" #include "../filters/filter_builder.h" #include "../interface/pixel_map_generator.h" @@ -21,7 +22,8 @@ #include #include #include -#include +#include +#include /** * @brief Constructs a new TabDeckEditor object. @@ -60,7 +62,8 @@ void TabDeckEditor::createMenus() registerDockWidget(viewMenu, filterDockWidget, {250, 250}); registerDockWidget(viewMenu, printingSelectorDockWidget, {525, 250}); - connect(&SettingsCache::instance(), &SettingsCache::overrideAllCardArtWithPersonalPreferenceChanged, this, + connect(&SettingsCache::instance().cardsDisplay(), + &CardsDisplaySettings::overrideAllCardArtWithPersonalPreferenceChanged, this, [this](bool enabled) { dockToActions[printingSelectorDockWidget].menu->setEnabled(!enabled); }); viewMenu->addSeparator(); diff --git a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp index bdf7901f1..f3535d850 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp @@ -2,6 +2,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../deck_loader/deck_loader.h" +#include "../../pixel_map_generator.h" #include "../interface/widgets/server/remote/remote_decklist_tree_widget.h" #include "../interface/widgets/utility/get_text_with_max.h" @@ -28,6 +29,8 @@ #include #include #include +#include +#include TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, AbstractClient *_client, @@ -35,7 +38,7 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, : Tab(_tabSupervisor), client(_client) { localDirModel = new QFileSystemModel(this); - localDirModel->setRootPath(SettingsCache::instance().getDeckPath()); + localDirModel->setRootPath(SettingsCache::instance().paths().getDeckPath()); localDirModel->sort(0, Qt::AscendingOrder); localDirView = new QTreeView; @@ -103,19 +106,19 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, // Left side actions aOpenLocalDeck = new QAction(this); - aOpenLocalDeck->setIcon(QPixmap("theme:icons/pencil")); + aOpenLocalDeck->setIcon(themePixmap(QStringLiteral("icons/pencil"))); connect(aOpenLocalDeck, &QAction::triggered, this, &TabDeckStorage::actOpenLocalDeck); aRenameLocal = new QAction(this); - aRenameLocal->setIcon(QPixmap("theme:icons/rename")); + aRenameLocal->setIcon(themePixmap(QStringLiteral("icons/rename"))); connect(aRenameLocal, &QAction::triggered, this, &TabDeckStorage::actRenameLocal); aUpload = new QAction(this); - aUpload->setIcon(QPixmap("theme:icons/arrow_right_green")); + aUpload->setIcon(themePixmap(QStringLiteral("icons/arrow_right_green"))); connect(aUpload, &QAction::triggered, this, &TabDeckStorage::actUpload); aNewLocalFolder = new QAction(this); aNewLocalFolder->setIcon(qApp->style()->standardIcon(QStyle::SP_FileDialogNewFolder)); connect(aNewLocalFolder, &QAction::triggered, this, &TabDeckStorage::actNewLocalFolder); aDeleteLocalDeck = new QAction(this); - aDeleteLocalDeck->setIcon(QPixmap("theme:icons/remove_row")); + aDeleteLocalDeck->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); connect(aDeleteLocalDeck, &QAction::triggered, this, &TabDeckStorage::actDeleteLocalDeck); aOpenDecksFolder = new QAction(this); @@ -124,16 +127,16 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, // Right side actions aOpenRemoteDeck = new QAction(this); - aOpenRemoteDeck->setIcon(QPixmap("theme:icons/pencil")); + aOpenRemoteDeck->setIcon(themePixmap(QStringLiteral("icons/pencil"))); connect(aOpenRemoteDeck, &QAction::triggered, this, &TabDeckStorage::actOpenRemoteDeck); aDownload = new QAction(this); - aDownload->setIcon(QPixmap("theme:icons/arrow_left_green")); + aDownload->setIcon(themePixmap(QStringLiteral("icons/arrow_left_green"))); connect(aDownload, &QAction::triggered, this, &TabDeckStorage::actDownload); aNewFolder = new QAction(this); aNewFolder->setIcon(qApp->style()->standardIcon(QStyle::SP_FileDialogNewFolder)); connect(aNewFolder, &QAction::triggered, this, &TabDeckStorage::actNewFolder); aDeleteRemoteDeck = new QAction(this); - aDeleteRemoteDeck->setIcon(QPixmap("theme:icons/remove_row")); + aDeleteRemoteDeck->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); connect(aDeleteRemoteDeck, &QAction::triggered, this, &TabDeckStorage::actDeleteRemoteDeck); // Add actions to toolbars diff --git a/cockatrice/src/interface/widgets/tabs/tab_developer.cpp b/cockatrice/src/interface/widgets/tabs/tab_developer.cpp new file mode 100644 index 000000000..39652ef7d --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_developer.cpp @@ -0,0 +1,261 @@ +/** + * @file tab_developer.cpp + * @ingroup ServerTabs + */ +//! \todo Document this file. + +#include "tab_developer.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static constexpr int DEFAULT_AUTO_REFRESH_INTERVAL_SECS = 30; + +TabDeveloper::TabDeveloper(TabSupervisor *_tabSupervisor, AbstractClient *_client) + : Tab(_tabSupervisor), client(_client) +{ + statsTable = new QTableWidget(0, 2); + statsTable->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); + statsTable->setEditTriggers(QAbstractItemView::NoEditTriggers); + statsTable->setSelectionBehavior(QAbstractItemView::SelectRows); + statsTable->setSelectionMode(QAbstractItemView::SingleSelection); + statsTable->verticalHeader()->setVisible(false); + statsTable->horizontalHeader()->setSectionResizeMode(0, QHeaderView::Interactive); + statsTable->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Interactive); + statsTable->horizontalHeader()->setStretchLastSection(true); + + commandTable = new QTableWidget(0, 4); + commandTable->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + commandTable->setEditTriggers(QAbstractItemView::NoEditTriggers); + commandTable->setSelectionBehavior(QAbstractItemView::SelectRows); + commandTable->setSelectionMode(QAbstractItemView::SingleSelection); + commandTable->verticalHeader()->setVisible(false); + commandTable->horizontalHeader()->setSectionResizeMode(0, QHeaderView::Interactive); + commandTable->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Interactive); + commandTable->horizontalHeader()->setSectionResizeMode(2, QHeaderView::Interactive); + commandTable->horizontalHeader()->setSectionResizeMode(3, QHeaderView::Interactive); + + statusLabel = new QLabel; + + autoRefreshCheckBox = new QCheckBox; + autoRefreshCheckBox->setChecked(false); + + refreshIntervalSpinBox = new QSpinBox; + refreshIntervalSpinBox->setRange(5, 3600); + refreshIntervalSpinBox->setValue(DEFAULT_AUTO_REFRESH_INTERVAL_SECS); + refreshIntervalSpinBox->setEnabled(false); + + autoRefreshTimer = new QTimer(this); + connect(autoRefreshTimer, &QTimer::timeout, this, &TabDeveloper::refreshClicked); + connect(autoRefreshCheckBox, &QCheckBox::toggled, this, &TabDeveloper::autoRefreshToggled); + connect(refreshIntervalSpinBox, QOverload::of(&QSpinBox::valueChanged), this, + &TabDeveloper::refreshIntervalChanged); + + refreshButton = new QPushButton; + refreshButton->setAutoDefault(true); + connect(refreshButton, &QPushButton::clicked, this, &TabDeveloper::refreshClicked); + + auto *buttonLayout = new QHBoxLayout; + buttonLayout->addWidget(statusLabel, 1, Qt::AlignLeft); + buttonLayout->addWidget(autoRefreshCheckBox, 0, Qt::AlignRight); + buttonLayout->addWidget(refreshIntervalSpinBox, 0, Qt::AlignRight); + buttonLayout->addWidget(refreshButton, 0, Qt::AlignRight); + + auto *tableLayout = new QHBoxLayout; + tableLayout->addWidget(statsTable, 1); + tableLayout->addWidget(commandTable, 2); + + auto *mainLayout = new QVBoxLayout; + mainLayout->addLayout(tableLayout, 1); + mainLayout->addLayout(buttonLayout); + + auto *central = new QWidget; + central->setLayout(mainLayout); + setCentralWidget(central); + + retranslateUi(); +} + +void TabDeveloper::retranslateUi() +{ + autoRefreshCheckBox->setText(tr("Auto-refresh")); + autoRefreshCheckBox->setToolTip(tr("Automatically request fresh server statistics at a fixed interval.")); + refreshIntervalSpinBox->setSuffix(tr(" s")); + refreshIntervalSpinBox->setToolTip(tr("Seconds between automatic refreshes.")); + refreshButton->setText(tr("Refresh server stats")); + statsTable->setHorizontalHeaderLabels(QString(tr("Statistic;Value")).split(";")); + commandTable->setHorizontalHeaderLabels(QString(tr("Command;Count;Total ms;Avg ms")).split(";")); + if (statsTable->rowCount() == 0) { + statusLabel->clear(); + } +} + +QString TabDeveloper::formatBytes(quint64 bytes) +{ + const quint64 kib = 1024; + const quint64 mib = 1024 * kib; + const quint64 gib = 1024 * mib; + if (bytes >= gib) { + return tr("%1 GiB").arg(QString::number(bytes / static_cast(gib), 'f', 2)); + } + if (bytes >= mib) { + return tr("%1 MiB").arg(QString::number(bytes / static_cast(mib), 'f', 2)); + } + if (bytes >= kib) { + return tr("%1 KiB").arg(QString::number(bytes / static_cast(kib), 'f', 2)); + } + return tr("%1 bytes").arg(bytes); +} + +QString TabDeveloper::formatDurationMs(qint64 ms) +{ + if (ms >= 1000) { + return tr("%1 s").arg(QString::number(ms / 1000.0, 'f', 2)); + } + return tr("%1 ms").arg(ms); +} + +void TabDeveloper::appendStatRow(const QString &name, const QString &value) +{ + const int row = statsTable->rowCount(); + statsTable->insertRow(row); + statsTable->setItem(row, 0, new QTableWidgetItem(name)); + statsTable->setItem(row, 1, new QTableWidgetItem(value)); +} + +void TabDeveloper::appendSeparatorRow(const QString §ionTitle) +{ + const int row = statsTable->rowCount(); + statsTable->insertRow(row); + auto *labelItem = new QTableWidgetItem(sectionTitle); + auto font = labelItem->font(); + font.setBold(true); + labelItem->setFont(font); + labelItem->setFlags(labelItem->flags() & ~Qt::ItemIsSelectable); + statsTable->setItem(row, 0, labelItem); + statsTable->setItem(row, 1, new QTableWidgetItem(QString())); +} + +void TabDeveloper::refreshClicked() +{ + if (requestPending) { + return; + } + requestPending = true; + Command_GetServerStats cmd; + PendingCommand *pend = client->prepareDeveloperCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabDeveloper::serverStatsResponse); + client->sendCommand(pend); +} + +void TabDeveloper::autoRefreshToggled(bool checked) +{ + refreshIntervalSpinBox->setEnabled(checked); + if (checked) { + refreshIntervalChanged(); + refreshClicked(); + } else { + autoRefreshTimer->stop(); + } +} + +void TabDeveloper::refreshIntervalChanged() +{ + if (autoRefreshCheckBox->isChecked()) { + autoRefreshTimer->start(refreshIntervalSpinBox->value() * 1000); + } +} + +void TabDeveloper::serverStatsResponse(const Response &resp) +{ + requestPending = false; + if (resp.response_code() != Response::RespOk) { + statusLabel->setText(tr("No server statistics available yet.")); + return; + } + + const Response_GetServerStats &response = resp.GetExtension(Response_GetServerStats::ext); + + statsTable->setRowCount(0); + + // Overview section + appendStatRow(tr("Registered users online"), QString::number(response.users_count())); + appendStatRow(tr("Moderators online"), QString::number(response.mods_count())); + appendStatRow(tr("Games running"), QString::number(response.games_count())); + appendStatRow(tr("Traffic sent (last tick)"), formatBytes(response.tx_bytes())); + appendStatRow(tr("Traffic received (last tick)"), formatBytes(response.rx_bytes())); + + const qint64 uptime = static_cast(response.uptime_secs()); + const int days = static_cast(uptime / 86400); + const int hours = static_cast((uptime % 86400) / 3600); + const int minutes = static_cast((uptime % 3600) / 60); + appendStatRow(tr("Server uptime"), days > 0 ? tr("%1d %2h %3m").arg(days).arg(hours).arg(minutes) + : tr("%1h %2m").arg(hours).arg(minutes)); + + const QDateTime snapshotTime = QDateTime::fromSecsSinceEpoch(static_cast(response.timest())); + appendStatRow(tr("Snapshot taken"), snapshotTime.toLocalTime().toString("yyyy-MM-dd HH:mm")); + + // Live metrics section + appendSeparatorRow(tr("Live Metrics")); + appendStatRow(tr("Cards in live games"), QString::number(response.cards_in_games())); + appendStatRow(tr("Total commands processed"), QString::number(response.total_commands())); + + if (response.total_commands() > 0) { + const double avgMs = static_cast(response.total_command_time_ms()) / response.total_commands(); + appendStatRow(tr("Avg command time"), QString::number(avgMs, 'f', 2) + " ms"); + } + appendStatRow(tr("Active command types"), QString::number(response.active_command_types())); + + appendStatRow(tr("Event loop stalls"), QString::number(response.eventloop_stalls_total())); + appendStatRow(tr("Last stall overshoot"), formatDurationMs(response.eventloop_last_stall_ms())); + appendStatRow(tr("Worst stall overshoot"), formatDurationMs(response.eventloop_max_stall_ms())); + + if (response.game_start_count() > 0) { + appendStatRow(tr("Game starts"), QString::number(response.game_start_count())); + const double avgStartMs = static_cast(response.game_start_total_ms()) / response.game_start_count(); + appendStatRow(tr("Avg game start time"), QString::number(avgStartMs, 'f', 1) + " ms"); + } + + // Per-command breakdown table + QList sortedStats(response.command_stats().begin(), response.command_stats().end()); + std::sort(sortedStats.begin(), sortedStats.end(), + [](const auto &a, const auto &b) { return a.total_ms() > b.total_ms(); }); + + commandTable->setRowCount(0); + for (const auto &cs : sortedStats) { + const int row = commandTable->rowCount(); + commandTable->insertRow(row); + commandTable->setItem(row, 0, new QTableWidgetItem(QString::fromStdString(cs.command_name()))); + + auto *countItem = new QTableWidgetItem(QString::number(cs.count())); + countItem->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter); + commandTable->setItem(row, 1, countItem); + + auto *totalItem = new QTableWidgetItem(QString::number(cs.total_ms())); + totalItem->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter); + commandTable->setItem(row, 2, totalItem); + + const double avg = cs.count() > 0 ? static_cast(cs.total_ms()) / cs.count() : 0.0; + auto *avgItem = new QTableWidgetItem(QString::number(avg, 'f', 2)); + avgItem->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter); + commandTable->setItem(row, 3, avgItem); + } + commandTable->resizeColumnsToContents(); + statsTable->resizeColumnsToContents(); + commandTable->resizeColumnsToContents(); + + statusLabel->setText(tr("Updated %1").arg(QDateTime::currentDateTime().toString("yyyy-MM-dd HH:mm"))); +} diff --git a/cockatrice/src/interface/widgets/tabs/tab_developer.h b/cockatrice/src/interface/widgets/tabs/tab_developer.h new file mode 100644 index 000000000..727a56bcb --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_developer.h @@ -0,0 +1,55 @@ +/** + * @file tab_developer.h + * @ingroup ServerTabs + */ +//! \todo Document this file. + +#ifndef TAB_DEVELOPER_H +#define TAB_DEVELOPER_H + +#include "tab.h" + +class AbstractClient; +class QCheckBox; +class QLabel; +class QPushButton; +class QSpinBox; +class QTableWidget; +class QTimer; +class Response; + +class TabDeveloper : public Tab +{ + Q_OBJECT +private: + AbstractClient *client; + QTableWidget *statsTable; + QTableWidget *commandTable; + QPushButton *refreshButton; + QLabel *statusLabel; + QCheckBox *autoRefreshCheckBox; + QSpinBox *refreshIntervalSpinBox; + QTimer *autoRefreshTimer; + bool requestPending = false; + + void appendStatRow(const QString &name, const QString &value); + void appendSeparatorRow(const QString §ionTitle); + static QString formatBytes(quint64 bytes); + static QString formatDurationMs(qint64 ms); + +private slots: + void refreshClicked(); + void serverStatsResponse(const Response &resp); + void autoRefreshToggled(bool checked); + void refreshIntervalChanged(); + +public: + explicit TabDeveloper(TabSupervisor *_tabSupervisor, AbstractClient *_client); + void retranslateUi() override; + [[nodiscard]] QString getTabText() const override + { + return tr("Developer"); + } +}; + +#endif diff --git a/cockatrice/src/interface/widgets/tabs/tab_game.cpp b/cockatrice/src/interface/widgets/tabs/tab_game.cpp index a81161e83..035ab1004 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_game.cpp @@ -1,6 +1,7 @@ #include "tab_game.h" #include "../../../client/settings/cache_settings.h" +#include "../../../client/settings/shortcuts_settings.h" #include "../game/game.h" #include "../game/player/player_logic.h" #include "../game/replay.h" @@ -19,14 +20,23 @@ #include "../interface/card_picture_loader/card_picture_loader.h" #include "../interface/widgets/cards/card_info_frame_widget.h" #include "../interface/widgets/dialogs/dlg_create_game.h" +#include "../interface/widgets/dialogs/dlg_invite_to_game.h" +#include "../interface/widgets/server/game_link.h" #include "../interface/widgets/server/user/user_list_manager.h" +#include "../interface/widgets/utility/completer_utils.h" #include "../interface/widgets/utility/line_edit_completer.h" #include "../interface/window_main.h" #include "../main.h" #include "../utility/visibility_change_listener.h" +#include "card/card_completer_proxy_model.h" +#include "card/card_search_model.h" +#include "card_database_display_model.h" +#include "card_database_model.h" #include "tab_supervisor.h" #include +#include +#include #include #include #include @@ -34,8 +44,12 @@ #include #include #include +#include +#include #include +#include #include +#include #include #include #include @@ -44,7 +58,11 @@ #include #include #include -#include +#include +#include +#include +#include +#include TabGame::TabGame(TabSupervisor *_tabSupervisor, GameReplay *_replay) : Tab(_tabSupervisor), sayLabel(nullptr), sayEdit(nullptr) @@ -248,21 +266,22 @@ void TabGame::resetChatAndPhase() // reset phase markers game->getGameState()->setCurrentPhase(-1); + + // reset spectator state so the replay can rebuild it from the start + game->getPlayerManager()->clearSpectators(); + playerListWidget->clearSpectators(); } void TabGame::emitUserEvent() { - bool globalEvent = - !game->getPlayerManager()->isSpectator() || SettingsCache::instance().getSpectatorNotificationsEnabled(); + bool globalEvent = !game->getPlayerManager()->isSpectator() || + SettingsCache::instance().userInterface().getSpectatorNotificationsEnabled(); emit userEvent(globalEvent); updatePlayerListDockTitle(); } TabGame::~TabGame() { - if (replayManager) { - delete replayManager->replay; - } for (auto &player : game->getPlayerManager()->getPlayers()) { player->clear(); } @@ -284,6 +303,9 @@ void TabGame::retranslateUi() QString tabText = " | " + type + " #" + QString::number(game->getGameMetaInfo()->gameId()); updatePlayerListDockTitle(); + if (inviteButton) { + inviteButton->setText(tr("Invite")); + } cardInfoDock->setWindowTitle(tr("Card Info") + (cardInfoDock->isWindow() ? tabText : QString())); messageLayoutDock->setWindowTitle(tr("Messages") + (messageLayoutDock->isWindow() ? tabText : QString())); if (replayDock) { @@ -322,6 +344,12 @@ void TabGame::retranslateUi() if (aGameInfo) { aGameInfo->setText(tr("Game &information")); } + if (aCopyGameLink) { + aCopyGameLink->setText(tr("Cop&y game link")); + } + if (aInviteToGame) { + aInviteToGame->setText(tr("Invite to Game...")); + } if (aConcede) { if (game->getPlayerManager()->isMainPlayerConceded()) { aConcede->setText(tr("Un&concede")); @@ -489,6 +517,56 @@ void TabGame::actGameInfo() dlg.exec(); } +void TabGame::actCopyGameLink() +{ + const QString link = + makeGameJoinLink(tabSupervisor->getClient()->serverName(), tabSupervisor->getClient()->serverPort(), + game->getGameMetaInfo()->proto().room_id(), game->getGameMetaInfo()->gameId(), + QString::fromStdString(game->getGameMetaInfo()->proto().description())); + QApplication::clipboard()->setText(link); +} + +void TabGame::updateInviteButtonState() +{ + // The dock button stays conservative (pre-start, not full); the menu action + // additionally covers started/full games, which are legitimate spectate + // invites, so it only needs the server-linked + not-closed conditions. + const bool canInvite = !tabSupervisor->getIsLocalGame() && !game->getGameState()->isGameClosed() && + !game->getGameMetaInfo()->started() && + game->getPlayerManager()->getPlayerCount() < game->getGameMetaInfo()->maxPlayers(); + if (inviteButton) { + inviteButton->setVisible(canInvite); + } + if (aInviteToGame) { + aInviteToGame->setEnabled(!tabSupervisor->getIsLocalGame() && !game->getGameState()->isGameClosed()); + } +} + +void TabGame::actInviteToGame() +{ + if (!tabSupervisor || tabSupervisor->getIsLocalGame()) { + return; + } + + GameMetaInfo *metaInfo = game->getGameMetaInfo(); + const QString inviteUrl = makeGameJoinLink( + tabSupervisor->getClient()->serverName(), tabSupervisor->getClient()->serverPort(), metaInfo->proto().room_id(), + metaInfo->gameId(), QString::fromStdString(metaInfo->proto().description())); + + QStringList excludeUserNames; + excludeUserNames << tabSupervisor->getUserListManager()->getOwnUsername(); + for (auto player : game->getPlayerManager()->getPlayers()) { + excludeUserNames << player->getPlayerInfo()->getName(); + } + for (auto it = game->getPlayerManager()->getSpectators().cbegin(); + it != game->getPlayerManager()->getSpectators().cend(); ++it) { + excludeUserNames << QString::fromStdString(it.value().name()); + } + + DlgInviteToGame dlg(tabSupervisor, inviteUrl, metaInfo->proto().only_buddies(), excludeUserNames, this); + dlg.exec(); +} + void TabGame::actConcede() { PlayerLogic *player = game->getPlayerManager()->getActiveLocalPlayer(game->getGameState()->getActivePlayer()); @@ -536,7 +614,8 @@ bool TabGame::leaveGame() void TabGame::actSay() { - if (completer->popup()->isVisible()) { + if (sayEdit->hasVisibleCompleterPopup()) { + sayEdit->hideCompleterPopups(); return; } @@ -556,14 +635,14 @@ void TabGame::addPlayerToAutoCompleteList(QString playerName) { if (sayEdit && !autocompleteUserList.contains(playerName)) { autocompleteUserList << playerName; - sayEdit->setCompletionList(autocompleteUserList); + mentionModel->setStringList(autocompleteUserList); } } void TabGame::removePlayerFromAutoCompleteList(QString playerName) { if (sayEdit && autocompleteUserList.removeOne(playerName)) { - sayEdit->setCompletionList(autocompleteUserList); + mentionModel->setStringList(autocompleteUserList); } } @@ -626,8 +705,8 @@ void TabGame::actRotateViewCCW() void TabGame::actCompleterChanged() { - SettingsCache::instance().getChatMentionCompleter() ? completer->setCompletionRole(2) - : completer->setCompletionRole(1); + SettingsCache::instance().chat().getChatMentionCompleter() ? mentionCompleter->setCompletionRole(2) + : mentionCompleter->setCompletionRole(1); } void TabGame::notifyPlayerJoin(QString playerName) @@ -836,6 +915,7 @@ void TabGame::stopGame() QMapIterator i(deckViewContainers); while (i.hasNext()) { i.next(); + i.value()->playerDeckView->advancePlaymatRotation(); i.value()->show(); } @@ -976,6 +1056,11 @@ void TabGame::createMenuItems() connect(aRotateViewCCW, &QAction::triggered, this, &TabGame::actRotateViewCCW); aGameInfo = new QAction(this); connect(aGameInfo, &QAction::triggered, this, &TabGame::actGameInfo); + aCopyGameLink = new QAction(this); + aCopyGameLink->setEnabled(!tabSupervisor->getIsLocalGame() && !tabSupervisor->getClient()->serverName().isEmpty()); + connect(aCopyGameLink, &QAction::triggered, this, &TabGame::actCopyGameLink); + aInviteToGame = new QAction(this); + connect(aInviteToGame, &QAction::triggered, this, &TabGame::actInviteToGame); aConcede = new QAction(this); connect(aConcede, &QAction::triggered, this, &TabGame::actConcede); if (!game->getGameMetaInfo()->started()) { @@ -1014,6 +1099,8 @@ void TabGame::createMenuItems() gameMenu->addAction(aRotateViewCCW); gameMenu->addSeparator(); gameMenu->addAction(aGameInfo); + gameMenu->addAction(aCopyGameLink); + gameMenu->addAction(aInviteToGame); gameMenu->addAction(aConcede); gameMenu->addAction(aFocusChat); gameMenu->addAction(aLeaveGame); @@ -1022,6 +1109,9 @@ void TabGame::createMenuItems() aCardMenu = gameMenu->addMenu(new QMenu(this)); + // Sync the new action with the same state the dock button already shows. + updateInviteButtonState(); + addTabMenu(gameMenu); } @@ -1036,6 +1126,8 @@ void TabGame::createReplayMenuItems() aRotateViewCCW = nullptr; aResetLayout = nullptr; aGameInfo = nullptr; + aCopyGameLink = nullptr; + aInviteToGame = nullptr; aConcede = nullptr; aFocusChat = nullptr; aLeaveGame = new QAction(this); @@ -1169,16 +1261,17 @@ void TabGame::createPlayAreaWidget(bool bReplay) void TabGame::createReplayDock(GameReplay *replay) { - replayManager = new ReplayManager(this, replay); + replayWidget = new ReplayWidget(this, replay); replayDock = new QDockWidget(this); replayDock->setObjectName("replayDock"); replayDock->setFeatures(QDockWidget::DockWidgetClosable | QDockWidget::DockWidgetFloatable | QDockWidget::DockWidgetMovable); - replayDock->setWidget(replayManager); + replayDock->setWidget(replayWidget); replayDock->setFloating(false); - connect(replayManager, &ReplayManager::eventReplayed, game->getGameEventHandler(), + connect(replayWidget, &ReplayWidget::rewound, this, &TabGame::resetChatAndPhase); + connect(replayWidget, &ReplayWidget::eventReplayed, game->getGameEventHandler(), [this](const auto &event, auto options) { game->getGameEventHandler()->processGameEventContainer(event, nullptr, options); }); @@ -1233,11 +1326,29 @@ void TabGame::createPlayerListDock(bool bReplay) } playerListWidget->setFocusPolicy(Qt::NoFocus); + auto *playerListBox = new QWidget(this); + auto *vbox = new QVBoxLayout(playerListBox); + vbox->setContentsMargins(0, 0, 0, 0); + vbox->setSpacing(0); + + vbox->addWidget(playerListWidget); + + if (!bReplay) { + inviteButton = new QPushButton(tr("Invite"), playerListBox); + inviteButton->setVisible(false); + connect(inviteButton, &QPushButton::clicked, this, &TabGame::actInviteToGame); + vbox->addWidget(inviteButton); + + connect(game->getGameMetaInfo(), &GameMetaInfo::startedChanged, this, &TabGame::updateInviteButtonState); + connect(game->getPlayerManager(), &PlayerManager::playerCountChanged, this, &TabGame::updateInviteButtonState); + updateInviteButtonState(); + } + playerListDock = new QDockWidget(this); playerListDock->setObjectName("playerListDock"); playerListDock->setFeatures(QDockWidget::DockWidgetClosable | QDockWidget::DockWidgetFloatable | QDockWidget::DockWidgetMovable); - playerListDock->setWidget(playerListWidget); + playerListDock->setWidget(playerListBox); playerListDock->setFloating(false); } @@ -1261,11 +1372,12 @@ void TabGame::createMessageDock(bool bReplay) qOverload(&CardInfoFrameWidget::setCard)); connect(messageLog, &MessageLogWidget::showCardInfoPopup, this, &TabGame::showCardInfoPopup); connect(messageLog, &MessageLogWidget::deleteCardInfoPopup, this, &TabGame::deleteCardInfoPopup); + connect(messageLog, &MessageLogWidget::cockatriceLinkActivated, this, &TabGame::cockatriceLinkActivated); if (!bReplay) { connect(messageLog, &MessageLogWidget::openMessageDialog, this, &TabGame::openMessageDialog); connect(messageLog, &MessageLogWidget::addMentionTag, this, &TabGame::addMentionTag); - connect(&SettingsCache::instance(), &SettingsCache::chatMentionCompleterChanged, this, + connect(&SettingsCache::instance().chat(), &ChatSettings::chatMentionCompleterChanged, this, &TabGame::actCompleterChanged); } @@ -1278,12 +1390,25 @@ void TabGame::createMessageDock(bool bReplay) sayEdit->setMaxLength(MAX_TEXT_LENGTH); sayLabel->setBuddy(sayEdit); connect(this, &TabGame::chatMessageSent, game->getGameEventHandler(), &GameEventHandler::handleChatMessageSent); - completer = new QCompleter(autocompleteUserList, sayEdit); - completer->setCaseSensitivity(Qt::CaseInsensitive); - completer->setMaxVisibleItems(5); - completer->setFilterMode(Qt::MatchStartsWith); + mentionModel = new QStringListModel(autocompleteUserList, sayEdit); + mentionCompleter = createMentionCompleter(mentionModel, sayEdit); + sayEdit->addCompleter(mentionCompleter, CompleterTrigger::Mention); + + auto *cardDatabaseModel = new CardDatabaseModel(CardDatabaseManager::getInstance(), false, sayEdit); + auto *displayModel = new CardDatabaseDisplayModel(sayEdit); + displayModel->setSourceModel(cardDatabaseModel); + const CardCompleterSetup cardSetup = createCardCompleter(displayModel, sayEdit); + sayEdit->addCompleter(cardSetup.completer, CompleterTrigger::Card); + + connect(sayEdit, &LineEditCompleter::cardPartialChanged, this, [this, cardSetup](const QString &text) { + cardSetup.searchModel->updateSearchResults(text); + cardSetup.proxyModel->setFilterRegularExpression( + QRegularExpression(QRegularExpression::escape(text), QRegularExpression::CaseInsensitiveOption)); + if (sayEdit->hasFocus()) { + cardSetup.completer->complete(); + } + }); - sayEdit->setCompleter(completer); actCompleterChanged(); if (game->getPlayerManager()->isSpectator()) { diff --git a/cockatrice/src/interface/widgets/tabs/tab_game.h b/cockatrice/src/interface/widgets/tabs/tab_game.h index b9289432d..a05b49a9f 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.h +++ b/cockatrice/src/interface/widgets/tabs/tab_game.h @@ -13,12 +13,13 @@ #include "../game/player/player_logic.h" #include "../game_graphics/log/message_log_widget.h" #include "../interface/widgets/menus/tearoff_menu.h" -#include "../interface/widgets/replay/replay_manager.h" +#include "../interface/widgets/replay/replay_widget.h" #include "tab.h" #include #include #include +#include class CardMenu; class ServerInfo_PlayerProperties; @@ -31,11 +32,12 @@ class AbstractClient; class CardDatabase; class GameView; class GameScene; -class ReplayManager; +class ReplayWidget; class CardInfoFrameWidget; class QTimer; class QSplitter; class QLabel; +class QPushButton; class QToolButton; class QMenu; class ZoneViewLayout; @@ -59,14 +61,16 @@ class TabGame : public Tab private: AbstractGame *game; const UserListProxy *userListProxy; - ReplayManager *replayManager = nullptr; + ReplayWidget *replayWidget = nullptr; QStringList gameTypes; - QCompleter *completer; + QCompleter *mentionCompleter; + QStringListModel *mentionModel; QStringList autocompleteUserList; QStackedWidget *mainWidget; CardInfoFrameWidget *cardInfoFrameWidget; PlayerListWidget *playerListWidget; + QPushButton *inviteButton = nullptr; QLabel *timeElapsedLabel; MessageLogWidget *messageLog; QLabel *sayLabel; @@ -81,9 +85,10 @@ private: QAction *playersSeparator; QMenu *gameMenu, *viewMenu; TearOffMenu *phasesMenu; - QAction *aGameInfo, *aConcede, *aLeaveGame, *aNextPhase, *aNextPhaseAction, *aNextTurn, *aReverseTurn, - *aRemoveLocalArrows, *aRotateViewCW, *aRotateViewCCW, *aResetLayout, *aResetReplayLayout; + QAction *aGameInfo, *aConcede, *aCopyGameLink, *aLeaveGame, *aNextPhase, *aNextPhaseAction, *aNextTurn, + *aReverseTurn, *aRemoveLocalArrows, *aRotateViewCW, *aRotateViewCCW, *aResetLayout, *aResetReplayLayout; QAction *aFocusChat; + QAction *aInviteToGame = nullptr; QList phaseActions; QAction *aCardMenu; @@ -126,6 +131,7 @@ private: void createPlayAreaWidget(bool bReplay = false); void createDeckViewContainerWidget(bool bReplay = false); void createReplayDock(GameReplay *replay); + void updateInviteButtonState(); signals: void gameClosing(TabGame *tab); void containerProcessingStarted(const GameEventContext &context); @@ -145,7 +151,9 @@ private slots: void setCardMenu(CardMenu *menu); void actGameInfo(); + void actInviteToGame(); void actConcede(); + void actCopyGameLink(); void actRemoveLocalArrows(); void actRotateViewCW(); void actRotateViewCCW(); diff --git a/cockatrice/src/interface/widgets/tabs/tab_logs.cpp b/cockatrice/src/interface/widgets/tabs/tab_logs.cpp index 9a030e7d9..f73d06b57 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_logs.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_logs.cpp @@ -17,9 +17,10 @@ #include #include #include -#include +#include -TabLog::TabLog(TabSupervisor *_tabSupervisor, AbstractClient *_client) : Tab(_tabSupervisor), client(_client) +TabLog::TabLog(TabSupervisor *_tabSupervisor, AbstractClient *_client, bool _canUseDeveloperCommands) + : Tab(_tabSupervisor), client(_client), canUseDeveloperCommands(_canUseDeveloperCommands) { roomTable = new QTableWidget(); roomTable->setColumnCount(6); @@ -80,7 +81,9 @@ void TabLog::getClicked() if (!mainRoom->isChecked() && !gameRoom->isChecked() && !privateChat->isChecked()) { mainRoom->setChecked(true); gameRoom->setChecked(true); - privateChat->setChecked(true); + if (!canUseDeveloperCommands) { + privateChat->setChecked(true); + } } if (maximumResults->value() == 0) { @@ -117,7 +120,15 @@ void TabLog::getClicked() }; cmd.set_date_range(dateRange); cmd.set_maximum_results(maximumResults->value()); - PendingCommand *pend = client->prepareModeratorCommand(cmd); + + PendingCommand *pend; + if (canUseDeveloperCommands) { + // Developers query logs through the developer command family. + pend = client->prepareDeveloperCommand(cmd); + } else { + pend = client->prepareModeratorCommand(cmd); + } + connect(pend, &PendingCommand::finished, this, &TabLog::viewLogHistory_processResponse); client->sendCommand(pend); } @@ -171,6 +182,14 @@ void TabLog::createDock() mainRoom = new QCheckBox(tr("Main Room")); gameRoom = new QCheckBox(tr("Game Room")); privateChat = new QCheckBox(tr("Private Chat")); + if (canUseDeveloperCommands) { + // Developers cannot query private conversations. + privateChat->setVisible(false); + // The developer family ignores the IP filter server-side, so showing + // the field would silently unfilter the result by it. Hide it. + labelFindIPAddress->setVisible(false); + findIPAddress->setVisible(false); + } pastDays = new QRadioButton(tr("Past X Days: ")); today = new QRadioButton(tr("Today")); diff --git a/cockatrice/src/interface/widgets/tabs/tab_logs.h b/cockatrice/src/interface/widgets/tabs/tab_logs.h index 5d164dc92..8e914ea64 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_logs.h +++ b/cockatrice/src/interface/widgets/tabs/tab_logs.h @@ -33,6 +33,7 @@ class TabLog : public Tab Q_OBJECT private: AbstractClient *client; + bool canUseDeveloperCommands; QLabel *labelFindUserName, *labelFindIPAddress, *labelFindGameName, *labelFindGameID, *labelMessage, *labelMaximum, *labelDescription; LineEditUnfocusable *findUsername, *findIPAddress, *findGameName, *findGameID, *findMessage; @@ -58,7 +59,7 @@ private slots: void restartLayout(); public: - TabLog(TabSupervisor *_tabSupervisor, AbstractClient *_client); + TabLog(TabSupervisor *_tabSupervisor, AbstractClient *_client, bool _canUseDeveloperCommands = false); ~TabLog() override; void retranslateUi() override; [[nodiscard]] QString getTabText() const override diff --git a/cockatrice/src/interface/widgets/tabs/tab_message.cpp b/cockatrice/src/interface/widgets/tabs/tab_message.cpp index d77cb0391..418843178 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_message.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_message.cpp @@ -17,19 +17,22 @@ #include #include #include -#include +#include +#include TabMessage::TabMessage(TabSupervisor *_tabSupervisor, AbstractClient *_client, const ServerInfo_User &_ownUserInfo, - const ServerInfo_User &_otherUserInfo) + const ServerInfo_User &_otherUserInfo, + bool _userOnline) : Tab(_tabSupervisor), client(_client), ownUserInfo(new ServerInfo_User(_ownUserInfo)), - otherUserInfo(new ServerInfo_User(_otherUserInfo)), userOnline(true) + otherUserInfo(new ServerInfo_User(_otherUserInfo)), userOnline(_userOnline) { chatView = new ChatView(tabSupervisor, 0, true); connect(chatView, &ChatView::showCardInfoPopup, this, &TabMessage::showCardInfoPopup); connect(chatView, &ChatView::deleteCardInfoPopup, this, &TabMessage::deleteCardInfoPopup); connect(chatView, &ChatView::addMentionTag, this, &TabMessage::addMentionTag); + connect(chatView, &ChatView::cockatriceLinkActivated, this, &TabMessage::cockatriceLinkActivated); sayEdit = new LineEditUnfocusable; sayEdit->setMaxLength(MAX_TEXT_LENGTH); connect(sayEdit, &LineEditUnfocusable::returnPressed, this, &TabMessage::sendMessage); @@ -93,28 +96,64 @@ void TabMessage::closeEvent(QCloseEvent *event) event->accept(); } -void TabMessage::sendMessage() +void TabMessage::sendPrivateMessage(const QString &text) { - if (sayEdit->text().isEmpty() || !userOnline) { + if (tabSupervisor->getUserListManager()->isUserIgnored(getUserName())) { + chatView->appendMessage(tr("You have ignored %1; your messages are not delivered.") + .arg(QString::fromStdString(otherUserInfo->name()))); return; } Command_Message cmd; cmd.set_user_name(otherUserInfo->name()); - cmd.set_message(sayEdit->text().toStdString()); + cmd.set_message(text.toStdString()); PendingCommand *pend = client->prepareSessionCommand(cmd); + pend->setExtraData(text); connect(pend, &PendingCommand::finished, this, &TabMessage::messageSent); client->sendCommand(pend); +} +void TabMessage::sendMessage() +{ + if (sayEdit->text().isEmpty()) { + return; + } + + if (!userOnline) { + notifyUserOffline(); + return; + } + + sendPrivateMessage(sayEdit->text()); sayEdit->clear(); } -void TabMessage::messageSent(const Response &response) +bool TabMessage::isUserOnline() const +{ + return userOnline; +} + +void TabMessage::sendInviteMessage(const QString &text) +{ + sayEdit->setText(text); + sendMessage(); +} + +void TabMessage::messageSent(const Response &response, + const CommandContainer & /*commandContainer*/, + const QVariant &extraData) { if (response.response_code() == Response::RespInIgnoreList) { chatView->appendMessage(tr( "This user is ignoring you, they cannot see your messages in main chat and you cannot join their games.")); + } else if (response.response_code() == Response::RespNameNotFound) { + // The recipient went offline before the command reached the server: restore the draft. + userOnline = false; + if (sayEdit->text().isEmpty()) { + sayEdit->setText(extraData.toString()); + } + notifyUserOffline(); } } @@ -126,7 +165,7 @@ void TabMessage::processUserMessageEvent(const Event_UserMessage &event) if (tabSupervisor->currentIndex() != tabSupervisor->indexOf(this)) { soundEngine->playSound("private_message"); } - if (SettingsCache::instance().getShowMessagePopup() && shouldShowSystemPopup(event)) { + if (SettingsCache::instance().chat().getShowMessagePopup() && shouldShowSystemPopup(event)) { showSystemPopup(event); } if (QString::fromStdString(event.sender_name()).toLower().simplified() == "servatrice") { @@ -174,3 +213,8 @@ void TabMessage::processUserJoined(const ServerInfo_User &_userInfo) userOnline = true; *otherUserInfo = _userInfo; } + +void TabMessage::notifyUserOffline() +{ + chatView->appendMessage(tr("Message not sent — %1 is offline.").arg(QString::fromStdString(otherUserInfo->name()))); +} diff --git a/cockatrice/src/interface/widgets/tabs/tab_message.h b/cockatrice/src/interface/widgets/tabs/tab_message.h index 0472bb061..0e6d66d4c 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_message.h +++ b/cockatrice/src/interface/widgets/tabs/tab_message.h @@ -19,6 +19,7 @@ class LineEditUnfocusable; class Event_UserMessage; class Response; class ServerInfo_User; +class CommandContainer; class TabMessage : public Tab { @@ -39,7 +40,7 @@ signals: void maximizeClient(); private slots: void sendMessage(); - void messageSent(const Response &response); + void messageSent(const Response &response, const CommandContainer &commandContainer, const QVariant &extraData); void addMentionTag(QString mentionTag); void messageClicked(); @@ -50,7 +51,8 @@ public: TabMessage(TabSupervisor *_tabSupervisor, AbstractClient *_client, const ServerInfo_User &_ownUserInfo, - const ServerInfo_User &_otherUserInfo); + const ServerInfo_User &_otherUserInfo, + bool _userOnline); ~TabMessage() override; void retranslateUi() override; void tabActivated() override; @@ -62,9 +64,14 @@ public: void processUserLeft(); void processUserJoined(const ServerInfo_User &_userInfo); + [[nodiscard]] bool isUserOnline() const; + void sendPrivateMessage(const QString &text); + void sendInviteMessage(const QString &text); + private: bool shouldShowSystemPopup(const Event_UserMessage &event); void showSystemPopup(const Event_UserMessage &event); + void notifyUserOffline(); }; #endif diff --git a/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp b/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp new file mode 100644 index 000000000..b9c37f1af --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp @@ -0,0 +1,465 @@ +#include "tab_moderation.h" + +#include "abstract_client.h" +#include "tab_supervisor.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +constexpr int COL_ALTS_USER = 0; +constexpr int COL_ALTS_EMAIL = 1; +constexpr int COL_ALTS_CLIENTID = 2; +constexpr int COL_ALTS_REGISTERED = 3; +constexpr int COL_ALTS_LAST_LOGIN = 4; +constexpr int COL_ALTS_WARNS = 5; +constexpr int COL_ALTS_BANS = 6; +constexpr int COL_ALTS_ACTIVE = 7; +constexpr int COL_ALTS_COUNT = 8; + +constexpr int COL_SESSIONS_IP = 0; +constexpr int COL_SESSIONS_CLIENTID = 1; +constexpr int COL_SESSIONS_START = 2; +constexpr int COL_SESSIONS_END = 3; +constexpr int COL_SESSIONS_TYPE = 4; +constexpr int COL_SESSIONS_COUNT = 5; + +constexpr int COL_STAFF_USER = 0; +constexpr int COL_STAFF_LEVEL = 1; +constexpr int COL_STAFF_LAST_LOGIN = 2; +constexpr int COL_STAFF_COUNT = 3; +} // namespace + +TabModeration::TabModeration(TabSupervisor *_tabSupervisor, AbstractClient *_client, const QString &initialUser) + : Tab(_tabSupervisor), client(_client) +{ + auto *centralWidget = new QWidget(this); + setCentralWidget(centralWidget); + + searchEdit = new QLineEdit; + searchEdit->setClearButtonEnabled(true); + connect(searchEdit, &QLineEdit::returnPressed, this, &TabModeration::investigateUser); + + investigateButton = new QPushButton; + connect(investigateButton, &QPushButton::clicked, this, &TabModeration::investigateUser); + + resetPasswordButton = new QPushButton; + connect(resetPasswordButton, &QPushButton::clicked, this, &TabModeration::resetPassword); + + removeAvatarButton = new QPushButton; + connect(removeAvatarButton, &QPushButton::clicked, this, &TabModeration::removeAvatar); + + auto *topBar = new QHBoxLayout; + topBar->addWidget(searchEdit); + topBar->addWidget(investigateButton); + topBar->addStretch(); + topBar->addWidget(resetPasswordButton); + topBar->addWidget(removeAvatarButton); + + userInfoGroup = new QGroupBox; + userInfoNameLabel = new QLabel; + userInfoNameValue = new QLabel; + userInfoRegisteredLabel = new QLabel; + userInfoRegisteredValue = new QLabel; + userInfoLastLoginLabel = new QLabel; + userInfoLastLoginValue = new QLabel; + userInfoStatusLabel = new QLabel; + userInfoStatusValue = new QLabel; + userInfoCountsLabel = new QLabel; + userInfoCountsValue = new QLabel; + userInfoNotesLabel = new QLabel; + userInfoNotesEdit = new QTextEdit; + userInfoNotesEdit->setReadOnly(true); + + auto *infoGrid = new QGridLayout; + infoGrid->addWidget(userInfoNameLabel, 0, 0); + infoGrid->addWidget(userInfoNameValue, 0, 1); + infoGrid->addWidget(userInfoRegisteredLabel, 0, 2); + infoGrid->addWidget(userInfoRegisteredValue, 0, 3); + infoGrid->addWidget(userInfoLastLoginLabel, 1, 0); + infoGrid->addWidget(userInfoLastLoginValue, 1, 1); + infoGrid->addWidget(userInfoStatusLabel, 1, 2); + infoGrid->addWidget(userInfoStatusValue, 1, 3); + infoGrid->addWidget(userInfoCountsLabel, 2, 0); + infoGrid->addWidget(userInfoCountsValue, 2, 1, 1, 3); + infoGrid->addWidget(userInfoNotesLabel, 3, 0, Qt::AlignTop); + infoGrid->addWidget(userInfoNotesEdit, 3, 1, 1, 3); + infoGrid->setColumnStretch(1, 1); + infoGrid->setColumnStretch(3, 1); + + auto *infoLayout = new QVBoxLayout(userInfoGroup); + infoLayout->addLayout(infoGrid); + + auto configureTable = [](QTableWidget *table) { + table->setSelectionBehavior(QAbstractItemView::SelectRows); + table->setSelectionMode(QAbstractItemView::SingleSelection); + table->setEditTriggers(QAbstractItemView::NoEditTriggers); + table->verticalHeader()->setVisible(false); + table->setAlternatingRowColors(true); + table->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents); + table->horizontalHeader()->setStretchLastSection(true); + }; + + altsGroup = new QGroupBox; + altsTable = new QTableWidget(0, COL_ALTS_COUNT); + configureTable(altsTable); + auto *altsLayout = new QVBoxLayout(altsGroup); + altsLayout->addWidget(altsTable); + + sessionsGroup = new QGroupBox; + sessionsTable = new QTableWidget(0, COL_SESSIONS_COUNT); + configureTable(sessionsTable); + auto *sessionsLayout = new QVBoxLayout(sessionsGroup); + sessionsLayout->addWidget(sessionsTable); + + staffGroup = new QGroupBox; + staffTable = new QTableWidget(0, COL_STAFF_COUNT); + configureTable(staffTable); + refreshStaffButton = new QPushButton; + connect(refreshStaffButton, &QPushButton::clicked, this, &TabModeration::requestModeratorLogins); + auto *staffHeader = new QHBoxLayout; + staffHeader->addStretch(); + staffHeader->addWidget(refreshStaffButton); + auto *staffLayout = new QVBoxLayout(staffGroup); + staffLayout->addWidget(staffTable); + staffLayout->addLayout(staffHeader); + + auto *splitter = new QSplitter(Qt::Vertical); + splitter->addWidget(userInfoGroup); + splitter->addWidget(altsGroup); + splitter->addWidget(sessionsGroup); + splitter->addWidget(staffGroup); + splitter->setStretchFactor(0, 1); + splitter->setStretchFactor(1, 2); + splitter->setStretchFactor(2, 2); + splitter->setStretchFactor(3, 1); + + auto *mainLayout = new QVBoxLayout(centralWidget); + mainLayout->addLayout(topBar); + mainLayout->addWidget(splitter); + + retranslateUi(); + clearUserData(); + requestModeratorLogins(); + investigate(initialUser); +} + +void TabModeration::retranslateUi() +{ + searchEdit->setPlaceholderText(tr("User name")); + investigateButton->setText(tr("Investigate")); + resetPasswordButton->setText(tr("Reset Password")); + removeAvatarButton->setText(tr("Remove Avatar")); + refreshStaffButton->setText(tr("Refresh")); + + userInfoGroup->setTitle(tr("User Info")); + userInfoNameLabel->setText(tr("Name:")); + userInfoRegisteredLabel->setText(tr("Registered:")); + userInfoLastLoginLabel->setText(tr("Last login:")); + userInfoStatusLabel->setText(tr("Status:")); + userInfoCountsLabel->setText(tr("Counts:")); + userInfoNotesLabel->setText(tr("Admin notes:")); + + altsGroup->setTitle(tr("Alts")); + sessionsGroup->setTitle(tr("Sessions")); + staffGroup->setTitle(tr("Staff Last Logins")); + + altsTable->setHorizontalHeaderLabels({tr("User"), tr("eMail"), tr("Client ID"), tr("Registered"), tr("Last login"), + tr("Warns"), tr("Bans"), tr("Active")}); + sessionsTable->setHorizontalHeaderLabels({tr("IP"), tr("Client ID"), tr("Start"), tr("End"), tr("Type")}); + staffTable->setHorizontalHeaderLabels({tr("User"), tr("Level"), tr("Last login")}); +} + +QString TabModeration::formatEpoch(quint64 ts) const +{ + if (ts == 0) { + return tr("Unknown"); + } + return QDateTime::fromSecsSinceEpoch(ts).toLocalTime().toString("yyyy-MM-dd HH:mm"); +} + +void TabModeration::clearUserData() +{ + currentUser.clear(); + userInfoNameValue->clear(); + userInfoRegisteredValue->clear(); + userInfoLastLoginValue->clear(); + userInfoStatusValue->clear(); + userInfoCountsValue->clear(); + userInfoNotesEdit->clear(); + altsTable->setRowCount(0); + sessionsTable->setRowCount(0); + resetPasswordButton->setEnabled(false); + removeAvatarButton->setEnabled(false); +} + +void TabModeration::investigate(const QString &userName) +{ + if (userName.isEmpty()) { + return; + } + searchEdit->setText(userName); + investigateUser(); +} + +void TabModeration::investigateUser() +{ + const QString userName = searchEdit->text().simplified(); + if (userName.isEmpty()) { + return; + } + currentUser = userName; + resetPasswordButton->setEnabled(true); + removeAvatarButton->setEnabled(true); + altsTable->setRowCount(0); + sessionsTable->setRowCount(0); + requestUserInfo(userName); + requestSessions(userName); + requestAlts(userName); +} + +void TabModeration::requestUserInfo(const QString &userName) +{ + userInfoNameValue->setText(userName); + userInfoRegisteredValue->setText(tr("Loading...")); + userInfoLastLoginValue->setText(tr("Loading...")); + userInfoStatusValue->clear(); + userInfoCountsValue->clear(); + userInfoNotesEdit->clear(); + + Command_ReportUserInfo cmd; + cmd.set_user_name(userName.toStdString()); + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabModeration::userInfoResponse); + client->sendCommand(pend); +} + +void TabModeration::userInfoResponse(const Response &response) +{ + if (response.response_code() != Response::RespOk) { + userInfoRegisteredValue->clear(); + userInfoLastLoginValue->clear(); + userInfoStatusValue->setText(tr("Error loading user info.")); + return; + } + + const Response_ReportUserInfo &resp = response.GetExtension(Response_ReportUserInfo::ext); + if (resp.has_user_name() && QString::fromStdString(resp.user_name()) != currentUser) { + return; + } + + userInfoRegisteredValue->setText(formatEpoch(resp.registration_time())); + userInfoLastLoginValue->setText(formatEpoch(resp.last_login())); + + QStringList statusParts; + statusParts << (resp.is_active() ? tr("active") : tr("inactive")); + if (resp.has_is_admin() && resp.is_admin()) { + statusParts << tr("admin"); + } + userInfoStatusValue->setText(statusParts.join(", ")); + + userInfoCountsValue->setText(tr("Reports: %1 Bans: %2 Warnings: %3") + .arg(resp.total_reports()) + .arg(resp.total_bans()) + .arg(resp.total_warns())); + + if (resp.has_admin_notes() && !resp.admin_notes().empty()) { + userInfoNotesEdit->setPlainText(QString::fromStdString(resp.admin_notes())); + } else { + userInfoNotesEdit->setPlainText(tr("(none)")); + } +} + +void TabModeration::requestSessions(const QString &userName) +{ + Command_GetUserSessions cmd; + cmd.set_user_name(userName.toStdString()); + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabModeration::sessionsResponse); + client->sendCommand(pend); +} + +void TabModeration::sessionsResponse(const Response &response) +{ + sessionsTable->setRowCount(0); + if (response.response_code() != Response::RespOk) { + return; + } + + const Response_UserSessions &resp = response.GetExtension(Response_UserSessions::ext); + sessionsTable->setRowCount(resp.sessions_size()); + for (int i = 0; i < resp.sessions_size(); ++i) { + const ServerInfo_UserSession &session = resp.sessions(i); + sessionsTable->setItem(i, COL_SESSIONS_IP, new QTableWidgetItem(QString::fromStdString(session.ip_address()))); + sessionsTable->setItem(i, COL_SESSIONS_CLIENTID, + new QTableWidgetItem(QString::fromStdString(session.clientid()))); + sessionsTable->setItem(i, COL_SESSIONS_START, new QTableWidgetItem(formatEpoch(session.start_time()))); + sessionsTable->setItem( + i, COL_SESSIONS_END, + new QTableWidgetItem(session.end_time() == 0 ? tr("Active") : formatEpoch(session.end_time()))); + sessionsTable->setItem(i, COL_SESSIONS_TYPE, + new QTableWidgetItem(QString::fromStdString(session.connection_type()))); + } +} + +void TabModeration::requestAlts(const QString &userName) +{ + Command_GetUserAlts cmd; + cmd.set_user_name(userName.toStdString()); + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabModeration::altsResponse); + client->sendCommand(pend); +} + +void TabModeration::altsResponse(const Response &response) +{ + altsTable->setRowCount(0); + if (response.response_code() != Response::RespOk) { + return; + } + + const Response_UserAlts &resp = response.GetExtension(Response_UserAlts::ext); + altsTable->setRowCount(resp.alts_size()); + for (int i = 0; i < resp.alts_size(); ++i) { + const ServerInfo_UserAlt &alt = resp.alts(i); + altsTable->setItem(i, COL_ALTS_USER, new QTableWidgetItem(QString::fromStdString(alt.user_name()))); + altsTable->setItem(i, COL_ALTS_EMAIL, new QTableWidgetItem(QString::fromStdString(alt.email()))); + altsTable->setItem(i, COL_ALTS_CLIENTID, new QTableWidgetItem(QString::fromStdString(alt.clientid()))); + altsTable->setItem(i, COL_ALTS_REGISTERED, new QTableWidgetItem(formatEpoch(alt.registration_time()))); + altsTable->setItem(i, COL_ALTS_LAST_LOGIN, new QTableWidgetItem(formatEpoch(alt.last_login()))); + altsTable->setItem(i, COL_ALTS_WARNS, new QTableWidgetItem(QString::number(alt.warn_count()))); + altsTable->setItem(i, COL_ALTS_BANS, new QTableWidgetItem(QString::number(alt.ban_count()))); + altsTable->setItem(i, COL_ALTS_ACTIVE, new QTableWidgetItem(alt.is_active() ? tr("yes") : tr("no"))); + } +} + +void TabModeration::requestModeratorLogins() +{ + Command_GetModeratorLastLogins cmd; + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabModeration::moderatorLoginsResponse); + client->sendCommand(pend); +} + +void TabModeration::moderatorLoginsResponse(const Response &response) +{ + staffTable->setRowCount(0); + if (response.response_code() != Response::RespOk) { + return; + } + + const Response_ModeratorLastLogins &resp = response.GetExtension(Response_ModeratorLastLogins::ext); + staffTable->setRowCount(resp.logins_size()); + for (int i = 0; i < resp.logins_size(); ++i) { + const ServerInfo_ModeratorLogin &login = resp.logins(i); + staffTable->setItem(i, COL_STAFF_USER, new QTableWidgetItem(QString::fromStdString(login.user_name()))); + staffTable->setItem(i, COL_STAFF_LAST_LOGIN, new QTableWidgetItem(formatEpoch(login.last_login()))); + + QStringList levels; + if (login.user_level() & ServerInfo_User::IsAdmin) { + levels << tr("Admin"); + } + if (login.user_level() & ServerInfo_User::IsDeveloper) { + levels << tr("Developer"); + } + if (login.user_level() & ServerInfo_User::IsModerator) { + levels << tr("Moderator"); + } + if (login.user_level() & ServerInfo_User::IsJudge) { + levels << tr("Judge"); + } + staffTable->setItem(i, COL_STAFF_LEVEL, new QTableWidgetItem(levels.join(" / "))); + } +} + +void TabModeration::resetPassword() +{ + if (currentUser.isEmpty()) { + return; + } + + QMessageBox::StandardButton choice = + QMessageBox::warning(this, tr("Reset Password"), + tr("Reset the password of %1? A temporary password will be generated and shown to you. " + "The user must change it on their first login.") + .arg(currentUser), + QMessageBox::Ok | QMessageBox::Cancel, QMessageBox::Cancel); + if (choice != QMessageBox::Ok) { + return; + } + + Command_ResetUserPassword cmd; + cmd.set_user_name(currentUser.toStdString()); + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabModeration::resetPasswordResponse); + client->sendCommand(pend); +} + +void TabModeration::resetPasswordResponse(const Response &response) +{ + if (response.response_code() != Response::RespOk) { + QMessageBox::critical(this, tr("Error"), tr("Password reset failed.")); + return; + } + + const Response_ResetUserPassword &resp = response.GetExtension(Response_ResetUserPassword::ext); + QMessageBox::information(this, tr("Password reset"), + tr("Temporary password for %1:\n\n%2\n\nPass it to the user through a secure channel.") + .arg(QString::fromStdString(resp.user_name())) + .arg(QString::fromStdString(resp.temporary_password()))); +} + +void TabModeration::removeAvatar() +{ + if (currentUser.isEmpty()) { + return; + } + + QMessageBox::StandardButton choice = + QMessageBox::warning(this, tr("Remove Avatar"), + tr("Remove the avatar of %1? The user will have to upload a new one.").arg(currentUser), + QMessageBox::Ok | QMessageBox::Cancel, QMessageBox::Cancel); + if (choice != QMessageBox::Ok) { + return; + } + + Command_RemoveUserAvatar cmd; + cmd.set_user_name(currentUser.toStdString()); + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabModeration::removeAvatarResponse); + client->sendCommand(pend); +} + +void TabModeration::removeAvatarResponse(const Response &response) +{ + if (response.response_code() != Response::RespOk) { + QMessageBox::critical(this, tr("Error"), tr("Could not remove the avatar.")); + return; + } + + const Response_RemoveUserAvatar &resp = response.GetExtension(Response_RemoveUserAvatar::ext); + QMessageBox::information(this, tr("Avatar removed"), + tr("The avatar of %1 has been removed.").arg(QString::fromStdString(resp.user_name()))); +} diff --git a/cockatrice/src/interface/widgets/tabs/tab_moderation.h b/cockatrice/src/interface/widgets/tabs/tab_moderation.h new file mode 100644 index 000000000..0a534992b --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_moderation.h @@ -0,0 +1,83 @@ +#ifndef TAB_MODERATION_H +#define TAB_MODERATION_H + +#include "tab.h" + +#include + +class AbstractClient; +class QGroupBox; +class QLabel; +class QLineEdit; +class QPushButton; +class QTableWidget; +class QTextEdit; + +/** + * Staff investigation tool. Lets moderators look up a user's account data, + * alternate accounts, login sessions, and staff login activity, and offers + * the password-reset and remove-avatar actions. + */ +class TabModeration : public Tab +{ + Q_OBJECT +public: + explicit TabModeration(TabSupervisor *_tabSupervisor, AbstractClient *_client, const QString &initialUser = {}); + void retranslateUi() override; + [[nodiscard]] QString getTabText() const override + { + return tr("Moderation"); + } + void investigate(const QString &userName); + +private slots: + void investigateUser(); + void userInfoResponse(const Response &response); + void sessionsResponse(const Response &response); + void altsResponse(const Response &response); + void moderatorLoginsResponse(const Response &response); + void resetPassword(); + void resetPasswordResponse(const Response &response); + void removeAvatar(); + void removeAvatarResponse(const Response &response); + +private: + void requestUserInfo(const QString &userName); + void requestSessions(const QString &userName); + void requestAlts(const QString &userName); + void requestModeratorLogins(); + void clearUserData(); + [[nodiscard]] QString formatEpoch(quint64 ts) const; + + AbstractClient *client; + QString currentUser; + + QLineEdit *searchEdit; + QPushButton *investigateButton; + QPushButton *resetPasswordButton; + QPushButton *removeAvatarButton; + + QGroupBox *userInfoGroup; + QLabel *userInfoNameLabel; + QLabel *userInfoNameValue; + QLabel *userInfoRegisteredLabel; + QLabel *userInfoRegisteredValue; + QLabel *userInfoLastLoginLabel; + QLabel *userInfoLastLoginValue; + QLabel *userInfoStatusLabel; + QLabel *userInfoStatusValue; + QLabel *userInfoCountsLabel; + QLabel *userInfoCountsValue; + QLabel *userInfoNotesLabel; + QTextEdit *userInfoNotesEdit; + + QGroupBox *altsGroup; + QTableWidget *altsTable; + QGroupBox *sessionsGroup; + QTableWidget *sessionsTable; + QGroupBox *staffGroup; + QTableWidget *staffTable; + QPushButton *refreshStaffButton; +}; + +#endif // TAB_MODERATION_H diff --git a/cockatrice/src/interface/widgets/tabs/tab_replays.cpp b/cockatrice/src/interface/widgets/tabs/tab_replays.cpp index af52c038f..ac4b2cbe9 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_replays.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_replays.cpp @@ -1,6 +1,7 @@ #include "tab_replays.h" #include "../../../client/settings/cache_settings.h" +#include "../../pixel_map_generator.h" #include "../interface/widgets/server/remote/remote_replay_list_tree_widget.h" #include "tab_game.h" @@ -29,6 +30,7 @@ #include #include #include +#include inline Q_LOGGING_CATEGORY(TabReplaysLog, "replays_tab"); @@ -59,7 +61,7 @@ TabReplays::TabReplays(TabSupervisor *_tabSupervisor, AbstractClient *_client, c QGroupBox *TabReplays::createLeftLayout() { localDirModel = new QFileSystemModel(this); - localDirModel->setRootPath(SettingsCache::instance().getReplaysPath()); + localDirModel->setRootPath(SettingsCache::instance().paths().getReplaysPath()); localDirModel->sort(0, Qt::AscendingOrder); localDirView = new QTreeView; @@ -101,17 +103,17 @@ QGroupBox *TabReplays::createLeftLayout() // Left side actions aOpenLocalReplay = new QAction(this); - aOpenLocalReplay->setIcon(QPixmap("theme:icons/view")); + aOpenLocalReplay->setIcon(themePixmap(QStringLiteral("icons/view"))); connect(aOpenLocalReplay, &QAction::triggered, this, &TabReplays::actOpenLocalReplay); connect(localDirView, &QTreeView::doubleClicked, this, &TabReplays::actOpenLocalReplay); aRenameLocal = new QAction(this); - aRenameLocal->setIcon(QPixmap("theme:icons/rename")); + aRenameLocal->setIcon(themePixmap(QStringLiteral("icons/rename"))); connect(aRenameLocal, &QAction::triggered, this, &TabReplays::actRenameLocal); aNewLocalFolder = new QAction(this); aNewLocalFolder->setIcon(qApp->style()->standardIcon(QStyle::SP_FileDialogNewFolder)); connect(aNewLocalFolder, &QAction::triggered, this, &TabReplays::actNewLocalFolder); aDeleteLocalReplay = new QAction(this); - aDeleteLocalReplay->setIcon(QPixmap("theme:icons/remove_row")); + aDeleteLocalReplay->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); connect(aDeleteLocalReplay, &QAction::triggered, this, &TabReplays::actDeleteLocalReplay); aOpenReplaysFolder = new QAction(this); @@ -163,24 +165,24 @@ QGroupBox *TabReplays::createRightLayout() // Right side actions aOpenRemoteReplay = new QAction(this); - aOpenRemoteReplay->setIcon(QPixmap("theme:icons/view")); + aOpenRemoteReplay->setIcon(themePixmap(QStringLiteral("icons/view"))); connect(aOpenRemoteReplay, &QAction::triggered, this, &TabReplays::actOpenRemoteReplay); connect(serverDirView, &QTreeView::doubleClicked, this, &TabReplays::actOpenRemoteReplay); aDownload = new QAction(this); - aDownload->setIcon(QPixmap("theme:icons/arrow_left_green")); + aDownload->setIcon(themePixmap(QStringLiteral("icons/arrow_left_green"))); connect(aDownload, &QAction::triggered, this, &TabReplays::actDownload); aKeep = new QAction(this); - aKeep->setIcon(QPixmap("theme:icons/lock")); + aKeep->setIcon(themePixmap(QStringLiteral("icons/lock"))); connect(aKeep, &QAction::triggered, this, &TabReplays::actKeepRemoteReplay); aDeleteRemoteReplay = new QAction(this); - aDeleteRemoteReplay->setIcon(QPixmap("theme:icons/remove_row")); + aDeleteRemoteReplay->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); connect(aDeleteRemoteReplay, &QAction::triggered, this, &TabReplays::actDeleteRemoteReplay); aGetReplayCode = new QAction(this); - aGetReplayCode->setIcon(QPixmap("theme:icons/share")); + aGetReplayCode->setIcon(themePixmap(QStringLiteral("icons/share"))); connect(aGetReplayCode, &QAction::triggered, this, &TabReplays::actGetReplayCode); aSubmitReplayCode = new QAction(this); - aSubmitReplayCode->setIcon(QPixmap("theme:icons/search")); + aSubmitReplayCode->setIcon(themePixmap(QStringLiteral("icons/search"))); connect(aSubmitReplayCode, &QAction::triggered, this, &TabReplays::actSubmitReplayCode); // Add actions to toolbars diff --git a/cockatrice/src/interface/widgets/tabs/tab_report.cpp b/cockatrice/src/interface/widgets/tabs/tab_report.cpp new file mode 100644 index 000000000..0b9108f9b --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_report.cpp @@ -0,0 +1,927 @@ +#include "tab_report.h" + +#include "../utility/report_utils.h" +#include "abstract_client.h" +#include "tab_supervisor.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +constexpr int COL_ID = 0; +constexpr int COL_TIME = 1; +constexpr int COL_REPORTER = 2; +constexpr int COL_REPORTED = 3; +constexpr int COL_CATEGORY = 4; +constexpr int COL_GAMEID = 5; +constexpr int COL_STATUS = 6; +constexpr int COL_ASSIGNED = 7; +constexpr int COL_REPLAY = 8; +constexpr int COL_ROOM = 9; +constexpr int COL_COUNT = 10; +constexpr int REFRESH_INTERVAL_MS = 300000; +} // namespace + +TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : Tab(_tabSupervisor), client(_client) +{ + auto *centralWidget = new QWidget(this); + setCentralWidget(centralWidget); + + searchEdit = new QLineEdit; + searchEdit->setClearButtonEnabled(true); + connect(searchEdit, &QLineEdit::textChanged, this, &TabReport::applyFilters); + + statusFilter = new QComboBox; + connect(statusFilter, &QComboBox::currentIndexChanged, this, &TabReport::applyFilters); + + unresolvedOnlyBox = new QCheckBox; + unresolvedOnlyBox->setChecked(true); +#if QT_VERSION >= QT_VERSION_CHECK(6, 7, 0) + connect(unresolvedOnlyBox, &QCheckBox::checkStateChanged, this, &TabReport::refreshList); +#else + connect(unresolvedOnlyBox, &QCheckBox::stateChanged, this, &TabReport::refreshList); +#endif + + refreshButton = new QPushButton; + connect(refreshButton, &QPushButton::clicked, this, &TabReport::refreshList); + + refreshTimer = new QTimer(this); + refreshTimer->setInterval(REFRESH_INTERVAL_MS); + connect(refreshTimer, &QTimer::timeout, this, [this]() { + if (tabSupervisor->currentWidget() == this) { + refreshList(); + } + }); + refreshTimer->start(); + + auto *topBar = new QHBoxLayout; + topBar->addWidget(searchEdit); + topBar->addWidget(statusFilter); + topBar->addWidget(unresolvedOnlyBox); + topBar->addStretch(); + topBar->addWidget(refreshButton); + + statsLabel = new QLabel; + + table = new QTableWidget(0, COL_COUNT); + table->setSelectionBehavior(QAbstractItemView::SelectRows); + table->setSelectionMode(QAbstractItemView::SingleSelection); + table->setEditTriggers(QAbstractItemView::NoEditTriggers); + table->setSortingEnabled(true); + table->verticalHeader()->setVisible(false); + table->setAlternatingRowColors(true); + table->horizontalHeader()->setSectionResizeMode(COL_TIME, QHeaderView::ResizeToContents); + table->horizontalHeader()->setSectionResizeMode(COL_REPORTED, QHeaderView::Stretch); + table->horizontalHeader()->setSectionResizeMode(COL_REPORTER, QHeaderView::ResizeToContents); + connect(table, &QTableWidget::itemSelectionChanged, this, &TabReport::onSelectionChanged); + + descGroup = new QGroupBox; + descriptionEdit = new QTextEdit; + descriptionEdit->setReadOnly(true); + auto *descLayout = new QVBoxLayout(descGroup); + descLayout->setContentsMargins(4, 4, 4, 4); + descLayout->addWidget(descriptionEdit); + + chatGroup = new QGroupBox; + chatLogEdit = new QTextEdit; + chatLogEdit->setReadOnly(true); + QFont monoFont("monospace"); + monoFont.setStyleHint(QFont::Monospace); + const int systemPointSize = QFontDatabase::systemFont(QFontDatabase::GeneralFont).pointSize(); + if (systemPointSize > 0) { + monoFont.setPointSize(systemPointSize); + } + chatLogEdit->setFont(monoFont); + auto *chatLayout = new QVBoxLayout(chatGroup); + chatLayout->setContentsMargins(4, 4, 4, 4); + chatLayout->addWidget(chatLogEdit); + + commentsGroup = new QGroupBox; + commentsEdit = new QTextEdit; + commentsEdit->setReadOnly(true); + auto *commentInputRow = new QHBoxLayout; + commentInput = new QLineEdit; + commentButton = new QPushButton; + commentButton->setEnabled(false); + connect(commentButton, &QPushButton::clicked, this, &TabReport::addComment); + connect(commentInput, &QLineEdit::returnPressed, this, &TabReport::addComment); + commentInputRow->addWidget(commentInput); + commentInputRow->addWidget(commentButton); + auto *commentsLayout = new QVBoxLayout(commentsGroup); + commentsLayout->setContentsMargins(4, 4, 4, 4); + commentsLayout->addWidget(commentsEdit); + commentsLayout->addLayout(commentInputRow); + + userContextGroup = new QGroupBox; + userContextName = new QLabel; + userContextAccountAge = new QLabel; + userContextReports = new QLabel; + userContextBans = new QLabel; + userContextWarns = new QLabel; + userContextNotes = new QTextEdit; + userContextNotes->setReadOnly(true); + userContextNotes->setMaximumHeight(80); + userContextRecentReports = new QTextEdit; + userContextRecentReports->setReadOnly(true); + userContextRecentReports->setMaximumHeight(120); + userContextUserLabel = new QLabel; + userContextAgeLabel = new QLabel; + userContextReportsLabel = new QLabel; + userContextBansLabel = new QLabel; + userContextWarnsLabel = new QLabel; + userContextNotesLabel = new QLabel; + userContextRecentReportsLabel = new QLabel; + auto *ucLayout = new QGridLayout(userContextGroup); + ucLayout->setContentsMargins(8, 8, 8, 8); + ucLayout->addWidget(userContextUserLabel, 0, 0); + ucLayout->addWidget(userContextName, 0, 1, 1, 3); + ucLayout->addWidget(userContextAgeLabel, 1, 0); + ucLayout->addWidget(userContextAccountAge, 1, 1); + ucLayout->addWidget(userContextReportsLabel, 1, 2); + ucLayout->addWidget(userContextReports, 1, 3); + ucLayout->addWidget(userContextBansLabel, 2, 0); + ucLayout->addWidget(userContextBans, 2, 1); + ucLayout->addWidget(userContextWarnsLabel, 2, 2); + ucLayout->addWidget(userContextWarns, 2, 3); + ucLayout->addWidget(userContextNotesLabel, 3, 0, Qt::AlignTop); + ucLayout->addWidget(userContextNotes, 3, 1, 1, 3); + ucLayout->addWidget(userContextRecentReportsLabel, 4, 0, 1, 4); + ucLayout->addWidget(userContextRecentReports, 5, 0, 1, 4); + userContextGroup->setVisible(false); + + statsGroup = new QGroupBox; + statsGroup->setCheckable(true); + statsTotalLabel = new QLabel; + statsTrendLabel = new QLabel; + statsCategoriesLabel = new QLabel; + statsDetailText = new QTextEdit; + statsDetailText->setReadOnly(true); + statsDetailText->setMaximumHeight(150); + auto *statsContent = new QWidget; + auto *sgLayout = new QVBoxLayout(statsContent); + sgLayout->setContentsMargins(8, 8, 8, 8); + sgLayout->addWidget(statsTotalLabel); + sgLayout->addWidget(statsTrendLabel); + sgLayout->addWidget(statsCategoriesLabel); + sgLayout->addWidget(statsDetailText); + auto *statsGroupLayout = new QVBoxLayout(statsGroup); + statsGroupLayout->setContentsMargins(0, 0, 0, 0); + statsGroupLayout->addWidget(statsContent); + statsGroup->setChecked(true); + connect(statsGroup, &QGroupBox::toggled, this, [this, statsContent](bool checked) { + statsContent->setVisible(checked); + if (checked) { + requestStats(); + } + }); + + detailSplitter = new QSplitter(Qt::Vertical); + detailSplitter->addWidget(descGroup); + detailSplitter->addWidget(chatGroup); + detailSplitter->addWidget(commentsGroup); + detailSplitter->setStretchFactor(0, 1); + detailSplitter->setStretchFactor(1, 1); + detailSplitter->setStretchFactor(2, 2); + + assignButton = new QPushButton; + resolveButton = new QPushButton; + resolveWithNoteButton = new QPushButton; + dismissButton = new QPushButton; + viewReplayButton = new QPushButton; + joinGameButton = new QPushButton; + connect(assignButton, &QPushButton::clicked, this, &TabReport::assignReport); + connect(resolveButton, &QPushButton::clicked, this, [this]() { resolveReport(false, false); }); + connect(resolveWithNoteButton, &QPushButton::clicked, this, [this]() { resolveReport(false, true); }); + connect(dismissButton, &QPushButton::clicked, this, [this]() { resolveReport(true, true); }); + connect(viewReplayButton, &QPushButton::clicked, this, &TabReport::viewReplay); + connect(joinGameButton, &QPushButton::clicked, this, &TabReport::joinGame); + + statusLabel = new QLabel; + + auto *actionBar = new QHBoxLayout; + actionBar->addWidget(assignButton); + actionBar->addWidget(resolveButton); + actionBar->addWidget(resolveWithNoteButton); + actionBar->addWidget(dismissButton); + actionBar->addSpacing(20); + actionBar->addWidget(viewReplayButton); + actionBar->addWidget(joinGameButton); + actionBar->addStretch(); + actionBar->addWidget(statusLabel); + + auto *layout = new QVBoxLayout(centralWidget); + layout->addLayout(topBar); + layout->addWidget(statsLabel); + layout->addWidget(table, 2); + layout->addWidget(detailSplitter, 1); + layout->addWidget(userContextGroup); + layout->addWidget(statsGroup); + layout->addLayout(actionBar); + + retranslateUi(); + + setActionsEnabled(false); + refreshList(); +} + +void TabReport::retranslateUi() +{ + searchEdit->setPlaceholderText(tr("Search by username, category...")); + statusFilter->clear(); + statusFilter->addItem(tr("All Statuses"), ""); + statusFilter->addItem(tr("Open"), "open"); + statusFilter->addItem(tr("Assigned"), "assigned"); + statusFilter->addItem(tr("Resolved"), "resolved"); + statusFilter->addItem(tr("Dismissed"), "dismissed"); + unresolvedOnlyBox->setText(tr("Unresolved only")); + refreshButton->setText(tr("Refresh")); + + table->setHorizontalHeaderLabels({tr("#"), tr("Time"), tr("Reporter"), tr("Reported User"), tr("Category"), + tr("Game ID"), tr("Status"), tr("Assigned To"), tr("Replay"), tr("Room")}); + + descGroup->setTitle(tr("Description")); + chatGroup->setTitle(tr("Chat Log Context")); + chatGroup->setToolTip( + tr("Chat log attached by the reporter. It is captured from their client and may be incomplete or edited.")); + commentsGroup->setTitle(tr("Comments / Thread")); + descriptionEdit->setPlaceholderText(tr("No description.")); + chatLogEdit->setPlaceholderText(tr("No chat log attached.")); + commentsEdit->setPlaceholderText(tr("No comments yet.")); + commentInput->setPlaceholderText(tr("Type a reply...")); + commentButton->setText(tr("Send")); + + userContextGroup->setTitle(tr("Reported User Context")); + userContextUserLabel->setText(tr("User:")); + userContextAgeLabel->setText(tr("Account Age:")); + userContextReportsLabel->setText(tr("Reports:")); + userContextBansLabel->setText(tr("Bans:")); + userContextWarnsLabel->setText(tr("Warns:")); + userContextNotesLabel->setText(tr("Admin Notes:")); + userContextRecentReportsLabel->setText(tr("Recent Reports Against User:")); + userContextAccountAge->setText(QString()); + userContextReports->setText(QString()); + userContextBans->setText(QString()); + userContextWarns->setText(QString()); + + statsGroup->setTitle(tr("Report Statistics")); + + assignButton->setText(tr("Assign to Me")); + resolveButton->setText(tr("Resolve")); + resolveWithNoteButton->setText(tr("Resolve with note...")); + dismissButton->setText(tr("Dismiss...")); + viewReplayButton->setText(tr("View Replay")); + joinGameButton->setText(tr("Join Game")); +} + +void TabReport::refreshList() +{ + selectedReportIdBeforeRefresh = selectedReportId(); + commentDraftBeforeRefresh = commentInput->text(); + previousSelectedReportValid = selectedReportInfo(previousSelectedReport); + + refreshButton->setEnabled(false); + statusLabel->setText(tr("Loading...")); + table->setRowCount(0); + allReports.clear(); + filteredReports.clear(); + commentButton->setEnabled(false); + setActionsEnabled(false); + + Command_ReportList cmd; + cmd.set_unresolved_only(unresolvedOnlyBox->isChecked()); + + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabReport::reportListResponse); + client->sendCommand(pend); +} + +void TabReport::reportListResponse(const Response &response) +{ + refreshButton->setEnabled(true); + + if (response.response_code() != Response::RespOk) { + statusLabel->setText(tr("Failed to load reports.")); + return; + } + + const Response_ReportList &resp = response.GetExtension(Response_ReportList::ext); + allReports.clear(); + for (int i = 0; i < resp.reports_size(); ++i) { + allReports.append(resp.reports(i)); + } + + applyFilters(); + updateStats(); + + if (selectedReportIdBeforeRefresh >= 0) { + bool found = false; + for (int row = 0; row < table->rowCount(); ++row) { + if (table->item(row, COL_ID) && + table->item(row, COL_ID)->data(Qt::UserRole).toInt() == selectedReportIdBeforeRefresh) { + found = true; + { + QSignalBlocker blocker(table); + table->setCurrentCell(row, 0); + } + break; + } + } + + if (found) { + bool dataChanged = false; + ServerInfo_Report newReport; + if (!selectedReportInfo(newReport) || !previousSelectedReportValid || + previousSelectedReport.description() != newReport.description() || + previousSelectedReport.status() != newReport.status() || + previousSelectedReport.resolution_note() != newReport.resolution_note() || + previousSelectedReport.assigned_mod_name() != newReport.assigned_mod_name()) { + dataChanged = true; + } + + if (dataChanged || detailsRequestedReportId != selectedReportIdBeforeRefresh) { + loadReportDetails(selectedReportIdBeforeRefresh); + } + updateActionStates(); + } else { + descriptionEdit->clear(); + chatLogEdit->clear(); + commentsEdit->clear(); + setActionsEnabled(false); + userContextGroup->setVisible(false); + } + } + + if (commentInput->text().isEmpty()) { + commentInput->setText(commentDraftBeforeRefresh); + } + + if (statsGroup->isChecked()) { + requestStats(); + } +} + +void TabReport::applyFilters() +{ + filteredReports.clear(); + + const QString searchText = searchEdit->text().toLower(); + const QString statusFilterValue = statusFilter->currentData().toString(); + + for (const ServerInfo_Report &r : allReports) { + bool matchesSearch = searchText.isEmpty() || + QString::fromStdString(r.reporter_name()).toLower().contains(searchText) || + QString::fromStdString(r.reported_user_name()).toLower().contains(searchText) || + QString::fromStdString(r.category()).toLower().contains(searchText) || + QString::fromStdString(r.description()).toLower().contains(searchText); + + bool matchesStatus = statusFilterValue.isEmpty() || QString::fromStdString(r.status()) == statusFilterValue; + + if (matchesSearch && matchesStatus) { + filteredReports.append(r); + } + } + + table->setSortingEnabled(false); + table->setRowCount(filteredReports.size()); + + for (int row = 0; row < filteredReports.size(); ++row) { + const ServerInfo_Report &r = filteredReports[row]; + + report_utils::fillReportTableRow(table, row, r, COL_ID, COL_TIME, COL_REPORTED, COL_CATEGORY, COL_GAMEID, + COL_STATUS, COL_ASSIGNED); + + table->setItem(row, COL_REPORTER, new QTableWidgetItem(QString::fromStdString(r.reporter_name()))); + + table->setItem(row, COL_REPLAY, + new QTableWidgetItem(r.has_replay_id() && r.replay_id() > 0 ? tr("Yes") : tr("No"))); + + table->setItem(row, COL_ROOM, + new QTableWidgetItem(r.has_room_id() && r.room_id() > 0 ? QString::number(r.room_id()) : "")); + } + + table->setSortingEnabled(true); + table->resizeColumnsToContents(); + table->horizontalHeader()->setSectionResizeMode(COL_REPORTED, QHeaderView::Stretch); + + statusLabel->setText(tr("%1 report(s)").arg(filteredReports.size())); +} + +void TabReport::updateStats() +{ + int open = 0, assigned = 0, resolved = 0, dismissed = 0; + for (const ServerInfo_Report &r : allReports) { + QString status = QString::fromStdString(r.status()); + if (status == "open") { + open++; + } else if (status == "assigned") { + assigned++; + } else if (status == "resolved") { + resolved++; + } else if (status == "dismissed") { + dismissed++; + } + } + + statsLabel->setText(tr("%1 open | %2 assigned | %3 resolved | %4 dismissed") + .arg(open) + .arg(assigned) + .arg(resolved) + .arg(dismissed)); +} + +void TabReport::onSelectionChanged() +{ + const int row = table->currentRow(); + if (row < 0 || !table->item(row, COL_ID)) { + descriptionEdit->clear(); + chatLogEdit->clear(); + commentsEdit->clear(); + commentInput->clear(); + commentButton->setEnabled(false); + setActionsEnabled(false); + userContextGroup->setVisible(false); + return; + } + + const int reportId = table->item(row, COL_ID)->data(Qt::UserRole).toInt(); + loadReportDetails(reportId); + updateActionStates(); +} + +void TabReport::loadReportDetails(int reportId) +{ + detailsRequestedReportId = reportId; + + for (const ServerInfo_Report &r : filteredReports) { + if (r.report_id() == reportId) { + descriptionEdit->setPlainText(QString::fromStdString(r.description())); + + QString reportedUser = QString::fromStdString(r.reported_user_name()); + if (!reportedUser.isEmpty()) { + requestUserInfo(reportedUser); + } else { + userContextGroup->setVisible(false); + } + + QString status = QString::fromStdString(r.status()); + bool canComment = (status == "open" || status == "assigned"); + commentButton->setEnabled(canComment); + commentInput->setEnabled(canComment); + if (!canComment) { + commentInput->setPlaceholderText(tr("This report is closed.")); + } else { + commentInput->setPlaceholderText(tr("Type a reply...")); + } + break; + } + } + + chatLogEdit->setPlainText(tr("Loading...")); + commentsEdit->setPlainText(tr("Loading...")); + + Command_ReportDetails cmd; + cmd.set_report_id(reportId); + + const int seq = ++detailsRequestSeq; + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, [this, seq](const Response &response) { + if (seq != detailsRequestSeq) { + return; + } + reportDetailsResponse(response); + }); + client->sendCommand(pend); +} + +void TabReport::reportDetailsResponse(const Response &response) +{ + if (response.response_code() != Response::RespOk) { + if (selectedReportId() == detailsRequestedReportId) { + chatLogEdit->clear(); + commentsEdit->setPlainText(tr("Failed to load report details.")); + } + return; + } + + const Response_ReportDetails &resp = response.GetExtension(Response_ReportDetails::ext); + const ServerInfo_Report &r = resp.report(); + + if (selectedReportId() != r.report_id()) { + return; + } + + report_utils::renderReportDetails(chatLogEdit, commentsEdit, r, tr("No comments yet."), tr("[Moderator]"), + tr("[Reporter]")); +} + +void TabReport::updateActionStates() +{ + const int row = table->currentRow(); + if (row < 0 || !table->item(row, COL_STATUS)) { + setActionsEnabled(false); + return; + } + + ServerInfo_Report report; + if (!selectedReportInfo(report)) { + setActionsEnabled(false); + return; + } + + const QString status = QString::fromStdString(report.status()); + assignButton->setEnabled(status == "open"); + resolveButton->setEnabled(status == "open" || status == "assigned"); + resolveWithNoteButton->setEnabled(status == "open" || status == "assigned"); + dismissButton->setEnabled(status == "open" || status == "assigned"); + + const bool hasGameId = report.game_id() > 0; + const bool hasReplay = hasGameId && report.has_replay_id() && report.replay_id() > 0; + viewReplayButton->setEnabled(hasReplay); + joinGameButton->setEnabled(hasGameId && report.has_room_id() && report.room_id() > 0); +} + +void TabReport::setActionsEnabled(bool enabled) +{ + assignButton->setEnabled(enabled); + resolveButton->setEnabled(enabled); + resolveWithNoteButton->setEnabled(enabled); + dismissButton->setEnabled(enabled); + viewReplayButton->setEnabled(false); + joinGameButton->setEnabled(false); + commentButton->setEnabled(enabled); + commentInput->setEnabled(enabled); +} + +int TabReport::selectedReportId() const +{ + const int row = table->currentRow(); + if (row < 0 || !table->item(row, COL_ID)) { + return -1; + } + return table->item(row, COL_ID)->data(Qt::UserRole).toInt(); +} + +bool TabReport::selectedReportInfo(ServerInfo_Report &info) const +{ + const int reportId = selectedReportId(); + if (reportId < 0) { + return false; + } + + for (const ServerInfo_Report &r : filteredReports) { + if (r.report_id() == reportId) { + info = r; + return true; + } + } + + return false; +} + +void TabReport::assignReport() +{ + const int reportId = selectedReportId(); + if (reportId < 0) { + return; + } + + setActionsEnabled(false); + statusLabel->setText(tr("Assigning...")); + + Command_ReportAssign cmd; + cmd.set_report_id(reportId); + + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabReport::assignResponse); + client->sendCommand(pend); +} + +void TabReport::assignResponse(const Response &response) +{ + if (response.response_code() == Response::RespOk) { + statusLabel->setText(tr("Assigned.")); + refreshList(); + } else { + statusLabel->setText(tr("Assignment failed.")); + updateActionStates(); + } +} + +void TabReport::resolveReport(bool dismissed, bool promptNote) +{ + const int reportId = selectedReportId(); + if (reportId < 0) { + return; + } + + QString note; + if (promptNote) { + bool ok; + note = QInputDialog::getText(this, dismissed ? tr("Dismiss Report") : tr("Resolve Report"), + dismissed ? tr("Optional note:") : tr("Resolution note (optional):"), + QLineEdit::Normal, QString(), &ok); + if (!ok) { + return; + } + } + + setActionsEnabled(false); + statusLabel->setText(dismissed ? tr("Dismissing...") : tr("Resolving...")); + + Command_ReportResolve cmd; + cmd.set_report_id(reportId); + cmd.set_dismissed(dismissed); + if (!note.isEmpty()) { + cmd.set_resolution_note(note.toStdString()); + } + + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabReport::resolveResponse); + client->sendCommand(pend); +} + +void TabReport::resolveResponse(const Response &response) +{ + if (response.response_code() == Response::RespOk) { + statusLabel->setText(tr("Done.")); + refreshList(); + } else { + statusLabel->setText(tr("Action failed.")); + updateActionStates(); + } +} + +void TabReport::viewReplay() +{ + ServerInfo_Report report; + if (!selectedReportInfo(report) || report.game_id() <= 0) { + return; + } + + setActionsEnabled(false); + statusLabel->setText(tr("Loading replay...")); + + Command_ReplayDownloadByGameId cmd; + cmd.set_game_id(report.game_id()); + + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabReport::viewReplayResponse); + client->sendCommand(pend); +} + +void TabReport::viewReplayResponse(const Response &response) +{ + if (response.response_code() != Response::RespOk) { + statusLabel->setText(tr("No replay available for this game.")); + updateActionStates(); + return; + } + + const Response_ReplayDownloadByGameId &resp = response.GetExtension(Response_ReplayDownloadByGameId::ext); + GameReplay *replay = new GameReplay; + if (replay->ParseFromString(resp.replay_data())) { + emit openReplay(replay); + statusLabel->setText(tr("Replay opened.")); + } else { + delete replay; + statusLabel->setText(tr("Failed to parse replay.")); + } + + updateActionStates(); +} + +void TabReport::joinGame() +{ + ServerInfo_Report report; + if (!selectedReportInfo(report) || report.game_id() <= 0) { + return; + } + + const int roomId = report.has_room_id() ? report.room_id() : -1; + if (roomId <= 0) { + statusLabel->setText(tr("No room recorded for this report, use the replay instead.")); + return; + } + + emit requestJoinGame(report.game_id(), roomId); + statusLabel->setText(tr("Attempting to join game...")); +} + +void TabReport::addComment() +{ + const int reportId = selectedReportId(); + if (reportId < 0) { + return; + } + + QString text = commentInput->text().trimmed(); + if (text.isEmpty()) { + return; + } + + commentButton->setEnabled(false); + + Command_ReportAddComment cmd; + cmd.set_report_id(reportId); + cmd.set_comment(text.toStdString()); + + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabReport::addCommentResponse); + client->sendCommand(pend); +} + +void TabReport::addCommentResponse(const Response &response) +{ + if (response.response_code() == Response::RespOk) { + commentInput->clear(); + refreshList(); + } else { + commentButton->setEnabled(true); + statusLabel->setText(tr("Failed to send comment.")); + } +} + +void TabReport::requestUserInfo(const QString &userName) +{ + if (userName.isEmpty()) { + userContextGroup->setVisible(false); + return; + } + + lastRequestedUser = userName; + userContextGroup->setVisible(true); + userContextName->setText(userName); + userContextAccountAge->setText(tr("Loading...")); + userContextReports->clear(); + userContextBans->clear(); + userContextWarns->clear(); + userContextNotes->clear(); + userContextRecentReports->clear(); + + Command_ReportUserInfo cmd; + cmd.set_user_name(userName.toStdString()); + + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabReport::userInfoResponse); + client->sendCommand(pend); +} + +void TabReport::userInfoResponse(const Response &response) +{ + if (response.response_code() != Response::RespOk) { + userContextAccountAge->setText(tr("Error loading user info.")); + return; + } + + const Response_ReportUserInfo &resp = response.GetExtension(Response_ReportUserInfo::ext); + + if (resp.has_user_name() && QString::fromStdString(resp.user_name()) != lastRequestedUser) { + return; + } + + QDateTime regTime = QDateTime::fromSecsSinceEpoch(resp.registration_time()); + qint64 daysSinceReg = regTime.daysTo(QDateTime::currentDateTime()); + userContextAccountAge->setText(tr("%1 days (since %2)").arg(daysSinceReg).arg(regTime.toString("yyyy-MM-dd"))); + + userContextReports->setText(QString::number(resp.total_reports())); + userContextBans->setText(QString::number(resp.total_bans())); + userContextWarns->setText(QString::number(resp.total_warns())); + + if (resp.has_admin_notes() && !resp.admin_notes().empty()) { + userContextNotes->setPlainText(QString::fromStdString(resp.admin_notes())); + } else { + userContextNotes->setPlainText(tr("(none)")); + } + + userContextRecentReports->clear(); + if (resp.recent_reports_size() == 0) { + userContextRecentReports->setPlainText(tr("No previous reports against this user.")); + } else { + for (int i = 0; i < resp.recent_reports_size(); ++i) { + const ServerInfo_Report &r = resp.recent_reports(i); + QDateTime dt = QDateTime::fromSecsSinceEpoch(r.report_time()); + userContextRecentReports->moveCursor(QTextCursor::End); + userContextRecentReports->insertPlainText(QString("[%1] #%2 by %3 [%4]: %5\n") + .arg(dt.toString("yyyy-MM-dd")) + .arg(r.report_id()) + .arg(QString::fromStdString(r.reporter_name())) + .arg(QString::fromStdString(r.status())) + .arg(QString::fromStdString(r.category()))); + } + } +} + +void TabReport::requestStats() +{ + statsTotalLabel->setText(tr("Loading statistics...")); + statsTrendLabel->clear(); + statsCategoriesLabel->clear(); + statsDetailText->clear(); + + Command_ReportStats cmd; + + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabReport::statsResponse); + client->sendCommand(pend); +} + +void TabReport::statsResponse(const Response &response) +{ + if (response.response_code() != Response::RespOk) { + statsTotalLabel->setText(tr("Error loading statistics.")); + return; + } + + const Response_ReportStats &resp = response.GetExtension(Response_ReportStats::ext); + + statsTotalLabel->setText(tr("Total: %1 reports (%2 open, %3 assigned, %4 resolved/dismissed)") + .arg(resp.total_reports()) + .arg(resp.total_pending()) + .arg(resp.total_assigned()) + .arg(resp.total_resolved())); + + statsTrendLabel->setText(tr("Last 24h: %1 | Last 7d: %2 | Last 30d: %3 | Avg resolution: %4h") + .arg(resp.reports_last_24h()) + .arg(resp.reports_last_7d()) + .arg(resp.reports_last_30d()) + .arg(resp.avg_resolution_hours(), 0, 'f', 1)); + + QString weekCompare = + tr("This week: %1 vs last week: %2 (%3%)") + .arg(resp.reports_this_week()) + .arg(resp.reports_last_week()) + .arg(resp.reports_last_week() > 0 ? QString::number(((resp.reports_this_week() - resp.reports_last_week()) * + 100.0 / resp.reports_last_week()), + 'f', 0) + : resp.reports_this_week() > 0 ? "new" + : "0"); + statsTrendLabel->setText(statsTrendLabel->text() + " | " + weekCompare); + + statsCategoriesLabel->setText(tr("By category:")); + statsDetailText->clear(); + + statsDetailText->moveCursor(QTextCursor::End); + statsDetailText->insertPlainText(tr("=== Top Categories ===") + "\n"); + for (int i = 0; i < resp.category_counts_size(); ++i) { + const ReportCategoryCount &cc = resp.category_counts(i); + QString cat = QString::fromStdString(cc.category()); + cat.replace('_', ' '); + cat = cat.left(1).toUpper() + cat.mid(1); + statsDetailText->moveCursor(QTextCursor::End); + statsDetailText->insertPlainText(QString(" %1: %2\n").arg(cat).arg(cc.count())); + } + + statsDetailText->moveCursor(QTextCursor::End); + statsDetailText->insertPlainText("\n" + tr("=== Most Reported Users ===") + "\n"); + for (int i = 0; i < resp.top_reported_users_size(); ++i) { + const ReportTopUser &tu = resp.top_reported_users(i); + statsDetailText->moveCursor(QTextCursor::End); + statsDetailText->insertPlainText( + QString(" %1: %2 reports\n").arg(QString::fromStdString(tu.user_name())).arg(tu.count())); + } + + statsDetailText->moveCursor(QTextCursor::End); + statsDetailText->insertPlainText("\n" + tr("=== Top Reporters ===") + "\n"); + for (int i = 0; i < resp.top_reporters_size(); ++i) { + const ReportTopUser &tu = resp.top_reporters(i); + statsDetailText->moveCursor(QTextCursor::End); + statsDetailText->insertPlainText( + QString(" %1: %2 reports filed\n").arg(QString::fromStdString(tu.user_name())).arg(tu.count())); + } +} diff --git a/cockatrice/src/interface/widgets/tabs/tab_report.h b/cockatrice/src/interface/widgets/tabs/tab_report.h new file mode 100644 index 000000000..b62a99ce2 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_report.h @@ -0,0 +1,124 @@ +#ifndef TAB_REPORT_H +#define TAB_REPORT_H + +#include "tab.h" + +#include +#include +#include + +class AbstractClient; +class QCheckBox; +class QComboBox; +class QGroupBox; +class QLabel; +class QLineEdit; +class QPushButton; +class QSplitter; +class QTableWidget; +class QTextEdit; +class QTimer; +class GameReplay; + +class TabReport : public Tab +{ + Q_OBJECT +public: + TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client); + void retranslateUi() override; + [[nodiscard]] QString getTabText() const override + { + return tr("Report Queue"); + } + +signals: + void openReplay(GameReplay *replay); + void requestJoinGame(int gameId, int roomId); + +private slots: + void refreshList(); + void reportListResponse(const Response &response); + void assignReport(); + void assignResponse(const Response &response); + void resolveReport(bool dismissed, bool promptNote); + void resolveResponse(const Response &response); + void onSelectionChanged(); + void viewReplay(); + void viewReplayResponse(const Response &response); + void joinGame(); + void addComment(); + void addCommentResponse(const Response &response); + void requestUserInfo(const QString &userName); + void userInfoResponse(const Response &response); + void reportDetailsResponse(const Response &response); + void requestStats(); + void statsResponse(const Response &response); + +private: + int selectedReportId() const; + bool selectedReportInfo(ServerInfo_Report &info) const; + void setActionsEnabled(bool enabled); + void updateActionStates(); + void applyFilters(); + void updateStats(); + void loadReportDetails(int reportId); + + AbstractClient *client; + + QLineEdit *searchEdit; + QComboBox *statusFilter; + QCheckBox *unresolvedOnlyBox; + QPushButton *refreshButton; + QLabel *statsLabel; + QTableWidget *table; + QSplitter *detailSplitter; + QGroupBox *descGroup; + QGroupBox *chatGroup; + QGroupBox *commentsGroup; + QTextEdit *descriptionEdit; + QTextEdit *chatLogEdit; + QTextEdit *commentsEdit; + QLineEdit *commentInput; + QPushButton *commentButton; + QPushButton *assignButton; + QPushButton *resolveButton; + QPushButton *resolveWithNoteButton; + QPushButton *dismissButton; + QPushButton *viewReplayButton; + QPushButton *joinGameButton; + QLabel *statusLabel; + QTimer *refreshTimer; + + QGroupBox *userContextGroup; + QLabel *userContextName; + QLabel *userContextAccountAge; + QLabel *userContextReports; + QLabel *userContextBans; + QLabel *userContextWarns; + QTextEdit *userContextNotes; + QTextEdit *userContextRecentReports; + QLabel *userContextUserLabel; + QLabel *userContextAgeLabel; + QLabel *userContextReportsLabel; + QLabel *userContextBansLabel; + QLabel *userContextWarnsLabel; + QLabel *userContextNotesLabel; + QLabel *userContextRecentReportsLabel; + QString lastRequestedUser; + QGroupBox *statsGroup; + QLabel *statsTotalLabel; + QLabel *statsTrendLabel; + QLabel *statsCategoriesLabel; + QTextEdit *statsDetailText; + + QList allReports; + QList filteredReports; + int detailsRequestedReportId = -1; + int detailsRequestSeq = 0; + int selectedReportIdBeforeRefresh = -1; + QString commentDraftBeforeRefresh; + ServerInfo_Report previousSelectedReport; + bool previousSelectedReportValid = false; +}; + +#endif // TAB_REPORT_H diff --git a/cockatrice/src/interface/widgets/tabs/tab_room.cpp b/cockatrice/src/interface/widgets/tabs/tab_room.cpp index 424742e9b..866324367 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_room.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_room.cpp @@ -1,12 +1,21 @@ #include "tab_room.h" #include "../../../client/settings/cache_settings.h" +#include "../../../client/settings/shortcuts_settings.h" +#include "../../pixel_map_generator.h" #include "../interface/widgets/dialogs/dlg_settings.h" #include "../interface/widgets/server/chat_view/chat_view.h" +#include "../interface/widgets/server/game_link.h" #include "../interface/widgets/server/game_selector.h" #include "../interface/widgets/server/user/user_list_manager.h" +#include "../interface/widgets/server/user/user_list_panel_widget.h" #include "../interface/widgets/server/user/user_list_widget.h" #include "../main.h" +#include "../utility/completer_utils.h" +#include "card/card_completer_proxy_model.h" +#include "card/card_search_model.h" +#include "card_database_display_model.h" +#include "card_database_model.h" #include "tab_account.h" #include "tab_supervisor.h" @@ -16,11 +25,15 @@ #include #include #include +#include #include +#include #include #include #include #include +#include +#include #include #include #include @@ -31,7 +44,8 @@ #include #include #include -#include +#include +#include TabRoom::TabRoom(TabSupervisor *_tabSupervisor, AbstractClient *_client, @@ -49,18 +63,24 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor, QMap tempMap; tempMap.insert(info.room_id(), gameTypes); gameSelector = new GameSelector(client, tabSupervisor, this, QMap(), tempMap, true, true); - userList = new UserListWidget(tabSupervisor, client, UserListWidget::RoomList); - connect(userList, SIGNAL(openMessageDialog(const QString &, bool)), this, - SIGNAL(openMessageDialog(const QString &, bool))); + + userListPanel = new UserListPanelWidget(tabSupervisor, client, this); + userListPanel->bind(tabSupervisor->getUserListManager()); + userList = userListPanel->getUserList(); + connect(userListPanel, &UserListPanelWidget::openMessageDialog, this, &TabRoom::openMessageDialog); + + const auto gameInviteLinkProvider = [this]() { return tabSupervisor->getGameInviteLinksForRoom(roomId); }; + userList->setGameInviteLinkProvider(gameInviteLinkProvider); chatView = new ChatView(tabSupervisor, nullptr, true, this); connect(chatView, &ChatView::showMentionPopup, this, &TabRoom::actShowMentionPopup); connect(chatView, &ChatView::messageClickedSignal, this, &TabRoom::focusTab); connect(chatView, &ChatView::openMessageDialog, this, &TabRoom::openMessageDialog); + connect(chatView, &ChatView::cockatriceLinkActivated, this, &TabRoom::cockatriceLinkActivated); connect(chatView, &ChatView::showCardInfoPopup, this, &TabRoom::showCardInfoPopup); connect(chatView, &ChatView::deleteCardInfoPopup, this, &TabRoom::deleteCardInfoPopup); connect(chatView, &ChatView::addMentionTag, this, &TabRoom::addMentionTag); - connect(&SettingsCache::instance(), &SettingsCache::chatMentionCompleterChanged, this, + connect(&SettingsCache::instance().chat(), &ChatSettings::chatMentionCompleterChanged, this, &TabRoom::actCompleterChanged); sayLabel = new QLabel; sayEdit = new LineEditCompleter; @@ -79,7 +99,7 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor, connect(aOpenChatSettings, &QAction::triggered, this, &TabRoom::actOpenChatSettings); auto *chatSettingsButton = new QToolButton; - chatSettingsButton->setIcon(QPixmap("theme:icons/settings")); + chatSettingsButton->setIcon(themePixmap(QStringLiteral("icons/settings"))); chatSettingsButton->setMenu(chatSettingsMenu); chatSettingsButton->setPopupMode(QToolButton::InstantPopup); @@ -101,7 +121,7 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor, auto *hbox = new QHBoxLayout; hbox->addWidget(splitter, 3); - hbox->addWidget(userList, 1); + hbox->addWidget(userListPanel, 1); aLeaveRoom = new QAction(this); connect(aLeaveRoom, &QAction::triggered, this, &TabRoom::closeRequest); @@ -112,23 +132,35 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor, const int userListSize = info.user_list_size(); for (int i = 0; i < userListSize; ++i) { - userList->processUserInfo(info.user_list(i), true); autocompleteUserList.append("@" + QString::fromStdString(info.user_list(i).name())); } - userList->sortItems(); const int gameListSize = info.game_list_size(); for (int i = 0; i < gameListSize; ++i) { gameSelector->processGameInfo(info.game_list(i)); } - completer = new QCompleter(autocompleteUserList, sayEdit); - completer->setCaseSensitivity(Qt::CaseInsensitive); - completer->setMaxVisibleItems(5); - completer->setFilterMode(Qt::MatchStartsWith); + mentionModel = new QStringListModel(autocompleteUserList, sayEdit); + mentionCompleter = createMentionCompleter(mentionModel, sayEdit); + sayEdit->addCompleter(mentionCompleter, CompleterTrigger::Mention); + + auto *cardDatabaseModel = new CardDatabaseModel(CardDatabaseManager::getInstance(), false, sayEdit); + auto *displayModel = new CardDatabaseDisplayModel(sayEdit); + displayModel->setSourceModel(cardDatabaseModel); + const CardCompleterSetup cardSetup = createCardCompleter(displayModel, sayEdit); + sayEdit->addCompleter(cardSetup.completer, CompleterTrigger::Card); + + connect(sayEdit, &LineEditCompleter::cardPartialChanged, this, [this, cardSetup](const QString &text) { + cardSetup.searchModel->updateSearchResults(text); + cardSetup.proxyModel->setFilterRegularExpression( + QRegularExpression(QRegularExpression::escape(text), QRegularExpression::CaseInsensitiveOption)); + if (sayEdit->hasFocus()) { + cardSetup.completer->complete(); + } + }); - sayEdit->setCompleter(completer); actCompleterChanged(); + connect(&SettingsCache::instance().shortcuts(), &ShortcutsSettings::shortCutChanged, this, &TabRoom::refreshShortcuts); refreshShortcuts(); @@ -144,7 +176,7 @@ void TabRoom::retranslateUi() { gameSelector->retranslateUi(); chatView->retranslateUi(); - userList->retranslateUi(); + userListPanel->retranslateUi(); sayLabel->setText(tr("&Say:")); chatGroupBox->setTitle(tr("Chat")); roomMenu->setTitle(tr("&Room")); @@ -198,8 +230,8 @@ void TabRoom::sendMessage() { if (sayEdit->text().isEmpty()) { return; - } else if (completer->popup()->isVisible()) { - completer->popup()->hide(); + } else if (sayEdit->hasVisibleCompleterPopup()) { + sayEdit->hideCompleterPopups(); return; } else { Command_RoomSay cmd; @@ -227,14 +259,14 @@ void TabRoom::actClearChat() void TabRoom::actOpenChatSettings() { DlgSettings settings(this); - settings.setTab(4); + settings.setTab(DlgSettings::MessagesPage); settings.exec(); } void TabRoom::actCompleterChanged() { - SettingsCache::instance().getChatMentionCompleter() ? completer->setCompletionRole(2) - : completer->setCompletionRole(1); + SettingsCache::instance().chat().getChatMentionCompleter() ? mentionCompleter->setCompletionRole(2) + : mentionCompleter->setCompletionRole(1); } void TabRoom::processRoomEvent(const RoomEvent &event) @@ -265,23 +297,23 @@ void TabRoom::processListGamesEvent(const Event_ListGames &event) for (int i = 0; i < gameListSize; ++i) { gameSelector->processGameInfo(event.game_list(i)); } + emit gameListUpdated(); } void TabRoom::processJoinRoomEvent(const Event_JoinRoom &event) { - userList->processUserInfo(event.user_info(), true); - userList->sortItems(); - if (!autocompleteUserList.contains("@" + QString::fromStdString(event.user_info().name()))) { - autocompleteUserList << "@" + QString::fromStdString(event.user_info().name()); - sayEdit->setCompletionList(autocompleteUserList); + QString mention = "@" + QString::fromStdString(event.user_info().name()); + if (!autocompleteUserList.contains(mention)) { + autocompleteUserList << mention; + mentionModel->setStringList(autocompleteUserList); } } void TabRoom::processLeaveRoomEvent(const Event_LeaveRoom &event) { - userList->deleteUser(QString::fromStdString(event.name())); - autocompleteUserList.removeOne("@" + QString::fromStdString(event.name())); - sayEdit->setCompletionList(autocompleteUserList); + QString mention = "@" + QString::fromStdString(event.name()); + autocompleteUserList.removeOne(mention); + mentionModel->setStringList(autocompleteUserList); } void TabRoom::processRoomSayEvent(const Event_RoomSay &event) @@ -297,13 +329,13 @@ void TabRoom::processRoomSayEvent(const Event_RoomSay &event) ServerInfo_User userInfo = {}; if (twi) { userInfo = twi->getUserInfo(); - if (SettingsCache::instance().getIgnoreUnregisteredUsers() && + if (SettingsCache::instance().chat().getIgnoreUnregisteredUsers() && !UserLevelFlags(userInfo.user_level()).testFlag(ServerInfo_User::IsRegistered)) { return; } } - if (event.message_type() == Event_RoomSay::ChatHistory && !SettingsCache::instance().getRoomHistory()) { + if (event.message_type() == Event_RoomSay::ChatHistory && !SettingsCache::instance().chat().getRoomHistory()) { return; } diff --git a/cockatrice/src/interface/widgets/tabs/tab_room.h b/cockatrice/src/interface/widgets/tabs/tab_room.h index eeb5a9e14..7d01d5cf6 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_room.h +++ b/cockatrice/src/interface/widgets/tabs/tab_room.h @@ -14,6 +14,7 @@ #include #include #include +#include class UserListProxy; class UserListManager; @@ -26,6 +27,7 @@ class Message; } // namespace google class AbstractClient; class UserListWidget; +class UserListPanelWidget; class QLabel; class ChatView; class QPushButton; @@ -56,11 +58,13 @@ private: QMap gameTypes; GameSelector *gameSelector; + UserListPanelWidget *userListPanel; UserListWidget *userList; const UserListProxy *userListProxy; ChatView *chatView; QLabel *sayLabel; LineEditCompleter *sayEdit; + QStringListModel *mentionModel; QGroupBox *chatGroupBox; QMenu *roomMenu; @@ -70,12 +74,13 @@ private: [[nodiscard]] QString sanitizeHtml(QString dirty) const; QStringList autocompleteUserList; - QCompleter *completer; + QCompleter *mentionCompleter; signals: void roomClosing(TabRoom *tab); void openMessageDialog(const QString &userName, bool focus); void maximizeClient(); void notIdle(); + void gameListUpdated(); private slots: void sendMessage(); void sayFinished(const Response &response); @@ -109,6 +114,10 @@ public: { return roomId; } + [[nodiscard]] QString getRoomName() const + { + return roomName; + } [[nodiscard]] const QMap &getGameTypes() const { return gameTypes; @@ -125,6 +134,10 @@ public: { return ownUser; } + [[nodiscard]] GameSelector *getGameSelector() const + { + return gameSelector; + } PendingCommand *prepareRoomCommand(const ::google::protobuf::Message &cmd); void sendRoomCommand(PendingCommand *pend); diff --git a/cockatrice/src/interface/widgets/tabs/tab_server.cpp b/cockatrice/src/interface/widgets/tabs/tab_server.cpp index 2fce5c1fa..fca32094c 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_server.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_server.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include @@ -185,52 +186,110 @@ void TabServer::processServerMessageEvent(const Event_ServerMessage &event) void TabServer::joinRoom(int id, bool setCurrent) { TabRoom *room = tabSupervisor->getRoomTabs().value(id); - if (!room) { - Command_JoinRoom cmd; - cmd.set_room_id(id); - - PendingCommand *pend = client->prepareSessionCommand(cmd); - pend->setExtraData(setCurrent); - connect(pend, &PendingCommand::finished, this, &TabServer::joinRoomFinished); - - client->sendCommand(pend); - + if (room) { + if (setCurrent) { + tabSupervisor->setCurrentWidget((QWidget *)room); + } return; } - if (setCurrent) { - tabSupervisor->setCurrentWidget((QWidget *)room); + auto pendingIt = pendingRoomJoins.find(id); + if (pendingIt != pendingRoomJoins.end()) { + // A join for this room is already in flight: the room tab opens when its response + // arrives. Fold the new request into the pending one so that, for example, clicking + // a room the selector is auto-joining does not send a second Command_JoinRoom - the + // server would reject that duplicate with RespContextError. + if (setCurrent) { + pendingIt.value() = true; + } + return; } + + pendingRoomJoins.insert(id, setCurrent); + + Command_JoinRoom cmd; + cmd.set_room_id(id); + + PendingCommand *pend = client->prepareSessionCommand(cmd); + pend->setExtraData(setCurrent); + connect( + pend, &PendingCommand::finished, this, + [this, id](const Response &r, const CommandContainer &c, const QVariant &v) { joinRoomFinished(r, c, v, id); }); + + client->sendCommand(pend); } void TabServer::joinRoomFinished(const Response &r, const CommandContainer & /*commandContainer*/, - const QVariant &extraData) + const QVariant &extraData, + int roomId) { + const bool setCurrent = pendingRoomJoins.value(roomId, extraData.toBool()); + pendingRoomJoins.remove(roomId); + const bool healedJoin = healedRoomJoins.contains(roomId); + healedRoomJoins.remove(roomId); + switch (r.response_code()) { case Response::RespOk: break; case Response::RespNameNotFound: - QMessageBox::critical(this, tr("Error"), - tr("Failed to join the server room: it doesn't exist on the server.")); + if (setCurrent) { + QMessageBox::critical(this, tr("Error"), + tr("Failed to join the server room: it doesn't exist on the server.")); + } + emit roomJoinFailed(roomId); return; case Response::RespContextError: - QMessageBox::critical( - this, tr("Error"), - tr("The server thinks you are in the server room but your client is unable to display it. " - "Try restarting your client.")); + if (healedJoin) { + // The rejoin below was already answered and the server still rejects the join, so + // the stale-membership heal cannot help: surface the error. The guard was already + // released above so a later user-initiated join may try a fresh heal. + if (setCurrent) { + QMessageBox::critical( + this, tr("Error"), + tr("The server thinks you are in the server room but your client is unable to display it. " + "Try restarting your client.")); + } + emit roomJoinFailed(roomId); + return; + } + // The server already had us registered in the room even though no tab was open, + // usually because two join attempts for the same room overlapped. Leaving and + // rejoining makes the server reply with a fresh RespOk so the tab is displayed + // without requiring a client restart. The guard above covers exactly the rejoin that + // leaveAndRejoinRoom triggers, so a server that keeps replying with RespContextError + // gets one heal attempt per join instead of an endless recursion. + healedRoomJoins.insert(roomId); + leaveAndRejoinRoom(roomId, setCurrent); return; case Response::RespUserLevelTooLow: - QMessageBox::critical(this, tr("Error"), - tr("You do not have the required permission to join this server room.")); + if (setCurrent) { + QMessageBox::critical(this, tr("Error"), + tr("You do not have the required permission to join this server room.")); + } + emit roomJoinFailed(roomId); return; default: - QMessageBox::critical( - this, tr("Error"), - tr("Failed to join the server room due to an unknown error: %1.").arg(r.response_code())); + if (setCurrent) { + QMessageBox::critical( + this, tr("Error"), + tr("Failed to join the server room due to an unknown error: %1.").arg(r.response_code())); + } + emit roomJoinFailed(roomId); return; } const Response_JoinRoom &resp = r.GetExtension(Response_JoinRoom::ext); - emit roomJoined(resp.room_info(), extraData.toBool()); + emit roomJoined(resp.room_info(), setCurrent); +} + +void TabServer::leaveAndRejoinRoom(int roomId, bool setCurrent) +{ + // Clear the stale room membership server-side. The leave is sent before the rejoin below, + // so the server no longer considers us a member by the time the join arrives. The leave + // response is intentionally not awaited: commands are processed in send order on the + // connection, and a failed leave (RespNotInRoom) only means the membership was already gone. + client->sendCommand(client->prepareRoomCommand(Command_LeaveRoom(), roomId)); + + joinRoom(roomId, setCurrent); } diff --git a/cockatrice/src/interface/widgets/tabs/tab_server.h b/cockatrice/src/interface/widgets/tabs/tab_server.h index 137823592..121ff814d 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_server.h +++ b/cockatrice/src/interface/widgets/tabs/tab_server.h @@ -10,6 +10,8 @@ #include "tab.h" #include +#include +#include #include #include @@ -49,19 +51,30 @@ class TabServer : public Tab Q_OBJECT signals: void roomJoined(const ServerInfo_Room &info, bool setCurrent); + void roomJoinFailed(int roomId); private slots: void processServerMessageEvent(const Event_ServerMessage &event); - void joinRoom(int id, bool setCurrent); - void joinRoomFinished(const Response &resp, const CommandContainer &commandContainer, const QVariant &extraData); + void joinRoomFinished(const Response &resp, + const CommandContainer &commandContainer, + const QVariant &extraData, + int roomId); private: + void leaveAndRejoinRoom(int roomId, bool setCurrent); + AbstractClient *client; RoomSelector *roomSelector; QTextBrowser *serverInfoBox; bool shouldEmitUpdate = false; + /** Room ids with a join command in flight, mapped to whether the tab should be focused once it opens. */ + QHash pendingRoomJoins; + /** Room ids for which a stale-membership heal (leave + rejoin) is currently in flight. Released as soon as the + * rejoin has been answered, so a heal is attempted at most once per join. */ + QSet healedRoomJoins; public: TabServer(TabSupervisor *_tabSupervisor, AbstractClient *_client); + void joinRoom(int id, bool setCurrent); void retranslateUi() override; [[nodiscard]] QString getTabText() const override { diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index e7075f78f..ccb687ff3 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -1,7 +1,10 @@ #include "tab_supervisor.h" #include "../../../client/settings/cache_settings.h" +#include "../../../client/settings/shortcuts_settings.h" +#include "../../intents/intent_join_server_game.h" #include "../interface/pixel_map_generator.h" +#include "../interface/widgets/server/game_link.h" #include "../interface/widgets/server/user/user_list_manager.h" #include "../interface/widgets/server/user/user_list_widget.h" #include "../main.h" @@ -9,13 +12,17 @@ #include "api/edhrec/tab_edhrec_main.h" #include "tab_account.h" #include "tab_admin.h" +#include "tab_card_art_rules.h" #include "tab_deck_editor.h" #include "tab_deck_storage.h" +#include "tab_developer.h" #include "tab_game.h" #include "tab_home.h" #include "tab_logs.h" #include "tab_message.h" +#include "tab_moderation.h" #include "tab_replays.h" +#include "tab_report.h" #include "tab_room.h" #include "tab_server.h" #include "tab_visual_database_display.h" @@ -28,6 +35,7 @@ #include #include #include +#include #include #include #include @@ -36,6 +44,11 @@ #include #include #include +#include +#include +#include +#include +#include QRect MacOSTabFixStyle::subElementRect(SubElement element, const QStyleOption *option, const QWidget *widget) const { @@ -44,7 +57,7 @@ QRect MacOSTabFixStyle::subElementRect(SubElement element, const QStyleOption *o } // Skip over QProxyStyle handling subElementRect, - // This fixes an issue with Qt 5.10 on OSX where the labels for tabs with a button and an icon + // This fixes an issue on OSX where the labels for tabs with a button and an icon // get cut-off too early return QCommonStyle::subElementRect(element, option, widget); } @@ -64,11 +77,7 @@ QSize CloseButton::sizeHint() const return {width, height}; } -#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) void CloseButton::enterEvent(QEnterEvent *event) -#else -void CloseButton::enterEvent(QEvent *event) -#endif { update(); QAbstractButton::enterEvent(event); @@ -108,16 +117,17 @@ void CloseButton::paintEvent(QPaintEvent * /*event*/) } TabSupervisor::TabSupervisor(AbstractClient *_client, QMenu *tabsMenu, QWidget *parent) - : QTabWidget(parent), userInfo(nullptr), client(_client), tabsMenu(tabsMenu), tabVisualDeckStorage(nullptr), - tabServer(nullptr), tabAccount(nullptr), tabDeckStorage(nullptr), tabReplays(nullptr), tabAdmin(nullptr), - tabLog(nullptr), isLocalGame(false) + : QTabWidget(parent), userInfo(nullptr), client(_client), tabsMenu(tabsMenu), tabHome(nullptr), + tabVisualDeckStorage(nullptr), tabServer(nullptr), tabAccount(nullptr), tabDeckStorage(nullptr), + tabReplays(nullptr), tabAdmin(nullptr), tabCardArtRules(nullptr), tabLog(nullptr), tabReport(nullptr), + tabModeration(nullptr), tabDeveloper(nullptr), isLocalGame(false) { setElideMode(Qt::ElideRight); setMovable(true); setIconSize(QSize(15, 15)); #if defined(Q_OS_MAC) - // This is necessary to fix an issue on macOS with qt5.10, + // This is necessary to fix an issue on macOS, // where tabs with icons and buttons get drawn incorrectly tabBar()->setStyle(new MacOSTabFixStyle); #endif @@ -133,6 +143,7 @@ TabSupervisor::TabSupervisor(AbstractClient *_client, QMenu *tabsMenu, QWidget * connect(client, &AbstractClient::gameJoinedEventReceived, this, &TabSupervisor::gameJoined); connect(client, &AbstractClient::userMessageEventReceived, this, &TabSupervisor::processUserMessageEvent); connect(client, &AbstractClient::maxPingTime, this, &TabSupervisor::updatePingTime); + connect(client, &AbstractClient::pingStatsUpdated, this, &TabSupervisor::updateLatencyTooltip); connect(client, &AbstractClient::notifyUserEventReceived, this, &TabSupervisor::processNotifyUserEvent); // create tabs menu actions @@ -179,10 +190,26 @@ TabSupervisor::TabSupervisor(AbstractClient *_client, QMenu *tabsMenu, QWidget * aTabAdmin->setCheckable(true); connect(aTabAdmin, &QAction::triggered, this, &TabSupervisor::actTabAdmin); + aTabCardArtRules = new QAction(this); + aTabCardArtRules->setCheckable(true); + connect(aTabCardArtRules, &QAction::triggered, this, &TabSupervisor::actTabCardArtRules); + aTabLog = new QAction(this); aTabLog->setCheckable(true); connect(aTabLog, &QAction::triggered, this, &TabSupervisor::actTabLog); + aTabReport = new QAction(this); + aTabReport->setCheckable(true); + connect(aTabReport, &QAction::triggered, this, &TabSupervisor::actTabReport); + + aTabModeration = new QAction(this); + aTabModeration->setCheckable(true); + connect(aTabModeration, &QAction::triggered, this, &TabSupervisor::actTabModeration); + + aTabDeveloper = new QAction(this); + aTabDeveloper->setCheckable(true); + connect(aTabDeveloper, &QAction::triggered, this, &TabSupervisor::actTabDeveloper); + connect(&SettingsCache::instance().shortcuts(), &ShortcutsSettings::shortCutChanged, this, &TabSupervisor::refreshShortcuts); refreshShortcuts(); @@ -222,6 +249,10 @@ void TabSupervisor::retranslateUi() aTabReplays->setText(tr("Game Replays")); aTabAdmin->setText(tr("Administration")); aTabLog->setText(tr("Logs")); + aTabReport->setText(tr("Report Queue")); + aTabModeration->setText(tr("Moderation")); + aTabCardArtRules->setText(tr("Card Art Rules")); + aTabDeveloper->setText(tr("Developer")); // tabs QList tabs; @@ -231,6 +262,10 @@ void TabSupervisor::retranslateUi() tabs.append(tabAdmin); tabs.append(tabAccount); tabs.append(tabLog); + tabs.append(tabReport); + tabs.append(tabModeration); + tabs.append(tabCardArtRules); + tabs.append(tabDeveloper); QMapIterator roomIterator(roomTabs); while (roomIterator.hasNext()) { tabs.append(roomIterator.next().value()); @@ -277,6 +312,8 @@ void TabSupervisor::refreshShortcuts() aTabReplays->setShortcuts(shortcuts.getShortcut("Tabs/aTabReplays")); aTabAdmin->setShortcuts(shortcuts.getShortcut("Tabs/aTabAdmin")); aTabLog->setShortcuts(shortcuts.getShortcut("Tabs/aTabLog")); + aTabReport->setShortcuts(shortcuts.getShortcut("Tabs/aTabReport")); + aTabModeration->setShortcuts(shortcuts.getShortcut("Tabs/aTabModeration")); } void TabSupervisor::closeEvent(QCloseEvent *event) @@ -329,22 +366,63 @@ static void checkAndTrigger(QAction *checkableAction, bool checked) } /** - * Opens the always-available tabs, depending on settings. + * Opens the always-available tabs, depending on settings, and lands on the configured startup tab. + * + * The startup destination is a request: tabs that were not open before (deck editors, storage + * tabs disabled in the Tabs menu) are opened as part of the startup flow. Destinations that + * require a server connection (Server, Server Room) are handled asynchronously by MainWindow + * through the intent system, since this class has no RemoteClient. */ void TabSupervisor::initStartupTabs() { openTabHome(); setCurrentWidget(tabHome); - if (SettingsCache::instance().getTabVisualDeckStorageOpen()) { + if (SettingsCache::instance().tabs().getTabVisualDeckStorageOpen()) { openTabVisualDeckStorage(); } - if (SettingsCache::instance().getTabDeckStorageOpen()) { + if (SettingsCache::instance().tabs().getTabDeckStorageOpen()) { openTabDeckStorage(); } - if (SettingsCache::instance().getTabReplaysOpen()) { + if (SettingsCache::instance().tabs().getTabReplaysOpen()) { openTabReplays(); } + + switch (SettingsCache::instance().tabs().getStartupTabIndex()) { + case StartupTab::StartupTabVisualDeckStorage: + if (!tabVisualDeckStorage) { + openTabVisualDeckStorage(); + } + setCurrentWidget(tabVisualDeckStorage); + break; + case StartupTab::StartupTabDeckStorage: + if (!tabDeckStorage) { + openTabDeckStorage(); + } + setCurrentWidget(tabDeckStorage); + break; + case StartupTab::StartupTabReplays: + if (!tabReplays) { + openTabReplays(); + } + setCurrentWidget(tabReplays); + break; + case StartupTab::StartupTabDeckEditor: + addDeckEditorTab(LoadedDeck()); + break; + case StartupTab::StartupTabVisualDeckEditor: + addVisualDeckEditorTab(LoadedDeck()); + break; + case StartupTab::StartupTabServer: + case StartupTab::StartupTabServerRoom: + // Handled asynchronously by MainWindow::applyStartupDestination(); Home stays selected + // until the server connection succeeds. + break; + case StartupTab::StartupTabHome: + default: + setCurrentWidget(tabHome); + break; + } } /** @@ -359,6 +437,7 @@ int TabSupervisor::myAddTab(Tab *tab, QAction *manager) { connect(tab, &TabGame::userEvent, this, &TabSupervisor::tabUserEvent); connect(tab, &TabGame::tabTextChanged, this, &TabSupervisor::updateTabText); + connect(tab, &TabGame::cockatriceLinkActivated, this, &TabSupervisor::cockatriceLinkActivated); QString tabText = tab->getTabText(); int idx = addTab(tab, sanitizeTabName(tabText)); @@ -422,10 +501,10 @@ void TabSupervisor::start(const ServerInfo_User &_userInfo) tabsMenu->addAction(aTabServer); tabsMenu->addAction(aTabAccount); - if (SettingsCache::instance().getTabServerOpen()) { + if (SettingsCache::instance().tabs().getTabServerOpen()) { openTabServer(); } - if (SettingsCache::instance().getTabAccountOpen()) { + if (SettingsCache::instance().tabs().getTabAccountOpen()) { openTabAccount(); } @@ -435,13 +514,38 @@ void TabSupervisor::start(const ServerInfo_User &_userInfo) tabsMenu->addSeparator(); tabsMenu->addAction(aTabAdmin); tabsMenu->addAction(aTabLog); + tabsMenu->addAction(aTabCardArtRules); + tabsMenu->addAction(aTabReport); + tabsMenu->addAction(aTabModeration); - if (SettingsCache::instance().getTabAdminOpen()) { + if (SettingsCache::instance().tabs().getTabAdminOpen()) { openTabAdmin(); } - if (SettingsCache::instance().getTabLogOpen()) { + if (SettingsCache::instance().tabs().getTabLogOpen()) { openTabLog(); } + if (SettingsCache::instance().tabs().getTabReportOpen()) { + openTabReport(); + } + if (SettingsCache::instance().tabs().getTabModerationOpen()) { + openTabModeration(); + } + if (SettingsCache::instance().tabs().getTabCardArtRulesOpen()) { + openTabCardArtRules(); + } + } + + if (userInfo->user_level() & ServerInfo_User::IsDeveloper) { + tabsMenu->addSeparator(); + tabsMenu->addAction(aTabDeveloper); + // Developers without moderation rights get log access through their + // own role. Moderators already have the Logs entry from above. + if (!(userInfo->user_level() & ServerInfo_User::IsModerator)) { + tabsMenu->addAction(aTabLog); + if (SettingsCache::instance().tabs().getTabLogOpen()) { + openTabLog(); + } + } } retranslateUi(); @@ -454,6 +558,9 @@ void TabSupervisor::startLocal(const QList &_clients) tabAccount = nullptr; tabAdmin = nullptr; tabLog = nullptr; + tabReport = nullptr; + tabModeration = nullptr; + tabDeveloper = nullptr; isLocalGame = true; userInfo = new ServerInfo_User; localClients = _clients; @@ -495,6 +602,18 @@ void TabSupervisor::stop() if (tabLog) { tabLog->close(); } + if (tabReport) { + tabReport->close(); + } + if (tabModeration) { + tabModeration->close(); + } + if (tabCardArtRules) { + tabCardArtRules->close(); + } + if (tabDeveloper) { + tabDeveloper->close(); + } } QList tabsToDelete; @@ -540,7 +659,7 @@ void TabSupervisor::openTabHome() void TabSupervisor::actTabVisualDeckStorage(bool checked) { - SettingsCache::instance().setTabVisualDeckStorageOpen(checked); + SettingsCache::instance().tabs().setTabVisualDeckStorageOpen(checked); if (checked && !tabVisualDeckStorage) { openTabVisualDeckStorage(); setCurrentWidget(tabVisualDeckStorage); @@ -564,7 +683,7 @@ void TabSupervisor::openTabVisualDeckStorage() void TabSupervisor::actTabServer(bool checked) { - SettingsCache::instance().setTabServerOpen(checked); + SettingsCache::instance().tabs().setTabServerOpen(checked); if (checked && !tabServer) { openTabServer(); setCurrentWidget(tabServer); @@ -575,6 +694,11 @@ void TabSupervisor::actTabServer(bool checked) void TabSupervisor::openTabServer() { + SettingsCache::instance().tabs().setTabServerOpen(true); + if (tabServer) { + return; + } + tabServer = new TabServer(this, client); connect(tabServer, &TabServer::roomJoined, this, &TabSupervisor::addRoomTab); myAddTab(tabServer, aTabServer); @@ -587,7 +711,7 @@ void TabSupervisor::openTabServer() void TabSupervisor::actTabAccount(bool checked) { - SettingsCache::instance().setTabAccountOpen(checked); + SettingsCache::instance().tabs().setTabAccountOpen(checked); if (checked && !tabAccount) { openTabAccount(); setCurrentWidget(tabAccount); @@ -612,7 +736,7 @@ void TabSupervisor::openTabAccount() void TabSupervisor::actTabDeckStorage(bool checked) { - SettingsCache::instance().setTabDeckStorageOpen(checked); + SettingsCache::instance().tabs().setTabDeckStorageOpen(checked); if (checked && !tabDeckStorage) { openTabDeckStorage(); setCurrentWidget(tabDeckStorage); @@ -635,7 +759,7 @@ void TabSupervisor::openTabDeckStorage() void TabSupervisor::actTabReplays(bool checked) { - SettingsCache::instance().setTabReplaysOpen(checked); + SettingsCache::instance().tabs().setTabReplaysOpen(checked); if (checked && !tabReplays) { openTabReplays(); setCurrentWidget(tabReplays); @@ -660,7 +784,7 @@ void TabSupervisor::openTabReplays() void TabSupervisor::actTabAdmin(bool checked) { - SettingsCache::instance().setTabAdminOpen(checked); + SettingsCache::instance().tabs().setTabAdminOpen(checked); if (checked && !tabAdmin) { openTabAdmin(); setCurrentWidget(tabAdmin); @@ -681,9 +805,34 @@ void TabSupervisor::openTabAdmin() aTabAdmin->setChecked(true); } +void TabSupervisor::actTabCardArtRules(bool checked) +{ + SettingsCache::instance().tabs().setTabCardArtRulesOpen(checked); + if (checked && !tabCardArtRules) { + openTabCardArtRules(); + setCurrentWidget(tabCardArtRules); + } else if (!checked && tabCardArtRules) { + tabCardArtRules->closeRequest(); + } +} + +void TabSupervisor::openTabCardArtRules() +{ + tabCardArtRules = new TabCardArtRules(this, client); + + myAddTab(tabCardArtRules, aTabCardArtRules); + + connect(tabCardArtRules, &QObject::destroyed, this, [this] { + tabCardArtRules = nullptr; + aTabCardArtRules->setChecked(false); + }); + + aTabCardArtRules->setChecked(true); +} + void TabSupervisor::actTabLog(bool checked) { - SettingsCache::instance().setTabLogOpen(checked); + SettingsCache::instance().tabs().setTabLogOpen(checked); if (checked && !tabLog) { openTabLog(); setCurrentWidget(tabLog); @@ -694,7 +843,13 @@ void TabSupervisor::actTabLog(bool checked) void TabSupervisor::openTabLog() { - tabLog = new TabLog(this, client); + // Developers query logs through the developer command family, so tell the + // tab which family to use. The moderator family is strictly stronger, so a + // moderator who also holds the developer bit keeps the moderator path — the + // developer bit only selects the (narrowed) developer family on its own. + const bool useDeveloperCommands = (userInfo->user_level() & ServerInfo_User::IsDeveloper) && + !(userInfo->user_level() & ServerInfo_User::IsModerator); + tabLog = new TabLog(this, client, useDeveloperCommands); myAddTab(tabLog, aTabLog); connect(tabLog, &QObject::destroyed, this, [this] { tabLog = nullptr; @@ -703,6 +858,80 @@ void TabSupervisor::openTabLog() aTabLog->setChecked(true); } +void TabSupervisor::actTabReport(bool checked) +{ + SettingsCache::instance().tabs().setTabReportOpen(checked); + if (checked && !tabReport) { + openTabReport(); + setCurrentWidget(tabReport); + } else if (!checked && tabReport) { + tabReport->closeRequest(); + } +} + +void TabSupervisor::openTabReport() +{ + tabReport = new TabReport(this, client); + myAddTab(tabReport, aTabReport); + connect(tabReport, &TabReport::openReplay, this, &TabSupervisor::openReplay); + connect(tabReport, &TabReport::requestJoinGame, this, &TabSupervisor::joinReportGame); + connect(tabReport, &QObject::destroyed, this, [this] { + tabReport = nullptr; + aTabReport->setChecked(false); + }); + aTabReport->setChecked(true); +} + +void TabSupervisor::actTabModeration(bool checked) +{ + SettingsCache::instance().tabs().setTabModerationOpen(checked); + if (checked && !tabModeration) { + openTabModeration(); + setCurrentWidget(tabModeration); + } else if (!checked && tabModeration) { + tabModeration->closeRequest(); + } +} + +void TabSupervisor::openTabModeration(const QString &userName) +{ + if (tabModeration) { + setCurrentWidget(tabModeration); + if (!userName.isEmpty()) { + tabModeration->investigate(userName); + } + return; + } + tabModeration = new TabModeration(this, client, userName); + myAddTab(tabModeration, aTabModeration); + connect(tabModeration, &QObject::destroyed, this, [this] { + tabModeration = nullptr; + aTabModeration->setChecked(false); + }); + aTabModeration->setChecked(true); +} + +void TabSupervisor::actTabDeveloper(bool checked) +{ + if (checked && !tabDeveloper) { + openTabDeveloper(); + setCurrentWidget(tabDeveloper); + } else if (!checked && tabDeveloper) { + tabDeveloper->closeRequest(); + } +} + +void TabSupervisor::openTabDeveloper() +{ + tabDeveloper = new TabDeveloper(this, client); + myAddTab(tabDeveloper, aTabDeveloper); + connect(tabDeveloper, &QObject::destroyed, this, [this] { + tabDeveloper = nullptr; + aTabDeveloper->setChecked(false); + }); + aTabDeveloper->setChecked(true); +} + void TabSupervisor::updatePingTime(int value, int max) { if (!tabServer) { @@ -715,6 +944,23 @@ void TabSupervisor::updatePingTime(int value, int max) setTabIcon(indexOf(tabServer), QIcon(PingPixmapGenerator::generatePixmap(15, value, max))); } +void TabSupervisor::updateLatencyTooltip(const LatencyTracker::Stats &stats) +{ + if (!tabServer) { + return; + } + + if (stats.sampleCount == 0) { + setTabToolTip(indexOf(tabServer), QString()); + return; + } + + setTabToolTip(indexOf(tabServer), + tr("Connection quality over the last %n sample(s):", "", stats.sampleCount) + "\n" + + tr("Last: %1 ms").arg(stats.lastMs) + "\n" + tr("Median: %1 ms").arg(stats.medianMs) + "\n" + + tr("95th percentile: %1 ms").arg(stats.p95Ms) + "\n" + tr("Maximum: %1 ms").arg(stats.maxMs)); +} + void TabSupervisor::gameJoined(const Event_GameJoined &event) { QMap roomGameTypes; @@ -773,6 +1019,7 @@ void TabSupervisor::addRoomTab(const ServerInfo_Room &info, bool setCurrent) connect(tab, &TabRoom::maximizeClient, this, &TabSupervisor::maximizeMainWindow); connect(tab, &TabRoom::roomClosing, this, &TabSupervisor::roomLeft); connect(tab, &TabRoom::openMessageDialog, this, &TabSupervisor::addMessageTab); + connect(tab, &TabRoom::cockatriceLinkActivated, this, &TabSupervisor::cockatriceLinkActivated); myAddTab(tab); roomTabs.insert(info.room_id(), tab); if (setCurrent) { @@ -819,6 +1066,30 @@ void TabSupervisor::replayLeft(TabGame *tab) replayTabs.removeOne(tab); } +void TabSupervisor::joinReportGame(const int gameId, const int roomId) +{ + auto *remoteClient = qobject_cast(client); + if (!remoteClient) { + actShowPopup(tr("Report joins are only available on a remote server.")); + return; + } + + auto ctx = std::make_unique(); + ctx->roomContext.serverContext.hostname = remoteClient->peerName(); + ctx->roomContext.serverContext.port = QString::number(remoteClient->peerPort()); + ctx->roomContext.roomId = roomId; + ctx->gameId = gameId; + ctx->asSpectator = true; + + auto *joinGameIntent = new IntentJoinServerGame(this, remoteClient, std::move(ctx)); + joinGameIntent->setParent(this); + connect(joinGameIntent, &Intent::failed, this, [gameId](const QString &reason) { + actShowPopup(tr("Could not join game %1.\n%2").arg(gameId).arg(reason)); + }); + + joinGameIntent->execute(); +} + TabMessage *TabSupervisor::addMessageTab(const QString &receiverName, bool focus) { if (receiverName == QString::fromStdString(userInfo->name())) { @@ -826,8 +1097,10 @@ TabMessage *TabSupervisor::addMessageTab(const QString &receiverName, bool focus } ServerInfo_User otherUser; + bool userOnline = false; if (auto user = userListManager->getOnlineUser(receiverName)) { otherUser = ServerInfo_User(*user); + userOnline = true; } else { otherUser.set_name(receiverName.toStdString()); } @@ -841,9 +1114,17 @@ TabMessage *TabSupervisor::addMessageTab(const QString &receiverName, bool focus return tab; } - tab = new TabMessage(this, client, *userInfo, otherUser); + if (focus && userListManager->isUserIgnored(receiverName)) { + QMessageBox::information( + this, tr("Ignored user"), + tr("You have ignored %1. Remove them from your ignore list to open a private chat.").arg(receiverName)); + return nullptr; + } + + tab = new TabMessage(this, client, *userInfo, otherUser, userOnline); connect(tab, &TabMessage::talkClosing, this, &TabSupervisor::talkLeft); connect(tab, &TabMessage::maximizeClient, this, &TabSupervisor::maximizeMainWindow); + connect(tab, &TabMessage::cockatriceLinkActivated, this, &TabSupervisor::cockatriceLinkActivated); myAddTab(tab); messageTabs.insert(receiverName, tab); if (focus) { @@ -867,6 +1148,54 @@ void TabSupervisor::talkLeft(TabMessage *tab) removeTab(indexOf(tab)); } +QList TabSupervisor::getGameInviteLinksForRoom(int roomId) const +{ + QList options; + if (isLocalGame) { + return options; + } + + // The inviter may be in several games of the same room (hosting one and + // spectating another, for example). Return every game so the caller can + // let the user choose which one to invite to. + for (auto it = gameTabs.cbegin(); it != gameTabs.cend(); ++it) { + TabGame *tab = it.value(); + GameMetaInfo *metaInfo = tab->getGame()->getGameMetaInfo(); + if (metaInfo->proto().room_id() != roomId) { + continue; + } + // A closed game is a dead end — drop it. Started/full games stay + // listed: an invite to them is a legitimate "come spectate" offer. + if (metaInfo->proto().closed()) { + continue; + } + + const int gameId = metaInfo->gameId(); + const QString description = QString::fromStdString(metaInfo->proto().description()); + + GameInviteOption option{ + .gameId = gameId, + .label = + description.isEmpty() ? tr("Game #%1").arg(gameId) : tr("Game #%1 — %2").arg(gameId).arg(description), + .url = makeGameJoinLink(client->serverName(), client->serverPort(), roomId, gameId, description), + .description = description, + .onlyBuddies = metaInfo->proto().only_buddies(), + .creatorName = QString::fromStdString(metaInfo->proto().creator_info().name()), + }; + options.append(option); + } + + return options; +} + +void TabSupervisor::sendInviteToUser(const QString &userName, const QString &inviteText) +{ + TabMessage *tab = addMessageTab(userName, true); + if (tab) { + tab->sendInviteMessage(inviteText); + } +} + /** * Creates a new deck editor tab and loads the deck into it. * Creates either a classic or visual deck editor tab depending on settings @@ -874,7 +1203,7 @@ void TabSupervisor::talkLeft(TabMessage *tab) */ void TabSupervisor::openDeckInNewTab(const LoadedDeck &deckToOpen) { - int type = SettingsCache::instance().getDefaultDeckEditorType(); + int type = SettingsCache::instance().deckEditor().getDefaultDeckEditorType(); switch (type) { case ClassicDeckEditor: addDeckEditorTab(deckToOpen); @@ -971,9 +1300,9 @@ void TabSupervisor::tabUserEvent(bool globalEvent) auto *tab = static_cast(sender()); if (tab != currentWidget()) { tab->setContentsChanged(true); - setTabIcon(indexOf(tab), QPixmap("theme:icons/tab_changed")); + setTabIcon(indexOf(tab), themePixmap(QStringLiteral("icons/tab_changed"))); } - if (globalEvent && SettingsCache::instance().getNotificationsEnabled()) { + if (globalEvent && SettingsCache::instance().userInterface().getNotificationsEnabled()) { QApplication::alert(this); } } @@ -1006,7 +1335,21 @@ void TabSupervisor::processGameEventContainer(const GameEventContainer &cont) void TabSupervisor::processUserMessageEvent(const Event_UserMessage &event) { + // "Ignore all private messages" silences every PM, including messages to + // already-open tabs — unlike the unregistered/non-buddy filters below, + // which only apply when creating a new tab. Messages from moderators/admins + // are exempt to ensure warnings still reach users. QString senderName = QString::fromStdString(event.sender_name()); + if (SettingsCache::instance().chat().getIgnoreAllPrivateMessages()) { + const ServerInfo_User *onlineUserInfo = userListManager->getOnlineUser(senderName); + if (!onlineUserInfo) { + return; + } + const UserLevelFlags userLevel(onlineUserInfo->user_level()); + if (!userLevel.testFlag(ServerInfo_User::IsModerator) && !userLevel.testFlag(ServerInfo_User::IsAdmin)) { + return; + } + } TabMessage *tab = messageTabs.value(senderName); if (!tab) { tab = messageTabs.value(QString::fromStdString(event.receiver_name())); @@ -1015,11 +1358,11 @@ void TabSupervisor::processUserMessageEvent(const Event_UserMessage &event) const ServerInfo_User *onlineUserInfo = userListManager->getOnlineUser(senderName); if (onlineUserInfo) { auto userLevel = UserLevelFlags(onlineUserInfo->user_level()); - if (SettingsCache::instance().getIgnoreUnregisteredUserMessages() && + if (SettingsCache::instance().chat().getIgnoreUnregisteredUserMessages() && !userLevel.testFlag(ServerInfo_User::IsRegistered)) { // Flags are additive, so reg/mod/admin are all IsRegistered return; - } else if (SettingsCache::instance().getIgnoreNonBuddyUserMessages() && + } else if (SettingsCache::instance().chat().getIgnoreNonBuddyUserMessages() && !userListManager->isUserBuddy(senderName) && !userLevel.testFlag(ServerInfo_User::IsModerator) && !userLevel.testFlag(ServerInfo_User::IsAdmin)) { // Ignore private messages from non-buddies @@ -1068,7 +1411,7 @@ void TabSupervisor::processUserJoined(const ServerInfo_User &userInfoJoined) } } - if (SettingsCache::instance().getBuddyConnectNotificationsEnabled()) { + if (SettingsCache::instance().userInterface().getBuddyConnectNotificationsEnabled()) { QApplication::alert(this); this->actShowPopup(tr("Your buddy %1 has signed on!").arg(userName)); } @@ -1108,6 +1451,11 @@ bool TabSupervisor::getAdminLocked() const return tabAdmin->getLocked(); } +bool TabSupervisor::canOverrideGameRestrictions() const +{ + return !getAdminLocked() || (userInfo->user_level() & ServerInfo_User::IsJudge); +} + void TabSupervisor::processNotifyUserEvent(const Event_NotifyUser &event) { @@ -1153,6 +1501,24 @@ void TabSupervisor::processNotifyUserEvent(const Event_NotifyUser &event) } break; } + case Event_NotifyUser::REPORT_RESOLVED: { + QString title = QString::fromStdString(event.custom_title()).simplified(); + QString content = QString::fromStdString(event.custom_content()).trimmed(); + if (!title.isEmpty() && !content.isEmpty()) { + actShowPopup(title + "\n" + content); + QApplication::alert(this); + } + break; + } + case Event_NotifyUser::REPORT_COMMENT: { + QString title = QString::fromStdString(event.custom_title()).simplified(); + QString content = QString::fromStdString(event.custom_content()).trimmed(); + if (!title.isEmpty() && !content.isEmpty()) { + actShowPopup(title + "\n" + content); + QApplication::alert(this); + } + break; + } default:; } } diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h index e77fb4f7b..adde7f971 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h @@ -9,6 +9,7 @@ #define TAB_SUPERVISOR_H #include "../../deck_loader/deck_loader.h" +#include "../interface/widgets/server/game_link.h" #include "../interface/widgets/server/user/user_list_proxy.h" #include "abstract_tab_deck_editor.h" #include "api/archidekt/tab_archidekt.h" @@ -23,7 +24,9 @@ #include #include #include +#include +class TabCardArtRules; inline Q_LOGGING_CATEGORY(TabSupervisorLog, "tab_supervisor"); class UserListManager; @@ -38,8 +41,11 @@ class TabDeckStorage; class TabReplays; class TabAdmin; class TabMessage; +class TabReport; +class TabModeration; class TabAccount; class TabDeckEditor; +class TabDeveloper; class TabLog; class RoomEvent; class GameEventContainer; @@ -70,11 +76,7 @@ public: } protected: -#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) void enterEvent(QEnterEvent *event) override; -#else - void enterEvent(QEvent *event) override; -#endif void leaveEvent(QEvent *event) override; void paintEvent(QPaintEvent *event) override; }; @@ -103,7 +105,11 @@ private: TabDeckStorage *tabDeckStorage; TabReplays *tabReplays; TabAdmin *tabAdmin; + TabCardArtRules *tabCardArtRules; TabLog *tabLog; + TabReport *tabReport; + TabModeration *tabModeration; + TabDeveloper *tabDeveloper; QMap roomTabs; QMap gameTabs; QList replayTabs; @@ -112,7 +118,8 @@ private: bool isLocalGame; QAction *aTabHome, *aTabDeckEditor, *aTabVisualDeckEditor, *aTabEdhRec, *aTabArchidekt, *aTabVisualDeckStorage, - *aTabVisualDatabaseDisplay, *aTabServer, *aTabAccount, *aTabDeckStorage, *aTabReplays, *aTabAdmin, *aTabLog; + *aTabVisualDatabaseDisplay, *aTabServer, *aTabAccount, *aTabDeckStorage, *aTabReplays, *aTabAdmin, + *aTabCardArtRules, *aTabLog, *aTabReport, *aTabModeration, *aTabDeveloper; int myAddTab(Tab *tab, QAction *manager = nullptr); void addCloseButtonToTab(Tab *tab, int tabIndex, QAction *manager); @@ -145,10 +152,14 @@ public: return userInfo; } [[nodiscard]] AbstractClient *getClient() const; - [[nodiscard]] const UserListManager *getUserListManager() const + [[nodiscard]] UserListManager *getUserListManager() const { return userListManager; } + [[nodiscard]] TabServer *getTabServer() const + { + return tabServer; + } [[nodiscard]] const QMap &getRoomTabs() const { return roomTabs; @@ -157,7 +168,10 @@ public: { return deckEditorTabs; } + [[nodiscard]] QList getGameInviteLinksForRoom(int roomId) const; + void sendInviteToUser(const QString &userName, const QString &inviteText); [[nodiscard]] bool getAdminLocked() const; + [[nodiscard]] bool canOverrideGameRestrictions() const; void closeEvent(QCloseEvent *event) override; bool switchToGameTabIfAlreadyExists(const int gameId); static void actShowPopup(const QString &message); @@ -166,6 +180,7 @@ signals: void localGameEnded(); void adminLockChanged(bool lock); void showWindowIfHidden(); + void cockatriceLinkActivated(const QString &url); public slots: void openDeckInNewTab(const LoadedDeck &deckToOpen); @@ -176,10 +191,14 @@ public slots: TabArchidekt *addArchidektTab(); TabEdhRec *addEdhrecTab(const CardInfoPtr &cardToQuery, bool isCommander = false); void openReplay(GameReplay *replay); + void joinReportGame(int gameId, int roomId); + void openTabModeration(const QString &userName = {}); void switchToFirstAvailableNetworkTab(); void maximizeMainWindow(); void actTabVisualDeckStorage(bool checked); void actTabReplays(bool checked); + void openTabServer(); + void addRoomTab(const ServerInfo_Room &info, bool setCurrent); private slots: void refreshShortcuts(); @@ -189,22 +208,28 @@ private slots: void actTabDeckStorage(bool checked); void actTabAdmin(bool checked); void actTabLog(bool checked); + void actTabReport(bool checked); + void actTabModeration(bool checked); + void actTabDeveloper(bool checked); void openTabVisualDeckStorage(); void openTabHome(); - void openTabServer(); void openTabAccount(); void openTabDeckStorage(); void openTabReplays(); void openTabAdmin(); + void actTabCardArtRules(bool checked); + void openTabCardArtRules(); void openTabLog(); + void openTabReport(); + void openTabDeveloper(); void updateCurrent(int index); void updatePingTime(int value, int max); + void updateLatencyTooltip(const LatencyTracker::Stats &stats); void gameJoined(const Event_GameJoined &event); void localGameJoined(const Event_GameJoined &event); void gameLeft(TabGame *tab); - void addRoomTab(const ServerInfo_Room &info, bool setCurrent); void roomLeft(TabRoom *tab); TabMessage *addMessageTab(const QString &userName, bool focus); void replayLeft(TabGame *tab); diff --git a/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.cpp b/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.cpp index fd465ec21..0f43893d3 100644 --- a/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.cpp +++ b/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.cpp @@ -1,8 +1,10 @@ #include "tab_deck_editor_visual.h" #include "../../../../client/settings/cache_settings.h" +#include "../../../../client/settings/shortcuts_settings.h" #include "../../cards/card_info_display_widget.h" #include "../../deck_editor/deck_state_manager.h" +#include "../../deck_editor/deck_zone_dialog.h" #include "../../filters/filter_builder.h" #include "../../interface/pixel_map_generator.h" #include "../../interface/widgets/cards/card_info_frame_widget.h" @@ -26,10 +28,11 @@ #include #include #include -#include #include #include #include +#include +#include /** * @brief Constructs the TabDeckEditorVisual instance. @@ -65,9 +68,6 @@ void TabDeckEditorVisual::createCentralFrame() centralFrame = new QVBoxLayout; centralWidget->setLayout(centralFrame); - auto databaseModel = new CardDatabaseModel(CardDatabaseManager::getInstance(), true, this); - databaseModel->setObjectName("databaseModel"); - tabContainer = new TabDeckEditorVisualTabWidget(centralWidget, this, deckStateManager->getModel(), databaseModel); connect(tabContainer, &TabDeckEditorVisualTabWidget::cardChanged, this, @@ -85,6 +85,7 @@ void TabDeckEditorVisual::createCentralFrame() connect(tabContainer, &TabDeckEditorVisualTabWidget::printingSelectorRequested, this, &TabDeckEditorVisual::showPrintingSelector); connect(tabContainer, &TabDeckEditorVisualTabWidget::cardInfoRequested, this, &TabDeckEditorVisual::updateCardInfo); + tabContainer->visualDatabaseDisplay->setNewZoneCreator([this] { return createNewZone(); }); centralFrame->addWidget(tabContainer); setCentralWidget(centralWidget); @@ -100,6 +101,33 @@ void TabDeckEditorVisual::onDeckChanged() tabContainer->sampleHandWidget->setDeckModel(deckStateManager->getModel()); } +/** @brief Sets the deck and selects the startup sub-tab matching the context. */ +void TabDeckEditorVisual::setDeck(const LoadedDeck &_deck) +{ + AbstractTabDeckEditor::setDeck(_deck); + + int startupTab = SettingsCache::instance().deckEditor().getVdeStartupTab(); + if (startupTab == VdeStartupTabContext) { + // New (empty) decks open on the database display so cards can be added + // right away. Existing decks open on the deck view. + startupTab = _deck.isEmpty() ? VdeStartupTabDatabaseDisplay : VdeStartupTabDeckDisplay; + } + + switch (startupTab) { + case VdeStartupTabDatabaseDisplay: + tabContainer->setCurrentIndex(TabDeckEditorVisualTabWidget::TabIndex::VisualDatabaseDisplay); + break; + case VdeStartupTabDeckDisplay: + tabContainer->setCurrentIndex(TabDeckEditorVisualTabWidget::TabIndex::VisualDeckView); + break; + default: + qCWarning(TabSupervisorLog) << "Unknown VdeStartupTab [" << startupTab + << "]; falling back to the deck view"; + tabContainer->setCurrentIndex(TabDeckEditorVisualTabWidget::TabIndex::VisualDeckView); + break; + } +} + /** @brief Creates menus for deck editing and view options, including dock actions. */ void TabDeckEditorVisual::createMenus() { @@ -243,6 +271,19 @@ bool TabDeckEditorVisual::actSaveDeckAs() return result; } +/** @brief Prompts for and creates a new custom deck zone. Returns the name of the created zone. */ +QString TabDeckEditorVisual::createNewZone() +{ + QString boardName; + const QString zoneName = DeckZoneDialog::promptForNewZone(this, {}, &boardName, [this](const QString &candidate) { + return deckStateManager->validateNewZoneName(candidate); + }); + if (!zoneName.isEmpty()) { + deckStateManager->createCustomZone(boardName, zoneName); + } + return zoneName; +} + /** @brief Refreshes keyboard shortcuts for this tab from settings. */ void TabDeckEditorVisual::refreshShortcuts() { diff --git a/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.h b/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.h index 7d7a3f3a2..fb09578c4 100644 --- a/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.h +++ b/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.h @@ -164,6 +164,21 @@ public slots: * @return true if successful, false otherwise. */ bool actSaveDeckAs() override; + + /** + * @brief Prompts for and creates a new custom deck zone. + * @return The name of the created zone, or an empty string if creation was cancelled. + */ + QString createNewZone(); + +private: + /** + * @brief Sets the deck for this tab and selects the sub-tab to open on + * startup, per the "Visual deck editor startup tab" setting (Context / + * Deck display / Database display). + * @param _deck The deck object. + */ + void setDeck(const LoadedDeck &_deck) override; }; #endif diff --git a/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual_tab_widget.cpp b/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual_tab_widget.cpp index 2ee560859..5ccfcc28f 100644 --- a/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual_tab_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual_tab_widget.cpp @@ -25,7 +25,8 @@ TabDeckEditorVisualTabWidget::TabDeckEditorVisualTabWidget(QWidget *parent, layout = new QVBoxLayout(this); setLayout(layout); - visualDeckView = new VisualDeckEditorWidget(this, deckModel, _deckEditor->deckDockWidget->getSelectionModel()); + visualDeckView = new VisualDeckEditorWidget(this, deckModel, _deckEditor->deckDockWidget->getSelectionModel(), + _cardDatabaseModel); visualDeckView->setObjectName("visualDeckView"); connect(visualDeckView, &VisualDeckEditorWidget::activeCardChanged, this, &TabDeckEditorVisualTabWidget::onCardChanged); diff --git a/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual_tab_widget.h b/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual_tab_widget.h index 2aabbb26a..4f04b51f6 100644 --- a/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual_tab_widget.h +++ b/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual_tab_widget.h @@ -49,6 +49,17 @@ class TabDeckEditorVisualTabWidget : public QTabWidget Q_OBJECT public: + /** + * @brief Sub-tab order in the container; addNewTab() is called in this order. + */ + enum TabIndex + { + VisualDeckView, + VisualDatabaseDisplay, + DeckAnalytics, + SampleHand, + }; + /** * @brief Construct the tab widget with required models. * @param parent Parent widget. diff --git a/cockatrice/src/interface/widgets/utility/card_completer_delegate.cpp b/cockatrice/src/interface/widgets/utility/card_completer_delegate.cpp new file mode 100644 index 000000000..29bf2263e --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/card_completer_delegate.cpp @@ -0,0 +1,419 @@ +#include "card_completer_delegate.h" + +#include "../../pixel_map_generator.h" +#include "../cards/additional_info/mana_cost_widget.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// Internal colour helpers +// --------------------------------------------------------------------------- + +namespace +{ + +struct ManaColor +{ + QColor fill; + QColor rim; + QColor text; +}; + +ManaColor manaColor(QChar symbol) +{ + switch (symbol.unicode()) { + case 'W': + return {QColor(248, 248, 246), QColor(190, 180, 160), QColor(80, 70, 50)}; + case 'U': + return {QColor(55, 130, 210), QColor(30, 90, 160), QColor(255, 255, 255)}; + case 'B': + return {QColor(90, 65, 160), QColor(55, 38, 110), QColor(220, 200, 255)}; + case 'R': + return {QColor(210, 55, 55), QColor(150, 30, 30), QColor(255, 255, 255)}; + case 'G': + return {QColor(45, 148, 90), QColor(28, 95, 58), QColor(255, 255, 255)}; + default: + return {QColor(100, 115, 135), QColor(65, 78, 95), QColor(230, 235, 240)}; + } +} + +QColor blend(QColor a, QColor b, qreal t) +{ + return QColor::fromRgbF(a.redF() + (b.redF() - a.redF()) * t, a.greenF() + (b.greenF() - a.greenF()) * t, + a.blueF() + (b.blueF() - a.blueF()) * t, a.alphaF() + (b.alphaF() - a.alphaF()) * t); +} + +} // namespace + +// --------------------------------------------------------------------------- + +QColor CardCompleterDelegate::accentForColors(const QString &colors) +{ + if (colors.isEmpty()) { + return QColor(100, 115, 135); + } + + QSet seen; + for (const QChar c : colors) { + if (QString("WUBRG").contains(c)) { + seen.insert(c); + } + } + + if (seen.size() > 1) { + return QColor(205, 145, 25); + } + + if (seen.isEmpty()) { + return QColor(100, 115, 135); + } + + return manaColor(*seen.begin()).fill; +} + +// --------------------------------------------------------------------------- + +CardCompleterDelegate::CardCompleterDelegate(QObject *parent) : QStyledItemDelegate(parent) +{ + setCodeCache.setMaxCost(64); +} + +// --------------------------------------------------------------------------- +// sizeHint +// --------------------------------------------------------------------------- + +QSize CardCompleterDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const +{ + Q_UNUSED(option) + + if (!index.isValid()) { + return QStyledItemDelegate::sizeHint(option, index); + } + + // Fixed wide rows so the popup has room for name, type line, set and mana + return {480, CardRowHeight}; +} + +// --------------------------------------------------------------------------- +// Mana symbol painting +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- + +void CardCompleterDelegate::drawManaSymbol(QPainter *p, QPoint centre, const QString &symbol, int radius) const +{ + const QRect pip(centre.x() - radius, centre.y() - radius, radius * 2, radius * 2); + + const QPixmap px = ManaSymbolPixmapGenerator::generatePixmap(symbol, QSize(radius * 2, radius * 2)); + + if (!px.isNull()) { + p->drawPixmap(pip, px); + return; + } + + bool isNumeric = false; + const int numVal = symbol.toInt(&isNumeric); + + const QString label = isNumeric ? QString::number(numVal) : symbol; + + const ManaColor mc = + (symbol.length() == 1 && QString("WUBRG").contains(symbol)) ? manaColor(symbol[0]) : manaColor(QChar('X')); + + QPainterPath circle; + circle.addEllipse(pip); + + p->save(); + p->setClipPath(circle); + p->fillPath(circle, mc.fill); + p->restore(); + + p->setPen(QPen(mc.rim, 1.2)); + p->setBrush(Qt::NoBrush); + p->drawEllipse(pip.adjusted(1, 1, -1, -1)); + + QFont f = p->font(); + f.setPixelSize(qMax(radius - 1, 7)); + f.setBold(true); + + p->setFont(f); + p->setPen(mc.text); + p->drawText(pip, Qt::AlignCenter, label); +} + +// --------------------------------------------------------------------------- + +int CardCompleterDelegate::drawManaCost(QPainter *p, const QRect &row, const QString &manaCost, int radius) const +{ + if (manaCost.isEmpty()) { + return row.right(); + } + + const int diam = radius * 2; + + // Split, adventure, aftermath and prepare cards store both halves of the + // cost joined by "//" (e.g. "1W // W"); draw each half as its own group. + static const QRegularExpression splitRegex("\\s*//\\s*"); + + QList parts; + + for (const QString &part : manaCost.split(splitRegex, Qt::SkipEmptyParts)) { + const QStringList symbols = ManaCostWidget::parseManaCost(part); + + if (!symbols.isEmpty()) { + parts.append(symbols); + } + } + + int totalW = 0; + + for (int i = 0; i < parts.size(); ++i) { + if (i > 0) { + totalW += PartGap; + } + + totalW += parts.at(i).size() * diam + qMax(0, parts.at(i).size() - 1) * SymbolSpacing; + } + + const int rightPad = 14; + + int x = row.right() - rightPad - totalW + radius; + + const int cy = row.center().y(); + + for (int i = 0; i < parts.size(); ++i) { + const QStringList &symbols = parts.at(i); + + for (const QString &sym : symbols) { + drawManaSymbol(p, {x, cy}, sym, radius); + x += diam + SymbolSpacing; + } + + if (i < parts.size() - 1) { + x += PartGap - SymbolSpacing; + } + } + + return row.right() - rightPad - totalW - 10; +} + +// --------------------------------------------------------------------------- + +QString CardCompleterDelegate::setCodeForCard(const QSharedPointer &card) const +{ + if (!card) { + return QString(); + } + + const QString name = card->getName(); + + if (setCodeCache.contains(name)) { + return *setCodeCache[name]; + } + + QString code; + + const PrintingInfo printing = CardDatabaseManager::query()->getPreferredPrinting(card); + + if (auto set = printing.getSet()) { + code = set->getShortName(); + } + + auto *cached = new QString(code); + setCodeCache.insert(name, cached); + return code; +} + +// --------------------------------------------------------------------------- +// paint +// --------------------------------------------------------------------------- + +void CardCompleterDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const +{ + if (!index.isValid()) { + QStyledItemDelegate::paint(painter, option, index); + return; + } + + const QRect rect = option.rect; + const QPalette &pal = option.palette; + + const bool selected = option.state & QStyle::State_Selected; + const bool hovered = option.state & QStyle::State_MouseOver; + + painter->save(); + painter->setRenderHints(QPainter::Antialiasing | QPainter::TextAntialiasing | QPainter::SmoothPixmapTransform); + + auto card = index.data(CardSearchModel::CardInfoRole).value>(); + + if (!card) { + painter->fillRect(rect, pal.color(QPalette::Base)); + painter->restore(); + return; + } + + const QString cardName = card->getName(); + const QString manaCost = card->getManaCost(); + const QString typeLine = card->getCardType(); + const QString setCode = setCodeForCard(card); + + const QColor accent = accentForColors(card->getColors()); + + const QColor base = pal.color(QPalette::Base); + const QColor textColor = pal.color(QPalette::Text); + const QColor secondaryColor = pal.color(QPalette::PlaceholderText); + + QColor tinted = blend(base, accent, 0.40); + + if (hovered) { + tinted = blend(tinted, Qt::white, 0.05); + } + + const QRectF cardRect = rect.adjusted(3, 2, -3, -2); + + // ----------------------------------------------------------------------- + // Main card body + // ----------------------------------------------------------------------- + + QPainterPath path; + path.addRoundedRect(cardRect, 7, 7); + + painter->save(); + painter->setClipPath(path); + + QLinearGradient bodyGrad(cardRect.topLeft(), cardRect.bottomLeft()); + + bodyGrad.setColorAt(0.0, blend(tinted, Qt::white, 0.10)); + bodyGrad.setColorAt(1.0, blend(tinted, Qt::black, 0.18)); + + painter->fillPath(path, bodyGrad); + + // ----------------------------------------------------------------------- + // Accent strip + // ----------------------------------------------------------------------- + + QRectF accentRect(cardRect.left(), cardRect.top(), AccentBarWidth, cardRect.height()); + + QLinearGradient accentGrad(accentRect.topLeft(), accentRect.bottomLeft()); + + accentGrad.setColorAt(0.0, blend(accent, Qt::white, 0.20)); + accentGrad.setColorAt(1.0, blend(accent, Qt::black, 0.25)); + + painter->fillRect(accentRect, accentGrad); + + // ----------------------------------------------------------------------- + // Right mana zone + // ----------------------------------------------------------------------- + + const QRectF manaZone(cardRect.right() - 110, cardRect.top(), 110, cardRect.height()); + + QLinearGradient manaGrad(manaZone.topLeft(), manaZone.bottomLeft()); + + manaGrad.setColorAt(0, QColor(0, 0, 0, 18)); + manaGrad.setColorAt(1, QColor(0, 0, 0, 42)); + + painter->fillRect(manaZone, manaGrad); + + painter->restore(); + + // ----------------------------------------------------------------------- + // Border + // ----------------------------------------------------------------------- + + QColor border = blend(accent, Qt::black, 0.45); + + if (hovered) { + border = blend(border, Qt::white, 0.18); + } + + painter->setPen(QPen(border, 1.2)); + painter->drawPath(path); + + // ----------------------------------------------------------------------- + // Selection glow + // ----------------------------------------------------------------------- + + if (selected) { + QColor glow = pal.color(QPalette::Highlight); + glow.setAlpha(30); + + painter->fillPath(path, glow); + + painter->setPen(QPen(pal.color(QPalette::Highlight), 2)); + painter->drawPath(path); + } + + // ----------------------------------------------------------------------- + // Mana cost + // ----------------------------------------------------------------------- + + const int costLeft = drawManaCost(painter, cardRect.toRect(), manaCost, SymbolRadius); + + // ----------------------------------------------------------------------- + // Card name + type line + set code + // ----------------------------------------------------------------------- + + const int textLeft = cardRect.left() + AccentBarWidth + 12; + const int textRight = costLeft - 8; + const int textWidth = qMax(0, textRight - textLeft); + + // ----------------------------------------------------------------------- + // Card name (top band) + // ----------------------------------------------------------------------- + + { + const QRect nameRect(textLeft, rect.top() + 2, textWidth, 20); + + QFont f = option.font; + f.setPixelSize(13); + f.setBold(true); + + painter->setFont(f); + + const QString nameText = QFontMetrics(f).elidedText(cardName, Qt::ElideRight, nameRect.width()); + + painter->setPen(QColor(0, 0, 0, 140)); + painter->drawText(nameRect.translated(0, 1), Qt::AlignLeft | Qt::AlignVCenter, nameText); + + painter->setPen(textColor); + painter->drawText(nameRect, Qt::AlignLeft | Qt::AlignVCenter, nameText); + } + + // ----------------------------------------------------------------------- + // Type line + set code (bottom band) + // ----------------------------------------------------------------------- + + { + const QRect infoRect(textLeft, rect.top() + 22, textWidth, rect.height() - 24); + + QFont f = option.font; + f.setPixelSize(10); + + painter->setFont(f); + + QString infoLine = typeLine; + + if (!setCode.isEmpty()) { + infoLine += " \u00b7 " + setCode; + } + + const QString infoText = QFontMetrics(f).elidedText(infoLine, Qt::ElideRight, infoRect.width()); + + painter->setPen(QColor(0, 0, 0, 120)); + painter->drawText(infoRect.translated(0, 1), Qt::AlignLeft | Qt::AlignVCenter, infoText); + + painter->setPen(secondaryColor); + painter->drawText(infoRect, Qt::AlignLeft | Qt::AlignVCenter, infoText); + } + + painter->restore(); +} diff --git a/cockatrice/src/interface/widgets/utility/card_completer_delegate.h b/cockatrice/src/interface/widgets/utility/card_completer_delegate.h new file mode 100644 index 000000000..78667f56b --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/card_completer_delegate.h @@ -0,0 +1,57 @@ +/** + * @file card_completer_delegate.h + * @ingroup UtilityWidgets + */ +//! \todo Document this file. + +#ifndef CARD_COMPLETER_DELEGATE_H +#define CARD_COMPLETER_DELEGATE_H + +#include +#include +#include +#include + +class CardInfo; + +/** + * @brief Paints styled card completer popup rows. + * + * Each row shows the card name, type line, set code and mana cost pips, + * color-coded by the card's color identity. Card data is read directly from + * the CardSearchModel::CardInfoRole so no extra database lookups are needed. + */ +class CardCompleterDelegate : public QStyledItemDelegate +{ + Q_OBJECT +public: + explicit CardCompleterDelegate(QObject *parent = nullptr); + + void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override; + QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override; + +private: + // Set short codes, resolved once per card name and cached + mutable QCache setCodeCache; + + // Resolve the card's color string ("RG", "W", "", ...) → accent QColor + static QColor accentForColors(const QString &colors); + + // Draw a single mana symbol pip at centre point + void drawManaSymbol(QPainter *p, QPoint centre, const QString &symbol, int radius) const; + + // Draw all mana pips for a cost string like "2RG" or "{2}{R}{G}"; split and + // adventure costs ("1W // W") are drawn as separate groups. Returns the left-most x used + int drawManaCost(QPainter *p, const QRect &row, const QString &manaCost, int radius) const; + + // Resolve the preferred printing's set short code for a card + QString setCodeForCard(const QSharedPointer &card) const; + + static constexpr int CardRowHeight = 40; + static constexpr int AccentBarWidth = 5; + static constexpr int SymbolRadius = 9; + static constexpr int SymbolSpacing = 2; + static constexpr int PartGap = 14; +}; + +#endif // CARD_COMPLETER_DELEGATE_H diff --git a/cockatrice/src/interface/widgets/utility/card_completer_styler.cpp b/cockatrice/src/interface/widgets/utility/card_completer_styler.cpp new file mode 100644 index 000000000..6794ccbb4 --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/card_completer_styler.cpp @@ -0,0 +1,340 @@ +#include "card_completer_styler.h" + +#include "../cards/card_info_picture_enlarged_widget.h" +#include "card_completer_delegate.h" +#include "reversed_completer_model.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +const QSize PreviewSize(300, 419); +const int PreviewMargin = 16; +const int FadeDuration = 120; +} // namespace + +// --------------------------------------------------------------------------- + +void CardCompleterStyler::apply(QCompleter *completer) +{ + if (!completer) { + return; + } + + // The styler lives as long as the completer + new CardCompleterStyler(completer, completer); +} + +// --------------------------------------------------------------------------- + +CardCompleterStyler::CardCompleterStyler(QCompleter *completer, QObject *parent) + : QObject(parent), completer(completer), reversedModel(nullptr), preview(nullptr), above(false) +{ + auto *popup = completer->popup(); + + // Wrap the completer's model so its row order can be reversed when the + // popup is shown above the text edit + QAbstractItemModel *sourceModel = completer->model(); + reversedModel = new ReversedCompleterModel(completer); + reversedModel->setSourceModel(sourceModel); + completer->setModel(reversedModel); + + popup->setItemDelegate(new CardCompleterDelegate(popup)); + + popup->viewport()->setMouseTracking(true); + + popup->installEventFilter(this); + popup->viewport()->installEventFilter(this); + + connect(popup->selectionModel(), &QItemSelectionModel::currentRowChanged, this, + &CardCompleterStyler::updatePreview); + connect(completer, qOverload(&QCompleter::activated), this, &CardCompleterStyler::hidePreview); + connect(completer->completionModel(), &QAbstractItemModel::modelReset, this, + &CardCompleterStyler::onCompletionReset); +} + +CardCompleterStyler::~CardCompleterStyler() +{ + if (preview) { + preview->deleteLater(); + } +} + +// --------------------------------------------------------------------------- + +bool CardCompleterStyler::eventFilter(QObject *obj, QEvent *ev) +{ + auto *popup = completer->popup(); + + if (obj == popup->viewport()) { + if (ev->type() == QEvent::MouseMove) { + updatePreviewFromHover(static_cast(ev)->pos()); + } + } else if (obj == popup) { + switch (ev->type()) { + case QEvent::Show: + case QEvent::Move: + case QEvent::Resize: + updateOrientation(); + reposition(); + break; + case QEvent::Hide: + hidePreview(); + break; + case QEvent::KeyPress: + if (handlePopupKeyPress(static_cast(ev))) { + return true; + } + break; + default: + break; + } + } + + return QObject::eventFilter(obj, ev); +} + +// --------------------------------------------------------------------------- + +bool CardCompleterStyler::handlePopupKeyPress(QKeyEvent *event) +{ + const int key = event->key(); + if (key != Qt::Key_Up && key != Qt::Key_Down) { + return false; + } + + if (!above) { + return false; + } + + auto *popup = completer->popup(); + const int rowCount = completer->completionModel()->rowCount(); + if (rowCount == 0) { + return true; + } + + // With the popup above the text edit the rows are shown in reverse order and + // the closest match is in the bottom-most row. The up arrow then advances + // through the list (towards its end at the top of the popup). + const int currentRow = popup->currentIndex().row(); + const int step = (key == Qt::Key_Up) ? -1 : 1; + const int newRow = qBound(0, currentRow + step, rowCount - 1); + + if (newRow != currentRow) { + popup->setCurrentIndex(completer->completionModel()->index(newRow, completer->completionColumn())); + } + + return true; +} + +// --------------------------------------------------------------------------- + +void CardCompleterStyler::updateOrientation() +{ + above = isPopupAboveWidget(); + reversedModel->setEnabled(above); +} + +// --------------------------------------------------------------------------- + +void CardCompleterStyler::ensureClosestSelected() +{ + auto *popup = completer->popup(); + auto *completionModel = completer->completionModel(); + const int rowCount = completionModel->rowCount(); + if (rowCount == 0) { + return; + } + + const int currentRow = popup->currentIndex().row(); + const int closestRow = rowCount - 1; + + if (currentRow != closestRow) { + popup->setCurrentIndex(completionModel->index(closestRow, completer->completionColumn())); + } +} + +// --------------------------------------------------------------------------- + +void CardCompleterStyler::onCompletionReset() +{ + if (above) { + ensureClosestSelected(); + } +} + +// --------------------------------------------------------------------------- + +bool CardCompleterStyler::isPopupAboveWidget() const +{ + auto *popup = completer->popup(); + QWidget *widget = completer->widget(); + + if (!widget || !popup->isVisible()) { + return false; + } + + const QPoint popupBottom = popup->mapToGlobal(QPoint(0, popup->height())); + const QPoint fieldTop = widget->mapToGlobal(QPoint(0, 0)); + + return popupBottom.y() <= fieldTop.y(); +} + +// --------------------------------------------------------------------------- + +void CardCompleterStyler::updatePreviewFromHover(const QPoint &pos) +{ + const QModelIndex index = completer->popup()->indexAt(pos); + + // Hovering updates the preview but must not change the current completion + if (index.isValid() && index != previewedIndex) { + updatePreview(index); + } +} + +// --------------------------------------------------------------------------- + +void CardCompleterStyler::updatePreview(const QModelIndex &index) +{ + previewedIndex = index; + + if (!index.isValid()) { + hidePreview(); + return; + } + + auto card = index.data(CardSearchModel::CardInfoRole).value>(); + + if (!card) { + hidePreview(); + return; + } + + if (!preview) { + QWidget *contextWindow = completer->widget() ? completer->widget()->window() : nullptr; + + preview = new CardInfoPictureEnlargedWidget(contextWindow); + preview->hide(); + preview->setWindowOpacity(0.0); + } + + const ExactCard exact = CardDatabaseManager::query()->getCard({card->getName()}); + + if (!exact) { + hidePreview(); + return; + } + + preview->setCardPixmap(exact, PreviewSize); + + reposition(); + showPreview(); +} + +// --------------------------------------------------------------------------- + +void CardCompleterStyler::showPreview() +{ + if (!preview) { + return; + } + + stopPreviewFade(); + + preview->show(); + preview->raise(); + + auto *fade = new QPropertyAnimation(preview, "windowOpacity", preview); + + fade->setDuration(FadeDuration); + fade->setStartValue(preview->windowOpacity()); + fade->setEndValue(1.0); + fade->start(QAbstractAnimation::DeleteWhenStopped); +} + +// --------------------------------------------------------------------------- + +void CardCompleterStyler::hidePreview() +{ + if (!preview) { + return; + } + + // Stop any in-flight fade first so a stale one cannot keep the preview visible + stopPreviewFade(); + preview->hide(); +} + +// --------------------------------------------------------------------------- + +void CardCompleterStyler::stopPreviewFade() +{ + if (!preview) { + return; + } + + const auto animations = preview->findChildren(); + for (auto *animation : animations) { + animation->stop(); + } +} + +// --------------------------------------------------------------------------- + +void CardCompleterStyler::reposition() +{ + if (!preview) { + return; + } + + auto *popup = completer->popup(); + + const QRect popupGlobalRect(popup->mapToGlobal(popup->rect().topLeft()), popup->rect().size()); + + int anchorY; + + if (previewedIndex.isValid()) { + const QRect itemRect = popup->visualRect(previewedIndex); + const QPoint itemCenter = popup->viewport()->mapToGlobal(itemRect.center()); + anchorY = itemCenter.y(); + } else { + anchorY = popupGlobalRect.center().y(); + } + + const QScreen *screen = popup->screen(); + const QRect screenGeom = screen ? screen->availableGeometry() : QRect(); + + const int rightX = popupGlobalRect.right() + PreviewMargin; + const int leftX = popupGlobalRect.left() - PreviewMargin - preview->width(); + + int x; + int y = anchorY - preview->height() / 2; + + if (screenGeom.isEmpty()) { + x = rightX; + } else if (rightX + preview->width() <= screenGeom.right()) { + x = rightX; + } else if (leftX >= screenGeom.left()) { + x = leftX; + } else { + x = rightX; + } + + if (!screenGeom.isEmpty()) { + x = qBound(screenGeom.left(), x, qMax(screenGeom.left(), screenGeom.right() - preview->width())); + y = qBound(screenGeom.top(), y, qMax(screenGeom.top(), screenGeom.bottom() - preview->height())); + } + + preview->move(x, y); +} diff --git a/cockatrice/src/interface/widgets/utility/card_completer_styler.h b/cockatrice/src/interface/widgets/utility/card_completer_styler.h new file mode 100644 index 000000000..616e3fe61 --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/card_completer_styler.h @@ -0,0 +1,67 @@ +/** + * @file card_completer_styler.h + * @ingroup UtilityWidgets + */ +//! \todo Document this file. + +#ifndef CARD_COMPLETER_STYLER_H +#define CARD_COMPLETER_STYLER_H + +#include +#include + +class CardInfoPictureEnlargedWidget; +class QCompleter; +class QKeyEvent; +class QPoint; +class ReversedCompleterModel; + +/** + * @brief Applies styled row painting and a card-image preview to a card completer. + * + * The completer popup rows are painted by CardCompleterDelegate and the image of + * the currently selected (or hovered) row is shown beside the popup. + */ +class CardCompleterStyler : public QObject +{ + Q_OBJECT +public: + /** + * @brief Styles @p completer and follows its current selection. + * + * The styler is parented to the completer so it lives exactly as long as the + * completer itself. + */ + static void apply(QCompleter *completer); + +protected: + bool eventFilter(QObject *obj, QEvent *ev) override; + +private slots: + void updatePreview(const QModelIndex &index); + void updatePreviewFromHover(const QPoint &pos); + void onCompletionReset(); + +private: + explicit CardCompleterStyler(QCompleter *completer, QObject *parent = nullptr); + ~CardCompleterStyler() override; + + void showPreview(); + void hidePreview(); + void stopPreviewFade(); + void reposition(); + + void updateOrientation(); + void ensureClosestSelected(); + + bool handlePopupKeyPress(QKeyEvent *event); + bool isPopupAboveWidget() const; + + QCompleter *completer; + ReversedCompleterModel *reversedModel; + CardInfoPictureEnlargedWidget *preview; + QModelIndex previewedIndex; + bool above; +}; + +#endif // CARD_COMPLETER_STYLER_H diff --git a/cockatrice/src/interface/widgets/utility/completer_utils.cpp b/cockatrice/src/interface/widgets/utility/completer_utils.cpp new file mode 100644 index 000000000..16d5cfd13 --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/completer_utils.cpp @@ -0,0 +1,52 @@ +#include "completer_utils.h" + +#include "card_completer_styler.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +CardCompleterSetup createCardCompleter(CardDatabaseDisplayModel *displayModel, QObject *parent, int maxVisibleItems) +{ + auto *searchModel = new CardSearchModel(displayModel, parent); + + auto *proxyModel = new CardCompleterProxyModel(parent); + proxyModel->setSourceModel(searchModel); + proxyModel->setFilterCaseSensitivity(Qt::CaseInsensitive); + + auto *completer = new QCompleter(proxyModel, parent); + completer->setCompletionRole(Qt::DisplayRole); + completer->setCompletionMode(QCompleter::PopupCompletion); + completer->setCaseSensitivity(Qt::CaseInsensitive); + completer->setFilterMode(Qt::MatchContains); + completer->setMaxVisibleItems(maxVisibleItems); + CardCompleterStyler::apply(completer); + + return {searchModel, proxyModel, completer}; +} + +void connectCardCompleterSearch(QLineEdit *edit, const CardCompleterSetup &setup) +{ + QObject::connect(edit, &QLineEdit::textEdited, setup.searchModel, &CardSearchModel::updateSearchResults); + QObject::connect(edit, &QLineEdit::textEdited, setup.completer, [setup](const QString &text) { + setup.proxyModel->setFilterRegularExpression( + QRegularExpression(QRegularExpression::escape(text), QRegularExpression::CaseInsensitiveOption)); + if (!text.isEmpty()) { + setup.completer->complete(); + } + }); +} + +QCompleter *createMentionCompleter(QStringListModel *model, QObject *parent) +{ + auto *completer = new QCompleter(model, parent); + completer->setCaseSensitivity(Qt::CaseInsensitive); + completer->setMaxVisibleItems(5); + completer->setFilterMode(Qt::MatchStartsWith); + return completer; +} diff --git a/cockatrice/src/interface/widgets/utility/completer_utils.h b/cockatrice/src/interface/widgets/utility/completer_utils.h new file mode 100644 index 000000000..5d2ddfc30 --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/completer_utils.h @@ -0,0 +1,32 @@ +/** + * @file completer_utils.h + * @ingroup UtilityWidgets + */ +//! \todo Document this file. + +#ifndef COMPLETER_UTILS_H +#define COMPLETER_UTILS_H + +class CardCompleterProxyModel; +class CardDatabaseDisplayModel; +class CardSearchModel; +class QCompleter; +class QLineEdit; +class QObject; +class QStringListModel; + +struct CardCompleterSetup +{ + CardSearchModel *searchModel; + CardCompleterProxyModel *proxyModel; + QCompleter *completer; +}; + +CardCompleterSetup +createCardCompleter(CardDatabaseDisplayModel *displayModel, QObject *parent, int maxVisibleItems = 10); + +void connectCardCompleterSearch(QLineEdit *edit, const CardCompleterSetup &setup); + +QCompleter *createMentionCompleter(QStringListModel *model, QObject *parent); + +#endif // CARD_COMPLETER_UTILS_H diff --git a/cockatrice/src/interface/widgets/utility/get_text_with_max.h b/cockatrice/src/interface/widgets/utility/get_text_with_max.h index 923d6f427..424bb0c6a 100644 --- a/cockatrice/src/interface/widgets/utility/get_text_with_max.h +++ b/cockatrice/src/interface/widgets/utility/get_text_with_max.h @@ -9,7 +9,7 @@ #include #include -#include +#include QString getTextWithMax(QWidget *parent, const QString &title, diff --git a/cockatrice/src/interface/widgets/utility/line_edit_completer.cpp b/cockatrice/src/interface/widgets/utility/line_edit_completer.cpp index 13f475a61..3a678029d 100644 --- a/cockatrice/src/interface/widgets/utility/line_edit_completer.cpp +++ b/cockatrice/src/interface/widgets/utility/line_edit_completer.cpp @@ -1,135 +1,199 @@ #include "line_edit_completer.h" #include -#include #include -#include -#include -#include -#include +#include -LineEditCompleter::LineEditCompleter(QWidget *parent) : LineEditUnfocusable(parent), c(nullptr) +LineEditCompleter::LineEditCompleter(QWidget *parent) : LineEditUnfocusable(parent) { } +void LineEditCompleter::addCompleter(QCompleter *c, CompleterTrigger trigger) +{ + c->setWidget(this); + c->setCompletionMode(QCompleter::PopupCompletion); + c->setCaseSensitivity(Qt::CaseInsensitive); + connect(c, qOverload(&QCompleter::activated), this, + qOverload(&LineEditCompleter::insertCompletion)); + + completers.append({c, trigger}); +} + +bool LineEditCompleter::hasVisibleCompleterPopup() const +{ + for (const auto &info : completers) { + if (info.completer->popup()->isVisible()) { + return true; + } + } + return false; +} + +void LineEditCompleter::hideCompleterPopups() +{ + for (const auto &info : completers) { + info.completer->popup()->hide(); + } +} + void LineEditCompleter::focusOutEvent(QFocusEvent *e) { LineEditUnfocusable::focusOutEvent(e); - if (c->popup()->isVisible()) { - // Remove Popup - c->popup()->hide(); - // Truncate the line to last space or whole string - QString textValue = text(); - int lastIndex = textValue.length(); - int lastWordStartIndex = textValue.lastIndexOf(" ") + 1; - int leftShift = qMin(lastIndex, lastWordStartIndex); - setText(textValue.left(leftShift)); - // Insert highlighted line from popup - insert(c->completionModel()->index(c->popup()->currentIndex().row(), 0).data().toString() + " "); - // Set focus back to the textbox since tab was pressed - setFocus(); + + // Only commit the highlighted completion when focus moves away via Tab. + // Other focus losses (e.g. the unfocus shortcut / Escape) must simply close + // the popup without inserting anything. + if (e->reason() != Qt::TabFocusReason) { + hideCompleterPopups(); + return; } + + for (auto &info : completers) { + if (!info.completer->popup()->isVisible()) { + continue; + } + + const QModelIndex currentIndex = info.completer->popup()->currentIndex(); + if (currentIndex.isValid()) { + insertCompletion(info.completer, currentIndex.data().toString()); + } + } + + hideCompleterPopups(); } void LineEditCompleter::keyPressEvent(QKeyEvent *event) { - switch (event->key()) { - case Qt::Key_Return: - case Qt::Key_Enter: - case Qt::Key_Escape: - if (c->popup()->isVisible()) { - event->ignore(); - // Remove Popup - c->popup()->hide(); - // Truncate the line to last space or whole string - QString textValue = text(); - int lastIndexof = qMax(0, textValue.lastIndexOf(" ")); - QString finalString = textValue.left(lastIndexof); - // Add a space if there's a word - if (finalString != "") { - finalString += " "; - } - setText(finalString); - return; - } - break; - case Qt::Key_Space: - if (c->popup()->isVisible()) { - event->ignore(); - // Remove Popup - c->popup()->hide(); - // Truncate the line to last space or whole string - QString textValue = text(); - int lastIndex = textValue.length(); - int lastWordStartIndex = textValue.lastIndexOf(" ") + 1; - int leftShift = qMin(lastIndex, lastWordStartIndex); - setText(textValue.left(leftShift)); - // Insert highlighted line from popup - insert(c->completionModel()->index(c->popup()->currentIndex().row(), 0).data().toString() + " "); - return; - } - break; - default: - break; - } - LineEditUnfocusable::keyPressEvent(event); - // return if the completer is null or if the most recently typed char was '@'. - // Only want the popup AFTER typing the first char of the mention. - if (!c || text().right(1).contains("@")) { - c->popup()->hide(); + + if (event->key() == Qt::Key_Escape) { + hideCompleterPopups(); return; } - // Set new completion prefix - c->setCompletionPrefix(cursorWord(text())); - if (c->completionPrefix().length() < 1) { - c->popup()->hide(); + QString textValue = text(); + int cursorPos = cursorPosition(); + + CompleterInfo *active = nullptr; + QString prefix; + + for (auto &info : completers) { + bool triggered = false; + switch (info.trigger) { + case CompleterTrigger::Mention: { + int triggerPos = textValue.lastIndexOf("@", cursorPos - 1); + if (triggerPos != -1 && (triggerPos == 0 || textValue[triggerPos - 1].isSpace())) { + triggered = true; + // Keep the "@" so the prefix matches the "@"-prefixed mention model entries. + prefix = textValue.mid(triggerPos, cursorPos - triggerPos); + } + break; + } + case CompleterTrigger::Card: { + int triggerPos = textValue.lastIndexOf("[[", cursorPos - 1); + int closePos = textValue.indexOf("]]", triggerPos + 2); + if (triggerPos != -1 && (closePos == -1 || closePos >= cursorPos)) { + triggered = true; + prefix = textValue.mid(triggerPos + 2, cursorPos - (triggerPos + 2)); + } + break; + } + } + + if (triggered) { + active = &info; + break; + } + } + + if (!active) { + for (auto &info : completers) { + info.completer->popup()->hide(); + } return; } - // Draw completion box - QRect cr = cursorRect(); - cr.setWidth(c->popup()->sizeHintForColumn(0) + c->popup()->verticalScrollBar()->sizeHint().width()); - c->complete(cr); + active->completer->setCompletionPrefix(prefix); - // Select first item in the completion popup - QItemSelectionModel *sm = new QItemSelectionModel(c->completionModel()); - c->popup()->setSelectionModel(sm); - sm->select(c->completionModel()->index(0, 0), QItemSelectionModel::ClearAndSelect); - sm->setCurrentIndex(c->completionModel()->index(0, 0), QItemSelectionModel::NoUpdate); -} - -QString LineEditCompleter::cursorWord(const QString &line) const -{ - return line.mid(line.left(cursorPosition()).lastIndexOf(" ") + 1, - cursorPosition() - line.left(cursorPosition()).lastIndexOf(" ") - 1); -} - -void LineEditCompleter::insertCompletion(QString arg) -{ - QString s_arg = arg + " "; - setText(text().replace(text().left(cursorPosition()).lastIndexOf(" ") + 1, - cursorPosition() - text().left(cursorPosition()).lastIndexOf(" ") - 1, s_arg)); -} - -void LineEditCompleter::setCompleter(QCompleter *completer) -{ - c = completer; - c->setWidget(this); - connect(c, qOverload(&QCompleter::activated), this, &LineEditCompleter::insertCompletion); -} - -void LineEditCompleter::setCompletionList(QStringList completionList) -{ - if (!c || c->popup()->isVisible()) { - return; + switch (active->trigger) { + case CompleterTrigger::Card: + emit cardPartialChanged(prefix); + return; + case CompleterTrigger::Mention: + break; } - QStringListModel *model; - model = (QStringListModel *)(c->model()); - if (model == NULL) { - model = new QStringListModel(); - } - model->setStringList(completionList); + active->completer->complete(); +} + +void LineEditCompleter::insertCompletion(const QString &completion) +{ + for (auto &info : completers) { + if (info.completer == sender()) { + insertCompletion(info.completer, completion); + return; + } + } +} + +void LineEditCompleter::insertCompletion(QCompleter *completer, const QString &completion) +{ + QString t = text(); + int pos = cursorPosition(); + + for (const auto &info : completers) { + if (info.completer != completer) { + continue; + } + + switch (info.trigger) { + case CompleterTrigger::Card: { + int triggerPos = t.lastIndexOf("[[", pos - 1); + if (triggerPos == -1) { + return; + } + + // If an earlier "[[" is still open it also encloses the cursor, so + // replace from its start. Otherwise completing in text such as + // "[[Opt[[Amok" would leave a stray "[[" behind. + int startPos = triggerPos; + for (int searchFrom = triggerPos; searchFrom > 0;) { + const int earlier = t.lastIndexOf("[[", searchFrom - 1); + if (earlier == -1) { + break; + } + const int earlierClose = t.indexOf("]]", earlier + 2); + if (earlierClose != -1 && earlierClose < pos) { + break; + } + startPos = earlier; + searchFrom = earlier; + } + + // If the cursor sits inside an already-closed [[...]] pair, replace + // the whole construct instead of leaving a duplicate closing bracket + // behind. + int insertEnd = pos; + const int closePos = t.indexOf("]]", startPos + 2); + if (closePos != -1 && closePos >= pos) { + insertEnd = closePos + 2; + } + + QString after = t.mid(insertEnd); + QString replaced = t.left(startPos + 2) + completion + "]] "; + setText(replaced + after); + setCursorPosition(replaced.length()); + return; + } + case CompleterTrigger::Mention: { + int triggerPos = t.lastIndexOf("@", pos - 1); + if (triggerPos == -1) { + return; + } + setText(t.replace(triggerPos, pos - triggerPos, completion + " ")); + setCursorPosition(triggerPos + completion.length() + 1); + return; + } + } + } } diff --git a/cockatrice/src/interface/widgets/utility/line_edit_completer.h b/cockatrice/src/interface/widgets/utility/line_edit_completer.h index 65fa382ac..369b7b553 100644 --- a/cockatrice/src/interface/widgets/utility/line_edit_completer.h +++ b/cockatrice/src/interface/widgets/utility/line_edit_completer.h @@ -1,6 +1,7 @@ /** * @file line_edit_completer.h * @ingroup UI + * @brief Line edit with support for multiple trigger-based completers, e.g., @mention and [[card]]. */ //! \todo Document this file. @@ -9,25 +10,49 @@ #include "custom_line_edit.h" +#include +#include #include +#include +#include +#include + +enum class CompleterTrigger +{ + Mention, //< "@" prefix, replaced in place with a trailing space. + Card //< "[[" prefix, replaced with the completion plus a closing "]] ". +}; + +struct CompleterInfo +{ + QCompleter *completer; + CompleterTrigger trigger; +}; class LineEditCompleter : public LineEditUnfocusable { Q_OBJECT +signals: + void cardPartialChanged(const QString &partial); + private: - QString cursorWord(const QString &line) const; - QCompleter *c; + QList completers; + + void insertCompletion(QCompleter *completer, const QString &completion); + private slots: - void insertCompletion(QString); + void insertCompletion(const QString &text); protected: - void keyPressEvent(QKeyEvent *event); - void focusOutEvent(QFocusEvent *e); + void keyPressEvent(QKeyEvent *event) override; + void focusOutEvent(QFocusEvent *e) override; public: explicit LineEditCompleter(QWidget *parent = nullptr); - void setCompleter(QCompleter *); - void setCompletionList(QStringList); + void addCompleter(QCompleter *c, CompleterTrigger trigger); + + bool hasVisibleCompleterPopup() const; + void hideCompleterPopups(); }; #endif diff --git a/cockatrice/src/interface/widgets/utility/report_utils.cpp b/cockatrice/src/interface/widgets/utility/report_utils.cpp new file mode 100644 index 000000000..aa4558e5c --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/report_utils.cpp @@ -0,0 +1,119 @@ +#include "report_utils.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace report_utils +{ + +namespace +{ +QColor reportStatusColor(const QString &status) +{ + const QColor text = qApp->palette().color(QPalette::Text); + const int luminance = (299 * text.red() + 587 * text.green() + 114 * text.blue()) / 1000; + const bool dark = luminance < 128; + + if (status == "open") { + return dark ? QColor("#e06060") : QColor("#c0392b"); + } + if (status == "assigned") { + return dark ? QColor("#e0a030") : QColor("#d68910"); + } + if (status == "resolved") { + return dark ? QColor("#50c878") : QColor("#1e8449"); + } + if (status == "dismissed") { + return qApp->palette().color(QPalette::PlaceholderText); + } + return QColor(); +} + +void setReportStatusItemColor(QTableWidgetItem *item, const QString &status) +{ + const QColor color = reportStatusColor(status); + if (color.isValid()) { + item->setForeground(QBrush(color)); + } +} +} // namespace + +QString formatReportCategory(const QString &category) +{ + QString result = category; + result.replace('_', ' '); + return result.left(1).toUpper() + result.mid(1); +} + +QString formatReportTime(qint64 secondsSinceEpoch) +{ + const QDateTime dt = QDateTime::fromSecsSinceEpoch(secondsSinceEpoch); + return dt.toString("yyyy-MM-dd hh:mm"); +} + +void fillReportTableRow(QTableWidget *table, + int row, + const ServerInfo_Report &report, + int idColumn, + int timeColumn, + int reportedColumn, + int categoryColumn, + int gameIdColumn, + int statusColumn, + int assignedColumn) +{ + auto *idItem = new QTableWidgetItem(QString::number(report.report_id())); + idItem->setData(Qt::UserRole, report.report_id()); + table->setItem(row, idColumn, idItem); + + table->setItem(row, timeColumn, new QTableWidgetItem(formatReportTime(report.report_time()))); + table->setItem(row, reportedColumn, new QTableWidgetItem(QString::fromStdString(report.reported_user_name()))); + table->setItem(row, categoryColumn, + new QTableWidgetItem(formatReportCategory(QString::fromStdString(report.category())))); + table->setItem(row, gameIdColumn, + new QTableWidgetItem(report.game_id() > 0 ? QString::number(report.game_id()) : QString())); + + auto *statusItem = new QTableWidgetItem(QString::fromStdString(report.status())); + setReportStatusItemColor(statusItem, QString::fromStdString(report.status())); + table->setItem(row, statusColumn, statusItem); + + table->setItem(row, assignedColumn, new QTableWidgetItem(QString::fromStdString(report.assigned_mod_name()))); +} + +void renderReportDetails(QTextEdit *chatLogEdit, + QTextEdit *commentsEdit, + const ServerInfo_Report &report, + const QString &commentsEmptyText, + const QString &moderatorPrefix, + const QString &nonModeratorPrefix) +{ + if (report.has_chat_log() && !report.chat_log().empty()) { + chatLogEdit->setPlainText(QString::fromStdString(report.chat_log())); + } else { + chatLogEdit->clear(); + } + + commentsEdit->clear(); + + if (report.comments_size() == 0) { + commentsEdit->setPlainText(commentsEmptyText); + return; + } + + for (int i = 0; i < report.comments_size(); ++i) { + const ServerInfo_ReportComment &c = report.comments(i); + const QString author = QString::fromStdString(c.author_name()); + const QString text = QString::fromStdString(c.comment_text()); + const QString prefix = c.is_moderator() ? moderatorPrefix : nonModeratorPrefix; + commentsEdit->moveCursor(QTextCursor::End); + commentsEdit->insertPlainText( + QString("[%1] %2 %3:\n%4\n\n").arg(formatReportTime(c.comment_time()), prefix, author, text)); + } +} + +} // namespace report_utils diff --git a/cockatrice/src/interface/widgets/utility/report_utils.h b/cockatrice/src/interface/widgets/utility/report_utils.h new file mode 100644 index 000000000..c6cc96c18 --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/report_utils.h @@ -0,0 +1,36 @@ +#ifndef REPORT_UTILS_H +#define REPORT_UTILS_H + +#include +#include + +class QTableWidget; +class QTextEdit; + +namespace report_utils +{ + +QString formatReportCategory(const QString &category); +QString formatReportTime(qint64 secondsSinceEpoch); + +void fillReportTableRow(QTableWidget *table, + int row, + const ServerInfo_Report &report, + int idColumn, + int timeColumn, + int reportedColumn, + int categoryColumn, + int gameIdColumn, + int statusColumn, + int assignedColumn); + +void renderReportDetails(QTextEdit *chatLogEdit, + QTextEdit *commentsEdit, + const ServerInfo_Report &report, + const QString &commentsEmptyText, + const QString &moderatorPrefix, + const QString &nonModeratorPrefix); + +} // namespace report_utils + +#endif // REPORT_UTILS_H diff --git a/cockatrice/src/interface/widgets/utility/reversed_completer_model.cpp b/cockatrice/src/interface/widgets/utility/reversed_completer_model.cpp new file mode 100644 index 000000000..1bc9ba14b --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/reversed_completer_model.cpp @@ -0,0 +1,102 @@ +#include "reversed_completer_model.h" + +#include +#include +#include + +void ReversedCompleterModel::setSourceModel(QAbstractItemModel *sourceModel) +{ + if (sourceModel == this->sourceModel()) { + return; + } + + if (QAbstractItemModel *old = this->sourceModel()) { + disconnect(old, nullptr, this, nullptr); + } + + QAbstractProxyModel::setSourceModel(sourceModel); + + if (sourceModel) { + connect(sourceModel, &QAbstractItemModel::modelReset, this, &ReversedCompleterModel::invalidate); + connect(sourceModel, &QAbstractItemModel::rowsInserted, this, &ReversedCompleterModel::invalidate); + connect(sourceModel, &QAbstractItemModel::rowsRemoved, this, &ReversedCompleterModel::invalidate); + connect(sourceModel, &QAbstractItemModel::rowsMoved, this, &ReversedCompleterModel::invalidate); + connect(sourceModel, &QAbstractItemModel::columnsInserted, this, &ReversedCompleterModel::invalidate); + connect(sourceModel, &QAbstractItemModel::columnsRemoved, this, &ReversedCompleterModel::invalidate); + connect(sourceModel, &QAbstractItemModel::dataChanged, this, &ReversedCompleterModel::invalidate); + connect(sourceModel, &QAbstractItemModel::layoutChanged, this, &ReversedCompleterModel::invalidate); + } + + invalidate(); +} + +void ReversedCompleterModel::setEnabled(bool enabled) +{ + if (enabled == isEnabled) { + return; + } + isEnabled = enabled; + invalidate(); +} + +QModelIndex ReversedCompleterModel::mapToSource(const QModelIndex &proxyIndex) const +{ + if (!proxyIndex.isValid() || !sourceModel()) { + return {}; + } + const int sourceRow = isEnabled ? sourceRowCount() - 1 - proxyIndex.row() : proxyIndex.row(); + return sourceModel()->index(sourceRow, proxyIndex.column()); +} + +QModelIndex ReversedCompleterModel::mapFromSource(const QModelIndex &sourceIndex) const +{ + if (!sourceIndex.isValid() || !sourceModel()) { + return {}; + } + const int proxyRow = isEnabled ? sourceRowCount() - 1 - sourceIndex.row() : sourceIndex.row(); + return index(proxyRow, sourceIndex.column()); +} + +QModelIndex ReversedCompleterModel::index(int row, int column, const QModelIndex &parent) const +{ + if (parent.isValid() || !sourceModel() || row < 0 || row >= rowCount() || column < 0 || column >= columnCount()) { + return {}; + } + return createIndex(row, column); +} + +QModelIndex ReversedCompleterModel::parent(const QModelIndex &) const +{ + return {}; +} + +int ReversedCompleterModel::rowCount(const QModelIndex &parent) const +{ + return parent.isValid() || !sourceModel() ? 0 : sourceModel()->rowCount(); +} + +int ReversedCompleterModel::columnCount(const QModelIndex &parent) const +{ + return parent.isValid() || !sourceModel() ? 0 : sourceModel()->columnCount(); +} + +QVariant ReversedCompleterModel::data(const QModelIndex &proxyIndex, int role) const +{ + return sourceModel() ? sourceModel()->data(mapToSource(proxyIndex), role) : QVariant(); +} + +QVariant ReversedCompleterModel::headerData(int section, Qt::Orientation orientation, int role) const +{ + return sourceModel() ? sourceModel()->headerData(section, orientation, role) : QVariant(); +} + +int ReversedCompleterModel::sourceRowCount() const +{ + return sourceModel() ? sourceModel()->rowCount() : 0; +} + +void ReversedCompleterModel::invalidate() +{ + beginResetModel(); + endResetModel(); +} diff --git a/cockatrice/src/interface/widgets/utility/reversed_completer_model.h b/cockatrice/src/interface/widgets/utility/reversed_completer_model.h new file mode 100644 index 000000000..64233a1b0 --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/reversed_completer_model.h @@ -0,0 +1,47 @@ +/** + * @file reversed_completer_model.h + * @ingroup UtilityWidgets + */ +//! \todo Document this file. + +#ifndef REVERSED_COMPLETER_MODEL_H +#define REVERSED_COMPLETER_MODEL_H + +#include + +class QAbstractItemModel; + +/** + * @brief A completer model that can present its rows bottom-to-top. + * + * The original row order is kept intact in the source model (row 0 is always the + * closest match). When enabled, the proxy maps the source rows in reverse so the + * popup shows the closest match in the row nearest to the text edit. Any change + * in the source model is forwarded as a full reset, which is all QCompleter + * needs to rebuild its completion list. + */ +class ReversedCompleterModel : public QAbstractProxyModel +{ +public: + using QAbstractProxyModel::QAbstractProxyModel; + + void setSourceModel(QAbstractItemModel *sourceModel) override; + void setEnabled(bool enabled); + + QModelIndex mapToSource(const QModelIndex &proxyIndex) const override; + QModelIndex mapFromSource(const QModelIndex &sourceIndex) const override; + QModelIndex index(int row, int column, const QModelIndex &parent = {}) const override; + QModelIndex parent(const QModelIndex &) const override; + int rowCount(const QModelIndex &parent = {}) const override; + int columnCount(const QModelIndex &parent = {}) const override; + QVariant data(const QModelIndex &proxyIndex, int role = Qt::DisplayRole) const override; + QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override; + +private: + int sourceRowCount() const; + void invalidate(); + + bool isEnabled = false; +}; + +#endif // REVERSED_COMPLETER_MODEL_H diff --git a/cockatrice/src/interface/widgets/utility/sequence_edit.cpp b/cockatrice/src/interface/widgets/utility/sequence_edit.cpp index a34f39042..06561a41d 100644 --- a/cockatrice/src/interface/widgets/utility/sequence_edit.cpp +++ b/cockatrice/src/interface/widgets/utility/sequence_edit.cpp @@ -1,6 +1,8 @@ #include "sequence_edit.h" #include "../../../client/settings/cache_settings.h" +#include "../../../client/settings/shortcuts_settings.h" +#include "../../pixel_map_generator.h" #include #include @@ -13,8 +15,8 @@ SequenceEdit::SequenceEdit(const QString &_shortcutName, QWidget *parent) : QWid defaultButton = new QPushButton("", this); lineEdit->setMinimumWidth(70); - clearButton->setIcon(QPixmap("theme:icons/clearsearch")); - defaultButton->setIcon(QPixmap("theme:icons/update")); + clearButton->setIcon(themePixmap(QStringLiteral("icons/clearsearch"))); + defaultButton->setIcon(themePixmap(QStringLiteral("icons/update"))); auto *layout = new QHBoxLayout(this); layout->setContentsMargins(0, 0, 0, 0); diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_save_load_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_save_load_widget.cpp index 7ee57b1e9..76a611d52 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_save_load_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_save_load_widget.cpp @@ -7,6 +7,7 @@ #include #include #include +#include VisualDatabaseDisplayFilterSaveLoadWidget::VisualDatabaseDisplayFilterSaveLoadWidget(QWidget *parent, FilterTreeModel *_filterModel) @@ -61,7 +62,7 @@ void VisualDatabaseDisplayFilterSaveLoadWidget::saveFilter() return; } - QString filePath = SettingsCache::instance().getFiltersPath() + QDir::separator() + filename + ".json"; + QString filePath = SettingsCache::instance().paths().getFiltersPath() + QDir::separator() + filename + ".json"; // Serialize the filter model to JSON QJsonArray filtersArray; @@ -86,7 +87,7 @@ void VisualDatabaseDisplayFilterSaveLoadWidget::saveFilter() void VisualDatabaseDisplayFilterSaveLoadWidget::loadFilter(const QString &filename) { - QString filePath = SettingsCache::instance().getFiltersPath() + QDir::separator() + filename; + QString filePath = SettingsCache::instance().paths().getFiltersPath() + QDir::separator() + filename; QFile file(filePath); if (!file.open(QIODevice::ReadOnly)) { @@ -156,7 +157,7 @@ void VisualDatabaseDisplayFilterSaveLoadWidget::refreshFilterList() fileListWidget->clearLayout(); fileButtons.clear(); - QDir dir(SettingsCache::instance().getFiltersPath()); + QDir dir(SettingsCache::instance().paths().getFiltersPath()); allFilterFiles = dir.entryList({"*.json"}, QDir::Files, QDir::Name); applySearchFilter(searchInput->text()); diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp index 62e1bf5ba..a59a068dd 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp @@ -1,5 +1,6 @@ #include "visual_database_display_filter_toolbar_widget.h" +#include "../../pixel_map_generator.h" #include "../deck_editor/card_database_view.h" #include "visual_database_display_widget.h" @@ -60,39 +61,36 @@ VisualDatabaseDisplayFilterToolbarWidget::VisualDatabaseDisplayFilterToolbarWidg }); quickFilterSaveLoadWidget = new SettingsButtonWidget(this); - quickFilterSaveLoadWidget->setButtonIcon(QPixmap("theme:icons/floppy_disk")); + quickFilterSaveLoadWidget->setButtonIcon(themePixmap(QStringLiteral("icons/floppy_disk"))); quickFilterNameWidget = new SettingsButtonWidget(this); - quickFilterNameWidget->setButtonIcon(QPixmap("theme:icons/pen_to_square")); + quickFilterNameWidget->setButtonIcon(themePixmap(QStringLiteral("icons/pen_to_square"))); quickFilterMainTypeWidget = new SettingsButtonWidget(this); - quickFilterMainTypeWidget->setButtonIcon(QPixmap("theme:icons/circle_half_stroke")); + quickFilterMainTypeWidget->setButtonIcon(themePixmap(QStringLiteral("icons/circle_half_stroke"))); quickFilterSubTypeWidget = new SettingsButtonWidget(this); - quickFilterSubTypeWidget->setButtonIcon(QPixmap("theme:icons/dragon")); + quickFilterSubTypeWidget->setButtonIcon(themePixmap(QStringLiteral("icons/dragon"))); quickFilterSetWidget = new SettingsButtonWidget(this); - quickFilterSetWidget->setButtonIcon(QPixmap("theme:icons/scroll")); + quickFilterSetWidget->setButtonIcon(themePixmap(QStringLiteral("icons/scroll"))); quickFilterFormatLegalityWidget = new SettingsButtonWidget(this); - quickFilterFormatLegalityWidget->setButtonIcon(QPixmap("theme:icons/scale_balanced")); + quickFilterFormatLegalityWidget->setButtonIcon(themePixmap(QStringLiteral("icons/scale_balanced"))); retranslateUi(); } void VisualDatabaseDisplayFilterToolbarWidget::initialize() { - // create groupbox layouts - auto sortLayout = new QHBoxLayout(this); + auto sortLayout = new QHBoxLayout(sortGroupBox); sortLayout->setContentsMargins(0, 0, 0, 0); sortLayout->setSpacing(0); - sortGroupBox->setLayout(sortLayout); sortLayout->setAlignment(Qt::AlignLeft); - auto filterLayout = new QHBoxLayout(this); + auto filterLayout = new QHBoxLayout(filterGroupBox); filterLayout->setContentsMargins(0, 0, 0, 0); filterLayout->setSpacing(2); - filterGroupBox->setLayout(filterLayout); filterLayout->setAlignment(Qt::AlignLeft); // create settings widgets diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_set_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_set_filter_widget.cpp index b72116461..358c3ed88 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_set_filter_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_set_filter_widget.cpp @@ -10,6 +10,7 @@ #include #include #include +#include VisualDatabaseDisplayRecentSetFilterSettingsWidget::VisualDatabaseDisplayRecentSetFilterSettingsWidget(QWidget *parent) : QWidget(parent) @@ -21,17 +22,19 @@ VisualDatabaseDisplayRecentSetFilterSettingsWidget::VisualDatabaseDisplayRecentS filterToMostRecentSetsCheckBox = new QCheckBox(this); filterToMostRecentSetsCheckBox->setChecked( - SettingsCache::instance().getVisualDatabaseDisplayFilterToMostRecentSetsEnabled()); - connect(filterToMostRecentSetsCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setVisualDatabaseDisplayFilterToMostRecentSetsEnabled); + SettingsCache::instance().visualDeckStorage().getVisualDatabaseDisplayFilterToMostRecentSetsEnabled()); + connect(filterToMostRecentSetsCheckBox, &QCheckBox::QT_STATE_CHANGED, + &SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::setVisualDatabaseDisplayFilterToMostRecentSetsEnabled); filterToMostRecentSetsAmount = new QSpinBox(this); filterToMostRecentSetsAmount->setMinimum(1); filterToMostRecentSetsAmount->setMaximum(100); filterToMostRecentSetsAmount->setValue( - SettingsCache::instance().getVisualDatabaseDisplayFilterToMostRecentSetsAmount()); - connect(filterToMostRecentSetsAmount, qOverload(&QSpinBox::valueChanged), &SettingsCache::instance(), - &SettingsCache::setVisualDatabaseDisplayFilterToMostRecentSetsAmount); + SettingsCache::instance().visualDeckStorage().getVisualDatabaseDisplayFilterToMostRecentSetsAmount()); + connect(filterToMostRecentSetsAmount, qOverload(&QSpinBox::valueChanged), + &SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::setVisualDatabaseDisplayFilterToMostRecentSetsAmount); layout->addWidget(filterToMostRecentSetsCheckBox); layout->addWidget(filterToMostRecentSetsAmount); @@ -66,9 +69,11 @@ VisualDatabaseDisplaySetFilterWidget::VisualDatabaseDisplaySetFilterWidget(QWidg recentSetsSettingsWidget = new VisualDatabaseDisplayRecentSetFilterSettingsWidget(this); layout->addWidget(recentSetsSettingsWidget); - connect(&SettingsCache::instance(), &SettingsCache::visualDatabaseDisplayFilterToMostRecentSetsEnabledChanged, this, + connect(&SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::visualDatabaseDisplayFilterToMostRecentSetsEnabledChanged, this, &VisualDatabaseDisplaySetFilterWidget::filterToRecentSets); - connect(&SettingsCache::instance(), &SettingsCache::visualDatabaseDisplayFilterToMostRecentSetsAmountChanged, this, + connect(&SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::visualDatabaseDisplayFilterToMostRecentSetsAmountChanged, this, &VisualDatabaseDisplaySetFilterWidget::filterToRecentSets); // Create the toggle button for Exact Match/Includes mode @@ -118,7 +123,7 @@ void VisualDatabaseDisplaySetFilterWidget::createSetButtons() void VisualDatabaseDisplaySetFilterWidget::filterToRecentSets() { - if (SettingsCache::instance().getVisualDatabaseDisplayFilterToMostRecentSetsEnabled()) { + if (SettingsCache::instance().visualDeckStorage().getVisualDatabaseDisplayFilterToMostRecentSetsEnabled()) { for (auto set : activeSets.keys()) { activeSets[set] = false; } @@ -129,7 +134,8 @@ void VisualDatabaseDisplaySetFilterWidget::filterToRecentSets() std::sort(allSets.begin(), allSets.end(), [](const auto &a, const auto &b) { return a->getReleaseDate() > b->getReleaseDate(); }); - int setsToPreactivate = SettingsCache::instance().getVisualDatabaseDisplayFilterToMostRecentSetsAmount(); + int setsToPreactivate = + SettingsCache::instance().visualDeckStorage().getVisualDatabaseDisplayFilterToMostRecentSetsAmount(); int setsActivated = 0; for (const auto &set : allSets) { diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp index cc4cce496..f4530a12b 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp @@ -16,10 +16,13 @@ #include #include +#include #include #include #include #include +#include +#include #include VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, @@ -51,9 +54,10 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, mainLayout->setContentsMargins(0, 0, 0, 0); flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarPolicy::ScrollBarAsNeeded); - cardSizeWidget = new CardSizeWidget(this, flowWidget, SettingsCache::instance().getVisualDatabaseDisplayCardSize()); - connect(cardSizeWidget, &CardSizeWidget::cardSizeSettingUpdated, &SettingsCache::instance(), - &SettingsCache::setVisualDatabaseDisplayCardSize); + cardSizeWidget = new CardSizeWidget(this, flowWidget, + SettingsCache::instance().cardsDisplay().getVisualDatabaseDisplayCardSize()); + connect(cardSizeWidget, &CardSizeWidget::cardSizeSettingUpdated, &SettingsCache::instance().cardsDisplay(), + &CardsDisplaySettings::setVisualDatabaseDisplayCardSize); searchContainer = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAlwaysOff); @@ -62,7 +66,7 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, searchEdit->setPlaceholderText(tr("Search by card name (or search expressions)")); searchEdit->setClearButtonEnabled(true); searchEdit->addAction(loadColorAdjustedPixmap("theme:icons/search"), QLineEdit::LeadingPosition); - auto help = searchEdit->addAction(QPixmap("theme:icons/info"), QLineEdit::TrailingPosition); + auto help = searchEdit->addAction(themePixmap(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); connect(help, &QAction::triggered, this, [this] { createSearchSyntaxHelpWindow(searchEdit); }); setFocusProxy(searchEdit); @@ -86,6 +90,19 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, databaseView->setItemDelegate(nullptr); databaseView->setVisible(false); + // Without a deck model there is nothing to add cards to, so the zone menu stays hidden. + if (deckListModel) { + databaseView->setZoneMenuProvider( + [deckListModel]() -> QList> { + QList> result; + for (const QString &boardName : InnerDecklistNode::boardZoneNames()) { + result.append({boardName, deckListModel->getCustomZoneNames(boardName)}); + } + return result; + }, + [this] { return newZoneCreator ? newZoneCreator() : QString(); }); + } + searchEdit->setTreeView(databaseView); searchEdit->installEventFilter(databaseView->getKeySignals()); @@ -104,7 +121,7 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, clearFilterWidget = new QToolButton(); clearFilterWidget->setFixedSize(32, 32); - clearFilterWidget->setIcon(QPixmap("theme:icons/delete")); + clearFilterWidget->setIcon(themePixmap(QStringLiteral("icons/delete"))); connect(clearFilterWidget, &QToolButton::clicked, this, [this] { filterModel->blockSignals(true); filterModel->filterTree()->blockSignals(true); @@ -138,9 +155,6 @@ void VisualDatabaseDisplayWidget::initialize() { databaseLoadIndicator->setVisible(false); - filterContainer->initialize(); - filterContainer->setVisible(true); - searchContainer->addWidget(colorFilterWidget); searchContainer->addWidget(clearFilterWidget); searchContainer->addWidget(searchEdit); @@ -156,17 +170,48 @@ void VisualDatabaseDisplayWidget::initialize() mainLayout->addWidget(cardSizeWidget); - databaseDisplayModel->setFilterTree(filterModel->filterTree()); - connect(filterModel, &FilterTreeModel::layoutChanged, this, &VisualDatabaseDisplayWidget::onSearchModelChanged); - loadCardsTimer = new QTimer(this); - loadCardsTimer->setSingleShot(true); // Ensure it only fires once after the timeout + initializeFilters(); +} - connect(loadCardsTimer, &QTimer::timeout, this, [this]() { loadCurrentPage(); }); - loadCardsTimer->start(5000); +void VisualDatabaseDisplayWidget::initializeFilters() +{ + if (filtersInitialized || !isVisible() || CardDatabaseManager::getInstance()->getLoadStatus() != LoadStatus::Ok) { + return; + } - retranslateUi(); + filtersInitialized = true; + + // The filter toolbar builds its widgets by iterating the entire card database + // (per-set, per-main-type, per-sub-type and per-format buttons). Building it + // inside showEvent would block the tab switch, so keep it hidden and defer the + // build to the next event loop turn, letting the tab paint first. The toolbar + // then appears one event loop turn later, shifting the grid down by the toolbar + // height -- the intended tradeoff of an responsive tab switch. + filterContainer->setVisible(false); + + QTimer::singleShot(0, this, [this] { + filterContainer->initialize(); + filterContainer->setVisible(true); + + databaseDisplayModel->setFilterTree(filterModel->filterTree()); + + QTimer::singleShot(5000, this, [this] { loadCurrentPage(); }); + + retranslateUi(); + }); +} + +void VisualDatabaseDisplayWidget::showEvent(QShowEvent *event) +{ + QWidget::showEvent(event); + initializeFilters(); +} + +void VisualDatabaseDisplayWidget::setNewZoneCreator(const std::function &creator) +{ + newZoneCreator = creator; } void VisualDatabaseDisplayWidget::retranslateUi() @@ -290,9 +335,17 @@ void VisualDatabaseDisplayWidget::loadCurrentPage() { // Ensure only the initial page is loaded if (currentPage == 0) { - // Only load the first page initially - qCDebug(VisualDatabaseDisplayLog) << "Loading the first page"; - populateCards(); + if (!initialLoadScheduled) { + initialLoadScheduled = true; + qCDebug(VisualDatabaseDisplayLog) << "Loading the first page"; + // Defer the first page so the tab switch stays responsive. The card + // grid builds one event loop turn later. This also applies to + // search-driven reloads, which reset currentPage back to 0. + QTimer::singleShot(0, this, [this] { + initialLoadScheduled = false; + populateCards(); + }); + } } else if (nearEndOfPage()) { // If not the first page, just load the next page and append to the flow widget loadNextPage(); diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.h b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.h index a383e8ead..d161ce362 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.h +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.h @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -46,6 +47,12 @@ public: void sortCardList(const QStringList &properties, Qt::SortOrder order) const; void setDeckList(const DeckList &new_deck_list_model); + /** + * @brief Sets the callback used to create a custom zone from the add-to-zone menu. + * The callback returns the name of the created zone, or an empty string if creation was cancelled. + */ + void setNewZoneCreator(const std::function &creator); + CardDatabaseDisplayModel *getDatabaseDisplayModel() { return databaseDisplayModel; @@ -106,6 +113,7 @@ private: VisualDatabaseDisplayFilterToolbarWidget *filterContainer; CardDatabaseDisplayModel *databaseDisplayModel; CardDatabaseView *databaseView; + std::function newZoneCreator; QList *cards; QVBoxLayout *mainLayout; QScrollArea *scrollArea; @@ -115,17 +123,20 @@ private: OverlapControlWidget *overlapControlWidget; CardSizeWidget *cardSizeWidget; QTimer *debounceTimer; - QTimer *loadCardsTimer; int debounceTime = 300; // in Ms int currentPage = 0; // Current page index int cardsPerPage = 100; // Number of cards per page + bool filtersInitialized = false; + bool initialLoadScheduled = false; + void initializeFilters(); void highlightAllSearchEdit(); bool nearEndOfPage() const; protected: void resizeEvent(QResizeEvent *event) override; + void showEvent(QShowEvent *event) override; }; #endif // VISUAL_DATABASE_DISPLAY_WIDGET_H diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_filter_display_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_filter_display_widget.cpp index 5364fa813..bb5b333d9 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_filter_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_filter_display_widget.cpp @@ -8,6 +8,7 @@ #include #include #include +#include FilterDisplayWidget::FilterDisplayWidget(QWidget *parent, const QString &filename, FilterTreeModel *_filterModel) : QWidget(parent), filterFilename(filename), filterModel(_filterModel) @@ -68,7 +69,7 @@ void FilterDisplayWidget::deleteFilter() QMessageBox::Yes | QMessageBox::No); if (reply == QMessageBox::Yes) { // If confirmed, delete the filter - QString filePath = SettingsCache::instance().getFiltersPath() + QDir::separator() + filterFilename; + QString filePath = SettingsCache::instance().paths().getFiltersPath() + QDir::separator() + filterFilename; QFile file(filePath); if (file.remove()) { emit filterDeleted(filterFilename); // Emit signal for deletion diff --git a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp index f44c9c3ef..28abe480e 100644 --- a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp @@ -1,5 +1,6 @@ #include "visual_deck_display_options_widget.h" +#include "../../pixel_map_generator.h" #include "../tabs/visual_deck_editor/tab_deck_editor_visual.h" #include @@ -47,7 +48,7 @@ VisualDeckDisplayOptionsWidget::VisualDeckDisplayOptionsWidget(QWidget *parent) sortByLabel = new QLabel(this); sortCriteriaButton = new SettingsButtonWidget(this); - sortCriteriaButton->setButtonIcon(QPixmap("theme:icons/sort_arrow_down")); + sortCriteriaButton->setButtonIcon(themePixmap(QStringLiteral("icons/sort_arrow_down"))); sortLabel = new QLabel(sortCriteriaButton); sortLabel->setWordWrap(true); @@ -92,7 +93,7 @@ void VisualDeckDisplayOptionsWidget::retranslateUi() sortLabel->setText(tr("Click and drag to change the sort order within the groups")); sortCriteriaButton->setToolTip(tr("Configure how cards are sorted within their groups")); displayTypeButton->setButtonText(tr("Toggle Layout: Overlap")); - displayTypeButton->setButtonIcon(QPixmap("theme:icons/scales")); + displayTypeButton->setButtonIcon(themePixmap(QStringLiteral("icons/scales"))); displayTypeButton->setToolTip( tr("Change how cards are displayed within zones (i.e. overlapped or fully visible.)")); } @@ -117,11 +118,11 @@ void VisualDeckDisplayOptionsWidget::updateDisplayType() switch (currentDisplayType) { case DisplayType::Flat: displayTypeButton->setButtonText(tr("Toggle Layout: Flat")); - displayTypeButton->setButtonIcon(QPixmap("theme:icons/scroll")); + displayTypeButton->setButtonIcon(themePixmap(QStringLiteral("icons/scroll"))); break; case DisplayType::Overlap: displayTypeButton->setButtonText(tr("Toggle Layout: Overlap")); - displayTypeButton->setButtonIcon(QPixmap("theme:icons/scales")); + displayTypeButton->setButtonIcon(themePixmap(QStringLiteral("icons/scales"))); break; } emit displayTypeChanged(currentDisplayType); diff --git a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_sample_hand_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_sample_hand_widget.cpp index 8417ffa34..a4268563a 100644 --- a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_sample_hand_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_sample_hand_widget.cpp @@ -8,6 +8,7 @@ #include #include +#include #include VisualDeckEditorSampleHandWidget::VisualDeckEditorSampleHandWidget(QWidget *parent, @@ -33,10 +34,10 @@ VisualDeckEditorSampleHandWidget::VisualDeckEditorSampleHandWidget(QWidget *pare resetAndHandSizeLayout->addWidget(resetButton); handSizeSpinBox = new QSpinBox(this); - handSizeSpinBox->setValue(SettingsCache::instance().getVisualDeckEditorSampleHandSize()); + handSizeSpinBox->setValue(SettingsCache::instance().cardsDisplay().getSampleHandSize()); handSizeSpinBox->setMinimum(1); - connect(handSizeSpinBox, qOverload(&QSpinBox::valueChanged), &SettingsCache::instance(), - &SettingsCache::setVisualDeckEditorSampleHandSize); + connect(handSizeSpinBox, qOverload(&QSpinBox::valueChanged), &SettingsCache::instance().cardsDisplay(), + &CardsDisplaySettings::setSampleHandSize); connect(handSizeSpinBox, qOverload(&QSpinBox::valueChanged), this, &VisualDeckEditorSampleHandWidget::updateDisplay); resetAndHandSizeLayout->addWidget(handSizeSpinBox); diff --git a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.cpp index 815892f4c..064fbed5d 100644 --- a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.cpp @@ -4,12 +4,14 @@ #include "../../../main.h" #include "../../deck_loader/deck_loader.h" #include "../../layouts/overlap_layout.h" +#include "../../pixel_map_generator.h" #include "../cards/card_info_picture_with_text_overlay_widget.h" #include "../cards/deck_card_zone_display_widget.h" #include "../general/layout_containers/flow_widget.h" #include "../tabs/visual_deck_editor/tab_deck_editor_visual.h" #include "../tabs/visual_deck_editor/tab_deck_editor_visual_tab_widget.h" #include "../utility/compact_push_button.h" +#include "../utility/completer_utils.h" #include "visual_deck_display_options_widget.h" #include @@ -21,16 +23,17 @@ #include #include #include -#include -#include #include #include +#include #include VisualDeckEditorWidget::VisualDeckEditorWidget(QWidget *parent, DeckListModel *_deckListModel, - QItemSelectionModel *_selectionModel) - : QWidget(parent), deckListModel(_deckListModel), selectionModel(_selectionModel) + QItemSelectionModel *_selectionModel, + CardDatabaseModel *_cardDatabaseModel) + : QWidget(parent), deckListModel(_deckListModel), selectionModel(_selectionModel), + cardDatabaseModel(_cardDatabaseModel) { // The Main Widget and Main Layout, which contain a single Widget: The Scroll Area setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); @@ -43,9 +46,10 @@ VisualDeckEditorWidget::VisualDeckEditorWidget(QWidget *parent, initializeScrollAreaAndZoneContainer(); - cardSizeWidget = new CardSizeWidget(this, nullptr, SettingsCache::instance().getVisualDeckEditorCardSize()); - connect(cardSizeWidget, &CardSizeWidget::cardSizeSettingUpdated, &SettingsCache::instance(), - &SettingsCache::setVisualDeckEditorCardSize); + cardSizeWidget = + new CardSizeWidget(this, nullptr, SettingsCache::instance().cardsDisplay().getVisualDeckEditorCardSize()); + connect(cardSizeWidget, &CardSizeWidget::cardSizeSettingUpdated, &SettingsCache::instance().cardsDisplay(), + &CardsDisplaySettings::setVisualDeckEditorCardSize); mainLayout->addWidget(displayOptionsAndSearch); mainLayout->addWidget(scrollArea); @@ -93,35 +97,19 @@ void VisualDeckEditorWidget::initializeSearchBarAndCompleter() setFocusProxy(searchBar); setFocusPolicy(Qt::ClickFocus); - cardDatabaseModel = new CardDatabaseModel(CardDatabaseManager::getInstance(), false, this); + if (!cardDatabaseModel) { + cardDatabaseModel = new CardDatabaseModel(CardDatabaseManager::getInstance(), false, this); + } cardDatabaseDisplayModel = new CardDatabaseDisplayModel(this); cardDatabaseDisplayModel->setSourceModel(cardDatabaseModel); - CardSearchModel *searchModel = new CardSearchModel(cardDatabaseDisplayModel, this); - proxyModel = new CardCompleterProxyModel(this); - proxyModel->setSourceModel(searchModel); - proxyModel->setFilterCaseSensitivity(Qt::CaseInsensitive); - proxyModel->setFilterRole(Qt::DisplayRole); - - completer = new QCompleter(proxyModel, this); - completer->setCompletionRole(Qt::DisplayRole); - completer->setCompletionMode(QCompleter::PopupCompletion); - completer->setCaseSensitivity(Qt::CaseInsensitive); - completer->setFilterMode(Qt::MatchContains); - completer->setMaxVisibleItems(15); + const CardCompleterSetup cardSetup = createCardCompleter(cardDatabaseDisplayModel, this, 15); + proxyModel = cardSetup.proxyModel; + completer = cardSetup.completer; searchBar->setCompleter(completer); // Update suggestions dynamically - connect(searchBar, &QLineEdit::textEdited, searchModel, &CardSearchModel::updateSearchResults); - connect(searchBar, &QLineEdit::textEdited, this, [=, this](const QString &text) { - // Ensure substring matching - QString pattern = ".*" + QRegularExpression::escape(text) + ".*"; - proxyModel->setFilterRegularExpression(QRegularExpression(pattern, QRegularExpression::CaseInsensitiveOption)); - - if (!text.isEmpty()) { - completer->complete(); // Force the dropdown to appear - } - }); + connectCardCompleterSearch(searchBar, cardSetup); connect(completer, static_cast(&QCompleter::activated), this, [=, this](const QString &completion) { @@ -144,7 +132,7 @@ void VisualDeckEditorWidget::initializeSearchBarAndCompleter() // Search button functionality searchPushButton = new CompactPushButton(searchContainer); - searchPushButton->setButtonIcon(QPixmap("theme:icons/search")); + searchPushButton->setButtonIcon(themePixmap(QStringLiteral("icons/search"))); connect(searchPushButton, &QPushButton::clicked, this, [=, this]() { ExactCard card = CardDatabaseManager::query()->getCard({searchBar->text()}); if (card) { diff --git a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.h b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.h index da02b5c1f..ac0d07efd 100644 --- a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.h @@ -38,7 +38,10 @@ class VisualDeckEditorWidget : public QWidget Q_OBJECT public: - explicit VisualDeckEditorWidget(QWidget *parent, DeckListModel *deckListModel, QItemSelectionModel *selectionModel); + explicit VisualDeckEditorWidget(QWidget *parent, + DeckListModel *deckListModel, + QItemSelectionModel *selectionModel, + CardDatabaseModel *_cardDatabaseModel = nullptr); void retranslateUi(); void updateCompactMode(); void clearAllDisplayWidgets(); diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.cpp index 575632724..fd529ff69 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.cpp @@ -1,9 +1,9 @@ #include "deck_preview_color_identity_filter_widget.h" #include "../../cards/additional_info/mana_symbol_widget.h" -#include "deck_preview_widget.h" +#include "../visual_deck_storage_widget.h" -#include +#include DeckPreviewColorIdentityFilterWidget::DeckPreviewColorIdentityFilterWidget(VisualDeckStorageWidget *parent) : QWidget(parent), layout(new QHBoxLayout(this)) @@ -28,15 +28,10 @@ DeckPreviewColorIdentityFilterWidget::DeckPreviewColorIdentityFilterWidget(Visua } toggleButton = new QPushButton(this); - toggleButton->setCheckable(true); layout->addWidget(toggleButton); - // Connect the button's toggled signal - connect(toggleButton, &QPushButton::toggled, this, &DeckPreviewColorIdentityFilterWidget::updateFilterMode); - connect(this, &DeckPreviewColorIdentityFilterWidget::activeColorsChanged, parent, - &VisualDeckStorageWidget::updateColorFilter); - connect(this, &DeckPreviewColorIdentityFilterWidget::filterModeChanged, parent, - &VisualDeckStorageWidget::updateColorFilter); + // Connect the button's clicked signal + connect(toggleButton, &QPushButton::clicked, this, &DeckPreviewColorIdentityFilterWidget::updateFilterMode); // Call retranslateUi to set the initial text retranslateUi(); @@ -45,76 +40,55 @@ DeckPreviewColorIdentityFilterWidget::DeckPreviewColorIdentityFilterWidget(Visua void DeckPreviewColorIdentityFilterWidget::retranslateUi() { // Set the toggle button text based on the current mode - toggleButton->setText(exactMatchMode ? tr("Mode: Exact Match") : tr("Mode: Includes")); + switch (filterMode) { + case VisualDeckStorageSortFilterProxyModel::FilterMode::ExactMatch: + toggleButton->setText(tr("Mode: Exact Match")); + break; + case VisualDeckStorageSortFilterProxyModel::FilterMode::Includes: + toggleButton->setText(tr("Mode: Includes")); + break; + case VisualDeckStorageSortFilterProxyModel::FilterMode::Excludes: + toggleButton->setText(tr("Mode: Excludes")); + break; + } toggleButton->setToolTip(tr("Color identity filter mode (AND/OR/NOT conjunctions of filters)")); } +/** + * @brief The colors that are currently toggled on. + */ +QSet DeckPreviewColorIdentityFilterWidget::getActiveColors() const +{ + QSet activeColorSet; + for (auto it = activeColors.constBegin(); it != activeColors.constEnd(); ++it) { + if (it.value()) { + activeColorSet.insert(it.key()); + } + } + return activeColorSet; +} + void DeckPreviewColorIdentityFilterWidget::handleColorToggled(QChar color, bool active) { activeColors[color] = active; emit activeColorsChanged(); } -void DeckPreviewColorIdentityFilterWidget::updateFilterMode(bool checked) +void DeckPreviewColorIdentityFilterWidget::updateFilterMode() { - exactMatchMode = checked; // Toggle between modes - retranslateUi(); // Update the button text - emit filterModeChanged(exactMatchMode); -} - -void DeckPreviewColorIdentityFilterWidget::filterWidgets(QList widgets) -{ - // Check if no colors are active - bool noColorsActive = true; - for (auto it = activeColors.constBegin(); it != activeColors.constEnd(); ++it) { - if (it.value()) { - noColorsActive = false; + // Cycle through the modes + switch (filterMode) { + case VisualDeckStorageSortFilterProxyModel::FilterMode::ExactMatch: + filterMode = VisualDeckStorageSortFilterProxyModel::FilterMode::Includes; + break; + case VisualDeckStorageSortFilterProxyModel::FilterMode::Includes: + filterMode = VisualDeckStorageSortFilterProxyModel::FilterMode::Excludes; + break; + case VisualDeckStorageSortFilterProxyModel::FilterMode::Excludes: + filterMode = VisualDeckStorageSortFilterProxyModel::FilterMode::ExactMatch; break; - } } - // If no colors are active, return the unfiltered list of widgets - if (noColorsActive) { - for (DeckPreviewWidget *previewWidget : widgets) { - previewWidget->filteredByColor = false; - } - } - - for (const auto &widget : widgets) { - QString colorIdentity = widget->getColorIdentity(); - - bool matchesFilter = true; - if (exactMatchMode) { - // Exact match mode: active colors must exactly match colorIdentity - - // Create a set of active colors - QSet activeColorSet; - for (auto it = activeColors.constBegin(); it != activeColors.constEnd(); ++it) { - if (it.value()) { - activeColorSet.insert(it.key().toUpper()); // Use uppercase for uniformity - } - } - - // Create a set of colors from the color identity string - QSet colorIdentitySet; - for (const QChar &color : colorIdentity) { - colorIdentitySet.insert(color.toUpper()); // Ensure case uniformity - } - - // Compare the sets: the sets must match exactly - if (activeColorSet != colorIdentitySet) { - matchesFilter = false; - } - } else { - // Includes mode: colorIdentity must contain all active colors - for (auto it = activeColors.constBegin(); it != activeColors.constEnd(); ++it) { - if (it.value() && !colorIdentity.contains(it.key())) { - matchesFilter = false; - break; - } - } - } - - widget->filteredByColor = !matchesFilter; - } + retranslateUi(); // Update the button text + emit filterModeChanged(filterMode); } diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.h index 0207e2ee2..def45de66 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.h @@ -2,18 +2,18 @@ * @file deck_preview_color_identity_filter_widget.h * @ingroup VisualDeckPreviewWidgets */ -//! \todo Document this file. #ifndef DECK_PREVIEW_COLOR_IDENTITY_FILTER_WIDGET_H #define DECK_PREVIEW_COLOR_IDENTITY_FILTER_WIDGET_H -#include "../visual_deck_storage_widget.h" +#include "../visual_deck_storage_sort_filter_proxy_model.h" #include +#include #include +#include #include -class DeckPreviewWidget; class VisualDeckStorageWidget; class DeckPreviewColorIdentityFilterWidget : public QWidget @@ -23,21 +23,41 @@ class DeckPreviewColorIdentityFilterWidget : public QWidget public: explicit DeckPreviewColorIdentityFilterWidget(VisualDeckStorageWidget *parent); void retranslateUi(); - void filterWidgets(QList widgets); + + /** + * @brief The currently active color identity filter mode. + */ + [[nodiscard]] VisualDeckStorageSortFilterProxyModel::FilterMode getFilterMode() const + { + return filterMode; + } + + /** + * @brief The colors that are currently toggled on. + */ + [[nodiscard]] QSet getActiveColors() const; signals: - void filterModeChanged(bool exactMatchMode); + /** + * Emitted when the set of active colors changed due to user interaction. + */ void activeColorsChanged(); + /** + * Emitted when the user cycles the color identity filter mode. + * @param mode The new filter mode. + */ + void filterModeChanged(VisualDeckStorageSortFilterProxyModel::FilterMode mode); + private slots: void handleColorToggled(QChar color, bool active); - void updateFilterMode(bool checked); + void updateFilterMode(); private: QHBoxLayout *layout; QPushButton *toggleButton; QMap activeColors; - bool exactMatchMode = false; // Default to "includes" mode + VisualDeckStorageSortFilterProxyModel::FilterMode filterMode = VisualDeckStorageSortFilterProxyModel::Includes; }; #endif // DECK_PREVIEW_COLOR_IDENTITY_FILTER_WIDGET_H diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.cpp index 8cd1004de..41d87ccb1 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.cpp @@ -1,17 +1,15 @@ #include "deck_preview_deck_tags_display_widget.h" #include "../../../../client/settings/cache_settings.h" -#include "../../../../interface/widgets/dialogs/dlg_convert_deck_to_cod_format.h" -#include "../../../../interface/widgets/tabs/tab_deck_editor.h" +#include "../../../deck_loader/deck_loader.h" #include "../../general/layout_containers/flow_widget.h" #include "deck_preview_tag_addition_widget.h" #include "deck_preview_tag_dialog.h" #include "deck_preview_tag_display_widget.h" -#include "deck_preview_widget.h" #include #include -#include +#include DeckPreviewDeckTagsDisplayWidget::DeckPreviewDeckTagsDisplayWidget(QWidget *_parent, const QStringList &_tags) : QWidget(_parent), currentTags(_tags) @@ -52,6 +50,16 @@ void DeckPreviewDeckTagsDisplayWidget::refreshTags() flowWidget->addWidget(tagAdditionWidget); } +void DeckPreviewDeckTagsDisplayWidget::setKnownTagsProvider(const std::function &provider) +{ + knownTagsProvider = provider; +} + +void DeckPreviewDeckTagsDisplayWidget::setConversionPromptHandler(const std::function &handler) +{ + conversionPromptHandler = handler; +} + /** * Gets the filepath of all files (no directories) in target directory and all subdirectories */ @@ -74,7 +82,7 @@ static QStringList getAllFiles(const QString &filePath) */ static QStringList findAllKnownTags() { - QStringList allFiles = getAllFiles(SettingsCache::instance().getDeckPath()); + QStringList allFiles = getAllFiles(SettingsCache::instance().paths().getDeckPath()); QStringList knownTags; for (const QString &file : allFiles) { @@ -90,92 +98,13 @@ static QStringList findAllKnownTags() void DeckPreviewDeckTagsDisplayWidget::openTagEditDlg() { - if (qobject_cast(parentWidget())) { - // If we're the child of a DeckPreviewWidget, then we need to handle conversion - auto *deckPreviewWidget = qobject_cast(parentWidget()); - - bool canAddTags = promptFileConversionIfRequired(deckPreviewWidget); - - if (canAddTags) { - QStringList knownTags = deckPreviewWidget->visualDeckStorageWidget->tagFilterWidget->getAllKnownTags(); - execTagDialog(knownTags); - } - } else { - // If we're the child of an AbstractTabDeckEditor, then we don't bother with conversion - QStringList knownTags = findAllKnownTags(); - execTagDialog(knownTags); - } -} - -static bool confirmOverwriteIfExists(QWidget *parent, const QString &filePath) -{ - QFileInfo fileInfo(filePath); - QString newFileName = QDir::toNativeSeparators(fileInfo.path() + "/" + fileInfo.completeBaseName() + ".cod"); - - if (QFile::exists(newFileName)) { - QMessageBox::StandardButton reply = - QMessageBox::question(parent, QObject::tr("Overwrite Existing File?"), - QObject::tr("A .cod version of this deck already exists. Overwrite it?"), - QMessageBox::Yes | QMessageBox::No); - return reply == QMessageBox::Yes; - } - return true; // Safe to proceed -} - -static void convertFileToCockatriceFormat(DeckPreviewWidget *deckPreviewWidget) -{ - DeckLoader::convertToCockatriceFormat(deckPreviewWidget->deckLoader->getDeck()); - deckPreviewWidget->filePath = deckPreviewWidget->deckLoader->getDeck().lastLoadInfo.fileName; - deckPreviewWidget->refreshBannerCardText(); -} - -/** - * Checks if the deck's file format supports tags. - * If not, then prompt the user for file conversion. - * @return whether the resulting file can support adding tags - */ -bool DeckPreviewDeckTagsDisplayWidget::promptFileConversionIfRequired(DeckPreviewWidget *deckPreviewWidget) -{ - if (DeckFileFormat::getFormatFromName(deckPreviewWidget->filePath) == DeckFileFormat::Cockatrice) { - return true; + // The deck editor path has no conversion prompt; the VDS path registers one. + if (conversionPromptHandler && !conversionPromptHandler()) { + return; } - // Retrieve saved preference if the prompt is disabled - if (!SettingsCache::instance().getVisualDeckStoragePromptForConversion()) { - if (!SettingsCache::instance().getVisualDeckStorageAlwaysConvert()) { - return false; - } - - if (!confirmOverwriteIfExists(this, deckPreviewWidget->filePath)) { - return false; - } - - convertFileToCockatriceFormat(deckPreviewWidget); - return true; - } - - // Show the dialog to the user - DialogConvertDeckToCodFormat conversionDialog(parentWidget()); - if (conversionDialog.exec() != QDialog::Accepted) { - SettingsCache::instance().setVisualDeckStoragePromptForConversion(!conversionDialog.dontAskAgain()); - SettingsCache::instance().setVisualDeckStorageAlwaysConvert(false); - - return false; - } - - // Try to convert file - if (!confirmOverwriteIfExists(this, deckPreviewWidget->filePath)) { - return false; - } - - convertFileToCockatriceFormat(deckPreviewWidget); - - if (conversionDialog.dontAskAgain()) { - SettingsCache::instance().setVisualDeckStoragePromptForConversion(false); - SettingsCache::instance().setVisualDeckStorageAlwaysConvert(true); - } - - return true; + const QStringList knownTags = knownTagsProvider ? knownTagsProvider() : findAllKnownTags(); + execTagDialog(knownTags); } void DeckPreviewDeckTagsDisplayWidget::execTagDialog(const QStringList &knownTags) @@ -188,4 +117,4 @@ void DeckPreviewDeckTagsDisplayWidget::execTagDialog(const QStringList &knownTag emit tagsChanged(updatedTags); } } -} \ No newline at end of file +} diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.h index 4bd7915cd..64bd5aa1a 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.h @@ -2,41 +2,53 @@ * @file deck_preview_deck_tags_display_widget.h * @ingroup VisualDeckPreviewWidgets */ -//! \todo Document this file. #ifndef DECK_PREVIEW_DECK_TAGS_DISPLAY_WIDGET_H #define DECK_PREVIEW_DECK_TAGS_DISPLAY_WIDGET_H -#include "../../../deck_loader/deck_loader.h" -#include "deck_preview_widget.h" - +#include #include +#include + +class FlowWidget; -class DeckPreviewWidget; class DeckPreviewDeckTagsDisplayWidget : public QWidget { Q_OBJECT QStringList currentTags; FlowWidget *flowWidget; + std::function knownTagsProvider; + std::function conversionPromptHandler; public: explicit DeckPreviewDeckTagsDisplayWidget(QWidget *_parent, const QStringList &_tags = {}); void setTags(const QStringList &_tags); void refreshTags(); + /** + * @brief Sets a provider for the tags shown in the edit dialog. + * Defaults to scanning all deck files in the deck folder. + */ + void setKnownTagsProvider(const std::function &provider); + + /** + * @brief Sets a handler run before opening the tag dialog. Returning false + * cancels the dialog. Defaults to no handler (the deck editor path). + */ + void setConversionPromptHandler(const std::function &handler); + public slots: void openTagEditDlg(); -private: - bool promptFileConversionIfRequired(DeckPreviewWidget *deckPreviewWidget); - void execTagDialog(const QStringList &knownTags); - signals: /** * Emitted when the tags have changed due to user interaction. * @param tags The new list of tags. */ void tagsChanged(const QStringList &tags); + +private: + void execTagDialog(const QStringList &knownTags); }; #endif // DECK_PREVIEW_DECK_TAGS_DISPLAY_WIDGET_H diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_dialog.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_dialog.cpp index e8a399fe5..f27d11d36 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_dialog.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_dialog.cpp @@ -11,6 +11,7 @@ #include #include #include +#include DeckPreviewTagDialog::DeckPreviewTagDialog(const QStringList &knownTags, const QStringList &_activeTags, @@ -19,7 +20,7 @@ DeckPreviewTagDialog::DeckPreviewTagDialog(const QStringList &knownTags, { resize(400, 500); - QStringList defaultTags = SettingsCache::instance().getVisualDeckStorageDefaultTagsList(); + QStringList defaultTags = SettingsCache::instance().visualDeckStorage().getVisualDeckStorageDefaultTagsList(); // Merge knownTags with defaultTags, ensuring no duplicates QStringList combinedTags = defaultTags + knownTags + activeTags; @@ -90,7 +91,8 @@ DeckPreviewTagDialog::DeckPreviewTagDialog(const QStringList &knownTags, connect(okButton, &QPushButton::clicked, this, &DeckPreviewTagDialog::accept); connect(cancelButton, &QPushButton::clicked, this, &DeckPreviewTagDialog::reject); - connect(&SettingsCache::instance(), &SettingsCache::visualDeckStorageDefaultTagsListChanged, this, + connect(&SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::visualDeckStorageDefaultTagsListChanged, this, &DeckPreviewTagDialog::refreshTagList); retranslateUi(); @@ -114,7 +116,7 @@ void DeckPreviewTagDialog::refreshTagList() tagListView->clear(); // Get the updated list of tags from SettingsCache - QStringList defaultTags = SettingsCache::instance().getVisualDeckStorageDefaultTagsList(); + QStringList defaultTags = SettingsCache::instance().visualDeckStorage().getVisualDeckStorageDefaultTagsList(); QStringList combinedTags = defaultTags + knownTags_ + activeTags; combinedTags.removeDuplicates(); diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.cpp index 06dc9dd84..876fbf6ad 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.cpp @@ -1,57 +1,104 @@ #include "deck_preview_widget.h" #include "../../../../client/settings/cache_settings.h" +#include "../../../../interface/widgets/dialogs/dlg_convert_deck_to_cod_format.h" +#include "../../../deck_loader/deck_loader.h" #include "../../cards/additional_info/color_identity_widget.h" #include "../../cards/deck_preview_card_picture_widget.h" +#include "../visual_deck_storage_quick_settings_widget.h" +#include "../visual_deck_storage_tag_filter_widget.h" +#include "../visual_deck_storage_widget.h" #include "deck_preview_deck_tags_display_widget.h" -#include -#include #include #include +#include #include #include #include #include #include #include +#include #include +#include DeckPreviewWidget::DeckPreviewWidget(QWidget *_parent, VisualDeckStorageWidget *_visualDeckStorageWidget, + VisualDeckStorageModel *_model, const QString &_filePath) - : QWidget(_parent), visualDeckStorageWidget(_visualDeckStorageWidget), filePath(_filePath), - colorIdentityWidget(nullptr), deckTagsDisplayWidget(nullptr) + : QWidget(_parent), visualDeckStorageWidget(_visualDeckStorageWidget), model(_model), filePath(_filePath) { layout = new QVBoxLayout(this); setLayout(layout); - deckLoader = new DeckLoader(this); - connect(deckLoader, &DeckLoader::loadFinished, this, &DeckPreviewWidget::initializeUi); - //! \todo Batch tag refresh: count finished deck loads and refresh tags once all decks are loaded. - // Currently expensive: refreshes on each individual deck load instead of once at the end. - connect(deckLoader, &DeckLoader::loadFinished, visualDeckStorageWidget->tagFilterWidget, - &VisualDeckStorageTagFilterWidget::refreshTags); - deckLoader->loadFromFileAsync(filePath, DeckFileFormat::getFormatFromName(filePath), false); - - bannerCardDisplayWidget = + auto *pictureWidget = new DeckPreviewCardPictureWidget(this, false, visualDeckStorageWidget->deckPreviewSelectionAnimationEnabled); - - connect(bannerCardDisplayWidget, &DeckPreviewCardPictureWidget::imageClicked, this, - &DeckPreviewWidget::imageClickedEvent); - connect(bannerCardDisplayWidget, &DeckPreviewCardPictureWidget::imageDoubleClicked, this, + pictureWidget->setFontSize(24); + connect(pictureWidget, &DeckPreviewCardPictureWidget::imageClicked, this, &DeckPreviewWidget::imageClickedEvent); + connect(pictureWidget, &DeckPreviewCardPictureWidget::imageDoubleClicked, this, &DeckPreviewWidget::imageDoubleClickedEvent); + bannerCardDisplayWidget = pictureWidget; - connect(&SettingsCache::instance(), &SettingsCache::visualDeckStorageShowColorIdentityChanged, this, + colorIdentityWidget = new ColorIdentityWidget(this); + deckTagsDisplayWidget = new DeckPreviewDeckTagsDisplayWidget(this); + connect(deckTagsDisplayWidget, &DeckPreviewDeckTagsDisplayWidget::tagsChanged, this, &DeckPreviewWidget::setTags); + deckTagsDisplayWidget->setKnownTagsProvider( + [this] { return visualDeckStorageWidget->tagFilterWidget->getAllKnownTags(); }); + deckTagsDisplayWidget->setConversionPromptHandler([this] { return promptFileConversionIfRequired(); }); + + bannerCardLabel = new QLabel(this); + bannerCardLabel->setObjectName("bannerCardLabel"); + bannerCardComboBox = new QComboBox(this); + bannerCardComboBox->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum); + bannerCardComboBox->setObjectName("bannerCardComboBox"); + bannerCardComboBox->installEventFilter(new NoScrollFilter(bannerCardComboBox)); + connect(bannerCardComboBox, QOverload::of(&QComboBox::currentIndexChanged), this, + &DeckPreviewWidget::setBannerCard); + + // Apply the initial visibility settings and keep them in sync while they change. + updateColorIdentityVisibility( + SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowColorIdentity()); + updateBannerCardComboBoxVisibility( + SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowBannerCardComboBox()); + updateTagsVisibility(SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowTagsOnDeckPreviews()); + + connect(&SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::visualDeckStorageShowColorIdentityChanged, this, &DeckPreviewWidget::updateColorIdentityVisibility); - connect(&SettingsCache::instance(), &SettingsCache::visualDeckStorageShowTagsOnDeckPreviewsChanged, this, + connect(&SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::visualDeckStorageShowTagsOnDeckPreviewsChanged, this, &DeckPreviewWidget::updateTagsVisibility); - connect(&SettingsCache::instance(), &SettingsCache::visualDeckStorageShowBannerCardComboBoxChanged, this, + connect(&SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::visualDeckStorageShowBannerCardComboBoxChanged, this, &DeckPreviewWidget::updateBannerCardComboBoxVisibility); connect(visualDeckStorageWidget->settings(), &VisualDeckStorageQuickSettingsWidget::deckPreviewTooltipChanged, this, &DeckPreviewWidget::refreshBannerCardToolTip); layout->addWidget(bannerCardDisplayWidget); + layout->addWidget(colorIdentityWidget); + layout->addWidget(deckTagsDisplayWidget); + layout->addWidget(bannerCardLabel); + layout->addWidget(bannerCardComboBox); + + // Only re-sync when this widget's own row changed. Without the row check, every + // finished deck load would trigger a full resync (card db lookup + combo rebuild) + // in every preview widget. + connect(model, &QAbstractItemModel::dataChanged, this, + [this](const QModelIndex &topLeft, const QModelIndex &bottomRight) { + const int r = row(); + if (r >= topLeft.row() && r <= bottomRight.row()) { + syncFromModel(); + } + }); + + retranslateUi(); + syncFromModel(); + + // resizeEvent clamps every child to the picture's width, so collect them once here + // to keep the resize handler from searching the widget tree on every layout pass. + fixedWidthChildren = {bannerCardDisplayWidget, colorIdentityWidget, deckTagsDisplayWidget, bannerCardLabel, + bannerCardComboBox}; } void DeckPreviewWidget::retranslateUi() @@ -65,95 +112,44 @@ void DeckPreviewWidget::resizeEvent(QResizeEvent *event) if (bannerCardDisplayWidget == nullptr) { return; } - QList widgets = findChildren(); - for (QWidget *widget : widgets) { - widget->setMaximumWidth(bannerCardDisplayWidget->width()); + + const int width = bannerCardDisplayWidget->width(); + if (width == lastKnownBannerWidth) { + return; + } + lastKnownBannerWidth = width; + + for (QWidget *widget : fixedWidthChildren) { + widget->setMaximumWidth(width); } } -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) void DeckPreviewWidget::enterEvent(QEnterEvent *event) -#else -void DeckPreviewWidget::enterEvent(QEvent *event) -#endif { QWidget::enterEvent(event); - // don't do reloads until widgets have been created - if (bannerCardComboBox != nullptr) { - reloadIfModified(); + // Don't do reloads until the deck has actually been loaded once. + reloadIfModified(); +} + +/** + * @brief The row of this deck in the source model, or -1 if it no longer exists. + */ +int DeckPreviewWidget::row() const +{ + return model->rowForFilePath(filePath); +} + +/** + * @brief The display name is given by the deck name, or the filename if the deck name is not set. + */ +QString DeckPreviewWidget::getDisplayName() const +{ + const int r = row(); + if (r == -1) { + return {}; } -} - -/** - * @brief Sets the lastModifiedTime to the value given by the file. - */ -void DeckPreviewWidget::updateLastModifiedTime() -{ - QFileInfo fileInfo(filePath); - lastModifiedTime = fileInfo.lastModified(); -} - -/** - * @brief Writes the current contents of the deck to file. Updates the lastModifiedTime afterward. - */ -void DeckPreviewWidget::writeDeckToFile() -{ - DeckLoader::saveToFile(deckLoader->getDeck()); - updateLastModifiedTime(); -} - -void DeckPreviewWidget::initializeUi(const bool deckLoadSuccess) -{ - if (!deckLoadSuccess) { - return; - } - - QFileInfo fileInfo(filePath); - lastModifiedTime = fileInfo.lastModified(); - - bannerCardDisplayWidget->setFontSize(24); - setFilePath(deckLoader->getDeck().lastLoadInfo.fileName); - - colorIdentityWidget = new ColorIdentityWidget(this); - deckTagsDisplayWidget = new DeckPreviewDeckTagsDisplayWidget(this); - connect(deckTagsDisplayWidget, &DeckPreviewDeckTagsDisplayWidget::tagsChanged, this, &DeckPreviewWidget::setTags); - - bannerCardLabel = new QLabel(this); - bannerCardLabel->setObjectName("bannerCardLabel"); - bannerCardComboBox = new QComboBox(this); - bannerCardComboBox->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum); - bannerCardComboBox->setObjectName("bannerCardComboBox"); - bannerCardComboBox->installEventFilter(new NoScrollFilter(bannerCardComboBox)); - connect(bannerCardComboBox, QOverload::of(&QComboBox::currentIndexChanged), this, - &DeckPreviewWidget::setBannerCard); - - updateColorIdentityVisibility(SettingsCache::instance().getVisualDeckStorageShowColorIdentity()); - updateBannerCardComboBoxVisibility(SettingsCache::instance().getVisualDeckStorageShowBannerCardComboBox()); - updateTagsVisibility(SettingsCache::instance().getVisualDeckStorageShowTagsOnDeckPreviews()); - - layout->addWidget(colorIdentityWidget); - layout->addWidget(deckTagsDisplayWidget); - layout->addWidget(bannerCardLabel); - layout->addWidget(bannerCardComboBox); - - retranslateUi(); - resyncWidgets(); -} - -/** - * @brief Syncs the contents of the child widgets with the current deck. - */ -void DeckPreviewWidget::resyncWidgets() -{ - auto bannerCardRef = deckLoader->getDeck().deckList.getBannerCard(); - auto bannerCard = bannerCardRef.name.isEmpty() ? ExactCard() : CardDatabaseManager::query()->getCard(bannerCardRef); - - bannerCardDisplayWidget->setCard(bannerCard); - refreshBannerCardText(); - updateBannerCardComboBox(bannerCardRef.name); - colorIdentityWidget->setColorIdentity(getColorIdentity()); - deckTagsDisplayWidget->setTags(deckLoader->getDeck().deckList.getTags()); + return model->dataForRow(r).displayName; } /** @@ -161,33 +157,36 @@ void DeckPreviewWidget::resyncWidgets() */ void DeckPreviewWidget::reloadIfModified() { - QFileInfo fileInfo(filePath); - QDateTime newLastModifiedTime = fileInfo.lastModified(); - - if (!newLastModifiedTime.isValid() || newLastModifiedTime <= lastModifiedTime) { + const int r = row(); + if (r == -1 || !model->dataForRow(r).loadSucceeded) { return; } - bool success = deckLoader->reload(); - - if (success) { - fileInfo.refresh(); - lastModifiedTime = fileInfo.lastModified(); - resyncWidgets(); - } + model->reloadIfModified(r); } -void DeckPreviewWidget::updateVisibility() +/** + * @brief Syncs the contents of the child widgets with the current row's data. + */ +void DeckPreviewWidget::syncFromModel() { - setHidden(!checkVisibility()); -} - -bool DeckPreviewWidget::checkVisibility() const -{ - if (filteredBySearch || filteredByColor || filteredByTags) { - return false; + const int r = row(); + if (r == -1) { + return; } - return true; + + const DeckPreviewData &data = model->dataForRow(r); + filePath = data.filePath; + + const CardRef bannerCardRef = data.deck.deckList.getBannerCard(); + const ExactCard bannerCard = + bannerCardRef.name.isEmpty() ? ExactCard() : CardDatabaseManager::query()->getCard(bannerCardRef); + + bannerCardDisplayWidget->setCard(bannerCard); + refreshBannerCardText(); + updateBannerCardComboBox(bannerCardRef.name); + colorIdentityWidget->setColorIdentity(data.colorIdentity); + deckTagsDisplayWidget->setTags(data.tags); } void DeckPreviewWidget::updateColorIdentityVisibility(bool visible) @@ -227,51 +226,6 @@ void DeckPreviewWidget::updateTagsVisibility(bool visible) } } -QString DeckPreviewWidget::getColorIdentity() -{ - QStringList cardList = deckLoader->getDeck().deckList.getCardList({DECK_ZONE_MAIN, DECK_ZONE_SIDE}); - if (cardList.isEmpty()) { - return {}; - } - - QSet colorSet; // A set to collect unique color symbols (e.g., W, U, B, R, G) - - for (const QString &cardName : cardList) { - CardInfoPtr currentCard = CardDatabaseManager::query()->getCardInfo(cardName); - if (currentCard) { - QString colors = currentCard->getColors(); // Assuming this returns something like "WUB" - for (const QChar &color : colors) { - colorSet.insert(color); - } - } - } - - // Ensure the color identity is in WUBRG order - QString colorIdentity; - const QString wubrgOrder = "WUBRG"; - for (const QChar &color : wubrgOrder) { - if (colorSet.contains(color)) { - colorIdentity.append(color); - } - } - - return colorIdentity; -} - -/** - * The display name is given by the deck name, or the filename if the deck name is not set. - */ -QString DeckPreviewWidget::getDisplayName() const -{ - QString deckName = deckLoader->getDeck().deckList.getName(); - return !deckName.isEmpty() ? deckName : QFileInfo(deckLoader->getDeck().lastLoadInfo.fileName).fileName(); -} - -void DeckPreviewWidget::setFilePath(const QString &_filePath) -{ - filePath = _filePath; -} - /** * Refreshes the banner card text. * This also calls `refreshBannerCardToolTip`, since those two often need to be updated together. @@ -308,11 +262,15 @@ void DeckPreviewWidget::updateBannerCardComboBox(const QString ¤tText) // Prepare the new items with deduplication QSet> bannerCardSet; - QList cardsInDeck = deckLoader->getDeck().deckList.getCardNodes(); + const int r = row(); + if (r != -1) { + const DeckList &deckList = model->dataForRow(r).deck.deckList; + const QList cardsInDeck = deckList.getCardNodes(); - for (auto currentCard : cardsInDeck) { - for (int k = 0; k < currentCard->getNumber(); ++k) { - bannerCardSet.insert(QPair(currentCard->getName(), currentCard->getCardProviderId())); + for (auto currentCard : cardsInDeck) { + for (int k = 0; k < currentCard->getNumber(); ++k) { + bannerCardSet.insert(QPair(currentCard->getName(), currentCard->getCardProviderId())); + } } } @@ -325,16 +283,16 @@ void DeckPreviewWidget::updateBannerCardComboBox(const QString ¤tText) // This is *slightly* more performant than using addItem in a loop. - QStandardItemModel *model = new QStandardItemModel(pairList.size(), 1, bannerCardComboBox); + QStandardItemModel *comboModel = new QStandardItemModel(pairList.size(), 1, bannerCardComboBox); int row = 0; for (const auto &pair : pairList) { QStandardItem *item = new QStandardItem(pair.first); item->setData(QVariant::fromValue(pair), Qt::UserRole); - model->setItem(row++, 0, item); + comboModel->setItem(row++, 0, item); } - bannerCardComboBox->setModel(model); + bannerCardComboBox->setModel(comboModel); // Try to restore the previous selection by finding the currentText int restoredIndex = bannerCardComboBox->findText(currentText); @@ -342,7 +300,9 @@ void DeckPreviewWidget::updateBannerCardComboBox(const QString ¤tText) bannerCardComboBox->setCurrentIndex(restoredIndex); } else { // Add a placeholder "-" and set it as the current selection - int bannerIndex = bannerCardComboBox->findText(deckLoader->getDeck().deckList.getBannerCard().name); + const QString currentBannerCardName = + r == -1 ? QString() : model->dataForRow(r).deck.deckList.getBannerCard().name; + int bannerIndex = bannerCardComboBox->findText(currentBannerCardName); if (bannerIndex != -1) { bannerCardComboBox->setCurrentIndex(bannerIndex); } else { @@ -360,8 +320,11 @@ void DeckPreviewWidget::setBannerCard(int /* changedIndex */) { auto [name, id] = bannerCardComboBox->currentData().value>(); CardRef cardRef = {name, id}; - deckLoader->getDeck().deckList.setBannerCard(cardRef); - writeDeckToFile(); + const int r = row(); + if (r == -1) { + return; + } + model->setBannerCard(r, cardRef); bannerCardDisplayWidget->setCard(CardDatabaseManager::query()->getCard(cardRef)); } @@ -383,17 +346,24 @@ void DeckPreviewWidget::imageDoubleClickedEvent(QMouseEvent *event, DeckPreviewC void DeckPreviewWidget::setTags(const QStringList &tags) { - deckLoader->getDeck().deckList.setTags(tags); - writeDeckToFile(); + const int r = row(); + if (r != -1) { + model->setTags(r, tags); + } } QMenu *DeckPreviewWidget::createRightClickMenu() { + const int r = row(); + auto *menu = new QMenu(this); menu->setAttribute(Qt::WA_DeleteOnClose); - connect(menu->addAction(tr("Open in deck editor")), &QAction::triggered, this, - [this] { emit openDeckEditor(deckLoader->getDeck()); }); + connect(menu->addAction(tr("Open in deck editor")), &QAction::triggered, this, [this, r] { + if (r != -1) { + emit openDeckEditor(model->deckForRow(r)); + } + }); connect(menu->addAction(tr("Edit Tags")), &QAction::triggered, deckTagsDisplayWidget, &DeckPreviewDeckTagsDisplayWidget::openTagEditDlg); @@ -406,14 +376,26 @@ QMenu *DeckPreviewWidget::createRightClickMenu() auto saveToClipboardMenu = menu->addMenu(tr("Save Deck to Clipboard")); - connect(saveToClipboardMenu->addAction(tr("Annotated")), &QAction::triggered, this, - [this] { DeckLoader::saveToClipboard(deckLoader->getDeck().deckList, true, true); }); - connect(saveToClipboardMenu->addAction(tr("Annotated (No set info)")), &QAction::triggered, this, - [this] { DeckLoader::saveToClipboard(deckLoader->getDeck().deckList, true, false); }); - connect(saveToClipboardMenu->addAction(tr("Not Annotated")), &QAction::triggered, this, - [this] { DeckLoader::saveToClipboard(deckLoader->getDeck().deckList, false, true); }); - connect(saveToClipboardMenu->addAction(tr("Not Annotated (No set info)")), &QAction::triggered, this, - [this] { DeckLoader::saveToClipboard(deckLoader->getDeck().deckList, false, false); }); + connect(saveToClipboardMenu->addAction(tr("Annotated")), &QAction::triggered, this, [this, r] { + if (r != -1) { + DeckLoader::saveToClipboard(model->dataForRow(r).deck.deckList, true, true); + } + }); + connect(saveToClipboardMenu->addAction(tr("Annotated (No set info)")), &QAction::triggered, this, [this, r] { + if (r != -1) { + DeckLoader::saveToClipboard(model->dataForRow(r).deck.deckList, true, false); + } + }); + connect(saveToClipboardMenu->addAction(tr("Not Annotated")), &QAction::triggered, this, [this, r] { + if (r != -1) { + DeckLoader::saveToClipboard(model->dataForRow(r).deck.deckList, false, true); + } + }); + connect(saveToClipboardMenu->addAction(tr("Not Annotated (No set info)")), &QAction::triggered, this, [this, r] { + if (r != -1) { + DeckLoader::saveToClipboard(model->dataForRow(r).deck.deckList, false, false); + } + }); menu->addSeparator(); @@ -448,57 +430,58 @@ void DeckPreviewWidget::addSetBannerCardMenu(QMenu *menu) void DeckPreviewWidget::actRenameDeck() { + const int r = row(); + if (r == -1) { + return; + } + // read input - const QString oldName = deckLoader->getDeck().deckList.getName(); + const QString oldName = model->dataForRow(r).deckName; bool ok; - QString newName = QInputDialog::getText(this, "Rename deck", tr("New name:"), QLineEdit::Normal, oldName, &ok); + QString newName = QInputDialog::getText(this, tr("Rename deck"), tr("New name:"), QLineEdit::Normal, oldName, &ok); if (!ok || oldName == newName) { return; } // write change - deckLoader->getDeck().deckList.setName(newName); - writeDeckToFile(); + model->renameDeck(r, newName); - // update VDS - refreshBannerCardText(); + // The banner card text updates via the model's dataChanged signal. } void DeckPreviewWidget::actRenameFile() { + const int r = row(); + if (r == -1) { + return; + } + // read input const auto info = QFileInfo(filePath); const QString oldName = info.baseName(); bool ok; - QString newName = QInputDialog::getText(this, "Rename file", tr("New name:"), QLineEdit::Normal, oldName, &ok); + QString newName = QInputDialog::getText(this, tr("Rename file"), tr("New name:"), QLineEdit::Normal, oldName, &ok); if (!ok || newName.isEmpty() || oldName == newName) { return; } - QString newFileName = newName; - if (!info.suffix().isEmpty()) { - newFileName += "." + info.suffix(); - } - // write change - const QString newFilePath = QFileInfo(info.dir(), newFileName).filePath(); - if (!QFile::rename(info.filePath(), newFilePath)) { + if (!model->renameFile(r, newName)) { QMessageBox::critical(this, tr("Error"), tr("Rename failed")); - return; } - deckLoader->getDeck().lastLoadInfo.fileName = newFilePath; - setFilePath(newFilePath); - - // update VDS - updateLastModifiedTime(); - refreshBannerCardText(); + // The file path and banner card text update via the model's signals. } void DeckPreviewWidget::actDeleteFile() { + const int r = row(); + if (r == -1) { + return; + } + // read input auto res = QMessageBox::warning(this, tr("Delete file"), tr("Are you sure you want to delete the selected file?"), QMessageBox::Yes | QMessageBox::No); @@ -507,11 +490,22 @@ void DeckPreviewWidget::actDeleteFile() } // write change - if (!QFile::remove(QFileInfo(filePath).filePath())) { + if (!model->deleteFile(r)) { QMessageBox::critical(this, tr("Error"), tr("Delete failed")); - return; } - // update VDS - this->deleteLater(); + // The folder widget removes this preview once the row is gone. +} + +/** + * Checks if the deck's file format supports tags. + * If not, then prompt the user for file conversion. + * @return whether the resulting file can support adding tags + */ +bool DeckPreviewWidget::promptFileConversionIfRequired() +{ + return DialogConvertDeckToCodFormat::promptIfRequired(this, filePath, [this] { + model->convertToCockatriceFormat(row()); + return true; + }); } diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.h index 0ed64e9e2..7bb69f9b9 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.h @@ -2,16 +2,12 @@ * @file deck_preview_widget.h * @ingroup VisualDeckPreviewWidgets */ -//! \todo Document this file. #ifndef DECK_PREVIEW_WIDGET_H #define DECK_PREVIEW_WIDGET_H -#include "../../../deck_loader/deck_loader.h" -#include "../../cards/additional_info/color_identity_widget.h" #include "../../cards/deck_preview_card_picture_widget.h" -#include "../visual_deck_storage_widget.h" -#include "deck_preview_deck_tags_display_widget.h" +#include "../visual_deck_storage_model.h" #include #include @@ -20,76 +16,82 @@ #include #include +class QEnterEvent; +class QLabel; class QMenu; -class VisualDeckStorageWidget; +class QMouseEvent; +class ColorIdentityWidget; +class DeckPreviewCardPictureWidget; class DeckPreviewDeckTagsDisplayWidget; +class VisualDeckStorageModel; +class VisualDeckStorageWidget; class DeckPreviewWidget final : public QWidget { Q_OBJECT public: - explicit DeckPreviewWidget(QWidget *_parent, + explicit DeckPreviewWidget(QWidget *parent, VisualDeckStorageWidget *_visualDeckStorageWidget, + VisualDeckStorageModel *_model, const QString &_filePath); void retranslateUi(); - QString getColorIdentity(); - [[nodiscard]] QString getDisplayName() const; - VisualDeckStorageWidget *visualDeckStorageWidget; - QVBoxLayout *layout; - QString filePath; - QDateTime lastModifiedTime; - DeckLoader *deckLoader; - DeckPreviewCardPictureWidget *bannerCardDisplayWidget = nullptr; - ColorIdentityWidget *colorIdentityWidget = nullptr; - DeckPreviewDeckTagsDisplayWidget *deckTagsDisplayWidget = nullptr; - QLabel *bannerCardLabel = nullptr; - QComboBox *bannerCardComboBox = nullptr; - bool filteredBySearch = false; - bool filteredByColor = false; - bool filteredByTags = false; - [[nodiscard]] bool checkVisibility() const; + /** + * @brief The banner card picture; the parent widget wires its size to the card size setting. + */ + DeckPreviewCardPictureWidget *bannerCardDisplayWidget; signals: void deckLoadRequested(const QString &filePath); void openDeckEditor(const LoadedDeck &deck); public slots: - void setFilePath(const QString &filePath); - void refreshBannerCardText(); - void refreshBannerCardToolTip(); - void updateBannerCardComboBox(const QString ¤tText); - void setBannerCard(int); - void imageClickedEvent(QMouseEvent *event, DeckPreviewCardPictureWidget *instance); - void imageDoubleClickedEvent(QMouseEvent *event, DeckPreviewCardPictureWidget *instance); - void initializeUi(bool deckLoadSuccess); - void resyncWidgets(); + /** + * @brief Re-reads the row's data from the model and syncs every child widget. + * Connected to the model's dataChanged signal. + */ + void syncFromModel(); + + /** + * @brief Reloads the deck file if its modification time is newer than the stored one. + */ void reloadIfModified(); - void updateVisibility(); + void refreshBannerCardToolTip(); void updateColorIdentityVisibility(bool visible); void updateBannerCardComboBoxVisibility(bool visible); void updateTagsVisibility(bool visible); - void resizeEvent(QResizeEvent *event) override; + void setBannerCard(int); + void setTags(const QStringList &tags); protected: -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) - void enterEvent(QEnterEvent *event) override; // Qt6 signature -#else - void enterEvent(QEvent *event) override; // Qt5 signature -#endif + void enterEvent(QEnterEvent *event) override; + void resizeEvent(QResizeEvent *event) override; private: - void updateLastModifiedTime(); - void writeDeckToFile(); + [[nodiscard]] int row() const; + [[nodiscard]] QString getDisplayName() const; + void refreshBannerCardText(); + void updateBannerCardComboBox(const QString ¤tText); + bool promptFileConversionIfRequired(); QMenu *createRightClickMenu(); void addSetBannerCardMenu(QMenu *menu); - -private slots: - void setTags(const QStringList &tags); + void imageClickedEvent(QMouseEvent *event, DeckPreviewCardPictureWidget *instance); + void imageDoubleClickedEvent(QMouseEvent *event, DeckPreviewCardPictureWidget *instance); void actRenameDeck(); void actRenameFile(); void actDeleteFile(); + + VisualDeckStorageWidget *visualDeckStorageWidget; + VisualDeckStorageModel *model; + QString filePath; + QVBoxLayout *layout; + ColorIdentityWidget *colorIdentityWidget; + DeckPreviewDeckTagsDisplayWidget *deckTagsDisplayWidget; + QLabel *bannerCardLabel; + QComboBox *bannerCardComboBox; + QList fixedWidthChildren; ///< Children clamped to the picture width on resize. + int lastKnownBannerWidth = -1; ///< The picture width last applied to the children. }; class NoScrollFilter : public QObject diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.cpp index fdb44dff4..22d73b604 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.cpp @@ -1,19 +1,27 @@ #include "visual_deck_storage_folder_display_widget.h" -#include "../../../client/settings/cache_settings.h" +#include "../cards/card_info_picture_widget.h" +#include "../general/display/banner_widget.h" +#include "../general/layout_containers/flow_widget.h" #include "deck_preview/deck_preview_widget.h" +#include "visual_deck_storage_model.h" +#include "visual_deck_storage_quick_settings_widget.h" +#include "visual_deck_storage_sort_filter_proxy_model.h" #include "visual_deck_storage_widget.h" -#include -#include +#include +#include +#include +#include VisualDeckStorageFolderDisplayWidget::VisualDeckStorageFolderDisplayWidget( QWidget *parent, VisualDeckStorageWidget *_visualDeckStorageWidget, - QString _filePath, + const QString &_folderPath, bool canBeHidden, bool _showFolders) - : QWidget(parent), showFolders(_showFolders), visualDeckStorageWidget(_visualDeckStorageWidget), filePath(_filePath) + : QWidget(parent), showFolders(_showFolders), folderPath(_folderPath), + visualDeckStorageWidget(_visualDeckStorageWidget) { layout = new QVBoxLayout(this); setLayout(layout); @@ -21,6 +29,9 @@ VisualDeckStorageFolderDisplayWidget::VisualDeckStorageFolderDisplayWidget( header = new BannerWidget(this, ""); header->setClickable(canBeHidden); header->setHidden(!showFolders); + + const QString bannerText = folderPath.isEmpty() ? tr("Deck Storage") : folderPath; + header->setText(bannerText); layout->addWidget(header); container = new QWidget(this); @@ -34,192 +45,283 @@ VisualDeckStorageFolderDisplayWidget::VisualDeckStorageFolderDisplayWidget( flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAlwaysOff); containerLayout->addWidget(flowWidget); - createWidgetsForFiles(); - createWidgetsForFolders(); + auto *proxy = visualDeckStorageWidget->proxyModel(); + // A burst of proxy changes (one dataChanged per finished deck load, plus the filter + // invalidations) coalesces into a single reconcile, so a scan of many decks doesn't + // rebuild the flow layout once per deck. + connect(proxy, &QAbstractItemModel::modelReset, this, &VisualDeckStorageFolderDisplayWidget::scheduleReconcile); + connect(proxy, &QAbstractItemModel::rowsInserted, this, &VisualDeckStorageFolderDisplayWidget::scheduleReconcile); + connect(proxy, &QAbstractItemModel::rowsRemoved, this, &VisualDeckStorageFolderDisplayWidget::scheduleReconcile); + connect(proxy, &QAbstractItemModel::dataChanged, this, &VisualDeckStorageFolderDisplayWidget::scheduleReconcile); + connect(proxy, &QAbstractItemModel::layoutChanged, this, &VisualDeckStorageFolderDisplayWidget::scheduleReconcile); - refreshUi(); + reconcileTimer = new QTimer(this); + reconcileTimer->setSingleShot(true); + reconcileTimer->setInterval(150); + connect(reconcileTimer, &QTimer::timeout, this, &VisualDeckStorageFolderDisplayWidget::reconcile); + + // Building the whole folder subtree synchronously here would stall the ui thread on large + // collections, so the first reconcile runs as a chunked pass on later event loop turns. + scheduleReconcile(); } -void VisualDeckStorageFolderDisplayWidget::refreshUi() +void VisualDeckStorageFolderDisplayWidget::scheduleReconcile() { - QString bannerText = tr("Deck Storage"); - QString deckPath = SettingsCache::instance().getDeckPath(); - if (filePath != deckPath) { - QString relativePath = filePath; - - if (filePath.startsWith(deckPath)) { - relativePath = filePath.mid(deckPath.length()); // Remove the deckPath prefix - if (relativePath.startsWith('/')) { - relativePath.remove(0, 1); // Remove leading '/' if it exists - } - } - - bannerText = relativePath; + if (deckPassActive) { + // The active pass may be scanning stale model state, so restart it from a clean + // slate once the current chunk yields. + deckPassRestartRequested = true; + return; } - header->setText(bannerText); + reconcileTimer->start(); } /** - * Gets all files in the directory that have an accepted decklist file extension - * - * @param filePath The directory to search through - * @param recursive Whether to search through subdirectories + * @brief Starts a new chunked scan of the source model, yielding to the event loop between chunks. */ -static QStringList getAllFiles(const QString &filePath, bool recursive) +void VisualDeckStorageFolderDisplayWidget::reconcile() { - QStringList allFiles; - - // QDirIterator with QDir::Files ensures only files are listed (no directories) - auto flags = - recursive ? QDirIterator::Subdirectories | QDirIterator::FollowSymlinks : QDirIterator::NoIteratorFlags; - QDirIterator it(filePath, DeckLoader::ACCEPTED_FILE_EXTENSIONS, QDir::Files, flags); - - while (it.hasNext()) { - allFiles << it.next(); // Add each file path to the list - } - - return allFiles; + beginDeckPass(); } -void VisualDeckStorageFolderDisplayWidget::createWidgetsForFiles() +void VisualDeckStorageFolderDisplayWidget::beginDeckPass() { - QList allDecks; - for (const QString &file : getAllFiles(filePath, !showFolders)) { - auto *display = new DeckPreviewWidget(flowWidget, visualDeckStorageWidget, file); + deckPassActive = true; + deckPassRestartRequested = false; + deckPassRow = 0; + visibleDeckCount = 0; + deckPassPresentPaths.clear(); - connect(display, &DeckPreviewWidget::deckLoadRequested, visualDeckStorageWidget, - &VisualDeckStorageWidget::deckLoadRequested); - connect(display, &DeckPreviewWidget::openDeckEditor, visualDeckStorageWidget, - &VisualDeckStorageWidget::openDeckEditor); - connect(visualDeckStorageWidget->settings(), &VisualDeckStorageQuickSettingsWidget::cardSizeChanged, - display->bannerCardDisplayWidget, &CardInfoPictureWidget::setScaleFactor); - display->bannerCardDisplayWidget->setScaleFactor(visualDeckStorageWidget->settings()->getCardSize()); - allDecks.append(display); + continueDeckPass(); +} + +void VisualDeckStorageFolderDisplayWidget::continueDeckPass() +{ + if (!deckPassActive) { + return; } - flowWidget->clearLayout(); // Clear existing widgets in the flow layout + QElapsedTimer passTimer; + passTimer.start(); - for (DeckPreviewWidget *deck : allDecks) { - flowWidget->addWidget(deck); + auto *proxy = visualDeckStorageWidget->proxyModel(); + const int proxyRowCount = proxy->rowCount(); + + // Scan rows of this folder, creating missing previews, until the time budget for this + // event loop turn runs out. The rest continues on the next turn. + while (deckPassRow < proxyRowCount) { + const int row = deckPassRow++; + const QModelIndex index = proxy->index(row, 0); + if (showFolders && index.data(VisualDeckStorageRoles::FolderPathRole).toString() != folderPath) { + continue; + } + const QString filePath = index.data(VisualDeckStorageRoles::FilePathRole).toString(); + deckPassPresentPaths.insert(filePath); + + DeckPreviewWidget *deckPreviewWidget = deckWidgets.value(filePath, nullptr); + if (!deckPreviewWidget) { + deckPreviewWidget = createDeckPreviewWidget(filePath); + flowWidget->addWidget(deckPreviewWidget); + } + + const bool matches = index.data(VisualDeckStorageRoles::FilterMatchRole).toBool(); + deckPreviewWidget->setVisible(matches); + if (matches) { + ++visibleDeckCount; + } + + if (passTimer.elapsed() >= DECK_PASS_TIME_BUDGET_MS) { + break; + } } + + if (deckPassRestartRequested) { + beginDeckPass(); + return; + } + + if (deckPassRow < proxyRowCount) { + QMetaObject::invokeMethod(this, &VisualDeckStorageFolderDisplayWidget::continueDeckPass, Qt::QueuedConnection); + return; + } + + finishDeckPass(); +} + +void VisualDeckStorageFolderDisplayWidget::finishDeckPass() +{ + auto *proxy = visualDeckStorageWidget->proxyModel(); + + // Drop previews of decks that no longer exist in the model. + for (auto it = deckWidgets.begin(); it != deckWidgets.end();) { + if (!deckPassPresentPaths.contains(it.key())) { + flowWidget->removeWidget(it.value()); + it.value()->deleteLater(); + it = deckWidgets.erase(it); + } else { + ++it; + } + } + + // Order the flow layout like the proxy sorts its rows. Filtered-out decks stay part of + // the layout, hidden in their sorted place until a filter lets them through again. + QStringList orderedFilePaths; + orderedFilePaths.reserve(proxy->rowCount()); + for (int proxyRow = 0; proxyRow < proxy->rowCount(); ++proxyRow) { + const QString filePath = proxy->index(proxyRow, 0).data(VisualDeckStorageRoles::FilePathRole).toString(); + if (deckWidgets.contains(filePath)) { + orderedFilePaths.append(filePath); + } + } + + // Re-add all widgets so the flow layout order matches the proxy order. Skipped when the + // order is unchanged so that data-only updates don't invalidate the flow layout. + if (orderedFilePaths != lastOrderedFilePaths) { + for (const QString &filePath : orderedFilePaths) { + flowWidget->removeWidget(deckWidgets.value(filePath)); + } + for (const QString &filePath : orderedFilePaths) { + flowWidget->addWidget(deckWidgets.value(filePath)); + } + lastOrderedFilePaths = orderedFilePaths; + } + + createSubFolderWidgets(); + + // Mark completion before evaluating visibility so this pass's own numbers decide whether + // the folder has content. The flag only guards evaluations made *during* a build. + deckPassActive = false; + initialPassCompleted = true; + + refreshVisibility(); } /** - * Updates the visibility of this folder and all its DeckPreviewWidgets + * @brief Creates a deck preview widget and wires it up to the storage widget. * - * @param recursive Also update the visibility of all subfolders and their DeckPreviewWidgets. + * @param filePath The absolute path of the deck file to preview. */ -void VisualDeckStorageFolderDisplayWidget::updateVisibility(bool recursive) +DeckPreviewWidget *VisualDeckStorageFolderDisplayWidget::createDeckPreviewWidget(const QString &filePath) { - bool atLeastOneWidgetVisible = checkVisibility(); - if (atLeastOneWidgetVisible) { - setVisible(true); - for (DeckPreviewWidget *display : flowWidget->findChildren()) { - display->updateVisibility(); - } - if (recursive) { - for (auto *subFolder : findChildren()) { - subFolder->updateVisibility(false); - } - } - } else { - setVisible(false); - } + auto *deckPreviewWidget = + new DeckPreviewWidget(flowWidget, visualDeckStorageWidget, visualDeckStorageWidget->model(), filePath); + connect(deckPreviewWidget, &DeckPreviewWidget::deckLoadRequested, visualDeckStorageWidget, + &VisualDeckStorageWidget::deckLoadRequested); + connect(deckPreviewWidget, &DeckPreviewWidget::openDeckEditor, visualDeckStorageWidget, + &VisualDeckStorageWidget::openDeckEditor); + connect(visualDeckStorageWidget->settings(), &VisualDeckStorageQuickSettingsWidget::cardSizeChanged, + deckPreviewWidget->bannerCardDisplayWidget, &CardInfoPictureWidget::setScaleFactor); + deckPreviewWidget->bannerCardDisplayWidget->setScaleFactor(visualDeckStorageWidget->settings()->getCardSize()); + deckWidgets.insert(filePath, deckPreviewWidget); + return deckPreviewWidget; } -bool VisualDeckStorageFolderDisplayWidget::checkVisibility() -{ - bool atLeastOneWidgetVisible = false; - if (flowWidget) { - // Iterate through all DeckPreviewWidgets - for (DeckPreviewWidget *display : flowWidget->findChildren()) { - if (display->checkVisibility()) { - atLeastOneWidgetVisible = true; - } - } - } - for (VisualDeckStorageFolderDisplayWidget *subFolder : findChildren()) { - if (subFolder->checkVisibility()) { - atLeastOneWidgetVisible = true; - } - } - return atLeastOneWidgetVisible; -} - -static QStringList getAllSubFolders(const QString &filePath) -{ - QStringList allFolders; - - // QDirIterator with QDir::Files ensures only files are listed (no directories) - QDirIterator it(filePath, QDir::Dirs | QDir::NoDotAndDotDot); - - while (it.hasNext()) { - allFolders << it.next(); // Add each file path to the list - } - - return allFolders; -} - -void VisualDeckStorageFolderDisplayWidget::createWidgetsForFolders() +/** + * @brief Creates, removes and keeps in sync the subfolder widgets of this folder. + * + * Only direct child folders are created here; each child manages its own + * children, mirroring the folder tree on disk. + */ +void VisualDeckStorageFolderDisplayWidget::createSubFolderWidgets() { if (!showFolders) { return; } - for (const QString &dir : getAllSubFolders(filePath)) { - auto *display = new VisualDeckStorageFolderDisplayWidget(this, visualDeckStorageWidget, dir, true, showFolders); - containerLayout->addWidget(display); + const QStringList children = childFolderPaths(); + + for (auto it = subFolderWidgets.begin(); it != subFolderWidgets.end();) { + if (!children.contains(it.key())) { + containerLayout->removeWidget(it.value()); + it.value()->deleteLater(); + it = subFolderWidgets.erase(it); + } else { + ++it; + } + } + + for (const QString &child : children) { + if (subFolderWidgets.contains(child)) { + continue; + } + + auto *subFolderWidget = + new VisualDeckStorageFolderDisplayWidget(this, visualDeckStorageWidget, child, true, showFolders); + connect(subFolderWidget, &VisualDeckStorageFolderDisplayWidget::contentVisibilityChanged, this, + &VisualDeckStorageFolderDisplayWidget::refreshVisibility); + containerLayout->addWidget(subFolderWidget); + subFolderWidgets.insert(child, subFolderWidget); } } void VisualDeckStorageFolderDisplayWidget::updateShowFolders(bool enabled) { showFolders = enabled; + header->setHidden(!showFolders); if (!showFolders) { - flattenFolderStructure(); - } else { - // if setting was switched from disabled to enabled, we assume that there aren't any existing subfolders - createWidgetsForFiles(); - createWidgetsForFolders(); + for (auto it = subFolderWidgets.begin(); it != subFolderWidgets.end(); ++it) { + containerLayout->removeWidget(it.value()); + it.value()->deleteLater(); + } + subFolderWidgets.clear(); } - header->setHidden(!showFolders); + scheduleReconcile(); } /** - * Steals all DeckPreviewWidgets from this widget's nested subfolders, and deletes those subfolders + * @brief Hides the folder when it contains nothing visible, and reports the change upward. */ -void VisualDeckStorageFolderDisplayWidget::flattenFolderStructure() +void VisualDeckStorageFolderDisplayWidget::refreshVisibility() { - for (auto *subFolder : findChildren()) { - // steal all DeckPreviewWidgets from the subfolder - for (auto *deck : subFolder->getFlowWidget()->findChildren()) { - flowWidget->addWidget(deck); - } - - // delete the subfolder - subFolder->deleteLater(); + const bool shouldBeVisible = hasContent(); + if (isHidden() == !shouldBeVisible) { + return; } + setHidden(!shouldBeVisible); + emit contentVisibilityChanged(); } -QStringList VisualDeckStorageFolderDisplayWidget::gatherAllTagsFromFlowWidget() const +/** + * @brief Whether this folder shows any deck previews or has any visible subfolder. + * + * While the first pass is still building, the folder counts as having content so it + * doesn't flicker or hide prematurely before its previews have been created. + */ +bool VisualDeckStorageFolderDisplayWidget::hasContent() const { - QStringList allTags; + if (!initialPassCompleted || visibleDeckCount > 0) { + return true; + } - if (flowWidget) { - // Iterate through all DeckPreviewWidgets - for (DeckPreviewWidget *display : flowWidget->findChildren()) { - // Get tags from each DeckPreviewWidget - QStringList tags = display->deckLoader->getDeck().deckList.getTags(); - - // Add tags to the list while avoiding duplicates - allTags.append(tags); + for (VisualDeckStorageFolderDisplayWidget *subFolderWidget : subFolderWidgets) { + if (subFolderWidget->hasContent()) { + return true; } } - // Remove duplicates by calling 'removeDuplicates' - allTags.removeDuplicates(); + return false; +} - return allTags; -} \ No newline at end of file +/** + * @brief The direct child folder paths of this folder, sorted by name. + */ +QStringList VisualDeckStorageFolderDisplayWidget::childFolderPaths() const +{ + QStringList children; + const QString prefix = folderPath.isEmpty() ? QString() : folderPath + "/"; + + for (const QString &candidate : visualDeckStorageWidget->model()->getFolderPaths()) { + if (!candidate.startsWith(prefix)) { + continue; + } + const QString rest = candidate.mid(prefix.length()); + if (rest.isEmpty() || rest.contains('/')) { + continue; + } + children.append(candidate); + } + + return children; +} diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.h index a5e3be212..257ce1778 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.h @@ -1,49 +1,105 @@ /** * @file visual_deck_storage_folder_display_widget.h * @ingroup VisualDeckStorageWidgets + * @brief Renders the decks of one folder of the Visual Deck Storage. + * + * This is a pure view: it keeps one persistent DeckPreviewWidget alive per deck + * in its folder, and shows or hides those widgets according to each row's + * FilterMatchRole in the VisualDeckStorageSortFilterProxyModel. Subfolders are + * shown as nested VisualDeckStorageFolderDisplayWidgets when the "show folders" + * setting is enabled. + * + * Reconciling runs as a time-budgeted chunked pass that yields to the event loop + * between chunks, so scanning a large collection never stalls the ui thread. */ -//! \todo Document this file. #ifndef VISUAL_DECK_STORAGE_FOLDER_DISPLAY_WIDGET_H #define VISUAL_DECK_STORAGE_FOLDER_DISPLAY_WIDGET_H -#include "../general/display/banner_widget.h" -#include "../general/layout_containers/flow_widget.h" +#include +#include +#include +#include +class BannerWidget; +class DeckPreviewWidget; +class FlowWidget; +class QTimer; +class QVBoxLayout; class VisualDeckStorageWidget; + class VisualDeckStorageFolderDisplayWidget : public QWidget { Q_OBJECT public: VisualDeckStorageFolderDisplayWidget(QWidget *parent, - VisualDeckStorageWidget *_visualDeckStorageWidget, - QString _filePath, + VisualDeckStorageWidget *visualDeckStorageWidget, + const QString &folderPath, bool canBeHidden, - bool _showFolders); - void refreshUi(); - void createWidgetsForFiles(); - void createWidgetsForFolders(); - void flattenFolderStructure(); - [[nodiscard]] QStringList gatherAllTagsFromFlowWidget() const; - [[nodiscard]] FlowWidget *getFlowWidget() const - { - return flowWidget; - } + bool showFolders); public slots: - void updateVisibility(bool recursive = true); - bool checkVisibility(); + /** + * @brief Starts a new chunked reconcile pass that re-reads the proxy and rebuilds + * the deck previews and subfolder widgets to match. + */ + void reconcile(); + + /** + * @brief Coalesces proxy change signals (a burst of deck loads or filter + * invalidations) into a single reconcile on the next event loop turn. + */ + void scheduleReconcile(); void updateShowFolders(bool enabled); +signals: + /** + * @brief Emitted whenever this folder's visible content changes, so parent + * folders can re-evaluate their own visibility. + */ + void contentVisibilityChanged(); + private: + void beginDeckPass(); + void continueDeckPass(); + void finishDeckPass(); + [[nodiscard]] DeckPreviewWidget *createDeckPreviewWidget(const QString &filePath); + void createSubFolderWidgets(); + void refreshVisibility(); + [[nodiscard]] bool hasContent() const; + [[nodiscard]] QStringList childFolderPaths() const; + + /** + * @brief The maximum time in milliseconds spent creating deck previews per event loop turn. + * + * Creating all previews of a large folder at once blocks the ui thread for hundreds of + * milliseconds, so the pass is split into chunks that yield to the event loop instead. + */ + static constexpr int DECK_PASS_TIME_BUDGET_MS = 20; + bool showFolders; + QString folderPath; ///< Path relative to the deck folder, empty for the root folder. + int visibleDeckCount = 0; ///< The number of this folder's deck previews not filtered out. QVBoxLayout *layout; - VisualDeckStorageWidget *visualDeckStorageWidget; - QString filePath; - BannerWidget *header; QWidget *container; QVBoxLayout *containerLayout; FlowWidget *flowWidget; + BannerWidget *header; + VisualDeckStorageWidget *visualDeckStorageWidget; + QHash deckWidgets; ///< Deck file path -> preview widget. + QHash subFolderWidgets; ///< Folder path -> subfolder widget. + QTimer *reconcileTimer = nullptr; ///< Coalesces proxy change bursts. + QStringList lastOrderedFilePaths; ///< The deck order last applied to the flow layout. + + /// Whether a chunked reconcile pass is currently running. + bool deckPassActive = false; + /// Set when the model changes mid-pass. Discards progress and restarts the scan once + /// the current chunk finishes so the pass always converges on the latest model state. + bool deckPassRestartRequested = false; + /// Whether the first reconcile pass has run to completion at least once. + bool initialPassCompleted = false; + int deckPassRow = 0; ///< Next proxy row to scan in the active pass. + QSet deckPassPresentPaths; ///< File paths seen so far in the active pass. }; #endif // VISUAL_DECK_STORAGE_FOLDER_DISPLAY_WIDGET_H diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.cpp new file mode 100644 index 000000000..5d0006539 --- /dev/null +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.cpp @@ -0,0 +1,595 @@ +#include "visual_deck_storage_model.h" + +#include "../../deck_loader/deck_loader.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +/** + * @brief The result of a background directory scan: the deck rows in scan order + * plus the sorted list of subfolder paths. + */ +struct DeckScanResult +{ + QList decks; ///< Deck rows in scan order. + QStringList folderPaths; ///< Sorted list of subfolder paths. +}; + +/** + * @brief The result of a background deck file load: the parsed deck plus the + * file's modification time, so the disk stat happens off the UI thread. + */ +struct DeckLoadResult +{ + LoadedDeck deck; ///< The parsed deck. + QDateTime lastModified; ///< File modification time at load. + QString colorIdentity; ///< WUBRG color identity, computed off the UI thread. +}; + +/** + * @brief How long per event loop turn the pending-load drain applies finished loads. + * + * Applying a load updates the row and wakes up the proxies and views, which is + * not free. A time budget instead of a fixed count lets fast machines apply + * many loads in one turn while keeping the ui thread responsive everywhere. + */ +constexpr int LOAD_DRAIN_TIME_BUDGET_MS = 8; + +/** + * @brief The path of \a path relative to the deck root, or empty if \a path + * is not below it. + */ +QString relativePathFromDeckRoot(const QString &path, const QString &deckPath) +{ + if (!path.startsWith(deckPath)) { + return {}; + } + QString relativePath = path.mid(deckPath.length()); + if (relativePath.startsWith('/')) { + relativePath.remove(0, 1); + } + return relativePath; +} + +/** + * @brief The path of \a filePath relative to \a deckPath, or the bare file name + * if \a filePath is not below \a deckPath. + */ +QString relativeFilePathFor(const QString &filePath, const QString &deckPath) +{ + if (filePath.startsWith(deckPath)) { + return filePath.mid(deckPath.length()); + } + + return QFileInfo(filePath).fileName(); +} + +/** + * @brief The directory of \a filePath relative to \a deckPath, or empty if the + * file sits directly in the deck root. + */ +QString folderPathFor(const QString &filePath, const QString &deckPath) +{ + return relativePathFromDeckRoot(QFileInfo(filePath).absolutePath(), deckPath); +} + +/** + * @brief Scans a deck directory on a worker thread, returning discovered deck + * files and subfolder paths. + */ +DeckScanResult scanDeckDirectory(const QString &deckPath) +{ + DeckScanResult result; + + QDirIterator fileIt(deckPath, DeckLoader::ACCEPTED_FILE_EXTENSIONS, QDir::Files, + QDirIterator::Subdirectories | QDirIterator::FollowSymlinks); + while (fileIt.hasNext()) { + const QString filePath = fileIt.next(); + DeckPreviewData data; + data.filePath = filePath; + data.relativeFilePath = relativeFilePathFor(filePath, deckPath); + data.folderPath = folderPathFor(filePath, deckPath); + data.lastModified = QFileInfo(filePath).lastModified(); + result.decks.append(std::move(data)); + } + + QSet seenFolders; + QDirIterator folderIt(deckPath, QDir::Dirs | QDir::NoDotAndDotDot, + QDirIterator::Subdirectories | QDirIterator::FollowSymlinks); + while (folderIt.hasNext()) { + const QString folderPath = relativePathFromDeckRoot(folderIt.next(), deckPath); + if (!folderPath.isEmpty() && !seenFolders.contains(folderPath)) { + seenFolders.insert(folderPath); + result.folderPaths.append(folderPath); + } + } + result.folderPaths.sort(); + + return result; +} +} // namespace + +static QString computeColorIdentity(const LoadedDeck &deck); + +VisualDeckStorageModel::VisualDeckStorageModel(QObject *parent) : QAbstractListModel(parent) +{ +} + +int VisualDeckStorageModel::rowCount(const QModelIndex &parent) const +{ + return parent.isValid() ? 0 : decks.size(); +} + +QVariant VisualDeckStorageModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= decks.size()) { + return {}; + } + + const DeckPreviewData &data = decks.at(index.row()); + switch (role) { + case Qt::DisplayRole: + case VisualDeckStorageRoles::DisplayNameRole: + return data.displayName; + case VisualDeckStorageRoles::FilePathRole: + return data.filePath; + case VisualDeckStorageRoles::RelativeFilePathRole: + return data.relativeFilePath; + case VisualDeckStorageRoles::FolderPathRole: + return data.folderPath; + case VisualDeckStorageRoles::TagsRole: + return data.tags; + case VisualDeckStorageRoles::ColorIdentityRole: + return data.colorIdentity; + case VisualDeckStorageRoles::LastModifiedRole: + return data.lastModified; + case VisualDeckStorageRoles::LastLoadedRole: + return data.lastLoaded; + case VisualDeckStorageRoles::BannerCardNameRole: + return data.bannerCard.name; + case VisualDeckStorageRoles::BannerCardProviderIdRole: + return data.bannerCard.providerId; + default: + return {}; + } +} + +void VisualDeckStorageModel::setDeckPath(const QString &path) +{ + QString cleanedPath = QDir::cleanPath(path); + if (cleanedPath == ".") { + cleanedPath.clear(); + } + deckPath = cleanedPath; + startScan(); +} + +void VisualDeckStorageModel::refresh() +{ + startScan(); +} + +const DeckPreviewData &VisualDeckStorageModel::dataForRow(int row) const +{ + static const DeckPreviewData emptyData; + if (row < 0 || row >= decks.size()) { + return emptyData; + } + return decks.at(row); +} + +const LoadedDeck &VisualDeckStorageModel::deckForRow(int row) const +{ + return dataForRow(row).deck; +} + +int VisualDeckStorageModel::rowForFilePath(const QString &filePath) const +{ + return rowByFilePath.value(filePath, -1); +} + +/** + * @brief Rebuilds the file path -> row index from scratch after bulk changes. + */ +void VisualDeckStorageModel::reindexFilePaths() +{ + rowByFilePath.clear(); + for (int row = 0; row < decks.size(); ++row) { + const QString &filePath = decks.at(row).filePath; + // First row wins, mirroring what a linear scan would return for duplicates. + if (!rowByFilePath.contains(filePath)) { + rowByFilePath.insert(filePath, row); + } + } +} + +void VisualDeckStorageModel::startScan() +{ + ++scanGeneration; + beginResetModel(); + decks.clear(); + rowByFilePath.clear(); + folderPaths.clear(); + pendingLoads.clear(); + endResetModel(); + + if (deckPath.isEmpty()) { + return; + } + + const QString currentDeckPath = deckPath; + const int generation = scanGeneration; + + // The scan (directory walk + one stat per file) runs on a worker thread so that + // constructing the widget never stalls the UI thread on a large deck folder. + auto *watcher = new QFutureWatcher(this); + connect(watcher, &QFutureWatcher::finished, this, [this, watcher, generation] { + watcher->deleteLater(); + + if (generation != scanGeneration) { + return; // A newer scan started while this one was running; drop the stale result. + } + + const DeckScanResult result = watcher->result(); + folderPaths = result.folderPaths; + + if (result.decks.isEmpty()) { + return; + } + + beginInsertRows(QModelIndex(), 0, result.decks.size() - 1); + decks = result.decks; + reindexFilePaths(); + endInsertRows(); + + for (int row = 0; row < decks.size(); ++row) { + beginLoad(row); + } + }); + + watcher->setFuture( + QtConcurrent::run([currentDeckPath]() -> DeckScanResult { return scanDeckDirectory(currentDeckPath); })); +} + +void VisualDeckStorageModel::beginLoad(int row) +{ + if (row < 0 || row >= decks.size() || decks.at(row).loadInProgress) { + return; + } + + DeckPreviewData &data = decks[row]; + data.loadInProgress = true; + + const QString filePath = data.filePath; + const DeckFileFormat::Format fmt = DeckFileFormat::getFormatFromName(filePath); + const int generation = scanGeneration; + + auto *watcher = new QFutureWatcher>(this); + connect(watcher, &QFutureWatcher>::finished, this, + [this, watcher, filePath, generation] { + watcher->deleteLater(); + + if (generation != scanGeneration) { + return; // The deck list was re-scanned while this load was running; drop the stale result. + } + + // Queue the result and apply a bounded number per event loop turn so that + // finishing hundreds of loads at once cannot stall the UI thread. + const std::optional result = watcher->result(); + PendingDeckLoad pending; + pending.filePath = filePath; + pending.generation = generation; + if (result) { + pending.ok = true; + pending.deck = std::move(result->deck); + pending.lastModified = result->lastModified; + pending.colorIdentity = std::move(result->colorIdentity); + } + pendingLoads.append(std::move(pending)); + schedulePendingLoadDrain(); + }); + + watcher->setFuture(QtConcurrent::run([filePath, fmt]() -> std::optional { + std::optional deck = DeckLoader::loadFromFile(filePath, fmt, false); + if (!deck) { + return std::nullopt; + } + // Color identity walks every card through the database, so compute it here to + // keep the completion handler on the UI thread cheap. + const QString colorIdentity = computeColorIdentity(*deck); + return DeckLoadResult{std::move(*deck), QFileInfo(filePath).lastModified(), colorIdentity}; + })); +} + +void VisualDeckStorageModel::schedulePendingLoadDrain() +{ + if (drainScheduled) { + return; + } + drainScheduled = true; + QMetaObject::invokeMethod(this, &VisualDeckStorageModel::drainPendingLoads, Qt::QueuedConnection); +} + +void VisualDeckStorageModel::drainPendingLoads() +{ + drainScheduled = false; + + QElapsedTimer timer; + timer.start(); + + while (!pendingLoads.isEmpty()) { + PendingDeckLoad pending = pendingLoads.takeFirst(); + + if (pending.generation != scanGeneration) { + continue; + } + + const int row = rowForFilePath(pending.filePath); + if (row == -1) { + continue; + } + + DeckPreviewData &data = decks[row]; + data.loadInProgress = false; + + if (!pending.ok) { + continue; // Leave the row unloaded so it stays visible without deck data. + } + + data.deck = std::move(pending.deck); + data.loadSucceeded = true; + data.lastModified = pending.lastModified; + recomputeDeckMetadata(data, false); + data.colorIdentity = std::move(pending.colorIdentity); + + emit dataChanged(index(row), index(row)); + emit deckLoaded(row); + + // Checked after applying at least one load, so a single slow application + // still makes progress instead of starving the queue. + if (timer.elapsed() >= LOAD_DRAIN_TIME_BUDGET_MS) { + break; + } + } + + if (!pendingLoads.isEmpty()) { + schedulePendingLoadDrain(); + } +} + +/** + * @brief Computes the color identity of a deck in WUBRG order. + */ +static QString computeColorIdentity(const LoadedDeck &deck) +{ + QStringList cardList = deck.deckList.getCardList({DECK_ZONE_MAIN, DECK_ZONE_SIDE}); + if (cardList.isEmpty()) { + return {}; + } + + QSet colorSet; // A set to collect unique color symbols (e.g., W, U, B, R, G) + + for (const QString &cardName : cardList) { + CardInfoPtr currentCard = CardDatabaseManager::query()->getCardInfo(cardName); + if (currentCard) { + const QString colors = currentCard->getColors(); // Something like "WUB" + for (const QChar &color : colors) { + colorSet.insert(color); + } + } + } + + // Ensure the color identity is in WUBRG order + QString colorIdentity; + const QString wubrgOrder = "WUBRG"; + for (const QChar &color : wubrgOrder) { + if (colorSet.contains(color)) { + colorIdentity.append(color); + } + } + + return colorIdentity; +} + +/** + * @brief Recomputes all derived metadata of a row from its loaded deck. + */ +void VisualDeckStorageModel::recomputeDeckMetadata(DeckPreviewData &data, bool recomputeColorIdentity) +{ + const DeckList &deckList = data.deck.deckList; + + data.deckName = deckList.getName(); + data.displayName = !data.deckName.isEmpty() ? data.deckName : QFileInfo(data.deck.lastLoadInfo.fileName).fileName(); + data.tags = deckList.getTags(); + data.lastLoaded = QDateTime::fromString(deckList.getLastLoadedTimestamp()); + data.bannerCard = deckList.getBannerCard(); + if (recomputeColorIdentity) { + data.colorIdentity = computeColorIdentity(data.deck); + } +} + +void VisualDeckStorageModel::setFilePathForRow(int row, const QString &newFilePath) +{ + if (row < 0 || row >= decks.size()) { + return; + } + + DeckPreviewData &data = decks[row]; + rowByFilePath.remove(data.filePath); + data.filePath = newFilePath; + data.relativeFilePath = relativeFilePathFor(newFilePath, deckPath); + data.folderPath = folderPathFor(newFilePath, deckPath); + if (!rowByFilePath.contains(newFilePath)) { + rowByFilePath.insert(newFilePath, row); + } +} + +bool VisualDeckStorageModel::renameDeck(int row, const QString &newName) +{ + if (row < 0 || row >= decks.size() || decks.at(row).deck.isEmpty()) { + return false; + } + + DeckPreviewData &data = decks[row]; + data.deck.deckList.setName(newName); + if (!DeckLoader::saveToFile(data.deck)) { + return false; + } + + recomputeDeckMetadata(data); + emit dataChanged(index(row), index(row), {VisualDeckStorageRoles::DisplayNameRole}); + return true; +} + +bool VisualDeckStorageModel::renameFile(int row, const QString &newBaseName) +{ + if (row < 0 || row >= decks.size() || newBaseName.isEmpty()) { + return false; + } + + DeckPreviewData &data = decks[row]; + const QFileInfo info(data.filePath); + if (newBaseName == info.baseName()) { + return false; + } + + QString newFileName = newBaseName; + if (!info.suffix().isEmpty()) { + newFileName += "." + info.suffix(); + } + + const QString newFilePath = QFileInfo(info.dir(), newFileName).filePath(); + if (!QFile::rename(info.filePath(), newFilePath)) { + return false; + } + + const QString oldFilePath = data.filePath; + data.deck.lastLoadInfo.fileName = newFilePath; + setFilePathForRow(row, newFilePath); + data.lastModified = QFileInfo(newFilePath).lastModified(); + + emit dataChanged(index(row), index(row)); + emit deckFilePathChanged(oldFilePath, newFilePath); + return true; +} + +bool VisualDeckStorageModel::deleteFile(int row) +{ + if (row < 0 || row >= decks.size()) { + return false; + } + + const QString filePath = decks.at(row).filePath; + if (!QFile::remove(QFileInfo(filePath).filePath())) { + return false; + } + + beginRemoveRows(QModelIndex(), row, row); + rowByFilePath.remove(filePath); + decks.removeAt(row); + // Rows after the deleted one shift down by one. + for (auto it = rowByFilePath.begin(); it != rowByFilePath.end(); ++it) { + if (it.value() > row) { + --it.value(); + } + } + endRemoveRows(); + return true; +} + +bool VisualDeckStorageModel::setTags(int row, const QStringList &tags) +{ + if (row < 0 || row >= decks.size() || decks.at(row).deck.isEmpty()) { + return false; + } + + DeckPreviewData &data = decks[row]; + data.deck.deckList.setTags(tags); + if (!DeckLoader::saveToFile(data.deck)) { + return false; + } + + data.tags = tags; + emit dataChanged(index(row), index(row), {VisualDeckStorageRoles::TagsRole}); + return true; +} + +bool VisualDeckStorageModel::setBannerCard(int row, const CardRef &cardRef) +{ + if (row < 0 || row >= decks.size() || decks.at(row).deck.isEmpty()) { + return false; + } + + DeckPreviewData &data = decks[row]; + data.deck.deckList.setBannerCard(cardRef); + if (!DeckLoader::saveToFile(data.deck)) { + return false; + } + + data.bannerCard = cardRef; + emit dataChanged(index(row), index(row), + {VisualDeckStorageRoles::BannerCardNameRole, VisualDeckStorageRoles::BannerCardProviderIdRole}); + return true; +} + +bool VisualDeckStorageModel::convertToCockatriceFormat(int row) +{ + if (row < 0 || row >= decks.size() || decks.at(row).deck.isEmpty()) { + return false; + } + + DeckPreviewData &data = decks[row]; + const QString oldFilePath = data.filePath; + if (!DeckLoader::convertToCockatriceFormat(data.deck)) { + return false; + } + + setFilePathForRow(row, data.deck.lastLoadInfo.fileName); + data.lastModified = QFileInfo(data.filePath).lastModified(); + recomputeDeckMetadata(data); + + emit dataChanged(index(row), index(row)); + if (oldFilePath != data.filePath) { + emit deckFilePathChanged(oldFilePath, data.filePath); + } + return true; +} + +bool VisualDeckStorageModel::reloadIfModified(int row) +{ + if (row < 0 || row >= decks.size()) { + return false; + } + + DeckPreviewData &data = decks[row]; + QFileInfo fileInfo(data.filePath); + const QDateTime newLastModified = fileInfo.lastModified(); + if (!newLastModified.isValid() || newLastModified <= data.lastModified) { + return false; + } + + std::optional result = + DeckLoader::loadFromFile(data.filePath, DeckFileFormat::getFormatFromName(data.filePath), false); + if (!result) { + return false; + } + + data.deck = *result; + data.loadSucceeded = true; + data.lastModified = fileInfo.lastModified(); + recomputeDeckMetadata(data); + + emit dataChanged(index(row), index(row)); + emit deckLoaded(row); + return true; +} diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.h b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.h new file mode 100644 index 000000000..330205356 --- /dev/null +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.h @@ -0,0 +1,188 @@ +/** + * @file visual_deck_storage_model.h + * @ingroup VisualDeckStorageWidgets + * @brief Source model for the Visual Deck Storage: the deck files on disk. + * + * The model owns the deck metadata (name, tags, color identity, banner card, + * modification times) and the parsed deck list, loading each deck file in the + * background. Views read through the roles or the direct accessors, and all + * mutations (rename, tags, banner card, delete, conversion) go through this + * class so the view layer never touches the filesystem directly. + */ + +#ifndef VISUAL_DECK_STORAGE_MODEL_H +#define VISUAL_DECK_STORAGE_MODEL_H + +#include "../../deck_loader/loaded_deck.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace VisualDeckStorageRoles +{ +/** + * @brief Custom roles exposed by the VisualDeckStorageModel. + */ +enum +{ + FilePathRole = Qt::UserRole + 1, /**< Absolute file path of the deck. */ + RelativeFilePathRole, /**< File path relative to the deck folder. */ + FolderPathRole, /**< Directory of the deck relative to the deck folder ("" for root). */ + DisplayNameRole, /**< Deck name, or the file name if the deck has no name. */ + TagsRole, /**< The deck's tags. */ + ColorIdentityRole, /**< The deck's color identity (WUBRG order). */ + LastModifiedRole, /**< QDateTime of the deck file's last modification. */ + LastLoadedRole, /**< QDateTime when the deck was last loaded from the file. */ + BannerCardNameRole, /**< Name of the deck's banner card. */ + BannerCardProviderIdRole, /**< Provider id of the deck's banner card. */ + /** + * @brief Whether the row passes the proxy's current search / tag / color filters. + * + * Not served by this model, but by VisualDeckStorageSortFilterProxyModel on top + * of it. Declared here so every role read through a proxy index stays unique. + */ + FilterMatchRole +}; +} // namespace VisualDeckStorageRoles + +/** + * @brief One deck file as seen by the Visual Deck Storage. + * + * The metadata is computed once when the deck loads and refreshed on reloads + * and mutations, so filters and sorts never re-read the file from disk. + */ +struct DeckPreviewData +{ + QString filePath; ///< Absolute file path. + QString relativeFilePath; ///< File path relative to the deck folder. + QString folderPath; ///< Directory relative to the deck folder ("" for the deck folder itself). + QString deckName; ///< The deck name as stored in the file (may be empty). + QString displayName; ///< Deck name, or the file name if the deck has no name. + QStringList tags; ///< The deck's tags. + QString colorIdentity; ///< The deck's color identity in WUBRG order. + QDateTime lastModified; ///< File modification time at last check. + QDateTime lastLoaded; ///< When the deck was last loaded from the file. + CardRef bannerCard; ///< The deck's banner card (name + provider id). + LoadedDeck deck; ///< The parsed deck; empty until the file has been loaded. + bool loadSucceeded = false; ///< Whether the deck file finished loading successfully. + bool loadInProgress = false; ///< Whether the deck file is currently being loaded. +}; + +/** + * @brief One finished background deck load that has not been applied to the model yet. + */ +struct PendingDeckLoad +{ + QString filePath; ///< Identifies the row the result belongs to. + int generation; ///< Scan generation the load was started in. + bool ok = false; ///< Whether the file parsed successfully. + LoadedDeck deck; ///< The parsed deck, valid when ok. + QDateTime lastModified; ///< File modification time at load, valid when ok. + QString colorIdentity; ///< WUBRG color identity computed off the UI thread, valid when ok. +}; + +/** + * @brief The list model backing the Visual Deck Storage widget tree. + * + * Rows are in filesystem scan order; ordering and filtering are handled by + * VisualDeckStorageSortFilterProxyModel on top of this model. The proxy keeps + * every row and exposes each row's filter result through its FilterMatchRole. + */ +class VisualDeckStorageModel : public QAbstractListModel +{ + Q_OBJECT +public: + explicit VisualDeckStorageModel(QObject *parent = nullptr); + + /// @name Qt model overrides + ///@{ + [[nodiscard]] int rowCount(const QModelIndex &parent = QModelIndex()) const override; + [[nodiscard]] QVariant data(const QModelIndex &index, int role) const override; + ///@} + + /** + * @brief Sets the folder to scan for deck files and starts (re)loading. + * Clears the model immediately (modelReset), then populates it asynchronously + * as the background scan discovers deck files. + */ + void setDeckPath(const QString &path); + + /** + * @brief Re-scans the current deck folder, reloading every deck file. + */ + void refresh(); + + [[nodiscard]] QString getDeckPath() const + { + return deckPath; + } + + /** + * @brief The relative paths of all subdirectories of the deck folder, one level at a time. + * Used by the view to build the folder tree. Sorted for deterministic order. + */ + [[nodiscard]] QStringList getFolderPaths() const + { + return folderPaths; + } + + /// @name Data accessors + ///@{ + [[nodiscard]] const DeckPreviewData &dataForRow(int row) const; + [[nodiscard]] const LoadedDeck &deckForRow(int row) const; + [[nodiscard]] int rowForFilePath(const QString &filePath) const; + ///@} + + /// @name Mutations (persist to disk and update the row) + ///@{ + bool renameDeck(int row, const QString &newName); + bool renameFile(int row, const QString &newBaseName); + bool deleteFile(int row); + bool setTags(int row, const QStringList &tags); + bool setBannerCard(int row, const CardRef &cardRef); + bool convertToCockatriceFormat(int row); + bool reloadIfModified(int row); + ///@} + +signals: + /** + * @brief Emitted when a deck file finishes loading. + * @param row The row of the deck that finished loading. + */ + void deckLoaded(int row); + + /** + * @brief Emitted when a deck's file path changes (rename file, conversion). + * @param oldFilePath The previous file path. + * @param newFilePath The new file path. + */ + void deckFilePathChanged(const QString &oldFilePath, const QString &newFilePath); + +private: + void startScan(); + void beginLoad(int row); + static void recomputeDeckMetadata(DeckPreviewData &data, bool recomputeColorIdentity = true); + void reindexFilePaths(); + void schedulePendingLoadDrain(); + +private slots: + void drainPendingLoads(); + +private: + void setFilePathForRow(int row, const QString &newFilePath); + + QString deckPath; + QList decks; + QHash rowByFilePath; ///< Maps each deck's file path to its row for O(1) lookups. + QStringList folderPaths; ///< All subdirectories of the deck folder, sorted. + int scanGeneration = 0; ///< Bumped on every scan so stale results are ignored. + QVector pendingLoads; ///< Finished background loads waiting to be applied. + bool drainScheduled = false; ///< Whether a queued drain pass is already pending. +}; + +#endif // VISUAL_DECK_STORAGE_MODEL_H diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_quick_settings_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_quick_settings_widget.cpp index a0e8512ac..478431703 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_quick_settings_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_quick_settings_widget.cpp @@ -1,63 +1,74 @@ #include "visual_deck_storage_quick_settings_widget.h" #include "../../../client/settings/cache_settings.h" +#include "../cards/card_size_widget.h" #include "visual_deck_storage_widget.h" #include #include #include +#include +#include +#include VisualDeckStorageQuickSettingsWidget::VisualDeckStorageQuickSettingsWidget(QWidget *parent) : SettingsButtonWidget(parent) { // show folders checkbox showFoldersCheckBox = new QCheckBox(this); - showFoldersCheckBox->setChecked(SettingsCache::instance().getVisualDeckStorageShowFolders()); + showFoldersCheckBox->setChecked(SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowFolders()); connect(showFoldersCheckBox, &QCheckBox::QT_STATE_CHANGED, this, &VisualDeckStorageQuickSettingsWidget::showFoldersChanged); - connect(showFoldersCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setVisualDeckStorageShowFolders); + connect(showFoldersCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::setVisualDeckStorageShowFolders); // show tag filter widget checkbox showTagFilterCheckBox = new QCheckBox(this); - showTagFilterCheckBox->setChecked(SettingsCache::instance().getVisualDeckStorageShowTagFilter()); + showTagFilterCheckBox->setChecked( + SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowTagFilter()); connect(showTagFilterCheckBox, &QCheckBox::QT_STATE_CHANGED, this, &VisualDeckStorageQuickSettingsWidget::showTagFilterChanged); - connect(showTagFilterCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setVisualDeckStorageShowTagFilter); + connect(showTagFilterCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::setVisualDeckStorageShowTagFilter); // show color identity on DeckPreviewWidget checkbox showColorIdentityCheckBox = new QCheckBox(this); - showColorIdentityCheckBox->setChecked(SettingsCache::instance().getVisualDeckStorageShowColorIdentity()); + showColorIdentityCheckBox->setChecked( + SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowColorIdentity()); connect(showColorIdentityCheckBox, &QCheckBox::QT_STATE_CHANGED, this, &VisualDeckStorageQuickSettingsWidget::showColorIdentityChanged); - connect(showColorIdentityCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setVisualDeckStorageShowColorIdentity); + connect(showColorIdentityCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::setVisualDeckStorageShowColorIdentity); // show tags on DeckPreviewWidget checkbox showTagsOnDeckPreviewsCheckBox = new QCheckBox(this); - showTagsOnDeckPreviewsCheckBox->setChecked(SettingsCache::instance().getVisualDeckStorageShowTagsOnDeckPreviews()); + showTagsOnDeckPreviewsCheckBox->setChecked( + SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowTagsOnDeckPreviews()); connect(showTagsOnDeckPreviewsCheckBox, &QCheckBox::QT_STATE_CHANGED, this, &VisualDeckStorageQuickSettingsWidget::showTagsOnDeckPreviewsChanged); - connect(showTagsOnDeckPreviewsCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setVisualDeckStorageShowTagsOnDeckPreviews); + connect(showTagsOnDeckPreviewsCheckBox, &QCheckBox::QT_STATE_CHANGED, + &SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::setVisualDeckStorageShowTagsOnDeckPreviews); // show banner card selector checkbox showBannerCardComboBoxCheckBox = new QCheckBox(this); - showBannerCardComboBoxCheckBox->setChecked(SettingsCache::instance().getVisualDeckStorageShowBannerCardComboBox()); + showBannerCardComboBoxCheckBox->setChecked( + SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowBannerCardComboBox()); connect(showBannerCardComboBoxCheckBox, &QCheckBox::QT_STATE_CHANGED, this, &VisualDeckStorageQuickSettingsWidget::showBannerCardComboBoxChanged); - connect(showBannerCardComboBoxCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setVisualDeckStorageShowBannerCardComboBox); + connect(showBannerCardComboBoxCheckBox, &QCheckBox::QT_STATE_CHANGED, + &SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::setVisualDeckStorageShowBannerCardComboBox); // draw unused color identities checkbox drawUnusedColorIdentitiesCheckBox = new QCheckBox(this); drawUnusedColorIdentitiesCheckBox->setChecked( - SettingsCache::instance().getVisualDeckStorageDrawUnusedColorIdentities()); + SettingsCache::instance().visualDeckStorage().getVisualDeckStorageDrawUnusedColorIdentities()); connect(drawUnusedColorIdentitiesCheckBox, &QCheckBox::QT_STATE_CHANGED, this, &VisualDeckStorageQuickSettingsWidget::drawUnusedColorIdentitiesChanged); - connect(drawUnusedColorIdentitiesCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setVisualDeckStorageDrawUnusedColorIdentities); + connect(drawUnusedColorIdentitiesCheckBox, &QCheckBox::QT_STATE_CHANGED, + &SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::setVisualDeckStorageDrawUnusedColorIdentities); // color identity opacity selector auto unusedColorIdentityOpacityWidget = new QWidget(this); @@ -68,11 +79,12 @@ VisualDeckStorageQuickSettingsWidget::VisualDeckStorageQuickSettingsWidget(QWidg unusedColorIdentitiesOpacitySpinBox->setMinimum(0); unusedColorIdentitiesOpacitySpinBox->setMaximum(100); unusedColorIdentitiesOpacitySpinBox->setValue( - SettingsCache::instance().getVisualDeckStorageUnusedColorIdentitiesOpacity()); + SettingsCache::instance().visualDeckStorage().getVisualDeckStorageUnusedColorIdentitiesOpacity()); connect(unusedColorIdentitiesOpacitySpinBox, qOverload(&QSpinBox::valueChanged), this, &VisualDeckStorageQuickSettingsWidget::unusedColorIdentitiesOpacityChanged); - connect(unusedColorIdentitiesOpacitySpinBox, qOverload(&QSpinBox::valueChanged), &SettingsCache::instance(), - &SettingsCache::setVisualDeckStorageUnusedColorIdentitiesOpacity); + connect(unusedColorIdentitiesOpacitySpinBox, qOverload(&QSpinBox::valueChanged), + &SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::setVisualDeckStorageUnusedColorIdentitiesOpacity); unusedColorIdentitiesOpacityLabel->setBuddy(unusedColorIdentitiesOpacitySpinBox); @@ -90,11 +102,13 @@ VisualDeckStorageQuickSettingsWidget::VisualDeckStorageQuickSettingsWidget(QWidg deckPreviewTooltipComboBox->addItem("", TooltipType::None); deckPreviewTooltipComboBox->addItem("", TooltipType::Filepath); - deckPreviewTooltipComboBox->setCurrentIndex(SettingsCache::instance().getVisualDeckStorageTooltipType()); + deckPreviewTooltipComboBox->setCurrentIndex( + SettingsCache::instance().visualDeckStorage().getVisualDeckStorageTooltipType()); connect(deckPreviewTooltipComboBox, QOverload::of(&QComboBox::currentIndexChanged), this, [this] { emit deckPreviewTooltipChanged(getDeckPreviewTooltip()); }); - connect(deckPreviewTooltipComboBox, QOverload::of(&QComboBox::currentIndexChanged), &SettingsCache::instance(), - &SettingsCache::setVisualDeckStorageTooltipType); + connect(deckPreviewTooltipComboBox, QOverload::of(&QComboBox::currentIndexChanged), + &SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::setVisualDeckStorageTooltipType); auto deckPreviewTooltipLayout = new QHBoxLayout(deckPreviewTooltipWidget); deckPreviewTooltipLayout->setContentsMargins(11, 0, 11, 0); @@ -102,11 +116,12 @@ VisualDeckStorageQuickSettingsWidget::VisualDeckStorageQuickSettingsWidget(QWidg deckPreviewTooltipLayout->addWidget(deckPreviewTooltipComboBox); // card size slider - cardSizeWidget = new CardSizeWidget(this, nullptr, SettingsCache::instance().getVisualDeckStorageCardSize()); + cardSizeWidget = + new CardSizeWidget(this, nullptr, SettingsCache::instance().cardsDisplay().getVisualDeckStorageCardSize()); connect(cardSizeWidget->getSlider(), &QSlider::valueChanged, this, &VisualDeckStorageQuickSettingsWidget::cardSizeChanged); - connect(cardSizeWidget, &CardSizeWidget::cardSizeSettingUpdated, &SettingsCache::instance(), - &SettingsCache::setVisualDeckStorageCardSize); + connect(cardSizeWidget, &CardSizeWidget::cardSizeSettingUpdated, &SettingsCache::instance().cardsDisplay(), + &CardsDisplaySettings::setVisualDeckStorageCardSize); // putting everything together this->addSettingsWidget(showFoldersCheckBox); @@ -119,7 +134,7 @@ VisualDeckStorageQuickSettingsWidget::VisualDeckStorageQuickSettingsWidget(QWidg this->addSettingsWidget(deckPreviewTooltipWidget); this->addSettingsWidget(cardSizeWidget); - connect(&SettingsCache::instance(), &SettingsCache::langChanged, this, + connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &VisualDeckStorageQuickSettingsWidget::retranslateUi); retranslateUi(); } diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp index 70b438232..406527893 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp @@ -1,22 +1,20 @@ #include "visual_deck_storage_search_widget.h" -#include "../../../client/settings/cache_settings.h" -#include "../../../filters/deck_filter_string.h" #include "../../../filters/syntax_help.h" #include "../../pixel_map_generator.h" #include -#include +#include /** - * @brief Constructs a PrintingSelectorCardSearchWidget for searching cards by set name or set code. + * @brief Constructs a search bar for filtering decks in the Visual Deck Storage. * - * This widget provides a search bar that allows users to search for cards by either their set name - * or set code. It uses a debounced timer to trigger the search action after the user stops typing. + * Provides a search bar that allows users to search decks by filename or search + * expression, with a debounced timer to trigger the search after the user stops typing. * - * @param parent The parent PrintingSelector widget that will handle the search results. + * @param parent The parent widget. */ -VisualDeckStorageSearchWidget::VisualDeckStorageSearchWidget(VisualDeckStorageWidget *parent) : parent(parent) +VisualDeckStorageSearchWidget::VisualDeckStorageSearchWidget(QWidget *parent) : QWidget(parent) { layout = new QHBoxLayout(this); layout->setContentsMargins(0, 0, 0, 0); @@ -27,7 +25,7 @@ VisualDeckStorageSearchWidget::VisualDeckStorageSearchWidget(VisualDeckStorageWi searchBar->setClearButtonEnabled(true); searchBar->addAction(loadColorAdjustedPixmap("theme:icons/search"), QLineEdit::LeadingPosition); - auto help = searchBar->addAction(QPixmap("theme:icons/info"), QLineEdit::TrailingPosition); + auto help = searchBar->addAction(themePixmap(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); connect(help, &QAction::triggered, this, [this] { createDeckSearchSyntaxHelpWindow(searchBar); }); layout->addWidget(searchBar); @@ -39,43 +37,5 @@ VisualDeckStorageSearchWidget::VisualDeckStorageSearchWidget(VisualDeckStorageWi searchDebounceTimer->start(300); // 300ms debounce }); - connect(searchDebounceTimer, &QTimer::timeout, parent, &VisualDeckStorageWidget::updateSearchFilter); -} - -/** - * @brief Retrieves the current text in the search bar. - * - * @return The text entered by the user in the search bar. - */ -QString VisualDeckStorageSearchWidget::getSearchText() -{ - return searchBar->text(); -} - -/** - * Converts the filepath into a relative filepath starting from the deck folder. - * If the file isn't in the deck folder, then this will just return the filename. - * - * @param filePath The filepath to convert into a relative filepath - */ -static QString toRelativeFilepath(const QString &filePath) -{ - QString deckPath = SettingsCache::instance().getDeckPath(); - if (filePath.startsWith(deckPath)) { - return filePath.mid(deckPath.length()); - } - - QFileInfo fileInfo(filePath); - QString fileName = fileInfo.fileName(); - return fileName; -} - -void VisualDeckStorageSearchWidget::filterWidgets(QList widgets, const QString &searchText) -{ - auto filterString = DeckFilterString(searchText); - - for (auto widget : widgets) { - QString relativeFilePath = toRelativeFilepath(widget->filePath); - widget->filteredBySearch = !filterString.check(widget, {relativeFilePath}); - } + connect(searchDebounceTimer, &QTimer::timeout, this, [this] { emit searchTextChanged(searchBar->text()); }); } diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.h index 2f3d81aeb..7769ea911 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.h @@ -2,30 +2,32 @@ * @file visual_deck_storage_search_widget.h * @ingroup VisualDeckStorageWidgets */ -//! \todo Document this file. #ifndef VISUAL_DECK_STORAGE_SEARCH_WIDGET_H #define VISUAL_DECK_STORAGE_SEARCH_WIDGET_H -#include "deck_preview/deck_preview_widget.h" - #include #include #include -class VisualDeckStorageWidget; +class QTimer; + class VisualDeckStorageSearchWidget : public QWidget { Q_OBJECT public: - explicit VisualDeckStorageSearchWidget(VisualDeckStorageWidget *parent); - QString getSearchText(); - void filterWidgets(QList widgets, const QString &searchText); + explicit VisualDeckStorageSearchWidget(QWidget *parent); + +signals: + /** + * Emitted once the debounce timer fires after the user stopped typing. + * @param text The current contents of the search bar. + */ + void searchTextChanged(const QString &text); private: QHBoxLayout *layout; - VisualDeckStorageWidget *parent; QLineEdit *searchBar; QTimer *searchDebounceTimer; }; diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.cpp new file mode 100644 index 000000000..c05da1cb3 --- /dev/null +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.cpp @@ -0,0 +1,292 @@ +#include "visual_deck_storage_sort_filter_proxy_model.h" + +#include "../../filters/deck_filter_string.h" + +#include +#include + +VisualDeckStorageSortFilterProxyModel::VisualDeckStorageSortFilterProxyModel(QObject *parent) + : QSortFilterProxyModel(parent) +{ + setDynamicSortFilter(false); +} + +void VisualDeckStorageSortFilterProxyModel::setSourceModel(QAbstractItemModel *model) +{ + if (QAbstractItemModel *oldModel = sourceModel()) { + disconnect(oldModel, &QAbstractItemModel::modelReset, this, + &VisualDeckStorageSortFilterProxyModel::resizeMatchLists); + disconnect(oldModel, &QAbstractItemModel::rowsInserted, this, + &VisualDeckStorageSortFilterProxyModel::resizeMatchLists); + disconnect(oldModel, &QAbstractItemModel::rowsRemoved, this, + &VisualDeckStorageSortFilterProxyModel::resizeMatchLists); + } + + QSortFilterProxyModel::setSourceModel(model); + + if (model) { + connect(model, &QAbstractItemModel::modelReset, this, &VisualDeckStorageSortFilterProxyModel::resizeMatchLists); + connect(model, &QAbstractItemModel::rowsInserted, this, + &VisualDeckStorageSortFilterProxyModel::resizeMatchLists); + connect(model, &QAbstractItemModel::rowsRemoved, this, + &VisualDeckStorageSortFilterProxyModel::resizeMatchLists); + } + + resizeMatchLists(); +} + +void VisualDeckStorageSortFilterProxyModel::setSearchText(const QString &text) +{ + if (searchText == text) { + return; + } + + searchText = text; + updateSearchMatches(); + invalidate(); +} + +void VisualDeckStorageSortFilterProxyModel::setTagFilter(const QSet &newSelectedTags, + const QSet &newExcludedTags) +{ + if (selectedTags == newSelectedTags && excludedTags == newExcludedTags) { + return; + } + + selectedTags = newSelectedTags; + excludedTags = newExcludedTags; + updateTagMatches(); + invalidate(); +} + +void VisualDeckStorageSortFilterProxyModel::setColorFilter(FilterMode mode, const QSet &colors) +{ + if (colorFilterMode == mode && activeColors == colors) { + return; + } + + colorFilterMode = mode; + activeColors = colors; + updateColorMatches(); + invalidate(); +} + +void VisualDeckStorageSortFilterProxyModel::setSortOrder(SortOrder order) +{ + // No equality guard: the initial reapply (with the default order) must still + // trigger sort(0), since without a sort the proxy would show scan order. + sortOrder = order; + sort(0); +} + +void VisualDeckStorageSortFilterProxyModel::reapplyFilters() +{ + const QList oldSearchMatches = searchMatches; + const QList oldTagMatches = tagMatches; + const QList oldColorMatches = colorMatches; + + updateSearchMatches(); + updateTagMatches(); + updateColorMatches(); + + if (searchMatches != oldSearchMatches || tagMatches != oldTagMatches || colorMatches != oldColorMatches) { + invalidate(); + } + + if (sortOrder == ByName || sortOrder == ByLastLoaded) { + // These orders depend on data that only becomes available when a deck finishes loading. + sort(0); + } +} + +void VisualDeckStorageSortFilterProxyModel::resort() +{ + sort(0); +} + +QVariant VisualDeckStorageSortFilterProxyModel::data(const QModelIndex &index, int role) const +{ + if (role == VisualDeckStorageRoles::FilterMatchRole) { + if (!index.isValid()) { + return true; + } + const QModelIndex sourceIndex = mapToSource(index); + return rowMatches(sourceIndex.row()); + } + + return QSortFilterProxyModel::data(index, role); +} + +bool VisualDeckStorageSortFilterProxyModel::rowMatches(int sourceRow) const +{ + // If the match lists aren't sized to the current model yet, don't hide anything. + if (sourceRow < 0 || sourceRow >= searchMatches.size() || sourceRow >= tagMatches.size() || + sourceRow >= colorMatches.size()) { + return true; + } + + return searchMatches.at(sourceRow) && tagMatches.at(sourceRow) && colorMatches.at(sourceRow); +} + +bool VisualDeckStorageSortFilterProxyModel::filterAcceptsRow(int /*sourceRow*/, + const QModelIndex & /*sourceParent*/) const +{ + // Rows are never dropped: the filter result is exposed per row through + // FilterMatchRole, so views can keep their widgets alive and just hide them. + return true; +} + +bool VisualDeckStorageSortFilterProxyModel::lessThan(const QModelIndex &left, const QModelIndex &right) const +{ + const auto *source = deckSourceModel(); + if (!source) { + return false; + } + + const DeckPreviewData &leftData = source->dataForRow(left.row()); + const DeckPreviewData &rightData = source->dataForRow(right.row()); + + switch (sortOrder) { + case ByName: + return leftData.deckName < rightData.deckName; + case Alphabetical: + return QString::localeAwareCompare(QFileInfo(leftData.filePath).fileName(), + QFileInfo(rightData.filePath).fileName()) < 0; + case ByLastModified: + return leftData.lastModified > rightData.lastModified; + case ByLastLoaded: + return leftData.lastLoaded > rightData.lastLoaded; + } + + return false; +} + +void VisualDeckStorageSortFilterProxyModel::resizeMatchLists() +{ + const int count = sourceModel() ? sourceModel()->rowCount() : 0; + searchMatches.resize(count); + searchMatches.fill(true); + tagMatches.resize(count); + tagMatches.fill(true); + colorMatches.resize(count); + colorMatches.fill(true); +} + +void VisualDeckStorageSortFilterProxyModel::updateSearchMatches() +{ + const auto *source = deckSourceModel(); + if (!source) { + searchMatches.clear(); + return; + } + + const int count = source->rowCount(); + searchMatches.resize(count); + if (searchText.isEmpty()) { + searchMatches.fill(true); + return; + } + + DeckFilterString filterString(searchText); + for (int row = 0; row < count; ++row) { + const DeckPreviewData &data = source->dataForRow(row); + + // isEmpty() is intentional: if a deck fails to load, loadInProgress becomes false + // but the deck remains empty. Using loadInProgress alone would pass failed decks + // to DeckFilterString::check, which requires a non-empty deck. + if (data.deck.isEmpty()) { + searchMatches[row] = true; + continue; + } + + DeckSearchData searchData{ + .deck = &data.deck, + .filePath = data.filePath, + .displayName = data.displayName, + .relativeFilePath = data.relativeFilePath, + }; + searchMatches[row] = filterString.check(searchData); + } +} + +void VisualDeckStorageSortFilterProxyModel::updateTagMatches() +{ + const auto *source = deckSourceModel(); + if (!source) { + tagMatches.clear(); + return; + } + + const int count = source->rowCount(); + tagMatches.resize(count); + + if (selectedTags.isEmpty() && excludedTags.isEmpty()) { + tagMatches.fill(true); + return; + } + + for (int row = 0; row < count; ++row) { + const QStringList deckTags = source->dataForRow(row).tags; + + const bool hasAllSelected = std::all_of(selectedTags.begin(), selectedTags.end(), + [&deckTags](const QString &tag) { return deckTags.contains(tag); }); + const bool hasAnyExcluded = std::any_of(excludedTags.begin(), excludedTags.end(), + [&deckTags](const QString &tag) { return deckTags.contains(tag); }); + + tagMatches[row] = hasAllSelected && !hasAnyExcluded; + } +} + +void VisualDeckStorageSortFilterProxyModel::updateColorMatches() +{ + const auto *source = deckSourceModel(); + if (!source) { + colorMatches.clear(); + return; + } + + const int count = source->rowCount(); + colorMatches.resize(count); + + if (activeColors.isEmpty()) { + colorMatches.fill(true); + return; + } + + for (int row = 0; row < count; ++row) { + const QString colorIdentity = source->dataForRow(row).colorIdentity; + + bool matches = true; + switch (colorFilterMode) { + case ExactMatch: { + QSet activeColorSet; + for (const QChar &color : activeColors) { + activeColorSet.insert(color.toUpper()); + } + + QSet colorIdentitySet; + for (const QChar &color : colorIdentity) { + colorIdentitySet.insert(color.toUpper()); + } + + matches = activeColorSet == colorIdentitySet; + break; + } + case Includes: + matches = std::all_of(activeColors.begin(), activeColors.end(), + [&colorIdentity](const QChar &color) { return colorIdentity.contains(color); }); + break; + case Excludes: + matches = std::none_of(activeColors.begin(), activeColors.end(), + [&colorIdentity](const QChar &color) { return colorIdentity.contains(color); }); + break; + } + + colorMatches[row] = matches; + } +} + +const VisualDeckStorageModel *VisualDeckStorageSortFilterProxyModel::deckSourceModel() const +{ + return qobject_cast(sourceModel()); +} diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.h b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.h new file mode 100644 index 000000000..7e771f6a9 --- /dev/null +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.h @@ -0,0 +1,105 @@ +/** + * @file visual_deck_storage_sort_filter_proxy_model.h + * @ingroup VisualDeckStorageWidgets + * @brief Sorting and filtering proxy on top of VisualDeckStorageModel. + * + * Owns all search / tag / color filter state and the sort order. Filtering is + * evaluated against the model's data (never against widgets), so it can run + * before any view exists and re-evaluate whenever deck data finishes loading. + * + * Rows are never removed by filtering. Instead, every row carries the + * FilterMatchRole, which views read to show or hide their widgets while keeping + * them alive; all rows stay in the proxy so they keep their sorted position. + */ + +#ifndef VISUAL_DECK_STORAGE_SORT_FILTER_PROXY_MODEL_H +#define VISUAL_DECK_STORAGE_SORT_FILTER_PROXY_MODEL_H + +#include "visual_deck_storage_model.h" + +#include +#include +#include + +class VisualDeckStorageSortFilterProxyModel : public QSortFilterProxyModel +{ + Q_OBJECT +public: + /** + * @brief The order in which decks are sorted. Values must match the + * entries of the sort widget's combo box and the stored settings value. + */ + enum SortOrder + { + ByName, + Alphabetical, + ByLastModified, + ByLastLoaded, + }; + Q_ENUM(SortOrder) + + /** + * @brief How the color identity filter is applied. + */ + enum FilterMode + { + ExactMatch, + Includes, + Excludes + }; + Q_ENUM(FilterMode) + + explicit VisualDeckStorageSortFilterProxyModel(QObject *parent = nullptr); + + [[nodiscard]] QVariant data(const QModelIndex &index, int role) const override; + + void setSourceModel(QAbstractItemModel *model) override; + + /// @name Filter input setters (each re-evaluates the affected matches) + ///@{ + void setSearchText(const QString &text); + void setTagFilter(const QSet &newSelectedTags, const QSet &newExcludedTags); + void setColorFilter(FilterMode mode, const QSet &colors); + ///@} + + /** + * @brief Sets the sort order and applies it immediately. + */ + void setSortOrder(SortOrder order); + + /** + * @brief Re-evaluates all matches against the current model data and + * re-applies filtering and sorting. Called after deck data changes. + */ + void reapplyFilters(); + + /** + * @brief Re-applies the current sort order without touching the filters. + */ + void resort(); + +protected: + bool filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const override; + bool lessThan(const QModelIndex &left, const QModelIndex &right) const override; + +private: + [[nodiscard]] bool rowMatches(int sourceRow) const; + void resizeMatchLists(); + void updateSearchMatches(); + void updateTagMatches(); + void updateColorMatches(); + [[nodiscard]] const VisualDeckStorageModel *deckSourceModel() const; + + QString searchText; + QSet selectedTags; + QSet excludedTags; + FilterMode colorFilterMode = ExactMatch; + QSet activeColors; + SortOrder sortOrder = Alphabetical; + + QList searchMatches; ///< Per-row search match, sized like the source model. + QList tagMatches; ///< Per-row tag match. + QList colorMatches; ///< Per-row color identity match. +}; + +#endif // VISUAL_DECK_STORAGE_SORT_FILTER_PROXY_MODEL_H diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_widget.cpp index 0eb45cdcc..6289a4941 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_widget.cpp @@ -1,19 +1,11 @@ #include "visual_deck_storage_sort_widget.h" #include "../../../client/settings/cache_settings.h" +#include "visual_deck_storage_widget.h" -#include +#include -/** - * @brief Constructs a PrintingSelectorCardSortWidget for searching cards by set name or set code. - * - * This widget provides a search bar that allows users to search for cards by either their set name - * or set code. It uses a debounced timer to trigger the search action after the user stops typing. - * - * @param parent The parent PrintingSelector widget that will handle the search results. - */ -VisualDeckStorageSortWidget::VisualDeckStorageSortWidget(VisualDeckStorageWidget *parent) - : parent(parent), sortOrder(Alphabetical) +VisualDeckStorageSortWidget::VisualDeckStorageSortWidget(VisualDeckStorageWidget *parent) : QWidget(parent) { layout = new QHBoxLayout(this); layout->setContentsMargins(0, 0, 0, 0); @@ -28,13 +20,11 @@ VisualDeckStorageSortWidget::VisualDeckStorageSortWidget(VisualDeckStorageWidget retranslateUi(); // Set the current sort order - sortComboBox->setCurrentIndex(SettingsCache::instance().getVisualDeckStorageSortingOrder()); - sortOrder = static_cast(sortComboBox->currentIndex()); + sortComboBox->setCurrentIndex(SettingsCache::instance().visualDeckStorage().getVisualDeckStorageSortingOrder()); - // Connect sorting change signal to refresh the file list + // Connect sorting change signal to persist the order and refresh the file list connect(sortComboBox, QOverload::of(&QComboBox::currentIndexChanged), this, &VisualDeckStorageSortWidget::updateSortOrder); - connect(this, &VisualDeckStorageSortWidget::sortOrderChanged, parent, &VisualDeckStorageWidget::updateSortOrder); } void VisualDeckStorageSortWidget::retranslateUi() @@ -46,10 +36,13 @@ void VisualDeckStorageSortWidget::retranslateUi() // Clear and repopulate the ComboBox with translated items sortComboBox->clear(); - sortComboBox->addItem(tr("Sort Alphabetically (Deck Name)"), ByName); - sortComboBox->addItem(tr("Sort Alphabetically (Filename)"), Alphabetical); - sortComboBox->addItem(tr("Sort by Last Modified"), ByLastModified); - sortComboBox->addItem(tr("Sort by Last Loaded"), ByLastLoaded); + sortComboBox->addItem(tr("Sort Alphabetically (Deck Name)"), + VisualDeckStorageSortFilterProxyModel::SortOrder::ByName); + sortComboBox->addItem(tr("Sort Alphabetically (Filename)"), + VisualDeckStorageSortFilterProxyModel::SortOrder::Alphabetical); + sortComboBox->addItem(tr("Sort by Last Modified"), + VisualDeckStorageSortFilterProxyModel::SortOrder::ByLastModified); + sortComboBox->addItem(tr("Sort by Last Loaded"), VisualDeckStorageSortFilterProxyModel::SortOrder::ByLastLoaded); // Restore the current index sortComboBox->setCurrentIndex(oldIndex); @@ -58,60 +51,13 @@ void VisualDeckStorageSortWidget::retranslateUi() sortComboBox->blockSignals(false); } +VisualDeckStorageSortFilterProxyModel::SortOrder VisualDeckStorageSortWidget::currentSortOrder() const +{ + return static_cast(sortComboBox->currentIndex()); +} + void VisualDeckStorageSortWidget::updateSortOrder() { - sortOrder = static_cast(sortComboBox->currentIndex()); - SettingsCache::instance().setVisualDeckStorageSortingOrder(sortComboBox->currentIndex()); + SettingsCache::instance().visualDeckStorage().setVisualDeckStorageSortingOrder(sortComboBox->currentIndex()); emit sortOrderChanged(); } - -void VisualDeckStorageSortWidget::sortFolder(VisualDeckStorageFolderDisplayWidget *folderWidget) -{ - auto children = - folderWidget->getFlowWidget()->findChildren(QString(), Qt::FindChildOption::FindDirectChildrenOnly); - for (auto widget : children) { - auto deckPreviewWidgets = - widget->findChildren(QString(), Qt::FindChildOption::FindDirectChildrenOnly); - auto newOrder = filterFiles(deckPreviewWidgets); - for (DeckPreviewWidget *previewWidget : newOrder) { - folderWidget->getFlowWidget()->removeWidget(previewWidget); - } - for (DeckPreviewWidget *previewWidget : newOrder) { - folderWidget->getFlowWidget()->addWidget(previewWidget); - } - } -} - -QList VisualDeckStorageSortWidget::filterFiles(QList widgets) -{ - // Sort the widgets list based on the current sort order - std::sort(widgets.begin(), widgets.end(), [this](DeckPreviewWidget *widget1, DeckPreviewWidget *widget2) { - if (!widget1 || !widget2) { - return false; // Handle null pointers gracefully - } - - QFileInfo info1(widget1->filePath); - QFileInfo info2(widget2->filePath); - - switch (sortOrder) { - case ByName: - return widget1->deckLoader->getDeck().deckList.getName() < - widget2->deckLoader->getDeck().deckList.getName(); - case Alphabetical: - return QString::localeAwareCompare(info1.fileName(), info2.fileName()) <= 0; - case ByLastModified: - return info1.lastModified() > info2.lastModified(); - case ByLastLoaded: { - QDateTime time1 = - QDateTime::fromString(widget1->deckLoader->getDeck().deckList.getLastLoadedTimestamp()); - QDateTime time2 = - QDateTime::fromString(widget2->deckLoader->getDeck().deckList.getLastLoadedTimestamp()); - return time1 > time2; - } - } - - return false; // Default case, no sorting applied - }); - - return widgets; -} diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_widget.h index 24eddba33..633924d84 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_widget.h @@ -2,19 +2,17 @@ * @file visual_deck_storage_sort_widget.h * @ingroup VisualDeckStorageWidgets */ -//! \todo Document this file. #ifndef VISUAL_DECK_STORAGE_SORT_WIDGET_H #define VISUAL_DECK_STORAGE_SORT_WIDGET_H -#include "visual_deck_storage_widget.h" +#include "visual_deck_storage_sort_filter_proxy_model.h" #include #include #include class VisualDeckStorageWidget; -class VisualDeckStorageFolderDisplayWidget; class VisualDeckStorageSortWidget : public QWidget { Q_OBJECT @@ -22,25 +20,23 @@ class VisualDeckStorageSortWidget : public QWidget public: explicit VisualDeckStorageSortWidget(VisualDeckStorageWidget *parent); void retranslateUi(); - void updateSortOrder(); - void sortFolder(VisualDeckStorageFolderDisplayWidget *folderWidget); - QString getSearchText(); - QList filterFiles(QList widgets); + + /** + * @brief The currently selected sort order. + */ + [[nodiscard]] VisualDeckStorageSortFilterProxyModel::SortOrder currentSortOrder() const; signals: + /** + * @brief Emitted when the user picks a different sort order. + */ void sortOrderChanged(); +private slots: + void updateSortOrder(); + private: - enum SortOrder - { - ByName, - Alphabetical, - ByLastModified, - ByLastLoaded, - }; QHBoxLayout *layout; - VisualDeckStorageWidget *parent; - SortOrder sortOrder; // Current sorting option QComboBox *sortComboBox; }; diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.cpp index c4c8d18a8..ba52cf8e9 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.cpp @@ -2,6 +2,8 @@ #include "../general/layout_containers/flow_widget.h" #include "deck_preview/deck_preview_tag_display_widget.h" +#include "visual_deck_storage_model.h" +#include "visual_deck_storage_sort_filter_proxy_model.h" #include "visual_deck_storage_widget.h" #include @@ -18,7 +20,7 @@ VisualDeckStorageTagFilterWidget::VisualDeckStorageTagFilterWidget(VisualDeckSto setFixedHeight(100); - auto *flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAsNeeded); + flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAsNeeded); layout->addWidget(flowWidget); } @@ -29,45 +31,26 @@ void VisualDeckStorageTagFilterWidget::showEvent(QShowEvent *event) refreshTags(); } -void VisualDeckStorageTagFilterWidget::filterDecksBySelectedTags(const QList &deckPreviews) const +/** + * @brief The tags of all decks currently accepted by the proxy model. + */ +QSet VisualDeckStorageTagFilterWidget::gatherAllTags() const { - QStringList selectedTags; - QStringList excludedTags; + QSet allTags; + auto *proxy = parent->proxyModel(); - // Collect selected and excluded tags - for (DeckPreviewTagDisplayWidget *tagWidget : findChildren()) { - switch (tagWidget->getState()) { - case TagState::Selected: - selectedTags.append(tagWidget->getTagName()); - break; - case TagState::Excluded: - excludedTags.append(tagWidget->getTagName()); - break; - default: - break; + for (int proxyRow = 0; proxyRow < proxy->rowCount(); ++proxyRow) { + const QModelIndex index = proxy->index(proxyRow, 0); + if (!index.data(VisualDeckStorageRoles::FilterMatchRole).toBool()) { + continue; + } + const QStringList deckTags = index.data(VisualDeckStorageRoles::TagsRole).toStringList(); + for (const QString &tag : deckTags) { + allTags.insert(tag); } } - // If no tags are selected or excluded, show all - if (selectedTags.isEmpty() && excludedTags.isEmpty()) { - for (DeckPreviewWidget *deckPreview : deckPreviews) { - deckPreview->filteredByTags = false; - } - return; - } - - for (DeckPreviewWidget *deckPreview : deckPreviews) { - QStringList deckTags = deckPreview->deckLoader->getDeck().deckList.getTags(); - - bool hasAllSelected = std::all_of(selectedTags.begin(), selectedTags.end(), - [&deckTags](const QString &tag) { return deckTags.contains(tag); }); - - bool hasAnyExcluded = std::any_of(excludedTags.begin(), excludedTags.end(), - [&deckTags](const QString &tag) { return deckTags.contains(tag); }); - - // Filter out if any excluded tag is present or if any selected tag is missing - deckPreview->filteredByTags = !(hasAllSelected && !hasAnyExcluded); - } + return allTags; } void VisualDeckStorageTagFilterWidget::refreshTags() @@ -80,8 +63,6 @@ void VisualDeckStorageTagFilterWidget::refreshTags() void VisualDeckStorageTagFilterWidget::removeTagsNotInList(const QSet &tags) { - auto *flowWidget = findChild(); - for (DeckPreviewTagDisplayWidget *tagWidget : findChildren()) { const QString &tagName = tagWidget->getTagName(); @@ -116,20 +97,12 @@ void VisualDeckStorageTagFilterWidget::addTagIfNotPresent(const QString &tag) auto *newTagWidget = new DeckPreviewTagDisplayWidget(this, tag); connect(newTagWidget, &DeckPreviewTagDisplayWidget::tagClicked, parent, &VisualDeckStorageWidget::updateTagFilter); - connect(newTagWidget, &DeckPreviewTagDisplayWidget::tagClicked, this, - &VisualDeckStorageTagFilterWidget::refreshTags); - auto *flowWidget = findChild(); flowWidget->addWidget(newTagWidget); } } void VisualDeckStorageTagFilterWidget::sortTags() { - auto *flowWidget = findChild(); - if (!flowWidget) { - return; - } - // Get all tag widgets QList tagWidgets = findChildren(); @@ -147,19 +120,26 @@ void VisualDeckStorageTagFilterWidget::sortTags() } } -QSet VisualDeckStorageTagFilterWidget::gatherAllTags() const +QStringList VisualDeckStorageTagFilterWidget::selectedTags() const { - QSet allTags; - QList deckWidgets = parent->findChildren(); - - for (DeckPreviewWidget *widget : deckWidgets) { - if (widget->checkVisibility()) { - for (const QString &tag : widget->deckLoader->getDeck().deckList.getTags()) { - allTags.insert(tag); - } + QStringList selected; + for (DeckPreviewTagDisplayWidget *tagWidget : findChildren()) { + if (tagWidget->getState() == TagState::Selected) { + selected.append(tagWidget->getTagName()); } } - return allTags; + return selected; +} + +QStringList VisualDeckStorageTagFilterWidget::excludedTags() const +{ + QStringList excluded; + for (DeckPreviewTagDisplayWidget *tagWidget : findChildren()) { + if (tagWidget->getState() == TagState::Excluded) { + excluded.append(tagWidget->getTagName()); + } + } + return excluded; } QStringList VisualDeckStorageTagFilterWidget::getAllKnownTags() const diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.h index 3290c9e9a..337c053c7 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.h @@ -2,21 +2,22 @@ * @file visual_deck_storage_tag_filter_widget.h * @ingroup VisualDeckStorageWidgets */ -//! \todo Document this file. #ifndef VISUAL_DECK_STORAGE_TAG_FILTER_WIDGET_H #define VISUAL_DECK_STORAGE_TAG_FILTER_WIDGET_H -#include "deck_preview/deck_preview_widget.h" - +#include +#include #include +class FlowWidget; class VisualDeckStorageWidget; class VisualDeckStorageTagFilterWidget : public QWidget { Q_OBJECT VisualDeckStorageWidget *parent; + FlowWidget *flowWidget; [[nodiscard]] QSet gatherAllTags() const; void removeTagsNotInList(const QSet &tags); @@ -27,9 +28,21 @@ class VisualDeckStorageTagFilterWidget : public QWidget public: explicit VisualDeckStorageTagFilterWidget(VisualDeckStorageWidget *_parent); [[nodiscard]] QStringList getAllKnownTags() const; - void filterDecksBySelectedTags(const QList &deckPreviews) const; + + /** + * @brief The tags currently in "selected" state. + */ + [[nodiscard]] QStringList selectedTags() const; + + /** + * @brief The tags currently in "excluded" state. + */ + [[nodiscard]] QStringList excludedTags() const; public slots: + /** + * @brief Rebuilds the tag chips from the tags of the currently visible decks. + */ void refreshTags(); void showEvent(QShowEvent *event) override; }; diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp index bfc425993..da7ddc368 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp @@ -1,23 +1,31 @@ #include "visual_deck_storage_widget.h" #include "../../../client/settings/cache_settings.h" +#include "../../pixel_map_generator.h" #include "../quick_settings/settings_button_widget.h" +#include "deck_preview/deck_preview_color_identity_filter_widget.h" #include "deck_preview/deck_preview_widget.h" #include "visual_deck_storage_folder_display_widget.h" +#include "visual_deck_storage_quick_settings_widget.h" #include "visual_deck_storage_search_widget.h" #include "visual_deck_storage_sort_widget.h" #include "visual_deck_storage_tag_filter_widget.h" -#include -#include -#include +#include +#include #include #include +#include +#include -VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(parent), folderWidget(nullptr) +VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(parent) { - deckListModel = new DeckListModel(this); - deckListModel->setObjectName("visualDeckModel"); + // The model and proxy own all deck data, sorting and filtering. The view widgets below + // only display the proxy's rows and their FilterMatchRole, so nothing touches the + // filesystem outside the model. + storageModel = new VisualDeckStorageModel(this); + storageProxyModel = new VisualDeckStorageSortFilterProxyModel(this); + storageProxyModel->setSourceModel(storageModel); layout = new QVBoxLayout(this); layout->setSpacing(0); @@ -36,7 +44,7 @@ VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(pare searchWidget = new VisualDeckStorageSearchWidget(this); refreshButton = new QToolButton(this); - refreshButton->setIcon(QPixmap("theme:icons/reload")); + refreshButton->setIcon(themePixmap(QStringLiteral("icons/reload"))); refreshButton->setFixedSize(32, 32); connect(refreshButton, &QPushButton::clicked, this, &VisualDeckStorageWidget::refreshIfPossible); @@ -54,10 +62,12 @@ VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(pare // tag filter box tagFilterWidget = new VisualDeckStorageTagFilterWidget(this); - updateTagsVisibility(SettingsCache::instance().getVisualDeckStorageShowTagFilter()); + updateTagsVisibility(SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowTagFilter()); - deckPreviewSelectionAnimationEnabled = SettingsCache::instance().getVisualDeckStorageSelectionAnimation(); - connect(&SettingsCache::instance(), &SettingsCache::visualDeckStorageSelectionAnimationChanged, this, + deckPreviewSelectionAnimationEnabled = + SettingsCache::instance().visualDeckStorage().getVisualDeckStorageSelectionAnimation(); + connect(&SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::visualDeckStorageSelectionAnimationChanged, this, &VisualDeckStorageWidget::updateSelectionAnimationEnabled); // deck area @@ -71,6 +81,33 @@ VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(pare layout->addWidget(tagFilterWidget); layout->addWidget(scrollArea); + // The deck data changed (a load finished, or a mutation happened): re-evaluate the filters, + // since search/tag/color matches are computed against the data. Debounced so that a burst of + // load completions triggers a single re-application instead of one O(n) pass per deck. + refreshTimer = new QTimer(this); + refreshTimer->setSingleShot(true); + refreshTimer->setInterval(150); + connect(refreshTimer, &QTimer::timeout, this, [this] { + storageProxyModel->reapplyFilters(); + // A batch of decks finished loading: re-gather the tag chips from the visible decks once + // the burst settles instead of on every individual load. + tagFilterWidget->refreshTags(); + }); + connect(storageModel, &QAbstractItemModel::dataChanged, this, [this] { refreshTimer->start(); }); + connect(storageModel, &VisualDeckStorageModel::deckLoaded, this, [this] { refreshTimer->start(); }); + // A deck's file path changed: re-apply the sort, since orders like "filename" depend on it. + connect(storageModel, &VisualDeckStorageModel::deckFilePathChanged, this, [this] { storageProxyModel->resort(); }); + connect(sortWidget, &VisualDeckStorageSortWidget::sortOrderChanged, this, + &VisualDeckStorageWidget::updateSortOrder); + // The filter widgets only own their ui state. Pushing it into the proxy model + // happens here, so the children stay decoupled from the model layer. + connect(deckPreviewColorIdentityFilterWidget, &DeckPreviewColorIdentityFilterWidget::activeColorsChanged, this, + &VisualDeckStorageWidget::updateColorFilter); + connect(deckPreviewColorIdentityFilterWidget, &DeckPreviewColorIdentityFilterWidget::filterModeChanged, this, + &VisualDeckStorageWidget::updateColorFilter); + connect(searchWidget, &VisualDeckStorageSearchWidget::searchTextChanged, this, + &VisualDeckStorageWidget::updateSearchFilter); + connect(CardDatabaseManager::getInstance(), &CardDatabase::cardDatabaseLoadingFinished, this, &VisualDeckStorageWidget::createRootFolderWidget); @@ -119,6 +156,8 @@ void VisualDeckStorageWidget::retranslateUi() refreshButton->setToolTip(tr("Refresh loaded files")); quickSettingsWidget->setToolTip(tr("Visual Deck Storage Settings")); + + sortWidget->retranslateUi(); } /** @@ -130,72 +169,69 @@ const VisualDeckStorageQuickSettingsWidget *VisualDeckStorageWidget::settings() } /** - * Reapplies all sort and filter options by calling the appropriate update methods. + * Reapplies all sort and filter options by updating the proxy model. */ void VisualDeckStorageWidget::reapplySortAndFilters() { - updateSortOrder(); - updateTagFilter(); - updateColorFilter(); - updateSearchFilter(); + storageProxyModel->setSortOrder(sortWidget->currentSortOrder()); + storageProxyModel->reapplyFilters(); } +/** + * @brief Scans the deck folder and rebuilds the folder tree of deck previews. + */ void VisualDeckStorageWidget::createRootFolderWidget() { - folderWidget = new VisualDeckStorageFolderDisplayWidget(this, this, SettingsCache::instance().getDeckPath(), false, - quickSettingsWidget->getShowFolders()); + storageModel->setDeckPath(SettingsCache::instance().paths().getDeckPath()); + + folderWidget = + new VisualDeckStorageFolderDisplayWidget(this, this, QString(), false, quickSettingsWidget->getShowFolders()); scrollArea->setWidget(folderWidget); // this automatically destroys the old folderWidget scrollArea->widget()->setMaximumWidth(scrollArea->viewport()->width()); scrollArea->widget()->adjustSize(); - /* We have to schedule a QTimer here so that the sorting logic doesn't try to access widgets that haven't been - * processed by the event loop yet. Otherwise, deck sorting will intermittently segfault on some systems. - */ - QTimer::singleShot(0, this, &VisualDeckStorageWidget::reapplySortAndFilters); + // Sort and filter runs against the model data, so it is safe to apply immediately. + reapplySortAndFilters(); } void VisualDeckStorageWidget::updateShowFolders(bool enabled) { if (folderWidget) { folderWidget->updateShowFolders(enabled); - QTimer::singleShot(0, this, &VisualDeckStorageWidget::reapplySortAndFilters); } } void VisualDeckStorageWidget::updateSortOrder() { - if (folderWidget) { - sortWidget->sortFolder(folderWidget); - for (VisualDeckStorageFolderDisplayWidget *subFolderWidget : - folderWidget->findChildren()) { - sortWidget->sortFolder(subFolderWidget); - } - } + storageProxyModel->setSortOrder(sortWidget->currentSortOrder()); } void VisualDeckStorageWidget::updateTagFilter() { - if (folderWidget) { - tagFilterWidget->filterDecksBySelectedTags(folderWidget->findChildren()); - folderWidget->updateVisibility(); - } + const QStringList selected = tagFilterWidget->selectedTags(); + const QStringList excluded = tagFilterWidget->excludedTags(); + storageProxyModel->setTagFilter(QSet(selected.cbegin(), selected.cend()), + QSet(excluded.cbegin(), excluded.cend())); + // The visible deck set changed, so the chips are re-gathered from it. + tagFilterWidget->refreshTags(); } +/** + * Pushes the color identity filter widget's state into the proxy model. + */ void VisualDeckStorageWidget::updateColorFilter() { - if (folderWidget) { - deckPreviewColorIdentityFilterWidget->filterWidgets(folderWidget->findChildren()); - folderWidget->updateVisibility(); - } + storageProxyModel->setColorFilter(deckPreviewColorIdentityFilterWidget->getFilterMode(), + deckPreviewColorIdentityFilterWidget->getActiveColors()); } -void VisualDeckStorageWidget::updateSearchFilter() +/** + * Pushes the search bar's text into the proxy model. + */ +void VisualDeckStorageWidget::updateSearchFilter(const QString &text) { - if (folderWidget) { - searchWidget->filterWidgets(folderWidget->findChildren(), searchWidget->getSearchText()); - folderWidget->updateVisibility(); - } + storageProxyModel->setSearchText(text); } void VisualDeckStorageWidget::updateTagsVisibility(const bool visible) @@ -211,4 +247,4 @@ void VisualDeckStorageWidget::updateTagsVisibility(const bool visible) void VisualDeckStorageWidget::updateSelectionAnimationEnabled(const bool enabled) { deckPreviewSelectionAnimationEnabled = enabled; -} \ No newline at end of file +} diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.h index c3c0ae91b..fe6389414 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.h @@ -2,29 +2,30 @@ * @file visual_deck_storage_widget.h * @ingroup VisualDeckStorageWidgets */ -//! \todo Document this file. #ifndef VISUAL_DECK_STORAGE_WIDGET_H #define VISUAL_DECK_STORAGE_WIDGET_H -#include "../../deck_loader/deck_loader.h" -#include "../cards/card_size_widget.h" -#include "../quick_settings/settings_button_widget.h" -#include "deck_preview/deck_preview_color_identity_filter_widget.h" -#include "visual_deck_storage_folder_display_widget.h" -#include "visual_deck_storage_quick_settings_widget.h" -#include "visual_deck_storage_search_widget.h" -#include "visual_deck_storage_sort_widget.h" -#include "visual_deck_storage_tag_filter_widget.h" +#include "visual_deck_storage_model.h" +#include "visual_deck_storage_sort_filter_proxy_model.h" -#include +#include +#include +#include +#include +#include -class QSpinBox; +class QLabel; +class QResizeEvent; +class QShowEvent; +class QTimer; +class DeckPreviewColorIdentityFilterWidget; +class VisualDeckStorageFolderDisplayWidget; +class VisualDeckStorageQuickSettingsWidget; class VisualDeckStorageSearchWidget; class VisualDeckStorageSortWidget; class VisualDeckStorageTagFilterWidget; -class VisualDeckStorageFolderDisplayWidget; -class DeckPreviewColorIdentityFilterWidget; + class VisualDeckStorageWidget final : public QWidget { Q_OBJECT @@ -37,29 +38,43 @@ public: bool deckPreviewSelectionAnimationEnabled; [[nodiscard]] const VisualDeckStorageQuickSettingsWidget *settings() const; + [[nodiscard]] VisualDeckStorageModel *model() const + { + return storageModel; + } + [[nodiscard]] VisualDeckStorageSortFilterProxyModel *proxyModel() const + { + return storageProxyModel; + } public slots: - void createRootFolderWidget(); // Refresh the display of cards based on the current sorting option + /** + * @brief Starts scanning the deck folder and rebuilds the folder tree and previews. + */ + void createRootFolderWidget(); void updateShowFolders(bool enabled); - void updateTagFilter(); - void updateColorFilter(); - void updateSearchFilter(); void updateTagsVisibility(bool visible); void updateSelectionAnimationEnabled(bool enabled); void updateSortOrder(); + void updateTagFilter(); + void updateColorFilter(); + void updateSearchFilter(const QString &text); + +signals: + void deckLoadRequested(const QString &filePath); + void openDeckEditor(const LoadedDeck &deck); + +protected: void resizeEvent(QResizeEvent *event) override; void showEvent(QShowEvent *event) override; -signals: - void bannerCardsRefreshed(); - void deckLoadRequested(const QString &filePath); - void openDeckEditor(const LoadedDeck &deck); +private: + void reapplySortAndFilters(); private: QVBoxLayout *layout; QWidget *searchAndSortContainer; QHBoxLayout *searchAndSortLayout; - DeckListModel *deckListModel; QLabel *databaseLoadIndicator; VisualDeckStorageSortWidget *sortWidget; VisualDeckStorageSearchWidget *searchWidget; @@ -67,9 +82,10 @@ private: QToolButton *refreshButton; VisualDeckStorageQuickSettingsWidget *quickSettingsWidget; QScrollArea *scrollArea; - VisualDeckStorageFolderDisplayWidget *folderWidget; - - void reapplySortAndFilters(); + VisualDeckStorageFolderDisplayWidget *folderWidget = nullptr; + VisualDeckStorageModel *storageModel = nullptr; + VisualDeckStorageSortFilterProxyModel *storageProxyModel = nullptr; + QTimer *refreshTimer = nullptr; ///< Coalesces the re-apply/refresh burst following a batch of deck loads. }; #endif // VISUAL_DECK_STORAGE_WIDGET_H diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index 69d3260bc..722df90ef 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -19,9 +19,11 @@ ***************************************************************************/ #include "window_main.h" +#include "../client/latency_status_widget.h" #include "../client/network/update/client/client_update_checker.h" #include "../client/network/update/client/release_channel.h" #include "../client/settings/cache_settings.h" +#include "../client/settings/shortcuts_settings.h" #include "../interface/widgets/dialogs/dlg_edit_tokens.h" #include "../interface/widgets/dialogs/dlg_local_game_options.h" #include "../interface/widgets/dialogs/dlg_manage_sets.h" @@ -30,13 +32,24 @@ #include "../interface/widgets/dialogs/dlg_tip_of_the_day.h" #include "../interface/widgets/dialogs/dlg_update.h" #include "../interface/widgets/dialogs/dlg_view_log.h" +#include "../interface/widgets/onboarding/first_run_wizard.h" +#include "../interface/widgets/settings_page/general_settings_page.h" #include "../interface/widgets/tabs/tab_game.h" +#include "../interface/widgets/tabs/tab_server.h" #include "../interface/widgets/tabs/tab_supervisor.h" #include "../main.h" +#include "intents/contexts/context_connect_to_server.h" +#include "intents/contexts/context_join_room.h" +#include "intents/intent_connect_to_server.h" +#include "intents/intent_login.h" +#include "intents/intent_open_server_room_by_name.h" +#include "intents/url_parser.h" #include "logger.h" +#include "pixel_map_generator.h" #include "version_string.h" #include "widgets/dialogs/dlg_connect.h" #include "widgets/server/handle_public_servers.h" +#include "widgets/tabs/api/commander_spellbook/handle_commander_brackets.h" #include "widgets/utility/get_text_with_max.h" #include @@ -47,6 +60,7 @@ #include #include #include +#include #include #include #include @@ -66,10 +80,21 @@ #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #define GITHUB_PAGES_URL "https://cockatrice.github.io" -#define GITHUB_CONTRIBUTORS_URL "https://github.com/Cockatrice/Cockatrice/graphs/contributors?type=c" -#define GITHUB_CONTRIBUTE_URL "https://github.com/Cockatrice/Cockatrice#cockatrice" +#define GITHUB_CONTRIBUTORS_URL "https://github.com/Cockatrice/Cockatrice/graphs/contributors" +#define GITHUB_CONTRIBUTE_URL "https://github.com/Cockatrice/Cockatrice#" #define GITHUB_TRANSIFEX_TRANSLATORS_URL "https://github.com/Cockatrice/Cockatrice/wiki/Translator-Hall-of-Fame" #define GITHUB_TRANSLATOR_FAQ_URL "https://github.com/Cockatrice/Cockatrice/wiki/Translation-FAQ" #define GITHUB_ISSUES_URL "https://github.com/Cockatrice/Cockatrice/issues" @@ -177,7 +202,7 @@ void MainWindow::startLocalGame(const LocalGameOptions &options) void MainWindow::actWatchReplay() { QFileDialog dlg(this, tr("Load replay")); - dlg.setDirectory(SettingsCache::instance().getReplaysPath()); + dlg.setDirectory(SettingsCache::instance().paths().getReplaysPath()); dlg.setNameFilters(QStringList() << QObject::tr("Cockatrice replays (*.cor)")); if (!dlg.exec()) { return; @@ -222,6 +247,8 @@ void MainWindow::actFullScreen(bool checked) void MainWindow::actSettings() { DlgSettings dlg(this); + auto *generalPage = qobject_cast(dlg.page(DlgSettings::GeneralPage)); + connect(generalPage, &GeneralSettingsPage::cardDatabaseUpdateRequested, this, &MainWindow::actCheckCardUpdates); dlg.exec(); } @@ -248,7 +275,8 @@ void MainWindow::actAbout() GITHUB_TROUBLESHOOTING_URL + "'>" + tr("Troubleshooting") + "
" + "" + tr("F.A.Q.") + "
"), QMessageBox::Ok, this); - mb.setIconPixmap(QPixmap("theme:cockatrice").scaled(64, 64, Qt::KeepAspectRatio, Qt::SmoothTransformation)); + mb.setIconPixmap( + themePixmap(QStringLiteral("cockatrice")).scaled(64, 64, Qt::KeepAspectRatio, Qt::SmoothTransformation)); mb.setTextInteractionFlags(Qt::TextBrowserInteraction); mb.exec(); } @@ -300,7 +328,7 @@ void MainWindow::retranslateUi() aRegister->setText(tr("&Register to server...")); aForgotPassword->setText(tr("&Restore password...")); aSettings->setText(tr("&Settings...")); - aSettings->setIcon(QPixmap("theme:icons/settings")); + aSettings->setIcon(themePixmap(QStringLiteral("icons/settings"))); aExit->setText(tr("&Exit")); #if defined(__APPLE__) /* For OSX */ @@ -328,6 +356,7 @@ void MainWindow::retranslateUi() aStatusBar->setText(tr("Show Status Bar")); aViewLog->setText(tr("View &Debug Log")); aOpenSettingsFolder->setText(tr("Open Settings Folder")); + aFirstRunWizard->setText(tr("Re-run Onboarding Wizard...")); aShow->setText(tr("Show/Hide")); @@ -382,12 +411,15 @@ void MainWindow::createActions() connect(aCheckCardUpdatesBackground, &QAction::triggered, this, &MainWindow::actCheckCardUpdatesBackground); aStatusBar = new QAction(this); aStatusBar->setCheckable(true); - aStatusBar->setChecked(SettingsCache::instance().getShowStatusBar()); - connect(aStatusBar, &QAction::triggered, &SettingsCache::instance(), &SettingsCache::setShowStatusBar); + aStatusBar->setChecked(SettingsCache::instance().userInterface().getShowStatusBar()); + connect(aStatusBar, &QAction::triggered, &SettingsCache::instance().userInterface(), + &InterfaceSettings::setShowStatusBar); aViewLog = new QAction(this); connect(aViewLog, &QAction::triggered, this, &MainWindow::actViewLog); aOpenSettingsFolder = new QAction(this); connect(aOpenSettingsFolder, &QAction::triggered, this, &MainWindow::actOpenSettingsFolder); + aFirstRunWizard = new QAction(this); + connect(aFirstRunWizard, &QAction::triggered, this, [this] { runFirstRunWizard(); }); aShow = new QAction(this); connect(aShow, &QAction::triggered, this, &MainWindow::actShow); @@ -466,17 +498,20 @@ void MainWindow::createMenus() helpMenu->addAction(aStatusBar); helpMenu->addAction(aViewLog); helpMenu->addAction(aOpenSettingsFolder); + helpMenu->addSeparator(); + helpMenu->addAction(aFirstRunWizard); } MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent), localServer(nullptr), bHasActivated(false), askedForDbUpdater(false), cardUpdateProcess(nullptr), logviewDialog(nullptr) { - connect(&SettingsCache::instance(), &SettingsCache::pixmapCacheSizeChanged, this, + connect(&SettingsCache::instance().cacheStorage(), &CacheStorageSettings::pixmapCacheSizeChanged, this, &MainWindow::pixmapCacheSizeChanged); - pixmapCacheSizeChanged(SettingsCache::instance().getPixmapCacheSize()); + pixmapCacheSizeChanged(SettingsCache::instance().cacheStorage().getPixmapCacheSize()); connectionController = new ConnectionController(this, this); + urlParser = new IntentUrlParser(this, this); createActions(); createMenus(); @@ -488,6 +523,7 @@ MainWindow::MainWindow(QWidget *parent) connect(tabSupervisor, &TabSupervisor::setMenu, this, &MainWindow::updateTabMenu); connect(tabSupervisor, &TabSupervisor::localGameEnded, this, &MainWindow::localGameEnded); connect(tabSupervisor, &TabSupervisor::showWindowIfHidden, this, &MainWindow::showWindowIfHidden); + connect(tabSupervisor, &TabSupervisor::cockatriceLinkActivated, this, &MainWindow::handleCockatriceLink); connect(connectionController, &ConnectionController::tabSupervisorStartRequested, tabSupervisor, &TabSupervisor::start); connect(connectionController, &ConnectionController::tabSupervisorStopRequested, tabSupervisor, @@ -508,9 +544,15 @@ MainWindow::MainWindow(QWidget *parent) } // status bar - connect(&SettingsCache::instance(), &SettingsCache::showStatusBarChanged, this, + connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::showStatusBarChanged, this, [this](bool show) { statusBar()->setVisible(show); }); - statusBar()->setVisible(SettingsCache::instance().getShowStatusBar()); + statusBar()->setVisible(SettingsCache::instance().userInterface().getShowStatusBar()); + + latencyStatus = new LatencyStatusWidget(this); + statusBar()->addPermanentWidget(latencyStatus); + + connect(connectionController, &ConnectionController::pingStatsUpdated, latencyStatus, + &LatencyStatusWidget::updateData); connect(&SettingsCache::instance().shortcuts(), &ShortcutsSettings::shortCutChanged, this, &MainWindow::refreshShortcuts); @@ -527,45 +569,59 @@ MainWindow::MainWindow(QWidget *parent) // run startup check async QTimer::singleShot(0, this, &MainWindow::startupConfigCheck); + QTimer::singleShot(0, this, &MainWindow::applyStartupDestination); } void MainWindow::startupConfigCheck() { + // checkUnknownSets() is intentionally deferred from the card database load + // (which runs in main() before MainWindow exists) so that + // cardDatabaseNewSetsFound / cardDatabaseAllNewSetsEnabled have live + // receivers when emitted. + CardDatabaseManager::getInstance()->checkUnknownSets(); + if (SettingsCache::instance().debug().getLocalGameOnStartup()) { LocalGameOptions options; options.numberPlayers = SettingsCache::instance().debug().getLocalGamePlayerCount(); startLocalGame(options); } - if (SettingsCache::instance().getCheckUpdatesOnStartup()) { + if (SettingsCache::instance().updates().getCheckUpdatesOnStartup()) { actCheckClientUpdates(); } - if (SettingsCache::instance().getClientVersion() == CLIENT_INFO_NOT_SET) { + actCheckCommanderBracketDefinitionUpdates(); + + if (SettingsCache::instance().network().getClientVersion() == CLIENT_INFO_NOT_SET) { // no config found, 99% new clean install qCInfo(WindowMainStartupVersionLog) << "Startup: old client version empty, assuming first start after clean install"; - alertForcedOracleRun(VERSION_STRING, false); SettingsCache::instance().downloads().resetToDefaultURLs(); // populate the download urls - SettingsCache::instance().setClientVersion(VERSION_STRING); - } else if (SettingsCache::instance().getClientVersion() != VERSION_STRING) { + SettingsCache::instance().network().setClientVersion(VERSION_STRING); + actCheckServerUpdates(); + runFirstRunWizard(); + + if (QString(VERSION_STRING).contains("custom", Qt::CaseInsensitive)) { + SettingsCache::instance().updates().setCheckUpdatesOnStartup(false); + } else if (QString(VERSION_STRING).contains("beta", Qt::CaseInsensitive)) { + SettingsCache::instance().updates().setUpdateReleaseChannelIndex(1); + } + } else if (SettingsCache::instance().network().getClientVersion() != VERSION_STRING) { // config found, from another (presumably older) version qCInfo(WindowMainStartupVersionLog) - << "Startup: old client version" << SettingsCache::instance().getClientVersion() + << "Startup: old client version" << SettingsCache::instance().network().getClientVersion() << "differs, assuming first start after update"; - if (SettingsCache::instance().getNotifyAboutNewVersion()) { + if (SettingsCache::instance().updates().getNotifyAboutNewVersion()) { alertForcedOracleRun(VERSION_STRING, true); - } else { - const auto reloadOk0 = QtConcurrent::run([] { CardDatabaseManager::getInstance()->loadCardDatabases(); }); } qCInfo(WindowMainStartupShortcutsLog) << "Migrating shortcuts after update detected."; SettingsCache::instance().shortcuts().migrateShortcuts(); - if (SettingsCache::instance().getCheckUpdatesOnStartup()) { + if (SettingsCache::instance().updates().getCheckUpdatesOnStartup()) { if (QString(VERSION_STRING).contains("custom", Qt::CaseInsensitive)) { qCInfo(WindowMainStartupShortcutsLog) << "Update has changed to custom version, disabling auto update"; - SettingsCache::instance().setCheckUpdatesOnStartup(Qt::Unchecked); + SettingsCache::instance().updates().setCheckUpdatesOnStartup(false); } else { int channel = 0; if (QString(VERSION_STRING).contains("beta", Qt::CaseInsensitive)) { @@ -573,18 +629,18 @@ void MainWindow::startupConfigCheck() } if (SettingsCache::instance().getUpdateReleaseChannelIndex() != channel) { qCInfo(WindowMainStartupShortcutsLog) << "Update has changed beta state, updating release channel."; - SettingsCache::instance().setUpdateReleaseChannelIndex(channel); + SettingsCache::instance().updates().setUpdateReleaseChannelIndex(channel); } } } - SettingsCache::instance().setClientVersion(VERSION_STRING); + SettingsCache::instance().network().setClientVersion(VERSION_STRING); } else { // previous config from this version found qCInfo(WindowMainStartupVersionLog) << "Startup: found config with current version"; - if (SettingsCache::instance().getCardUpdateCheckRequired()) { - if (SettingsCache::instance().getStartupCardUpdateCheckPromptForUpdate()) { + if (SettingsCache::instance().updates().getCardUpdateCheckRequired()) { + if (SettingsCache::instance().updates().getStartupCardUpdateCheckPromptForUpdate()) { auto startupCardCheckDialog = new DlgStartupCardCheck(this); if (startupCardCheckDialog->exec() == QDialog::Accepted) { @@ -596,34 +652,125 @@ void MainWindow::startupConfigCheck() actCheckCardUpdatesBackground(); break; case 2: // background + always - SettingsCache::instance().setStartupCardUpdateCheckPromptForUpdate(false); - SettingsCache::instance().setStartupCardUpdateCheckAlwaysUpdate(true); + SettingsCache::instance().updates().setStartupCardUpdateCheckPromptForUpdate(false); + SettingsCache::instance().updates().setStartupCardUpdateCheckAlwaysUpdate(true); actCheckCardUpdatesBackground(); break; case 3: // don't prompt again + don't run - SettingsCache::instance().setStartupCardUpdateCheckPromptForUpdate(false); - SettingsCache::instance().setStartupCardUpdateCheckAlwaysUpdate(false); + SettingsCache::instance().updates().setStartupCardUpdateCheckPromptForUpdate(false); + SettingsCache::instance().updates().setStartupCardUpdateCheckAlwaysUpdate(false); break; default: break; } } - } else if (SettingsCache::instance().getStartupCardUpdateCheckAlwaysUpdate()) { + } else if (SettingsCache::instance().updates().getStartupCardUpdateCheckAlwaysUpdate()) { actCheckCardUpdatesBackground(); } } - const auto reloadOk1 = QtConcurrent::run([] { CardDatabaseManager::getInstance()->loadCardDatabases(); }); - // Run the tips dialog only on subsequent startups. // On the first run after an install/update the startup is already crowded enough - if (tip->successfulInit && SettingsCache::instance().getShowTipsOnStartup() && tip->newTipsAvailable) { + if (tip->successfulInit && SettingsCache::instance().personal().getShowTipsOnStartup() && + tip->newTipsAvailable) { tip->raise(); tip->show(); } } } +void MainWindow::runFirstRunWizard() +{ + auto *wizard = new FirstRunWizard(this); + wizard->setAttribute(Qt::WA_DeleteOnClose); + + connect(wizard, &FirstRunWizard::cardDatabaseUpdateRequested, this, &MainWindow::actCheckCardUpdatesBackground); + connect(wizard, &FirstRunWizard::manualCardDatabaseSetupRequested, this, &MainWindow::actCheckCardUpdates); + connect(this, &MainWindow::cardDatabaseUpdateFinished, wizard, &FirstRunWizard::onCardDatabaseUpdateFinished); + connect(this, &MainWindow::cardDatabaseUpdateProgress, wizard, &FirstRunWizard::onCardDatabaseUpdateProgress); + connect(wizard, &FirstRunWizard::registerRequested, connectionController, &ConnectionController::registerToServer); + connect(wizard, &FirstRunWizard::connectRequested, connectionController, &ConnectionController::connectToServer); + + wizard->setModal(true); + wizard->show(); +} + +/** + * Drives the server-based startup destinations (Server lobby, Server Room) through the intent + * system: fetch saved credentials, connect to the configured server, then land on the Lobby or + * join the configured room by name. + */ +void MainWindow::applyStartupDestination() +{ + // An explicit command-line connect takes precedence over the startup destination. + if (!connectTo.isEmpty()) { + return; + } + + const int destination = SettingsCache::instance().tabs().getStartupTabIndex(); + if (destination != StartupTab::StartupTabServer && destination != StartupTab::StartupTabServerRoom) { + return; + } + + const QString host = SettingsCache::instance().tabs().getStartupServerHost(); + const QString port = SettingsCache::instance().tabs().getStartupServerPort(); + if (host.isEmpty() || port.isEmpty()) { + qCWarning(WindowMainStartupLog) << "Startup destination needs a configured server"; + return; + } + + auto serverContext = std::make_shared(); + serverContext->hostname = host; + serverContext->port = port; + + auto *credentials = new IntentGetLoginCredentials(serverContext.get()); + auto *connector = new IntentConnectToServer(getRemoteClient(), serverContext.get()); + + connect(credentials, &Intent::finished, connector, &Intent::execute); + connect(credentials, &Intent::failed, this, &MainWindow::startupDestinationFailed); + connect(connector, &Intent::finished, this, + [this, destination, serverContext]() { onStartupDestinationConnected(destination, *serverContext); }); + connect(connector, &Intent::failed, this, &MainWindow::startupDestinationFailed); + + credentials->execute(); +} + +void MainWindow::onStartupDestinationConnected(int destination, const ContextConnectToServer &serverContext) +{ + // The server tab must exist: it is what requests the room list. + if (!tabSupervisor->getTabServer()) { + tabSupervisor->openTabServer(); + } + + if (destination == StartupTab::StartupTabServerRoom) { + auto roomContext = std::make_unique(); + roomContext->serverContext = serverContext; + auto *roomIntent = new IntentOpenServerRoomByName(tabSupervisor, getRemoteClient(), std::move(roomContext), + SettingsCache::instance().tabs().getStartupRoomName()); + roomIntent->setParent(this); + connect(roomIntent, &Intent::failed, this, &MainWindow::startupDestinationFailed); + roomIntent->execute(); + return; + } + + if (tabSupervisor->getTabServer()) { + tabSupervisor->setCurrentWidget(tabSupervisor->getTabServer()); + } else { + qCWarning(WindowMainStartupLog) << "Startup destination: server tab could not be opened"; + } +} + +void MainWindow::startupDestinationFailed(const QString &reason) +{ + qCWarning(WindowMainStartupLog) << "Startup destination failed:" << reason; +} + +bool MainWindow::startupDestinationConnectsToServer() const +{ + const int destination = SettingsCache::instance().tabs().getStartupTabIndex(); + return destination == StartupTab::StartupTabServer || destination == StartupTab::StartupTabServerRoom; +} + void MainWindow::alertForcedOracleRun(const QString &version, bool isUpdate) { if (isUpdate) { @@ -640,6 +787,7 @@ void MainWindow::alertForcedOracleRun(const QString &version, bool isUpdate) actCheckCardUpdates(); actCheckServerUpdates(); + actCheckCommanderBracketDefinitionUpdates(); } MainWindow::~MainWindow() @@ -674,7 +822,7 @@ void MainWindow::createTrayIcon() trayIcon = new QSystemTrayIcon(this); trayIcon->setContextMenu(trayIconMenu); - trayIcon->setIcon(QPixmap("theme:cockatrice")); + trayIcon->setIcon(themePixmap(QStringLiteral("cockatrice"))); trayIcon->show(); } @@ -701,6 +849,17 @@ void MainWindow::closeEvent(QCloseEvent *event) } bClosingDown = true; + if (cardUpdateProcess && cardUpdateProcess->state() != QProcess::NotRunning) { + if (QMessageBox::question(this, tr("Are you sure?"), + tr("A card database update is still running. Quitting now will cancel it.\n" + "Are you sure you want to quit?"), + QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::No) { + event->ignore(); + bClosingDown = false; + return; + } + } + if (!tabSupervisor->close()) { event->ignore(); bClosingDown = false; @@ -725,7 +884,8 @@ void MainWindow::changeEvent(QEvent *event) connectionController->connectToServerDirect(connectTo.host(), connectTo.port(), connectTo.userName(), connectTo.password()); } else if (SettingsCache::instance().servers().getAutoConnect() && - !SettingsCache::instance().debug().getLocalGameOnStartup()) { + !SettingsCache::instance().debug().getLocalGameOnStartup() && + !startupDestinationConnectsToServer()) { qCInfo(WindowMainStartupAutoconnectLog) << "Attempting auto-connect..."; DlgConnect dlg(this); connectionController->connectToServerDirect(dlg.getHost(), static_cast(dlg.getPort()), @@ -751,6 +911,11 @@ void MainWindow::showWindowIfHidden() show(); } +void MainWindow::handleCockatriceLink(const QString &url) +{ + urlParser->handle(url); +} + void MainWindow::cardDatabaseLoadingFailed() { if (askedForDbUpdater) { @@ -780,7 +945,7 @@ void MainWindow::cardDatabaseLoadingFailed() void MainWindow::cardDatabaseNewSetsFound(int numUnknownSets, QStringList unknownSetsNames) { - if (SettingsCache::instance().getAlwaysEnableNewSets()) { + if (SettingsCache::instance().updates().getAlwaysEnableNewSets()) { CardDatabaseManager::getInstance()->enableAllUnknownSets(); const auto reloadOk1 = QtConcurrent::run([] { CardDatabaseManager::getInstance()->reloadCardDatabasesAndNotify(); }); @@ -812,7 +977,7 @@ void MainWindow::cardDatabaseNewSetsFound(int numUnknownSets, QStringList unknow CardDatabaseManager::getInstance()->enableAllUnknownSets(); const auto reloadOk1 = QtConcurrent::run([] { CardDatabaseManager::getInstance()->reloadCardDatabasesAndNotify(); }); - SettingsCache::instance().setAlwaysEnableNewSets(true); + SettingsCache::instance().updates().setAlwaysEnableNewSets(true); } else if (msgBox.clickedButton() == noButton) { CardDatabaseManager::getInstance()->markAllSetsAsKnown(); } else if (msgBox.clickedButton() == settingsButton) { @@ -888,7 +1053,7 @@ void MainWindow::createCardUpdateProcess(bool background) if (dir.exists(binaryName)) { updaterCmd = dir.absoluteFilePath(binaryName); - } else { // try and find the directory oracle is stored in the build directory + } else { // try and find the directory Oracle is stored in the build directory QDir findLocalDir(dir); findLocalDir.cdUp(); findLocalDir.cd(getCardUpdaterBinaryName()); @@ -902,19 +1067,57 @@ void MainWindow::createCardUpdateProcess(bool background) QMessageBox::warning(this, tr("Error"), tr("Unable to run the card database updater: ") + dir.absoluteFilePath(binaryName)); exitCardDatabaseUpdate(); + emit cardDatabaseUpdateFinished(false); return; } if (!background) { cardUpdateProcess->start(updaterCmd, QStringList()); } else { + cardUpdateOutputBuffer.clear(); + connect(cardUpdateProcess, &QProcess::readyReadStandardOutput, this, &MainWindow::cardUpdateProgressOutput); cardUpdateProcess->start(updaterCmd, QStringList("-b")); statusBar()->showMessage(tr("Card database update running.")); } } +void MainWindow::cardUpdateProgressOutput() +{ + if (!cardUpdateProcess) { + return; + } + cardUpdateOutputBuffer.append(cardUpdateProcess->readAllStandardOutput()); + while (true) { + const int newline = cardUpdateOutputBuffer.indexOf('\n'); + if (newline < 0) { + break; + } + const QByteArray line = cardUpdateOutputBuffer.left(newline).trimmed(); + cardUpdateOutputBuffer.remove(0, newline + 1); + // Protocol emitted by `oracle -b`: "PROGRESS " + if (!line.startsWith("PROGRESS ")) { + continue; + } + const QList parts = line.split(' '); + if (parts.size() != 4) { + continue; + } + bool doneOk = false; + bool totalOk = false; + const qint64 done = parts.at(2).toLongLong(&doneOk); + const qint64 total = parts.at(3).toLongLong(&totalOk); + if (!doneOk || !totalOk || done < 0 || total < 0) { + continue; + } + emit cardDatabaseUpdateProgress(QString::fromLatin1(parts.at(1)), done, total); + } +} + void MainWindow::exitCardDatabaseUpdate() { + if (!cardUpdateProcess) { + return; + } cardUpdateProcess->deleteLater(); cardUpdateProcess = nullptr; statusBar()->clearMessage(); @@ -952,14 +1155,19 @@ void MainWindow::cardUpdateError(QProcess::ProcessError err) exitCardDatabaseUpdate(); QMessageBox::warning(this, tr("Error"), tr("The card database updater exited with an error:\n%1").arg(error)); + emit cardDatabaseUpdateFinished(false); } -void MainWindow::cardUpdateFinished(int, QProcess::ExitStatus exitStatus) +void MainWindow::cardUpdateFinished(int exitCode, QProcess::ExitStatus exitStatus) { + cardUpdateProgressOutput(); // drain any progress lines not yet parsed + + const bool success = (exitStatus == QProcess::NormalExit) && (exitCode == 0); if (exitStatus == QProcess::NormalExit) { - SettingsCache::instance().setLastCardUpdateCheck(QDateTime::currentDateTime().date()); + SettingsCache::instance().updates().setLastCardUpdateCheck(QDateTime::currentDateTime().date()); } exitCardDatabaseUpdate(); + emit cardDatabaseUpdateFinished(success); } void MainWindow::actCheckServerUpdates() @@ -984,6 +1192,16 @@ void MainWindow::checkClientUpdatesFinished(bool needToUpdate, bool /* isCompati } } +void MainWindow::actCheckCommanderBracketDefinitionUpdates() +{ + auto *handler = new HandleCommanderBrackets(this); + + connect(handler, &HandleCommanderBrackets::sigBracketDefinitionsDownloaded, this, + []() { qDebug() << "Bracket definitions loaded"; }); + + handler->downloadBracketDefinitions(); +} + void MainWindow::refreshShortcuts() { ShortcutsSettings &shortcuts = SettingsCache::instance().shortcuts(); @@ -1004,7 +1222,7 @@ void MainWindow::refreshShortcuts() void MainWindow::actOpenCustomFolder() { - QString dir = SettingsCache::instance().getCustomPicsPath(); + QString dir = SettingsCache::instance().paths().getCustomPicsPath(); QDesktopServices::openUrl(QUrl::fromLocalFile(dir)); } diff --git a/cockatrice/src/interface/window_main.h b/cockatrice/src/interface/window_main.h index 5f631ddc3..08481fd36 100644 --- a/cockatrice/src/interface/window_main.h +++ b/cockatrice/src/interface/window_main.h @@ -25,6 +25,7 @@ #ifndef WINDOW_H #define WINDOW_H +#include "../client/lag_monitor.h" #include "connection_controller/remote_connection_controller.h" #include "widgets/dialogs/dlg_local_game_options.h" @@ -49,16 +50,29 @@ class GameReplay; class HandlePublicServers; class LocalClient; class LocalServer; +class QLabel; +class LatencyStatusWidget; class QThread; class RemoteClient; class ServerInfo_User; class TabSupervisor; class WndSets; class DlgTipOfTheDay; +struct ContextConnectToServer; +class IntentUrlParser; class MainWindow : public QMainWindow { Q_OBJECT +signals: + /** @brief Emitted after the background card-database update subprocess exits. */ + void cardDatabaseUpdateFinished(bool success); + + /** @brief Emitted while the background card-database update subprocess runs. + * @p stage is one of "download", "scan" or "import"; @p done/@p total + * are byte counts for the first two stages and set indices for "import". */ + void cardDatabaseUpdateProgress(const QString &stage, qint64 done, qint64 total); + public slots: void actCheckCardUpdates(); void actCheckCardUpdatesBackground(); @@ -83,15 +97,18 @@ private slots: void actOpenSettingsFolder(); void actShow(); void showWindowIfHidden(); + void handleCockatriceLink(const QString &url); void cardUpdateError(QProcess::ProcessError err); void cardUpdateFinished(int exitCode, QProcess::ExitStatus exitStatus); + void cardUpdateProgressOutput(); void refreshShortcuts(); void cardDatabaseLoadingFailed(); void cardDatabaseNewSetsFound(int numUnknownSets, QStringList unknownSetsNames); void cardDatabaseAllNewSetsEnabled(); void checkClientUpdatesFinished(bool needToUpdate, bool isCompatible, Release *release); + void actCheckCommanderBracketDefinitionUpdates(); void actOpenCustomFolder(); void actOpenCustomsetsFolder(); @@ -104,6 +121,11 @@ private slots: void startupConfigCheck(); void alertForcedOracleRun(const QString &version, bool isUpdate); + void applyStartupDestination(); + void onStartupDestinationConnected(int destination, const ContextConnectToServer &serverContext); + void startupDestinationFailed(const QString &reason); + [[nodiscard]] bool startupDestinationConnectsToServer() const; + private: static const QString appName; static const QStringList fileNameFilters; @@ -113,6 +135,9 @@ private: void createTrayIcon(); int getNextCustomSetPrefix(QDir dataDir); + + void runFirstRunWizard(); + inline QString getCardUpdaterBinaryName() { return "oracle"; @@ -128,15 +153,19 @@ private: QAction *aConnect, *aDisconnect, *aRegister, *aForgotPassword, *aSinglePlayer, *aWatchReplay, *aFullScreen; QAction *aManageSets, *aEditTokens, *aOpenCustomFolder, *aOpenCustomsetsFolder, *aAddCustomSet, *aReloadCardDatabase; - QAction *aTips, *aUpdate, *aCheckCardUpdates, *aCheckCardUpdatesBackground, *aStatusBar, *aViewLog, - *aOpenSettingsFolder; + QAction *aTips, *aUpdate, *aCheckCardUpdates, *aCheckCardUpdatesBackground, *aFirstRunWizard, *aStatusBar, + *aViewLog, *aOpenSettingsFolder; TabSupervisor *tabSupervisor; + IntentUrlParser *urlParser; WndSets *wndSets; ConnectionController *connectionController; LocalServer *localServer; + LagMonitor lagMonitor; ///< watches the main thread for event loop stalls + LatencyStatusWidget *latencyStatus = nullptr; ///< status bar widget with live round-trip stats and history graph bool bHasActivated, askedForDbUpdater; QProcess *cardUpdateProcess; + QByteArray cardUpdateOutputBuffer; DlgViewLog *logviewDialog; GameReplay *replay; DlgTipOfTheDay *tip; @@ -150,6 +179,11 @@ public: } ~MainWindow() override; + RemoteClient *getRemoteClient() const + { + return connectionController->client(); + } + TabSupervisor *getTabSupervisor() const { return tabSupervisor; diff --git a/cockatrice/src/main.cpp b/cockatrice/src/main.cpp index ad68d4be9..829e08742 100644 --- a/cockatrice/src/main.cpp +++ b/cockatrice/src/main.cpp @@ -23,12 +23,17 @@ #include "client/network/update/card_spoiler/spoiler_background_updater.h" #include "client/settings/cache_settings.h" #include "client/sound_engine.h" +#include "client/url_scheme_event_filter.h" #include "database/interface/settings_card_preference_provider.h" +#include "interface/intents/intent_open_local_deck.h" +#include "interface/intents/url_parser.h" #include "interface/logger.h" #include "interface/pixel_map_generator.h" #include "interface/theme_manager.h" #include "interface/widgets/dialogs/dlg_settings.h" +#include "interface/widgets/tabs/tab_supervisor.h" #include "interface/window_main.h" +#include "single_instance_manager.h" #include "version_string.h" #include @@ -37,10 +42,18 @@ #include #include #include +#include #include #include #include #include +#include +#include +#include +#include +#include +#include +#include QTranslator *translator, *qtTranslator; RNG_Abstract *rng; @@ -125,14 +138,10 @@ LONG WINAPI CockatriceUnhandledExceptionFilter(EXCEPTION_POINTERS *exceptionPoin void installNewTranslator() { - QString lang = SettingsCache::instance().getLang(); + QString lang = SettingsCache::instance().personal().getLang(); QString qtNameHint = "qt_" + lang; -#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) QString qtTranslationPath = QLibraryInfo::path(QLibraryInfo::TranslationsPath); -#else - QString qtTranslationPath = QLibraryInfo::location(QLibraryInfo::TranslationsPath); -#endif bool qtTranslationLoaded = qtTranslator->load(qtNameHint, qtTranslationPath); if (!qtTranslationLoaded) { @@ -174,6 +183,7 @@ int main(int argc, char *argv[]) SetUnhandledExceptionFilter(CockatriceUnhandledExceptionFilter); #endif + // Logging setup #ifdef Q_OS_APPLE // /cockatrice/cockatrice.app/Contents/MacOS/cockatrice const QByteArray configPath = "../../../qtlogging.ini"; @@ -191,15 +201,29 @@ int main(int argc, char *argv[]) // Set the QT_LOGGING_CONF environment variable qputenv("QT_LOGGING_CONF", configPath); } + qSetMessagePattern( "\033[0m[%{time yyyy-MM-dd h:mm:ss.zzz} " "%{if-debug}\033[36mD%{endif}%{if-info}\033[32mI%{endif}%{if-warning}\033[33mW%{endif}%{if-critical}\033[31mC%{" "endif}%{if-fatal}\033[1;31mF%{endif}\033[0m] [%{function}] - %{message} [%{file}:%{line}]"); QApplication app(argc, argv); +#ifdef Q_OS_MAC + UrlSchemeEventFilter cockatriceFilter(QStringList{QStringLiteral("cockatrice")}); + + QStringList pendingMacUrls; + + const auto cocoaBufferConn = + QObject::connect(&cockatriceFilter, &UrlSchemeEventFilter::urlReceived, + [&pendingMacUrls](const QString &url) { pendingMacUrls.append(url); }); + + app.installEventFilter(&cockatriceFilter); +#endif + QObject::connect(&app, &QApplication::lastWindowClosed, &app, &QApplication::quit); qInstallMessageHandler(CockatriceLogger); + #ifdef Q_OS_WIN app.addLibraryPath(app.applicationDirPath() + "/plugins"); #endif @@ -207,7 +231,7 @@ int main(int argc, char *argv[]) // These values are only used by the settings loader/saver // Wrong or outdated values are kept to not break things QCoreApplication::setOrganizationName("Cockatrice"); - QCoreApplication::setOrganizationDomain("cockatrice.de"); + QCoreApplication::setOrganizationDomain("cockatrice.github.io"); QCoreApplication::setApplicationName("Cockatrice"); QCoreApplication::setApplicationVersion(VERSION_STRING); @@ -215,6 +239,7 @@ int main(int argc, char *argv[]) qApp->setAttribute(Qt::AA_DontShowIconsInMenus, true); #endif + // Translations #ifdef Q_OS_MAC translationPath = qApp->applicationDirPath() + "/../Resources/translations"; #elif defined(Q_OS_WIN) @@ -223,8 +248,9 @@ int main(int argc, char *argv[]) translationPath = qApp->applicationDirPath() + "/../share/cockatrice/translations"; #endif + // Command-line parser QCommandLineParser parser; - parser.setApplicationDescription("Cockatrice"); + parser.setApplicationDescription("Cockatrice Client"); parser.addHelpOption(); parser.addVersionOption(); @@ -238,7 +264,36 @@ int main(int argc, char *argv[]) Logger::getInstance().logToFile(true); } - rng = new RNG_SFMT; + // --- Handle files or URLs passed at startup --- + // Only positional arguments are treated as files/URLs, so options like + // --connect are never handed off to another instance. + const QStringList startupFiles = parser.positionalArguments(); + const bool hasActivationFiles = !startupFiles.isEmpty(); + + SingleInstanceManager instance; + + if (hasActivationFiles) { + // Activation launch: hand off to the primary instance if one is + // running, otherwise become the primary ourselves. Do this before + // constructing the main window so a hand-off exits cheaply. + if (!instance.tryRun(startupFiles)) { + // Sent successfully → exit + return 0; + } + // No primary instance → become server + qInfo() << "No existing instance found, becoming primary instance"; + } else { + // Plain launch: if another instance is running, run independently + // instead of handing off and exiting. + if (!instance.tryRun(QStringList())) { + // Another instance is already running → just run independently + qInfo() << "Another instance exists, running independently"; + } else { + qInfo() << "No existing instance found, starting server"; + } + } + + rng = new RNG_SFMT(CryptoUtil::randomUInt64()); themeManager = new ThemeManager; soundEngine = new SoundEngine; @@ -251,21 +306,54 @@ int main(int argc, char *argv[]) // Dependency Injections CardDatabaseManager::setCardPreferenceProvider(new SettingsCardPreferenceProvider()); CardDatabaseManager::setCardDatabasePathProvider(&SettingsCache::instance()); - CardDatabaseManager::setCardSetPriorityController(SettingsCache::instance().cardDatabase()); + CardDatabaseManager::setCardSetPriorityController(&SettingsCache::instance().cardDatabase()); qCInfo(MainLog) << "Starting main program"; + // Front-load the card database before constructing the main window. The + // binary-cache read is cheap when uncontended (~1s); doing it here -- while no + // GUI work yet competes for CPU -- avoids the multi-second, GUI-freezing + // contention that happens when the load runs alongside window construction. + // The CardDatabaseModel populates from the already-loaded data in its + // constructor, so the window appears fully populated with no startup lag. + // Note: checkUnknownSets() is deferred from the initial load to + // MainWindow::startupConfigCheck() so that + // cardDatabaseNewSetsFound / cardDatabaseAllNewSetsEnabled have live + // receivers when emitted. Subsequent reloads (e.g. path changes) call + // checkUnknownSets() directly from the loader after the first load. + CardDatabaseManager::getInstance()->loadCardDatabases(); + MainWindow ui; + + auto handleActivation = [&ui](const QString &file) { + if (file.startsWith("cockatrice://")) { + auto urlParser = new IntentUrlParser(&ui, &ui); + urlParser->handle(file); + } else if (QFileInfo(file).exists()) { + auto openDeckIntent = new IntentOpenLocalDeck(ui.getTabSupervisor(), file); + QObject::connect(openDeckIntent, &Intent::failed, &ui, [&ui](const QString &reason) { + QMessageBox::warning(&ui, QObject::tr("Open deck"), reason); + }); + openDeckIntent->execute(); + } + }; + +#ifdef Q_OS_MAC + QObject::disconnect(cocoaBufferConn); + + QObject::connect(&cockatriceFilter, &UrlSchemeEventFilter::urlReceived, + [&handleActivation](const QString &url) { handleActivation(url); }); +#endif if (parser.isSet("connect")) { ui.setConnectTo(parser.value("connect")); } qCInfo(MainLog) << "MainWindow constructor finished"; - ui.setWindowIcon(QPixmap("theme:cockatrice")); - // set name of the app desktop file; used by wayland to load the window icon - QGuiApplication::setDesktopFileName("cockatrice"); + ui.setWindowIcon(themePixmap(QStringLiteral("cockatrice"))); + // Set name of the app desktop file; used by wayland to load the window icon + QGuiApplication::setDesktopFileName("Cockatrice"); - SettingsCache::instance().setClientID(generateClientID()); + SettingsCache::instance().network().setClientID(generateClientID()); // If spoiler mode is enabled, we will download the spoilers // then reload the DB. otherwise just reload the DB @@ -275,18 +363,35 @@ int main(int argc, char *argv[]) qCInfo(MainLog) << "ui.show() finished"; // force shortcuts to be shown/hidden in right-click menus, regardless of system defaults - qApp->setAttribute(Qt::AA_DontShowShortcutsInContextMenus, !SettingsCache::instance().getShowShortcuts()); + qApp->setAttribute(Qt::AA_DontShowShortcutsInContextMenus, + !SettingsCache::instance().userInterface().getShowShortcuts()); -#if (QT_VERSION < QT_VERSION_CHECK(6, 0, 0)) - app.setAttribute(Qt::AA_UseHighDpiPixmaps); +#ifdef Q_OS_MAC + for (const QString &url : pendingMacUrls) { + handleActivation(url); + } + pendingMacUrls.clear(); #endif - app.exec(); + + for (const QString &file : startupFiles) { + handleActivation(file); + } + + // Connect to future file/URL events from other instances + QObject::connect(&instance, &SingleInstanceManager::filesReceived, [&handleActivation](const QStringList &files) { + for (const QString &file : files) { + handleActivation(file); + } + }); + + int ret = app.exec(); qCInfo(MainLog) << "Event loop finished, terminating..."; delete rng; PingPixmapGenerator::clear(); CountryPixmapGenerator::clear(); UserLevelPixmapGenerator::clear(); + ManaSymbolPixmapGenerator::clear(); - return 0; + return ret; } diff --git a/cockatrice/src/single_instance_manager.cpp b/cockatrice/src/single_instance_manager.cpp new file mode 100644 index 000000000..aca23160c --- /dev/null +++ b/cockatrice/src/single_instance_manager.cpp @@ -0,0 +1,126 @@ +#include "single_instance_manager.h" + +#include + +SingleInstanceManager::SingleInstanceManager(QObject *parent) : QObject(parent) +{ +} + +bool SingleInstanceManager::tryRun(const QStringList &filesToSend) +{ + // Scope the socket name to the current user. On Linux the default abstract + // namespace is system-wide, so a plain name would let one user's instance + // hijack another user's session. + QString userName = qEnvironmentVariable("USER"); + if (userName.isEmpty()) { + userName = qEnvironmentVariable("USERNAME"); + } + if (userName.isEmpty()) { + userName = QDir::home().dirName(); + } + serverName = QStringLiteral("CockatriceSingleInstance-%1").arg(userName); + + // Hand off to an already-running primary instance if one exists. + if (forwardToPrimary(filesToSend)) { + return false; + } + + // No primary instance is currently reachable, so become the primary. + server = new QLocalServer(this); + connect(server, &QLocalServer::newConnection, this, &SingleInstanceManager::handleNewConnection); + + if (server->listen(serverName)) { + return true; + } + + // Another instance may have started while we were probing; hand off to it + // instead of stealing its socket. + if (forwardToPrimary(filesToSend)) { + return false; + } + + // The socket is stale (left over by a crashed instance): remove it and + // retry. If that still fails, another instance just took the name. + QLocalServer::removeServer(serverName); + if (server->listen(serverName)) { + return true; + } + + forwardToPrimary(filesToSend); + return false; +} + +bool SingleInstanceManager::forwardToPrimary(const QStringList &filesToSend) +{ + QLocalSocket socket; + socket.connectToServer(serverName); + if (!socket.waitForConnected(200)) { + return false; + } + + // Serialize payload with length prefix + QByteArray payload; + QDataStream out(&payload, QIODevice::WriteOnly); + out << filesToSend; + + QByteArray message; + QDataStream msgStream(&message, QIODevice::WriteOnly); + msgStream << quint32(payload.size()); + message.append(payload); + + socket.write(message); + socket.flush(); + socket.waitForBytesWritten(1000); + + return true; +} + +void SingleInstanceManager::handleNewConnection() +{ + QLocalSocket *socket = server->nextPendingConnection(); + + // Per-connection state. QSharedPointer keeps the buffers alive for as long + // as the connection handler is attached to the socket. + auto buffer = QSharedPointer::create(); + auto expectedSize = QSharedPointer::create(0); + + connect(socket, &QLocalSocket::readyRead, this, [this, socket, buffer, expectedSize]() { + buffer->append(socket->readAll()); + + QDataStream stream(buffer.data(), QIODevice::ReadOnly); + + while (true) { + // Step 1: read size + if (*expectedSize == 0) { + if (buffer->size() < static_cast(sizeof(quint32))) { + return; + } + + stream >> *expectedSize; + } + + // Step 2: wait for full payload + if (buffer->size() < static_cast(sizeof(quint32) + *expectedSize)) { + return; + } + + // Step 3: extract payload + QByteArray payload = buffer->mid(sizeof(quint32), *expectedSize); + + QDataStream payloadStream(&payload, QIODevice::ReadOnly); + QStringList files; + payloadStream >> files; + + emit filesReceived(files); + + // Reset buffer (single message use-case) + buffer->clear(); + *expectedSize = 0; + + socket->disconnectFromServer(); + return; + } + }); + + connect(socket, &QLocalSocket::disconnected, socket, &QLocalSocket::deleteLater); +} diff --git a/cockatrice/src/single_instance_manager.h b/cockatrice/src/single_instance_manager.h new file mode 100644 index 000000000..55bff0e80 --- /dev/null +++ b/cockatrice/src/single_instance_manager.h @@ -0,0 +1,32 @@ +#ifndef COCKATRICE_SINGLE_INSTANCE_MANAGER_H +#define COCKATRICE_SINGLE_INSTANCE_MANAGER_H + +#include +#include +#include +#include + +class SingleInstanceManager : public QObject +{ + Q_OBJECT +public: + explicit SingleInstanceManager(QObject *parent = nullptr); + + // Returns true if this process became the primary instance, false if + // another instance is already running (and received our files). + bool tryRun(const QStringList &initialFiles); + +signals: + void filesReceived(const QStringList &files); + +private slots: + void handleNewConnection(); + +private: + bool forwardToPrimary(const QStringList &filesToSend); + + QString serverName; + QLocalServer *server = nullptr; +}; + +#endif // COCKATRICE_SINGLE_INSTANCE_MANAGER_H diff --git a/cockatrice/themes/CMakeLists.txt b/cockatrice/themes/CMakeLists.txt index 577977551..70344d95a 100644 --- a/cockatrice/themes/CMakeLists.txt +++ b/cockatrice/themes/CMakeLists.txt @@ -2,7 +2,7 @@ # # add themes subfolders -set(defthemes Default Fabric Fusion Leather Plasma VelvetMarble) +set(defthemes Fabric Fusion Leather Plasma VelvetMarble System) if(UNIX) if(APPLE) diff --git a/cockatrice/themes/Fabric/backgrounds/home-dark.png b/cockatrice/themes/Fabric/backgrounds/home-dark.png new file mode 100644 index 000000000..8d9514626 Binary files /dev/null and b/cockatrice/themes/Fabric/backgrounds/home-dark.png differ diff --git a/cockatrice/themes/Fabric/backgrounds/home-light.png b/cockatrice/themes/Fabric/backgrounds/home-light.png new file mode 100644 index 000000000..78f51a686 Binary files /dev/null and b/cockatrice/themes/Fabric/backgrounds/home-light.png differ diff --git a/cockatrice/themes/Fabric/palette-default-dark.toml b/cockatrice/themes/Fabric/palette-default-dark.toml new file mode 100644 index 000000000..cad18921a --- /dev/null +++ b/cockatrice/themes/Fabric/palette-default-dark.toml @@ -0,0 +1,74 @@ +# Fabric — dark identity palette (navy-cloth chrome) +[Palette] +WindowText = #e8ecf2 +Button = #2b374a +Light = #3a4759 +Midlight = #333f50 +Dark = #141a23 +Mid = #252c38 +Text = #e8ecf2 +BrightText = #8ec2ff +ButtonText = #f2f4f8 +Base = #222c3d +Window = #18202e +Shadow = #0b0f15 +Highlight = #4a82e6 +HighlightedText = #ffffff +Link = #7fb0f2 +LinkVisited = #a98ad9 +AlternateBase = #1d2636 +ToolTipBase = #2b3546 +ToolTipText = #f2f4f8 +PlaceholderText = #6ee8ecf2 +Accent = #4a82e6 + +[Palette.Disabled] +WindowText = #9d9d9d +Button = #18202e +Light = #3a4759 +Midlight = #333f50 +Dark = #141a23 +Mid = #252c38 +Text = #9d9d9d +BrightText = #8ec2ff +ButtonText = #787878 +Base = #18202e +Window = #18202e +Shadow = #0b0f15 +Highlight = #222d40 +HighlightedText = #9d9d9d +Link = #308cc6 +LinkVisited = #b450ff +AlternateBase = #1d2636 +ToolTipBase = #2b3546 +ToolTipText = #f2f4f8 +PlaceholderText = #6ee8ecf2 +Accent = #9d9d9d + +[Palette.Inactive] +WindowText = #e8ecf2 +Button = #2b374a +Light = #3a4759 +Midlight = #333f50 +Dark = #141a23 +Mid = #252c38 +Text = #e8ecf2 +BrightText = #8ec2ff +ButtonText = #f2f4f8 +Base = #222c3d +Window = #18202e +Shadow = #0b0f15 +Highlight = #222d40 +HighlightedText = #ffffff +Link = #7fb0f2 +LinkVisited = #a98ad9 +AlternateBase = #1d2636 +ToolTipBase = #2b3546 +ToolTipText = #f2f4f8 +PlaceholderText = #6ee8ecf2 +Accent = #4a82e6 + +[AppColors] +AccentStrong = #3a6fd6 +AccentSoft = #8fb8f2 + diff --git a/cockatrice/themes/Fabric/palette-default-light.toml b/cockatrice/themes/Fabric/palette-default-light.toml new file mode 100644 index 000000000..f5151af3e --- /dev/null +++ b/cockatrice/themes/Fabric/palette-default-light.toml @@ -0,0 +1,74 @@ +# Fabric — light identity palette (navy-cloth chrome) +[Palette] +WindowText = #1c2330 +Button = #c8d6ea +Light = #ffffff +Midlight = #b9c6dc +Dark = #8fa0bd +Mid = #a3b3cd +Text = #1c2330 +BrightText = #1d4fa0 +ButtonText = #141c29 +Base = #f6f8fc +Window = #dbe3ef +Shadow = #5a6a85 +Highlight = #2f6fd6 +HighlightedText = #ffffff +Link = #2555b0 +LinkVisited = #6a4bb8 +AlternateBase = #e9eef7 +ToolTipBase = #e9eff8 +ToolTipText = #141c29 +PlaceholderText = #7a1c2330 +Accent = #2f6fd6 + +[Palette.Disabled] +WindowText = #787878 +Button = #dbe3ef +Light = #ffffff +Midlight = #b9c6dc +Dark = #8fa0bd +Mid = #a3b3cd +Text = #787878 +BrightText = #1d4fa0 +ButtonText = #969696 +Base = #dbe3ef +Window = #dbe3ef +Shadow = #5a6a85 +Highlight = #cdd8e9 +HighlightedText = #787878 +Link = #0000ff +LinkVisited = #ff00ff +AlternateBase = #e9eef7 +ToolTipBase = #e9eff8 +ToolTipText = #141c29 +PlaceholderText = #7a1c2330 +Accent = #787878 + +[Palette.Inactive] +WindowText = #1c2330 +Button = #c8d6ea +Light = #ffffff +Midlight = #b9c6dc +Dark = #8fa0bd +Mid = #a3b3cd +Text = #1c2330 +BrightText = #1d4fa0 +ButtonText = #141c29 +Base = #f6f8fc +Window = #dbe3ef +Shadow = #5a6a85 +Highlight = #cdd8e9 +HighlightedText = #000000 +Link = #2555b0 +LinkVisited = #6a4bb8 +AlternateBase = #e9eef7 +ToolTipBase = #e9eff8 +ToolTipText = #141c29 +PlaceholderText = #7a1c2330 +Accent = #2f6fd6 + +[AppColors] +AccentStrong = #3a6fd6 +AccentSoft = #8fb8f2 + diff --git a/cockatrice/themes/Fabric/theme.cfg b/cockatrice/themes/Fabric/theme.cfg new file mode 100644 index 000000000..55b916e71 --- /dev/null +++ b/cockatrice/themes/Fabric/theme.cfg @@ -0,0 +1,5 @@ +[Appearance] +ColorScheme = System + +[Style] +Name = Fusion \ No newline at end of file diff --git a/cockatrice/themes/Fabric/zones/handzone-light.png b/cockatrice/themes/Fabric/zones/handzone-light.png new file mode 100644 index 000000000..a87f9e292 Binary files /dev/null and b/cockatrice/themes/Fabric/zones/handzone-light.png differ diff --git a/cockatrice/themes/Fabric/zones/handzone.png b/cockatrice/themes/Fabric/zones/handzone.png index 25d22b070..a5b72286c 100644 Binary files a/cockatrice/themes/Fabric/zones/handzone.png and b/cockatrice/themes/Fabric/zones/handzone.png differ diff --git a/cockatrice/themes/Fabric/zones/playerzone-light.png b/cockatrice/themes/Fabric/zones/playerzone-light.png new file mode 100644 index 000000000..05da3ea9b Binary files /dev/null and b/cockatrice/themes/Fabric/zones/playerzone-light.png differ diff --git a/cockatrice/themes/Fabric/zones/playerzone.png b/cockatrice/themes/Fabric/zones/playerzone.png index 728fccdfa..7fa977249 100644 Binary files a/cockatrice/themes/Fabric/zones/playerzone.png and b/cockatrice/themes/Fabric/zones/playerzone.png differ diff --git a/cockatrice/themes/Fabric/zones/stackzone-light.png b/cockatrice/themes/Fabric/zones/stackzone-light.png new file mode 100644 index 000000000..dcd6dc045 Binary files /dev/null and b/cockatrice/themes/Fabric/zones/stackzone-light.png differ diff --git a/cockatrice/themes/Fabric/zones/stackzone.png b/cockatrice/themes/Fabric/zones/stackzone.png index 89e2080f8..9046aa049 100644 Binary files a/cockatrice/themes/Fabric/zones/stackzone.png and b/cockatrice/themes/Fabric/zones/stackzone.png differ diff --git a/cockatrice/themes/Fabric/zones/tablezone-light.png b/cockatrice/themes/Fabric/zones/tablezone-light.png new file mode 100644 index 000000000..cb297e7e1 Binary files /dev/null and b/cockatrice/themes/Fabric/zones/tablezone-light.png differ diff --git a/cockatrice/themes/Fabric/zones/tablezone.png b/cockatrice/themes/Fabric/zones/tablezone.png index 6a2ce68e1..8048a4cc1 100644 Binary files a/cockatrice/themes/Fabric/zones/tablezone.png and b/cockatrice/themes/Fabric/zones/tablezone.png differ diff --git a/cockatrice/themes/Fusion/palette-default-dark.toml b/cockatrice/themes/Fusion/palette-default-dark.toml index c1d83a4cd..b180e9b63 100644 --- a/cockatrice/themes/Fusion/palette-default-dark.toml +++ b/cockatrice/themes/Fusion/palette-default-dark.toml @@ -11,15 +11,15 @@ ButtonText = #ffffffff Base = #ff2d2d2d Window = #ff1e1e1e Shadow = #ff000000 -Highlight = #ff148c3c +Highlight = #ff139740 HighlightedText = #ffffffff -Link = #ff00f652 -LinkVisited = #ff00d346 +Link = #ffc9fd62 +LinkVisited = #ff9ad43e AlternateBase = #ff353535 ToolTipBase = #ff3c3c3c ToolTipText = #ffd4d4d4 PlaceholderText = #80ffffff -Accent = #ff00d346 +Accent = #ffc9fd62 [Palette.Disabled] WindowText = #ff9d9d9d @@ -34,7 +34,7 @@ ButtonText = #ff9d9d9d Base = #ff1e1e1e Window = #ff1e1e1e Shadow = #ff000000 -Highlight = #ff148c3c +Highlight = #ff139740 HighlightedText = #ffffffff Link = #ff308cc6 LinkVisited = #ffff00ff @@ -59,11 +59,15 @@ Window = #ff1e1e1e Shadow = #ff000000 Highlight = #ff1e1e1e HighlightedText = #ffffffff -Link = #ff00f652 -LinkVisited = #ff00d346 +Link = #ffc9fd62 +LinkVisited = #ff9ad43e AlternateBase = #ff353535 ToolTipBase = #ff3c3c3c ToolTipText = #ffd4d4d4 PlaceholderText = #80ffffff Accent = #ff1e1e1e + +[AppColors] +AccentStrong = #ff139740 +AccentSoft = #ffc9fd62 diff --git a/cockatrice/themes/Fusion/palette-default-light.toml b/cockatrice/themes/Fusion/palette-default-light.toml index 86c41be78..5a1163a2b 100644 --- a/cockatrice/themes/Fusion/palette-default-light.toml +++ b/cockatrice/themes/Fusion/palette-default-light.toml @@ -11,15 +11,15 @@ ButtonText = #ff000000 Base = #ffffffff Window = #fff0f0f0 Shadow = #ff696969 -Highlight = #ff148c3c +Highlight = #ff139740 HighlightedText = #ffffffff -Link = #ff0d5f28 -LinkVisited = #ff08401b +Link = #ff0e6b31 +LinkVisited = #ff0a521f AlternateBase = #ffe9e7e3 ToolTipBase = #ffffffdc ToolTipText = #ff000000 PlaceholderText = #80000000 -Accent = #ff107532 +Accent = #ff139740 [Palette.Disabled] WindowText = #ff787878 @@ -34,7 +34,7 @@ ButtonText = #ff787878 Base = #fff0f0f0 Window = #fff0f0f0 Shadow = #ff000000 -Highlight = #ff148c3c +Highlight = #ff139740 HighlightedText = #ffffffff Link = #ff0000ff LinkVisited = #ffff00ff @@ -59,11 +59,15 @@ Window = #fff0f0f0 Shadow = #ff696969 Highlight = #fff0f0f0 HighlightedText = #ff000000 -Link = #ff0d5f28 -LinkVisited = #ff08401b +Link = #ff0e6b31 +LinkVisited = #ff0a521f AlternateBase = #ffe9e7e3 ToolTipBase = #ffffffdc ToolTipText = #ff000000 PlaceholderText = #80000000 Accent = #fff0f0f0 + +[AppColors] +AccentStrong = #ff139740 +AccentSoft = #ffc9fd62 diff --git a/cockatrice/themes/Leather/backgrounds/home-dark.png b/cockatrice/themes/Leather/backgrounds/home-dark.png new file mode 100644 index 000000000..20d16a27f Binary files /dev/null and b/cockatrice/themes/Leather/backgrounds/home-dark.png differ diff --git a/cockatrice/themes/Leather/backgrounds/home-light.png b/cockatrice/themes/Leather/backgrounds/home-light.png new file mode 100644 index 000000000..ee1da361d Binary files /dev/null and b/cockatrice/themes/Leather/backgrounds/home-light.png differ diff --git a/cockatrice/themes/Leather/palette-default-dark.toml b/cockatrice/themes/Leather/palette-default-dark.toml new file mode 100644 index 000000000..b0db0e90a --- /dev/null +++ b/cockatrice/themes/Leather/palette-default-dark.toml @@ -0,0 +1,74 @@ +# Leather — dark identity palette (black-brown chrome with brass accents) +[Palette] +WindowText = #eee9e2 +Button = #332d26 +Light = #4a4239 +Midlight = #3d3730 +Dark = #131009 +Mid = #26221c +Text = #eee9e2 +BrightText = #e3c27e +ButtonText = #f5f1ea +Base = #26221d +Window = #1d1a16 +Shadow = #0d0b07 +Highlight = #4a5f8f +HighlightedText = #ffffff +Link = #cfa263 +LinkVisited = #9a7db8 +AlternateBase = #211d19 +ToolTipBase = #37302a +ToolTipText = #f5f1ea +PlaceholderText = #6eeee9e2 +Accent = #c9995a + +[Palette.Disabled] +WindowText = #9d9d9d +Button = #1d1a16 +Light = #4a4239 +Midlight = #3d3730 +Dark = #131009 +Mid = #26221c +Text = #9d9d9d +BrightText = #e3c27e +ButtonText = #787878 +Base = #1d1a16 +Window = #1d1a16 +Shadow = #0d0b07 +Highlight = #2d2822 +HighlightedText = #9d9d9d +Link = #308cc6 +LinkVisited = #b450ff +AlternateBase = #211d19 +ToolTipBase = #37302a +ToolTipText = #f5f1ea +PlaceholderText = #6eeee9e2 +Accent = #9d9d9d + +[Palette.Inactive] +WindowText = #eee9e2 +Button = #332d26 +Light = #4a4239 +Midlight = #3d3730 +Dark = #131009 +Mid = #26221c +Text = #eee9e2 +BrightText = #e3c27e +ButtonText = #f5f1ea +Base = #26221d +Window = #1d1a16 +Shadow = #0d0b07 +Highlight = #2d2822 +HighlightedText = #ffffff +Link = #cfa263 +LinkVisited = #9a7db8 +AlternateBase = #211d19 +ToolTipBase = #37302a +ToolTipText = #f5f1ea +PlaceholderText = #6eeee9e2 +Accent = #c9995a + +[AppColors] +AccentStrong = #b7823b +AccentSoft = #e4c58f + diff --git a/cockatrice/themes/Leather/palette-default-light.toml b/cockatrice/themes/Leather/palette-default-light.toml new file mode 100644 index 000000000..ff122db76 --- /dev/null +++ b/cockatrice/themes/Leather/palette-default-light.toml @@ -0,0 +1,74 @@ +# Leather — light identity palette (black-brown chrome with brass accents) +[Palette] +WindowText = #2a2114 +Button = #dfcbb0 +Light = #ffffff +Midlight = #c6b08c +Dark = #a18a64 +Mid = #b29a74 +Text = #2a2114 +BrightText = #7a531c +ButtonText = #1f1710 +Base = #fdf8ee +Window = #f0e2cb +Shadow = #5d4d33 +Highlight = #34508c +HighlightedText = #ffffff +Link = #8a5e1e +LinkVisited = #6b5190 +AlternateBase = #f5ebd7 +ToolTipBase = #fff7e8 +ToolTipText = #1f1710 +PlaceholderText = #7a2a2114 +Accent = #a5712f + +[Palette.Disabled] +WindowText = #787878 +Button = #f0e2cb +Light = #ffffff +Midlight = #c6b08c +Dark = #a18a64 +Mid = #b29a74 +Text = #787878 +BrightText = #7a531c +ButtonText = #969696 +Base = #f0e2cb +Window = #f0e2cb +Shadow = #5d4d33 +Highlight = #ecd9bb +HighlightedText = #787878 +Link = #0000ff +LinkVisited = #ff00ff +AlternateBase = #f5ebd7 +ToolTipBase = #fff7e8 +ToolTipText = #1f1710 +PlaceholderText = #7a2a2114 +Accent = #787878 + +[Palette.Inactive] +WindowText = #2a2114 +Button = #dfcbb0 +Light = #ffffff +Midlight = #c6b08c +Dark = #a18a64 +Mid = #b29a74 +Text = #2a2114 +BrightText = #7a531c +ButtonText = #1f1710 +Base = #fdf8ee +Window = #f0e2cb +Shadow = #5d4d33 +Highlight = #ecd9bb +HighlightedText = #000000 +Link = #8a5e1e +LinkVisited = #6b5190 +AlternateBase = #f5ebd7 +ToolTipBase = #fff7e8 +ToolTipText = #1f1710 +PlaceholderText = #7a2a2114 +Accent = #a5712f + +[AppColors] +AccentStrong = #b7823b +AccentSoft = #e4c58f + diff --git a/cockatrice/themes/Leather/theme.cfg b/cockatrice/themes/Leather/theme.cfg new file mode 100644 index 000000000..55b916e71 --- /dev/null +++ b/cockatrice/themes/Leather/theme.cfg @@ -0,0 +1,5 @@ +[Appearance] +ColorScheme = System + +[Style] +Name = Fusion \ No newline at end of file diff --git a/cockatrice/themes/Leather/zones/handzone-light.png b/cockatrice/themes/Leather/zones/handzone-light.png new file mode 100644 index 000000000..b2c9bba34 Binary files /dev/null and b/cockatrice/themes/Leather/zones/handzone-light.png differ diff --git a/cockatrice/themes/Leather/zones/handzone.png b/cockatrice/themes/Leather/zones/handzone.png index aba879683..5f803df74 100644 Binary files a/cockatrice/themes/Leather/zones/handzone.png and b/cockatrice/themes/Leather/zones/handzone.png differ diff --git a/cockatrice/themes/Leather/zones/playerzone-light.png b/cockatrice/themes/Leather/zones/playerzone-light.png new file mode 100644 index 000000000..a26f96a5e Binary files /dev/null and b/cockatrice/themes/Leather/zones/playerzone-light.png differ diff --git a/cockatrice/themes/Leather/zones/playerzone.png b/cockatrice/themes/Leather/zones/playerzone.png index 4f42ee41d..cccbb3fd3 100644 Binary files a/cockatrice/themes/Leather/zones/playerzone.png and b/cockatrice/themes/Leather/zones/playerzone.png differ diff --git a/cockatrice/themes/Leather/zones/stackzone-light.png b/cockatrice/themes/Leather/zones/stackzone-light.png new file mode 100644 index 000000000..117c738f7 Binary files /dev/null and b/cockatrice/themes/Leather/zones/stackzone-light.png differ diff --git a/cockatrice/themes/Leather/zones/stackzone.png b/cockatrice/themes/Leather/zones/stackzone.png index c02e2284a..d9cff892b 100644 Binary files a/cockatrice/themes/Leather/zones/stackzone.png and b/cockatrice/themes/Leather/zones/stackzone.png differ diff --git a/cockatrice/themes/Leather/zones/tablezone-light.png b/cockatrice/themes/Leather/zones/tablezone-light.png new file mode 100644 index 000000000..912cbfb94 Binary files /dev/null and b/cockatrice/themes/Leather/zones/tablezone-light.png differ diff --git a/cockatrice/themes/Leather/zones/tablezone.png b/cockatrice/themes/Leather/zones/tablezone.png index 152f4f7e9..8af4c853d 100644 Binary files a/cockatrice/themes/Leather/zones/tablezone.png and b/cockatrice/themes/Leather/zones/tablezone.png differ diff --git a/cockatrice/themes/Plasma/backgrounds/home-dark.png b/cockatrice/themes/Plasma/backgrounds/home-dark.png new file mode 100644 index 000000000..ce1469e2b Binary files /dev/null and b/cockatrice/themes/Plasma/backgrounds/home-dark.png differ diff --git a/cockatrice/themes/Plasma/backgrounds/home-light.png b/cockatrice/themes/Plasma/backgrounds/home-light.png new file mode 100644 index 000000000..2e9000bd6 Binary files /dev/null and b/cockatrice/themes/Plasma/backgrounds/home-light.png differ diff --git a/cockatrice/themes/Plasma/palette-default-dark.toml b/cockatrice/themes/Plasma/palette-default-dark.toml new file mode 100644 index 000000000..222456f4f --- /dev/null +++ b/cockatrice/themes/Plasma/palette-default-dark.toml @@ -0,0 +1,74 @@ +# Plasma — dark identity palette (electric violet chrome with cyan sparks) +[Palette] +WindowText = #eeebff +Button = #2a2440 +Light = #453d63 +Midlight = #383252 +Dark = #0e0c16 +Mid = #211e31 +Text = #eeebff +BrightText = #7fd7ff +ButtonText = #f6f4ff +Base = #1e1a2e +Window = #151220 +Shadow = #08070e +Highlight = #6a4df0 +HighlightedText = #ffffff +Link = #37c7e8 +LinkVisited = #9b7aff +AlternateBase = #1a1726 +ToolTipBase = #2c2745 +ToolTipText = #f6f4ff +PlaceholderText = #6eeeebff +Accent = #6a4df0 + +[Palette.Disabled] +WindowText = #9d9d9d +Button = #151220 +Light = #453d63 +Midlight = #383252 +Dark = #0e0c16 +Mid = #211e31 +Text = #9d9d9d +BrightText = #7fd7ff +ButtonText = #787878 +Base = #151220 +Window = #151220 +Shadow = #08070e +Highlight = #211c32 +HighlightedText = #9d9d9d +Link = #308cc6 +LinkVisited = #b450ff +AlternateBase = #1a1726 +ToolTipBase = #2c2745 +ToolTipText = #f6f4ff +PlaceholderText = #6eeeebff +Accent = #9d9d9d + +[Palette.Inactive] +WindowText = #eeebff +Button = #2a2440 +Light = #453d63 +Midlight = #383252 +Dark = #0e0c16 +Mid = #211e31 +Text = #eeebff +BrightText = #7fd7ff +ButtonText = #f6f4ff +Base = #1e1a2e +Window = #151220 +Shadow = #08070e +Highlight = #211c32 +HighlightedText = #ffffff +Link = #37c7e8 +LinkVisited = #9b7aff +AlternateBase = #1a1726 +ToolTipBase = #2c2745 +ToolTipText = #f6f4ff +PlaceholderText = #6eeeebff +Accent = #6a4df0 + +[AppColors] +AccentStrong = #5b3ee0 +AccentSoft = #a28bf7 + diff --git a/cockatrice/themes/Plasma/palette-default-light.toml b/cockatrice/themes/Plasma/palette-default-light.toml new file mode 100644 index 000000000..d54db31d2 --- /dev/null +++ b/cockatrice/themes/Plasma/palette-default-light.toml @@ -0,0 +1,74 @@ +# Plasma — light identity palette (electric violet chrome with cyan sparks) +[Palette] +WindowText = #1d1830 +Button = #cfc6f0 +Light = #ffffff +Midlight = #b7adde +Dark = #9589c8 +Mid = #a49ad2 +Text = #1d1830 +BrightText = #10406e +ButtonText = #120d26 +Base = #f9f7ff +Window = #e3dcf7 +Shadow = #554c85 +Highlight = #5a3ee2 +HighlightedText = #ffffff +Link = #0f8fb5 +LinkVisited = #6a45d6 +AlternateBase = #ece8fa +ToolTipBase = #f5f3ff +ToolTipText = #120d26 +PlaceholderText = #7a1d1830 +Accent = #5a3ee2 + +[Palette.Disabled] +WindowText = #787878 +Button = #e3dcf7 +Light = #ffffff +Midlight = #b7adde +Dark = #9589c8 +Mid = #a49ad2 +Text = #787878 +BrightText = #10406e +ButtonText = #969696 +Base = #e3dcf7 +Window = #e3dcf7 +Shadow = #554c85 +Highlight = #d6ccf3 +HighlightedText = #787878 +Link = #0000ff +LinkVisited = #ff00ff +AlternateBase = #ece8fa +ToolTipBase = #f5f3ff +ToolTipText = #120d26 +PlaceholderText = #7a1d1830 +Accent = #787878 + +[Palette.Inactive] +WindowText = #1d1830 +Button = #cfc6f0 +Light = #ffffff +Midlight = #b7adde +Dark = #9589c8 +Mid = #a49ad2 +Text = #1d1830 +BrightText = #10406e +ButtonText = #120d26 +Base = #f9f7ff +Window = #e3dcf7 +Shadow = #554c85 +Highlight = #d6ccf3 +HighlightedText = #000000 +Link = #0f8fb5 +LinkVisited = #6a45d6 +AlternateBase = #ece8fa +ToolTipBase = #f5f3ff +ToolTipText = #120d26 +PlaceholderText = #7a1d1830 +Accent = #5a3ee2 + +[AppColors] +AccentStrong = #5b3ee0 +AccentSoft = #a28bf7 + diff --git a/cockatrice/themes/Plasma/theme.cfg b/cockatrice/themes/Plasma/theme.cfg new file mode 100644 index 000000000..55b916e71 --- /dev/null +++ b/cockatrice/themes/Plasma/theme.cfg @@ -0,0 +1,5 @@ +[Appearance] +ColorScheme = System + +[Style] +Name = Fusion \ No newline at end of file diff --git a/cockatrice/themes/Plasma/zones/handzone-light.png b/cockatrice/themes/Plasma/zones/handzone-light.png new file mode 100644 index 000000000..89fa7e6d4 Binary files /dev/null and b/cockatrice/themes/Plasma/zones/handzone-light.png differ diff --git a/cockatrice/themes/Plasma/zones/handzone.png b/cockatrice/themes/Plasma/zones/handzone.png index 543571d8a..82debb405 100644 Binary files a/cockatrice/themes/Plasma/zones/handzone.png and b/cockatrice/themes/Plasma/zones/handzone.png differ diff --git a/cockatrice/themes/Plasma/zones/playerzone-light.png b/cockatrice/themes/Plasma/zones/playerzone-light.png new file mode 100644 index 000000000..9034818f2 Binary files /dev/null and b/cockatrice/themes/Plasma/zones/playerzone-light.png differ diff --git a/cockatrice/themes/Plasma/zones/playerzone.png b/cockatrice/themes/Plasma/zones/playerzone.png index eebcd1f84..4623524f6 100644 Binary files a/cockatrice/themes/Plasma/zones/playerzone.png and b/cockatrice/themes/Plasma/zones/playerzone.png differ diff --git a/cockatrice/themes/Plasma/zones/stackzone-light.png b/cockatrice/themes/Plasma/zones/stackzone-light.png new file mode 100644 index 000000000..2607b3389 Binary files /dev/null and b/cockatrice/themes/Plasma/zones/stackzone-light.png differ diff --git a/cockatrice/themes/Plasma/zones/stackzone.png b/cockatrice/themes/Plasma/zones/stackzone.png index 1845c7091..144d991f3 100644 Binary files a/cockatrice/themes/Plasma/zones/stackzone.png and b/cockatrice/themes/Plasma/zones/stackzone.png differ diff --git a/cockatrice/themes/Plasma/zones/tablezone-light.png b/cockatrice/themes/Plasma/zones/tablezone-light.png new file mode 100644 index 000000000..dcf2ef95e Binary files /dev/null and b/cockatrice/themes/Plasma/zones/tablezone-light.png differ diff --git a/cockatrice/themes/Plasma/zones/tablezone.png b/cockatrice/themes/Plasma/zones/tablezone.png index 8f998d4bb..4dc36ec2d 100644 Binary files a/cockatrice/themes/Plasma/zones/tablezone.png and b/cockatrice/themes/Plasma/zones/tablezone.png differ diff --git a/cockatrice/themes/Default/palette-default-dark.toml b/cockatrice/themes/System/palette-default-dark.toml similarity index 96% rename from cockatrice/themes/Default/palette-default-dark.toml rename to cockatrice/themes/System/palette-default-dark.toml index 3ee174a2f..e101935b4 100644 --- a/cockatrice/themes/Default/palette-default-dark.toml +++ b/cockatrice/themes/System/palette-default-dark.toml @@ -61,3 +61,7 @@ ToolTipBase = #ffffffdc ToolTipText = #ff000000 PlaceholderText = #6effffff + +[AppColors] +AccentStrong = #ff148c3c +AccentSoft = #ff78c850 diff --git a/cockatrice/themes/System/palette-default-light.toml b/cockatrice/themes/System/palette-default-light.toml new file mode 100644 index 000000000..14c215cdf --- /dev/null +++ b/cockatrice/themes/System/palette-default-light.toml @@ -0,0 +1,67 @@ +[Palette] +WindowText = #ff000000 +Button = #fff0f0f0 +Light = #ffffffff +Midlight = #ffe3e3e3 +Dark = #ffa0a0a0 +Mid = #ffa0a0a0 +Text = #ff000000 +BrightText = #ffffffff +ButtonText = #ff000000 +Base = #ffffffff +Window = #fff0f0f0 +Shadow = #ff696969 +HighlightedText = #ffffffff +Link = #ff0d5f28 +LinkVisited = #ff08401b +AlternateBase = #ffe9e7e3 +ToolTipBase = #ffffffdc +ToolTipText = #ff000000 +PlaceholderText = #80000000 + +[Palette.Disabled] +WindowText = #ff787878 +Button = #fff0f0f0 +Light = #ffffffff +Midlight = #fff7f7f7 +Dark = #ffa0a0a0 +Mid = #ffa0a0a0 +Text = #ff787878 +BrightText = #ffffffff +ButtonText = #ff787878 +Base = #fff0f0f0 +Window = #fff0f0f0 +Shadow = #ff000000 +HighlightedText = #ffffffff +Link = #ff0000ff +LinkVisited = #ffff00ff +AlternateBase = #fff7f7f7 +ToolTipBase = #ffffffdc +ToolTipText = #ff000000 +PlaceholderText = #80000000 + +[Palette.Inactive] +WindowText = #ff000000 +Button = #fff0f0f0 +Light = #ffffffff +Midlight = #ffe3e3e3 +Dark = #ffa0a0a0 +Mid = #ffa0a0a0 +Text = #ff000000 +BrightText = #ffffffff +ButtonText = #ff000000 +Base = #ffffffff +Window = #fff0f0f0 +Shadow = #ff696969 +HighlightedText = #ff000000 +Link = #ff0d5f28 +LinkVisited = #ff08401b +AlternateBase = #ffe9e7e3 +ToolTipBase = #ffffffdc +ToolTipText = #ff000000 +PlaceholderText = #80000000 + + +[AppColors] +AccentStrong = #ff148c3c +AccentSoft = #ff78c850 \ No newline at end of file diff --git a/cockatrice/themes/Default/theme.cfg b/cockatrice/themes/System/theme.cfg similarity index 73% rename from cockatrice/themes/Default/theme.cfg rename to cockatrice/themes/System/theme.cfg index d2016a238..4b756a5e8 100644 --- a/cockatrice/themes/Default/theme.cfg +++ b/cockatrice/themes/System/theme.cfg @@ -2,4 +2,4 @@ ColorScheme = Light [Style] -Name = Default +Name = System diff --git a/cockatrice/themes/VelvetMarble/backgrounds/home-dark.png b/cockatrice/themes/VelvetMarble/backgrounds/home-dark.png new file mode 100644 index 000000000..34738090f Binary files /dev/null and b/cockatrice/themes/VelvetMarble/backgrounds/home-dark.png differ diff --git a/cockatrice/themes/VelvetMarble/backgrounds/home-light.png b/cockatrice/themes/VelvetMarble/backgrounds/home-light.png new file mode 100644 index 000000000..de783e086 Binary files /dev/null and b/cockatrice/themes/VelvetMarble/backgrounds/home-light.png differ diff --git a/cockatrice/themes/VelvetMarble/palette-default-dark.toml b/cockatrice/themes/VelvetMarble/palette-default-dark.toml new file mode 100644 index 000000000..496943931 --- /dev/null +++ b/cockatrice/themes/VelvetMarble/palette-default-dark.toml @@ -0,0 +1,74 @@ +# VelvetMarble — dark identity palette (charcoal-velvet chrome with slate marble) +[Palette] +WindowText = #e8eaee +Button = #262a31 +Light = #3a3f47 +Midlight = #31363d +Dark = #0e1013 +Mid = #1f2329 +Text = #e8eaee +BrightText = #c3d8f2 +ButtonText = #f2f3f6 +Base = #1b1e23 +Window = #131519 +Shadow = #08090b +Highlight = #8fa6c0 +HighlightedText = #101318 +Link = #a9c6e8 +LinkVisited = #b9a8d9 +AlternateBase = #171a1f +ToolTipBase = #2d323a +ToolTipText = #f2f3f6 +PlaceholderText = #6ee8eaee +Accent = #8fa6c0 + +[Palette.Disabled] +WindowText = #9d9d9d +Button = #131519 +Light = #3a3f47 +Midlight = #31363d +Dark = #0e1013 +Mid = #1f2329 +Text = #9d9d9d +BrightText = #c3d8f2 +ButtonText = #787878 +Base = #131519 +Window = #131519 +Shadow = #08090b +Highlight = #1f2229 +HighlightedText = #9d9d9d +Link = #308cc6 +LinkVisited = #b450ff +AlternateBase = #171a1f +ToolTipBase = #2d323a +ToolTipText = #f2f3f6 +PlaceholderText = #6ee8eaee +Accent = #9d9d9d + +[Palette.Inactive] +WindowText = #e8eaee +Button = #262a31 +Light = #3a3f47 +Midlight = #31363d +Dark = #0e1013 +Mid = #1f2329 +Text = #e8eaee +BrightText = #c3d8f2 +ButtonText = #f2f3f6 +Base = #1b1e23 +Window = #131519 +Shadow = #08090b +Highlight = #1f2229 +HighlightedText = #ffffff +Link = #a9c6e8 +LinkVisited = #b9a8d9 +AlternateBase = #171a1f +ToolTipBase = #2d323a +ToolTipText = #f2f3f6 +PlaceholderText = #6ee8eaee +Accent = #8fa6c0 + +[AppColors] +AccentStrong = #54687e +AccentSoft = #9db0c4 + diff --git a/cockatrice/themes/VelvetMarble/palette-default-light.toml b/cockatrice/themes/VelvetMarble/palette-default-light.toml new file mode 100644 index 000000000..5323fd320 --- /dev/null +++ b/cockatrice/themes/VelvetMarble/palette-default-light.toml @@ -0,0 +1,74 @@ +# VelvetMarble — light identity palette (charcoal-velvet chrome with slate marble) +[Palette] +WindowText = #1b1d21 +Button = #c7d1dd +Light = #ffffff +Midlight = #b3becb +Dark = #93a0b1 +Mid = #a1adbc +Text = #1b1d21 +BrightText = #26465f +ButtonText = #121418 +Base = #f7f9fb +Window = #d9e0ea +Shadow = #57626f +Highlight = #54687e +HighlightedText = #ffffff +Link = #2e4d6b +LinkVisited = #5d4a78 +AlternateBase = #e6ebf2 +ToolTipBase = #f2f4f6 +ToolTipText = #121418 +PlaceholderText = #7a1b1d21 +Accent = #54687e + +[Palette.Disabled] +WindowText = #787878 +Button = #d9e0ea +Light = #ffffff +Midlight = #b3becb +Dark = #93a0b1 +Mid = #a1adbc +Text = #787878 +BrightText = #26465f +ButtonText = #969696 +Base = #d9e0ea +Window = #d9e0ea +Shadow = #57626f +Highlight = #ccd5e3 +HighlightedText = #787878 +Link = #0000ff +LinkVisited = #ff00ff +AlternateBase = #e6ebf2 +ToolTipBase = #f2f4f6 +ToolTipText = #121418 +PlaceholderText = #7a1b1d21 +Accent = #787878 + +[Palette.Inactive] +WindowText = #1b1d21 +Button = #c7d1dd +Light = #ffffff +Midlight = #b3becb +Dark = #93a0b1 +Mid = #a1adbc +Text = #1b1d21 +BrightText = #26465f +ButtonText = #121418 +Base = #f7f9fb +Window = #d9e0ea +Shadow = #57626f +Highlight = #ccd5e3 +HighlightedText = #000000 +Link = #2e4d6b +LinkVisited = #5d4a78 +AlternateBase = #e6ebf2 +ToolTipBase = #f2f4f6 +ToolTipText = #121418 +PlaceholderText = #7a1b1d21 +Accent = #54687e + +[AppColors] +AccentStrong = #54687e +AccentSoft = #9db0c4 + diff --git a/cockatrice/themes/VelvetMarble/theme.cfg b/cockatrice/themes/VelvetMarble/theme.cfg new file mode 100644 index 000000000..55b916e71 --- /dev/null +++ b/cockatrice/themes/VelvetMarble/theme.cfg @@ -0,0 +1,5 @@ +[Appearance] +ColorScheme = System + +[Style] +Name = Fusion \ No newline at end of file diff --git a/cockatrice/themes/VelvetMarble/zones/handzone-light.png b/cockatrice/themes/VelvetMarble/zones/handzone-light.png new file mode 100644 index 000000000..b21e78e61 Binary files /dev/null and b/cockatrice/themes/VelvetMarble/zones/handzone-light.png differ diff --git a/cockatrice/themes/VelvetMarble/zones/handzone.jpg b/cockatrice/themes/VelvetMarble/zones/handzone.jpg deleted file mode 100644 index 2ec9b37fe..000000000 Binary files a/cockatrice/themes/VelvetMarble/zones/handzone.jpg and /dev/null differ diff --git a/cockatrice/themes/VelvetMarble/zones/handzone.png b/cockatrice/themes/VelvetMarble/zones/handzone.png new file mode 100644 index 000000000..3e2b7ebea Binary files /dev/null and b/cockatrice/themes/VelvetMarble/zones/handzone.png differ diff --git a/cockatrice/themes/VelvetMarble/zones/playerzone-light.png b/cockatrice/themes/VelvetMarble/zones/playerzone-light.png new file mode 100644 index 000000000..630aac6eb Binary files /dev/null and b/cockatrice/themes/VelvetMarble/zones/playerzone-light.png differ diff --git a/cockatrice/themes/VelvetMarble/zones/playerzone.jpg b/cockatrice/themes/VelvetMarble/zones/playerzone.jpg deleted file mode 100644 index dd13cab78..000000000 Binary files a/cockatrice/themes/VelvetMarble/zones/playerzone.jpg and /dev/null differ diff --git a/cockatrice/themes/VelvetMarble/zones/playerzone.png b/cockatrice/themes/VelvetMarble/zones/playerzone.png new file mode 100644 index 000000000..dcf21ba38 Binary files /dev/null and b/cockatrice/themes/VelvetMarble/zones/playerzone.png differ diff --git a/cockatrice/themes/VelvetMarble/zones/stackzone-light.png b/cockatrice/themes/VelvetMarble/zones/stackzone-light.png new file mode 100644 index 000000000..62027827c Binary files /dev/null and b/cockatrice/themes/VelvetMarble/zones/stackzone-light.png differ diff --git a/cockatrice/themes/VelvetMarble/zones/stackzone.jpg b/cockatrice/themes/VelvetMarble/zones/stackzone.jpg deleted file mode 100644 index b63aa0902..000000000 Binary files a/cockatrice/themes/VelvetMarble/zones/stackzone.jpg and /dev/null differ diff --git a/cockatrice/themes/VelvetMarble/zones/stackzone.png b/cockatrice/themes/VelvetMarble/zones/stackzone.png new file mode 100644 index 000000000..a47f7bf9d Binary files /dev/null and b/cockatrice/themes/VelvetMarble/zones/stackzone.png differ diff --git a/cockatrice/themes/VelvetMarble/zones/tablezone-light.png b/cockatrice/themes/VelvetMarble/zones/tablezone-light.png new file mode 100644 index 000000000..5a9014f9d Binary files /dev/null and b/cockatrice/themes/VelvetMarble/zones/tablezone-light.png differ diff --git a/cockatrice/themes/VelvetMarble/zones/tablezone.jpg b/cockatrice/themes/VelvetMarble/zones/tablezone.jpg deleted file mode 100644 index 9f511491a..000000000 Binary files a/cockatrice/themes/VelvetMarble/zones/tablezone.jpg and /dev/null differ diff --git a/cockatrice/themes/VelvetMarble/zones/tablezone.png b/cockatrice/themes/VelvetMarble/zones/tablezone.png new file mode 100644 index 000000000..1523d0821 Binary files /dev/null and b/cockatrice/themes/VelvetMarble/zones/tablezone.png differ diff --git a/cockatrice/translations/cockatrice_cs.ts b/cockatrice/translations/cockatrice_cs.ts index c210315c7..aa15c6fcc 100644 --- a/cockatrice/translations/cockatrice_cs.ts +++ b/cockatrice/translations/cockatrice_cs.ts @@ -2,7 +2,7 @@ AbstractCounter - + &Set counter... &Nastavit žeton... @@ -10,12 +10,12 @@ AbstractCounterDialog - + Set counter - + New value for counter '%1': Nová hodnota pro žeton '%1': @@ -36,60 +36,60 @@ AbstractTabDeckEditor - + Open in new tab - + Are you sure? - + The decklist has been modified. Do you want to save the changes? - - - - - - + + + + + + Error - + Could not open deck at %1 - + Could not save remote deck - - + + The deck could not be saved. Please check that the directory is writable and try again. - + Save deck - + The deck could not be saved. - + There are no cards in your deck to be exported @@ -105,12 +105,12 @@ Please check that the directory is writable and try again. AdminNotesDialog - + Update Notes - + Admin Notes for %1 @@ -127,172 +127,227 @@ Please check that the directory is writable and try again. Sideboard + + + Tokens + + AppearanceSettingsPage - + seconds - + Error Chyba - + Could not create themes directory at '%1'. - + Theme settings Nastavení vzhledu - + Current theme: Aktuální vzhled - + Open themes folder - + Home tab background source: - + Home tab background shuffle frequency: - + Disabled - - Display card name of background in bottom right: - - - - + Menu settings - + Show keyboard shortcuts in right-click menus - + Show game filter toolbar above list in room tab - + Card rendering Vykreslování karet - + Display card names on cards having a picture Zobrazit jména karet na kartách s obrázky - + Auto-Rotate cards with sideways layout - + Override all card art with personal set preference (Pre-ProviderID change behavior) - + + Light + + + + + Dark + + + + + System + + + + + Active theme palette: + + + + + Edit theme palette + + + + + Home tab settings + + + + + Display card name of background in bottom right + + + + + Styling settings + + + + + Style user list + + + + + Card printings + + + + Bump sets that the deck contains cards from to the top in the printing selector - + Scale cards on mouse over Zvětšovat karty při najetí myši - + Use rounded card corners - + + Card layout + + + + Minimum overlap percentage of cards on the stack and in vertical hand - + Maximum initial height for card view window: - - + + rows - + Maximum expanded height for card view window: - + Card counters - + Counter %1 - + Hand layout Rozvržení ruky - + Display hand horizontally (wastes space) Zobrazit ruku horizontálně (plýtvá místem) - + Enable left justification Zapnout - + Table grid layout Rozložení herní mřížky - + Invert vertical coordinate Převrátit vertikální souřadnice - + Minimum player count for multi-column layout: Minimální počet hráčů pro víceřádkové rozvržení: - + Maximum font size for information displayed on cards: Maximální velikost písma pro informace na kartách @@ -300,12 +355,12 @@ Please check that the directory is writable and try again. ArchidektApiResponseDeckDisplayWidget - + Back to results - + Open Deck in Deck Editor @@ -331,112 +386,112 @@ Please check that the directory is writable and try again. BanDialog - + ban &user name Blokovat & Uživatelské jméno - + ban &IP address Blokovat & IP Adresu - + ban client I&D Banovat I&D klienta - + Ban type Typ banu - + &permanent ban &trvalý ban - + &temporary ban &dočasný ban - + &Days: &Dní: - + &Hours: &Hodin: - + &Minutes: &Minut: - + Duration of the ban Trvání banu - + Please enter the reason for the ban. This is only saved for moderators and cannot be seen by the banned person. Uveďte prosím důvod pro ban. Toto je uloženo pouze pro moderátory a banovaná osoba to neuvidí. - + Please enter the reason for the ban that will be visible to the banned person. Uveďte prosám důvod pro ban, který uvidí banovaná osoba. - + Redact all messages from this user in all rooms - + &OK &OK - + &Cancel &Zrušit - + Ban user from server Zabanovat uživatele ze serveru - - - - + + + + Error Chyba - + You have to select a name-based, IP-based, clientId based, or some combination of the three to place a ban. - + You must have a value in the name ban when selecting the name ban checkbox. - + You must have a value in the ip ban when selecting the ip ban checkbox. - + You must have a value in the clientid ban when selecting the clientid ban checkbox. @@ -444,59 +499,123 @@ Toto je uloženo pouze pro moderátory a banovaná osoba to neuvidí. BetaReleaseChannel - + Beta - + No reply received from the release update server. - + Invalid reply received from the release update server. - + No reply received from the file update server. + + CardArtPreviewWidget + + + No card selected + + + + + CardArtRulesModel + + + Card + + + + + ProviderId + + + + + Mode + + + + + Reason + + + CardDatabaseModel - + Name Jméno - + Sets Edice - + Mana cost Sesílací cena - + Card type Typ karty - + P/T S/O - + Color(s) Barva(y) + + CardDatabaseView + + + Add to Deck + + + + + Add to Sideboard + + + + + Select Printing + + + + + Show on EDHRec (Commander) + + + + + Show on EDHRec (Card) + + + + + Show Related cards + + + CardFilter @@ -625,22 +744,22 @@ Toto je uloženo pouze pro moderátory a banovaná osoba to neuvidí. CardInfoPictureWidget - + View related cards - + Add card to deck - + Mainboard - + Sideboard @@ -663,12 +782,12 @@ Toto je uloženo pouze pro moderátory a banovaná osoba to neuvidí. - + Related cards: - + Unknown card: @@ -676,128 +795,146 @@ Toto je uloženo pouze pro moderátory a banovaná osoba to neuvidí. CardMenu - + Re&veal to... - + &All players - + View related cards - + Token: - + All tokens - + &Select All - + S&elect Row - + S&elect Column - + &Play - + &Hide - + Play &Face Down - + &Tap / Untap Turn sideways or back again - + Skip &untapping - + T&urn Over Turn face up/face down - + &Peek at card face - + &Clone - + Attac&h to card... - + Unattac&h - + &Draw arrow... - + &Set annotation... - + + Reduce life by power + + + + Ca&rd counters - + &Add counter (%1) - + &Remove counter (%1) - + &Set counters (%1)... + + CardPictureLoaderCacheMethod + + + Network Cache + + + + + Filesystem + + + CardSizeWidget @@ -809,133 +946,133 @@ Toto je uloženo pouze pro moderátory a banovaná osoba to neuvidí. CardZoneLogic - + their hand nominative - + %1's hand nominative - + their library look at zone - + %1's library look at zone - + of their library top cards of zone, - + of %1's library top cards of zone - + their library reveal zone - + %1's library reveal zone - + their library shuffle - + %1's library shuffle - + their library nominative - + %1's library nominative - + their graveyard nominative - + %1's graveyard nominative - + their exile nominative - + %1's exile nominative - + their sideboard look at zone - + %1's sideboard look at zone - + their sideboard nominative - + %1's sideboard nominative - + their custom zone '%1' nominative - + %1's custom zone '%2' nominative @@ -957,16 +1094,421 @@ Toto je uloženo pouze pro moderátory a banovaná osoba to neuvidí. + + ColorButton + + + Click to pick a color + + + + + Pick colour + + + + + ConnectionController + + + + The server has reached its maximum user capacity, please check back later. + + + + + There are too many concurrent connections from your address. + + + + + Banned by moderator + + + + + Expected end time: %1 + + + + + This ban lasts indefinitely. + + + + + Scheduled server shutdown. + + + + + + Invalid username. + + + + + You have been logged out due to logging in at another location. + + + + + Connection closed + + + + + The server has terminated your connection. +Reason: %1 + + + + + The server is going to be restarted in %n minute(s). +All running games will be lost. +Reason for shutdown: %1 + + + + + Scheduled server shutdown + + + + + Failed Login + + + + + Your client seems to be missing features this server requires for connection. + + + + + To update your client, go to 'Help -> Check for Client Updates'. + + + + + + + + + + + + + + + + + + + + + + Error + + + + + Incorrect username or password. Please check your authentication information and try again. + + + + + There is already an active session using this user name. +Please close that session first and re-login. + + + + + + You are banned until %1. + + + + + + You are banned indefinitely. + + + + + This server requires user registration. Do you want to register now? + + + + + This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. +Please close and reopen your client to try again. + + + + + An internal error has occurred, please close and reopen Cockatrice before trying again. +If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. + + + + + Account activation + + + + + Your account has not been activated yet. +You need to provide the activation token received in the activation email. + + + + + Server Full + + + + + Unknown login error: %1 + + + + + + +This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. + + + + + + + + + + Registration denied + + + + + Registration is currently disabled on this server + + + + + There is already an existing account with the same user name. + + + + + It's mandatory to specify a valid email address when registering. + + + + + It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. + + + + + Password too short. + + + + + Registration failed for a technical problem on the server. + + + + + The connection to the server has been lost. + + + + + Unknown registration error: %1 + + + + + Account activation failed + + + + + Socket error: %1 + + + + + Server timeout + + + + + You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. +Local version is %1, remote version is %2. + + + + + Your Cockatrice client is obsolete. Please update your Cockatrice version. +Local version is %1, remote version is %2. + + + + + + Success + + + + + Registration accepted. +Will now login. + + + + + Account activation accepted. +Will now login. + + + + + Information + + + + + This server supports additional features that your client doesn't have. +This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. + +To update your client, go to Help -> Check for Updates. + + + + + + + Reset Password + + + + + Your password has been reset successfully, you can now log in using the new credentials. + + + + + Failed to reset user account password, please contact the server operator to reset your password. + + + + + Activation request received, please check your email for an activation token. + + + + + Connecting to %1... + + + + + Registering to %1 as %2... + + + + + Disconnected + + + + + Connected, logging in at %1 + + + + + Requesting forgotten password to %1 as %2... + + + + + Your username must respect these rules: + + + + + is %1 - %2 characters long + + + + + can %1 contain lowercase characters + + + + + + + + NOT + + + + + can %1 contain uppercase characters + + + + + can %1 contain numeric characters + + + + + can contain the following punctuation: %1 + + + + + first character can %1 be a punctuation mark + + + + + no unacceptable language as specified by these server rules: + note that the following lines will not be translated + + + + + can not contain any of the following words: %1 + + + + + can not match any of the following expressions: %1 + + + + + You may only use A-Z, a-z, 0-9, _, ., and - in your username. + + + CustomZoneMenu - + C&ustom Zones - - + + View custom zone '%1' @@ -974,30 +1516,35 @@ Toto je uloženo pouze pro moderátory a banovaná osoba to neuvidí. DeckAnalyticsWidget - + Add Panel - + Remove Panel - + Save Layout - + Load Layout + + + Include Sideboard + + DeckEditorCardDatabaseDockWidget - + Card Database @@ -1013,47 +1560,17 @@ Toto je uloženo pouze pro moderátory a banovaná osoba to neuvidí. DeckEditorDatabaseDisplayWidget - + Search by card name (or search expressions) - - Add to Deck - - - - - Add to Sideboard - - - - - Select Printing - - - - - Show on EDHRec (Commander) - - - - - Show on EDHRec (Card) - - - - - Show Related cards - - - - + Add card to &maindeck - + Add card to &sideboard @@ -1086,72 +1603,72 @@ Toto je uloženo pouze pro moderátory a banovaná osoba to neuvidí. - + Select Printing - + Deck - + Deck &name: - + Banner Card/Tags Visibility Settings - + Show banner card selection menu - + Show tags selection menu - + &Comments: - + Group by: - + Format: - + Hash: - + &Increment number - + &Decrement number - + &Remove row - + Swap card to/from sideboard @@ -1159,17 +1676,17 @@ Toto je uloženo pouze pro moderátory a banovaná osoba to neuvidí. DeckEditorFilterDockWidget - + Filters - + &Clear all filters - + Delete selected @@ -1300,169 +1817,113 @@ Toto je uloženo pouze pro moderátory a banovaná osoba to neuvidí. DeckEditorSettingsPage - - + + Update Spoilers Aktualizovat spoilery - - + Success Úspěch - + Download URLs have been reset. - - Downloaded card pictures have been reset. - Stáhnuté obrázky karet byly resetovány. - - - - Error - Chyba - - - - One or more downloaded card pictures could not be cleared. - Některé stáhnuté obrázky karet nemohli být vymazány. - - - + Add URL - - + + URL: URL: - - + + Edit URL - - Network Cache Size: - - - - - Redirect Cache TTL: - - - - - How long cached redirects for urls are valid for. - - - - - Picture Cache Size: - - - - + Add New URL - + Remove URL - - Day(s) - - - - + Updating... Aktualizuji... - + Choose path Vyberte cestu - + URL Download Priority - + Spoilers Spoilery - + Download Spoilers Automatically Stahovat spoilery automaticky - + Spoiler Location: - + Last Change Poslední změna - + Spoilers download automatically on launch Automaticky stahovat spoilery při spuštění - + Press the button to manually update without relaunching Manuálně aktualizovat bez restartování - + Do not close settings until manual update is complete - + Download card pictures on the fly - + How to add a custom URL - - Delete Downloaded Images - Smazat stáhnuté obrázky - - - + Reset Download URLs - - - On-disk cache for downloaded pictures - - - - - In-memory cache for pictures not currently on screen - - DeckListHistoryManagerWidget @@ -1528,12 +1989,12 @@ Toto je uloženo pouze pro moderátory a banovaná osoba to neuvidí. DeckLoader - + Common deck formats (%1) - + All files (*.*) @@ -1755,27 +2216,27 @@ Toto je uloženo pouze pro moderátory a banovaná osoba to neuvidí. - + Moved to %1 1 × "%2" (%3) - + Removed "%1" (all copies) - + %1 1 × "%2" (%3) - + Added - + Removed @@ -1797,74 +2258,74 @@ Toto je uloženo pouze pro moderátory a banovaná osoba to neuvidí. DeckViewContainer - + Load deck... Načíst balíček... - + Load remote deck... Načíst balíček ze serveru... - + Load from clipboard... - + Load from website... - + Unload deck - + Ready to start Připraven začít - + Force start - + Sideboard unlocked - + Sideboard locked - - + + Error Chyba - + The selected file could not be loaded. Vybraný soubor se nepodařilo načíst. - + Deck is greater than maximum file size. - + Are you sure you want to force start? This will kick all non-ready players from the game. - + Cockatrice @@ -1963,7 +2424,7 @@ This will kick all non-ready players from the game. - + Webpage Webová stránka @@ -2003,37 +2464,37 @@ This will kick all non-ready players from the game. Připojit k serveru - + Server URL - + Communication Port Komunikační port - + Unique Server Name - + Connection Warning - + You need to name your new connection profile. - + Connect Warning Varování přihlášení - + The player name can't be empty. Jméno hráče nemůže být prázdné @@ -2146,17 +2607,17 @@ This will kick all non-ready players from the game. Vytvořit hru - + Game information Informace o hře - + Error Chyba - + Server error. Chyba serveru. @@ -2164,97 +2625,97 @@ This will kick all non-ready players from the game. DlgCreateToken - + &Name: &Jméno: - + Token Token - + C&olor: B&arva: - + white bílá - + blue modrá - + black černá - + red červená - + green zelená - + multicolor vícebarevný - + colorless bezbarvý - + &P/T: &S/O: - + &Annotation: &Poznámka: - + &Destroy token when it leaves the table &Token se při odchodu z bojiště zničí - + Create face-down (Only hides name) - + Token data Data tokenů - + Show &all tokens Ukázat &všechny tokeny - + Show tokens from this &deck Ukázat tokeny z tohto &balíčku - + Choose token from list Vybrat token ze seznamu. - + Create token Vytvořit token @@ -2288,7 +2749,7 @@ This will kick all non-ready players from the game. - + @@ -2303,12 +2764,12 @@ This will kick all non-ready players from the game. - + Duplicate Tag - + This tag already exists. @@ -2495,12 +2956,12 @@ Pro odstranění současného avatara potvrďte bez vybrání nového obrázku.< Zadejte prosím jméno pro token: - + Error Chyba - + The chosen name conflicts with an existing card or token. Make sure to enable the 'Token' set in the "Manage sets" dialog to display them correctly. Vybrané jméno se nemůže shodovat s již existující kartou nebo tokenem. @@ -2525,12 +2986,12 @@ Pro správné zobrazení si aktivujte si edici 'Token' v "Nastavi Nespecifikováno - + Real name: Skutečné jméno: - + Edit user profile Upravit uživatelský profil @@ -2568,113 +3029,113 @@ Pro správné zobrazení si aktivujte si edici 'Token' v "Nastavi - + Hide 'buddies only' games - + Hide full games - + Hide games that have started - + Hide password protected games - + Hide 'ignored user' games - + Hide games not created by buddies Hide games not created by buddy - + Hide games with forced open decklists - + &Newer than: - + Game &description: &Popis hry: - + &Creator name: Jméno &tvůrce: - + General Obecné - + &Game types Typ &hry - + at &least: &minimálně: - + at &most: &maximálně: - + Maximum player count Maximální počet hráčů - + Restrictions Omezení - + Show games only if &spectators can watch - + Show spectator password p&rotected games - + Show only if spectators can ch&at - + Show only if spectators can see &hands - + Spectators - + Filter games Filtrovat hry @@ -2977,37 +3438,37 @@ https://tappedout.net/mtg-decks/your-deck-name/ DlgMoveTopCardsUntil - + Card name (or search expressions): - + Number of hits: - + Auto play hits - + Put top cards on stack until... - + No cards matching the search expression exists in the card database. Proceed anyways? - + Cockatrice - + Invalid filter @@ -3066,40 +3527,40 @@ Your email will be used to verify your account. Neznámý - + Real name: Skutečné jméno: - + Register to server Registrovat se na server - - - - + + + + Registration Warning - + Your password is too short. - + Your passwords do not match, please try again. Vaše hesla se neshodují. - + Your email addresses do not match, please try again. Vaše emailové adresy se neshodují. - + The player name can't be empty. Vyplňte jméno hráče. @@ -3107,17 +3568,17 @@ Your email will be used to verify your account. DlgRollDice - + Number of sides: - + Number of dice: - + Roll Dice @@ -3168,12 +3629,12 @@ Your email will be used to verify your account. DlgSettings - + Unknown Error loading card database Neznámý error při nahrávání databáze karet. - + Your card database is invalid. Cockatrice may not function correctly with an invalid database @@ -3190,7 +3651,7 @@ Je možné že bude nutné znovu spustit Oracle pro obnovení databáze karet. Chtěl/a by jste změnit nastavení lokace databáze? - + Your card database version is too old. This can cause problems loading card information or images @@ -3207,7 +3668,7 @@ Je možné to spravit spuštěním Oracle pro aktualizaci vaší databáze karet Chcete změnit nastavení lokace databáze? - + Your card database did not finish loading Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues with your cards.xml attached @@ -3216,7 +3677,7 @@ Would you like to change your database location setting? - + File Error loading your card database. Would you like to change your database location setting? @@ -3225,7 +3686,7 @@ Would you like to change your database location setting? Chcete změnit nastavení lokace databáze? - + Your card database was loaded but contains no cards. Would you like to change your database location setting? @@ -3234,7 +3695,7 @@ Would you like to change your database location setting? Chcete změnit nastavení lokace databáze? - + Unknown card database load status Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues @@ -3243,59 +3704,64 @@ Would you like to change your database location setting? - - - + + + Error Chyba - + The path to your deck directory is invalid. Would you like to go back and set the correct path? Cesta k adresáři s balíčky je neplatná. Chcete se vrátit a nastavit správnou? - + The path to your card pictures directory is invalid. Would you like to go back and set the correct path? Cesta k adresáři s obrázky je neplatná. Chcete se vrátit a nastavit správnou? - + Settings Nastavení - + General Obecné - + Appearance Vzhled - + User Interface Rozhraní - + + Storage + + + + Card Sources - + Chat Chat - + Sound Zvuk - + Shortcuts Skratky @@ -3387,9 +3853,9 @@ You can always change this behavior in the 'General' settings tab. - - - + + + Error Chyba @@ -3441,31 +3907,31 @@ Please visit the download page to update manually. - + Update Available Dostupná aktualizace - + A new version of Cockatrice is available! Je dostupná nová verze Cockatrice! - + New version Nová verze - + Released - + Changelog @@ -3475,50 +3941,50 @@ Please visit the download page to update manually. Chcete aktualizovat nyní? - + Unfortunately, the automatic updater failed to find a compatible download. You may have to manually download the new version. - + Please check the <a href="%1">releases page</a> on our Github and download the build for your system. - - - + + + Update Error Chyba aktualizace - + An error occurred while checking for updates: Došlo k chybě při kontrole aktualizací: - + An error occurred while downloading an update: Došlo k chybě při stahování aktualizace: - + Installing... Instaluji... - + Cockatrice is unable to open the installer. - + Try to update manually by closing Cockatrice and running the installer. - + Download location @@ -3608,67 +4074,67 @@ You may have to manually download the new version. DrawProbabilityWidget - + Draw Probability - + Probability of drawing - + Card Name - + Type - + Subtype - + Mana Value - + At least - + Exactly - + card(s) having drawn at least - + cards - + Category - + Qty - + Odds (%) @@ -3782,7 +4248,7 @@ You may have to manually download the new version. FilterBuilder - + Type your filter here Zde napište svůj filtr: @@ -3813,22 +4279,22 @@ You may have to manually download the new version. GameEventHandler - + kicked by game host or moderator - + player left the game - + player disconnected from server - + reason unknown @@ -3836,140 +4302,140 @@ You may have to manually download the new version. GameSelector - - - - - - - - - + + + + + + + + + Error Chyba - + Please join the appropriate room first. - + Wrong password. Špatné heslo. - + Spectators are not allowed in this game. Do této hry je divákům přístup zakázán. - + The game is already full. Hra je plná. - + The game does not exist any more. Hra již neexistuje. - + This game is only open to registered users. Hra je určena jen pro registrované. - + This game is only open to its creator's buddies. Hra je dostupná jen pro přátele zakládajícícho. - + You are being ignored by the creator of this game. Zakladatel hry vás ignoruje. - + Join Game - + Spectate Game - + Game Information - + Join Game as Judge - + Spectate Game as Judge - + Join game Připojit ke hře - + Password: Heslo: - + Please join the respective room first. - + &Filter games &Filtrovat hry: - + C&lear filter Z&rušit filtr: - + C&reate V&ytvořit - + &Join &Připojit se - + Join as judge - + J&oin as spectator P&řipojit se jako divák - + Join as judge spectator - + Games shown: %1 / %2 - + Games Hry @@ -3977,32 +4443,32 @@ You may have to manually download the new version. GameSelectorQuickFilterToolBar - + All types - + Filter by game name... - + Filter by game type/format - + Hide games not created by buddies - + Hide full games - + Hide started games @@ -4010,105 +4476,105 @@ You may have to manually download the new version. GamesModel - + >1 day - + %1%2 hr short age in hours - + new - + %1%2 min short age in minutes - + password heslo - + buddies only jen pro přátele - + reg. users only jen pro registrované - + open decklists - + can chat může chatovat - + see hands vidět do rukou - + can see hands může vidět do rukou - + not allowed nepovolené - + Room Místnost - + Age Doba od otevření - + Description Popis - + Creator Zakladatel - + Type Typ - + Restrictions Omezení - + Players Hráči - + Spectators Diváci @@ -4116,143 +4582,158 @@ You may have to manually download the new version. GeneralSettingsPage - + Reset all paths - + All paths have been reset - - - - - - - + + + + + + + Choose path Vyberte cestu - - Personal settings - Osobní nastavení + + Language settings + - + Language: Jazyk: - + + Version settings + + + + + Card database + + + + + Startup settings + + + + Paths (editing disabled in portable mode) - + Paths Cesty - + How to help with translations - + Decks directory: Adresář s balíčky: - + Filters directory: - + Replays directory: Adresář se záznamy her - + Pictures directory: Adresář s obrázky: - + Card database: Databáze karet: - + Custom database directory: - + Token database: Databáze tokenů: - + Update channel - + Check for client updates on startup - + Check for card database updates on startup - + Don't check - + Prompt for update - + Always update in the background - + Check for card database updates every - + days - + Notify if a feature supported by the server is missing in my client - + Automatically run Oracle when running a new version of Cockatrice Automaticky spustit Oracle při spouštění nové verze Cockatrice - + Show tips on startup Ukázat tip při spuštění - + Last update check on %1 (%2 days ago) @@ -4260,47 +4741,47 @@ You may have to manually download the new version. GraveyardMenu - + &Graveyard - + &View graveyard - + &Move graveyard to... - + &Top of library - + &Bottom of library - + &All players - + &Hand - + &Exile - + Reveal random card to... @@ -4308,88 +4789,88 @@ You may have to manually download the new version. HandMenu - + &Hand - + &View hand - + Sort hand by... - + Name - + Type - + Mana Value - + Take &mulligan (Choose hand size) - + Take mulligan (Same hand size) - + Take mulligan (Hand size - 1) - + &Move hand to... - + &Top of library - + &Bottom of library - + &Graveyard - + &Exile - + &Reveal hand to... - - + + All players - + Reveal r&andom card to... @@ -4397,52 +4878,52 @@ You may have to manually download the new version. HomeWidget - + Create New Deck - + Browse Decks - + Browse Card Database - + Browse EDHRec - + Browse Archidekt - + View Replays - + Quit - + Connecting... - + Connect - + Play @@ -4450,213 +4931,213 @@ You may have to manually download the new version. LibraryMenu - + &Library - + &View library - + View &top cards of library... - + View bottom cards of library... - + Reveal &library to... - + Lend library to... - + Reveal &top cards to... - + &Top of library... - + &Bottom of library... - + &Always reveal top card - + &Always look at top card - + &Open deck in deck editor - + &Draw card - + D&raw cards... - + &Undo last draw - + Shuffle - + &Play top card - + Play top card &face down - + Put top card on &bottom - + Move top card to grave&yard - + Move top card to e&xile - + Move top cards to &graveyard... - + Move top cards to graveyard face down... - + Move top cards to &exile... - + Move top cards to exile face down... - + Put top cards on stack &until... - + Shuffle top cards... - + &Draw bottom card - + D&raw bottom cards... - + &Play bottom card - + Play bottom card &face down - + Move bottom card to grave&yard - + Move bottom card to e&xile - + Move bottom cards to &graveyard... - + Move bottom cards to graveyard face down... - + Move bottom cards to &exile... - + Move bottom cards to exile face down... - + Put bottom card on &top - + Shuffle bottom cards... - - + + &All players - + Reveal top cards of library - + Number of cards: (max. %1) @@ -4664,835 +5145,453 @@ You may have to manually download the new version. MainWindow - - - The server has reached its maximum user capacity, please check back later. - Na serveru je maximální počet uživatelů, skuste později. - - - - There are too many concurrent connections from your address. - Z vaší adresy jde mnoho současných připojení. - - - - Banned by moderator - - - - - Expected end time: %1 - - - - - This ban lasts indefinitely. - - - - - Scheduled server shutdown. - - - - - - Invalid username. - Neplatné uživatelské jméno. - - - - You have been logged out due to logging in at another location. - - - - - Connection closed - Připojení uzavřeno - - - - The server has terminated your connection. -Reason: %1 - Server přerušil spojení. -Důvod: %1 - - - - The server is going to be restarted in %n minute(s). -All running games will be lost. -Reason for shutdown: %1 - - - - - Scheduled server shutdown - - - - - - Success - - - - - Registration accepted. -Will now login. - - - - - Account activation accepted. -Will now login. - - - - - + + Player %1 Hráč %1 - + Load replay - + About Cockatrice O Cockatrice - + Version - + Cockatrice Webpage - + Project Manager: - + Past Project Managers: - + Developers: - + Our Developers - + Help Develop! - + Translators: Překlad: - + Our Translators - + Help Translate! - + Support: - + Report an Issue - + Troubleshooting - + F.A.Q. - - - - - - - - - - - - - - - - - - - - + + Error Chyba - - Server timeout - Vypršel časový limit - - - - Failed Login - - - - - Your client seems to be missing features this server requires for connection. - - - - - To update your client, go to 'Help -> Check for Client Updates'. - - - - - Incorrect username or password. Please check your authentication information and try again. - - - - - There is already an active session using this user name. -Please close that session first and re-login. - S tímto uživatelským jménem jste již připojeni. -Přerušte spojení a znovu se přihlašte. - - - - - You are banned until %1. - - - - - - You are banned indefinitely. - - - - - This server requires user registration. Do you want to register now? - - - - - This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. -Please close and reopen your client to try again. - - - - - An internal error has occurred, please close and reopen Cockatrice before trying again. -If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. - - - - - Account activation - - - - - Your account has not been activated yet. -You need to provide the activation token received in the activation email. - - - - - Server Full - - - - - Unknown login error: %1 - - - - - - -This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. - - - - - Your username must respect these rules: - - - - - is %1 - %2 characters long - - - - - can %1 contain lowercase characters - - - - - - - - NOT - - - - - can %1 contain uppercase characters - - - - - can %1 contain numeric characters - - - - - can contain the following punctuation: %1 - - - - - first character can %1 be a punctuation mark - - - - - no unacceptable language as specified by these server rules: - note that the following lines will not be translated - - - - - can not contain any of the following words: %1 - - - - - can not match any of the following expressions: %1 - - - - - You may only use A-Z, a-z, 0-9, _, ., and - in your username. - - - - - - - - - - Registration denied - - - - - Registration is currently disabled on this server - - - - - There is already an existing account with the same user name. - - - - - It's mandatory to specify a valid email address when registering. - - - - - It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. - - - - - Password too short. - - - - - Registration failed for a technical problem on the server. - - - - - The connection to the server has been lost. - - - - - Unknown registration error: %1 - - - - - Account activation failed - - - - - Socket error: %1 - Chyba socketu: %1 - - - - You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. -Local version is %1, remote version is %2. - Snažíte se připojit na zastaralý server. Prosíme, stáhněte si nižší verzi Cockatrice, nebo se připojte k odpovídajícímu serveru. -Lokální verze je %1, verze serveru je %2. - - - - Your Cockatrice client is obsolete. Please update your Cockatrice version. -Local version is %1, remote version is %2. - Váš klient je zastaralý. Prosíme, aktualizujte Cockatrice na vyšší verzi. -Lokální verze je %1, verze serveru je %2. - - - - Connecting to %1... - Připojování k %1... - - - - Registering to %1 as %2... - - - - - Disconnected - Odpojeno - - - - Connected, logging in at %1 - - - - - - - Requesting forgotten password to %1 as %2... - - - - + &Connect... &Připojit... - + &Disconnect &Odpojit - + Start &local game... Spustit &lokální hru... - + &Watch replay... - + &Full screen &Celá obrazovka - + &Register to server... - + &Restore password... - + &Settings... &Nastavení... - + &Exit &Konec - + A&ctions - + &Cockatrice &Cockatrice - + C&ard Database - + &Manage sets... - + Edit custom &tokens... - + Open custom image folder - + Open custom sets folder - + Add custom sets/cards - + Reload card database - + Tabs - + &Help &Nápověda - + &About Cockatrice &O Cockatrice - + &Tip of the Day - + Check for Client Updates - + Check for Card Updates... - + Check for Card Updates (Automatic) - + Show Status Bar - + View &Debug Log - + Open Settings Folder - + Show/Hide - + New Version - + Congratulations on updating to Cockatrice %1! Oracle will now launch to update your card database. - + Cockatrice installed - + Congratulations on installing Cockatrice %1! Oracle will now launch to install the initial card database. - + Card database - + Cockatrice is unable to load the card database. Do you want to update your card database now? If unsure or first time user, choose "Yes" - - + + Yes - - + + No - + Open settings - + New sets found - + %n new set(s) found in the card database Set code(s): %1 Do you want to enable it/them? - + + Yes, always enable + + + + View sets - + Welcome - + Hi! It seems like you're running this version of Cockatrice for the first time. All the sets in the card database have been enabled. Read more about changing the set order or disabling specific sets and consequent effects in the "Manage Sets" dialog. - - + Information - + A card database update is already running. - + Unable to run the card database updater: - + Card database update running. - + Failed to start. The file might be missing, or permissions might be incorrect. - + The process crashed some time after starting successfully. - + Timed out. The process took too long to respond. The last waitFor...() function timed out. - + An error occurred when attempting to write to the process. For example, the process may not be running, or it may have closed its input channel. - + An error occurred when attempting to read from the process. For example, the process may not be running. - + Unknown error occurred. - + The card database updater exited with an error: %1 - - This server supports additional features that your client doesn't have. -This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. - -To update your client, go to Help -> Check for Updates. - - - - - - - - + + + + + Load sets/cards - + Selected file cannot be found. - + You can only import XML databases at this time. - + The new sets/cards have been added successfully. Cockatrice will now reload the card database. - + Sets/cards failed to import. - - - - - Reset Password - - - - - Your password has been reset successfully, you can now log in using the new credentials. - - - - - Failed to reset user account password, please contact the server operator to reset your password. - - - - - Activation request received, please check your email for an activation token. - - ManaBaseConfigDialog - + Mana Base Configuration - + Display type: - + pie - + bar - + combinedBar - + Filter Colors (optional): - + OK - + Cancel @@ -5564,27 +5663,27 @@ Cockatrice will now reload the card database. ManaDevotionConfigDialog - + Display type: - + pie - + bar - + combinedBar - + Filter Colors (optional): @@ -5654,610 +5753,615 @@ Cockatrice will now reload the card database. MessageLogWidget - + from play - + from their graveyard - + from exile z exilnutých karet - + from their hand - + the top card of %1's library - + the top card of their library - + from the top of %1's library - + from the top of their library - + the bottom card of %1's library - + the bottom card of their library - + from the bottom of %1's library - + from the bottom of their library - + from %1's library - + from their library - + from sideboard ze sideboardu - + from the stack ze stacku - + from custom zone '%1' - + %1 is now keeping the top card %2 revealed. - + %1 is not revealing the top card %2 any longer. - + %1 can now look at top card %2 at any time. - + %1 no longer can look at top card %2 at any time. - + %1 attaches %2 to %3's %4. - + %1 has conceded the game. - + %1 has unconceded the game. - + %1 has restored connection to the game. - + %1 has lost connection to the game. - + %1 points from their %2 to themselves. - + %1 points from their %2 to %3. - + %1 points from %2's %3 to themselves. - + %1 points from %2's %3 to %4. - + %1 points from their %2 to their %3. - + %1 points from their %2 to %3's %4. - + %1 points from %2's %3 to their own %4. - + %1 points from %2's %3 to %4's %5. - + %1 creates a face down token. - + %1 creates token: %2%3. - + %1 has loaded a deck (%2). - + %1 has loaded a deck with %2 sideboard cards (%3). - + %1 destroys %2. - + a card kartu - + %1 gives %2 control over %3. %1 předává kontrolu hráči %2 karty %3. - + %1 puts %2 into play%3 face down. - + %1 puts %2 into play%3. %1 dává kartu %2 %3 do hry. - + %1 puts %2%3 into their graveyard face down. - + %1 puts %2%3 into their graveyard. - + %1 exiles %2%3 face down. - + %1 exiles %2%3. %1 exiluje %2%3. - + %1 moves %2%3 to their hand. - + %1 puts %2%3 into their library. - + %1 puts %2%3 onto the bottom of their library. - + %1 puts %2%3 on top of their library. - + %1 puts %2%3 into their library %4 cards from the top. - + %1 moves %2%3 to sideboard. %1 přesouvá %2%3 do sideboardu. - + %1 plays %2%3 face down. - + %1 plays %2%3. %1 sesílá %2%3. - + %1 moves %2%3 to custom zone '%4' face down. - + %1 moves %2%3 to custom zone '%4'. - + %1 tries to draw from an empty library - + %1 draws %2 card(s). - + %1 is looking at %2. - + %1 is looking at the %4 %3 card(s) %2. top card for singular, top %3 cards for plural - + bottom - + top - + %1 turns %2 face-down. - + %1 turns %2 face-up. - + The game has been closed. Hra byla ukončena. - + The game has started. Hra začíná. - + You are flooding the game. Please wait a couple of seconds. - + %1 has joined the game. - + %1 is now watching the game. %1 nyní sleduje hru. - + You have been kicked out of the game. - + %1 has left the game (%2). - + %1 is not watching the game any more (%2). - + %1 is not ready to start the game any more. - + %1 shuffles their deck and draws a new hand of %2 card(s). - + %1 shuffles their deck and draws a new hand. - + You are watching a replay of game #%1. - + %1 is ready to start the game. - + cards an unknown amount of cards - + %1 card(s) a card for singular, %1 cards for plural - + %1 lends %2 to %3. - + %1 reveals %2 to %3. - + %1 reveals %2. - + %1 randomly reveals %2%3 to %4. - + %1 randomly reveals %2%3. - + %1 peeks at face down card #%2. - + %1 peeks at face down card #%2: %3. - + %1 reveals %2%3 to %4. - + %1 reveals %2%3. - + %1 reversed turn order, now it's %2. - + reversed - + normal - + Heads - + Tails - + %1 flipped a coin. It landed as %2. - + %1 rolls a %2 with a %3-sided die. - + %1 flips %2 coins. There are %3 heads and %4 tails. - + %1 rolls a %2-sided dice %3 times: %4. - + %1's turn. - + %1 sets annotation of %2 to %3. - - %1 places %2 "%3" counter(s) on %4 (now %5). + + %1 places %2 %3%4 counter(s) on %5 (now %6). - - %1 removes %2 "%3" counter(s) from %4 (now %5). + + %1 removes %2 %3%4 counter(s) from %5 (now %6). - + + %1 failed to undo their last draw. + + + + %1 sets counter %2 to %3 (%4%5). - + %1 sets %2 to not untap normally. - + %1 sets %2 to untap normally. - + %1 removes the PT of %2. - + %1 changes the PT of %2 from nothing to %4. - + %1 changes the PT of %2 from %3 to %4. - + %1 has locked their sideboard. - + %1 has unlocked their sideboard. - + %1 taps their permanents. - + %1 untaps their permanents. - + %1 taps %2. - + %1 untaps %2. - + %1 shuffles %2. - + %1 shuffles the bottom %3 cards of %2. - + %1 shuffles the top %3 cards of %2. - + %1 shuffles cards %3 - %4 of %2. - + %1 unattaches %2. - + %1 undoes their last draw. - + %1 undoes their last draw (%2). @@ -6265,110 +6369,115 @@ Cockatrice will now reload the card database. MessagesSettingsPage - + Word1 Word2 Word3 - + Add New Message - + Edit Message - + Remove Message - + Add message Přidat zprávu - - + + Message: Zpráva: - + Edit message - + Chat settings - + Custom alert words - + Enable chat mentions - + Enable mention completer - + In-game message macros - + How to use in-game message macros - + Ignore chat room messages sent by unregistered users - + Ignore private messages sent by unregistered users - - + + Ignore private messages sent by non-buddy users + + + + + Invert text color - + Enable desktop notifications for private messages - + Enable desktop notification for mentions - + Enable room message history on join - - + + (Color is hexadecimal) - + Separate words with a space, alphanumeric characters only @@ -6376,42 +6485,42 @@ Cockatrice will now reload the card database. MoveMenu - + Move to - + &Top of library in random order - + X cards from the top of library... - + &Bottom of library in random order - + T&able - + &Hand - + &Graveyard - + &Exile @@ -6424,47 +6533,47 @@ Cockatrice will now reload the card database. - + Mana Value - + Color(s) - + Loyalty - + Main Card Type - + Mana Cost - + P/T - + Side - + Layout - + Color Identity @@ -6487,6 +6596,152 @@ Cockatrice will now reload the card database. + + PaletteEditorDialog + + + + Reset + + + + + + Apply + + + + + + Save && Apply + + + + + ▼ Edit Palette + + + + + + ▶ Edit Palette + + + + + Palette Editor — %1 + + + + + <b>Palette Editor</b> &nbsp;·&nbsp; %1 + + + + + This theme ships no default palette files + + + + + Replace current colours with the theme author's defaults + + + + + Switch between the light and dark palette files + + + + + Editing: + + + + + Show or hide the per-role colour grid for manual tweaks + + + + + ↺ Revert to theme default + + + + + Discard unsaved edits and restore the last saved palette + + + + + Preview this palette without saving to disk + + + + + Write palette-%1.toml and reload the theme + + + + + Cannot save: this theme has no directory on disk + + + + + Save failed + + + + + Could not write %1 to: +%2 + + + + + No default found + + + + + No default palette file found for the "%1" scheme. + + + + + PaletteGridWidget + + + Active + + + + + Disabled + + + + + Inactive + + + + + Normal interactive state + + + + + Widget is disabled / not interactive + + + + + Window is in background / unfocused + + + Phase @@ -6553,57 +6808,57 @@ Cockatrice will now reload the card database. PhasesToolbar - + Untap step Odtapovací fáze - + Upkeep step Upkeep - + Draw step Lízací fáze - + First main phase První hlavní fáze - + Beginning of combat step Začátek bojové fáze - + Declare attackers step Oznámení útočníků - + Declare blockers step Oznámení blokujících - + Combat damage step Udělení bojového zranění - + End of combat step Konec bojové fáze - + Second main phase Druhá hlavní fáze - + End of turn step Konec kola @@ -6611,7 +6866,7 @@ Cockatrice will now reload the card database. PictureLoader - + en code for scryfall's language property, not available for all languages @@ -6620,151 +6875,142 @@ Cockatrice will now reload the card database. PlayerActions - - View top cards of library - - - - - - - - - - - - - Number of cards: (max. %1) - - - - - View bottom cards of library - - - - - Shuffle top cards of library - - - - - Shuffle bottom cards of library - - - - - Draw hand - - - - - 0 and lower are in comparison to current hand size - - - - - Draw cards - - - - - - - + + + + grave - - - - + + + + exile + + + PlayerDialogs - + + View top cards of library + + + + + + + + + + + + + Number of cards: (max. %1) + + + + + View bottom cards of library + + + + + Shuffle top cards of library + + + + + Shuffle bottom cards of library + + + + + Draw hand + + + + + 0 and lower are in comparison to current hand size + + + + + Draw cards + + + + Move top cards to %1 - + Move bottom cards to %1 - + Draw bottom cards - - - C&reate another %1 token - - - - + Create tokens - - + Number: - + Place card X cards from top of library - + Which position should this card be placed: - + (max. %1) - + Change power/toughness - + Change stats to: - + Set annotation - + Please enter the new annotation: - - - Set counters - - PlayerMenu - + Player "%1" - + &Counters @@ -6794,7 +7040,7 @@ This setting means you'll only see the default printing for each card, instead o - + Printing Selector @@ -6802,22 +7048,22 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelectorCardOverlayWidget - + Preference - + Pin Printing - + Unpin Printing - + Show Related cards @@ -6888,57 +7134,57 @@ This setting means you'll only see the default printing for each card, instead o PtMenu - + Power / toughness - + &Increase power - + &Decrease power - + I&ncrease toughness - + D&ecrease toughness - + In&crease power and toughness - + Dec&rease power and toughness - + Increase power and decrease toughness - + Decrease power and increase toughness - + Set &power and toughness... - + Reset p&ower and toughness @@ -6946,37 +7192,37 @@ This setting means you'll only see the default printing for each card, instead o QMenuBar - + Services - + Hide %1 - + Hide Others - + Show All - + Preferences... - + Quit %1 - + About %1 @@ -6984,17 +7230,17 @@ This setting means you'll only see the default printing for each card, instead o QObject - + Cockatrice card database (*.xml) - + All files (*.*) - + Cockatrice replays (*.cor) @@ -7054,110 +7300,177 @@ Are you sure you would like to disable this feature? QPlatformTheme - + OK - + Save - + Save All - + Open - + &Yes - + Yes to &All - + &No - + N&o to All - + Abort - + Retry - + Ignore - + Close - + Cancel - + Discard - + Help - + Apply - + Reset - + Restore Defaults + + QuickSetupPanel + + + %1% + + + + + <b>Quick Setup</b> + + + + + Generate all palette roles automatically from a single accent colour + + + + + Accent: + + + + + Primary hue. Used directly for highlights and links. +At high intensity it also tints buttons and backgrounds. + + + + + Intensity: + + + + + Subtle + + + + + Full colour + + + + + 0–30 Subtle tint — only highlights and links change hue +30–70 Accented — buttons, tooltips, and borders join in +70–100 Full colour — backgrounds, everything + + + + + 70% + + + + + Generate ↓ + + + + + Derive all palette roles from the accent colour above. +Fine-tune individual colours in the grid afterwards. + + + RemoteDeckList_TreeModel - + Name Jméno - + ID ID - + Upload time Průběh nahrávání @@ -7165,32 +7478,32 @@ Are you sure you would like to disable this feature? RemoteReplayList_TreeModel - + ID - + Name - + Players - + Keep - + Time started - + Duration (sec) @@ -7198,37 +7511,37 @@ Are you sure you would like to disable this feature? RfgMenu - + &Exile - + &View exile - + &Move exile to... - + &Top of library - + &Bottom of library - + &Hand - + &Graveyard @@ -7274,7 +7587,7 @@ Are you sure you would like to disable this feature? SayMenu - + S&ay @@ -7315,27 +7628,27 @@ Are you sure you would like to disable this feature? SetsModel - + Enabled - + Set type - + Set code - + Long name Dlouhý název - + Release date @@ -7343,53 +7656,53 @@ Are you sure you would like to disable this feature? ShortcutSettingsPage - - + + Restore all default shortcuts - + Do you really want to restore all default shortcuts? - + Clear all default shortcuts - + Do you really want to clear all shortcuts? - + Section: - + Action: - + Shortcut: - + How to set custom shortcuts - + Clear all shortcuts - + Search by shortcut name @@ -7443,12 +7756,12 @@ Please check your shortcut settings! SideboardMenu - + &Sideboard - + &View sideboard @@ -7456,27 +7769,27 @@ Please check your shortcut settings! SoundSettingsPage - + Enable &sounds - + Current sounds theme: - + Test system sound engine - + Sound settings - + Master volume @@ -7548,16 +7861,146 @@ Please check your shortcut settings! - + No reply received from the tag update server. - + Invalid reply received from the tag update server. + + StorageSettingsPage + + + + + Success + + + + + Cached card pictures have been reset. + + + + + Downloaded card pictures have been reset. + + + + + Error + + + + + One or more downloaded card pictures could not be cleared. + + + + + In-memory (currently loaded) card pictures have been reset. + + + + + Card Picture Loader Caching Method: + + + + + The network cache is the preferred way of storing images. Downloaded images are stored here until the size of the cache exceeds the configured size. Cockatrice automatically monitors this cache and deletes the least recently seen card images to ensure the cache does not exceed the configured size. + + + + + Writing card images directly to a folder on your hard drive is another way of storing images. This does not change how Cockatrice accesses or downloads images. Cockatrice will NOT automatically monitor and clear this folder, so if you enable this option, it is up to you to ensure sufficient available space. It should also be noted that if a provider outage causes you to download the wrong picture (i.e. wrong printing) you will be stuck with it until you manually delete the file, as opposed to using the network cache, which automatically rotates and thus correct errors after a while. + + + + + This is the in-memory picture cache used by the application at runtime. It determines how much memory (RAM) Cockatrice can use before it has to fetch card images from the hard disk again. Increasing this will allow more card images to be displayed at once but shouldn't be necessary. Clearing this will make Cockatrice reload all images from the network cache or the disk. + + + + + Delete Cached Images + + + + + Delete Saved Images + + + + + Clear In-Memory Images + + + + + Card Picture Loader Cache Method + + + + + Network Cache + + + + + Filesystem + + + + + In-Memory Picture Cache + + + + + Network Cache Size: + + + + + On-disk cache for downloaded pictures + + + + + Redirect Cache TTL: + + + + + How long cached redirects for urls are valid for. + + + + + Picture Cache Size: + + + + + In-memory cache for pictures not currently on screen + + + + + Naming scheme: + + + + + Day(s) + + + TabAccount @@ -7693,117 +8136,117 @@ Please check your shortcut settings! TabArchidekt - - + + Desc. - - + + AND - - + + Require ALL selected colors - - + + Deck name... - - + + Owner... - - + + Packages - - + + Advanced Filters - + Bracket: - - + + Any - - + + Contains card... - - + + Commander... - - + + Tag... - - + + Deck Size - + Cards: - - + + Asc. - + Sort by: - + Filter by: - + Display Settings - - + + Search - - + + Formats @@ -7813,6 +8256,54 @@ Please check your shortcut settings! + + TabCardArtRules + + + Card: + + + + + ProviderId: + + + + + Mode: + + + + + Reason: + + + + + Type a card name... + + + + + Add rule + + + + + Remove rule + + + + + Refresh + + + + + Card Art Rules + + + TabDeckEditor @@ -7861,7 +8352,7 @@ Please check your shortcut settings! - + Deck: %1 @@ -7869,55 +8360,49 @@ Please check your shortcut settings! TabDeckEditorVisual - + Visual Deck: %1 - + &Visual Deck Editor - - + + Card Info - - + + Deck - - - Filters - - - - + &View - + Printing - + Visible - + Floating - + Reset layout @@ -7925,22 +8410,22 @@ Please check your shortcut settings! TabDeckEditorVisualTabWidget - + Visual Deck View - + Visual Database Display - + Deck Analytics - + Sample Hand @@ -7948,134 +8433,134 @@ Please check your shortcut settings! TabDeckStorage - + Local file system Lokální systém souborů - + Server deck storage Balíčky na serveru - - + + Open in deck editor Otevřít v editoru balíčků - + Rename deck or folder - + Upload deck Nahrát balíček - + Download deck Stáhnout balíček - - - + + + New folder Nová složka - + Delete Smazat - + Open decks folder - + Rename local folder - + Rename local file - + New name: - - - - + + + + Error - + Rename failed - - + + Invalid deck file - + Enter deck name Vložit jméno balíčku - + This decklist does not have a name. Please enter a name: Tentobalíček nemá jméno. Prosím vložte jméno: - + Unnamed deck Bezejmenný balíček - + Failed to upload deck to server - + Delete local file - + Are you sure you want to delete the selected files? - + Delete remote decks - + Are you sure you want to delete the selected decks? - - + + Name of new folder: Název nové složky: @@ -8147,191 +8632,191 @@ Prosím vložte jméno: TabGame - - - + + + Replay - - + + Game - - + + Player List - - + + Card Info - - + + Messages - - + + Replay Timeline - + &Phases &Fáze - + &Game &Hra - + Next &phase Další &fáze - + Next phase with &action - + Next &turn Další &kolo - + Reverse turn order - + &Remove all local arrows &Odstranit všechny lokální šipky - + Rotate View Cl&ockwise - + Rotate View Co&unterclockwise - + Game &information - + Un&concede - - - + + + &Concede &Ukončit hru - + &Leave game &Opustit hru - + C&lose replay - + &Focus Chat - + &Say: &Chat: - + Selected cards - + &View - + Visible - + Floating - + Reset layout - + Concede Ukončit hru - + Are you sure you want to concede this game? Opravdu chcete ukončit tuto hru? - + Unconcede - + You have already conceded. Do you want to return to this game? - + Leave game Opustit hru - + Are you sure you want to leave this game? Opravdu chcete opustit tuto hru? - + A player has joined game #%1 - + %1 has joined the game - + You have been kicked out of the game. @@ -8390,114 +8875,114 @@ Prosím vložte jméno: - + Username: - + IP Address: - + Game Name: - + GameID: - + Message: - + Main Room - + Game Room - + Private Chat - + Past X Days: - + Today - + Last Hour - + Maximum Results: - + At least one filter is required. The more information you put in, the more specific your results will be. - + Get User Logs - + Clear Filters - + Filters - + Log Locations - + Date Range - + Maximum Results - - + + Message History - + Failed to collect message history information. - + There are no messages for the selected filters. @@ -8515,27 +9000,27 @@ The more information you put in, the more specific your results will be.&Odejít - + %1 - Private chat - + This user is ignoring you, they cannot see your messages in main chat and you cannot join their games. - + Private message from - + %1 has left the server. %1 opustil server. - + %1 has joined the server. %1 se připojil k serveru. @@ -8543,180 +9028,180 @@ The more information you put in, the more specific your results will be. TabReplays - + Local file system - + Server replay storage - - + + Watch replay - + Rename - - + + New folder - - + + Delete - + Open replays folder - + Download replay - + Toggle expiration lock - + Get replay share code - - + + Look up replay by share code - + Rename local folder - + Rename local file - + New name: - + Error - + Rename failed - + Name of new folder: - + Delete local file - + Are you sure you want to delete the selected files? - + Are you sure you want to delete the selected replays? - + Failed to get code - - + + Either this server does not support replay sharing, or does not permit replay sharing for you. - - - + + + Failed - + Could not get replay code - + Replay Share Code - + Others can use this code to add the replay to their list of remote replays: %1 - + Copy to clipboard - + Replay share code - + Replay code found - + Replay was added, or you already had access to it. - + Replay code not found - + Failed to submit code - + Unexpected error - + Delete remote replay @@ -8729,47 +9214,62 @@ The more information you put in, the more specific your results will be. TabRoom - + + Friends + + + + + Online + + + + + Ignored + + + + &Say: &Chat: - + Chat Chatovat - + &Room &Místnost - + &Leave room &Opustit místnost - + &Clear chat - + Chat Settings... - + mentioned you. - + Click to view - + You are flooding the chat. Please wait a couple of seconds. Píšete příliš intenzivně. Počkejte pár sekund. @@ -8782,30 +9282,30 @@ The more information you put in, the more specific your results will be. - - - - + + + + Error Chyba - + Failed to join the server room: it doesn't exist on the server. - + The server thinks you are in the server room but your client is unable to display it. Try restarting your client. - + You do not have the required permission to join this server room. - + Failed to join the server room due to an unknown error: %1. @@ -8813,97 +9313,97 @@ The more information you put in, the more specific your results will be. TabSupervisor - + Deck Editor - + Visual Deck Editor - + EDHRec - + Archidekt - + Home - + &Visual Deck Storage - + Visual Database Display - + Server - + Account - + Deck Storage - + Game Replays - + Administration - + Logs - + Are you sure? - + There are still open games. Are you sure you want to quit? - + Click to view - + Your buddy %1 has signed on! - + Unknown Event - + The server has sent you a message that your client does not understand. This message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -8911,38 +9411,38 @@ To update your client, go to Help -> Check for Updates. - + Idle Timeout - + You are about to be logged out due to inactivity. - + Promotion - + You have been promoted. Please log out and back in for changes to take effect. - + Warned - + You have received a warning due to %1. Please refrain from engaging in this activity or further actions may be taken against you. If you have any questions, please private message a moderator. - + You have received the following message from the server. (custom messages like these could be untranslated) @@ -8951,12 +9451,12 @@ Please refrain from engaging in this activity or further actions may be taken ag TabVisualDatabaseDisplay - + Database Display - + Visual Database Display @@ -8993,42 +9493,42 @@ Please refrain from engaging in this activity or further actions may be taken ag TranslateCounterName - + Life - + White - + Blue - + Black - + Red - + Green - + Colorless - + Other @@ -9036,11 +9536,69 @@ Please refrain from engaging in this activity or further actions may be taken ag UpdateDownloader - + Could not open the file for reading. + + UserCardArtSettingsDialog + + + Card Art Settings + + + + + Type a card name... + + + + + Card name: + + + + + Card ProviderId: + + + + + Left margin (%): + + + + + Right margin (%): + + + + + Vertical offset: + + + + + Zoom: + + + + + Parameters + + + + + Preview + + + + + Remove Banner Card + + + UserContextMenu @@ -9158,7 +9716,7 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Ban History @@ -9173,77 +9731,87 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Failed to collect ban information. - - - + + + Warning History - + Warning Time;Moderator;User Name;Reason - + User has never been warned. - + Failed to collect warning information. - + Failed to get admin notes. - - + + Success - + Successfully promoted user. - + Successfully demoted user. - - - + + Kick Player + + + + + Are you sure you want to kick this player from the game? + + + + + + Failed - + Failed to promote user. - + Failed to demote user. - + Copy hash to clipboard - + Remove this user's messages @@ -9251,326 +9819,554 @@ Please refrain from engaging in this activity or further actions may be taken ag UserInfoBox - + Location: Místo: - + Account Age: - + Edit - + Change password - + Change avatar - + Administrator Administrátor - + Moderator - + Registered user Registrovaný - - + + Unregistered user Neregistrovaný - + Judge - + Unknown - + The entered password does not match your account. - - - + + + + Information - + User information updated. - - - - - - - - - - + + + + + + + + + + + Error Chyba - + User Information - + Real Name: - + User Level: + + + Edit Banner Card + + - + %n Year(s), amount of years (only shown if more than 0) - + %10%n Day(s) %20 amount of years (if more than 0), amount of days, date in local short format - + Enter Password - + Password verification is required in order to change your email address - - - + + + An error occurred while trying to update your user information. - - This server does not permit you to update your user informations. + + The selected card is blacklisted on this server or another error occurred. - - Password changed. + + Banner card removed. - - This server does not permit you to change your password. - - - - - The new password is too short. - - - - - The old password is incorrect. - - - - - Avatar updated. - - - - - This server does not permit you to update your avatar. + + Banner card updated. - An error occured while trying to updater your avatar. + This server does not permit you to update your user informations. + + + + + Password changed. + + + + + This server does not permit you to change your password. + + + + + The new password is too short. + + + + + The old password is incorrect. + + + + + Avatar updated. + + + + + This server does not permit you to update your avatar. + + + + + An error occured while trying to update your avatar. + + + + + This server does not permit you to update your user informations. + An error occured while trying to updater your avatar. + + + + + UserInfoPopup + + + + Games + + + + + Chat + + + + + Open private chat + + + + + Profile + + + + + View user profile + + + + + Show this user's games + + + + + − Buddy + + + + + Remove from buddy list + + + + + + Buddy + + + + + Add to buddy list + + + + + − Ignore + + + + + Remove from ignore list + + + + + + Ignore + + + + + Add to ignore list + + + + + Ban + + + + + Ban from server + + + + + Warn + + + + + Warn user + + + + + Ban log + + + + + View ban history + + + + + Warn log + + + + + View warning history + + + + + Notes + + + + + View admin notes + + + + + − Mod + + + + + Demote from moderator + + + + + + Mod + + + + + Promote to moderator + + + + + − Judge + + + + + Demote from judge + + + + + + Judge + + + + + Promote to judge + + + + + Join game + + + + + Spectate + + + + + + Loading games… + + + + + Could not load games. + + + + + No active games. UserInterfaceSettingsPage - + General interface settings Obecné - + &Double-click cards to play them (instead of single-click) &Pro zahraní karty je třeba dvojklik - + &Clicking plays all selected cards (instead of just the clicked card) - + &Play all nonlands onto the stack (not the battlefield) by default - + Do not delete &arrows inside of subphases - + Close card view window when last card is removed - + Auto focus search bar when card view window is opened - + Annotate card text on tokens - - Show selection counter during drag selection + + Show selection count during drag selection - - Show total selection counter + + Show total selection count - + + Show subtype breakdown in selection tally + + + + Use tear-off menus, allowing right click menus to persist on screen - + + Keep game chat focused when clicking in game (Note: disables card view search bar) + + + + Notifications settings - + Enable notifications in taskbar - + Notify in the taskbar for game events while you are spectating - + Notify in the taskbar when users in your buddy list connect - + Animation settings Animace - + &Tap/untap animation &Animace tapnutí/odtapnutí - + Deck editor/storage settings - + Open deck in new tab by default - + Use visual deck storage in game lobby - + Use selection animation for Visual Deck Storage - + When adding a tag in the visual deck storage to a .txt deck: - + do nothing - + ask to convert to .cod - + always convert to .cod - + Default deck editor type - + Classic Deck Editor - + Visual Deck Editor - + Replay settings - + Buffer time for backwards skip via shortcut: @@ -9578,22 +10374,22 @@ Please refrain from engaging in this activity or further actions may be taken ag UserListWidget - + Users connected to server: %1 - + Users in this room: %1 - + Buddies online: %1 / %2 - + Ignored users online: %1 / %2 @@ -9601,35 +10397,45 @@ Please refrain from engaging in this activity or further actions may be taken ag UtilityMenu - + Increment all card counters - + &Untap all permanents - + R&oll die... - + + Flip coin + + + + &Create token... - + C&reate another token - + Cr&eate predefined token + + + C&reate another %1 token + + VisualDatabaseDisplayColorFilterWidget @@ -9681,72 +10487,72 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayFilterToolbarWidget - + Sort by - + Filter by - + Save and load filters - + Filter by exact card name - + Filter by card main-type - + Filter by card sub-type - + Filter by set - + Filter by format legality - + Save/Load - + Name - + Main Type - + Sub Type - + Sets - + Formats @@ -9754,22 +10560,22 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayFormatLegalityFilterWidget - + Show formats with at least: - + cards - + Do not display formats with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) @@ -9787,32 +10593,32 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayMainTypeFilterWidget - + Show main types with at least: - + cards - + Do not display card main-types with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) - + Mode: Exact Match - + Mode: Includes @@ -9820,27 +10626,27 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayNameFilterWidget - + Filter by name... (Exact match) - + Load from Deck - + Apply all card names in currently loaded deck as exact match name filters - + Load from Clipboard - + Apply all card names in clipboard as exact match name filters @@ -9848,7 +10654,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayRecentSetFilterSettingsWidget - + Filter to most recent sets @@ -9856,19 +10662,19 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplaySetFilterWidget - + Search sets... - - + + Mode: Exact Match - - + + Mode: Includes @@ -9876,37 +10682,37 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplaySubTypeFilterWidget - + Search subtypes... - + Show sub types with at least: - + cards - + Do not display card sub-types with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) - + Mode: Exact Match - + Mode: Includes @@ -9914,23 +10720,23 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayWidget - + Search by card name (or search expressions) - + Visual - + Loading database ... - + Clear all filters @@ -9969,17 +10775,17 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Toggle Layout: Overlap - + Change how cards are displayed within zones (i.e. overlapped or fully visible.) - + Toggle Layout: Flat @@ -10008,17 +10814,17 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckEditorWidget - + Type a card name here for suggestions from the database... - + Quick search and add card - + Search for closest match in the database (with auto-suggestions) and add preferred printing to the deck on pressing enter @@ -10136,43 +10942,43 @@ Please refrain from engaging in this activity or further actions may be taken ag WarningDialog - + Which warning would you like to send? - + Redact all messages from this user in all rooms - + &OK - + &Cancel - + Warn user for misconduct - - + + Error Chyba - + User name to send a warning to can not be blank, please specify a user to warn. - + Warning to use can not be blank, please select a valid warning to send. @@ -10180,133 +10986,133 @@ Please refrain from engaging in this activity or further actions may be taken ag WndSets - + Move selected set to the top - + Move selected set up - + Move selected set down - + Move selected set to the bottom - + Search by set name, code, or type - + Default order - + Restore original art priority order - + Enable all sets - + Disable all sets - + Enable selected set(s) - + Disable selected set(s) - + Deck Editor - + Use CTRL+A to select all sets in the view. - + Only cards in enabled sets will appear in the card list of the deck editor. - + Image priority is decided in the following order: - + first the CUSTOM Folder (%1), then the Enabled Sets in this dialog (Top to Bottom) %1 is a link to the wiki - + Include cards rebalanced for Alchemy [requires restart] - + Card Art - + How to use custom card art - + Hints - + Note - + Sorting by column allows you to find a set while not changing set priority. - + To enable ordering again, click the column header until this message disappears. - + Use the current sorting as the set priority instead - + Sorts the set priority using the same column - + Manage sets @@ -10314,72 +11120,72 @@ Please refrain from engaging in this activity or further actions may be taken ag ZoneViewWidget - + Search by card name (or search expressions) - + Ungrouped - + Group by Type - + Group by Mana Value - + Group by Color - + Unsorted - + Sort by Name - + Sort by Type - + Sort by Mana Cost - + Sort by Colors - + Sort by P/T - + Sort by Set - + shuffle when closing zamíchat po zavření - + pile view @@ -10387,7 +11193,7 @@ Please refrain from engaging in this activity or further actions may be taken ag i18n - + English Česky (Czech) @@ -10395,12 +11201,12 @@ Please refrain from engaging in this activity or further actions may be taken ag main - + Connect on startup - + Debug to file @@ -10414,7 +11220,7 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Deck Editor @@ -10495,7 +11301,7 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Replays @@ -10610,129 +11416,129 @@ Please refrain from engaging in this activity or further actions may be taken ag - - + + Load Deck from Clipboard... - + Edit Deck in Clipboard, Annotated - + Edit Deck in Clipboard - + New Deck - + Open Custom Pictures Folder - + Print Deck... - + Delete Card - - + + Reset Layout - + Save Deck - + Save Deck as... - + Save Deck to Clipboard, Annotated - + Save Deck to Clipboard, Annotated (No Set Info) - + Save Deck to Clipboard - + Save Deck to Clipboard (No Set Info) - + Load Local Deck... - + Load Remote Deck... - + Set Ready to Start - + Toggle Sideboard Lock - + Add Green Counter - + Remove Green Counter - + Set Green Counters... - + Add Red Counter - + Remove Red Counter - + Set Red Counters... - + Add Life Counter @@ -10742,724 +11548,744 @@ Please refrain from engaging in this activity or further actions may be taken ag - + + Load deck from online service... + + + + + Load from website... + + + + Unload Deck - + Force Start - + Add Card Counter (F) - + Remove Card Counter (F) - + Set Card Counters (F)... - + Add Card Counter (E) - + Remove Card Counter (E) - + Set Card Counters (E)... - + Add Card Counter(D) - + Remove Card Counter (D) - + Set Card Counters (D)... - + Add Card Counter (C) - + Remove Card Counter (C) - + Set Card Counters (C)... - + Add Card Counter (B) - + Remove Card Counter (B) - + Set Card Counters (B)... - + Add Card Counter (A) - + Remove Card Counter (A) - + Set Card Counters (A)... - + Remove Life Counter - + Set Life Counters... - + Add White Counter - + Remove White Counter - + Set White Counters... - + Add Blue Counter - + Remove Blue Counter - + Set Blue Counters... - + Add Black Counter - + Remove Black Counter - + Set Black Counters... - + Add Colorless Counter - + Remove Colorless Counter - + Set Colorless Counters... - + Add Other Counter - + Remove Other Counter - + Set Other Counters... - + Increment all card counters - + Add Power (+1/+0) - + Remove Power (-1/-0) - + Move Toughness to Power (+1/-1) - + Add Toughness (+0/+1) - + Remove Toughness (-0/-1) - + Move Power to Toughness (-1/+1) - + Add Power and Toughness (+1/+1) - + Remove Power and Toughness (-1/-1) - + Set Power and Toughness... - + Reset Power and Toughness - + Untap Odtapnutí - + Upkeep Upkeep - + Draw Lízání - + First Main Phase - + Start Combat - + Attack Útok - + Block Bránění - + Damage Zranění - + End Combat - + Second Main Phase - + End Konec - + Next Phase - + Next Phase Action - + Next Turn - + Hide Card in Reveal Window - + Tap / Untap Card - + Untap All - + Toggle Skip Untapping Toggle Untap - + Turn Card Over - + Peek Card - + Play Card - + Play Card, Face Down - + Attach Card... - + Unattach Card - + Clone Card - + Create Token... - + Create All Related Tokens - + Create Another Token - + Set Annotation... - + + Reduce Life by Power + + + + Select All Cards in Zone - + Select All Cards in Row - + Select All Cards in Column - + Reveal Selected Cards to All Players - - + + Bottom of Library - - - - + + + + Exile Exil - - - - + + + + Graveyard Hřbitov - - + + Hand Ruka - - + + Top of Library - - + + Battlefield, Face Down - + Battlefield - + Library Knihovna - + Sideboard Sideboard - + Top Cards of Library - + Bottom Cards of Library - + Close Recent View - - + + Stack - - + + Graveyard (Multiple) - - + + Graveyard (Multiple), Face Down - - + + Exile (Multiple) - - + + Exile (Multiple), Face Down - + Stack Until Found - + Draw Bottom Card - + Draw Multiple Cards from Bottom... - + Draw Arrow... - + Remove Local Arrows - + Leave Game - + Concede Vzdát hru - + Roll Dice... - + + Flip Coin + + + + Shuffle Library - + Shuffle Top Cards of Library - + Shuffle Bottom Cards of Library - + Mulligan Mulligan - + Mulligan (Same hand size) - + Mulligan (Hand size - 1) - + Draw a Card - + Draw Multiple Cards... - + Undo Draw - + Always Reveal Top Card - + Always Look At Top Card - + Sort Hand by Name - + Sort Hand by Type - + Sort Hand by Mana Value - + Reveal Hand to All Players - + Reveal Random Card to All Players - + Rotate View Clockwise - + Rotate View Counterclockwise - + Unfocus Text Box - + Focus Chat - + Clear Chat - + Refresh - + Skip Forward - + Skip Backward - + Skip Forward by a lot - + Skip Backward by a lot - + Play/Pause - + Toggle Fast Forward - + Home - + Visual Deck Storage - + Deck Storage - + Server - + Account - + Administration - + Logs diff --git a/cockatrice/translations/cockatrice_de.ts b/cockatrice/translations/cockatrice_de.ts index 564f39e25..51bee3cd4 100644 --- a/cockatrice/translations/cockatrice_de.ts +++ b/cockatrice/translations/cockatrice_de.ts @@ -2,7 +2,7 @@ AbstractCounter - + &Set counter... Zähler &setzen... @@ -10,12 +10,12 @@ AbstractCounterDialog - + Set counter Zähler setzen - + New value for counter '%1': Neuer Wert für den Zähler '%1': @@ -36,62 +36,62 @@ AbstractTabDeckEditor - + Open in new tab In neuem Reiter öffnen - + Are you sure? Sind Sie sicher? - + The decklist has been modified. Do you want to save the changes? Die Deckliste wurde bearbeitet. Änderungen speichern? - - - - - - + + + + + + Error Fehler - + Could not open deck at %1 Deck an Stelle %1 konnte nicht geöffnet werden - + Could not save remote deck Remote-Deck konnte nicht gespeichert werden - - + + The deck could not be saved. Please check that the directory is writable and try again. Das Deck konnte nicht abgespeichert werden. Bitte überprüfen Sie, ob das Verzeichnis bearbeitet werden kann und versuchen Sie es erneut. - + Save deck Deck speichern - + The deck could not be saved. Das Deck konnte nicht abgespeichert werden. - + There are no cards in your deck to be exported Es gibt keine Karten in Ihrem Deck zum Exportieren @@ -107,12 +107,12 @@ Bitte überprüfen Sie, ob das Verzeichnis bearbeitet werden kann und versuchen AdminNotesDialog - + Update Notes Aktualisierungsnotizen - + Admin Notes for %1 Administrator Notizen für %1 @@ -129,172 +129,227 @@ Bitte überprüfen Sie, ob das Verzeichnis bearbeitet werden kann und versuchen Sideboard Nebendeck + + + Tokens + + AppearanceSettingsPage - + seconds Sekunden - + Error Fehler - + Could not create themes directory at '%1'. Das Themeverzeichnis '%1' konnte nicht erstellt werden. - + Theme settings Darstellungseinstellungen - + Current theme: Aktuelles Theme: - + Open themes folder Öffne Themeverzeichnis - + Home tab background source: Startseitenhintergrund-Quelle: - + Home tab background shuffle frequency: Startseitenhintergrund Wechselfrequenz: - + Disabled Deaktiviert - - Display card name of background in bottom right: - Kartenname des Hintergrunds unten rechts anzeigen: - - - + Menu settings Einstellungsmenü - + Show keyboard shortcuts in right-click menus Tastatur-Kürzel anzeigen bei Rechtsklickmenü - + Show game filter toolbar above list in room tab Spiel-Filter Symbolleiste über der Liste im Raum-Tab anzeigen - + Card rendering Kartendarstellung - + Display card names on cards having a picture Kartennamen auch bei Karten mit Bildern darstellen - + Auto-Rotate cards with sideways layout Automatisches Drehen von Karten mit Seitwärts-Ausrichtung - + Override all card art with personal set preference (Pre-ProviderID change behavior) Überschreiben aller Kartenbilder mit persönlichen Set-Präferenzen (Vor-AnbieterID Wechselverhalten) - + + Light + + + + + Dark + + + + + System + + + + + Active theme palette: + + + + + Edit theme palette + + + + + Home tab settings + + + + + Display card name of background in bottom right + + + + + Styling settings + + + + + Style user list + + + + + Card printings + + + + Bump sets that the deck contains cards from to the top in the printing selector Priorisieren von Sets, in welchen Karten des ausgewählten Decks vorhanden sind, nach oben in der Druckauswahl - + Scale cards on mouse over Karten beim Darüberfahren mit der Maus vergrößern - + Use rounded card corners Abgerundete Kartenecken verwenden - + + Card layout + + + + Minimum overlap percentage of cards on the stack and in vertical hand Minimaler Überschneidungsanteil der Karten auf dem Stapel und in der vertikalen Hand - + Maximum initial height for card view window: Maximale Starthöhe für das Kartenansichtsfenster - - + + rows Reihen - + Maximum expanded height for card view window: Maximale ausgeklappte Höhe für das Kartenansichtsfenster: - + Card counters Kartenzähler - + Counter %1 Zähler %1 - + Hand layout Handdarstellung - + Display hand horizontally (wastes space) Hand horizontal anzeigen (verschwendet Platz) - + Enable left justification Linksbündige Ausrichtung aktivieren - + Table grid layout Spielfeldraster - + Invert vertical coordinate Vertikale Koordinate umkehren - + Minimum player count for multi-column layout: Mindestspieleranzahl für mehrspaltige Anordnung: - + Maximum font size for information displayed on cards: Maximale Schriftgröße für die Anzeige von Informationen auf Karten: @@ -302,12 +357,12 @@ Bitte überprüfen Sie, ob das Verzeichnis bearbeitet werden kann und versuchen ArchidektApiResponseDeckDisplayWidget - + Back to results Zurück zu den Ergebnissen - + Open Deck in Deck Editor Deck im Deck-Editor öffnen @@ -333,112 +388,112 @@ Bitte überprüfen Sie, ob das Verzeichnis bearbeitet werden kann und versuchen BanDialog - + ban &user name &Benutzername - + ban &IP address &IP-Adresse - + ban client I&D Client I&D bannen - + Ban type Art des Banns - + &permanent ban &permanenter Bann - + &temporary ban &temporärer Bann - + &Days: &Tage: - + &Hours: &Stunden: - + &Minutes: &Minuten: - + Duration of the ban Länge des Banns - + Please enter the reason for the ban. This is only saved for moderators and cannot be seen by the banned person. Bitte geben Sie den Grund für den Bann ein. Dies wird nur für Moderatoren gespeichert und kann von der gebannten Person nicht gesehen werden. - + Please enter the reason for the ban that will be visible to the banned person. Bitte geben Sie den Grund ein, den die gebannte Person sehen kann. - + Redact all messages from this user in all rooms Entferne alle Nachrichten dieses Nutzers in allen Räumen - + &OK &OK - + &Cancel &Abbrechen - + Ban user from server Benutzer vom Server bannen - - - - + + + + Error Fehler - + You have to select a name-based, IP-based, clientId based, or some combination of the three to place a ban. Bitte wählen Sie einen Namens-, Adress- oder Client-ID-Bann oder eine Kombination aus diesen. - + You must have a value in the name ban when selecting the name ban checkbox. Wenn Sie das Namensbannkästchen auswählen, muss ein Wert für den Namen eingegeben werden. - + You must have a value in the ip ban when selecting the ip ban checkbox. Wenn Sie das Adressbannkästchen auswählen, muss ein Wert für die IP eingegeben werden. - + You must have a value in the clientid ban when selecting the clientid ban checkbox. Wenn Sie das Client-ID-Kästchen auswählen, muss ein Wert für die Client-ID eingegeben werden. @@ -446,59 +501,123 @@ Dies wird nur für Moderatoren gespeichert und kann von der gebannten Person nic BetaReleaseChannel - + Beta Beta - + No reply received from the release update server. Keine Antwort vom Veröffentlichungsaktualisierungsserver erhalten. - + Invalid reply received from the release update server. Ungültige Antwort vom Veröffentlichungsaktualisierungsserver erhalten. - + No reply received from the file update server. Keine Antwort vom Dateiaktualisierungsserver erhalten. + + CardArtPreviewWidget + + + No card selected + + + + + CardArtRulesModel + + + Card + + + + + ProviderId + + + + + Mode + + + + + Reason + + + CardDatabaseModel - + Name Name - + Sets Editionen - + Mana cost Manakosten - + Card type Kartentyp - + P/T S/W - + Color(s) Farbe(n) + + CardDatabaseView + + + Add to Deck + + + + + Add to Sideboard + + + + + Select Printing + + + + + Show on EDHRec (Commander) + + + + + Show on EDHRec (Card) + + + + + Show Related cards + + + CardFilter @@ -627,22 +746,22 @@ Dies wird nur für Moderatoren gespeichert und kann von der gebannten Person nic CardInfoPictureWidget - + View related cards Zugehörige Karten ansehen - + Add card to deck Karte zum Deck hinzufügen - + Mainboard Mainboard - + Sideboard Sideboard @@ -665,12 +784,12 @@ Dies wird nur für Moderatoren gespeichert und kann von der gebannten Person nic Sammlernummer: - + Related cards: Zugehörige Karten: - + Unknown card: Unbekannte Karte: @@ -678,128 +797,146 @@ Dies wird nur für Moderatoren gespeichert und kann von der gebannten Person nic CardMenu - + Re&veal to... Zeigen an.... - + &All players &Alle Spieler - + View related cards Verwandte Karten ansehen - + Token: Spielstein: - + All tokens Alle Spielsteine - + &Select All &Alles auswählen - + S&elect Row Reihe auswählen - + S&elect Column Spalte auswählen - + &Play &Spielen - + &Hide &Verstecken - + Play &Face Down Verdeckt ausspielen - + &Tap / Untap Turn sideways or back again &Tappen/ Enttappen - + Skip &untapping Enttappen überspringen - + T&urn Over Turn face up/face down Umdrehen - + &Peek at card face %Kartenvorderseite ansehen - + &Clone &Kopieren - + Attac&h to card... An Karte anhängen - + Unattac&h Lösen - + &Draw arrow... &Pfeil zeichnen - + &Set annotation... &Anmerkung setzen - + + Reduce life by power + + + + Ca&rd counters Kartenzähler - + &Add counter (%1) &Zähler hinzufügen (%1) - + &Remove counter (%1) &Zähler entfernen (%1) - + &Set counters (%1)... &Zähler setzen (%1)... + + CardPictureLoaderCacheMethod + + + Network Cache + + + + + Filesystem + + + CardSizeWidget @@ -811,133 +948,133 @@ Dies wird nur für Moderatoren gespeichert und kann von der gebannten Person nic CardZoneLogic - + their hand nominative ihrer Hand - + %1's hand nominative %1's Hand - + their library look at zone ihrer Bibliothek - + %1's library look at zone %1's Bibliothek - + of their library top cards of zone, von ihrer Bibliothek - + of %1's library top cards of zone von %1's Bibliothek - + their library reveal zone ihrer Bibliothek - + %1's library reveal zone %1's Bibliothek - + their library shuffle ihrer Bibliothek - + %1's library shuffle %1's Bibliothek - + their library nominative ihrer Bibliothek - + %1's library nominative %1's Bibliothek - + their graveyard nominative ihres Friedhofs - + %1's graveyard nominative %1's Friedhof - + their exile nominative ihres Exils - + %1's exile nominative %1's Exil - + their sideboard look at zone ihres Nebendecks - + %1's sideboard look at zone %1's Nebendeck - + their sideboard nominative ihres Nebendecks - + %1's sideboard nominative %1's Nebendeck - + their custom zone '%1' nominative ihrer individuellen Zone '%1' - + %1's custom zone '%2' nominative %1's individuelle Zone '%2' @@ -959,16 +1096,421 @@ Dies wird nur für Moderatoren gespeichert und kann von der gebannten Person nic Parsingfehler in Zeile %1 Spalte %2: + + ColorButton + + + Click to pick a color + + + + + Pick colour + + + + + ConnectionController + + + + The server has reached its maximum user capacity, please check back later. + + + + + There are too many concurrent connections from your address. + + + + + Banned by moderator + + + + + Expected end time: %1 + + + + + This ban lasts indefinitely. + + + + + Scheduled server shutdown. + + + + + + Invalid username. + + + + + You have been logged out due to logging in at another location. + + + + + Connection closed + + + + + The server has terminated your connection. +Reason: %1 + + + + + The server is going to be restarted in %n minute(s). +All running games will be lost. +Reason for shutdown: %1 + + + + + Scheduled server shutdown + + + + + Failed Login + + + + + Your client seems to be missing features this server requires for connection. + + + + + To update your client, go to 'Help -> Check for Client Updates'. + + + + + + + + + + + + + + + + + + + + + + Error + + + + + Incorrect username or password. Please check your authentication information and try again. + + + + + There is already an active session using this user name. +Please close that session first and re-login. + + + + + + You are banned until %1. + + + + + + You are banned indefinitely. + + + + + This server requires user registration. Do you want to register now? + + + + + This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. +Please close and reopen your client to try again. + + + + + An internal error has occurred, please close and reopen Cockatrice before trying again. +If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. + + + + + Account activation + + + + + Your account has not been activated yet. +You need to provide the activation token received in the activation email. + + + + + Server Full + + + + + Unknown login error: %1 + + + + + + +This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. + + + + + + + + + + Registration denied + + + + + Registration is currently disabled on this server + + + + + There is already an existing account with the same user name. + + + + + It's mandatory to specify a valid email address when registering. + + + + + It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. + + + + + Password too short. + + + + + Registration failed for a technical problem on the server. + + + + + The connection to the server has been lost. + + + + + Unknown registration error: %1 + + + + + Account activation failed + + + + + Socket error: %1 + + + + + Server timeout + + + + + You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. +Local version is %1, remote version is %2. + + + + + Your Cockatrice client is obsolete. Please update your Cockatrice version. +Local version is %1, remote version is %2. + + + + + + Success + + + + + Registration accepted. +Will now login. + + + + + Account activation accepted. +Will now login. + + + + + Information + + + + + This server supports additional features that your client doesn't have. +This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. + +To update your client, go to Help -> Check for Updates. + + + + + + + Reset Password + + + + + Your password has been reset successfully, you can now log in using the new credentials. + + + + + Failed to reset user account password, please contact the server operator to reset your password. + + + + + Activation request received, please check your email for an activation token. + + + + + Connecting to %1... + + + + + Registering to %1 as %2... + + + + + Disconnected + + + + + Connected, logging in at %1 + + + + + Requesting forgotten password to %1 as %2... + + + + + Your username must respect these rules: + + + + + is %1 - %2 characters long + + + + + can %1 contain lowercase characters + + + + + + + + NOT + + + + + can %1 contain uppercase characters + + + + + can %1 contain numeric characters + + + + + can contain the following punctuation: %1 + + + + + first character can %1 be a punctuation mark + + + + + no unacceptable language as specified by these server rules: + note that the following lines will not be translated + + + + + can not contain any of the following words: %1 + + + + + can not match any of the following expressions: %1 + + + + + You may only use A-Z, a-z, 0-9, _, ., and - in your username. + + + CustomZoneMenu - + C&ustom Zones Individuelle Zonen - - + + View custom zone '%1' Individuelle Zone '%1' ansehen @@ -976,30 +1518,35 @@ Dies wird nur für Moderatoren gespeichert und kann von der gebannten Person nic DeckAnalyticsWidget - + Add Panel Feld hinzufügen - + Remove Panel Feld entfernen - + Save Layout Layout speichern - + Load Layout Layout laden + + + Include Sideboard + + DeckEditorCardDatabaseDockWidget - + Card Database Kartendatenbank @@ -1015,47 +1562,17 @@ Dies wird nur für Moderatoren gespeichert und kann von der gebannten Person nic DeckEditorDatabaseDisplayWidget - + Search by card name (or search expressions) Suche nach Kartennamen (oder Suchausdrücken) - - Add to Deck - Zum Deck hinzufügen - - - - Add to Sideboard - Zum Sideboard hinzufügen - - - - Select Printing - Auswahl der Auflage - - - - Show on EDHRec (Commander) - Auf EDHRec ansehen (Als Commander) - - - - Show on EDHRec (Card) - Auf EDHRec ansehen (Als Karte) - - - - Show Related cards - Zeige ähnliche Karten - - - + Add card to &maindeck Karte zum &Hauptdeck hinzufügen - + Add card to &sideboard Karte zum &Sideboard hinzufügen @@ -1088,72 +1605,72 @@ Dies wird nur für Moderatoren gespeichert und kann von der gebannten Person nic Farben - + Select Printing Auswahl der Auflage - + Deck Deck - + Deck &name: Deck&name: - + Banner Card/Tags Visibility Settings Bannerkarte/Sichtbarkeitseinstellungen von Tags - + Show banner card selection menu Bannerkartenauswahlmenü anzeigen - + Show tags selection menu Tagauswahlmenü anzeigen - + &Comments: &Kommentare: - + Group by: Gruppieren nach: - + Format: Format: - + Hash: Hash: - + &Increment number &Zahl erhöhen - + &Decrement number &Zahl verringern - + &Remove row &Reihe entfernen - + Swap card to/from sideboard Karte ins/aus dem Sideboard tauschen @@ -1161,17 +1678,17 @@ Dies wird nur für Moderatoren gespeichert und kann von der gebannten Person nic DeckEditorFilterDockWidget - + Filters Filter - + &Clear all filters &Alle Filter entfernen - + Delete selected Auswahl löschen @@ -1302,169 +1819,113 @@ Dies wird nur für Moderatoren gespeichert und kann von der gebannten Person nic DeckEditorSettingsPage - - + + Update Spoilers Spoiler aktualisieren - - + Success Erfolgreich - + Download URLs have been reset. Download-URLs wurden zurückgesetzt. - - Downloaded card pictures have been reset. - Heruntergeladene Kartenbilder wurden zurückgesetzt. - - - - Error - Fehler - - - - One or more downloaded card pictures could not be cleared. - Eines oder mehrere Kartenbilder konnten nicht gelöscht werden. - - - + Add URL URL hinzufügen - - + + URL: URL - - + + Edit URL URL bearbeiten - - Network Cache Size: - Größe des Netzwerkzwischenspeichers: - - - - Redirect Cache TTL: - Umleitungszwischenspeicher TTL: - - - - How long cached redirects for urls are valid for. - Wie lange gespeicherte Umleitungen für URLS gültig sind. - - - - Picture Cache Size: - Größe des Bilderzwischenspeichers: - - - + Add New URL Neue URL hinzufügen - + Remove URL URL entfernen - - Day(s) - Tag(e) - - - + Updating... Aktualisiere... - + Choose path Pfad auswählen - + URL Download Priority URL Downloadpriorität - + Spoilers Spoiler - + Download Spoilers Automatically Lade Spoiler automatisch herunter - + Spoiler Location: Spoiler Verzeichnis: - + Last Change Letzte Änderung - + Spoilers download automatically on launch Spoiler werden beim Start automatisch heruntergeladen - + Press the button to manually update without relaunching Drücke den Knopf um manuell zu aktualisieren ohne neu zu starten - + Do not close settings until manual update is complete Schließen Sie die Einstellungen nicht solange die manuelle Aktualisierung nicht abgeschlossen ist. - + Download card pictures on the fly Kartenbilder dynamisch herunterladen - + How to add a custom URL Wie eine benutzerdefinierte URL hinzugefügt wird - - Delete Downloaded Images - Heruntergeladene Bilder löschen - - - + Reset Download URLs Download-URLs zurücksetzen - - - On-disk cache for downloaded pictures - Festplatten-Cache für heruntergeladene Bilder - - - - In-memory cache for pictures not currently on screen - Arbeitsspeicher-Cache für Bilder die gerade nicht angezeigt werden - DeckListHistoryManagerWidget @@ -1530,12 +1991,12 @@ Dies wird nur für Moderatoren gespeichert und kann von der gebannten Person nic DeckLoader - + Common deck formats (%1) Standarddeckformate (%1) - + All files (*.*) Alle Dateien (*.*) @@ -1757,27 +2218,27 @@ Dies wird nur für Moderatoren gespeichert und kann von der gebannten Person nic (%1): %2 (%3) %4 hinzugefügt - + Moved to %1 1 × "%2" (%3) Nach %1 1 × "%2" (%3) bewegt - + Removed "%1" (all copies) "%1" entfernt (alle Kopien) - + %1 1 × "%2" (%3) %1 1 × "%2" (%3) - + Added Hinzugefügt - + Removed Entfernt @@ -1799,75 +2260,75 @@ Dies wird nur für Moderatoren gespeichert und kann von der gebannten Person nic DeckViewContainer - + Load deck... Deck laden... - + Load remote deck... Deck vom Server laden... - + Load from clipboard... Aus der Zwischenablage laden... - + Load from website... Von Internetseite laden.... - + Unload deck Deck entladen - + Ready to start Bereit zum Start - + Force start Zwangsstart - + Sideboard unlocked Sideboard entsperrt - + Sideboard locked Sideboard gesperrt - - + + Error Fehler - + The selected file could not be loaded. Die gewählte Datei konnte nicht geladen werden. - + Deck is greater than maximum file size. Deck übersteigt die maximale Dateigröße. - + Are you sure you want to force start? This will kick all non-ready players from the game. Sind Sie sicher, dass sie den Start erzwingen wollen? Dies wirft alle nicht bereiten Spieler aus dem Spiel. - + Cockatrice Cockatrice @@ -1968,7 +2429,7 @@ Möchten sie das Deck ins .cod Format konvertieren? - + Webpage Webseite @@ -2008,37 +2469,37 @@ Möchten sie das Deck ins .cod Format konvertieren? Verbinde zum Server - + Server URL Server URL - + Communication Port Kommunikationsport - + Unique Server Name Einzigartiger Servername - + Connection Warning Verbindungswarnung - + You need to name your new connection profile. Sie müssen Ihr neues Verbindungsprofil benennen. - + Connect Warning Verbindungswarnung - + The player name can't be empty. Der Spielername kann nicht leer sein. @@ -2151,17 +2612,17 @@ Möchten sie das Deck ins .cod Format konvertieren? Spiel erstellen - + Game information &Spielinformationen - + Error Fehler - + Server error. Serverfehler. @@ -2169,97 +2630,97 @@ Möchten sie das Deck ins .cod Format konvertieren? DlgCreateToken - + &Name: &Name: - + Token Spielstein - + C&olor: &Farbe: - + white weiß - + blue blau - + black schwarz - + red rot - + green grün - + multicolor mehrfarbig - + colorless farblos - + &P/T: &Kampfwerte: - + &Annotation: &Hinweis: - + &Destroy token when it leaves the table Spielstein &zerstören, wenn er das Spielfeld verlässt - + Create face-down (Only hides name) Verdeckt erstellen (versteckt nur den Namen) - + Token data Spielstein-Daten - + Show &all tokens &Alle möglichen Spielsteine zeigen - + Show tokens from this &deck Spielsteine dieses &Decks zeigen - + Choose token from list Spielstein aus Liste auswählen - + Create token Spielstein erstellen @@ -2293,7 +2754,7 @@ Möchten sie das Deck ins .cod Format konvertieren? - + @@ -2308,12 +2769,12 @@ Möchten sie das Deck ins .cod Format konvertieren? Tagname kann nicht leer sein! - + Duplicate Tag Tag duplizieren - + This tag already exists. Dieser Tag existiert bereits @@ -2500,12 +2961,12 @@ Um Ihren derzeitigen Avatar zu entfernen, bestätigen Sie, ohne ein neues Bild z Bitte geben Sie den Namen des Spielsteins ein: - + Error Fehler - + The chosen name conflicts with an existing card or token. Make sure to enable the 'Token' set in the "Manage sets" dialog to display them correctly. Es existiert bereits eine Karte oder ein Spielstein mit dem gewählten Namen. @@ -2530,12 +2991,12 @@ Aktivieren Sie die Edition „Token" im „Editionen verwalten...“ Menü Undefiniert - + Real name: Echter Name: - + Edit user profile Benutzerprofil bearbeiten @@ -2573,113 +3034,113 @@ Aktivieren Sie die Edition „Token" im „Editionen verwalten...“ Menü 2 Stunden - + Hide 'buddies only' games „Nur Freunde“ Spiele ausblenden - + Hide full games Volle Spiele ausblenden - + Hide games that have started Bereits begonnene Spiele ausblenden - + Hide password protected games Passwortgeschützte Spiele ausblenden - + Hide 'ignored user' games Spiele von „Ignorierten Benutzern“ ausblenden - + Hide games not created by buddies Hide games not created by buddy Nicht von Freunden erstellte Spiele verstecken - + Hide games with forced open decklists Spiele mit erzwungenen offenen Decklisten verstecken - + &Newer than: &Neuer als: - + Game &description: &Spielbeschreibung: - + &Creator name: &Name des Erstellers: - + General Allgemeines - + &Game types Spiel&typen - + at &least: &mindestens: - + at &most: &höchstens: - + Maximum player count Maximale Spielerzahl - + Restrictions Einschränkungen - + Show games only if &spectators can watch Zeige Spiele nur, wenn &Zuschauer erlaubt sind - + Show spectator password p&rotected games Zeige für Zuschauer passwo&rtgeschützte Spiele - + Show only if spectators can ch&at Zeige nur, wenn Zuschauer den &Chat nutzen können - + Show only if spectators can see &hands Zeige nur, wenn Zuschauer die &Hände sehen können - + Spectators Zuschauer - + Filter games Spiele filtern @@ -2990,37 +3451,37 @@ https://tappedout.net/mtg-decks/your-deck-name/ DlgMoveTopCardsUntil - + Card name (or search expressions): Kartenname (oder Suchbezeichnung) - + Number of hits: Anzahl der Treffer: - + Auto play hits Automatisches Ausspielen von Treffern - + Put top cards on stack until... Oberste Karten auf den Stapel legen bis... - + No cards matching the search expression exists in the card database. Proceed anyways? Keine Karten, die den Suchbezeichnungen entsprechen, existieren in der Kartendatenbank. Trotzdem fortfahren? - + Cockatrice Basilisk - + Invalid filter Ungültiger Filter @@ -3080,40 +3541,40 @@ Die E-Mail-Adresse wird zur Accountverifikation genutzt. Undefiniert - + Real name: Echter Name: - + Register to server Auf Server registrieren - - - - + + + + Registration Warning Registrierungswarnung - + Your password is too short. Das Passwort ist zu kurz. - + Your passwords do not match, please try again. Ihre Passwörter stimmen nicht überein, bitte versuchen Sie es erneut. - + Your email addresses do not match, please try again. Ihre Mailadressen stimmen nicht überein, bitte versuchen Sie es erneut. - + The player name can't be empty. Der Spielername kann nicht leer sein. @@ -3121,17 +3582,17 @@ Die E-Mail-Adresse wird zur Accountverifikation genutzt. DlgRollDice - + Number of sides: Anzahl der Seiten: - + Number of dice: Anzahl der Würfel: - + Roll Dice Würfeln @@ -3182,12 +3643,12 @@ Die E-Mail-Adresse wird zur Accountverifikation genutzt. DlgSettings - + Unknown Error loading card database Unbekannter Fehler beim Laden der Kartendatenbank - + Your card database is invalid. Cockatrice may not function correctly with an invalid database @@ -3204,7 +3665,7 @@ Sie müssen Oracle unter Umständen nochmals ausführen um Ihre Kartendatenbank Möchten Sie Ihren Speicherort der Datenbank aktualisieren?? - + Your card database version is too old. This can cause problems loading card information or images @@ -3221,7 +3682,7 @@ Normalerweise kann dies durch einen erneuten Start von Oracle, um die Kartendate Möchten Sie Ihren Speicherort der Datenbank aktualisieren? - + Your card database did not finish loading Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues with your cards.xml attached @@ -3234,7 +3695,7 @@ Bitte erstellen Sie ein Ticket auf https://github.com/Cockatrice/Cockatrice/issu Möchten Sie die Einstellung des Datenbankspeicherorts ändern? - + File Error loading your card database. Would you like to change your database location setting? @@ -3243,7 +3704,7 @@ Would you like to change your database location setting? Möchten Sie Ihren Speicherort der Datenbank aktualisieren? - + Your card database was loaded but contains no cards. Would you like to change your database location setting? @@ -3252,7 +3713,7 @@ Would you like to change your database location setting? Möchten Sie Ihren Speicherort der Datenbank aktualisieren? - + Unknown card database load status Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues @@ -3265,59 +3726,64 @@ Bitte erstellen Sie ein Ticket auf https://github.com/Cockatrice/Cockatrice/issu Möchten Sie die Einstellung des Datenbankspeicherorts ändern? - - - + + + Error Fehler - + The path to your deck directory is invalid. Would you like to go back and set the correct path? Der Pfad zu Ihrem Deckordner ist ungültig. Möchten Sie zurückgehen und den korrekten Pfad einstellen? - + The path to your card pictures directory is invalid. Would you like to go back and set the correct path? Der Pfad zu Ihrem Kartenbilderordner ist ungültig. Möchten Sie zurückgehen und den korrekten Pfad einstellen? - + Settings Einstellungen - + General Allgemeines - + Appearance Erscheinungsbild - + User Interface Benutzeroberfläche - + + Storage + + + + Card Sources Kartenquellen - + Chat Chat - + Sound Töne - + Shortcuts Tastaturkürzel @@ -3411,9 +3877,9 @@ Sie können das Verhalten jederzeit im "Allgemein"-Tab der Einstellung - - - + + + Error Fehler @@ -3466,31 +3932,31 @@ Bitte besuchen Sie die Downloadseite, um Cockatrice manuell zu aktualisieren. - + Update Available Aktualisierung verfügbar - + A new version of Cockatrice is available! Eine neue Cockatrice-Version ist verfügbar! - + New version Neue Version - + Released Veröffentlicht - + Changelog Änderungsprotokoll @@ -3500,51 +3966,51 @@ Bitte besuchen Sie die Downloadseite, um Cockatrice manuell zu aktualisieren.Möchten Sie jetzt aktualisieren? - + Unfortunately, the automatic updater failed to find a compatible download. You may have to manually download the new version. Leider hat der automatische Updater keinen kompatiblen Download gefunden. Eventuell müssen sie manuell eine neue Version herunterladen. - + Please check the <a href="%1">releases page</a> on our Github and download the build for your system. Bitte prüfen Sie die <a href='%1'>Veröffentlichungsseite</a> auf unserer Github-Seite und laden Sie die Version für Ihr Betriebssystem herunter. - - - + + + Update Error Aktualisierungsfehler - + An error occurred while checking for updates: Während der Suche nach Aktualisierungen ist ein Fehler aufgetreten: - + An error occurred while downloading an update: Während des Herunterladens der Aktualisierung ist ein Fehler aufgetreten: - + Installing... Installiere... - + Cockatrice is unable to open the installer. Cockatrice kann das Installationsprogramm nicht öffnen. - + Try to update manually by closing Cockatrice and running the installer. Versuchen Sie manuell zu aktualisieren, indem Sie Cockatrice beenden und das Installationsprogramm aufrufen. - + Download location Downloadverzeichnis @@ -3634,67 +4100,67 @@ Eventuell müssen sie manuell eine neue Version herunterladen. DrawProbabilityWidget - + Draw Probability Zieh-Wahrscheinlichkeit - + Probability of drawing Wahrscheinlichkeit des Ziehens - + Card Name Kartenname - + Type Typ - + Subtype Untertyp - + Mana Value Manawert - + At least mindestens - + Exactly genau - + card(s) having drawn at least Karte(n) gezogen haben mindestens - + cards Karten - + Category Kategorie - + Qty Menge - + Odds (%) Wahrscheinlichkeit (%) @@ -3808,7 +4274,7 @@ Eventuell müssen sie manuell eine neue Version herunterladen. FilterBuilder - + Type your filter here Gib hier deine Filter ein @@ -3839,22 +4305,22 @@ Eventuell müssen sie manuell eine neue Version herunterladen. GameEventHandler - + kicked by game host or moderator Von Spielhost oder Moderator gekickt - + player left the game Spieler hat das Spiel verlassen - + player disconnected from server Spieler hat die Verbindung zum Server getrennt - + reason unknown Grund unbekannt @@ -3862,140 +4328,140 @@ Eventuell müssen sie manuell eine neue Version herunterladen. GameSelector - - - - - - - - - + + + + + + + + + Error Fehler - + Please join the appropriate room first. Bitte betreten Sie erst den entsprechenden Raum. - + Wrong password. Falsches Passwort. - + Spectators are not allowed in this game. In diesem Spiel sind keine Zuschauer zugelassen. - + The game is already full. Das Spiel ist bereits voll. - + The game does not exist any more. Dieses Spiel gibt es nicht mehr. - + This game is only open to registered users. Dieses Spiel kann nur von registrierten Benutzern betreten werden. - + This game is only open to its creator's buddies. Dieses Spiel kann nur von Freunden des Erstellers betreten werden. - + You are being ignored by the creator of this game. Der Ersteller dieses Spiels ignoriert Sie. - + Join Game Spiel beitreten - + Spectate Game Spiel beobachten - + Game Information Spielinformationen - + Join Game as Judge Spiel als Richter beitreten - + Spectate Game as Judge Spiel als Richter zuschauen - + Join game Spiel beitreten - + Password: Passwort: - + Please join the respective room first. Bitte betreten Sie zuerst den entsprechenden Raum. - + &Filter games Spiele &filtern - + C&lear filter Filter &zurücksetzen - + C&reate E&rstellen - + &Join &Beitreten - + Join as judge Als Richter beitreten - + J&oin as spectator Als Zuschauer beitreten - + Join as judge spectator Als Richter-Zuschauer beitreten - + Games shown: %1 / %2 Angezeigte Spiele: %1 / %2 - + Games Spiele @@ -4003,32 +4469,32 @@ Eventuell müssen sie manuell eine neue Version herunterladen. GameSelectorQuickFilterToolBar - + All types Alle Typen - + Filter by game name... Nach Spielnamen filtern... - + Filter by game type/format Nach Spieltyp/Format filtern - + Hide games not created by buddies Nicht von Freunden erstellte Spiele ausblenden - + Hide full games Volle Spiele ausblenden - + Hide started games Gestartete Spiele ausblenden @@ -4036,105 +4502,105 @@ Eventuell müssen sie manuell eine neue Version herunterladen. GamesModel - + >1 day >1 Tag - + %1%2 hr short age in hours %1%2 h%1%2 h - + new neu - + %1%2 min short age in minutes %1%2 min%1%2 min - + password Passwort - + buddies only nur Freunde - + reg. users only nur reg. Benutzer - + open decklists Offene Decklisten - + can chat können chatten - + see hands sehen Hände - + can see hands können Hände sehen - + not allowed nicht erlaubt - + Room Raum - + Age Alter - + Description Beschreibung - + Creator Ersteller - + Type Typ - + Restrictions Bedingungen - + Players Spieler - + Spectators Zuschauer @@ -4142,143 +4608,158 @@ Eventuell müssen sie manuell eine neue Version herunterladen. GeneralSettingsPage - + Reset all paths Alle Pfade zurücksetzen - + All paths have been reset Alle Pfade wurden zurückgesetzt - - - - - - - + + + + + + + Choose path Pfad auswählen - - Personal settings - Persönliche Einstellungen + + Language settings + - + Language: Sprache: - + + Version settings + + + + + Card database + + + + + Startup settings + + + + Paths (editing disabled in portable mode) Pfade (Anpassungen im portablen Modus deaktiviert) - + Paths Pfade - + How to help with translations Wie man beim Übersetzen helfen kann - + Decks directory: Verzeichnis mit Decklisten: - + Filters directory: Filterverzeichnis: - + Replays directory: Verzeichnis mit aufgezeichneten Spielen: - + Pictures directory: Verzeichnis mit Bilddateien: - + Card database: Kartendatenbank: - + Custom database directory: Verzeichnis für benutzerdefinierte Datenbank: - + Token database: Spielsteindatenbank: - + Update channel Aktualisierungskanal - + Check for client updates on startup Nach Aktualisierungen des Klienten beim Start der Anwendung suchen - + Check for card database updates on startup Auf Kartendatenbankaktualisierungen beim Anwendungsstart prüfen - + Don't check Nicht überprüfen - + Prompt for update Zu Aktualisierungen auffordern - + Always update in the background Immer im Hintergrund aktualisieren - + Check for card database updates every Auf Kartendatenbankaktualisierung prüfen alle - + days Tage - + Notify if a feature supported by the server is missing in my client Benachrichtigung wenn ein vom Server unterstütze Funktion in meinem Client fehlt - + Automatically run Oracle when running a new version of Cockatrice Starte Oracle automatisch, wenn eine neue Version von Cockatrice gestartet wird - + Show tips on startup Zeige Tipps beim Start - + Last update check on %1 (%2 days ago) Letzte Aktualisierungsprüfung am %1 (vor %2 Tagen) @@ -4286,47 +4767,47 @@ Eventuell müssen sie manuell eine neue Version herunterladen. GraveyardMenu - + &Graveyard &Friedhof - + &View graveyard &Friedhof ansehen - + &Move graveyard to... & Friedhof nach ... bewegen - + &Top of library &Oben in der Bibliothek - + &Bottom of library &Unten in der Bibliothek - + &All players &Alle Spieler - + &Hand &Hand - + &Exile &Exil - + Reveal random card to... Zufällige Karte vorzeigen für ... @@ -4334,88 +4815,88 @@ Eventuell müssen sie manuell eine neue Version herunterladen. HandMenu - + &Hand &Hand - + &View hand &Hand ansehen - + Sort hand by... Hand nach ... sortieren - + Name Name - + Type Typ - + Mana Value Manawert - + Take &mulligan (Choose hand size) &Mulligan nehmen (Handgröße wählen) - + Take mulligan (Same hand size) Mulligan nehmen (Gleiche Handgröße) - + Take mulligan (Hand size - 1) Mulligan nehmen (Handgröße -1) - + &Move hand to... &Hand nach ... bewegen - + &Top of library &Oben in der Bibliothek - + &Bottom of library &Unten in der Bibliothek - + &Graveyard &Friedhof - + &Exile &Exil - + &Reveal hand to... &Hand an ... vorzeigen - - + + All players Alle Spieler - + Reveal r&andom card to... Zufällige Karten an ... vorzeigen @@ -4423,52 +4904,52 @@ Eventuell müssen sie manuell eine neue Version herunterladen. HomeWidget - + Create New Deck Neues Deck erstellen - + Browse Decks Decks durchsuchen - + Browse Card Database Kartendatenbank durchsuchen - + Browse EDHRec EDHRec durchsuchen - + Browse Archidekt Archidekt durchsuchen - + View Replays Wiederholung ansehen - + Quit Verlassen - + Connecting... Verbinden... - + Connect Verbinden - + Play Spielen @@ -4476,213 +4957,213 @@ Eventuell müssen sie manuell eine neue Version herunterladen. LibraryMenu - + &Library &Bibliothek - + &View library &Bibliothek ansehen - + View &top cards of library... Die obersten Karten der Bibliothek ansehen - + View bottom cards of library... Die untersten Karten der Bibliothek ansehen - + Reveal &library to... Bibliothek an ... vorzeigen - + Lend library to... Bibliothek ... leihen - + Reveal &top cards to... Die obersten Karten ... zeigen - + &Top of library... &Oben auf die Bibliothek - + &Bottom of library... &Unter die Bibliothek... - + &Always reveal top card &Immer die oberste Karte vorzeigen - + &Always look at top card &Immer die oberste Karte ansehen - + &Open deck in deck editor &Deck in Deckeditor öffnen - + &Draw card &Karte ziehen - + D&raw cards... Karten ziehen... - + &Undo last draw &Letztes Ziehen rückgängig machen - + Shuffle Mischen - + &Play top card &Oberste Karte spielen - + Play top card &face down Oberste Karte verdeckt ausspielen - + Put top card on &bottom Oberste Karte nach unten legen - + Move top card to grave&yard Oberste Karte in den Friedhof bewegen - + Move top card to e&xile Oberste Karte ins Exil bewegen - + Move top cards to &graveyard... Die obersten Karten in den Friedhof bewegen... - + Move top cards to graveyard face down... Die obersten Karten verdeckt in den Friedhof bewegen... - + Move top cards to &exile... Die obersten Karten ins Exil bewegen - + Move top cards to exile face down... Die obersten Karten verdeckt ins Exil bewegen... - + Put top cards on stack &until... Die obersten Karten auf den Stapel packen bis... - + Shuffle top cards... Oberste Karten mischeln... - + &Draw bottom card &Unterste Karte ziehen - + D&raw bottom cards... Untere Karten ziehen... - + &Play bottom card &Unterste Karte ausspielen - + Play bottom card &face down Unterste Karte verdeckt spielen - + Move bottom card to grave&yard Unterste Karte in den Friedhof bewegen - + Move bottom card to e&xile Unterste Karte ins Exil bewegen - + Move bottom cards to &graveyard... Unterste Karten in den Friedhof bewegen... - + Move bottom cards to graveyard face down... Die untersten Karten verdeckt in den Friedhof bewegen... - + Move bottom cards to &exile... Unterste Karten ins Exil bewegen... - + Move bottom cards to exile face down... Die untersten Karten verdeckt ins Exil bewegen... - + Put bottom card on &top Unterste Karte nach oben bewegen - + Shuffle bottom cards... Unterste Karten mischen... - - + + &All players &Alle Spieler - + Reveal top cards of library Oberste Karten der Bibliothek vorzeigen - + Number of cards: (max. %1) Anzahl der Karten: (max. %1) @@ -4690,639 +5171,273 @@ Eventuell müssen sie manuell eine neue Version herunterladen. MainWindow - - - The server has reached its maximum user capacity, please check back later. - Der Server hat seine maximale Benutzeranzahl erreicht. Bitte versuchen Sie es später noch einmal. - - - - There are too many concurrent connections from your address. - Es gibt zu viele gleichzeitige Verbindungen von Ihrer Adresse. - - - - Banned by moderator - Gebannt von einem Moderator - - - - Expected end time: %1 - Voraussichtliches Ende: %1 - - - - This ban lasts indefinitely. - Dieser Bann ist unbefristet. - - - - Scheduled server shutdown. - Planmäßige Serverabschaltung. - - - - - Invalid username. - Ungültiger Benutzername. - - - - You have been logged out due to logging in at another location. - Sie wurden abgemeldet, da Sie sich an einer anderen Stelle angemeldet haben. - - - - Connection closed - Verbindung geschlossen - - - - The server has terminated your connection. -Reason: %1 - Der Server hat Ihre Verbindung beendet. -Grund: %1 - - - - The server is going to be restarted in %n minute(s). -All running games will be lost. -Reason for shutdown: %1 - Der Server wird in %n Minute neu gestartet. -Alle laufenden Spiele werden beendet. -Grund für die Abschaltung: %1Der Server wird in %n Minuten neu gestartet. -Alle laufenden Spiele werden beendet. -Grund für die Abschaltung: %1 - - - - Scheduled server shutdown - Planmäßige Serverabschaltung - - - - - Success - Erfolgreich - - - - Registration accepted. -Will now login. - Registrierung angenommen. -Login läuft. - - - - Account activation accepted. -Will now login. - Accountaktivierung angenommen. -Login läuft. - - - - + + Player %1 Spieler %1 - + Load replay Aufgezeichnetes Spiel laden - + About Cockatrice Über Cockatrice - + Version Version - + Cockatrice Webpage Cockatrice Webseite - + Project Manager: Projektmanager: - + Past Project Managers: frühere Projektmanager: - + Developers: Entwickler: - + Our Developers unsere Entwickler - + Help Develop! Hilf bei der Entwicklung! - + Translators: Übersetzer: - + Our Translators Unsere Übersetzer - + Help Translate! Hilf bei der Übersetzung! - + Support: Unterstützung: - + Report an Issue Problem melden - + Troubleshooting Fehlersuche - + F.A.Q. F.A.Q. - - - - - - - - - - - - - - - - - - - - + + Error Fehler - - Server timeout - Server Zeitüberschreitung - - - - Failed Login - Anmeldung fehlgeschlagen - - - - Your client seems to be missing features this server requires for connection. - Ihrem Client scheinen Funktionen, die der Server für die Verbindung benötigt, zu fehlen. - - - - To update your client, go to 'Help -> Check for Client Updates'. - Um Ihren Client zu aktualisieren, gehen Sie auf 'Hilfe -> Auf Clientaktualisierungen prüfen'. - - - - Incorrect username or password. Please check your authentication information and try again. - Falscher Benutzername oder Passwort. Bitte überprüfe deine Login Informationen und versuche es erneut. - - - - There is already an active session using this user name. -Please close that session first and re-login. - Es gibt bereits eine aktive Verbindung mit diesem Benutzernamen. -Bitte schließen Sie diese Verbindung zuerst und versuchen Sie es dann erneut. - - - - - You are banned until %1. - Sie sind gebannt bis: %1. - - - - - You are banned indefinitely. - Sie sind auf unbestimmte Zeit gebannt. - - - - This server requires user registration. Do you want to register now? - Diese Server benötigt eine Benutzerregistrierung. Möchten Sie sich jetzt registrieren? - - - - This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. -Please close and reopen your client to try again. - Dieser Server verlangt Client-IDs. Entweder das Erzeugen der ID schlägt fehl oder Sie verwenden einen modifizierten Client. -Bitte starten Sie ihren Client neu, um es erneut zu versuchen. - - - - An internal error has occurred, please close and reopen Cockatrice before trying again. -If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. - Ein interner Fehler ist aufgetreten, bitte starten Sie Cockatrice neu, bevor Sie es erneut versuchen. -Falls der Fehler erneut auftritt, versichern Sie sich, dass Sie die neuste Softwareversion verwenden, und kontaktieren Sie falls nötig die Softwareentwickler. - - - - Account activation - Accountaktivierung - - - - Your account has not been activated yet. -You need to provide the activation token received in the activation email. - Ihr Account wurde noch nicht aktiviert. -Sie müssen den Aktivierungstoken aus der Aktivierungsemail verwenden - - - - Server Full - Server voll - - - - Unknown login error: %1 - Unbekannter Login-Fehler: %1 - - - - - -This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. - -Dies bedeutet normalerweise, dass Ihre Clientversion veraltet ist und der Server eine Antwort geschickt hat, die Ihr Client nicht versteht. - - - - Your username must respect these rules: - Ihr Benutzername muss diesen Regeln entsprechen: - - - - is %1 - %2 characters long - %1 - %2 Zeichen lang - - - - can %1 contain lowercase characters - kann %1 Kleinbuchstaben beinhalten - - - - - - - NOT - NICHT - - - - can %1 contain uppercase characters - kann %1 Großbuchstaben beinhalten - - - - can %1 contain numeric characters - kann %1 Zahlen beinhalten - - - - can contain the following punctuation: %1 - kann folgende Sonderzeichen beinhalten: %1 - - - - first character can %1 be a punctuation mark - Das erste Zeichen kann %1 ein Sonderzeichen sein - - - - no unacceptable language as specified by these server rules: - note that the following lines will not be translated - Keine inakzeptable Sprache wie durch folgende Serverregeln festgelegt: - - - - can not contain any of the following words: %1 - kann keines der folgenden Wörter enthalten: %1 - - - - can not match any of the following expressions: %1 - kann keinem der folgenden Ausdrücke entsprechen: %1 - - - - You may only use A-Z, a-z, 0-9, _, ., and - in your username. - Sie können nur A-Z, a-z, 0-9, _, ., und - in Ihrem Benutzernamen verwenden. - - - - - - - - - Registration denied - Registrierung verweigert - - - - Registration is currently disabled on this server - Momentan ist die Registrierung auf diesem Server deaktiviert - - - - There is already an existing account with the same user name. - Es existiert bereits ein Account mit dem gleichen Benutzernamen. - - - - It's mandatory to specify a valid email address when registering. - Es ist erforderlich, eine korrekte Emailadresse während der Registrierung anzugeben. - - - - It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. - Scheinbar versuchen Sie, ein neues Benutzerkonto auf diesem Server zu registrieren, obwohl Sie bereits ein Konto mit der verwendeten E-Mailadresse haben. Dieser Server beschränkt die Anzahl an Benutzerkonten, die ein Nutzer pro E-Mailadresse registrieren kann. Bitte kontaktieren Sie den Server-Betreiber für weitere Hilfe oder um Ihre Zugangsinformationen zu erhalten. - - - - Password too short. - Passwort zu kurz. - - - - Registration failed for a technical problem on the server. - Registrierung aufgrund eines technischen Problems des Servers fehlgeschlagen. - - - - The connection to the server has been lost. - Verbindung zum Server verloren. - - - - Unknown registration error: %1 - Unbekannter Registrierungsfehler: %1 - - - - Account activation failed - Accountaktivierung fehlgeschlagen - - - - Socket error: %1 - Netzwerkfehler: %1 - - - - You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. -Local version is %1, remote version is %2. - Sie versuchen sich an einem veralteten Server anzumelden. Bitte verwenden Sie eine ältere Cockatrice-Version oder melden Sie sich an einem aktuellen Server an. -Lokale Version ist %1, Serverversion ist %2. - - - - Your Cockatrice client is obsolete. Please update your Cockatrice version. -Local version is %1, remote version is %2. - Ihr Cockatrice-Client ist veraltet. Bitte laden Sie sich die neueste Version herunter. -Lokale Version ist %1, Serverversion ist %2. - - - - Connecting to %1... - Verbinde zu %1... - - - - Registering to %1 as %2... - Registrierung bei %1 als %2... - - - - Disconnected - nicht verbunden - - - - Connected, logging in at %1 - Verbunden, Anmeldung bei %1 - - - - - - Requesting forgotten password to %1 as %2... - Fordere vergessenes Password von %1 als %2 an... - - - + &Connect... &Verbinden... - + &Disconnect Verbindung &trennen - + Start &local game... &Lokales Spiel starten... - + &Watch replay... &Aufgezeichnetes Spiel abspielen... - + &Full screen &Vollbild - + &Register to server... Auf Server &registrieren... - + &Restore password... Passwort wiede&rherstellen... - + &Settings... &Einstellungen... - + &Exit &Beenden - + A&ctions Aktionen - + &Cockatrice &Cockatrice - + C&ard Database K&artendatenbank - + &Manage sets... Editionen &verwalten... - + Edit custom &tokens... Bearbeite benutzerdefinierte Spiels&teine... - + Open custom image folder Öffne den Ordner für benutzerdefinierte Kartenbilder - + Open custom sets folder Öffne den Ordner für benutzerdefinierte Sets - + Add custom sets/cards Benutzerdefinierte Karten/Sets hinzufügen - + Reload card database Neu laden der Kartendatenbank - + Tabs Reiter - + &Help &Hilfe - + &About Cockatrice &Über Cockatrice - + &Tip of the Day &Tipp des Tages - + Check for Client Updates Auf Clientaktualisierungen prüfen - + Check for Card Updates... Nach Kartenaktualisierungen suchen... - + Check for Card Updates (Automatic) Prüfe auf Kartenaktualisierungen (automatisch) - + Show Status Bar Statuszeile anzeigen - + View &Debug Log Fehlerprotokoll ansehen - + Open Settings Folder Einstellungsordner öffnen - + Show/Hide Ein-/Ausblenden - + New Version Neue Version - + Congratulations on updating to Cockatrice %1! Oracle will now launch to update your card database. Herzlichen Glückwunsch zur Aktualisierung auf Cockatrice %1! Oracle wird gestartet um ihre Kartendatenbank zu aktualisieren. - + Cockatrice installed Cockatrice wurde installiert - + Congratulations on installing Cockatrice %1! Oracle will now launch to install the initial card database. Herzlichen Glückwunsch, dass Sie Cockatrice %1 installiert haben! Oracle wird jetzt gestartet und installiert die initiale Kartendatenbank. - + Card database Kartendatenbank - + Cockatrice is unable to load the card database. Do you want to update your card database now? If unsure or first time user, choose "Yes" @@ -5331,29 +5446,29 @@ Möchten Sie Ihre Kartendatenbank jetzt aktualisieren? Falls Sie unsicher sind oder Cockatrice das erste Mal nutzen, wählen Sie „Ja“ - - + + Yes Ja - - + + No Nein - + Open settings Einstellungen öffnen - + New sets found Neue Editionen gefunden - + %n new set(s) found in the card database Set code(s): %1 Do you want to enable it/them? @@ -5364,17 +5479,22 @@ Setcode(s): %1 Möchten Sie es/sie aktivieren? - + + Yes, always enable + + + + View sets Editionen ansehen - + Welcome Willkommen - + Hi! It seems like you're running this version of Cockatrice for the first time. All the sets in the card database have been enabled. Read more about changing the set order or disabling specific sets and consequent effects in the "Manage Sets" dialog. @@ -5383,167 +5503,133 @@ Alle Editionen der Kartendatenbank wurden aktiviert. Lesen Sie mehr über das Ändern der Editionsreihenfolge oder die Deaktivierung bestimmter Editionen im „Editionen verwalten...“ Fenster. - - + Information Information - + A card database update is already running. Eine Datenbankaktualisierung wird bereits durchgeführt. - + Unable to run the card database updater: Kartendatenbankaktualisierung nicht ausführbar: - + Card database update running. Kartendatenbankaktualisierung läuft. - + Failed to start. The file might be missing, or permissions might be incorrect. Starten fehlgeschlagen. Die Datei könnte fehlen oder die Berechtigung fehlen. - + The process crashed some time after starting successfully. Der Prozess ist einige Zeit nach erfolgreichem Start abgestürzt. - + Timed out. The process took too long to respond. The last waitFor...() function timed out. Zeitüberschreitung. Der Prozess hat zu lange nicht geantwortet. Zeitüberschreitung bei letzter waitFor...() Funktion. - + An error occurred when attempting to write to the process. For example, the process may not be running, or it may have closed its input channel. Ein Fehler ist beim Schreiben an den Prozess aufgetreten. Beispielsweise könnte der Prozess derzeit nicht ausgeführt werden oder er hat seinen Eingabekanal geschlossen. - + An error occurred when attempting to read from the process. For example, the process may not be running. Ein Fehler ist beim Lesen vom Prozess aufgetreten. Beispielsweise könnte der Prozess derzeit nicht ausgeführt werden. - + Unknown error occurred. Unbekannter Fehler aufgetreten. - + The card database updater exited with an error: %1 Die Kartendatenbankaktualisierung brach mit einem Fehler ab: %1 - - This server supports additional features that your client doesn't have. -This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. - -To update your client, go to Help -> Check for Updates. - Diese Server unterstützt zusätzliche Funktionen, über die Ihr Client nicht verfügt. -Dies stellt wahrscheinlich kein Problem dar, allerdings könnte dies bedeuten, dass eine neue Version von Cockatrice verfügbar ist oder dieser Server eine angepasste oder unveröffentlichte Version verwendet. - -Um Ihren Client zu aktualisieren, navigieren Sie zu Hilfe -> Auf Aktualisierungen prüfen. - - - - - - - + + + + + Load sets/cards Lade Sets/Karten - + Selected file cannot be found. Die ausgewählte Datei wurde nicht gefunden. - + You can only import XML databases at this time. Im Moment ist es nur möglich XML Datenbanken zu importieren. - + The new sets/cards have been added successfully. Cockatrice will now reload the card database. Die neuen Sets/Karten wurden erfolgreich hinzugefügt. Cockatrice wird jetzt die Kartendatenbank neu laden. - + Sets/cards failed to import. Set-/Kartenimport fehlgeschlagen. - - - - - Reset Password - Passwort zurücksetzen - - - - Your password has been reset successfully, you can now log in using the new credentials. - Ihr Passwort wurde erfolgreich zurückgesetzt, Sie können sich jetzt mit den neuen Anmeldedaten anmelden. - - - - Failed to reset user account password, please contact the server operator to reset your password. - Passwortzurücksetzung fehlgeschlagen. Bitte kontaktieren Sie den Serverbetreiber, um Ihr Passwort zurücksetzen zu lassen. - - - - Activation request received, please check your email for an activation token. - Aktivierungsanfrage erhalten. Bitte prüfen Sie Ihre E-Mails nach einem Aktivierungstoken. - ManaBaseConfigDialog - + Mana Base Configuration Mana-Basis Konfiguration - + Display type: Anzeigetyp: - + pie Torte - + bar Leiste - + combinedBar kombinierteLeiste - + Filter Colors (optional): Farben filtern (optional): - + OK OK - + Cancel Abbrechen @@ -5615,27 +5701,27 @@ Cockatrice wird jetzt die Kartendatenbank neu laden. ManaDevotionConfigDialog - + Display type: Anzeigetyp: - + pie Torte - + bar Leiste - + combinedBar kombinierteLeiste - + Filter Colors (optional): Farben filtern (optional): @@ -5705,610 +5791,615 @@ Cockatrice wird jetzt die Kartendatenbank neu laden. MessageLogWidget - + from play vom Spielfeld - + from their graveyard von ihrem Friedhof - + from exile aus dem Exil - + from their hand von ihrer Hand - + the top card of %1's library die oberste Karte von %1s Bibliothek - + the top card of their library die oberste Karte ihrer Bibliothek - + from the top of %1's library oben von %1s Bibliothek - + from the top of their library von ihrer Bibliothek von oben - + the bottom card of %1's library die unterste Karte von %1s Bibliothek - + the bottom card of their library die unterste Karte ihrer Bibliothek - + from the bottom of %1's library von der Unterseite von %1s Bibliothek - + from the bottom of their library von ihrer Bibliothek von unten - + from %1's library aus %1s Bibliothek - + from their library von ihrer Bibliothek - + from sideboard aus dem Sideboard - + from the stack vom Stapel - + from custom zone '%1' von der benutzerdefinierten Zone '%1' - + %1 is now keeping the top card %2 revealed. %1 lässt nun die oberste Karte %2 aufgedeckt. - + %1 is not revealing the top card %2 any longer. %1 lässt die oberste Karte %2 nicht mehr aufgedeckt. - + %1 can now look at top card %2 at any time. %1 kann nun die oberste Karte %2 jederzeit betrachten. - + %1 no longer can look at top card %2 at any time. %1 kann die oberste Karte %2 nicht mehr jederzeit betrachten. - + %1 attaches %2 to %3's %4. %1 legt %2 an %3s %4 an. - + %1 has conceded the game. %1 hat das Spiel aufgegeben. - + %1 has unconceded the game. %1 hat das Spiel doch nicht aufgegeben. - + %1 has restored connection to the game. %1 ist wieder mit dem Spiel verbunden. - + %1 has lost connection to the game. %1 hat die Verbindung zum Spiel verloren. - + %1 points from their %2 to themselves. %1 zeigt von ihrem %2 auf sich selbst. - + %1 points from their %2 to %3. %1 zeigt von ihrem %2 auf %3. - + %1 points from %2's %3 to themselves. %1 zeigt von %2s %3 auf sich selbst. - + %1 points from %2's %3 to %4. %1 zeigt von %2s %3 auf %4. - + %1 points from their %2 to their %3. %1 zeigt von ihrem %2 auf ihren %3. - + %1 points from their %2 to %3's %4. %1 zeigt von ihrem %2 auf %3s %4. - + %1 points from %2's %3 to their own %4. %1 zeigt von %2s %3 auf ihr %4. - + %1 points from %2's %3 to %4's %5. %1 zeigt von %2s %3 auf %4s %5. - + %1 creates a face down token. %1 erzeugt einen umgedrehten Spielstein. - + %1 creates token: %2%3. %1 erstellt Spielstein: %2%3. - + %1 has loaded a deck (%2). %1 hat ein Deck geladen (%2). - + %1 has loaded a deck with %2 sideboard cards (%3). %1 hat ein Deck mit %2 Sideboardkarten geladen (%3). - + %1 destroys %2. %1 zerstört %2. - + a card eine Karte - + %1 gives %2 control over %3. %1 überlässt %2 die Kontrolle über %3. - + %1 puts %2 into play%3 face down. %1 bringt %2%3 verdeckt ins Spiel. - + %1 puts %2 into play%3. %1 bringt %2%3 ins Spiel. - + %1 puts %2%3 into their graveyard face down. %1 legt %2%3 verdeckt in ihren Friedhof - + %1 puts %2%3 into their graveyard. %1 legt %2%3 in ihren Friedhof. - + %1 exiles %2%3 face down. %1 schickt %2%3 verdeckt ins Exil. - + %1 exiles %2%3. %1 schickt %2%3 ins Exil. - + %1 moves %2%3 to their hand. %1 bewegt %2%3 in ihre Hand. - + %1 puts %2%3 into their library. %1 legt %2%3 in ihre Bibliothek. - + %1 puts %2%3 onto the bottom of their library. %1 legt %2%3 unter die Bibliothek. - + %1 puts %2%3 on top of their library. %1 legt %2%3 auf ihre Bibliothek. - + %1 puts %2%3 into their library %4 cards from the top. %1 legt %2%3 in die Bibliothek an %4 Stelle von oben. - + %1 moves %2%3 to sideboard. %1 legt %2%3 in sein Sideboard. - + %1 plays %2%3 face down. %1 bringt %2%3 verdeckt ins Spiel. - + %1 plays %2%3. %1 spielt %2%3 aus. - + %1 moves %2%3 to custom zone '%4' face down. %1 bewegt %2%3 verdeckt zur benutzerdefinierten Zone '%4'. - + %1 moves %2%3 to custom zone '%4'. %1 bewegt %2%3 zur benutzerdefinierten Zone '%4'. - + %1 tries to draw from an empty library %1 versucht von einer leeren Bibliothek zu ziehen - + %1 draws %2 card(s). %1 zieht %2 Karte(n).%1 zieht %2 Karte(n). - + %1 is looking at %2. %1 sieht sich %2 an. - + %1 is looking at the %4 %3 card(s) %2. top card for singular, top %3 cards for plural %1 sieht sich die %4 %3 Karte(n) %2 an.%1 sieht sich die %4 %3 Karte(n) %2 an. - + bottom untersten - + top obersten - + %1 turns %2 face-down. %1 wendet %2 auf die Rückseite. - + %1 turns %2 face-up. %1 wendet %2 auf die Vorderseite. - + The game has been closed. Das Spiel wurde geschlossen. - + The game has started. Das Spiel hat begonnen. - + You are flooding the game. Please wait a couple of seconds. Du überschwemmst das Spiel. Warte bitte einige Sekunden. - + %1 has joined the game. %1 ist dem Spiel beigetreten. - + %1 is now watching the game. %1 schaut nun dem Spiel zu. - + You have been kicked out of the game. Sie wurden aus dem Spiel geworfen. - + %1 has left the game (%2). %1 hat das Spiel verlassen (%2). - + %1 is not watching the game any more (%2). %1 schaut dem Spiel nicht mehr zu (%2). - + %1 is not ready to start the game any more. %1 ist nicht mehr bereit, das Spiel zu starten. - + %1 shuffles their deck and draws a new hand of %2 card(s). %1 mischt sein Deck und zieht eine Hand mit %2 Karte(n).%1 mischt sein Deck und zieht eine neue Hand mit %2 Karte(n). - + %1 shuffles their deck and draws a new hand. %1 mischt sein Deck und zieht eine neue Hand. - + You are watching a replay of game #%1. Sie sehen eine Aufzeichnung des Spiels #%1. - + %1 is ready to start the game. %1 ist bereit, das Spiel zu starten. - + cards an unknown amount of cards Karten - + %1 card(s) a card for singular, %1 cards for plural %1 Karte(n)%1 Karte(n) - + %1 lends %2 to %3. %1 verleiht %2 an %3. - + %1 reveals %2 to %3. %1 zeigt %3 %2. - + %1 reveals %2. %1 zeigt %2 offen vor. - + %1 randomly reveals %2%3 to %4. %1 zeigt %4 zufällig %2%3 vor. - + %1 randomly reveals %2%3. %1 zeigt zufällig %2%3 offen vor. - + %1 peeks at face down card #%2. %1 schaut sich die umgedrehte Karte #%2 an. - + %1 peeks at face down card #%2: %3. %1 schaut sich die umgedrehte Karte #%2 an: %3. - + %1 reveals %2%3 to %4. %1 zeigt %4 %2%3 vor. - + %1 reveals %2%3. %1 zeigt %2%3 offen vor. - + %1 reversed turn order, now it's %2. %1 kehrte die Zugreihenfolge um, nun ist es %2. - + reversed umgekehrt - + normal normal - + Heads Kopf - + Tails Zahl - + %1 flipped a coin. It landed as %2. %1 warf eine Münze. Es fiel %2. - + %1 rolls a %2 with a %3-sided die. %1 würfelt eine %2 mit einem %3-seitigen Würfel. - + %1 flips %2 coins. There are %3 heads and %4 tails. %1 wirft %2 Münzen. Es ergab %3 mal Kopf und %4 mal Zahl. - + %1 rolls a %2-sided dice %3 times: %4. %1 wirft einen %2-seitigen Würfel %3 mal: %4. - + %1's turn. %1 ist am Zug. - + %1 sets annotation of %2 to %3. %1 versieht %2 mit dem Hinweis %3. - - %1 places %2 "%3" counter(s) on %4 (now %5). - %1 platziert %2 "%3" Zähler auf %4 (jetzt %5).%1 platziert %2 "%3" Zähler auf %4 (jetzt %5). + + %1 places %2 %3%4 counter(s) on %5 (now %6). + - - %1 removes %2 "%3" counter(s) from %4 (now %5). - %1 entfernt %2 "%3" Marke(n) von %4 (jetzt %5).%1 entfernt %2 "%3" Zähler von %4 (jetzt %5). + + %1 removes %2 %3%4 counter(s) from %5 (now %6). + - + + %1 failed to undo their last draw. + + + + %1 sets counter %2 to %3 (%4%5). %1 setzt Zähler %2 auf %3 (%4%5). - + %1 sets %2 to not untap normally. %1 setzt %2 auf explizites Enttappen. - + %1 sets %2 to untap normally. %1 setzt %2 auf normales Enttappen. - + %1 removes the PT of %2. %1 entfernt die Kampfwerte von %2. - + %1 changes the PT of %2 from nothing to %4. %1 ändert die Kampfwerte von %2 von nichts auf %4. - + %1 changes the PT of %2 from %3 to %4. %1 ändert die Kampfwerte von %2 von %3 auf %4. - + %1 has locked their sideboard. %1 hat ihr Sideboard gesperrt. - + %1 has unlocked their sideboard. %1 hat ihr Sideboard entsperrt. - + %1 taps their permanents. %1 tappt ihre bleibenden Karten. - + %1 untaps their permanents. %1 enttappt ihre bleibenden Karten. - + %1 taps %2. %1 tappt %2. - + %1 untaps %2. %1 enttappt %2. - + %1 shuffles %2. %1 mischt %2. - + %1 shuffles the bottom %3 cards of %2. %1 mischt die untersten %3 Karten von %2. - + %1 shuffles the top %3 cards of %2. %1 mischt die obersten %3 Karten von %2. - + %1 shuffles cards %3 - %4 of %2. %1 mischt die Karten %3 - %4 von %2. - + %1 unattaches %2. %1 löst %2 ab. - + %1 undoes their last draw. %1 legt die zuletzt gezogene Karte zurück. - + %1 undoes their last draw (%2). %1 legt die zuletzt gezogene Karte zurück (%2) @@ -6316,110 +6407,115 @@ Cockatrice wird jetzt die Kartendatenbank neu laden. MessagesSettingsPage - + Word1 Word2 Word3 Wort1 Wort2 Wort3 - + Add New Message Neue Nachricht hinzufügen - + Edit Message Nachricht bearbeiten - + Remove Message Nachricht entfernen - + Add message Nachricht hinzufügen - - + + Message: Nachricht: - + Edit message Nachricht bearbeiten - + Chat settings Chat Einstellungen - + Custom alert words Benutzerdefinierte Benachrichtigungswörter - + Enable chat mentions Chat Erwähnungen aktivieren - + Enable mention completer Autovervollständigung aktivieren - + In-game message macros Makros für Nachrichten in Spielen - + How to use in-game message macros Anleitung zum Verwenden der Makros für Nachrichten in Spielen - + Ignore chat room messages sent by unregistered users Nachrichten von unregistrierten Benutzern im Chatroom ignorieren - + Ignore private messages sent by unregistered users Private Nachrichten von unregistrierten Benutzern ignorieren - - + + Ignore private messages sent by non-buddy users + + + + + Invert text color Textfarbe invertieren - + Enable desktop notifications for private messages Desktop Benachrichtigungen für private Nachrichten aktivieren - + Enable desktop notification for mentions Desktop Benachrichtigungen für Erwähnungen aktivieren - + Enable room message history on join Nachrichtenverlauf beim Betreten eines Raumes aktivieren - - + + (Color is hexadecimal) (Farbcode in hexadezimal) - + Separate words with a space, alphanumeric characters only Wörter durch Leerzeichen trennen, nur alphanumerische Zeichen @@ -6427,42 +6523,42 @@ Cockatrice wird jetzt die Kartendatenbank neu laden. MoveMenu - + Move to Bewegen nach - + &Top of library in random order &Oben auf der Bibliothek in zufälliger Reihenfolge - + X cards from the top of library... X Karten von oben in der Bibliothek... - + &Bottom of library in random order &Unter die Bibliothek in zufälliger Reihenfolge - + T&able T&isch - + &Hand &Hand - + &Graveyard &Friedhof - + &Exile &Exil @@ -6475,47 +6571,47 @@ Cockatrice wird jetzt die Kartendatenbank neu laden. Kartentyp - + Mana Value Manawert - + Color(s) Farbe(n) - + Loyalty Loyalität - + Main Card Type Hauptkartentyp - + Mana Cost Manakosten - + P/T S/W - + Side Seite - + Layout Layout - + Color Identity Farbidentität @@ -6538,6 +6634,152 @@ Cockatrice wird jetzt die Kartendatenbank neu laden. Richtung der Überschreitung: + + PaletteEditorDialog + + + + Reset + + + + + + Apply + + + + + + Save && Apply + + + + + ▼ Edit Palette + + + + + + ▶ Edit Palette + + + + + Palette Editor — %1 + + + + + <b>Palette Editor</b> &nbsp;·&nbsp; %1 + + + + + This theme ships no default palette files + + + + + Replace current colours with the theme author's defaults + + + + + Switch between the light and dark palette files + + + + + Editing: + + + + + Show or hide the per-role colour grid for manual tweaks + + + + + ↺ Revert to theme default + + + + + Discard unsaved edits and restore the last saved palette + + + + + Preview this palette without saving to disk + + + + + Write palette-%1.toml and reload the theme + + + + + Cannot save: this theme has no directory on disk + + + + + Save failed + + + + + Could not write %1 to: +%2 + + + + + No default found + + + + + No default palette file found for the "%1" scheme. + + + + + PaletteGridWidget + + + Active + + + + + Disabled + + + + + Inactive + + + + + Normal interactive state + + + + + Widget is disabled / not interactive + + + + + Window is in background / unfocused + + + Phase @@ -6604,57 +6846,57 @@ Cockatrice wird jetzt die Kartendatenbank neu laden. PhasesToolbar - + Untap step Enttappsegment - + Upkeep step Versorgungssegment - + Draw step Ziehsegment - + First main phase erste Hauptphase - + Beginning of combat step Anfangssegment der Kampfphase - + Declare attackers step Angreifer-Deklarieren-Segment - + Declare blockers step Blocker-Deklarieren-Segment - + Combat damage step Kampfschadenssegment - + End of combat step Endsegment der Kampfphase - + Second main phase zweite Hauptphase - + End of turn step Ende-des-Zuges-Segment @@ -6662,7 +6904,7 @@ Cockatrice wird jetzt die Kartendatenbank neu laden. PictureLoader - + en code for scryfall's language property, not available for all languages de @@ -6671,151 +6913,142 @@ Cockatrice wird jetzt die Kartendatenbank neu laden. PlayerActions - - View top cards of library - Oberste Karten der Bibliothek ansehen - - - - - - - - - - - - Number of cards: (max. %1) - Anzahl der Karten: (max. %1) - - - - View bottom cards of library - Unterste Karten der Bibliothek ansehen - - - - Shuffle top cards of library - Oberste Karten der Bibliothek mischen - - - - Shuffle bottom cards of library - Unterste Karten der Bibliothek mischen - - - - Draw hand - Hand ziehen - - - - 0 and lower are in comparison to current hand size - 0 und kleiner sind im Vergleich zur jetzigen Handgröße - - - - Draw cards - Karten ziehen - - - - - - + + + + grave Friedhof - - - - + + + + exile Exil + + + PlayerDialogs - + + View top cards of library + + + + + + + + + + + + + Number of cards: (max. %1) + + + + + View bottom cards of library + + + + + Shuffle top cards of library + + + + + Shuffle bottom cards of library + + + + + Draw hand + + + + + 0 and lower are in comparison to current hand size + + + + + Draw cards + + + + Move top cards to %1 - Oberste Karten nach %1 bewegen + - + Move bottom cards to %1 - Unterste Karten nach %1 bewegen + - + Draw bottom cards - Unterste Karten ziehen + - - - C&reate another %1 token - Einen weiteren %1 Spielstein erzeugen - - - + Create tokens - Spielsteine erzeugen + - - + Number: - Anzahl: + - + Place card X cards from top of library - Karte X Karten von oben in der Bibliothek plazieren + - + Which position should this card be placed: - In welcher Position sollte diese Karte platziert werden: + - + (max. %1) - (max. %1) + - + Change power/toughness - Stärke/Widerstandskraft ändern + - + Change stats to: - Ändere Werte auf: + - + Set annotation - Anmerkung setzen + - + Please enter the new annotation: - Bitte die neue Anmerkung eingeben: - - - - Set counters - Zähler setzen + PlayerMenu - + Player "%1" Spieler "%1" - + &Counters &Zähler @@ -6848,7 +7081,7 @@ Diese Einstellung bedeutet, dass sie nur den Standarddruck für jede Karte sehen Navigationstasten anzeigen - + Printing Selector Druckauswahl @@ -6856,22 +7089,22 @@ Diese Einstellung bedeutet, dass sie nur den Standarddruck für jede Karte sehen PrintingSelectorCardOverlayWidget - + Preference Preferenz - + Pin Printing Version festsetzen - + Unpin Printing Version lösen - + Show Related cards Zugehörige Karten anzeigen @@ -6942,57 +7175,57 @@ Diese Einstellung bedeutet, dass sie nur den Standarddruck für jede Karte sehen PtMenu - + Power / toughness Stärke/Widerstandskraft - + &Increase power &Stärke erhöhen - + &Decrease power &Stärke verringern - + I&ncrease toughness &Widerstandskraft erhöhen - + D&ecrease toughness &Widerstandskraft verringern - + In&crease power and toughness Stärke und Widerstandskraft erhöhen - + Dec&rease power and toughness Stärke und Widerstandskraft verringern - + Increase power and decrease toughness Erhöhe Stärke und verringere Widerstandskraft - + Decrease power and increase toughness Verringere Stärke und erhöhe Widerstandskraft - + Set &power and toughness... Stärke und Widerstandskraft setzen... - + Reset p&ower and toughness Stärke und Widerstandskraft zurücksetzen @@ -7000,37 +7233,37 @@ Diese Einstellung bedeutet, dass sie nur den Standarddruck für jede Karte sehen QMenuBar - + Services Dienste - + Hide %1 %1 ausblenden - + Hide Others Andere ausblenden - + Show All Alle anzeigen - + Preferences... Einstellungen... - + Quit %1 Schließe %1 - + About %1 Über %1 @@ -7038,17 +7271,17 @@ Diese Einstellung bedeutet, dass sie nur den Standarddruck für jede Karte sehen QObject - + Cockatrice card database (*.xml) Cockatrice Kartendatenbank (*.xml) - + All files (*.*) Alle Dateien (*.*) - + Cockatrice replays (*.cor) Aufgezeichnete Cockatrice-Spiele (*.cor) @@ -7120,110 +7353,177 @@ Sind sie sicher, dass diese Funktion deaktiviert werden soll? QPlatformTheme - + OK OK - + Save Speichern - + Save All Alle speichern - + Open Öffnen - + &Yes Ja - + Yes to &All Ja zu &Allem - + &No &Nein - + N&o to All Nein zu Allem - + Abort Abbrechen - + Retry Erneut versuchen - + Ignore Ignorieren - + Close Schließen - + Cancel Abbrechen - + Discard Verwerfen - + Help Hilfe - + Apply Anwenden - + Reset Zurücksetzen - + Restore Defaults Standard wiederherstellen + + QuickSetupPanel + + + %1% + + + + + <b>Quick Setup</b> + + + + + Generate all palette roles automatically from a single accent colour + + + + + Accent: + + + + + Primary hue. Used directly for highlights and links. +At high intensity it also tints buttons and backgrounds. + + + + + Intensity: + + + + + Subtle + + + + + Full colour + + + + + 0–30 Subtle tint — only highlights and links change hue +30–70 Accented — buttons, tooltips, and borders join in +70–100 Full colour — backgrounds, everything + + + + + 70% + + + + + Generate ↓ + + + + + Derive all palette roles from the accent colour above. +Fine-tune individual colours in the grid afterwards. + + + RemoteDeckList_TreeModel - + Name Name - + ID ID - + Upload time Änderungsdatum @@ -7231,32 +7531,32 @@ Sind sie sicher, dass diese Funktion deaktiviert werden soll? RemoteReplayList_TreeModel - + ID ID - + Name Name - + Players Spieler - + Keep Behalten - + Time started Startzeit - + Duration (sec) Länge (Sekunden) @@ -7264,37 +7564,37 @@ Sind sie sicher, dass diese Funktion deaktiviert werden soll? RfgMenu - + &Exile &Exil - + &View exile &Exil ansehen - + &Move exile to... &Exil nach ... bewegen - + &Top of library &Oben in der Bibliothek - + &Bottom of library &Unten in der Bibliothek - + &Hand &Hand - + &Graveyard &Friedhof @@ -7340,7 +7640,7 @@ Sind sie sicher, dass diese Funktion deaktiviert werden soll? SayMenu - + S&ay S&agen @@ -7381,27 +7681,27 @@ Sind sie sicher, dass diese Funktion deaktiviert werden soll? SetsModel - + Enabled Aktiviert - + Set type Editionsart - + Set code Editionscode - + Long name Langer Name - + Release date Veröffentlichung @@ -7409,53 +7709,53 @@ Sind sie sicher, dass diese Funktion deaktiviert werden soll? ShortcutSettingsPage - - + + Restore all default shortcuts Alle Standard-Tastaturkürzel wiederherstellen - + Do you really want to restore all default shortcuts? Möchten Sie wirklich alle Standard-Tastaturkürzel wiederherstellen? - + Clear all default shortcuts Alle Standard-Tastaturkürzel entfernen - + Do you really want to clear all shortcuts? Möchten Sie wirklich alle Tastaturkürzel entfernen? - + Section: Abschnitt: - + Action: Aktion: - + Shortcut: Tastaturkürzel: - + How to set custom shortcuts Wie werden benutzerdefinierte Tastaturkürzel gesetzt - + Clear all shortcuts Alle Tastaturkürzel entfernen - + Search by shortcut name Suchen nach Name des Tastenkürzels @@ -7511,12 +7811,12 @@ Bitte überprüfen Sie die Verknüpfungseinstellungen! SideboardMenu - + &Sideboard &Nebendeck - + &View sideboard &Nebendeck ansehen @@ -7524,27 +7824,27 @@ Bitte überprüfen Sie die Verknüpfungseinstellungen! SoundSettingsPage - + Enable &sounds Töne aktivieren - + Current sounds theme: Aktuelles Ton-Theme: - + Test system sound engine Systemsound testen - + Sound settings Toneinstellungen - + Master volume Masterlautstärke @@ -7616,16 +7916,146 @@ Bitte überprüfen Sie die Verknüpfungseinstellungen! Ungültige Antwort vom Veröffentlichungsaktualisierungsserver erhalten. - + No reply received from the tag update server. Keine Antwort vom Kennzeichnungsaktualisierungsserver. - + Invalid reply received from the tag update server. Ungültige Antwort vom Kennzeichungsaktualisierungsserver. + + StorageSettingsPage + + + + + Success + + + + + Cached card pictures have been reset. + + + + + Downloaded card pictures have been reset. + + + + + Error + + + + + One or more downloaded card pictures could not be cleared. + + + + + In-memory (currently loaded) card pictures have been reset. + + + + + Card Picture Loader Caching Method: + + + + + The network cache is the preferred way of storing images. Downloaded images are stored here until the size of the cache exceeds the configured size. Cockatrice automatically monitors this cache and deletes the least recently seen card images to ensure the cache does not exceed the configured size. + + + + + Writing card images directly to a folder on your hard drive is another way of storing images. This does not change how Cockatrice accesses or downloads images. Cockatrice will NOT automatically monitor and clear this folder, so if you enable this option, it is up to you to ensure sufficient available space. It should also be noted that if a provider outage causes you to download the wrong picture (i.e. wrong printing) you will be stuck with it until you manually delete the file, as opposed to using the network cache, which automatically rotates and thus correct errors after a while. + + + + + This is the in-memory picture cache used by the application at runtime. It determines how much memory (RAM) Cockatrice can use before it has to fetch card images from the hard disk again. Increasing this will allow more card images to be displayed at once but shouldn't be necessary. Clearing this will make Cockatrice reload all images from the network cache or the disk. + + + + + Delete Cached Images + + + + + Delete Saved Images + + + + + Clear In-Memory Images + + + + + Card Picture Loader Cache Method + + + + + Network Cache + + + + + Filesystem + + + + + In-Memory Picture Cache + + + + + Network Cache Size: + + + + + On-disk cache for downloaded pictures + + + + + Redirect Cache TTL: + + + + + How long cached redirects for urls are valid for. + + + + + Picture Cache Size: + + + + + In-memory cache for pictures not currently on screen + + + + + Naming scheme: + + + + + Day(s) + + + TabAccount @@ -7761,117 +8191,117 @@ Bitte überprüfen Sie die Verknüpfungseinstellungen! TabArchidekt - - + + Desc. Beschreibung. - - + + AND UND - - + + Require ALL selected colors Benötigt ALLE ausgewählten Farben - - + + Deck name... Deckname... - - + + Owner... Besitzer... - - + + Packages Pakete - - + + Advanced Filters Erweiterte Filter - + Bracket: Kategorie: - - + + Any Beliebig - - + + Contains card... Enthält Karte... - - + + Commander... Kommandant... - - + + Tag... Etikett... - - + + Deck Size Deckgröße - + Cards: Karten: - - + + Asc. Aufsteigend. - + Sort by: Sortieren nach: - + Filter by: Filtern nach: - + Display Settings Anzeigeeinstellungen - - + + Search Suche - - + + Formats Formate @@ -7881,6 +8311,54 @@ Bitte überprüfen Sie die Verknüpfungseinstellungen! Archidekt: + + TabCardArtRules + + + Card: + + + + + ProviderId: + + + + + Mode: + + + + + Reason: + + + + + Type a card name... + + + + + Add rule + + + + + Remove rule + + + + + Refresh + + + + + Card Art Rules + + + TabDeckEditor @@ -7929,7 +8407,7 @@ Bitte überprüfen Sie die Verknüpfungseinstellungen! Darstellung zurücksetzen - + Deck: %1 Deck: %1 @@ -7937,55 +8415,49 @@ Bitte überprüfen Sie die Verknüpfungseinstellungen! TabDeckEditorVisual - + Visual Deck: %1 Visuelles Deck: %1 - + &Visual Deck Editor &Visueller Deckeditor - - + + Card Info Karteninformation - - + + Deck Deck - - - Filters - Filter - - - + &View &Ansehen - + Printing Auflage - + Visible Sichtbar - + Floating Schwebend - + Reset layout Anordnung zurücksetzen @@ -7993,22 +8465,22 @@ Bitte überprüfen Sie die Verknüpfungseinstellungen! TabDeckEditorVisualTabWidget - + Visual Deck View Visuelle Deckansicht - + Visual Database Display Visuelle Datenbankanzeige - + Deck Analytics Deckanalysen - + Sample Hand Probehand @@ -8016,134 +8488,134 @@ Bitte überprüfen Sie die Verknüpfungseinstellungen! TabDeckStorage - + Local file system Lokales Dateisystem - + Server deck storage Deckablage auf dem Server - - + + Open in deck editor Im Deckeditor öffnen - + Rename deck or folder Deck oder Ordner umbenennen - + Upload deck Deck hochladen - + Download deck Deck herunterladen - - - + + + New folder Neuer Ordner - + Delete Löschen - + Open decks folder Deckordner öffnen - + Rename local folder Lokalen Ordner umbenennen - + Rename local file Lokale Datei umbenennen - + New name: Neuer Name: - - - - + + + + Error Fehler - + Rename failed Umbenennen fehlgeschlagen - - + + Invalid deck file Ungültige Deckdatei - + Enter deck name Decknamen eingeben - + This decklist does not have a name. Please enter a name: Diese Deckliste hat keinen Namen. Bitte geben Sie einen Namen ein: - + Unnamed deck Unbenanntes Deck - + Failed to upload deck to server Hochladen des Decks zum Server fehlgeschlagen - + Delete local file Lokale Datei löschen - + Are you sure you want to delete the selected files? Sind sie sicher, dass die ausgewählten Dateien gelöscht werden sollen? - + Delete remote decks Löschen der online gespeicherten Decks - + Are you sure you want to delete the selected decks? Sind sie sicher, dass sie die ausgewählten Decks löschen wollen? - - + + Name of new folder: Name für den neuen Ordner: @@ -8215,191 +8687,191 @@ Bitte geben Sie einen Namen ein: TabGame - - - + + + Replay Wiederholung - - + + Game Spiel - - + + Player List Spielerliste - - + + Card Info Karteninformationen - - + + Messages Nachrichten - - + + Replay Timeline Zeitleiste der Aufzeichnung - + &Phases &Phasen - + &Game Spi&el - + Next &phase Nächste &Phase - + Next phase with &action Nächste Phase mit &Aktion - + Next &turn Nächster &Zug - + Reverse turn order Kehre Zugreihenfolge um - + &Remove all local arrows &Lokale Pfeile entfernen - + Rotate View Cl&ockwise Ansicht im Uhrzeigesinn drehen - + Rotate View Co&unterclockwise Ansicht gegen den Uhrzeigersinn drehen - + Game &information &Spielinformationen - + Un&concede Aufgabe zurückziehen - - - + + + &Concede &Aufgeben - + &Leave game Spiel ver&lassen - + C&lose replay Wiederholung sch&ließen - + &Focus Chat Chat &fokussieren - + &Say: &Sagen: - + Selected cards Ausgewählte Karten - + &View Ansicht - + Visible Sichtbar - + Floating Schwebend - + Reset layout Darstellung zurücksetzen - + Concede Aufgeben - + Are you sure you want to concede this game? Sind Sie sicher, dass Sie das Spiel aufgeben möchten? - + Unconcede Doch nicht aufgeben - + You have already conceded. Do you want to return to this game? Sie haben bereits aufgegeben. Möchten Sie zu diesem Spiel zurückkehren? - + Leave game Spiel verlassen - + Are you sure you want to leave this game? Sind Sie sicher, dass Sie das Spiel verlassen möchten? - + A player has joined game #%1 Ein Spieler ist Spiel #%1 beigetreten - + %1 has joined the game %1 ist dem Spiel beigetreten - + You have been kicked out of the game. Sie wurden aus dem Spiel geworfen. @@ -8458,115 +8930,115 @@ Bitte geben Sie einen Namen ein: Sie müssen eine gültige Anzahl an zu lokalisierenden Tagen auswählen. - + Username: Benutzername: - + IP Address: IP-Adresse: - + Game Name: Spielname: - + GameID: Spiel-ID: - + Message: Nachricht: - + Main Room Hauptraum - + Game Room Spielraum - + Private Chat Privater Chat - + Past X Days: Die letzten X Tage: - + Today Heute - + Last Hour Letzte Stunde - + Maximum Results: Maximale Ergebnisse: - + At least one filter is required. The more information you put in, the more specific your results will be. Mindestens ein Filter ist notwendig. Je mehr Informationen Sie angeben, desto spezifischer fallen die Ergebnisse aus. - + Get User Logs Benutzerlogs anzeigen - + Clear Filters Filter entfernen - + Filters Filter - + Log Locations Logstandorte - + Date Range Datumsbereich - + Maximum Results Maximale Ergebnisse - - + + Message History Nachrichtenverlauf - + Failed to collect message history information. Das Sammeln von Nachrichtenverlaufsinformationen ist fehlgeschlagen. - + There are no messages for the selected filters. Keine Nachrichten für die gewählten Filter. @@ -8584,27 +9056,27 @@ Je mehr Informationen Sie angeben, desto spezifischer fallen die Ergebnisse aus. Ver&lassen - + %1 - Private chat %1 - privater Chat - + This user is ignoring you, they cannot see your messages in main chat and you cannot join their games. Dieser Benutzer ignoriert dich. Er kann deine Nachrichten im Hauptchat nicht sehen und du kannst seinen Spielen nicht beitreten. - + Private message from Private Nachricht von - + %1 has left the server. %1 hat den Server verlassen. - + %1 has joined the server. %1 hat den Server betreten. @@ -8612,181 +9084,181 @@ Je mehr Informationen Sie angeben, desto spezifischer fallen die Ergebnisse aus. TabReplays - + Local file system Lokales Dateisystem - + Server replay storage Wiederholungsablage auf dem Server - - + + Watch replay Wiederholung abspielen - + Rename Umbenennen - - + + New folder Neuer Ordner - - + + Delete Löschen - + Open replays folder Wiederholungsorder öffnen - + Download replay Wiederholung herunterladen - + Toggle expiration lock automatische Löschung umschalten - + Get replay share code Wiederholungscode zum Teilen erhalten - - + + Look up replay by share code Wiederholung durch einen Code zum Teilen nachschauen - + Rename local folder Umbenennen des lokalen Ordners - + Rename local file Umbenennen der lokalen Datei - + New name: Neuer Name: - + Error Fehler - + Rename failed Umbenennen fehlgeschlagen - + Name of new folder: Name des neuen Ordners: - + Delete local file Lokale Datei löschen - + Are you sure you want to delete the selected files? Sind sie sicher, dass die ausgewählten Dateien gelöscht werden sollen? - + Are you sure you want to delete the selected replays? Sind sie sicher, dass die ausgewählten Wiederholungen gelöscht werden sollen? - + Failed to get code Erhalten des Codes fehlgeschlagen - - + + Either this server does not support replay sharing, or does not permit replay sharing for you. Entweder unterstützt dieser Server das Teilen von Wiederholungen nicht, oder erlaubt dir das Teilen von Wiederholungen nicht. - - - + + + Failed Fehlgeschlagen - + Could not get replay code Wiederholungs-Code konnte nicht erhalten werden - + Replay Share Code Wiederholungscode zum Teilen - + Others can use this code to add the replay to their list of remote replays: %1 Andere können diesen Code benutzen um diese Wiederholung zu ihrer Liste von Fremd-Wiederholungen hinzuzufügen: %1 - + Copy to clipboard In die Zwischenablage kopieren - + Replay share code Wiederholungscode zum Teilen - + Replay code found Wiederholungscode gefunden - + Replay was added, or you already had access to it. Wiederholung wurde hinzugefügt, oder du hattest bereits Zugang dazu. - + Replay code not found Wiederholungscode nicht gefunden - + Failed to submit code Einreichen des Codes fehlgeschlagen - + Unexpected error Unerwarteter Fehler - + Delete remote replay Wiederholung vom Server löschen @@ -8799,47 +9271,62 @@ Je mehr Informationen Sie angeben, desto spezifischer fallen die Ergebnisse aus. TabRoom - + + Friends + + + + + Online + + + + + Ignored + + + + &Say: &Sagen: - + Chat Chat - + &Room &Raum - + &Leave room Raum ver&lassen - + &Clear chat Chat leeren - + Chat Settings... Chat Einstellungen - + mentioned you. hat dich erwähnt. - + Click to view Zum Anzeigen klicken. - + You are flooding the chat. Please wait a couple of seconds. Sie überfluten den Chatraum. Bitte warten Sie ein paar Sekunden. @@ -8852,30 +9339,30 @@ Je mehr Informationen Sie angeben, desto spezifischer fallen die Ergebnisse aus. Server - - - - + + + + Error Fehler - + Failed to join the server room: it doesn't exist on the server. Serverraumbeitritt fehlgeschlagen: der Raum existiert auf dem Server nicht. - + The server thinks you are in the server room but your client is unable to display it. Try restarting your client. Der Server denkt Sie seien im Serverraum, allerdings kann Ihr Client diesen nicht anzeigen. Versuchen Sie, Ihren Client neu zu starten. - + You do not have the required permission to join this server room. Sie haben nicht die erforderlichen Berechtigungen, um diesem Serverraum beizutreten. - + Failed to join the server room due to an unknown error: %1. Beitritt des Serverraums aufgrund eines unbekannten Fehlers fehlgeschlagen: %1. @@ -8883,97 +9370,97 @@ Je mehr Informationen Sie angeben, desto spezifischer fallen die Ergebnisse aus. TabSupervisor - + Deck Editor Deckeditor - + Visual Deck Editor Visueller Deckeditor - + EDHRec EDHRec - + Archidekt Archidekt - + Home Zuhause - + &Visual Deck Storage &Visuelle Deckablage - + Visual Database Display Visuelle Datenbankanzeige - + Server Server - + Account Benutzerkonto - + Deck Storage Deckablage - + Game Replays Aufgezeichnete Spiele - + Administration Administration - + Logs Logs - + Are you sure? Sind Sie sicher? - + There are still open games. Are you sure you want to quit? Es sind noch Spiele offen. Wollen Sie das Programm trotzdem beenden? - + Click to view Zum Anzeigen klicken - + Your buddy %1 has signed on! Dein Freund %1 hat sich eingeloggt! - + Unknown Event Unbekanntes Ereignis - + The server has sent you a message that your client does not understand. This message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -8984,39 +9471,39 @@ Dies könnte bedeuten, dass eine neue Version von Cockatrice verfügbar ist oder Um Ihren Client zu aktualisieren, navigieren Sie zu Hilfe -> Auf Aktualisierungen prüfen. - + Idle Timeout Inaktivitätszeitüberschreitung - + You are about to be logged out due to inactivity. Sie werden wegen Inaktivität abgemeldet. - + Promotion Beförderung - + You have been promoted. Please log out and back in for changes to take effect. Sie wurden befördert. Bitte loggen Sie sich aus und wieder ein, damit die Änderungen in Kraft treten. - + Warned Gewarnt - + You have received a warning due to %1. Please refrain from engaging in this activity or further actions may be taken against you. If you have any questions, please private message a moderator. Sie haben eine Warnung erhalten aufgrund von %1. Bitte unterlassen Sie diese Aktivitäten oder weitere Schritte werden gegen Sie eingeleitet. Sollten Sie Fragen haben, stellen Sie diese bitte über eine private Nachricht an einen Moderator. - + You have received the following message from the server. (custom messages like these could be untranslated) Sie haben die folgende Nachricht vom Server erhalten. @@ -9026,12 +9513,12 @@ Bitte unterlassen Sie diese Aktivitäten oder weitere Schritte werden gegen Sie TabVisualDatabaseDisplay - + Database Display Datenbankanzeige - + Visual Database Display Visuelle Datenbankanzeige @@ -9070,42 +9557,42 @@ Bitte unterlassen Sie diese Aktivitäten oder weitere Schritte werden gegen Sie TranslateCounterName - + Life Leben - + White Weiß - + Blue Blau - + Black Schwarz - + Red Rot - + Green Grün - + Colorless Farblos - + Other Andere @@ -9113,11 +9600,69 @@ Bitte unterlassen Sie diese Aktivitäten oder weitere Schritte werden gegen Sie UpdateDownloader - + Could not open the file for reading. Datei konnte nicht zum Lesen geöffnet werden. + + UserCardArtSettingsDialog + + + Card Art Settings + + + + + Type a card name... + + + + + Card name: + + + + + Card ProviderId: + + + + + Left margin (%): + + + + + Right margin (%): + + + + + Vertical offset: + + + + + Zoom: + + + + + Parameters + + + + + Preview + + + + + Remove Banner Card + + + UserContextMenu @@ -9235,7 +9780,7 @@ Bitte unterlassen Sie diese Aktivitäten oder weitere Schritte werden gegen Sie - + Ban History Bannverlauf @@ -9250,77 +9795,87 @@ Bitte unterlassen Sie diese Aktivitäten oder weitere Schritte werden gegen Sie Nutzer wurde bisher nie gebannt. - + Failed to collect ban information. Sammeln von Informationen über Bans fehlgeschlagen. - - - + + + Warning History Warnungsverlauf - + Warning Time;Moderator;User Name;Reason Warnungszeit;Moderator;Nutzername;Grund - + User has never been warned. Nutzer wurde bisher nie gewarnt. - + Failed to collect warning information. Sammeln von Informationen über Verwarnungen fehlgeschlagen. - + Failed to get admin notes. Administratornotizen konnten nicht abgerufen werden. - - + + Success Erfolgreich - + Successfully promoted user. Nutzer erfolgreich befördert. - + Successfully demoted user. Nutzer erfolgreich degradiert. - - - + + Kick Player + + + + + Are you sure you want to kick this player from the game? + + + + + + Failed Fehlgeschlagen - + Failed to promote user. Beförderung des Nutzers fehlgeschlagen. - + Failed to demote user. Degradierung des Nutzers fehlgeschlagen. - + Copy hash to clipboard Kopiere Hash in die Zwischenablage - + Remove this user's messages Nachrichten dieses Nutzers entfernen @@ -9328,326 +9883,554 @@ Bitte unterlassen Sie diese Aktivitäten oder weitere Schritte werden gegen Sie UserInfoBox - + Location: Ort: - + Account Age: Accountalter: - + Edit Bearbeiten - + Change password Passwort ändern - + Change avatar Avatar wechseln - + Administrator Administrator - + Moderator Moderator - + Registered user Registrierter Benutzer - - + + Unregistered user Unregistrierter Benutzer - + Judge Richter - + Unknown Unbekannt - + The entered password does not match your account. Das eingegebene Passwort stimmt nicht mit dem Konto überein. - - - + + + + Information Information - + User information updated. Benutzerinformationen aktualisiert. - - - - - - - - - - + + + + + + + + + + + Error Fehler - + User Information Benutzerinformationen - + Real Name: Echter Name: - + User Level: Nutzerlevel: + + + Edit Banner Card + + - + %n Year(s), amount of years (only shown if more than 0) %n Jahre,%n Jahre, - + %10%n Day(s) %20 amount of years (if more than 0), amount of days, date in local short format %10%n Tage %20%10%n Tage %20 - + Enter Password Passwort eingeben - + Password verification is required in order to change your email address Passwortverifikation benötigt, um die E-Mail-Adresse zu ändern - - - + + + An error occurred while trying to update your user information. Ein Fehler ist während der Aktualisierung Ihrer Benutzerinformationen aufgetreten. - + + The selected card is blacklisted on this server or another error occurred. + + + + + Banner card removed. + + + + + Banner card updated. + + + + This server does not permit you to update your user informations. Dieser Server erlaubt Ihnen nicht, Ihre Benutzerinformationen zu aktualisieren. - + Password changed. Passwort geändert. - + This server does not permit you to change your password. Dieser Server erlaubt Ihnen nicht, Ihr Passwort zu ändern. - + The new password is too short. Das neue Passwort ist zu kurz. - + The old password is incorrect. Altes Passwort inkorrekt. - + Avatar updated. Avatar gewechselt. - + This server does not permit you to update your avatar. Dieser Server erlaubt Ihnen nicht, Ihren Avatar zu ändern. + + + An error occured while trying to update your avatar. + + - An error occured while trying to updater your avatar. - Ein Fehler ist während des Wechselns Ihres Avatars aufgetreten. + This server does not permit you to update your user informations. + An error occured while trying to updater your avatar. + Dieser Server erlaubt Ihnen nicht, Ihre Benutzerinformationen zu aktualisieren. + + + + UserInfoPopup + + + + Games + + + + + Chat + + + + + Open private chat + + + + + Profile + + + + + View user profile + + + + + Show this user's games + + + + + − Buddy + + + + + Remove from buddy list + + + + + + Buddy + + + + + Add to buddy list + + + + + − Ignore + + + + + Remove from ignore list + + + + + + Ignore + + + + + Add to ignore list + + + + + Ban + + + + + Ban from server + + + + + Warn + + + + + Warn user + + + + + Ban log + + + + + View ban history + + + + + Warn log + + + + + View warning history + + + + + Notes + + + + + View admin notes + + + + + − Mod + + + + + Demote from moderator + + + + + + Mod + + + + + Promote to moderator + + + + + − Judge + + + + + Demote from judge + + + + + + Judge + + + + + Promote to judge + + + + + Join game + + + + + Spectate + + + + + + Loading games… + + + + + Could not load games. + + + + + No active games. + UserInterfaceSettingsPage - + General interface settings Allgemeine Bedienung - + &Double-click cards to play them (instead of single-click) Karten durch &Doppelklick ausspielen (statt Einzelklick) - + &Clicking plays all selected cards (instead of just the clicked card) &Anklicken spielt alle ausgewählten Karten aus (anstatt nur der angeklickten Karte) - + &Play all nonlands onto the stack (not the battlefield) by default Alle Nichtländer standardmäßig über den Stapel spielen (anstatt direkt auf das Spielfeld) - + Do not delete &arrows inside of subphases Keine &Pfeile löschen in Unterphasen - + Close card view window when last card is removed Schließe Kartenanzeigefenster, wenn die letzte Karte entfernt wird - + Auto focus search bar when card view window is opened Sucheingabe automatisch in den Fokus nehmen, wenn das Kartenansichtsfenster geöffnet wird - + Annotate card text on tokens Kartentext auf Spielsteinen anzeigen - - Show selection counter during drag selection - Auswahlzähler während Zugauswahl anzeigen + + Show selection count during drag selection + - - Show total selection counter - Gesamtauswahlszähler anzeigen + + Show total selection count + - + + Show subtype breakdown in selection tally + + + + Use tear-off menus, allowing right click menus to persist on screen Benutze Abreißmenüs, erlaubt Kontextmenüs auf dem Bildschirm zu verbleiben - + + Keep game chat focused when clicking in game (Note: disables card view search bar) + + + + Notifications settings Benachrichtigungseinstellungen - + Enable notifications in taskbar Benachrichtigungen in der Taskleiste aktivieren - + Notify in the taskbar for game events while you are spectating Benachrichtigungen für Spielereignisse auch beim Zuschauen anderer Spiele in der Taskbar anzeigen - + Notify in the taskbar when users in your buddy list connect Benachrichtige in der Taskleiste wenn sich Benutzer aus der Freundesliste anmelden - + Animation settings Animationseinstellungen - + &Tap/untap animation Animiertes &Tappen/Enttappen - + Deck editor/storage settings Deckeditor/-ablage Einstellungen - + Open deck in new tab by default Decks in neuem Tab öffnen als Standard - + Use visual deck storage in game lobby Visuelle Deckablage in der Spielelobby verwenden - + Use selection animation for Visual Deck Storage Auswahlanimation für Visuellen Deckspeicher verwenden - + When adding a tag in the visual deck storage to a .txt deck: Wenn ein Tag im Visuellen Deckspeicher zu einem .txt Deck hinzugefügt wird: - + do nothing tue nichts - + ask to convert to .cod frage, ob zu .cod konvertiert werden soll - + always convert to .cod immer nach .cod konvertieren - + Default deck editor type Standarddeckeditortyp - + Classic Deck Editor Klassischer Deckeditor - + Visual Deck Editor Visueller Deckeditor - + Replay settings Wiederholungsoptionen - + Buffer time for backwards skip via shortcut: Pufferzeit für Rückwärtsüberspringen durch Tastenkürzel @@ -9655,22 +10438,22 @@ Bitte unterlassen Sie diese Aktivitäten oder weitere Schritte werden gegen Sie UserListWidget - + Users connected to server: %1 Mit dem Server verbundene Nutzer: %1 - + Users in this room: %1 Benutzer in diesem Raum: %1 - + Buddies online: %1 / %2 Freunde online: %1 / %2 - + Ignored users online: %1 / %2 Ignorierte Benutzer online: %1 / %2 @@ -9678,35 +10461,45 @@ Bitte unterlassen Sie diese Aktivitäten oder weitere Schritte werden gegen Sie UtilityMenu - + Increment all card counters Alle Kartenzähler erhöhen - + &Untap all permanents &Enttappe alle bleibenden Karten - + R&oll die... &Würfeln... - + + Flip coin + + + + &Create token... &Spielstein erzeugen... - + C&reate another token Einen weiteren Spielstein erzeugen - + Cr&eate predefined token &Vordefinierten Spielstein erzeugen + + + C&reate another %1 token + + VisualDatabaseDisplayColorFilterWidget @@ -9758,72 +10551,72 @@ Bitte unterlassen Sie diese Aktivitäten oder weitere Schritte werden gegen Sie VisualDatabaseDisplayFilterToolbarWidget - + Sort by Sortieren nach - + Filter by Filtern nach - + Save and load filters Filter speichern und laden - + Filter by exact card name Nach exaktem Kartennamen filtern - + Filter by card main-type Nach Kartenhaupttyp filtern - + Filter by card sub-type Nach Kartenuntertyp filtern - + Filter by set Nach Set filtern - + Filter by format legality Nach Formatslegalität filtern - + Save/Load Speichern/Laden - + Name Name - + Main Type Haupttyp - + Sub Type Untertyp - + Sets Editionen - + Formats Formate @@ -9831,22 +10624,22 @@ Bitte unterlassen Sie diese Aktivitäten oder weitere Schritte werden gegen Sie VisualDatabaseDisplayFormatLegalityFilterWidget - + Show formats with at least: Formate anzeigen mit mindestens: - + cards Karten - + Do not display formats with less than this amount of cards in the database Keine Formate mit weniger als dieser Anzahl an Karten in der Datenbank anzeigen - + Filter mode (AND/OR/NOT conjunctions of filters) Filtermodus (UND/ODER/NICHT Verbindungen von Filtern) @@ -9864,32 +10657,32 @@ Bitte unterlassen Sie diese Aktivitäten oder weitere Schritte werden gegen Sie VisualDatabaseDisplayMainTypeFilterWidget - + Show main types with at least: Haupttypen anzeigen mit mindestens: - + cards Karten - + Do not display card main-types with less than this amount of cards in the database Kartenhaupttypen mit weniger als dieser Anzahl Karten in der Datenbank nicht anzeigen - + Filter mode (AND/OR/NOT conjunctions of filters) Filtermodus (UND/ODER/NICHT Verknüpfungen der Filter) - + Mode: Exact Match Modus: Exakte Übereinstimmung - + Mode: Includes Modus: Enthält @@ -9897,27 +10690,27 @@ Bitte unterlassen Sie diese Aktivitäten oder weitere Schritte werden gegen Sie VisualDatabaseDisplayNameFilterWidget - + Filter by name... (Exact match) Nach Namen filtern... (Exakte Übereinstimmung) - + Load from Deck Aus Deck laden - + Apply all card names in currently loaded deck as exact match name filters Alle Kartennamen im momentan geladenen Deck als Filter mit exakter Übereinstimmung anwenden - + Load from Clipboard Aus der Zwischenablage laden - + Apply all card names in clipboard as exact match name filters Alle Kartennamen aus der Zwischenablage als Filter mit exakter Übereinstimmung anwenden @@ -9925,7 +10718,7 @@ Bitte unterlassen Sie diese Aktivitäten oder weitere Schritte werden gegen Sie VisualDatabaseDisplayRecentSetFilterSettingsWidget - + Filter to most recent sets Filtern nach aktuellsten Sets @@ -9933,19 +10726,19 @@ Bitte unterlassen Sie diese Aktivitäten oder weitere Schritte werden gegen Sie VisualDatabaseDisplaySetFilterWidget - + Search sets... Sets durchsuchen... - - + + Mode: Exact Match Modus: Exakte Übereinstimmung - - + + Mode: Includes Modus: Enthält @@ -9953,37 +10746,37 @@ Bitte unterlassen Sie diese Aktivitäten oder weitere Schritte werden gegen Sie VisualDatabaseDisplaySubTypeFilterWidget - + Search subtypes... Untertypen durchsuchen... - + Show sub types with at least: Untertypen anzeigen mit mindestens: - + cards Karten - + Do not display card sub-types with less than this amount of cards in the database Kartenuntertypen mit weniger als dieser Anzahl Karten in der Datenbank nicht anzeigen - + Filter mode (AND/OR/NOT conjunctions of filters) Filtermodus (UND/ODER/NICHT Verknüpfungen der Filter) - + Mode: Exact Match Modus: Exakte Übereinstimmung - + Mode: Includes Modus: Enthält @@ -9991,23 +10784,23 @@ Bitte unterlassen Sie diese Aktivitäten oder weitere Schritte werden gegen Sie VisualDatabaseDisplayWidget - + Search by card name (or search expressions) Suche nach Kartennamen (oder Suchausdrücken) - + Visual Visuell - + Loading database ... Datenbank wird geladen... - + Clear all filters Alle Filter entfernen @@ -10046,17 +10839,17 @@ Bitte unterlassen Sie diese Aktivitäten oder weitere Schritte werden gegen Sie - + Toggle Layout: Overlap Umschalten des Layouts: Überlappung - + Change how cards are displayed within zones (i.e. overlapped or fully visible.) Wie Karten innerhalb von Zonen angezeigt werden ändern ( z.B. überlappend oder vollständig sichtbar) - + Toggle Layout: Flat Umschalten des Layouts: Flach @@ -10085,17 +10878,17 @@ Bitte unterlassen Sie diese Aktivitäten oder weitere Schritte werden gegen Sie VisualDeckEditorWidget - + Type a card name here for suggestions from the database... Hier einen Kartennahmen eingeben für Vorschläge aus der Datenbank... - + Quick search and add card Schnellsuche und Karte hinzufügen - + Search for closest match in the database (with auto-suggestions) and add preferred printing to the deck on pressing enter Nach bester Entsprechung in der Datenbank suchen (mit automatischen Vorschlägen) und die bevorzugte Auflage beim Betätigen der Eingabetaste dem Deck hinzufügen @@ -10213,43 +11006,43 @@ Bitte unterlassen Sie diese Aktivitäten oder weitere Schritte werden gegen Sie WarningDialog - + Which warning would you like to send? Welche Warnung möchten Sie versenden? - + Redact all messages from this user in all rooms Entferne alle Nachrichten dieses Nutzers in allen Räumen - + &OK &OK - + &Cancel Abbre&chen - + Warn user for misconduct Nutzer aufgrund von Fehlverhalten warnen - - + + Error Fehler - + User name to send a warning to can not be blank, please specify a user to warn. Der zu warnende Nutzername kann nicht leer gelassen werden, bitte geben Sie einen Nutzernamen an. - + Warning to use can not be blank, please select a valid warning to send. Warnungsart kann nicht leer gelassen werden, bitte wählen Sie die zu sendende Warnungsart. @@ -10257,133 +11050,133 @@ Bitte unterlassen Sie diese Aktivitäten oder weitere Schritte werden gegen Sie WndSets - + Move selected set to the top Ausgewählte Edition an die Spitze verschieben - + Move selected set up nach oben verschieben - + Move selected set down nach unten verschieben - + Move selected set to the bottom Ausgewählte Edition ans Ende verschieben - + Search by set name, code, or type Suche nach Setname, Code oder Typ - + Default order Standardreihenfolge - + Restore original art priority order Stelle die ursprüngliche Reihenfolge für Bilderprioritäten wieder her - + Enable all sets Alle Editionen aktivieren - + Disable all sets Alle Editionen deaktivieren - + Enable selected set(s) Ausgewählte Edition(en) aktivieren - + Disable selected set(s) Ausgewählte Edition(en) deaktivieren - + Deck Editor Deckeditor - + Use CTRL+A to select all sets in the view. Benutzen Sie Strg+A, um alle Sets in der Ansicht auszuwählen. - + Only cards in enabled sets will appear in the card list of the deck editor. Nur Karten aus aktivierten Sets werden in der Kartenliste des Deckeditors angezeigt. - + Image priority is decided in the following order: Bildpriorität wird auf folgende Reihenfolge festgelegt: - + first the CUSTOM Folder (%1), then the Enabled Sets in this dialog (Top to Bottom) %1 is a link to the wiki zuerst der BENUTZERDEFINIERTE Ordner (%1), dann die aktivierten Sets aus diesem Dialog (von oben nach unten) - + Include cards rebalanced for Alchemy [requires restart] Karten, die für Alchemy augeglichen wurden, einschließen [erfordert Neustart] - + Card Art Kartenzeichnung - + How to use custom card art Wie benutzerdefinierte Kartenbilder verwendet werden - + Hints Tipps - + Note Notiz - + Sorting by column allows you to find a set while not changing set priority. Nach einer Spalte sortieren erlaubt, eine Edition zu finden, ohne die Editionspriorität zu verändern. - + To enable ordering again, click the column header until this message disappears. Um die Sortierung erneut zu aktivieren, klicken Sie auf den Spaltenkopf bis diese Nachricht verschwindet. - + Use the current sorting as the set priority instead Benutze die momentane Sortierung als Editionspriorität. - + Sorts the set priority using the same column Sortiert die Editionspriorität mit der selben Spalte - + Manage sets Editionen verwalten @@ -10391,72 +11184,72 @@ Bitte unterlassen Sie diese Aktivitäten oder weitere Schritte werden gegen Sie ZoneViewWidget - + Search by card name (or search expressions) Suche nach Kartennamen (oder Suchausdrücken) - + Ungrouped Ungruppiert - + Group by Type Gruppieren nach Kartentyp - + Group by Mana Value Gruppieren nach Manabetrag - + Group by Color Gruppieren nach Farbe - + Unsorted Unsortiert - + Sort by Name Sortieren nach Name - + Sort by Type Sortieren nach Kartentyp - + Sort by Mana Cost Sortieren nach Manakosten - + Sort by Colors Sortieren nach Farben - + Sort by P/T Sortieren nach P/T - + Sort by Set Sortieren nach Auflage - + shuffle when closing beim Schließen mischen - + pile view Stapelansicht @@ -10464,7 +11257,7 @@ Bitte unterlassen Sie diese Aktivitäten oder weitere Schritte werden gegen Sie i18n - + English Deutsch (German) @@ -10472,12 +11265,12 @@ Bitte unterlassen Sie diese Aktivitäten oder weitere Schritte werden gegen Sie main - + Connect on startup Verbinde beim Start - + Debug to file Debugge in Datei @@ -10491,7 +11284,7 @@ Bitte unterlassen Sie diese Aktivitäten oder weitere Schritte werden gegen Sie - + Deck Editor Deckeditor @@ -10572,7 +11365,7 @@ Bitte unterlassen Sie diese Aktivitäten oder weitere Schritte werden gegen Sie - + Replays Wiederholungen @@ -10687,129 +11480,129 @@ Bitte unterlassen Sie diese Aktivitäten oder weitere Schritte werden gegen Sie Deck laden... - - + + Load Deck from Clipboard... Deck aus der Zwischenablage laden... - + Edit Deck in Clipboard, Annotated Deck in Zwischenablage bearbeiten, kommentiert - + Edit Deck in Clipboard Deck in Zwischenablage bearbeiten - + New Deck Neues Deck - + Open Custom Pictures Folder Benutzerdefinierten Bilderordner öffnen - + Print Deck... Deck drucken... - + Delete Card Karte löschen - - + + Reset Layout Darstellung zurücksetzen - + Save Deck Deck speichern - + Save Deck as... Deck speichern unter... - + Save Deck to Clipboard, Annotated Deck kommentiert in die Zwischenablage speichern - + Save Deck to Clipboard, Annotated (No Set Info) Deck in die Zwischenablage speichern, kommentiert (keine Setinformationen) - + Save Deck to Clipboard Deck in die Zwischenablage speichern - + Save Deck to Clipboard (No Set Info) Deck in die Zwischenablage speichern (keine Setinformationen) - + Load Local Deck... Lokales Deck laden... - + Load Remote Deck... Deck vom Server laden... - + Set Ready to Start Bereit zum Start setzen - + Toggle Sideboard Lock Sideboardsperre umschalten - + Add Green Counter Grüne Marke hinzufügen - + Remove Green Counter Grüne Marke entfernen - + Set Green Counters... Grüne Marken setzen... - + Add Red Counter Rote Marke hinzufügen - + Remove Red Counter Rote Marke entfernen - + Set Red Counters... Rote Marken setzen... - + Add Life Counter Lebensmarke hinzufügen @@ -10819,724 +11612,744 @@ Bitte unterlassen Sie diese Aktivitäten oder weitere Schritte werden gegen Sie Statuszeile anzeigen - + + Load deck from online service... + + + + + Load from website... + + + + Unload Deck Deck entladen - + Force Start Zwangsstart - + Add Card Counter (F) Kartenmarke hinzufügen (F) - + Remove Card Counter (F) Kartenmarke entfernen (F) - + Set Card Counters (F)... Kartenmarke setzen (F)... - + Add Card Counter (E) Kartenmarke hinzufügen (E) - + Remove Card Counter (E) Kartenmarke entfernen (E) - + Set Card Counters (E)... Kartenmarke setzen (E)... - + Add Card Counter(D) Kartenmarke hinzufügen (D) - + Remove Card Counter (D) Kartenmarke entfernen (D) - + Set Card Counters (D)... Kartenmarke setzen (D)... - + Add Card Counter (C) Kartenmarke hinzufügen (C) - + Remove Card Counter (C) Kartenmarke entfernen (C) - + Set Card Counters (C)... Kartenmarke setzen (C)... - + Add Card Counter (B) Kartenmarke hinzufügen (B) - + Remove Card Counter (B) Kartenmarke entfernen (B) - + Set Card Counters (B)... Kartenmarke setzen (B)... - + Add Card Counter (A) Kartenmarke hinzufügen (A) - + Remove Card Counter (A) Kartenmarke entfernen (A) - + Set Card Counters (A)... Kartenmarke setzen (A)... - + Remove Life Counter Lebensmarke entfernen - + Set Life Counters... Lebensmarken setzen... - + Add White Counter Weiße Marke hinzufügen - + Remove White Counter Weiße Marke entfernen - + Set White Counters... Weiße Marken setzen... - + Add Blue Counter Blaue Marke hinzufügen - + Remove Blue Counter Blaue Marke entfernen - + Set Blue Counters... Blaue Marken setzen... - + Add Black Counter Schwarze Marke hinzufügen - + Remove Black Counter Schwarze Marke entfernen - + Set Black Counters... Schwarze Marken setzen... - + Add Colorless Counter Farblose Marke hinzufügen - + Remove Colorless Counter Farblose Marke entfernen - + Set Colorless Counters... Farblose Marken setzen... - + Add Other Counter Andere Marke hinzufügen - + Remove Other Counter Andere Marke entfernen - + Set Other Counters... Andere Marke setzen... - + Increment all card counters Alle Kartenzähler erhöhen - + Add Power (+1/+0) Stärke hinzufügen (+1/+0) - + Remove Power (-1/-0) Stärke entfernen (-1/-0) - + Move Toughness to Power (+1/-1) Widerstandskraft nach Stärke verschieben (+1/-1) - + Add Toughness (+0/+1) Widerstandskraft hinzufügen (+0/+1) - + Remove Toughness (-0/-1) Widerstandskraft entfernen (-0/-1) - + Move Power to Toughness (-1/+1) Stärke nach Widerstandskraft verschieben (-1/+1) - + Add Power and Toughness (+1/+1) Stärke und Widerstandskraft hinzufügen (+1/+1) - + Remove Power and Toughness (-1/-1) Stärke und Widerstandskraft entfernen (-1/-1) - + Set Power and Toughness... Stärke und Widerstandskraft setzen... - + Reset Power and Toughness Stärke und Widerstandskraft zurücksetzen - + Untap Enttappen - + Upkeep Versorgung - + Draw Ziehen - + First Main Phase Erste Hauptphase - + Start Combat Beginn des Kampfes - + Attack Angriff - + Block Blocken - + Damage Schaden - + End Combat Ende des Kampfes - + Second Main Phase Zweite Hauptphase - + End Endsegment - + Next Phase Nächste Phase - + Next Phase Action Nächste Phasenaktion - + Next Turn Nächster Zug - + Hide Card in Reveal Window Karte im Enthüllungsfenster verstecken - + Tap / Untap Card Tappe / Enttappe Karte - + Untap All Alle enttappen - + Toggle Skip Untapping Toggle Untap Enttapen überspringen umschalten - + Turn Card Over Karte herumdrehen - + Peek Card Karte ansehen - + Play Card Karte spielen - + Play Card, Face Down Karte spielen, verdeckt - + Attach Card... Karte anlegen... - + Unattach Card Karte lösen - + Clone Card Karte klonen - + Create Token... Spielstein erstellen... - + Create All Related Tokens Alle zugehörigen Spielsteine erstellen - + Create Another Token Einen weiter Spielstein erstellen - + Set Annotation... Notiz setzen... - + + Reduce Life by Power + + + + Select All Cards in Zone Alle Karten in der Zone auswählen - + Select All Cards in Row Alle Karten in der Reihe auswählen - + Select All Cards in Column Alle Karten in der Spalte auswählen - + Reveal Selected Cards to All Players Ausgewählte Karten allen Spielern vorzeigen - - + + Bottom of Library Untere Karte der Bibliothek - - - - + + + + Exile Exil - - - - + + + + Graveyard Friedhof - - + + Hand Hand - - + + Top of Library Obere Karte der Bibliothek - - + + Battlefield, Face Down Spielfeld, verdeckt - + Battlefield Spielfeld - + Library Bibliothek - + Sideboard Sideboard - + Top Cards of Library Oberste Karten der Bibliothek - + Bottom Cards of Library Unterste Karten der Bibliothek - + Close Recent View Schließe letzte Ansicht - - + + Stack Stapel - - + + Graveyard (Multiple) Friedhof (mehrere) - - + + Graveyard (Multiple), Face Down Friedhof (mehrere), verdeckt - - + + Exile (Multiple) Exil (mehrere) - - + + Exile (Multiple), Face Down Exil (mehrere), verdeckt - + Stack Until Found Stapel bis gefunden - + Draw Bottom Card Unterste Karte ziehen - + Draw Multiple Cards from Bottom... Mehrere Karten von unten ziehen... - + Draw Arrow... Pfeil zeichnen... - + Remove Local Arrows Lokale Pfeile entfernen - + Leave Game Spiel verlassen - + Concede Aufgeben - + Roll Dice... Würfeln... - + + Flip Coin + + + + Shuffle Library Bibliothek mischen - + Shuffle Top Cards of Library Oberste Karten der Bibliothek mischen - + Shuffle Bottom Cards of Library Unterste Karten der Bibliothek mischen - + Mulligan Mulligan - + Mulligan (Same hand size) Mulligan nehmen (Gleiche Handgröße) - + Mulligan (Hand size - 1) Mulligan nehmen (Handgröße -1) - + Draw a Card Eine Karte ziehen - + Draw Multiple Cards... Mehrere Karten ziehen... - + Undo Draw Ziehen rückgängig machen - + Always Reveal Top Card Oberste Karte aufgedeckt lassen - + Always Look At Top Card Oberste Karte immer betrachten - + Sort Hand by Name Hand sortieren nach Name - + Sort Hand by Type Hand sortieren nach Typ - + Sort Hand by Mana Value Hand sortieren nach Manawert - + Reveal Hand to All Players Hand allen Spielern vorzeigen - + Reveal Random Card to All Players Zufällige Karte allen Spielern vorzeigen - + Rotate View Clockwise Ansicht im Uhrzeigersinn drehen - + Rotate View Counterclockwise Ansicht gegen den Uhrzeigersinn drehen - + Unfocus Text Box Textboxfokus aufheben - + Focus Chat Chat fokussieren - + Clear Chat Chat leeren - + Refresh Aktualisieren - + Skip Forward Vorspulen - + Skip Backward Zurückspulen - + Skip Forward by a lot Sehr weit vorspulen - + Skip Backward by a lot Sehr weit zurückspulen - + Play/Pause Abspielen/Pausieren - + Toggle Fast Forward Schnelldurchlauf umschalten - + Home Zuhause - + Visual Deck Storage Visuelle Deckablage - + Deck Storage Deckablage - + Server Server - + Account Benutzerkonto - + Administration Administration - + Logs Logs diff --git a/cockatrice/translations/cockatrice_el.ts b/cockatrice/translations/cockatrice_el.ts index 5bd3b2826..be66756d9 100644 --- a/cockatrice/translations/cockatrice_el.ts +++ b/cockatrice/translations/cockatrice_el.ts @@ -2,7 +2,7 @@ AbstractCounter - + &Set counter... &Ρύθμιση μετρητή... @@ -10,12 +10,12 @@ AbstractCounterDialog - + Set counter Ρύθμιση μετρητή - + New value for counter '%1': Νέα τιμή για τον μετρητή '%1' @@ -36,60 +36,60 @@ AbstractTabDeckEditor - + Open in new tab - + Are you sure? - + The decklist has been modified. Do you want to save the changes? - - - - - - + + + + + + Error - + Could not open deck at %1 - + Could not save remote deck - - + + The deck could not be saved. Please check that the directory is writable and try again. - + Save deck - + The deck could not be saved. - + There are no cards in your deck to be exported @@ -105,12 +105,12 @@ Please check that the directory is writable and try again. AdminNotesDialog - + Update Notes - + Admin Notes for %1 @@ -127,172 +127,227 @@ Please check that the directory is writable and try again. Sideboard + + + Tokens + + AppearanceSettingsPage - + seconds - + Error Σφάλμα - + Could not create themes directory at '%1'. - + Theme settings Ρυθμίσεις θέματος - + Current theme: Τρέχον θέμα: - + Open themes folder Άνοιγμα φακέλου θεμάτων - + Home tab background source: - + Home tab background shuffle frequency: - + Disabled - - Display card name of background in bottom right: - - - - + Menu settings - + Show keyboard shortcuts in right-click menus - + Show game filter toolbar above list in room tab - + Card rendering Απόδοση κάρτας - + Display card names on cards having a picture Εμφανίστε τα ονόματα καρτών σε κάρτες με εικόνα - + Auto-Rotate cards with sideways layout - + Override all card art with personal set preference (Pre-ProviderID change behavior) - + + Light + + + + + Dark + + + + + System + + + + + Active theme palette: + + + + + Edit theme palette + + + + + Home tab settings + + + + + Display card name of background in bottom right + + + + + Styling settings + + + + + Style user list + + + + + Card printings + + + + Bump sets that the deck contains cards from to the top in the printing selector - + Scale cards on mouse over Αύξηση της κλίμακας των καρτών με την επιλογή του ποντικιού - + Use rounded card corners - + + Card layout + + + + Minimum overlap percentage of cards on the stack and in vertical hand - + Maximum initial height for card view window: - - + + rows - + Maximum expanded height for card view window: - + Card counters - + Counter %1 - + Hand layout Διάταξη χειρός - + Display hand horizontally (wastes space) Εμφάνιση χεριού οριζόντια (σπατάλη χώρου) - + Enable left justification Ενεργοποιήστε την αριστερή αιτιολόγηση - + Table grid layout Διάταξη πλέγματος πίνακα - + Invert vertical coordinate Ανατροπή κάθετης συντεταγμένης - + Minimum player count for multi-column layout: Ελάχιστος αριθμός παικτών για διάταξη πολλαπλών στηλών: - + Maximum font size for information displayed on cards: Μέγιστο μέγεθος γραμματοσειράς για πληροφορίες που εμφανίζονται στις κάρτες: @@ -300,12 +355,12 @@ Please check that the directory is writable and try again. ArchidektApiResponseDeckDisplayWidget - + Back to results - + Open Deck in Deck Editor @@ -331,112 +386,112 @@ Please check that the directory is writable and try again. BanDialog - + ban &user name απαγόρευση και όνομα χρήστη - + ban &IP address απαγόρευση & διεύθυνση IP - + ban client I&D απαγόρευση εξυπηρετητή I&D - + Ban type Τύπος απαγόρευσης - + &permanent ban &μόνιμη απαγόρευση - + &temporary ban &προσωρινή απαγόρευση - + &Days: &Μέρες: - + &Hours: &Ώρες: - + &Minutes: &Λεπτά: - + Duration of the ban Διάρκεια της απαγόρευσης - + Please enter the reason for the ban. This is only saved for moderators and cannot be seen by the banned person. Παρακαλώ καταχωρίστε τον λόγο της απαγόρευσης. Αυτό αποθηκεύεται μόνο για τους επόπτες και δεν μπορεί να το δει το αποκλεισμένο άτομο. - + Please enter the reason for the ban that will be visible to the banned person. Παρακαλώ καταχωρίστε τον λόγο της απαγόρευσης που θα είναι ορατή στο αποκλεισμένο άτομο. - + Redact all messages from this user in all rooms - + &OK &Εντάξει - + &Cancel &Άκυρο - + Ban user from server Απαγόρευση χρήστη από τον διακομιστή - - - - + + + + Error Σφάλμα - + You have to select a name-based, IP-based, clientId based, or some combination of the three to place a ban. Για την απαγόρευση πρέπει να επιλέξετε ένα όνομα, μία IP, ένα clientId, ή κάποιο συνδυασμό των τριών. - + You must have a value in the name ban when selecting the name ban checkbox. Πρέπει να έχετε μια τιμή στην απαγόρευση ονόματος όταν επιλέγετε το πλαίσιο ελέγχου απαγόρευσης ονόματος. - + You must have a value in the ip ban when selecting the ip ban checkbox. Πρέπει να έχετε μια τιμή στην απαγόρευση ip όταν επιλέγετε το πλαίσιο ελέγχου απαγόρευσης ip. - + You must have a value in the clientid ban when selecting the clientid ban checkbox. Πρέπει να έχετε μια τιμή στην απαγόρευση clientid όταν επιλέγετε το πλαίσιο ελέγχου απαγόρευσης clientid. @@ -444,59 +499,123 @@ This is only saved for moderators and cannot be seen by the banned person. BetaReleaseChannel - + Beta - + No reply received from the release update server. - + Invalid reply received from the release update server. - + No reply received from the file update server. + + CardArtPreviewWidget + + + No card selected + + + + + CardArtRulesModel + + + Card + + + + + ProviderId + + + + + Mode + + + + + Reason + + + CardDatabaseModel - + Name Όνομα - + Sets Σετς - + Mana cost Το κόστος του Μάνα - + Card type Είδος κάρτας - + P/T P/T - + Color(s) Χρώμα(τα) + + CardDatabaseView + + + Add to Deck + + + + + Add to Sideboard + + + + + Select Printing + + + + + Show on EDHRec (Commander) + + + + + Show on EDHRec (Card) + + + + + Show Related cards + + + CardFilter @@ -625,22 +744,22 @@ This is only saved for moderators and cannot be seen by the banned person. CardInfoPictureWidget - + View related cards - + Add card to deck - + Mainboard - + Sideboard @@ -663,12 +782,12 @@ This is only saved for moderators and cannot be seen by the banned person. - + Related cards: - + Unknown card: @@ -676,128 +795,146 @@ This is only saved for moderators and cannot be seen by the banned person. CardMenu - + Re&veal to... - + &All players - + View related cards - + Token: - + All tokens - + &Select All - + S&elect Row - + S&elect Column - + &Play - + &Hide - + Play &Face Down - + &Tap / Untap Turn sideways or back again - + Skip &untapping - + T&urn Over Turn face up/face down - + &Peek at card face - + &Clone - + Attac&h to card... - + Unattac&h - + &Draw arrow... - + &Set annotation... - + + Reduce life by power + + + + Ca&rd counters - + &Add counter (%1) - + &Remove counter (%1) - + &Set counters (%1)... + + CardPictureLoaderCacheMethod + + + Network Cache + + + + + Filesystem + + + CardSizeWidget @@ -809,133 +946,133 @@ This is only saved for moderators and cannot be seen by the banned person. CardZoneLogic - + their hand nominative - + %1's hand nominative - + their library look at zone - + %1's library look at zone - + of their library top cards of zone, - + of %1's library top cards of zone - + their library reveal zone - + %1's library reveal zone - + their library shuffle - + %1's library shuffle - + their library nominative - + %1's library nominative - + their graveyard nominative - + %1's graveyard nominative - + their exile nominative - + %1's exile nominative - + their sideboard look at zone - + %1's sideboard look at zone - + their sideboard nominative - + %1's sideboard nominative - + their custom zone '%1' nominative - + %1's custom zone '%2' nominative @@ -957,16 +1094,421 @@ This is only saved for moderators and cannot be seen by the banned person. + + ColorButton + + + Click to pick a color + + + + + Pick colour + + + + + ConnectionController + + + + The server has reached its maximum user capacity, please check back later. + + + + + There are too many concurrent connections from your address. + + + + + Banned by moderator + + + + + Expected end time: %1 + + + + + This ban lasts indefinitely. + + + + + Scheduled server shutdown. + + + + + + Invalid username. + + + + + You have been logged out due to logging in at another location. + + + + + Connection closed + + + + + The server has terminated your connection. +Reason: %1 + + + + + The server is going to be restarted in %n minute(s). +All running games will be lost. +Reason for shutdown: %1 + + + + + Scheduled server shutdown + + + + + Failed Login + + + + + Your client seems to be missing features this server requires for connection. + + + + + To update your client, go to 'Help -> Check for Client Updates'. + + + + + + + + + + + + + + + + + + + + + + Error + + + + + Incorrect username or password. Please check your authentication information and try again. + + + + + There is already an active session using this user name. +Please close that session first and re-login. + + + + + + You are banned until %1. + + + + + + You are banned indefinitely. + + + + + This server requires user registration. Do you want to register now? + + + + + This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. +Please close and reopen your client to try again. + + + + + An internal error has occurred, please close and reopen Cockatrice before trying again. +If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. + + + + + Account activation + + + + + Your account has not been activated yet. +You need to provide the activation token received in the activation email. + + + + + Server Full + + + + + Unknown login error: %1 + + + + + + +This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. + + + + + + + + + + Registration denied + + + + + Registration is currently disabled on this server + + + + + There is already an existing account with the same user name. + + + + + It's mandatory to specify a valid email address when registering. + + + + + It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. + + + + + Password too short. + + + + + Registration failed for a technical problem on the server. + + + + + The connection to the server has been lost. + + + + + Unknown registration error: %1 + + + + + Account activation failed + + + + + Socket error: %1 + + + + + Server timeout + + + + + You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. +Local version is %1, remote version is %2. + + + + + Your Cockatrice client is obsolete. Please update your Cockatrice version. +Local version is %1, remote version is %2. + + + + + + Success + + + + + Registration accepted. +Will now login. + + + + + Account activation accepted. +Will now login. + + + + + Information + + + + + This server supports additional features that your client doesn't have. +This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. + +To update your client, go to Help -> Check for Updates. + + + + + + + Reset Password + + + + + Your password has been reset successfully, you can now log in using the new credentials. + + + + + Failed to reset user account password, please contact the server operator to reset your password. + + + + + Activation request received, please check your email for an activation token. + + + + + Connecting to %1... + + + + + Registering to %1 as %2... + + + + + Disconnected + + + + + Connected, logging in at %1 + + + + + Requesting forgotten password to %1 as %2... + + + + + Your username must respect these rules: + + + + + is %1 - %2 characters long + + + + + can %1 contain lowercase characters + + + + + + + + NOT + + + + + can %1 contain uppercase characters + + + + + can %1 contain numeric characters + + + + + can contain the following punctuation: %1 + + + + + first character can %1 be a punctuation mark + + + + + no unacceptable language as specified by these server rules: + note that the following lines will not be translated + + + + + can not contain any of the following words: %1 + + + + + can not match any of the following expressions: %1 + + + + + You may only use A-Z, a-z, 0-9, _, ., and - in your username. + + + CustomZoneMenu - + C&ustom Zones - - + + View custom zone '%1' @@ -974,30 +1516,35 @@ This is only saved for moderators and cannot be seen by the banned person. DeckAnalyticsWidget - + Add Panel - + Remove Panel - + Save Layout - + Load Layout + + + Include Sideboard + + DeckEditorCardDatabaseDockWidget - + Card Database @@ -1013,47 +1560,17 @@ This is only saved for moderators and cannot be seen by the banned person. DeckEditorDatabaseDisplayWidget - + Search by card name (or search expressions) - - Add to Deck - - - - - Add to Sideboard - - - - - Select Printing - - - - - Show on EDHRec (Commander) - - - - - Show on EDHRec (Card) - - - - - Show Related cards - - - - + Add card to &maindeck - + Add card to &sideboard @@ -1086,72 +1603,72 @@ This is only saved for moderators and cannot be seen by the banned person. - + Select Printing - + Deck - + Deck &name: - + Banner Card/Tags Visibility Settings - + Show banner card selection menu - + Show tags selection menu - + &Comments: - + Group by: - + Format: - + Hash: - + &Increment number - + &Decrement number - + &Remove row - + Swap card to/from sideboard @@ -1159,17 +1676,17 @@ This is only saved for moderators and cannot be seen by the banned person. DeckEditorFilterDockWidget - + Filters - + &Clear all filters - + Delete selected @@ -1300,169 +1817,113 @@ This is only saved for moderators and cannot be seen by the banned person. DeckEditorSettingsPage - - + + Update Spoilers - - + Success Επιτυχία - + Download URLs have been reset. - - Downloaded card pictures have been reset. - - - - - Error - Σφάλμα - - - - One or more downloaded card pictures could not be cleared. - - - - + Add URL - - + + URL: - - + + Edit URL - - Network Cache Size: - - - - - Redirect Cache TTL: - - - - - How long cached redirects for urls are valid for. - - - - - Picture Cache Size: - - - - + Add New URL - + Remove URL - - Day(s) - - - - + Updating... - + Choose path - + URL Download Priority - + Spoilers - + Download Spoilers Automatically - + Spoiler Location: - + Last Change - + Spoilers download automatically on launch - + Press the button to manually update without relaunching - + Do not close settings until manual update is complete - + Download card pictures on the fly - + How to add a custom URL - - Delete Downloaded Images - - - - + Reset Download URLs - - - On-disk cache for downloaded pictures - - - - - In-memory cache for pictures not currently on screen - - DeckListHistoryManagerWidget @@ -1528,12 +1989,12 @@ This is only saved for moderators and cannot be seen by the banned person. DeckLoader - + Common deck formats (%1) - + All files (*.*) @@ -1755,27 +2216,27 @@ This is only saved for moderators and cannot be seen by the banned person. - + Moved to %1 1 × "%2" (%3) - + Removed "%1" (all copies) - + %1 1 × "%2" (%3) - + Added - + Removed @@ -1797,74 +2258,74 @@ This is only saved for moderators and cannot be seen by the banned person. DeckViewContainer - + Load deck... Φόρτωση deck... - + Load remote deck... Φόρτωση απομακρυσμένου deck... - + Load from clipboard... - + Load from website... - + Unload deck - + Ready to start - + Force start - + Sideboard unlocked - + Sideboard locked - - + + Error Σφάλμα - + The selected file could not be loaded. Δεν ήταν δυνατή η φόρτωση του επιλεγμένου αρχείου. - + Deck is greater than maximum file size. - + Are you sure you want to force start? This will kick all non-ready players from the game. - + Cockatrice @@ -1963,7 +2424,7 @@ This will kick all non-ready players from the game. - + Webpage @@ -2003,37 +2464,37 @@ This will kick all non-ready players from the game. - + Server URL - + Communication Port - + Unique Server Name - + Connection Warning Προειδοποίηση σύνδεσης - + You need to name your new connection profile. Πρέπει να ονομάσετε το νέο προφίλ σύνδεσης. - + Connect Warning Προειδοποίηση σύνδεσης - + The player name can't be empty. Το όνομα παίκτη δεν μπορεί να είναι άδειο. @@ -2146,17 +2607,17 @@ This will kick all non-ready players from the game. Δημιουργία παιχνιδιού - + Game information Πληροφορίες παιχνιδιού - + Error Σφάλμα - + Server error. Σφάλμα Διακομιστή. @@ -2164,97 +2625,97 @@ This will kick all non-ready players from the game. DlgCreateToken - + &Name: &Όνομα: - + Token Token - + C&olor: Χ&ρώμα: - + white άσπρο - + blue μπλε - + black μαύρο - + red κόκκινο - + green πράσινο - + multicolor πολύχρωμο - + colorless άχρωμο - + &P/T: &P/T: - + &Annotation: &Σχόλιο: - + &Destroy token when it leaves the table &κατέστρεψε το token όταν φύγει από το τραπέζι - + Create face-down (Only hides name) - + Token data Στοιχεία token - + Show &all tokens Εμφάνισε &όλα τα tokens - + Show tokens from this &deck Εμφάνιση tokens από αυτό το &deck - + Choose token from list Επέλεξε token από τη λίστα - + Create token Δημιουργία token @@ -2288,7 +2749,7 @@ This will kick all non-ready players from the game. - + @@ -2303,12 +2764,12 @@ This will kick all non-ready players from the game. - + Duplicate Tag - + This tag already exists. @@ -2495,12 +2956,12 @@ To remove your current avatar, confirm without choosing a new image. Παρακαλώ εισάγετε το όνομα του token: - + Error Σφάλμα - + The chosen name conflicts with an existing card or token. Make sure to enable the 'Token' set in the "Manage sets" dialog to display them correctly. @@ -2524,12 +2985,12 @@ Make sure to enable the 'Token' set in the "Manage sets" dia Απροσδιόριστο - + Real name: Πραγματικό όνομα: - + Edit user profile Επεξεργασία προφίλ χρήστη @@ -2567,113 +3028,113 @@ Make sure to enable the 'Token' set in the "Manage sets" dia - + Hide 'buddies only' games - + Hide full games - + Hide games that have started - + Hide password protected games - + Hide 'ignored user' games - + Hide games not created by buddies Hide games not created by buddy - + Hide games with forced open decklists - + &Newer than: - + Game &description: Παιχνίδι και &περιγραφή: - + &Creator name: &Όνομα δημιουργού: - + General - + &Game types &Είδη παιχνιδιού - + at &least: το &λιγότερο: - + at &most: το &περισσότερο: - + Maximum player count Μέγιστος αριθμός παικτών - + Restrictions Περιορισμοί - + Show games only if &spectators can watch - + Show spectator password p&rotected games - + Show only if spectators can ch&at - + Show only if spectators can see &hands - + Spectators - + Filter games Φίλτρο παιχνιδιών @@ -2976,37 +3437,37 @@ https://tappedout.net/mtg-decks/your-deck-name/ DlgMoveTopCardsUntil - + Card name (or search expressions): - + Number of hits: - + Auto play hits - + Put top cards on stack until... - + No cards matching the search expression exists in the card database. Proceed anyways? - + Cockatrice - + Invalid filter @@ -3065,40 +3526,40 @@ Your email will be used to verify your account. Απροσδιόριστο - + Real name: Πραγματικό όνομα: - + Register to server Εγγραφή σε διακομιστή - - - - + + + + Registration Warning Προειδοποίηση Εγγραφής - + Your password is too short. Ο κωδικός σας είναι πολύ μικρός. - + Your passwords do not match, please try again. Οι κωδικοί δεν ταιριάζουν, παρακαλώ προσπαθήστε ξανά - + Your email addresses do not match, please try again. - + The player name can't be empty. Το όνομα παίκτη δεν μπορεί να είναι άδειο. @@ -3106,17 +3567,17 @@ Your email will be used to verify your account. DlgRollDice - + Number of sides: - + Number of dice: - + Roll Dice @@ -3167,12 +3628,12 @@ Your email will be used to verify your account. DlgSettings - + Unknown Error loading card database - + Your card database is invalid. Cockatrice may not function correctly with an invalid database @@ -3183,7 +3644,7 @@ Would you like to change your database location setting? - + Your card database version is too old. This can cause problems loading card information or images @@ -3200,7 +3661,7 @@ Would you like to change your database location setting? Θα θέλατε να αλλάξετε τη ρυθμισμένη θέση της βάσης δεδομένων σας; - + Your card database did not finish loading Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues with your cards.xml attached @@ -3209,21 +3670,21 @@ Would you like to change your database location setting? - + File Error loading your card database. Would you like to change your database location setting? - + Your card database was loaded but contains no cards. Would you like to change your database location setting? - + Unknown card database load status Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues @@ -3232,59 +3693,64 @@ Would you like to change your database location setting? - - - + + + Error Σφάλμα - + The path to your deck directory is invalid. Would you like to go back and set the correct path? - + The path to your card pictures directory is invalid. Would you like to go back and set the correct path? - + Settings Ρυθμίσεις - + General Γενικά - + Appearance Εμφάνιση - + User Interface Διεπαφή Χρήστη - + + Storage + + + + Card Sources - + Chat - + Sound Ήχος - + Shortcuts Συντομεύσεις @@ -3376,9 +3842,9 @@ You can always change this behavior in the 'General' settings tab. - - - + + + Error Σφάλμα @@ -3430,31 +3896,31 @@ Please visit the download page to update manually. - + Update Available Υπάρχει Διαθέσιμη Ενημέρωση - + A new version of Cockatrice is available! Μία νέα έκδοση του Cockatrice είναι διαθέσιμη! - + New version Νέα έκδοση - + Released - + Changelog Αρχείο Αλλαγών @@ -3464,50 +3930,50 @@ Please visit the download page to update manually. Θέλετε να ενημερώσετε τώρα; - + Unfortunately, the automatic updater failed to find a compatible download. You may have to manually download the new version. - + Please check the <a href="%1">releases page</a> on our Github and download the build for your system. - - - + + + Update Error - + An error occurred while checking for updates: - + An error occurred while downloading an update: - + Installing... - + Cockatrice is unable to open the installer. - + Try to update manually by closing Cockatrice and running the installer. - + Download location @@ -3597,67 +4063,67 @@ You may have to manually download the new version. DrawProbabilityWidget - + Draw Probability - + Probability of drawing - + Card Name - + Type - + Subtype - + Mana Value - + At least - + Exactly - + card(s) having drawn at least - + cards - + Category - + Qty - + Odds (%) @@ -3771,7 +4237,7 @@ You may have to manually download the new version. FilterBuilder - + Type your filter here @@ -3802,22 +4268,22 @@ You may have to manually download the new version. GameEventHandler - + kicked by game host or moderator - + player left the game - + player disconnected from server - + reason unknown @@ -3825,140 +4291,140 @@ You may have to manually download the new version. GameSelector - - - - - - - - - + + + + + + + + + Error Σφάλμα - + Please join the appropriate room first. - + Wrong password. Λάθος κωδικός. - + Spectators are not allowed in this game. - + The game is already full. - + The game does not exist any more. - + This game is only open to registered users. - + This game is only open to its creator's buddies. - + You are being ignored by the creator of this game. - + Join Game - + Spectate Game - + Game Information - + Join Game as Judge - + Spectate Game as Judge - + Join game - + Password: Κωδικός: - + Please join the respective room first. - + &Filter games - + C&lear filter Καθάρισμα Φίλτρου - + C&reate Δημιουργία - + &Join Είσοδος - + Join as judge - + J&oin as spectator Είσοδος ως θεατής - + Join as judge spectator - + Games shown: %1 / %2 - + Games @@ -3966,32 +4432,32 @@ You may have to manually download the new version. GameSelectorQuickFilterToolBar - + All types - + Filter by game name... - + Filter by game type/format - + Hide games not created by buddies - + Hide full games - + Hide started games @@ -3999,105 +4465,105 @@ You may have to manually download the new version. GamesModel - + >1 day - + %1%2 hr short age in hours - + new - + %1%2 min short age in minutes - + password κωδικός - + buddies only μόνο φίλοι - + reg. users only μόνο εγγεγραμμένοι χρήστες - + open decklists - + can chat μπορούν να συνομιλήσουν - + see hands βλέπουν τα χέρια - + can see hands μπορούν να δούν τα χέρια - + not allowed δεν επιτρέπεται - + Room Δωμάτιο - + Age Ηλικία - + Description Περιγραφή - + Creator Δημιουργός - + Type Τύπος - + Restrictions Περιορισμοί - + Players Παίκτες - + Spectators Θεατές @@ -4105,143 +4571,158 @@ You may have to manually download the new version. GeneralSettingsPage - + Reset all paths - + All paths have been reset - - - - - - - + + + + + + + Choose path Επιλέξτε θέση - - Personal settings + + Language settings - + Language: Γλώσσα: - + + Version settings + + + + + Card database + + + + + Startup settings + + + + Paths (editing disabled in portable mode) - + Paths - + How to help with translations - + Decks directory: Φάκελος deck: - + Filters directory: - + Replays directory: Φάκελος replay: - + Pictures directory: Φάκελος εικόνων: - + Card database: Βάση δεδομένων καρτών - + Custom database directory: - + Token database: Βάση δεδομένων token: - + Update channel - + Check for client updates on startup - + Check for card database updates on startup - + Don't check - + Prompt for update - + Always update in the background - + Check for card database updates every - + days - + Notify if a feature supported by the server is missing in my client - + Automatically run Oracle when running a new version of Cockatrice - + Show tips on startup - + Last update check on %1 (%2 days ago) @@ -4249,47 +4730,47 @@ You may have to manually download the new version. GraveyardMenu - + &Graveyard - + &View graveyard - + &Move graveyard to... - + &Top of library - + &Bottom of library - + &All players - + &Hand - + &Exile - + Reveal random card to... @@ -4297,88 +4778,88 @@ You may have to manually download the new version. HandMenu - + &Hand - + &View hand - + Sort hand by... - + Name - + Type - + Mana Value - + Take &mulligan (Choose hand size) - + Take mulligan (Same hand size) - + Take mulligan (Hand size - 1) - + &Move hand to... - + &Top of library - + &Bottom of library - + &Graveyard - + &Exile - + &Reveal hand to... - - + + All players - + Reveal r&andom card to... @@ -4386,52 +4867,52 @@ You may have to manually download the new version. HomeWidget - + Create New Deck - + Browse Decks - + Browse Card Database - + Browse EDHRec - + Browse Archidekt - + View Replays - + Quit - + Connecting... - + Connect - + Play @@ -4439,213 +4920,213 @@ You may have to manually download the new version. LibraryMenu - + &Library - + &View library - + View &top cards of library... - + View bottom cards of library... - + Reveal &library to... - + Lend library to... - + Reveal &top cards to... - + &Top of library... - + &Bottom of library... - + &Always reveal top card - + &Always look at top card - + &Open deck in deck editor - + &Draw card - + D&raw cards... - + &Undo last draw - + Shuffle - + &Play top card - + Play top card &face down - + Put top card on &bottom - + Move top card to grave&yard - + Move top card to e&xile - + Move top cards to &graveyard... - + Move top cards to graveyard face down... - + Move top cards to &exile... - + Move top cards to exile face down... - + Put top cards on stack &until... - + Shuffle top cards... - + &Draw bottom card - + D&raw bottom cards... - + &Play bottom card - + Play bottom card &face down - + Move bottom card to grave&yard - + Move bottom card to e&xile - + Move bottom cards to &graveyard... - + Move bottom cards to graveyard face down... - + Move bottom cards to &exile... - + Move bottom cards to exile face down... - + Put bottom card on &top - + Shuffle bottom cards... - - + + &All players - + Reveal top cards of library - + Number of cards: (max. %1) @@ -4653,831 +5134,453 @@ You may have to manually download the new version. MainWindow - - - The server has reached its maximum user capacity, please check back later. - - - - - There are too many concurrent connections from your address. - - - - - Banned by moderator - - - - - Expected end time: %1 - - - - - This ban lasts indefinitely. - - - - - Scheduled server shutdown. - - - - - - Invalid username. - - - - - You have been logged out due to logging in at another location. - - - - - Connection closed - - - - - The server has terminated your connection. -Reason: %1 - - - - - The server is going to be restarted in %n minute(s). -All running games will be lost. -Reason for shutdown: %1 - - - - - Scheduled server shutdown - - - - - - Success - - - - - Registration accepted. -Will now login. - - - - - Account activation accepted. -Will now login. - - - - - + + Player %1 - + Load replay - + About Cockatrice - + Version Έκδοση - + Cockatrice Webpage - + Project Manager: - + Past Project Managers: - + Developers: - + Our Developers - + Help Develop! - + Translators: - + Our Translators Οι μεταφραστές μας - + Help Translate! - + Support: Υποστήριξη: - + Report an Issue Αναφορά Προβλήματος - + Troubleshooting Αντιμετώπιση Προβλημάτων - + F.A.Q. Συχνές Ερωτήσεις - - - - - - - - - - - - - - - - - - - - + + Error Σφάλμα - - Server timeout - - - - - Failed Login - Αποτυχημένη Σύνδεση - - - - Your client seems to be missing features this server requires for connection. - - - - - To update your client, go to 'Help -> Check for Client Updates'. - - - - - Incorrect username or password. Please check your authentication information and try again. - - - - - There is already an active session using this user name. -Please close that session first and re-login. - - - - - - You are banned until %1. - Είστε αποκλεισμένος μέχρι %1. - - - - - You are banned indefinitely. - Είστε αποκλεισμένος επ' αόριστον. - - - - This server requires user registration. Do you want to register now? - Ο σέρβερ απαιτεί εγγραφή. Θέλετε να εγγραφείτε τώρα; - - - - This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. -Please close and reopen your client to try again. - - - - - An internal error has occurred, please close and reopen Cockatrice before trying again. -If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. - - - - - Account activation - Ενεργοποίηση λογαριασμού - - - - Your account has not been activated yet. -You need to provide the activation token received in the activation email. - - - - - Server Full - Σέρβερ Πλήρης - - - - Unknown login error: %1 - Άγνωστο σφάλμα σύνδεσης: %1 - - - - - -This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. - - - - - Your username must respect these rules: - - - - - is %1 - %2 characters long - - - - - can %1 contain lowercase characters - - - - - - - - NOT - - - - - can %1 contain uppercase characters - - - - - can %1 contain numeric characters - - - - - can contain the following punctuation: %1 - - - - - first character can %1 be a punctuation mark - - - - - no unacceptable language as specified by these server rules: - note that the following lines will not be translated - - - - - can not contain any of the following words: %1 - - - - - can not match any of the following expressions: %1 - - - - - You may only use A-Z, a-z, 0-9, _, ., and - in your username. - - - - - - - - - - Registration denied - - - - - Registration is currently disabled on this server - - - - - There is already an existing account with the same user name. - - - - - It's mandatory to specify a valid email address when registering. - - - - - It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. - - - - - Password too short. - Ο κωδικός είναι πολύ μικρός. - - - - Registration failed for a technical problem on the server. - - - - - The connection to the server has been lost. - - - - - Unknown registration error: %1 - - - - - Account activation failed - - - - - Socket error: %1 - - - - - You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. -Local version is %1, remote version is %2. - - - - - Your Cockatrice client is obsolete. Please update your Cockatrice version. -Local version is %1, remote version is %2. - - - - - Connecting to %1... - - - - - Registering to %1 as %2... - - - - - Disconnected - - - - - Connected, logging in at %1 - - - - - - - Requesting forgotten password to %1 as %2... - - - - + &Connect... Σύνδεση... - + &Disconnect Αποσύνδεση - + Start &local game... Έναρξη τοπικού παιχνιδιού... - + &Watch replay... - + &Full screen Πλήρης οθόνη - + &Register to server... Εγγραφή σε σέρβερ... - + &Restore password... - + &Settings... Ρυθμίσεις... - + &Exit Έξοδος - + A&ctions - + &Cockatrice &Cockatrice - + C&ard Database Βάση δεδομένων καρτών - + &Manage sets... - + Edit custom &tokens... - + Open custom image folder - + Open custom sets folder - + Add custom sets/cards - + Reload card database - + Tabs - + &Help Βοήθεια - + &About Cockatrice Σχετικά με το Cockatrice - + &Tip of the Day - + Check for Client Updates Έλεγχος για ενημερώσεις - + Check for Card Updates... - + Check for Card Updates (Automatic) - + Show Status Bar - + View &Debug Log - + Open Settings Folder - + Show/Hide - + New Version - + Congratulations on updating to Cockatrice %1! Oracle will now launch to update your card database. - + Cockatrice installed - + Congratulations on installing Cockatrice %1! Oracle will now launch to install the initial card database. - + Card database Βάση δεδομένων καρτών - + Cockatrice is unable to load the card database. Do you want to update your card database now? If unsure or first time user, choose "Yes" - - + + Yes Ναι - - + + No Όχι - + Open settings Άνοιγμα ρυθμίσεων - + New sets found Βρέθηκαν νέα σετ - + %n new set(s) found in the card database Set code(s): %1 Do you want to enable it/them? - + + Yes, always enable + + + + View sets Εμφάνιση σετ - + Welcome Καλωσήρθατε - + Hi! It seems like you're running this version of Cockatrice for the first time. All the sets in the card database have been enabled. Read more about changing the set order or disabling specific sets and consequent effects in the "Manage Sets" dialog. - - + Information Πληροφορίες - + A card database update is already running. - + Unable to run the card database updater: - + Card database update running. - + Failed to start. The file might be missing, or permissions might be incorrect. - + The process crashed some time after starting successfully. - + Timed out. The process took too long to respond. The last waitFor...() function timed out. - + An error occurred when attempting to write to the process. For example, the process may not be running, or it may have closed its input channel. - + An error occurred when attempting to read from the process. For example, the process may not be running. - + Unknown error occurred. - + The card database updater exited with an error: %1 - - This server supports additional features that your client doesn't have. -This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. - -To update your client, go to Help -> Check for Updates. - - - - - - - - + + + + + Load sets/cards Φόρτωση σετ/καρτών - + Selected file cannot be found. Το επιλεγμένο αρχείο δε βρέθηκε. - + You can only import XML databases at this time. - + The new sets/cards have been added successfully. Cockatrice will now reload the card database. - + Sets/cards failed to import. - - - - - Reset Password - - - - - Your password has been reset successfully, you can now log in using the new credentials. - - - - - Failed to reset user account password, please contact the server operator to reset your password. - - - - - Activation request received, please check your email for an activation token. - - ManaBaseConfigDialog - + Mana Base Configuration - + Display type: - + pie - + bar - + combinedBar - + Filter Colors (optional): - + OK - + Cancel @@ -5549,27 +5652,27 @@ Cockatrice will now reload the card database. ManaDevotionConfigDialog - + Display type: - + pie - + bar - + combinedBar - + Filter Colors (optional): @@ -5639,610 +5742,615 @@ Cockatrice will now reload the card database. MessageLogWidget - + from play από το παιχνίδι - + from their graveyard - + from exile από την εξορία - + from their hand - + the top card of %1's library την πρώτη κάρτα της βιβλιοθήκης του/της %1 - + the top card of their library - + from the top of %1's library από την κορυφή της βιβλιοθήκης του/της %1 - + from the top of their library - + the bottom card of %1's library την τελευταία κάρτα της βιβλιοθήκης του/της %1 - + the bottom card of their library - + from the bottom of %1's library από το τέλος της βιβλιοθήκης του/της %1 - + from the bottom of their library - + from %1's library από τη βιβλιοθήκη του/της %1 - + from their library - + from sideboard από το sideboard - + from the stack από το stack - + from custom zone '%1' - + %1 is now keeping the top card %2 revealed. - + %1 is not revealing the top card %2 any longer. - + %1 can now look at top card %2 at any time. - + %1 no longer can look at top card %2 at any time. - + %1 attaches %2 to %3's %4. - + %1 has conceded the game. - + %1 has unconceded the game. - + %1 has restored connection to the game. - + %1 has lost connection to the game. - + %1 points from their %2 to themselves. - + %1 points from their %2 to %3. - + %1 points from %2's %3 to themselves. - + %1 points from %2's %3 to %4. - + %1 points from their %2 to their %3. - + %1 points from their %2 to %3's %4. - + %1 points from %2's %3 to their own %4. - + %1 points from %2's %3 to %4's %5. - + %1 creates a face down token. - + %1 creates token: %2%3. - + %1 has loaded a deck (%2). Ο/Η %1 φόρτωσε ένα deck (%2). - + %1 has loaded a deck with %2 sideboard cards (%3). Ο/Η %1 φόρτωσε ένα deck με %2 κάτρες στο sideboard (%3). - + %1 destroys %2. - + a card μία κάρτα - + %1 gives %2 control over %3. ο/η %1 δίνει στον/στην %2 τον έλεγχο του %3 . - + %1 puts %2 into play%3 face down. - + %1 puts %2 into play%3. ο/η %1 βάζει στο παιχνίδι το %2%3. - + %1 puts %2%3 into their graveyard face down. - + %1 puts %2%3 into their graveyard. - + %1 exiles %2%3 face down. - + %1 exiles %2%3. ο/η %1 εξορίζει το %2%3. - + %1 moves %2%3 to their hand. - + %1 puts %2%3 into their library. - + %1 puts %2%3 onto the bottom of their library. - + %1 puts %2%3 on top of their library. - + %1 puts %2%3 into their library %4 cards from the top. - + %1 moves %2%3 to sideboard. - + %1 plays %2%3 face down. - + %1 plays %2%3. - + %1 moves %2%3 to custom zone '%4' face down. - + %1 moves %2%3 to custom zone '%4'. - + %1 tries to draw from an empty library - + %1 draws %2 card(s). - + %1 is looking at %2. - + %1 is looking at the %4 %3 card(s) %2. top card for singular, top %3 cards for plural - + bottom - + top - + %1 turns %2 face-down. - + %1 turns %2 face-up. - + The game has been closed. Το παιχνίδι έχει κλείσει - + The game has started. Το παιχνίδι ξεκίνησε. - + You are flooding the game. Please wait a couple of seconds. - + %1 has joined the game. - + %1 is now watching the game. Ο/Η %1 παρακολουθεί το παιχνίδι. - + You have been kicked out of the game. Σας απέβαλαν από το παιχνίδι. - + %1 has left the game (%2). - + %1 is not watching the game any more (%2). - + %1 is not ready to start the game any more. - + %1 shuffles their deck and draws a new hand of %2 card(s). - + %1 shuffles their deck and draws a new hand. - + You are watching a replay of game #%1. - + %1 is ready to start the game. - + cards an unknown amount of cards - + %1 card(s) a card for singular, %1 cards for plural - + %1 lends %2 to %3. - + %1 reveals %2 to %3. - + %1 reveals %2. - + %1 randomly reveals %2%3 to %4. - + %1 randomly reveals %2%3. - + %1 peeks at face down card #%2. - + %1 peeks at face down card #%2: %3. - + %1 reveals %2%3 to %4. - + %1 reveals %2%3. - + %1 reversed turn order, now it's %2. - + reversed - + normal - + Heads - + Tails - + %1 flipped a coin. It landed as %2. - + %1 rolls a %2 with a %3-sided die. - + %1 flips %2 coins. There are %3 heads and %4 tails. - + %1 rolls a %2-sided dice %3 times: %4. - + %1's turn. - + %1 sets annotation of %2 to %3. - - %1 places %2 "%3" counter(s) on %4 (now %5). + + %1 places %2 %3%4 counter(s) on %5 (now %6). - - %1 removes %2 "%3" counter(s) from %4 (now %5). + + %1 removes %2 %3%4 counter(s) from %5 (now %6). - + + %1 failed to undo their last draw. + + + + %1 sets counter %2 to %3 (%4%5). - + %1 sets %2 to not untap normally. - + %1 sets %2 to untap normally. - + %1 removes the PT of %2. - + %1 changes the PT of %2 from nothing to %4. - + %1 changes the PT of %2 from %3 to %4. - + %1 has locked their sideboard. - + %1 has unlocked their sideboard. - + %1 taps their permanents. - + %1 untaps their permanents. - + %1 taps %2. - + %1 untaps %2. - + %1 shuffles %2. - + %1 shuffles the bottom %3 cards of %2. - + %1 shuffles the top %3 cards of %2. - + %1 shuffles cards %3 - %4 of %2. - + %1 unattaches %2. - + %1 undoes their last draw. - + %1 undoes their last draw (%2). @@ -6250,110 +6358,115 @@ Cockatrice will now reload the card database. MessagesSettingsPage - + Word1 Word2 Word3 - + Add New Message - + Edit Message - + Remove Message - + Add message - - + + Message: - + Edit message - + Chat settings - + Custom alert words - + Enable chat mentions - + Enable mention completer - + In-game message macros - + How to use in-game message macros - + Ignore chat room messages sent by unregistered users - + Ignore private messages sent by unregistered users - - + + Ignore private messages sent by non-buddy users + + + + + Invert text color - + Enable desktop notifications for private messages - + Enable desktop notification for mentions - + Enable room message history on join - - + + (Color is hexadecimal) - + Separate words with a space, alphanumeric characters only @@ -6361,42 +6474,42 @@ Cockatrice will now reload the card database. MoveMenu - + Move to - + &Top of library in random order - + X cards from the top of library... - + &Bottom of library in random order - + T&able - + &Hand - + &Graveyard - + &Exile @@ -6409,47 +6522,47 @@ Cockatrice will now reload the card database. - + Mana Value - + Color(s) - + Loyalty - + Main Card Type - + Mana Cost - + P/T - + Side - + Layout - + Color Identity @@ -6472,6 +6585,152 @@ Cockatrice will now reload the card database. + + PaletteEditorDialog + + + + Reset + + + + + + Apply + + + + + + Save && Apply + + + + + ▼ Edit Palette + + + + + + ▶ Edit Palette + + + + + Palette Editor — %1 + + + + + <b>Palette Editor</b> &nbsp;·&nbsp; %1 + + + + + This theme ships no default palette files + + + + + Replace current colours with the theme author's defaults + + + + + Switch between the light and dark palette files + + + + + Editing: + + + + + Show or hide the per-role colour grid for manual tweaks + + + + + ↺ Revert to theme default + + + + + Discard unsaved edits and restore the last saved palette + + + + + Preview this palette without saving to disk + + + + + Write palette-%1.toml and reload the theme + + + + + Cannot save: this theme has no directory on disk + + + + + Save failed + + + + + Could not write %1 to: +%2 + + + + + No default found + + + + + No default palette file found for the "%1" scheme. + + + + + PaletteGridWidget + + + Active + + + + + Disabled + + + + + Inactive + + + + + Normal interactive state + + + + + Widget is disabled / not interactive + + + + + Window is in background / unfocused + + + Phase @@ -6538,57 +6797,57 @@ Cockatrice will now reload the card database. PhasesToolbar - + Untap step - + Upkeep step - + Draw step - + First main phase - + Beginning of combat step - + Declare attackers step - + Declare blockers step - + Combat damage step - + End of combat step - + Second main phase - + End of turn step @@ -6596,7 +6855,7 @@ Cockatrice will now reload the card database. PictureLoader - + en code for scryfall's language property, not available for all languages @@ -6605,151 +6864,142 @@ Cockatrice will now reload the card database. PlayerActions - - View top cards of library - - - - - - - - - - - - - Number of cards: (max. %1) - - - - - View bottom cards of library - - - - - Shuffle top cards of library - - - - - Shuffle bottom cards of library - - - - - Draw hand - - - - - 0 and lower are in comparison to current hand size - - - - - Draw cards - - - - - - - + + + + grave - - - - + + + + exile + + + PlayerDialogs - + + View top cards of library + + + + + + + + + + + + + Number of cards: (max. %1) + + + + + View bottom cards of library + + + + + Shuffle top cards of library + + + + + Shuffle bottom cards of library + + + + + Draw hand + + + + + 0 and lower are in comparison to current hand size + + + + + Draw cards + + + + Move top cards to %1 - + Move bottom cards to %1 - + Draw bottom cards - - - C&reate another %1 token - - - - + Create tokens - - + Number: - + Place card X cards from top of library - + Which position should this card be placed: - + (max. %1) - + Change power/toughness - + Change stats to: - + Set annotation - + Please enter the new annotation: - - - Set counters - - PlayerMenu - + Player "%1" - + &Counters @@ -6779,7 +7029,7 @@ This setting means you'll only see the default printing for each card, instead o - + Printing Selector @@ -6787,22 +7037,22 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelectorCardOverlayWidget - + Preference - + Pin Printing - + Unpin Printing - + Show Related cards @@ -6873,57 +7123,57 @@ This setting means you'll only see the default printing for each card, instead o PtMenu - + Power / toughness - + &Increase power - + &Decrease power - + I&ncrease toughness - + D&ecrease toughness - + In&crease power and toughness - + Dec&rease power and toughness - + Increase power and decrease toughness - + Decrease power and increase toughness - + Set &power and toughness... - + Reset p&ower and toughness @@ -6931,37 +7181,37 @@ This setting means you'll only see the default printing for each card, instead o QMenuBar - + Services - + Hide %1 - + Hide Others - + Show All - + Preferences... - + Quit %1 - + About %1 @@ -6969,17 +7219,17 @@ This setting means you'll only see the default printing for each card, instead o QObject - + Cockatrice card database (*.xml) - + All files (*.*) - + Cockatrice replays (*.cor) @@ -7039,110 +7289,177 @@ Are you sure you would like to disable this feature? QPlatformTheme - + OK - + Save - + Save All - + Open - + &Yes - + Yes to &All - + &No - + N&o to All - + Abort - + Retry - + Ignore - + Close - + Cancel - + Discard - + Help - + Apply - + Reset - + Restore Defaults + + QuickSetupPanel + + + %1% + + + + + <b>Quick Setup</b> + + + + + Generate all palette roles automatically from a single accent colour + + + + + Accent: + + + + + Primary hue. Used directly for highlights and links. +At high intensity it also tints buttons and backgrounds. + + + + + Intensity: + + + + + Subtle + + + + + Full colour + + + + + 0–30 Subtle tint — only highlights and links change hue +30–70 Accented — buttons, tooltips, and borders join in +70–100 Full colour — backgrounds, everything + + + + + 70% + + + + + Generate ↓ + + + + + Derive all palette roles from the accent colour above. +Fine-tune individual colours in the grid afterwards. + + + RemoteDeckList_TreeModel - + Name - + ID - + Upload time @@ -7150,32 +7467,32 @@ Are you sure you would like to disable this feature? RemoteReplayList_TreeModel - + ID - + Name - + Players - + Keep - + Time started - + Duration (sec) @@ -7183,37 +7500,37 @@ Are you sure you would like to disable this feature? RfgMenu - + &Exile - + &View exile - + &Move exile to... - + &Top of library - + &Bottom of library - + &Hand - + &Graveyard @@ -7259,7 +7576,7 @@ Are you sure you would like to disable this feature? SayMenu - + S&ay @@ -7300,27 +7617,27 @@ Are you sure you would like to disable this feature? SetsModel - + Enabled - + Set type - + Set code - + Long name - + Release date @@ -7328,53 +7645,53 @@ Are you sure you would like to disable this feature? ShortcutSettingsPage - - + + Restore all default shortcuts - + Do you really want to restore all default shortcuts? - + Clear all default shortcuts - + Do you really want to clear all shortcuts? - + Section: - + Action: - + Shortcut: - + How to set custom shortcuts - + Clear all shortcuts - + Search by shortcut name @@ -7428,12 +7745,12 @@ Please check your shortcut settings! SideboardMenu - + &Sideboard - + &View sideboard @@ -7441,27 +7758,27 @@ Please check your shortcut settings! SoundSettingsPage - + Enable &sounds - + Current sounds theme: - + Test system sound engine - + Sound settings - + Master volume @@ -7533,16 +7850,146 @@ Please check your shortcut settings! - + No reply received from the tag update server. - + Invalid reply received from the tag update server. + + StorageSettingsPage + + + + + Success + + + + + Cached card pictures have been reset. + + + + + Downloaded card pictures have been reset. + + + + + Error + + + + + One or more downloaded card pictures could not be cleared. + + + + + In-memory (currently loaded) card pictures have been reset. + + + + + Card Picture Loader Caching Method: + + + + + The network cache is the preferred way of storing images. Downloaded images are stored here until the size of the cache exceeds the configured size. Cockatrice automatically monitors this cache and deletes the least recently seen card images to ensure the cache does not exceed the configured size. + + + + + Writing card images directly to a folder on your hard drive is another way of storing images. This does not change how Cockatrice accesses or downloads images. Cockatrice will NOT automatically monitor and clear this folder, so if you enable this option, it is up to you to ensure sufficient available space. It should also be noted that if a provider outage causes you to download the wrong picture (i.e. wrong printing) you will be stuck with it until you manually delete the file, as opposed to using the network cache, which automatically rotates and thus correct errors after a while. + + + + + This is the in-memory picture cache used by the application at runtime. It determines how much memory (RAM) Cockatrice can use before it has to fetch card images from the hard disk again. Increasing this will allow more card images to be displayed at once but shouldn't be necessary. Clearing this will make Cockatrice reload all images from the network cache or the disk. + + + + + Delete Cached Images + + + + + Delete Saved Images + + + + + Clear In-Memory Images + + + + + Card Picture Loader Cache Method + + + + + Network Cache + + + + + Filesystem + + + + + In-Memory Picture Cache + + + + + Network Cache Size: + + + + + On-disk cache for downloaded pictures + + + + + Redirect Cache TTL: + + + + + How long cached redirects for urls are valid for. + + + + + Picture Cache Size: + + + + + In-memory cache for pictures not currently on screen + + + + + Naming scheme: + + + + + Day(s) + + + TabAccount @@ -7678,117 +8125,117 @@ Please check your shortcut settings! TabArchidekt - - + + Desc. - - + + AND - - + + Require ALL selected colors - - + + Deck name... - - + + Owner... - - + + Packages - - + + Advanced Filters - + Bracket: - - + + Any - - + + Contains card... - - + + Commander... - - + + Tag... - - + + Deck Size - + Cards: - - + + Asc. - + Sort by: - + Filter by: - + Display Settings - - + + Search - - + + Formats @@ -7798,6 +8245,54 @@ Please check your shortcut settings! + + TabCardArtRules + + + Card: + + + + + ProviderId: + + + + + Mode: + + + + + Reason: + + + + + Type a card name... + + + + + Add rule + + + + + Remove rule + + + + + Refresh + + + + + Card Art Rules + + + TabDeckEditor @@ -7846,7 +8341,7 @@ Please check your shortcut settings! - + Deck: %1 @@ -7854,55 +8349,49 @@ Please check your shortcut settings! TabDeckEditorVisual - + Visual Deck: %1 - + &Visual Deck Editor - - + + Card Info - - + + Deck - - - Filters - - - - + &View - + Printing - + Visible - + Floating - + Reset layout @@ -7910,22 +8399,22 @@ Please check your shortcut settings! TabDeckEditorVisualTabWidget - + Visual Deck View - + Visual Database Display - + Deck Analytics - + Sample Hand @@ -7933,133 +8422,133 @@ Please check your shortcut settings! TabDeckStorage - + Local file system - + Server deck storage - - + + Open in deck editor - + Rename deck or folder - + Upload deck - + Download deck - - - + + + New folder - + Delete - + Open decks folder - + Rename local folder - + Rename local file - + New name: - - - - + + + + Error Σφάλμα - + Rename failed - - + + Invalid deck file - + Enter deck name - + This decklist does not have a name. Please enter a name: - + Unnamed deck - + Failed to upload deck to server - + Delete local file - + Are you sure you want to delete the selected files? - + Delete remote decks - + Are you sure you want to delete the selected decks? - - + + Name of new folder: @@ -8131,191 +8620,191 @@ Please enter a name: TabGame - - - + + + Replay - - + + Game - - + + Player List - - + + Card Info - - + + Messages - - + + Replay Timeline - + &Phases - + &Game - + Next &phase - + Next phase with &action - + Next &turn - + Reverse turn order - + &Remove all local arrows - + Rotate View Cl&ockwise - + Rotate View Co&unterclockwise - + Game &information - + Un&concede - - - + + + &Concede - + &Leave game - + C&lose replay - + &Focus Chat - + &Say: - + Selected cards - + &View - + Visible - + Floating - + Reset layout - + Concede - + Are you sure you want to concede this game? - + Unconcede - + You have already conceded. Do you want to return to this game? - + Leave game - + Are you sure you want to leave this game? - + A player has joined game #%1 - + %1 has joined the game - + You have been kicked out of the game. @@ -8374,114 +8863,114 @@ Please enter a name: - + Username: - + IP Address: - + Game Name: - + GameID: - + Message: - + Main Room - + Game Room - + Private Chat - + Past X Days: - + Today - + Last Hour - + Maximum Results: - + At least one filter is required. The more information you put in, the more specific your results will be. - + Get User Logs - + Clear Filters - + Filters - + Log Locations - + Date Range - + Maximum Results - - + + Message History - + Failed to collect message history information. - + There are no messages for the selected filters. @@ -8499,27 +8988,27 @@ The more information you put in, the more specific your results will be. - + %1 - Private chat - + This user is ignoring you, they cannot see your messages in main chat and you cannot join their games. - + Private message from - + %1 has left the server. - + %1 has joined the server. @@ -8527,180 +9016,180 @@ The more information you put in, the more specific your results will be. TabReplays - + Local file system - + Server replay storage - - + + Watch replay - + Rename - - + + New folder - - + + Delete - + Open replays folder - + Download replay - + Toggle expiration lock - + Get replay share code - - + + Look up replay by share code - + Rename local folder - + Rename local file - + New name: - + Error - + Rename failed - + Name of new folder: - + Delete local file - + Are you sure you want to delete the selected files? - + Are you sure you want to delete the selected replays? - + Failed to get code - - + + Either this server does not support replay sharing, or does not permit replay sharing for you. - - - + + + Failed - + Could not get replay code - + Replay Share Code - + Others can use this code to add the replay to their list of remote replays: %1 - + Copy to clipboard - + Replay share code - + Replay code found - + Replay was added, or you already had access to it. - + Replay code not found - + Failed to submit code - + Unexpected error - + Delete remote replay @@ -8713,47 +9202,62 @@ The more information you put in, the more specific your results will be. TabRoom - + + Friends + + + + + Online + + + + + Ignored + + + + &Say: - + Chat - + &Room - + &Leave room - + &Clear chat - + Chat Settings... - + mentioned you. - + Click to view - + You are flooding the chat. Please wait a couple of seconds. @@ -8766,30 +9270,30 @@ The more information you put in, the more specific your results will be. - - - - + + + + Error Σφάλμα - + Failed to join the server room: it doesn't exist on the server. - + The server thinks you are in the server room but your client is unable to display it. Try restarting your client. - + You do not have the required permission to join this server room. - + Failed to join the server room due to an unknown error: %1. @@ -8797,97 +9301,97 @@ The more information you put in, the more specific your results will be. TabSupervisor - + Deck Editor - + Visual Deck Editor - + EDHRec - + Archidekt - + Home - + &Visual Deck Storage - + Visual Database Display - + Server - + Account - + Deck Storage - + Game Replays - + Administration - + Logs - + Are you sure? - + There are still open games. Are you sure you want to quit? - + Click to view - + Your buddy %1 has signed on! - + Unknown Event - + The server has sent you a message that your client does not understand. This message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -8895,38 +9399,38 @@ To update your client, go to Help -> Check for Updates. - + Idle Timeout - + You are about to be logged out due to inactivity. - + Promotion - + You have been promoted. Please log out and back in for changes to take effect. - + Warned - + You have received a warning due to %1. Please refrain from engaging in this activity or further actions may be taken against you. If you have any questions, please private message a moderator. - + You have received the following message from the server. (custom messages like these could be untranslated) @@ -8935,12 +9439,12 @@ Please refrain from engaging in this activity or further actions may be taken ag TabVisualDatabaseDisplay - + Database Display - + Visual Database Display @@ -8977,42 +9481,42 @@ Please refrain from engaging in this activity or further actions may be taken ag TranslateCounterName - + Life - + White - + Blue - + Black - + Red - + Green - + Colorless - + Other @@ -9020,11 +9524,69 @@ Please refrain from engaging in this activity or further actions may be taken ag UpdateDownloader - + Could not open the file for reading. + + UserCardArtSettingsDialog + + + Card Art Settings + + + + + Type a card name... + + + + + Card name: + + + + + Card ProviderId: + + + + + Left margin (%): + + + + + Right margin (%): + + + + + Vertical offset: + + + + + Zoom: + + + + + Parameters + + + + + Preview + + + + + Remove Banner Card + + + UserContextMenu @@ -9142,7 +9704,7 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Ban History @@ -9157,77 +9719,87 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Failed to collect ban information. - - - + + + Warning History - + Warning Time;Moderator;User Name;Reason - + User has never been warned. - + Failed to collect warning information. - + Failed to get admin notes. - - + + Success - + Successfully promoted user. - + Successfully demoted user. - - - + + Kick Player + + + + + Are you sure you want to kick this player from the game? + + + + + + Failed - + Failed to promote user. - + Failed to demote user. - + Copy hash to clipboard - + Remove this user's messages @@ -9235,326 +9807,554 @@ Please refrain from engaging in this activity or further actions may be taken ag UserInfoBox - + Location: - + Account Age: - + Edit - + Change password - + Change avatar - + Administrator - + Moderator - + Registered user - - + + Unregistered user - + Judge - + Unknown - + The entered password does not match your account. - - - + + + + Information - + User information updated. - - - - - - - - - - + + + + + + + + + + + Error Σφάλμα - + User Information - + Real Name: - + User Level: + + + Edit Banner Card + + - + %n Year(s), amount of years (only shown if more than 0) - + %10%n Day(s) %20 amount of years (if more than 0), amount of days, date in local short format - + Enter Password Εισάγετε κωδικό - + Password verification is required in order to change your email address - - - + + + An error occurred while trying to update your user information. - - This server does not permit you to update your user informations. + + The selected card is blacklisted on this server or another error occurred. - - Password changed. + + Banner card removed. - - This server does not permit you to change your password. - - - - - The new password is too short. - - - - - The old password is incorrect. - - - - - Avatar updated. - - - - - This server does not permit you to update your avatar. + + Banner card updated. - An error occured while trying to updater your avatar. + This server does not permit you to update your user informations. + + + + + Password changed. + + + + + This server does not permit you to change your password. + + + + + The new password is too short. + + + + + The old password is incorrect. + + + + + Avatar updated. + + + + + This server does not permit you to update your avatar. + + + + + An error occured while trying to update your avatar. + + + + + This server does not permit you to update your user informations. + An error occured while trying to updater your avatar. + + + + + UserInfoPopup + + + + Games + + + + + Chat + + + + + Open private chat + + + + + Profile + + + + + View user profile + + + + + Show this user's games + + + + + − Buddy + + + + + Remove from buddy list + + + + + + Buddy + + + + + Add to buddy list + + + + + − Ignore + + + + + Remove from ignore list + + + + + + Ignore + + + + + Add to ignore list + + + + + Ban + + + + + Ban from server + + + + + Warn + + + + + Warn user + + + + + Ban log + + + + + View ban history + + + + + Warn log + + + + + View warning history + + + + + Notes + + + + + View admin notes + + + + + − Mod + + + + + Demote from moderator + + + + + + Mod + + + + + Promote to moderator + + + + + − Judge + + + + + Demote from judge + + + + + + Judge + + + + + Promote to judge + + + + + Join game + + + + + Spectate + + + + + + Loading games… + + + + + Could not load games. + + + + + No active games. UserInterfaceSettingsPage - + General interface settings - + &Double-click cards to play them (instead of single-click) - + &Clicking plays all selected cards (instead of just the clicked card) - + &Play all nonlands onto the stack (not the battlefield) by default - + Do not delete &arrows inside of subphases - + Close card view window when last card is removed - + Auto focus search bar when card view window is opened - + Annotate card text on tokens - - Show selection counter during drag selection + + Show selection count during drag selection - - Show total selection counter + + Show total selection count - + + Show subtype breakdown in selection tally + + + + Use tear-off menus, allowing right click menus to persist on screen - + + Keep game chat focused when clicking in game (Note: disables card view search bar) + + + + Notifications settings - + Enable notifications in taskbar - + Notify in the taskbar for game events while you are spectating - + Notify in the taskbar when users in your buddy list connect - + Animation settings - + &Tap/untap animation - + Deck editor/storage settings - + Open deck in new tab by default - + Use visual deck storage in game lobby - + Use selection animation for Visual Deck Storage - + When adding a tag in the visual deck storage to a .txt deck: - + do nothing - + ask to convert to .cod - + always convert to .cod - + Default deck editor type - + Classic Deck Editor - + Visual Deck Editor - + Replay settings - + Buffer time for backwards skip via shortcut: @@ -9562,22 +10362,22 @@ Please refrain from engaging in this activity or further actions may be taken ag UserListWidget - + Users connected to server: %1 - + Users in this room: %1 - + Buddies online: %1 / %2 - + Ignored users online: %1 / %2 @@ -9585,35 +10385,45 @@ Please refrain from engaging in this activity or further actions may be taken ag UtilityMenu - + Increment all card counters - + &Untap all permanents - + R&oll die... - + + Flip coin + + + + &Create token... - + C&reate another token - + Cr&eate predefined token + + + C&reate another %1 token + + VisualDatabaseDisplayColorFilterWidget @@ -9665,72 +10475,72 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayFilterToolbarWidget - + Sort by - + Filter by - + Save and load filters - + Filter by exact card name - + Filter by card main-type - + Filter by card sub-type - + Filter by set - + Filter by format legality - + Save/Load - + Name - + Main Type - + Sub Type - + Sets - + Formats @@ -9738,22 +10548,22 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayFormatLegalityFilterWidget - + Show formats with at least: - + cards - + Do not display formats with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) @@ -9771,32 +10581,32 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayMainTypeFilterWidget - + Show main types with at least: - + cards - + Do not display card main-types with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) - + Mode: Exact Match - + Mode: Includes @@ -9804,27 +10614,27 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayNameFilterWidget - + Filter by name... (Exact match) - + Load from Deck - + Apply all card names in currently loaded deck as exact match name filters - + Load from Clipboard - + Apply all card names in clipboard as exact match name filters @@ -9832,7 +10642,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayRecentSetFilterSettingsWidget - + Filter to most recent sets @@ -9840,19 +10650,19 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplaySetFilterWidget - + Search sets... - - + + Mode: Exact Match - - + + Mode: Includes @@ -9860,37 +10670,37 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplaySubTypeFilterWidget - + Search subtypes... - + Show sub types with at least: - + cards - + Do not display card sub-types with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) - + Mode: Exact Match - + Mode: Includes @@ -9898,23 +10708,23 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayWidget - + Search by card name (or search expressions) - + Visual - + Loading database ... - + Clear all filters @@ -9953,17 +10763,17 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Toggle Layout: Overlap - + Change how cards are displayed within zones (i.e. overlapped or fully visible.) - + Toggle Layout: Flat @@ -9992,17 +10802,17 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckEditorWidget - + Type a card name here for suggestions from the database... - + Quick search and add card - + Search for closest match in the database (with auto-suggestions) and add preferred printing to the deck on pressing enter @@ -10120,43 +10930,43 @@ Please refrain from engaging in this activity or further actions may be taken ag WarningDialog - + Which warning would you like to send? - + Redact all messages from this user in all rooms - + &OK - + &Cancel - + Warn user for misconduct - - + + Error Σφάλμα - + User name to send a warning to can not be blank, please specify a user to warn. - + Warning to use can not be blank, please select a valid warning to send. @@ -10164,133 +10974,133 @@ Please refrain from engaging in this activity or further actions may be taken ag WndSets - + Move selected set to the top - + Move selected set up - + Move selected set down - + Move selected set to the bottom - + Search by set name, code, or type - + Default order - + Restore original art priority order - + Enable all sets - + Disable all sets - + Enable selected set(s) - + Disable selected set(s) - + Deck Editor - + Use CTRL+A to select all sets in the view. - + Only cards in enabled sets will appear in the card list of the deck editor. - + Image priority is decided in the following order: - + first the CUSTOM Folder (%1), then the Enabled Sets in this dialog (Top to Bottom) %1 is a link to the wiki - + Include cards rebalanced for Alchemy [requires restart] - + Card Art - + How to use custom card art - + Hints - + Note - + Sorting by column allows you to find a set while not changing set priority. - + To enable ordering again, click the column header until this message disappears. - + Use the current sorting as the set priority instead - + Sorts the set priority using the same column - + Manage sets @@ -10298,72 +11108,72 @@ Please refrain from engaging in this activity or further actions may be taken ag ZoneViewWidget - + Search by card name (or search expressions) - + Ungrouped - + Group by Type - + Group by Mana Value - + Group by Color - + Unsorted - + Sort by Name - + Sort by Type - + Sort by Mana Cost - + Sort by Colors - + Sort by P/T - + Sort by Set - + shuffle when closing - + pile view @@ -10371,7 +11181,7 @@ Please refrain from engaging in this activity or further actions may be taken ag i18n - + English Ελληνικά (Greek) @@ -10379,12 +11189,12 @@ Please refrain from engaging in this activity or further actions may be taken ag main - + Connect on startup - + Debug to file @@ -10398,7 +11208,7 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Deck Editor @@ -10479,7 +11289,7 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Replays @@ -10594,129 +11404,129 @@ Please refrain from engaging in this activity or further actions may be taken ag - - + + Load Deck from Clipboard... - + Edit Deck in Clipboard, Annotated - + Edit Deck in Clipboard - + New Deck - + Open Custom Pictures Folder - + Print Deck... - + Delete Card - - + + Reset Layout - + Save Deck - + Save Deck as... - + Save Deck to Clipboard, Annotated - + Save Deck to Clipboard, Annotated (No Set Info) - + Save Deck to Clipboard - + Save Deck to Clipboard (No Set Info) - + Load Local Deck... - + Load Remote Deck... - + Set Ready to Start - + Toggle Sideboard Lock - + Add Green Counter - + Remove Green Counter - + Set Green Counters... - + Add Red Counter - + Remove Red Counter - + Set Red Counters... - + Add Life Counter @@ -10726,724 +11536,744 @@ Please refrain from engaging in this activity or further actions may be taken ag - + + Load deck from online service... + + + + + Load from website... + + + + Unload Deck - + Force Start - + Add Card Counter (F) - + Remove Card Counter (F) - + Set Card Counters (F)... - + Add Card Counter (E) - + Remove Card Counter (E) - + Set Card Counters (E)... - + Add Card Counter(D) - + Remove Card Counter (D) - + Set Card Counters (D)... - + Add Card Counter (C) - + Remove Card Counter (C) - + Set Card Counters (C)... - + Add Card Counter (B) - + Remove Card Counter (B) - + Set Card Counters (B)... - + Add Card Counter (A) - + Remove Card Counter (A) - + Set Card Counters (A)... - + Remove Life Counter - + Set Life Counters... - + Add White Counter - + Remove White Counter - + Set White Counters... - + Add Blue Counter - + Remove Blue Counter - + Set Blue Counters... - + Add Black Counter - + Remove Black Counter - + Set Black Counters... - + Add Colorless Counter - + Remove Colorless Counter - + Set Colorless Counters... - + Add Other Counter - + Remove Other Counter - + Set Other Counters... - + Increment all card counters - + Add Power (+1/+0) - + Remove Power (-1/-0) - + Move Toughness to Power (+1/-1) - + Add Toughness (+0/+1) - + Remove Toughness (-0/-1) - + Move Power to Toughness (-1/+1) - + Add Power and Toughness (+1/+1) - + Remove Power and Toughness (-1/-1) - + Set Power and Toughness... - + Reset Power and Toughness - + Untap - + Upkeep - + Draw - + First Main Phase - + Start Combat - + Attack - + Block - + Damage - + End Combat - + Second Main Phase - + End - + Next Phase - + Next Phase Action - + Next Turn - + Hide Card in Reveal Window - + Tap / Untap Card - + Untap All - + Toggle Skip Untapping Toggle Untap - + Turn Card Over - + Peek Card - + Play Card - + Play Card, Face Down - + Attach Card... - + Unattach Card - + Clone Card - + Create Token... - + Create All Related Tokens - + Create Another Token - + Set Annotation... - + + Reduce Life by Power + + + + Select All Cards in Zone - + Select All Cards in Row - + Select All Cards in Column - + Reveal Selected Cards to All Players - - + + Bottom of Library - - - - + + + + Exile - - - - + + + + Graveyard - - + + Hand - - + + Top of Library - - + + Battlefield, Face Down - + Battlefield - + Library - + Sideboard - + Top Cards of Library - + Bottom Cards of Library - + Close Recent View - - + + Stack - - + + Graveyard (Multiple) - - + + Graveyard (Multiple), Face Down - - + + Exile (Multiple) - - + + Exile (Multiple), Face Down - + Stack Until Found - + Draw Bottom Card - + Draw Multiple Cards from Bottom... - + Draw Arrow... - + Remove Local Arrows - + Leave Game - + Concede - + Roll Dice... - + + Flip Coin + + + + Shuffle Library - + Shuffle Top Cards of Library - + Shuffle Bottom Cards of Library - + Mulligan - + Mulligan (Same hand size) - + Mulligan (Hand size - 1) - + Draw a Card - + Draw Multiple Cards... - + Undo Draw - + Always Reveal Top Card - + Always Look At Top Card - + Sort Hand by Name - + Sort Hand by Type - + Sort Hand by Mana Value - + Reveal Hand to All Players - + Reveal Random Card to All Players - + Rotate View Clockwise - + Rotate View Counterclockwise - + Unfocus Text Box - + Focus Chat - + Clear Chat - + Refresh - + Skip Forward - + Skip Backward - + Skip Forward by a lot - + Skip Backward by a lot - + Play/Pause - + Toggle Fast Forward - + Home - + Visual Deck Storage - + Deck Storage - + Server - + Account - + Administration - + Logs diff --git a/cockatrice/translations/cockatrice_en_US.ts b/cockatrice/translations/cockatrice_en_US.ts index 254ead590..00075c8c3 100644 --- a/cockatrice/translations/cockatrice_en_US.ts +++ b/cockatrice/translations/cockatrice_en_US.ts @@ -2,7 +2,7 @@ AbstractCounter - + &Set counter... &Set counter... @@ -10,12 +10,12 @@ AbstractCounterDialog - + Set counter Set counter - + New value for counter '%1': New value for counter '%1': @@ -36,62 +36,62 @@ AbstractTabDeckEditor - + Open in new tab Open in new tab - + Are you sure? Are you sure? - + The decklist has been modified. Do you want to save the changes? The decklist has been modified. Do you want to save the changes? - - - - - - + + + + + + Error Error - + Could not open deck at %1 Could not open deck at %1 - + Could not save remote deck Could not save remote deck - - + + The deck could not be saved. Please check that the directory is writable and try again. The deck could not be saved. Please check that the directory is writable and try again. - + Save deck Save deck - + The deck could not be saved. The deck could not be saved. - + There are no cards in your deck to be exported There are no cards in your deck to be exported @@ -107,12 +107,12 @@ Please check that the directory is writable and try again. AdminNotesDialog - + Update Notes Update Notes - + Admin Notes for %1 Admin Notes for %1 @@ -129,172 +129,227 @@ Please check that the directory is writable and try again. Sideboard Sideboard + + + Tokens + + AppearanceSettingsPage - + seconds seconds - + Error Error - + Could not create themes directory at '%1'. Could not create themes directory at '%1'. - + Theme settings Theme settings - + Current theme: Current theme: - + Open themes folder Open themes folder - + Home tab background source: Home tab background source: - + Home tab background shuffle frequency: Home tab background shuffle frequency: - + Disabled Disabled - - Display card name of background in bottom right: - Display card name of background in bottom right: - - - + Menu settings Menu settings - + Show keyboard shortcuts in right-click menus Show keyboard shortcuts in right-click menus - + Show game filter toolbar above list in room tab Show game filter toolbar above list in room tab - + Card rendering Card rendering - + Display card names on cards having a picture Display card names on cards having a picture - + Auto-Rotate cards with sideways layout Auto-Rotate cards with sideways layout - + Override all card art with personal set preference (Pre-ProviderID change behavior) Override all card art with personal set preference (Pre-ProviderID change behavior) - + + Light + + + + + Dark + + + + + System + + + + + Active theme palette: + + + + + Edit theme palette + + + + + Home tab settings + + + + + Display card name of background in bottom right + + + + + Styling settings + + + + + Style user list + + + + + Card printings + + + + Bump sets that the deck contains cards from to the top in the printing selector Bump sets that the deck contains cards from to the top in the printing selector - + Scale cards on mouse over Scale cards on mouse over - + Use rounded card corners Use rounded card corners - + + Card layout + + + + Minimum overlap percentage of cards on the stack and in vertical hand Minimum overlap percentage of cards on the stack and in vertical hand - + Maximum initial height for card view window: Maximum initial height for card view window: - - + + rows rows - + Maximum expanded height for card view window: Maximum expanded height for card view window: - + Card counters Card counters - + Counter %1 Counter %1 - + Hand layout Hand layout - + Display hand horizontally (wastes space) Display hand horizontally (wastes space) - + Enable left justification Enable left justification - + Table grid layout Table grid layout - + Invert vertical coordinate Invert vertical coordinate - + Minimum player count for multi-column layout: Minimum player count for multi-column layout: - + Maximum font size for information displayed on cards: Maximum font size for information displayed on cards: @@ -302,12 +357,12 @@ Please check that the directory is writable and try again. ArchidektApiResponseDeckDisplayWidget - + Back to results Back to results - + Open Deck in Deck Editor Open Deck in Deck Editor @@ -333,112 +388,112 @@ Please check that the directory is writable and try again. BanDialog - + ban &user name ban &user name - + ban &IP address ban &IP address - + ban client I&D ban client I&D - + Ban type Ban type - + &permanent ban &permanent ban - + &temporary ban &temporary ban - + &Days: &Days: - + &Hours: &Hours: - + &Minutes: &Minutes: - + Duration of the ban Duration of the ban - + Please enter the reason for the ban. This is only saved for moderators and cannot be seen by the banned person. Please enter the reason for the ban. This is only saved for moderators and cannot be seen by the banned person. - + Please enter the reason for the ban that will be visible to the banned person. Please enter the reason for the ban that will be visible to the banned person. - + Redact all messages from this user in all rooms Redact all messages from this user in all rooms - + &OK &OK - + &Cancel &Cancel - + Ban user from server Ban user from server - - - - + + + + Error Error - + You have to select a name-based, IP-based, clientId based, or some combination of the three to place a ban. You have to select a name-based, IP-based, clientId based, or some combination of the three to place a ban. - + You must have a value in the name ban when selecting the name ban checkbox. You must have a value in the name ban when selecting the name ban checkbox. - + You must have a value in the ip ban when selecting the ip ban checkbox. You must have a value in the ip ban when selecting the ip ban checkbox. - + You must have a value in the clientid ban when selecting the clientid ban checkbox. You must have a value in the clientid ban when selecting the clientid ban checkbox. @@ -446,59 +501,123 @@ This is only saved for moderators and cannot be seen by the banned person. BetaReleaseChannel - + Beta Beta - + No reply received from the release update server. No reply received from the release update server. - + Invalid reply received from the release update server. Invalid reply received from the release update server. - + No reply received from the file update server. No reply received from the file update server. + + CardArtPreviewWidget + + + No card selected + + + + + CardArtRulesModel + + + Card + + + + + ProviderId + + + + + Mode + + + + + Reason + + + CardDatabaseModel - + Name Name - + Sets Sets - + Mana cost Mana cost - + Card type Card type - + P/T P/T - + Color(s) Color(s) + + CardDatabaseView + + + Add to Deck + + + + + Add to Sideboard + + + + + Select Printing + + + + + Show on EDHRec (Commander) + + + + + Show on EDHRec (Card) + + + + + Show Related cards + + + CardFilter @@ -627,22 +746,22 @@ This is only saved for moderators and cannot be seen by the banned person. CardInfoPictureWidget - + View related cards View related cards - + Add card to deck Add card to deck - + Mainboard Mainboard - + Sideboard Sideboard @@ -665,12 +784,12 @@ This is only saved for moderators and cannot be seen by the banned person.Collector Number: - + Related cards: Related cards: - + Unknown card: Unknown card: @@ -678,128 +797,146 @@ This is only saved for moderators and cannot be seen by the banned person. CardMenu - + Re&veal to... Re&veal to... - + &All players &All players - + View related cards View related cards - + Token: Token: - + All tokens All tokens - + &Select All &Select All - + S&elect Row S&elect Row - + S&elect Column S&elect Column - + &Play &Play - + &Hide &Hide - + Play &Face Down Play &Face Down - + &Tap / Untap Turn sideways or back again &Tap / Untap - + Skip &untapping Skip &untapping - + T&urn Over Turn face up/face down T&urn Over - + &Peek at card face &Peek at card face - + &Clone &Clone - + Attac&h to card... Attac&h to card... - + Unattac&h Unattac&h - + &Draw arrow... &Draw arrow... - + &Set annotation... &Set annotation... - + + Reduce life by power + + + + Ca&rd counters Ca&rd counters - + &Add counter (%1) &Add counter (%1) - + &Remove counter (%1) &Remove counter (%1) - + &Set counters (%1)... &Set counters (%1)... + + CardPictureLoaderCacheMethod + + + Network Cache + + + + + Filesystem + + + CardSizeWidget @@ -811,133 +948,133 @@ This is only saved for moderators and cannot be seen by the banned person. CardZoneLogic - + their hand nominative their hand - + %1's hand nominative %1's hand - + their library look at zone their library - + %1's library look at zone %1's library - + of their library top cards of zone, of their library - + of %1's library top cards of zone of %1's library - + their library reveal zone their library - + %1's library reveal zone %1's library - + their library shuffle their library - + %1's library shuffle %1's library - + their library nominative their library - + %1's library nominative %1's library - + their graveyard nominative their graveyard - + %1's graveyard nominative %1's graveyard - + their exile nominative their exile - + %1's exile nominative %1's exile - + their sideboard look at zone their sideboard - + %1's sideboard look at zone %1's sideboard - + their sideboard nominative their sideboard - + %1's sideboard nominative %1's sideboard - + their custom zone '%1' nominative their custom zone '%1' - + %1's custom zone '%2' nominative %1's custom zone '%2' @@ -959,16 +1096,421 @@ This is only saved for moderators and cannot be seen by the banned person.Parse error at line %1 col %2: + + ColorButton + + + Click to pick a color + + + + + Pick colour + + + + + ConnectionController + + + + The server has reached its maximum user capacity, please check back later. + + + + + There are too many concurrent connections from your address. + + + + + Banned by moderator + + + + + Expected end time: %1 + + + + + This ban lasts indefinitely. + + + + + Scheduled server shutdown. + + + + + + Invalid username. + + + + + You have been logged out due to logging in at another location. + + + + + Connection closed + + + + + The server has terminated your connection. +Reason: %1 + + + + + The server is going to be restarted in %n minute(s). +All running games will be lost. +Reason for shutdown: %1 + + + + + Scheduled server shutdown + + + + + Failed Login + + + + + Your client seems to be missing features this server requires for connection. + + + + + To update your client, go to 'Help -> Check for Client Updates'. + + + + + + + + + + + + + + + + + + + + + + Error + + + + + Incorrect username or password. Please check your authentication information and try again. + + + + + There is already an active session using this user name. +Please close that session first and re-login. + + + + + + You are banned until %1. + + + + + + You are banned indefinitely. + + + + + This server requires user registration. Do you want to register now? + + + + + This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. +Please close and reopen your client to try again. + + + + + An internal error has occurred, please close and reopen Cockatrice before trying again. +If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. + + + + + Account activation + + + + + Your account has not been activated yet. +You need to provide the activation token received in the activation email. + + + + + Server Full + + + + + Unknown login error: %1 + + + + + + +This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. + + + + + + + + + + Registration denied + + + + + Registration is currently disabled on this server + + + + + There is already an existing account with the same user name. + + + + + It's mandatory to specify a valid email address when registering. + + + + + It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. + + + + + Password too short. + + + + + Registration failed for a technical problem on the server. + + + + + The connection to the server has been lost. + + + + + Unknown registration error: %1 + + + + + Account activation failed + + + + + Socket error: %1 + + + + + Server timeout + + + + + You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. +Local version is %1, remote version is %2. + + + + + Your Cockatrice client is obsolete. Please update your Cockatrice version. +Local version is %1, remote version is %2. + + + + + + Success + + + + + Registration accepted. +Will now login. + + + + + Account activation accepted. +Will now login. + + + + + Information + + + + + This server supports additional features that your client doesn't have. +This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. + +To update your client, go to Help -> Check for Updates. + + + + + + + Reset Password + + + + + Your password has been reset successfully, you can now log in using the new credentials. + + + + + Failed to reset user account password, please contact the server operator to reset your password. + + + + + Activation request received, please check your email for an activation token. + + + + + Connecting to %1... + + + + + Registering to %1 as %2... + + + + + Disconnected + + + + + Connected, logging in at %1 + + + + + Requesting forgotten password to %1 as %2... + + + + + Your username must respect these rules: + + + + + is %1 - %2 characters long + + + + + can %1 contain lowercase characters + + + + + + + + NOT + + + + + can %1 contain uppercase characters + + + + + can %1 contain numeric characters + + + + + can contain the following punctuation: %1 + + + + + first character can %1 be a punctuation mark + + + + + no unacceptable language as specified by these server rules: + note that the following lines will not be translated + + + + + can not contain any of the following words: %1 + + + + + can not match any of the following expressions: %1 + + + + + You may only use A-Z, a-z, 0-9, _, ., and - in your username. + + + CustomZoneMenu - + C&ustom Zones C&ustom Zones - - + + View custom zone '%1' View custom zone '%1' @@ -976,30 +1518,35 @@ This is only saved for moderators and cannot be seen by the banned person. DeckAnalyticsWidget - + Add Panel Add Panel - + Remove Panel Remove Panel - + Save Layout Save Layout - + Load Layout Load Layout + + + Include Sideboard + + DeckEditorCardDatabaseDockWidget - + Card Database Card Database @@ -1015,47 +1562,17 @@ This is only saved for moderators and cannot be seen by the banned person. DeckEditorDatabaseDisplayWidget - + Search by card name (or search expressions) Search by card name (or search expressions) - - Add to Deck - Add to Deck - - - - Add to Sideboard - Add to Sideboard - - - - Select Printing - Select Printing - - - - Show on EDHRec (Commander) - Show on EDHRec (Commander) - - - - Show on EDHRec (Card) - Show on EDHRec (Card) - - - - Show Related cards - Show Related cards - - - + Add card to &maindeck Add card to &maindeck - + Add card to &sideboard Add card to &sideboard @@ -1088,72 +1605,72 @@ This is only saved for moderators and cannot be seen by the banned person.Colors - + Select Printing Select Printing - + Deck Deck - + Deck &name: Deck &name: - + Banner Card/Tags Visibility Settings Banner Card/Tags Visibility Settings - + Show banner card selection menu Show banner card selection menu - + Show tags selection menu Show tags selection menu - + &Comments: &Comments: - + Group by: Group by: - + Format: Format: - + Hash: Hash: - + &Increment number &Increment number - + &Decrement number &Decrement number - + &Remove row &Remove row - + Swap card to/from sideboard Swap card to/from sideboard @@ -1161,17 +1678,17 @@ This is only saved for moderators and cannot be seen by the banned person. DeckEditorFilterDockWidget - + Filters Filters - + &Clear all filters &Clear all filters - + Delete selected Delete selected @@ -1302,169 +1819,113 @@ This is only saved for moderators and cannot be seen by the banned person. DeckEditorSettingsPage - - + + Update Spoilers Update Spoilers - - + Success Success - + Download URLs have been reset. Download URLs have been reset. - - Downloaded card pictures have been reset. - Downloaded card pictures have been reset. - - - - Error - Error - - - - One or more downloaded card pictures could not be cleared. - One or more downloaded card pictures could not be cleared. - - - + Add URL Add URL - - + + URL: URL: - - + + Edit URL Edit URL - - Network Cache Size: - Network Cache Size: - - - - Redirect Cache TTL: - Redirect Cache TTL: - - - - How long cached redirects for urls are valid for. - How long cached redirects for urls are valid for. - - - - Picture Cache Size: - Picture Cache Size: - - - + Add New URL Add New URL - + Remove URL Remove URL - - Day(s) - Day(s) - - - + Updating... Updating... - + Choose path Choose path - + URL Download Priority URL Download Priority - + Spoilers Spoilers - + Download Spoilers Automatically Download Spoilers Automatically - + Spoiler Location: Spoiler Location: - + Last Change Last Change - + Spoilers download automatically on launch Spoilers download automatically on launch - + Press the button to manually update without relaunching Press the button to manually update without relaunching - + Do not close settings until manual update is complete Do not close settings until manual update is complete - + Download card pictures on the fly Download card pictures on the fly - + How to add a custom URL How to add a custom URL - - Delete Downloaded Images - Delete Downloaded Images - - - + Reset Download URLs Reset Download URLs - - - On-disk cache for downloaded pictures - On-disk cache for downloaded pictures - - - - In-memory cache for pictures not currently on screen - In-memory cache for pictures not currently on screen - DeckListHistoryManagerWidget @@ -1530,12 +1991,12 @@ This is only saved for moderators and cannot be seen by the banned person. DeckLoader - + Common deck formats (%1) Common deck formats (%1) - + All files (*.*) All files (*.*) @@ -1757,27 +2218,27 @@ This is only saved for moderators and cannot be seen by the banned person.Added (%1): %2 (%3) %4 - + Moved to %1 1 × "%2" (%3) Moved to %1 1 × "%2" (%3) - + Removed "%1" (all copies) Removed "%1" (all copies) - + %1 1 × "%2" (%3) %1 1 × "%2" (%3) - + Added Added - + Removed Removed @@ -1799,75 +2260,75 @@ This is only saved for moderators and cannot be seen by the banned person. DeckViewContainer - + Load deck... Load deck... - + Load remote deck... Load remote deck... - + Load from clipboard... Load from clipboard... - + Load from website... Load from website... - + Unload deck Unload deck - + Ready to start Ready to start - + Force start Force start - + Sideboard unlocked Sideboard unlocked - + Sideboard locked Sideboard locked - - + + Error Error - + The selected file could not be loaded. The selected file could not be loaded. - + Deck is greater than maximum file size. Deck is greater than maximum file size. - + Are you sure you want to force start? This will kick all non-ready players from the game. Are you sure you want to force start? This will kick all non-ready players from the game. - + Cockatrice Cockatrice @@ -1968,7 +2429,7 @@ This will kick all non-ready players from the game. - + Webpage Webpage @@ -2008,37 +2469,37 @@ This will kick all non-ready players from the game. Connect to Server - + Server URL Server URL - + Communication Port Communication Port - + Unique Server Name Unique Server Name - + Connection Warning Connection Warning - + You need to name your new connection profile. You need to name your new connection profile. - + Connect Warning Connect Warning - + The player name can't be empty. The player name can't be empty. @@ -2151,17 +2612,17 @@ This will kick all non-ready players from the game. Create game - + Game information Game information - + Error Error - + Server error. Server error. @@ -2169,97 +2630,97 @@ This will kick all non-ready players from the game. DlgCreateToken - + &Name: &Name: - + Token Token - + C&olor: C&olor: - + white white - + blue blue - + black black - + red red - + green green - + multicolor multicolor - + colorless colorless - + &P/T: &P/T: - + &Annotation: &Annotation: - + &Destroy token when it leaves the table &Destroy token when it leaves the table - + Create face-down (Only hides name) Create face-down (Only hides name) - + Token data Token data - + Show &all tokens Show &all tokens - + Show tokens from this &deck Show tokens from this &deck - + Choose token from list Choose token from list - + Create token Create token @@ -2293,7 +2754,7 @@ This will kick all non-ready players from the game. - + @@ -2308,12 +2769,12 @@ This will kick all non-ready players from the game. Tag name cannot be empty! - + Duplicate Tag Duplicate Tag - + This tag already exists. This tag already exists. @@ -2500,12 +2961,12 @@ To remove your current avatar, confirm without choosing a new image.Please enter the name of the token: - + Error Error - + The chosen name conflicts with an existing card or token. Make sure to enable the 'Token' set in the "Manage sets" dialog to display them correctly. The chosen name conflicts with an existing card or token. @@ -2530,12 +2991,12 @@ Make sure to enable the 'Token' set in the "Manage sets" dia Undefined - + Real name: Real name: - + Edit user profile Edit user profile @@ -2573,113 +3034,113 @@ Make sure to enable the 'Token' set in the "Manage sets" dia 2 hours - + Hide 'buddies only' games Hide 'buddies only' games - + Hide full games Hide full games - + Hide games that have started Hide games that have started - + Hide password protected games Hide password protected games - + Hide 'ignored user' games Hide 'ignored user' games - + Hide games not created by buddies Hide games not created by buddy Hide games not created by buddies - + Hide games with forced open decklists Hide games with forced open decklists - + &Newer than: &Newer than: - + Game &description: Game &description: - + &Creator name: &Creator name: - + General General - + &Game types &Game types - + at &least: at &least: - + at &most: at &most: - + Maximum player count Maximum player count - + Restrictions Restrictions - + Show games only if &spectators can watch Show games only if &spectators can watch - + Show spectator password p&rotected games Show spectator password p&rotected games - + Show only if spectators can ch&at Show only if spectators can ch&at - + Show only if spectators can see &hands Show only if spectators can see &hands - + Spectators Spectators - + Filter games Filter games @@ -2990,37 +3451,37 @@ https://tappedout.net/mtg-decks/your-deck-name/ DlgMoveTopCardsUntil - + Card name (or search expressions): Card name (or search expressions): - + Number of hits: Number of hits: - + Auto play hits Auto play hits - + Put top cards on stack until... Put top cards on stack until... - + No cards matching the search expression exists in the card database. Proceed anyways? No cards matching the search expression exists in the card database. Proceed anyways? - + Cockatrice Cockatrice - + Invalid filter Invalid filter @@ -3080,40 +3541,40 @@ Your email will be used to verify your account. Undefined - + Real name: Real name: - + Register to server Register to server - - - - + + + + Registration Warning Registration Warning - + Your password is too short. Your password is too short. - + Your passwords do not match, please try again. Your passwords do not match, please try again. - + Your email addresses do not match, please try again. Your email addresses do not match, please try again. - + The player name can't be empty. The player name can't be empty. @@ -3121,17 +3582,17 @@ Your email will be used to verify your account. DlgRollDice - + Number of sides: Number of sides: - + Number of dice: Number of dice: - + Roll Dice Roll Dice @@ -3182,12 +3643,12 @@ Your email will be used to verify your account. DlgSettings - + Unknown Error loading card database Unknown Error loading card database - + Your card database is invalid. Cockatrice may not function correctly with an invalid database @@ -3204,7 +3665,7 @@ You may need to rerun oracle to update your card database. Would you like to change your database location setting? - + Your card database version is too old. This can cause problems loading card information or images @@ -3221,7 +3682,7 @@ Usually this can be fixed by rerunning oracle to to update your card database. Would you like to change your database location setting? - + Your card database did not finish loading Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues with your cards.xml attached @@ -3234,7 +3695,7 @@ Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues with you Would you like to change your database location setting? - + File Error loading your card database. Would you like to change your database location setting? @@ -3243,7 +3704,7 @@ Would you like to change your database location setting? Would you like to change your database location setting? - + Your card database was loaded but contains no cards. Would you like to change your database location setting? @@ -3252,7 +3713,7 @@ Would you like to change your database location setting? Would you like to change your database location setting? - + Unknown card database load status Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues @@ -3265,59 +3726,64 @@ Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues Would you like to change your database location setting? - - - + + + Error Error - + The path to your deck directory is invalid. Would you like to go back and set the correct path? The path to your deck directory is invalid. Would you like to go back and set the correct path? - + The path to your card pictures directory is invalid. Would you like to go back and set the correct path? The path to your card pictures directory is invalid. Would you like to go back and set the correct path? - + Settings Settings - + General General - + Appearance Appearance - + User Interface User Interface - + + Storage + + + + Card Sources Card Sources - + Chat Chat - + Sound Sound - + Shortcuts Shortcuts @@ -3411,9 +3877,9 @@ You can always change this behavior in the 'General' settings tab. - - - + + + Error Error @@ -3466,31 +3932,31 @@ Please visit the download page to update manually. - + Update Available Update Available - + A new version of Cockatrice is available! A new version of Cockatrice is available! - + New version New version - + Released Released - + Changelog Changelog @@ -3500,51 +3966,51 @@ Please visit the download page to update manually. Do you want to update now? - + Unfortunately, the automatic updater failed to find a compatible download. You may have to manually download the new version. Unfortunately, the automatic updater failed to find a compatible download. You may have to manually download the new version. - + Please check the <a href="%1">releases page</a> on our Github and download the build for your system. Please check the <a href="%1">releases page</a> on our Github and download the build for your system. - - - + + + Update Error Update Error - + An error occurred while checking for updates: An error occurred while checking for updates: - + An error occurred while downloading an update: An error occurred while downloading an update: - + Installing... Installing... - + Cockatrice is unable to open the installer. Cockatrice is unable to open the installer. - + Try to update manually by closing Cockatrice and running the installer. Try to update manually by closing Cockatrice and running the installer. - + Download location Download location @@ -3634,67 +4100,67 @@ You may have to manually download the new version. DrawProbabilityWidget - + Draw Probability Draw Probability - + Probability of drawing Probability of drawing - + Card Name Card Name - + Type Type - + Subtype Subtype - + Mana Value Mana Value - + At least At least - + Exactly Exactly - + card(s) having drawn at least card(s) having drawn at least - + cards cards - + Category Category - + Qty Qty - + Odds (%) Odds (%) @@ -3808,7 +4274,7 @@ You may have to manually download the new version. FilterBuilder - + Type your filter here Type your filter here @@ -3839,22 +4305,22 @@ You may have to manually download the new version. GameEventHandler - + kicked by game host or moderator kicked by game host or moderator - + player left the game player left the game - + player disconnected from server player disconnected from server - + reason unknown reason unknown @@ -3862,140 +4328,140 @@ You may have to manually download the new version. GameSelector - - - - - - - - - + + + + + + + + + Error Error - + Please join the appropriate room first. Please join the appropriate room first. - + Wrong password. Wrong password. - + Spectators are not allowed in this game. Spectators are not allowed in this game. - + The game is already full. The game is already full. - + The game does not exist any more. The game does not exist any more. - + This game is only open to registered users. This game is only open to registered users. - + This game is only open to its creator's buddies. This game is only open to its creator's buddies. - + You are being ignored by the creator of this game. You are being ignored by the creator of this game. - + Join Game Join Game - + Spectate Game Spectate Game - + Game Information Game Information - + Join Game as Judge Join Game as Judge - + Spectate Game as Judge Spectate Game as Judge - + Join game Join game - + Password: Password: - + Please join the respective room first. Please join the respective room first. - + &Filter games &Filter games - + C&lear filter C&lear filter - + C&reate C&reate - + &Join &Join - + Join as judge Join as judge - + J&oin as spectator J&oin as spectator - + Join as judge spectator Join as judge spectator - + Games shown: %1 / %2 Games shown: %1 / %2 - + Games Games @@ -4003,32 +4469,32 @@ You may have to manually download the new version. GameSelectorQuickFilterToolBar - + All types All types - + Filter by game name... Filter by game name... - + Filter by game type/format Filter by game type/format - + Hide games not created by buddies Hide games not created by buddies - + Hide full games Hide full games - + Hide started games Hide started games @@ -4036,105 +4502,105 @@ You may have to manually download the new version. GamesModel - + >1 day >1 day - + %1%2 hr short age in hours %1%2 hr%1%2 hrs - + new new - + %1%2 min short age in minutes %1%2 min%1%2 mins - + password password - + buddies only buddies only - + reg. users only reg. users only - + open decklists open decklists - + can chat can chat - + see hands see hands - + can see hands can see hands - + not allowed not allowed - + Room Room - + Age Age - + Description Description - + Creator Creator - + Type Type - + Restrictions Restrictions - + Players Players - + Spectators Spectators @@ -4142,143 +4608,158 @@ You may have to manually download the new version. GeneralSettingsPage - + Reset all paths Reset all paths - + All paths have been reset All paths have been reset - - - - - - - + + + + + + + Choose path Choose path - - Personal settings - Personal settings + + Language settings + - + Language: Language: - + + Version settings + + + + + Card database + + + + + Startup settings + + + + Paths (editing disabled in portable mode) Paths (editing disabled in portable mode) - + Paths Paths - + How to help with translations How to help with translations - + Decks directory: Decks directory: - + Filters directory: Filters directory: - + Replays directory: Replays directory: - + Pictures directory: Pictures directory: - + Card database: Card database: - + Custom database directory: Custom database directory: - + Token database: Token database: - + Update channel Update channel - + Check for client updates on startup Check for client updates on startup - + Check for card database updates on startup Check for card database updates on startup - + Don't check Don't check - + Prompt for update Prompt for update - + Always update in the background Always update in the background - + Check for card database updates every Check for card database updates every - + days days - + Notify if a feature supported by the server is missing in my client Notify if a feature supported by the server is missing in my client - + Automatically run Oracle when running a new version of Cockatrice Automatically run Oracle when running a new version of Cockatrice - + Show tips on startup Show tips on startup - + Last update check on %1 (%2 days ago) Last update check on %1 (%2 days ago) @@ -4286,47 +4767,47 @@ You may have to manually download the new version. GraveyardMenu - + &Graveyard &Graveyard - + &View graveyard &View graveyard - + &Move graveyard to... &Move graveyard to... - + &Top of library &Top of library - + &Bottom of library &Bottom of library - + &All players &All players - + &Hand &Hand - + &Exile &Exile - + Reveal random card to... Reveal random card to... @@ -4334,88 +4815,88 @@ You may have to manually download the new version. HandMenu - + &Hand &Hand - + &View hand &View hand - + Sort hand by... Sort hand by... - + Name Name - + Type Type - + Mana Value Mana Value - + Take &mulligan (Choose hand size) Take &mulligan (Choose hand size) - + Take mulligan (Same hand size) Take mulligan (Same hand size) - + Take mulligan (Hand size - 1) Take mulligan (Hand size - 1) - + &Move hand to... &Move hand to... - + &Top of library &Top of library - + &Bottom of library &Bottom of library - + &Graveyard &Graveyard - + &Exile &Exile - + &Reveal hand to... &Reveal hand to... - - + + All players All players - + Reveal r&andom card to... Reveal r&andom card to... @@ -4423,52 +4904,52 @@ You may have to manually download the new version. HomeWidget - + Create New Deck Create New Deck - + Browse Decks Browse Decks - + Browse Card Database Browse Card Database - + Browse EDHRec Browse EDHRec - + Browse Archidekt Browse Archidekt - + View Replays View Replays - + Quit Quit - + Connecting... Connecting... - + Connect Connect - + Play Play @@ -4476,213 +4957,213 @@ You may have to manually download the new version. LibraryMenu - + &Library &Library - + &View library &View library - + View &top cards of library... View &top cards of library... - + View bottom cards of library... View bottom cards of library... - + Reveal &library to... Reveal &library to... - + Lend library to... Lend library to... - + Reveal &top cards to... Reveal &top cards to... - + &Top of library... &Top of library... - + &Bottom of library... &Bottom of library... - + &Always reveal top card &Always reveal top card - + &Always look at top card &Always look at top card - + &Open deck in deck editor &Open deck in deck editor - + &Draw card &Draw card - + D&raw cards... D&raw cards... - + &Undo last draw &Undo last draw - + Shuffle Shuffle - + &Play top card &Play top card - + Play top card &face down Play top card &face down - + Put top card on &bottom Put top card on &bottom - + Move top card to grave&yard Move top card to grave&yard - + Move top card to e&xile Move top card to e&xile - + Move top cards to &graveyard... Move top cards to &graveyard... - + Move top cards to graveyard face down... Move top cards to graveyard face down... - + Move top cards to &exile... Move top cards to &exile... - + Move top cards to exile face down... Move top cards to exile face down... - + Put top cards on stack &until... Put top cards on stack &until... - + Shuffle top cards... Shuffle top cards... - + &Draw bottom card &Draw bottom card - + D&raw bottom cards... D&raw bottom cards... - + &Play bottom card &Play bottom card - + Play bottom card &face down Play bottom card &face down - + Move bottom card to grave&yard Move bottom card to grave&yard - + Move bottom card to e&xile Move bottom card to e&xile - + Move bottom cards to &graveyard... Move bottom cards to &graveyard... - + Move bottom cards to graveyard face down... Move bottom cards to graveyard face down... - + Move bottom cards to &exile... Move bottom cards to &exile... - + Move bottom cards to exile face down... Move bottom cards to exile face down... - + Put bottom card on &top Put bottom card on &top - + Shuffle bottom cards... Shuffle bottom cards... - - + + &All players &All players - + Reveal top cards of library Reveal top cards of library - + Number of cards: (max. %1) Number of cards: (max. %1) @@ -4690,639 +5171,273 @@ You may have to manually download the new version. MainWindow - - - The server has reached its maximum user capacity, please check back later. - The server has reached its maximum user capacity, please check back later. - - - - There are too many concurrent connections from your address. - There are too many concurrent connections from your address. - - - - Banned by moderator - Banned by moderator - - - - Expected end time: %1 - Expected end time: %1 - - - - This ban lasts indefinitely. - This ban lasts indefinitely. - - - - Scheduled server shutdown. - Scheduled server shutdown. - - - - - Invalid username. - Invalid username. - - - - You have been logged out due to logging in at another location. - You have been logged out due to logging in at another location. - - - - Connection closed - Connection closed - - - - The server has terminated your connection. -Reason: %1 - The server has terminated your connection. -Reason: %1 - - - - The server is going to be restarted in %n minute(s). -All running games will be lost. -Reason for shutdown: %1 - The server is going to be restarted in %n minute. -All running games will be lost. -Reason for shutdown: %1The server is going to be restarted in %n minutes. -All running games will be lost. -Reason for shutdown: %1 - - - - Scheduled server shutdown - Scheduled server shutdown - - - - - Success - Success - - - - Registration accepted. -Will now login. - Registration accepted. -Will now login. - - - - Account activation accepted. -Will now login. - Account activation accepted. -Will now login. - - - - + + Player %1 Player %1 - + Load replay Load replay - + About Cockatrice About Cockatrice - + Version Version - + Cockatrice Webpage Cockatrice Webpage - + Project Manager: Project Manager: - + Past Project Managers: Past Project Managers: - + Developers: Developers: - + Our Developers Our Developers - + Help Develop! Help Develop! - + Translators: Translators: - + Our Translators Our Translators - + Help Translate! Help Translate! - + Support: Support: - + Report an Issue Report an Issue - + Troubleshooting Troubleshooting - + F.A.Q. F.A.Q. - - - - - - - - - - - - - - - - - - - - + + Error Error - - Server timeout - Server timeout - - - - Failed Login - Failed Login - - - - Your client seems to be missing features this server requires for connection. - Your client seems to be missing features this server requires for connection. - - - - To update your client, go to 'Help -> Check for Client Updates'. - To update your client, go to 'Help -> Check for Client Updates'. - - - - Incorrect username or password. Please check your authentication information and try again. - Incorrect username or password. Please check your authentication information and try again. - - - - There is already an active session using this user name. -Please close that session first and re-login. - There is already an active session using this user name. -Please close that session first and re-login. - - - - - You are banned until %1. - You are banned until %1. - - - - - You are banned indefinitely. - You are banned indefinitely. - - - - This server requires user registration. Do you want to register now? - This server requires user registration. Do you want to register now? - - - - This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. -Please close and reopen your client to try again. - This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. -Please close and reopen your client to try again. - - - - An internal error has occurred, please close and reopen Cockatrice before trying again. -If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. - An internal error has occurred, please close and reopen Cockatrice before trying again. -If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. - - - - Account activation - Account activation - - - - Your account has not been activated yet. -You need to provide the activation token received in the activation email. - Your account has not been activated yet. -You need to provide the activation token received in the activation email. - - - - Server Full - Server Full - - - - Unknown login error: %1 - Unknown login error: %1 - - - - - -This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. - -This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. - - - - Your username must respect these rules: - Your username must respect these rules: - - - - is %1 - %2 characters long - is %1 - %2 characters long - - - - can %1 contain lowercase characters - can %1 contain lowercase characters - - - - - - - NOT - NOT - - - - can %1 contain uppercase characters - can %1 contain uppercase characters - - - - can %1 contain numeric characters - can %1 contain numeric characters - - - - can contain the following punctuation: %1 - can contain the following punctuation: %1 - - - - first character can %1 be a punctuation mark - first character can %1 be a punctuation mark - - - - no unacceptable language as specified by these server rules: - note that the following lines will not be translated - no unacceptable language as specified by these server rules: - - - - can not contain any of the following words: %1 - can not contain any of the following words: %1 - - - - can not match any of the following expressions: %1 - can not match any of the following expressions: %1 - - - - You may only use A-Z, a-z, 0-9, _, ., and - in your username. - You may only use A-Z, a-z, 0-9, _, ., and - in your username. - - - - - - - - - Registration denied - Registration denied - - - - Registration is currently disabled on this server - Registration is currently disabled on this server - - - - There is already an existing account with the same user name. - There is already an existing account with the same user name. - - - - It's mandatory to specify a valid email address when registering. - It's mandatory to specify a valid email address when registering. - - - - It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. - It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. - - - - Password too short. - Password too short. - - - - Registration failed for a technical problem on the server. - Registration failed for a technical problem on the server. - - - - The connection to the server has been lost. - The connection to the server has been lost. - - - - Unknown registration error: %1 - Unknown registration error: %1 - - - - Account activation failed - Account activation failed - - - - Socket error: %1 - Socket error: %1 - - - - You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. -Local version is %1, remote version is %2. - You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. -Local version is %1, remote version is %2. - - - - Your Cockatrice client is obsolete. Please update your Cockatrice version. -Local version is %1, remote version is %2. - Your Cockatrice client is obsolete. Please update your Cockatrice version. -Local version is %1, remote version is %2. - - - - Connecting to %1... - Connecting to %1... - - - - Registering to %1 as %2... - Registering to %1 as %2... - - - - Disconnected - Disconnected - - - - Connected, logging in at %1 - Connected, logging in at %1 - - - - - - Requesting forgotten password to %1 as %2... - Requesting forgotten password to %1 as %2... - - - + &Connect... &Connect... - + &Disconnect &Disconnect - + Start &local game... Start &local game... - + &Watch replay... &Watch replay... - + &Full screen &Full screen - + &Register to server... &Register to server... - + &Restore password... &Restore password... - + &Settings... &Settings... - + &Exit &Exit - + A&ctions A&ctions - + &Cockatrice &Cockatrice - + C&ard Database C&ard Database - + &Manage sets... &Manage sets... - + Edit custom &tokens... Edit custom &tokens... - + Open custom image folder Open custom image folder - + Open custom sets folder Open custom sets folder - + Add custom sets/cards Add custom sets/cards - + Reload card database Reload card database - + Tabs Tabs - + &Help &Help - + &About Cockatrice &About Cockatrice - + &Tip of the Day &Tip of the Day - + Check for Client Updates Check for Client Updates - + Check for Card Updates... Check for Card Updates... - + Check for Card Updates (Automatic) Check for Card Updates (Automatic) - + Show Status Bar Show Status Bar - + View &Debug Log View &Debug Log - + Open Settings Folder Open Settings Folder - + Show/Hide Show/Hide - + New Version New Version - + Congratulations on updating to Cockatrice %1! Oracle will now launch to update your card database. Congratulations on updating to Cockatrice %1! Oracle will now launch to update your card database. - + Cockatrice installed Cockatrice installed - + Congratulations on installing Cockatrice %1! Oracle will now launch to install the initial card database. Congratulations on installing Cockatrice %1! Oracle will now launch to install the initial card database. - + Card database Card database - + Cockatrice is unable to load the card database. Do you want to update your card database now? If unsure or first time user, choose "Yes" @@ -5331,29 +5446,29 @@ Do you want to update your card database now? If unsure or first time user, choose "Yes" - - + + Yes Yes - - + + No No - + Open settings Open settings - + New sets found New sets found - + %n new set(s) found in the card database Set code(s): %1 Do you want to enable it/them? @@ -5364,17 +5479,22 @@ Set codes: %1 Do you want to enable them? - + + Yes, always enable + + + + View sets View sets - + Welcome Welcome - + Hi! It seems like you're running this version of Cockatrice for the first time. All the sets in the card database have been enabled. Read more about changing the set order or disabling specific sets and consequent effects in the "Manage Sets" dialog. @@ -5383,167 +5503,133 @@ All the sets in the card database have been enabled. Read more about changing the set order or disabling specific sets and consequent effects in the "Manage Sets" dialog. - - + Information Information - + A card database update is already running. A card database update is already running. - + Unable to run the card database updater: Unable to run the card database updater: - + Card database update running. Card database update running. - + Failed to start. The file might be missing, or permissions might be incorrect. Failed to start. The file might be missing, or permissions might be incorrect. - + The process crashed some time after starting successfully. The process crashed some time after starting successfully. - + Timed out. The process took too long to respond. The last waitFor...() function timed out. Timed out. The process took too long to respond. The last waitFor...() function timed out. - + An error occurred when attempting to write to the process. For example, the process may not be running, or it may have closed its input channel. An error occurred when attempting to write to the process. For example, the process may not be running, or it may have closed its input channel. - + An error occurred when attempting to read from the process. For example, the process may not be running. An error occurred when attempting to read from the process. For example, the process may not be running. - + Unknown error occurred. Unknown error occurred. - + The card database updater exited with an error: %1 The card database updater exited with an error: %1 - - This server supports additional features that your client doesn't have. -This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. - -To update your client, go to Help -> Check for Updates. - This server supports additional features that your client doesn't have. -This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. - -To update your client, go to Help -> Check for Updates. - - - - - - - + + + + + Load sets/cards Load sets/cards - + Selected file cannot be found. Selected file cannot be found. - + You can only import XML databases at this time. You can only import XML databases at this time. - + The new sets/cards have been added successfully. Cockatrice will now reload the card database. The new sets/cards have been added successfully. Cockatrice will now reload the card database. - + Sets/cards failed to import. Sets/cards failed to import. - - - - - Reset Password - Reset Password - - - - Your password has been reset successfully, you can now log in using the new credentials. - Your password has been reset successfully, you can now log in using the new credentials. - - - - Failed to reset user account password, please contact the server operator to reset your password. - Failed to reset user account password, please contact the server operator to reset your password. - - - - Activation request received, please check your email for an activation token. - Activation request received, please check your email for an activation token. - ManaBaseConfigDialog - + Mana Base Configuration Mana Base Configuration - + Display type: Display type: - + pie pie - + bar bar - + combinedBar combinedBar - + Filter Colors (optional): Filter Colors (optional): - + OK OK - + Cancel Cancel @@ -5615,27 +5701,27 @@ Cockatrice will now reload the card database. ManaDevotionConfigDialog - + Display type: Display type: - + pie pie - + bar bar - + combinedBar combinedBar - + Filter Colors (optional): Filter Colors (optional): @@ -5705,610 +5791,615 @@ Cockatrice will now reload the card database. MessageLogWidget - + from play from play - + from their graveyard from their graveyard - + from exile from exile - + from their hand from their hand - + the top card of %1's library the top card of %1's library - + the top card of their library the top card of their library - + from the top of %1's library from the top of %1's library - + from the top of their library from the top of their library - + the bottom card of %1's library the bottom card of %1's library - + the bottom card of their library the bottom card of their library - + from the bottom of %1's library from the bottom of %1's library - + from the bottom of their library from the bottom of their library - + from %1's library from %1's library - + from their library from their library - + from sideboard from sideboard - + from the stack from the stack - + from custom zone '%1' from custom zone '%1' - + %1 is now keeping the top card %2 revealed. %1 is now keeping the top card %2 revealed. - + %1 is not revealing the top card %2 any longer. %1 is not revealing the top card %2 any longer. - + %1 can now look at top card %2 at any time. %1 can now look at top card %2 at any time. - + %1 no longer can look at top card %2 at any time. %1 no longer can look at top card %2 at any time. - + %1 attaches %2 to %3's %4. %1 attaches %2 to %3's %4. - + %1 has conceded the game. %1 has conceded the game. - + %1 has unconceded the game. %1 has unconceded the game. - + %1 has restored connection to the game. %1 has restored connection to the game. - + %1 has lost connection to the game. %1 has lost connection to the game. - + %1 points from their %2 to themselves. %1 points from their %2 to themselves. - + %1 points from their %2 to %3. %1 points from their %2 to %3. - + %1 points from %2's %3 to themselves. %1 points from %2's %3 to themselves. - + %1 points from %2's %3 to %4. %1 points from %2's %3 to %4. - + %1 points from their %2 to their %3. %1 points from their %2 to their %3. - + %1 points from their %2 to %3's %4. %1 points from their %2 to %3's %4. - + %1 points from %2's %3 to their own %4. %1 points from %2's %3 to their own %4. - + %1 points from %2's %3 to %4's %5. %1 points from %2's %3 to %4's %5. - + %1 creates a face down token. %1 creates a face down token. - + %1 creates token: %2%3. %1 creates token: %2%3. - + %1 has loaded a deck (%2). %1 has loaded a deck (%2). - + %1 has loaded a deck with %2 sideboard cards (%3). %1 has loaded a deck with %2 sideboard cards (%3). - + %1 destroys %2. %1 destroys %2. - + a card a card - + %1 gives %2 control over %3. %1 gives %2 control over %3. - + %1 puts %2 into play%3 face down. %1 puts %2 into play%3 face down. - + %1 puts %2 into play%3. %1 puts %2 into play%3. - + %1 puts %2%3 into their graveyard face down. %1 puts %2%3 into their graveyard face down. - + %1 puts %2%3 into their graveyard. %1 puts %2%3 into their graveyard. - + %1 exiles %2%3 face down. %1 exiles %2%3 face down. - + %1 exiles %2%3. %1 exiles %2%3. - + %1 moves %2%3 to their hand. %1 moves %2%3 to their hand. - + %1 puts %2%3 into their library. %1 puts %2%3 into their library. - + %1 puts %2%3 onto the bottom of their library. %1 puts %2%3 onto the bottom of their library. - + %1 puts %2%3 on top of their library. %1 puts %2%3 on top of their library. - + %1 puts %2%3 into their library %4 cards from the top. %1 puts %2%3 into their library %4 cards from the top. - + %1 moves %2%3 to sideboard. %1 moves %2%3 to sideboard. - + %1 plays %2%3 face down. %1 plays %2%3 face down. - + %1 plays %2%3. %1 plays %2%3. - + %1 moves %2%3 to custom zone '%4' face down. %1 moves %2%3 to custom zone '%4' face down. - + %1 moves %2%3 to custom zone '%4'. %1 moves %2%3 to custom zone '%4'. - + %1 tries to draw from an empty library %1 tries to draw from an empty library - + %1 draws %2 card(s). %1 draws %2 card.%1 draws %2 cards. - + %1 is looking at %2. %1 is looking at %2. - + %1 is looking at the %4 %3 card(s) %2. top card for singular, top %3 cards for plural %1 is looking at the %4 %3 card %2.%1 is looking at the %4 %3 cards %2. - + bottom bottom - + top top - + %1 turns %2 face-down. %1 turns %2 face-down. - + %1 turns %2 face-up. %1 turns %2 face-up. - + The game has been closed. The game has been closed. - + The game has started. The game has started. - + You are flooding the game. Please wait a couple of seconds. You are flooding the game. Please wait a couple of seconds. - + %1 has joined the game. %1 has joined the game. - + %1 is now watching the game. %1 is now watching the game. - + You have been kicked out of the game. You have been kicked out of the game. - + %1 has left the game (%2). %1 has left the game (%2). - + %1 is not watching the game any more (%2). %1 is not watching the game any more (%2). - + %1 is not ready to start the game any more. %1 is not ready to start the game any more. - + %1 shuffles their deck and draws a new hand of %2 card(s). %1 shuffles their deck and draws a card.%1 shuffles their deck and draws a new hand of %2 cards. - + %1 shuffles their deck and draws a new hand. %1 shuffles their deck and draws a new hand. - + You are watching a replay of game #%1. You are watching a replay of game #%1. - + %1 is ready to start the game. %1 is ready to start the game. - + cards an unknown amount of cards cards - + %1 card(s) a card for singular, %1 cards for plural one card%1 cards - + %1 lends %2 to %3. %1 lends %2 to %3. - + %1 reveals %2 to %3. %1 reveals %2 to %3. - + %1 reveals %2. %1 reveals %2. - + %1 randomly reveals %2%3 to %4. %1 randomly reveals %2%3 to %4. - + %1 randomly reveals %2%3. %1 randomly reveals %2%3. - + %1 peeks at face down card #%2. %1 peeks at face down card #%2. - + %1 peeks at face down card #%2: %3. %1 peeks at face down card #%2: %3. - + %1 reveals %2%3 to %4. %1 reveals %2%3 to %4. - + %1 reveals %2%3. %1 reveals %2%3. - + %1 reversed turn order, now it's %2. %1 reversed turn order, now it's %2. - + reversed reversed - + normal normal - + Heads Heads - + Tails Tails - + %1 flipped a coin. It landed as %2. %1 flipped a coin. It landed as %2. - + %1 rolls a %2 with a %3-sided die. %1 rolls a %2 with a %3-sided die. - + %1 flips %2 coins. There are %3 heads and %4 tails. %1 flips %2 coins. There are %3 heads and %4 tails. - + %1 rolls a %2-sided dice %3 times: %4. %1 rolls a %2-sided dice %3 times: %4. - + %1's turn. %1's turn. - + %1 sets annotation of %2 to %3. %1 sets annotation of %2 to %3. - - %1 places %2 "%3" counter(s) on %4 (now %5). - %1 places %2 "%3" counter on %4 (now %5).%1 places %2 "%3" counters on %4 (now %5). + + %1 places %2 %3%4 counter(s) on %5 (now %6). + - - %1 removes %2 "%3" counter(s) from %4 (now %5). - %1 removes %2 "%3" counter from %4 (now %5).%1 removes %2 "%3" counters from %4 (now %5). + + %1 removes %2 %3%4 counter(s) from %5 (now %6). + - + + %1 failed to undo their last draw. + + + + %1 sets counter %2 to %3 (%4%5). %1 sets counter %2 to %3 (%4%5). - + %1 sets %2 to not untap normally. %1 sets %2 to not untap normally. - + %1 sets %2 to untap normally. %1 sets %2 to untap normally. - + %1 removes the PT of %2. %1 removes the PT of %2. - + %1 changes the PT of %2 from nothing to %4. %1 changes the PT of %2 from nothing to %4. - + %1 changes the PT of %2 from %3 to %4. %1 changes the PT of %2 from %3 to %4. - + %1 has locked their sideboard. %1 has locked their sideboard. - + %1 has unlocked their sideboard. %1 has unlocked their sideboard. - + %1 taps their permanents. %1 taps their permanents. - + %1 untaps their permanents. %1 untaps their permanents. - + %1 taps %2. %1 taps %2. - + %1 untaps %2. %1 untaps %2. - + %1 shuffles %2. %1 shuffles %2. - + %1 shuffles the bottom %3 cards of %2. %1 shuffles the bottom %3 cards of %2. - + %1 shuffles the top %3 cards of %2. %1 shuffles the top %3 cards of %2. - + %1 shuffles cards %3 - %4 of %2. %1 shuffles cards %3 - %4 of %2. - + %1 unattaches %2. %1 unattaches %2. - + %1 undoes their last draw. %1 undoes their last draw. - + %1 undoes their last draw (%2). %1 undoes their last draw (%2). @@ -6316,110 +6407,115 @@ Cockatrice will now reload the card database. MessagesSettingsPage - + Word1 Word2 Word3 Word1 Word2 Word3 - + Add New Message Add New Message - + Edit Message Edit Message - + Remove Message Remove Message - + Add message Add message - - + + Message: Message: - + Edit message Edit message - + Chat settings Chat settings - + Custom alert words Custom alert words - + Enable chat mentions Enable chat mentions - + Enable mention completer Enable mention completer - + In-game message macros In-game message macros - + How to use in-game message macros How to use in-game message macros - + Ignore chat room messages sent by unregistered users Ignore chat room messages sent by unregistered users - + Ignore private messages sent by unregistered users Ignore private messages sent by unregistered users - - + + Ignore private messages sent by non-buddy users + + + + + Invert text color Invert text color - + Enable desktop notifications for private messages Enable desktop notifications for private messages - + Enable desktop notification for mentions Enable desktop notification for mentions - + Enable room message history on join Enable room message history on join - - + + (Color is hexadecimal) (Color is hexadecimal) - + Separate words with a space, alphanumeric characters only Separate words with a space, alphanumeric characters only @@ -6427,42 +6523,42 @@ Cockatrice will now reload the card database. MoveMenu - + Move to Move to - + &Top of library in random order &Top of library in random order - + X cards from the top of library... X cards from the top of library... - + &Bottom of library in random order &Bottom of library in random order - + T&able T&able - + &Hand &Hand - + &Graveyard &Graveyard - + &Exile &Exile @@ -6475,47 +6571,47 @@ Cockatrice will now reload the card database. Card Type - + Mana Value Mana Value - + Color(s) Color(s) - + Loyalty Loyalty - + Main Card Type Main Card Type - + Mana Cost Mana Cost - + P/T P/T - + Side Side - + Layout Layout - + Color Identity Color Identity @@ -6538,6 +6634,152 @@ Cockatrice will now reload the card database. Overlap direction: + + PaletteEditorDialog + + + + Reset + + + + + + Apply + + + + + + Save && Apply + + + + + ▼ Edit Palette + + + + + + ▶ Edit Palette + + + + + Palette Editor — %1 + + + + + <b>Palette Editor</b> &nbsp;·&nbsp; %1 + + + + + This theme ships no default palette files + + + + + Replace current colours with the theme author's defaults + + + + + Switch between the light and dark palette files + + + + + Editing: + + + + + Show or hide the per-role colour grid for manual tweaks + + + + + ↺ Revert to theme default + + + + + Discard unsaved edits and restore the last saved palette + + + + + Preview this palette without saving to disk + + + + + Write palette-%1.toml and reload the theme + + + + + Cannot save: this theme has no directory on disk + + + + + Save failed + + + + + Could not write %1 to: +%2 + + + + + No default found + + + + + No default palette file found for the "%1" scheme. + + + + + PaletteGridWidget + + + Active + + + + + Disabled + + + + + Inactive + + + + + Normal interactive state + + + + + Widget is disabled / not interactive + + + + + Window is in background / unfocused + + + Phase @@ -6604,57 +6846,57 @@ Cockatrice will now reload the card database. PhasesToolbar - + Untap step Untap step - + Upkeep step Upkeep step - + Draw step Draw step - + First main phase First main phase - + Beginning of combat step Beginning of combat step - + Declare attackers step Declare attackers step - + Declare blockers step Declare blockers step - + Combat damage step Combat damage step - + End of combat step End of combat step - + Second main phase Second main phase - + End of turn step End of turn step @@ -6662,7 +6904,7 @@ Cockatrice will now reload the card database. PictureLoader - + en code for scryfall's language property, not available for all languages en @@ -6671,151 +6913,142 @@ Cockatrice will now reload the card database. PlayerActions - - View top cards of library - View top cards of library - - - - - - - - - - - - Number of cards: (max. %1) - Number of cards: (max. %1) - - - - View bottom cards of library - View bottom cards of library - - - - Shuffle top cards of library - Shuffle top cards of library - - - - Shuffle bottom cards of library - Shuffle bottom cards of library - - - - Draw hand - Draw hand - - - - 0 and lower are in comparison to current hand size - 0 and lower are in comparison to current hand size - - - - Draw cards - Draw cards - - - - - - + + + + grave grave - - - - + + + + exile exile + + + PlayerDialogs - + + View top cards of library + + + + + + + + + + + + + Number of cards: (max. %1) + + + + + View bottom cards of library + + + + + Shuffle top cards of library + + + + + Shuffle bottom cards of library + + + + + Draw hand + + + + + 0 and lower are in comparison to current hand size + + + + + Draw cards + + + + Move top cards to %1 - Move top cards to %1 + - + Move bottom cards to %1 - Move bottom cards to %1 + - + Draw bottom cards - Draw bottom cards + - - - C&reate another %1 token - C&reate another %1 token - - - + Create tokens - Create tokens + - - + Number: - Number: + - + Place card X cards from top of library - Place card X cards from top of library + - + Which position should this card be placed: - Which position should this card be placed: + - + (max. %1) - (max. %1) + - + Change power/toughness - Change power/toughness + - + Change stats to: - Change stats to: + - + Set annotation - Set annotation + - + Please enter the new annotation: - Please enter the new annotation: - - - - Set counters - Set counters + PlayerMenu - + Player "%1" Player "%1" - + &Counters &Counters @@ -6849,7 +7082,7 @@ This setting means you'll only see the default printing for each card, inst Display Navigation Buttons - + Printing Selector Printing Selector @@ -6857,22 +7090,22 @@ This setting means you'll only see the default printing for each card, inst PrintingSelectorCardOverlayWidget - + Preference Preference - + Pin Printing Pin Printing - + Unpin Printing Unpin Printing - + Show Related cards Show Related cards @@ -6943,57 +7176,57 @@ This setting means you'll only see the default printing for each card, inst PtMenu - + Power / toughness Power / toughness - + &Increase power &Increase power - + &Decrease power &Decrease power - + I&ncrease toughness I&ncrease toughness - + D&ecrease toughness D&ecrease toughness - + In&crease power and toughness In&crease power and toughness - + Dec&rease power and toughness Dec&rease power and toughness - + Increase power and decrease toughness Increase power and decrease toughness - + Decrease power and increase toughness Decrease power and increase toughness - + Set &power and toughness... Set &power and toughness... - + Reset p&ower and toughness Reset p&ower and toughness @@ -7001,37 +7234,37 @@ This setting means you'll only see the default printing for each card, inst QMenuBar - + Services Services - + Hide %1 Hide %1 - + Hide Others Hide Others - + Show All Show All - + Preferences... Preferences... - + Quit %1 Quit %1 - + About %1 About %1 @@ -7039,17 +7272,17 @@ This setting means you'll only see the default printing for each card, inst QObject - + Cockatrice card database (*.xml) Cockatrice card database (*.xml) - + All files (*.*) All files (*.*) - + Cockatrice replays (*.cor) Cockatrice replays (*.cor) @@ -7121,110 +7354,177 @@ Are you sure you would like to disable this feature? QPlatformTheme - + OK OK - + Save Save - + Save All Save All - + Open Open - + &Yes &Yes - + Yes to &All Yes to &All - + &No &No - + N&o to All N&o to All - + Abort Abort - + Retry Retry - + Ignore Ignore - + Close Close - + Cancel Cancel - + Discard Discard - + Help Help - + Apply Apply - + Reset Reset - + Restore Defaults Restore Defaults + + QuickSetupPanel + + + %1% + + + + + <b>Quick Setup</b> + + + + + Generate all palette roles automatically from a single accent colour + + + + + Accent: + + + + + Primary hue. Used directly for highlights and links. +At high intensity it also tints buttons and backgrounds. + + + + + Intensity: + + + + + Subtle + + + + + Full colour + + + + + 0–30 Subtle tint — only highlights and links change hue +30–70 Accented — buttons, tooltips, and borders join in +70–100 Full colour — backgrounds, everything + + + + + 70% + + + + + Generate ↓ + + + + + Derive all palette roles from the accent colour above. +Fine-tune individual colours in the grid afterwards. + + + RemoteDeckList_TreeModel - + Name Name - + ID ID - + Upload time Upload time @@ -7232,32 +7532,32 @@ Are you sure you would like to disable this feature? RemoteReplayList_TreeModel - + ID ID - + Name Name - + Players Players - + Keep Keep - + Time started Time started - + Duration (sec) Duration (sec) @@ -7265,37 +7565,37 @@ Are you sure you would like to disable this feature? RfgMenu - + &Exile &Exile - + &View exile &View exile - + &Move exile to... &Move exile to... - + &Top of library &Top of library - + &Bottom of library &Bottom of library - + &Hand &Hand - + &Graveyard &Graveyard @@ -7341,7 +7641,7 @@ Are you sure you would like to disable this feature? SayMenu - + S&ay S&ay @@ -7382,27 +7682,27 @@ Are you sure you would like to disable this feature? SetsModel - + Enabled Enabled - + Set type Set type - + Set code Set code - + Long name Long name - + Release date Release date @@ -7410,53 +7710,53 @@ Are you sure you would like to disable this feature? ShortcutSettingsPage - - + + Restore all default shortcuts Restore all default shortcuts - + Do you really want to restore all default shortcuts? Do you really want to restore all default shortcuts? - + Clear all default shortcuts Clear all default shortcuts - + Do you really want to clear all shortcuts? Do you really want to clear all shortcuts? - + Section: Section: - + Action: Action: - + Shortcut: Shortcut: - + How to set custom shortcuts How to set custom shortcuts - + Clear all shortcuts Clear all shortcuts - + Search by shortcut name Search by shortcut name @@ -7512,12 +7812,12 @@ Please check your shortcut settings! SideboardMenu - + &Sideboard &Sideboard - + &View sideboard &View sideboard @@ -7525,27 +7825,27 @@ Please check your shortcut settings! SoundSettingsPage - + Enable &sounds Enable &sounds - + Current sounds theme: Current sounds theme: - + Test system sound engine Test system sound engine - + Sound settings Sound settings - + Master volume Master volume @@ -7617,16 +7917,146 @@ Please check your shortcut settings! Invalid reply received from the release update server. - + No reply received from the tag update server. No reply received from the tag update server. - + Invalid reply received from the tag update server. Invalid reply received from the tag update server. + + StorageSettingsPage + + + + + Success + + + + + Cached card pictures have been reset. + + + + + Downloaded card pictures have been reset. + + + + + Error + + + + + One or more downloaded card pictures could not be cleared. + + + + + In-memory (currently loaded) card pictures have been reset. + + + + + Card Picture Loader Caching Method: + + + + + The network cache is the preferred way of storing images. Downloaded images are stored here until the size of the cache exceeds the configured size. Cockatrice automatically monitors this cache and deletes the least recently seen card images to ensure the cache does not exceed the configured size. + + + + + Writing card images directly to a folder on your hard drive is another way of storing images. This does not change how Cockatrice accesses or downloads images. Cockatrice will NOT automatically monitor and clear this folder, so if you enable this option, it is up to you to ensure sufficient available space. It should also be noted that if a provider outage causes you to download the wrong picture (i.e. wrong printing) you will be stuck with it until you manually delete the file, as opposed to using the network cache, which automatically rotates and thus correct errors after a while. + + + + + This is the in-memory picture cache used by the application at runtime. It determines how much memory (RAM) Cockatrice can use before it has to fetch card images from the hard disk again. Increasing this will allow more card images to be displayed at once but shouldn't be necessary. Clearing this will make Cockatrice reload all images from the network cache or the disk. + + + + + Delete Cached Images + + + + + Delete Saved Images + + + + + Clear In-Memory Images + + + + + Card Picture Loader Cache Method + + + + + Network Cache + + + + + Filesystem + + + + + In-Memory Picture Cache + + + + + Network Cache Size: + + + + + On-disk cache for downloaded pictures + + + + + Redirect Cache TTL: + + + + + How long cached redirects for urls are valid for. + + + + + Picture Cache Size: + + + + + In-memory cache for pictures not currently on screen + + + + + Naming scheme: + + + + + Day(s) + + + TabAccount @@ -7762,117 +8192,117 @@ Please check your shortcut settings! TabArchidekt - - + + Desc. Desc. - - + + AND AND - - + + Require ALL selected colors Require ALL selected colors - - + + Deck name... Deck name... - - + + Owner... Owner... - - + + Packages Packages - - + + Advanced Filters Advanced Filters - + Bracket: Bracket: - - + + Any Any - - + + Contains card... Contains card... - - + + Commander... Commander... - - + + Tag... Tag... - - + + Deck Size Deck Size - + Cards: Cards: - - + + Asc. Asc. - + Sort by: Sort by: - + Filter by: Filter by: - + Display Settings Display Settings - - + + Search Search - - + + Formats Formats @@ -7882,6 +8312,54 @@ Please check your shortcut settings! Archidekt: + + TabCardArtRules + + + Card: + + + + + ProviderId: + + + + + Mode: + + + + + Reason: + + + + + Type a card name... + + + + + Add rule + + + + + Remove rule + + + + + Refresh + + + + + Card Art Rules + + + TabDeckEditor @@ -7930,7 +8408,7 @@ Please check your shortcut settings! Reset layout - + Deck: %1 Deck: %1 @@ -7938,55 +8416,49 @@ Please check your shortcut settings! TabDeckEditorVisual - + Visual Deck: %1 Visual Deck: %1 - + &Visual Deck Editor &Visual Deck Editor - - + + Card Info Card Info - - + + Deck Deck - - - Filters - Filters - - - + &View &View - + Printing Printing - + Visible Visible - + Floating Floating - + Reset layout Reset layout @@ -7994,22 +8466,22 @@ Please check your shortcut settings! TabDeckEditorVisualTabWidget - + Visual Deck View Visual Deck View - + Visual Database Display Visual Database Display - + Deck Analytics Deck Analytics - + Sample Hand Sample Hand @@ -8017,134 +8489,134 @@ Please check your shortcut settings! TabDeckStorage - + Local file system Local file system - + Server deck storage Server deck storage - - + + Open in deck editor Open in deck editor - + Rename deck or folder Rename deck or folder - + Upload deck Upload deck - + Download deck Download deck - - - + + + New folder New folder - + Delete Delete - + Open decks folder Open decks folder - + Rename local folder Rename local folder - + Rename local file Rename local file - + New name: New name: - - - - + + + + Error Error - + Rename failed Rename failed - - + + Invalid deck file Invalid deck file - + Enter deck name Enter deck name - + This decklist does not have a name. Please enter a name: This decklist does not have a name. Please enter a name: - + Unnamed deck Unnamed deck - + Failed to upload deck to server Failed to upload deck to server - + Delete local file Delete local file - + Are you sure you want to delete the selected files? Are you sure you want to delete the selected files? - + Delete remote decks Delete remote decks - + Are you sure you want to delete the selected decks? Are you sure you want to delete the selected decks? - - + + Name of new folder: Name of new folder: @@ -8216,191 +8688,191 @@ Please enter a name: TabGame - - - + + + Replay Replay - - + + Game Game - - + + Player List Player List - - + + Card Info Card Info - - + + Messages Messages - - + + Replay Timeline Replay Timeline - + &Phases &Phases - + &Game &Game - + Next &phase Next &phase - + Next phase with &action Next phase with &action - + Next &turn Next &turn - + Reverse turn order Reverse turn order - + &Remove all local arrows &Remove all local arrows - + Rotate View Cl&ockwise Rotate View Cl&ockwise - + Rotate View Co&unterclockwise Rotate View Co&unterclockwise - + Game &information Game &information - + Un&concede Un&concede - - - + + + &Concede &Concede - + &Leave game &Leave game - + C&lose replay C&lose replay - + &Focus Chat &Focus Chat - + &Say: &Say: - + Selected cards Selected cards - + &View &View - + Visible Visible - + Floating Floating - + Reset layout Reset layout - + Concede Concede - + Are you sure you want to concede this game? Are you sure you want to concede this game? - + Unconcede Unconcede - + You have already conceded. Do you want to return to this game? You have already conceded. Do you want to return to this game? - + Leave game Leave game - + Are you sure you want to leave this game? Are you sure you want to leave this game? - + A player has joined game #%1 A player has joined game #%1 - + %1 has joined the game %1 has joined the game - + You have been kicked out of the game. You have been kicked out of the game. @@ -8459,115 +8931,115 @@ Please enter a name: You have to select a valid number of days to locate. - + Username: Username: - + IP Address: IP Address: - + Game Name: Game Name: - + GameID: GameID: - + Message: Message: - + Main Room Main Room - + Game Room Game Room - + Private Chat Private Chat - + Past X Days: Past X Days: - + Today Today - + Last Hour Last Hour - + Maximum Results: Maximum Results: - + At least one filter is required. The more information you put in, the more specific your results will be. At least one filter is required. The more information you put in, the more specific your results will be. - + Get User Logs Get User Logs - + Clear Filters Clear Filters - + Filters Filters - + Log Locations Log Locations - + Date Range Date Range - + Maximum Results Maximum Results - - + + Message History Message History - + Failed to collect message history information. Failed to collect message history information. - + There are no messages for the selected filters. There are no messages for the selected filters. @@ -8585,27 +9057,27 @@ The more information you put in, the more specific your results will be.&Leave - + %1 - Private chat %1 - Private chat - + This user is ignoring you, they cannot see your messages in main chat and you cannot join their games. This user is ignoring you, they cannot see your messages in main chat and you cannot join their games. - + Private message from Private message from - + %1 has left the server. %1 has left the server. - + %1 has joined the server. %1 has joined the server. @@ -8613,181 +9085,181 @@ The more information you put in, the more specific your results will be. TabReplays - + Local file system Local file system - + Server replay storage Server replay storage - - + + Watch replay Watch replay - + Rename Rename - - + + New folder New folder - - + + Delete Delete - + Open replays folder Open replays folder - + Download replay Download replay - + Toggle expiration lock Toggle expiration lock - + Get replay share code Get replay share code - - + + Look up replay by share code Look up replay by share code - + Rename local folder Rename local folder - + Rename local file Rename local file - + New name: New name: - + Error Error - + Rename failed Rename failed - + Name of new folder: Name of new folder: - + Delete local file Delete local file - + Are you sure you want to delete the selected files? Are you sure you want to delete the selected files? - + Are you sure you want to delete the selected replays? Are you sure you want to delete the selected replays? - + Failed to get code Failed to get code - - + + Either this server does not support replay sharing, or does not permit replay sharing for you. Either this server does not support replay sharing, or does not permit replay sharing for you. - - - + + + Failed Failed - + Could not get replay code Could not get replay code - + Replay Share Code Replay Share Code - + Others can use this code to add the replay to their list of remote replays: %1 Others can use this code to add the replay to their list of remote replays: %1 - + Copy to clipboard Copy to clipboard - + Replay share code Replay share code - + Replay code found Replay code found - + Replay was added, or you already had access to it. Replay was added, or you already had access to it. - + Replay code not found Replay code not found - + Failed to submit code Failed to submit code - + Unexpected error Unexpected error - + Delete remote replay Delete remote replay @@ -8800,47 +9272,62 @@ The more information you put in, the more specific your results will be. TabRoom - + + Friends + + + + + Online + + + + + Ignored + + + + &Say: &Say: - + Chat Chat - + &Room &Room - + &Leave room &Leave room - + &Clear chat &Clear chat - + Chat Settings... Chat Settings... - + mentioned you. mentioned you. - + Click to view Click to view - + You are flooding the chat. Please wait a couple of seconds. You are flooding the chat. Please wait a couple of seconds. @@ -8853,30 +9340,30 @@ The more information you put in, the more specific your results will be.Server - - - - + + + + Error Error - + Failed to join the server room: it doesn't exist on the server. Failed to join the server room: it doesn't exist on the server. - + The server thinks you are in the server room but your client is unable to display it. Try restarting your client. The server thinks you are in the server room but your client is unable to display it. Try restarting your client. - + You do not have the required permission to join this server room. You do not have the required permission to join this server room. - + Failed to join the server room due to an unknown error: %1. Failed to join the server room due to an unknown error: %1. @@ -8884,97 +9371,97 @@ The more information you put in, the more specific your results will be. TabSupervisor - + Deck Editor Deck Editor - + Visual Deck Editor Visual Deck Editor - + EDHRec EDHRec - + Archidekt Archidekt - + Home Home - + &Visual Deck Storage &Visual Deck Storage - + Visual Database Display Visual Database Display - + Server Server - + Account Account - + Deck Storage Deck Storage - + Game Replays Game Replays - + Administration Administration - + Logs Logs - + Are you sure? Are you sure? - + There are still open games. Are you sure you want to quit? There are still open games. Are you sure you want to quit? - + Click to view Click to view - + Your buddy %1 has signed on! Your buddy %1 has signed on! - + Unknown Event Unknown Event - + The server has sent you a message that your client does not understand. This message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -8985,39 +9472,39 @@ This message might mean there is a new version of Cockatrice available or this s To update your client, go to Help -> Check for Updates. - + Idle Timeout Idle Timeout - + You are about to be logged out due to inactivity. You are about to be logged out due to inactivity. - + Promotion Promotion - + You have been promoted. Please log out and back in for changes to take effect. You have been promoted. Please log out and back in for changes to take effect. - + Warned Warned - + You have received a warning due to %1. Please refrain from engaging in this activity or further actions may be taken against you. If you have any questions, please private message a moderator. You have received a warning due to %1. Please refrain from engaging in this activity or further actions may be taken against you. If you have any questions, please private message a moderator. - + You have received the following message from the server. (custom messages like these could be untranslated) You have received the following message from the server. @@ -9027,12 +9514,12 @@ Please refrain from engaging in this activity or further actions may be taken ag TabVisualDatabaseDisplay - + Database Display Database Display - + Visual Database Display Visual Database Display @@ -9071,42 +9558,42 @@ Please refrain from engaging in this activity or further actions may be taken ag TranslateCounterName - + Life Life - + White White - + Blue Blue - + Black Black - + Red Red - + Green Green - + Colorless Colorless - + Other Other @@ -9114,11 +9601,69 @@ Please refrain from engaging in this activity or further actions may be taken ag UpdateDownloader - + Could not open the file for reading. Could not open the file for reading. + + UserCardArtSettingsDialog + + + Card Art Settings + + + + + Type a card name... + + + + + Card name: + + + + + Card ProviderId: + + + + + Left margin (%): + + + + + Right margin (%): + + + + + Vertical offset: + + + + + Zoom: + + + + + Parameters + + + + + Preview + + + + + Remove Banner Card + + + UserContextMenu @@ -9236,7 +9781,7 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Ban History Ban History @@ -9251,77 +9796,87 @@ Please refrain from engaging in this activity or further actions may be taken ag User has never been banned. - + Failed to collect ban information. Failed to collect ban information. - - - + + + Warning History Warning History - + Warning Time;Moderator;User Name;Reason Warning Time;Moderator;User Name;Reason - + User has never been warned. User has never been warned. - + Failed to collect warning information. Failed to collect warning information. - + Failed to get admin notes. Failed to get admin notes. - - + + Success Success - + Successfully promoted user. Successfully promoted user. - + Successfully demoted user. Successfully demoted user. - - - + + Kick Player + + + + + Are you sure you want to kick this player from the game? + + + + + + Failed Failed - + Failed to promote user. Failed to promote user. - + Failed to demote user. Failed to demote user. - + Copy hash to clipboard Copy hash to clipboard - + Remove this user's messages Remove this user's messages @@ -9329,326 +9884,554 @@ Please refrain from engaging in this activity or further actions may be taken ag UserInfoBox - + Location: Location: - + Account Age: Account Age: - + Edit Edit - + Change password Change password - + Change avatar Change avatar - + Administrator Administrator - + Moderator Moderator - + Registered user Registered user - - + + Unregistered user Unregistered user - + Judge Judge - + Unknown Unknown - + The entered password does not match your account. The entered password does not match your account. - - - + + + + Information Information - + User information updated. User information updated. - - - - - - - - - - + + + + + + + + + + + Error Error - + User Information User Information - + Real Name: Real Name: - + User Level: User Level: + + + Edit Banner Card + + - + %n Year(s), amount of years (only shown if more than 0) %n Year, %n Years, - + %10%n Day(s) %20 amount of years (if more than 0), amount of days, date in local short format %10%n Day %20%10%n Days %20 - + Enter Password Enter Password - + Password verification is required in order to change your email address Password verification is required in order to change your email address - - - + + + An error occurred while trying to update your user information. An error occurred while trying to update your user information. - + + The selected card is blacklisted on this server or another error occurred. + + + + + Banner card removed. + + + + + Banner card updated. + + + + This server does not permit you to update your user informations. This server does not permit you to update your user informations. - + Password changed. Password changed. - + This server does not permit you to change your password. This server does not permit you to change your password. - + The new password is too short. The new password is too short. - + The old password is incorrect. The old password is incorrect. - + Avatar updated. Avatar updated. - + This server does not permit you to update your avatar. This server does not permit you to update your avatar. + + + An error occured while trying to update your avatar. + + - An error occured while trying to updater your avatar. - An error occured while trying to updater your avatar. + This server does not permit you to update your user informations. + An error occured while trying to updater your avatar. + This server does not permit you to update your user informations. + + + + UserInfoPopup + + + + Games + + + + + Chat + + + + + Open private chat + + + + + Profile + + + + + View user profile + + + + + Show this user's games + + + + + − Buddy + + + + + Remove from buddy list + + + + + + Buddy + + + + + Add to buddy list + + + + + − Ignore + + + + + Remove from ignore list + + + + + + Ignore + + + + + Add to ignore list + + + + + Ban + + + + + Ban from server + + + + + Warn + + + + + Warn user + + + + + Ban log + + + + + View ban history + + + + + Warn log + + + + + View warning history + + + + + Notes + + + + + View admin notes + + + + + − Mod + + + + + Demote from moderator + + + + + + Mod + + + + + Promote to moderator + + + + + − Judge + + + + + Demote from judge + + + + + + Judge + + + + + Promote to judge + + + + + Join game + + + + + Spectate + + + + + + Loading games… + + + + + Could not load games. + + + + + No active games. + UserInterfaceSettingsPage - + General interface settings General interface settings - + &Double-click cards to play them (instead of single-click) &Double-click cards to play them (instead of single-click) - + &Clicking plays all selected cards (instead of just the clicked card) &Clicking plays all selected cards (instead of just the clicked card) - + &Play all nonlands onto the stack (not the battlefield) by default &Play all nonlands onto the stack (not the battlefield) by default - + Do not delete &arrows inside of subphases Do not delete &arrows inside of subphases - + Close card view window when last card is removed Close card view window when last card is removed - + Auto focus search bar when card view window is opened Auto focus search bar when card view window is opened - + Annotate card text on tokens Annotate card text on tokens - - Show selection counter during drag selection - Show selection counter during drag selection + + Show selection count during drag selection + - - Show total selection counter - Show total selection counter + + Show total selection count + - + + Show subtype breakdown in selection tally + + + + Use tear-off menus, allowing right click menus to persist on screen Use tear-off menus, allowing right click menus to persist on screen - + + Keep game chat focused when clicking in game (Note: disables card view search bar) + + + + Notifications settings Notifications settings - + Enable notifications in taskbar Enable notifications in taskbar - + Notify in the taskbar for game events while you are spectating Notify in the taskbar for game events while you are spectating - + Notify in the taskbar when users in your buddy list connect Notify in the taskbar when users in your buddy list connect - + Animation settings Animation settings - + &Tap/untap animation &Tap/untap animation - + Deck editor/storage settings Deck editor/storage settings - + Open deck in new tab by default Open deck in new tab by default - + Use visual deck storage in game lobby Use visual deck storage in game lobby - + Use selection animation for Visual Deck Storage Use selection animation for Visual Deck Storage - + When adding a tag in the visual deck storage to a .txt deck: When adding a tag in the visual deck storage to a .txt deck: - + do nothing do nothing - + ask to convert to .cod ask to convert to .cod - + always convert to .cod always convert to .cod - + Default deck editor type Default deck editor type - + Classic Deck Editor Classic Deck Editor - + Visual Deck Editor Visual Deck Editor - + Replay settings Replay settings - + Buffer time for backwards skip via shortcut: Buffer time for backwards skip via shortcut: @@ -9656,22 +10439,22 @@ Please refrain from engaging in this activity or further actions may be taken ag UserListWidget - + Users connected to server: %1 Users connected to server: %1 - + Users in this room: %1 Users in this room: %1 - + Buddies online: %1 / %2 Buddies online: %1 / %2 - + Ignored users online: %1 / %2 Ignored users online: %1 / %2 @@ -9679,35 +10462,45 @@ Please refrain from engaging in this activity or further actions may be taken ag UtilityMenu - + Increment all card counters Increment all card counters - + &Untap all permanents &Untap all permanents - + R&oll die... R&oll die... - + + Flip coin + + + + &Create token... &Create token... - + C&reate another token C&reate another token - + Cr&eate predefined token Cr&eate predefined token + + + C&reate another %1 token + + VisualDatabaseDisplayColorFilterWidget @@ -9759,72 +10552,72 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayFilterToolbarWidget - + Sort by Sort by - + Filter by Filter by - + Save and load filters Save and load filters - + Filter by exact card name Filter by exact card name - + Filter by card main-type Filter by card main-type - + Filter by card sub-type Filter by card sub-type - + Filter by set Filter by set - + Filter by format legality Filter by format legality - + Save/Load Save/Load - + Name Name - + Main Type Main Type - + Sub Type Sub Type - + Sets Sets - + Formats Formats @@ -9832,22 +10625,22 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayFormatLegalityFilterWidget - + Show formats with at least: Show formats with at least: - + cards cards - + Do not display formats with less than this amount of cards in the database Do not display formats with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) Filter mode (AND/OR/NOT conjunctions of filters) @@ -9865,32 +10658,32 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayMainTypeFilterWidget - + Show main types with at least: Show main types with at least: - + cards cards - + Do not display card main-types with less than this amount of cards in the database Do not display card main-types with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) Filter mode (AND/OR/NOT conjunctions of filters) - + Mode: Exact Match Mode: Exact Match - + Mode: Includes Mode: Includes @@ -9898,27 +10691,27 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayNameFilterWidget - + Filter by name... (Exact match) Filter by name... (Exact match) - + Load from Deck Load from Deck - + Apply all card names in currently loaded deck as exact match name filters Apply all card names in currently loaded deck as exact match name filters - + Load from Clipboard Load from Clipboard - + Apply all card names in clipboard as exact match name filters Apply all card names in clipboard as exact match name filters @@ -9926,7 +10719,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayRecentSetFilterSettingsWidget - + Filter to most recent sets Filter to most recent sets @@ -9934,19 +10727,19 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplaySetFilterWidget - + Search sets... Search sets... - - + + Mode: Exact Match Mode: Exact Match - - + + Mode: Includes Mode: Includes @@ -9954,37 +10747,37 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplaySubTypeFilterWidget - + Search subtypes... Search subtypes... - + Show sub types with at least: Show sub types with at least: - + cards cards - + Do not display card sub-types with less than this amount of cards in the database Do not display card sub-types with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) Filter mode (AND/OR/NOT conjunctions of filters) - + Mode: Exact Match Mode: Exact Match - + Mode: Includes Mode: Includes @@ -9992,23 +10785,23 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayWidget - + Search by card name (or search expressions) Search by card name (or search expressions) - + Visual Visual - + Loading database ... Loading database ... - + Clear all filters Clear all filters @@ -10047,17 +10840,17 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Toggle Layout: Overlap Toggle Layout: Overlap - + Change how cards are displayed within zones (i.e. overlapped or fully visible.) Change how cards are displayed within zones (i.e. overlapped or fully visible.) - + Toggle Layout: Flat Toggle Layout: Flat @@ -10086,17 +10879,17 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckEditorWidget - + Type a card name here for suggestions from the database... Type a card name here for suggestions from the database... - + Quick search and add card Quick search and add card - + Search for closest match in the database (with auto-suggestions) and add preferred printing to the deck on pressing enter Search for closest match in the database (with auto-suggestions) and add preferred printing to the deck on pressing enter @@ -10214,43 +11007,43 @@ Please refrain from engaging in this activity or further actions may be taken ag WarningDialog - + Which warning would you like to send? Which warning would you like to send? - + Redact all messages from this user in all rooms Redact all messages from this user in all rooms - + &OK &OK - + &Cancel &Cancel - + Warn user for misconduct Warn user for misconduct - - + + Error Error - + User name to send a warning to can not be blank, please specify a user to warn. User name to send a warning to can not be blank, please specify a user to warn. - + Warning to use can not be blank, please select a valid warning to send. Warning to use can not be blank, please select a valid warning to send. @@ -10258,133 +11051,133 @@ Please refrain from engaging in this activity or further actions may be taken ag WndSets - + Move selected set to the top Move selected set to the top - + Move selected set up Move selected set up - + Move selected set down Move selected set down - + Move selected set to the bottom Move selected set to the bottom - + Search by set name, code, or type Search by set name, code, or type - + Default order Default order - + Restore original art priority order Restore original art priority order - + Enable all sets Enable all sets - + Disable all sets Disable all sets - + Enable selected set(s) Enable selected set(s) - + Disable selected set(s) Disable selected set(s) - + Deck Editor Deck Editor - + Use CTRL+A to select all sets in the view. Use CTRL+A to select all sets in the view. - + Only cards in enabled sets will appear in the card list of the deck editor. Only cards in enabled sets will appear in the card list of the deck editor. - + Image priority is decided in the following order: Image priority is decided in the following order: - + first the CUSTOM Folder (%1), then the Enabled Sets in this dialog (Top to Bottom) %1 is a link to the wiki first the CUSTOM Folder (%1), then the Enabled Sets in this dialog (Top to Bottom) - + Include cards rebalanced for Alchemy [requires restart] Include cards rebalanced for Alchemy [requires restart] - + Card Art Card Art - + How to use custom card art How to use custom card art - + Hints Hints - + Note Note - + Sorting by column allows you to find a set while not changing set priority. Sorting by column allows you to find a set while not changing set priority. - + To enable ordering again, click the column header until this message disappears. To enable ordering again, click the column header until this message disappears. - + Use the current sorting as the set priority instead Use the current sorting as the set priority instead - + Sorts the set priority using the same column Sorts the set priority using the same column - + Manage sets Manage sets @@ -10392,72 +11185,72 @@ Please refrain from engaging in this activity or further actions may be taken ag ZoneViewWidget - + Search by card name (or search expressions) Search by card name (or search expressions) - + Ungrouped Ungrouped - + Group by Type Group by Type - + Group by Mana Value Group by Mana Value - + Group by Color Group by Color - + Unsorted Unsorted - + Sort by Name Sort by Name - + Sort by Type Sort by Type - + Sort by Mana Cost Sort by Mana Cost - + Sort by Colors Sort by Colors - + Sort by P/T Sort by P/T - + Sort by Set Sort by Set - + shuffle when closing shuffle when closing - + pile view pile view @@ -10465,7 +11258,7 @@ Please refrain from engaging in this activity or further actions may be taken ag i18n - + English English @@ -10473,12 +11266,12 @@ Please refrain from engaging in this activity or further actions may be taken ag main - + Connect on startup Connect on startup - + Debug to file Debug to file @@ -10492,7 +11285,7 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Deck Editor Deck Editor @@ -10573,7 +11366,7 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Replays Replays @@ -10688,129 +11481,129 @@ Please refrain from engaging in this activity or further actions may be taken ag Load Deck... - - + + Load Deck from Clipboard... Load Deck from Clipboard... - + Edit Deck in Clipboard, Annotated Edit Deck in Clipboard, Annotated - + Edit Deck in Clipboard Edit Deck in Clipboard - + New Deck New Deck - + Open Custom Pictures Folder Open Custom Pictures Folder - + Print Deck... Print Deck... - + Delete Card Delete Card - - + + Reset Layout Reset Layout - + Save Deck Save Deck - + Save Deck as... Save Deck as... - + Save Deck to Clipboard, Annotated Save Deck to Clipboard, Annotated - + Save Deck to Clipboard, Annotated (No Set Info) Save Deck to Clipboard, Annotated (No Set Info) - + Save Deck to Clipboard Save Deck to Clipboard - + Save Deck to Clipboard (No Set Info) Save Deck to Clipboard (No Set Info) - + Load Local Deck... Load Local Deck... - + Load Remote Deck... Load Remote Deck... - + Set Ready to Start Set Ready to Start - + Toggle Sideboard Lock Toggle Sideboard Lock - + Add Green Counter Add Green Counter - + Remove Green Counter Remove Green Counter - + Set Green Counters... Set Green Counters... - + Add Red Counter Add Red Counter - + Remove Red Counter Remove Red Counter - + Set Red Counters... Set Red Counters... - + Add Life Counter Add Life Counter @@ -10820,724 +11613,744 @@ Please refrain from engaging in this activity or further actions may be taken ag Show Status Bar - + + Load deck from online service... + + + + + Load from website... + + + + Unload Deck Unload Deck - + Force Start Force Start - + Add Card Counter (F) Add Card Counter (F) - + Remove Card Counter (F) Remove Card Counter (F) - + Set Card Counters (F)... Set Card Counters (F)... - + Add Card Counter (E) Add Card Counter (E) - + Remove Card Counter (E) Remove Card Counter (E) - + Set Card Counters (E)... Set Card Counters (E)... - + Add Card Counter(D) Add Card Counter(D) - + Remove Card Counter (D) Remove Card Counter (D) - + Set Card Counters (D)... Set Card Counters (D)... - + Add Card Counter (C) Add Card Counter (C) - + Remove Card Counter (C) Remove Card Counter (C) - + Set Card Counters (C)... Set Card Counters (C)... - + Add Card Counter (B) Add Card Counter (B) - + Remove Card Counter (B) Remove Card Counter (B) - + Set Card Counters (B)... Set Card Counters (B)... - + Add Card Counter (A) Add Card Counter (A) - + Remove Card Counter (A) Remove Card Counter (A) - + Set Card Counters (A)... Set Card Counters (A)... - + Remove Life Counter Remove Life Counter - + Set Life Counters... Set Life Counters... - + Add White Counter Add White Counter - + Remove White Counter Remove White Counter - + Set White Counters... Set White Counters... - + Add Blue Counter Add Blue Counter - + Remove Blue Counter Remove Blue Counter - + Set Blue Counters... Set Blue Counters... - + Add Black Counter Add Black Counter - + Remove Black Counter Remove Black Counter - + Set Black Counters... Set Black Counters... - + Add Colorless Counter Add Colorless Counter - + Remove Colorless Counter Remove Colorless Counter - + Set Colorless Counters... Set Colorless Counters... - + Add Other Counter Add Other Counter - + Remove Other Counter Remove Other Counter - + Set Other Counters... Set Other Counters... - + Increment all card counters Increment all card counters - + Add Power (+1/+0) Add Power (+1/+0) - + Remove Power (-1/-0) Remove Power (-1/-0) - + Move Toughness to Power (+1/-1) Move Toughness to Power (+1/-1) - + Add Toughness (+0/+1) Add Toughness (+0/+1) - + Remove Toughness (-0/-1) Remove Toughness (-0/-1) - + Move Power to Toughness (-1/+1) Move Power to Toughness (-1/+1) - + Add Power and Toughness (+1/+1) Add Power and Toughness (+1/+1) - + Remove Power and Toughness (-1/-1) Remove Power and Toughness (-1/-1) - + Set Power and Toughness... Set Power and Toughness... - + Reset Power and Toughness Reset Power and Toughness - + Untap Untap - + Upkeep Upkeep - + Draw Draw - + First Main Phase First Main Phase - + Start Combat Start Combat - + Attack Attack - + Block Block - + Damage Damage - + End Combat End Combat - + Second Main Phase Second Main Phase - + End End - + Next Phase Next Phase - + Next Phase Action Next Phase Action - + Next Turn Next Turn - + Hide Card in Reveal Window Hide Card in Reveal Window - + Tap / Untap Card Tap / Untap Card - + Untap All Untap All - + Toggle Skip Untapping Toggle Untap Toggle Skip Untapping - + Turn Card Over Turn Card Over - + Peek Card Peek Card - + Play Card Play Card - + Play Card, Face Down Play Card, Face Down - + Attach Card... Attach Card... - + Unattach Card Unattach Card - + Clone Card Clone Card - + Create Token... Create Token... - + Create All Related Tokens Create All Related Tokens - + Create Another Token Create Another Token - + Set Annotation... Set Annotation... - + + Reduce Life by Power + + + + Select All Cards in Zone Select All Cards in Zone - + Select All Cards in Row Select All Cards in Row - + Select All Cards in Column Select All Cards in Column - + Reveal Selected Cards to All Players Reveal Selected Cards to All Players - - + + Bottom of Library Bottom of Library - - - - + + + + Exile Exile - - - - + + + + Graveyard Graveyard - - + + Hand Hand - - + + Top of Library Top of Library - - + + Battlefield, Face Down Battlefield, Face Down - + Battlefield Battlefield - + Library Library - + Sideboard Sideboard - + Top Cards of Library Top Cards of Library - + Bottom Cards of Library Bottom Cards of Library - + Close Recent View Close Recent View - - + + Stack Stack - - + + Graveyard (Multiple) Graveyard (Multiple) - - + + Graveyard (Multiple), Face Down Graveyard (Multiple), Face Down - - + + Exile (Multiple) Exile (Multiple) - - + + Exile (Multiple), Face Down Exile (Multiple), Face Down - + Stack Until Found Stack Until Found - + Draw Bottom Card Draw Bottom Card - + Draw Multiple Cards from Bottom... Draw Multiple Cards from Bottom... - + Draw Arrow... Draw Arrow... - + Remove Local Arrows Remove Local Arrows - + Leave Game Leave Game - + Concede Concede - + Roll Dice... Roll Dice... - + + Flip Coin + + + + Shuffle Library Shuffle Library - + Shuffle Top Cards of Library Shuffle Top Cards of Library - + Shuffle Bottom Cards of Library Shuffle Bottom Cards of Library - + Mulligan Mulligan - + Mulligan (Same hand size) Mulligan (Same hand size) - + Mulligan (Hand size - 1) Mulligan (Hand size - 1) - + Draw a Card Draw a Card - + Draw Multiple Cards... Draw Multiple Cards... - + Undo Draw Undo Draw - + Always Reveal Top Card Always Reveal Top Card - + Always Look At Top Card Always Look At Top Card - + Sort Hand by Name Sort Hand by Name - + Sort Hand by Type Sort Hand by Type - + Sort Hand by Mana Value Sort Hand by Mana Value - + Reveal Hand to All Players Reveal Hand to All Players - + Reveal Random Card to All Players Reveal Random Card to All Players - + Rotate View Clockwise Rotate View Clockwise - + Rotate View Counterclockwise Rotate View Counterclockwise - + Unfocus Text Box Unfocus Text Box - + Focus Chat Focus Chat - + Clear Chat Clear Chat - + Refresh Refresh - + Skip Forward Skip Forward - + Skip Backward Skip Backward - + Skip Forward by a lot Skip Forward by a lot - + Skip Backward by a lot Skip Backward by a lot - + Play/Pause Play/Pause - + Toggle Fast Forward Toggle Fast Forward - + Home Home - + Visual Deck Storage Visual Deck Storage - + Deck Storage Deck Storage - + Server Server - + Account Account - + Administration Administration - + Logs Logs diff --git a/cockatrice/translations/cockatrice_es.ts b/cockatrice/translations/cockatrice_es.ts index 53fc9ac61..2a0e479c3 100644 --- a/cockatrice/translations/cockatrice_es.ts +++ b/cockatrice/translations/cockatrice_es.ts @@ -2,7 +2,7 @@ AbstractCounter - + &Set counter... &Establecer contador... @@ -10,12 +10,12 @@ AbstractCounterDialog - + Set counter Establecer contador - + New value for counter '%1': Nuevo valor para el contador '%1': @@ -30,66 +30,66 @@ Parse Set Name and Number (if available) - + Interpretar nombre del expansión y numero (si disponible) AbstractTabDeckEditor - + Open in new tab Abrir en nueva pestaña - + Are you sure? ¿Estás seguro? - + The decklist has been modified. Do you want to save the changes? La lista del mazo ha sido modificada. ¿Deseas guardar los cambios? - - - - - - + + + + + + Error Error - + Could not open deck at %1 - + No se pudo abrir mazo en %1 - + Could not save remote deck - + No se pudo guardar mazo remoto - - + + The deck could not be saved. Please check that the directory is writable and try again. - + Save deck Guardar mazo - + The deck could not be saved. El mazo no puede ser guardado. - + There are no cards in your deck to be exported No hay cartas en tu mazo para exportar @@ -99,18 +99,18 @@ Please check that the directory is writable and try again. Add Analytics Panel - + Añadir panel de análisis AdminNotesDialog - + Update Notes Notas de actualización - + Admin Notes for %1 @@ -127,172 +127,227 @@ Please check that the directory is writable and try again. Sideboard Banquillo + + + Tokens + + AppearanceSettingsPage - + seconds segundos - + Error Error - + Could not create themes directory at '%1'. No se pudo crear directorio de temas en '%1'. - + Theme settings Preferencias del tema - + Current theme: Tema actual: - + Open themes folder Abrir carpeta de temas - + Home tab background source: - + Fuente de segundo plano de pestaña de incio: - + Home tab background shuffle frequency: - + Disabled Deshabilitado - - Display card name of background in bottom right: - - - - + Menu settings - + Preferencias del menú - + Show keyboard shortcuts in right-click menus - + Show game filter toolbar above list in room tab - + Card rendering Representación de las cartas - + Display card names on cards having a picture Mostrar nombre de las cartas en aquellas que tengan imagen - + Auto-Rotate cards with sideways layout - + Override all card art with personal set preference (Pre-ProviderID change behavior) - + + Light + + + + + Dark + + + + + System + + + + + Active theme palette: + + + + + Edit theme palette + + + + + Home tab settings + + + + + Display card name of background in bottom right + + + + + Styling settings + + + + + Style user list + + + + + Card printings + + + + Bump sets that the deck contains cards from to the top in the printing selector - + Scale cards on mouse over Cambiar tamaño de las cartas al pasar el ratón por encima - + Use rounded card corners + Usar esquinas redondeadas de las cartas + + + + Card layout - + Minimum overlap percentage of cards on the stack and in vertical hand - + Maximum initial height for card view window: - - + + rows filas - + Maximum expanded height for card view window: - + Card counters Contadores de carta - + Counter %1 - + Contador %1 - + Hand layout Disposición de la mano - + Display hand horizontally (wastes space) Mostrar la mano horizontalmente (desperdicia espacio) - + Enable left justification Habilitar justificación a la izquierda - + Table grid layout Diseño de la cuadrícula de la mesa - + Invert vertical coordinate Invertir coordenada vertical - + Minimum player count for multi-column layout: Número minimo de jugadores para usar la cuadrícula multicolumna: - + Maximum font size for information displayed on cards: Tamaño de fuente máxima para la información presentada en las cartas: @@ -300,14 +355,14 @@ Please check that the directory is writable and try again. ArchidektApiResponseDeckDisplayWidget - + Back to results - + Regressar a los resultados - + Open Deck in Deck Editor - + Abrir mazo en editor de mazos @@ -315,7 +370,7 @@ Please check that the directory is writable and try again. Theme - + Tema @@ -331,112 +386,112 @@ Please check that the directory is writable and try again. BanDialog - + ban &user name banear &usuario - + ban &IP address banear dirección IP &IP - + ban client I&D banear por I&D de cliente - + Ban type Tipo de ban - + &permanent ban acceso prohibido &permanentemente - + &temporary ban acceso prohibido &temporalmente - + &Days: &Días: - + &Hours: &Horas: - + &Minutes: &Minutos: - + Duration of the ban Duración del ban - + Please enter the reason for the ban. This is only saved for moderators and cannot be seen by the banned person. Por favor, introduce el motivo de la restricción de acceso Se almacenará unicamente para moderadores y no podrá ser visto por la persona restringida. - + Please enter the reason for the ban that will be visible to the banned person. Por favor, introduce el motivo de la restricción que será visible para la persona restringida. - + Redact all messages from this user in all rooms Redactar todos los mensajes de este usuario en todas las salas - + &OK Aceptar (&O) - + &Cancel &Cancelar - + Ban user from server Banear usuario del servidor - - - - + + + + Error Error - + You have to select a name-based, IP-based, clientId based, or some combination of the three to place a ban. Debes seleccionar baneo por nombre, por IP, por id de cliente, o cualquier combinación de los tres. - + You must have a value in the name ban when selecting the name ban checkbox. Debes introducir un nombre cuando la casilla de baneo por nombre está activada. - + You must have a value in the ip ban when selecting the ip ban checkbox. Debes introducir una IP cuando la casilla de baneo por IP está activada. - + You must have a value in the clientid ban when selecting the clientid ban checkbox. Debes introducir un id de cliente cuando la casilla de baneo por id de cliente está activada. @@ -444,59 +499,123 @@ Se almacenará unicamente para moderadores y no podrá ser visto por la persona BetaReleaseChannel - + Beta Beta - + No reply received from the release update server. No se recibió respuesta del servidor de actualización. - + Invalid reply received from the release update server. Respuesta inválida del servidor de actualización. - + No reply received from the file update server. Sin respuesta por parte del servidor de actualización de archivos + + CardArtPreviewWidget + + + No card selected + + + + + CardArtRulesModel + + + Card + + + + + ProviderId + + + + + Mode + + + + + Reason + + + CardDatabaseModel - + Name Nombre - + Sets Ediciones - + Mana cost Coste de maná - + Card type Tipo de carta - + P/T F/R - + Color(s) Color(es) + + CardDatabaseView + + + Add to Deck + + + + + Add to Sideboard + + + + + Select Printing + + + + + Show on EDHRec (Commander) + + + + + Show on EDHRec (Card) + + + + + Show Related cards + + + CardFilter @@ -531,7 +650,7 @@ Se almacenará unicamente para moderadores y no podrá ser visto por la persona Name (Exact) - + Nombre (exacto) @@ -619,28 +738,28 @@ Se almacenará unicamente para moderadores y no podrá ser visto por la persona View transformation - + Ver transformación CardInfoPictureWidget - + View related cards Ver cartas relacionadas - + Add card to deck Añadir carta al mazo - + Mainboard Mazo principal - + Sideboard Banquillo @@ -655,7 +774,7 @@ Se almacenará unicamente para moderadores y no podrá ser visto por la persona Set: - + Expansión @@ -663,12 +782,12 @@ Se almacenará unicamente para moderadores y no podrá ser visto por la persona - + Related cards: Cartas relacionadas: - + Unknown card: Carta desconocida: @@ -676,128 +795,146 @@ Se almacenará unicamente para moderadores y no podrá ser visto por la persona CardMenu - + Re&veal to... - + &All players &Todos los jugadores - + View related cards Ver cartas relacionadas - + Token: Ficha: - + All tokens Todas las fichas - + &Select All &Seleccionar todo - + S&elect Row &Seleccionar fila - + S&elect Column &Seleccionar columna - + &Play &Jugar - + &Hide &Ocultar - + Play &Face Down Jugar boca abajo - + &Tap / Untap Turn sideways or back again &Girar / Enderezar - + Skip &untapping - + T&urn Over Turn face up/face down - + &Peek at card face - + &Clone &Clonar - + Attac&h to card... - + Anexar a carta... - + Unattac&h - + Desanexar - + &Draw arrow... - + Dibujar flecha... - + &Set annotation... + Escribir anotación... + + + + Reduce life by power - + Ca&rd counters - + Contadores de carta - + &Add counter (%1) &Añadir contador (%1) - + &Remove counter (%1) &Quitar contador (%1) - + &Set counters (%1)... &Establecer contadores (%1) + + CardPictureLoaderCacheMethod + + + Network Cache + + + + + Filesystem + + + CardSizeWidget @@ -809,136 +946,136 @@ Se almacenará unicamente para moderadores y no podrá ser visto por la persona CardZoneLogic - + their hand nominative su mano - + %1's hand nominative - + el mano de %1 - + their library look at zone su biblioteca - + %1's library look at zone - + la biblioteca de %1 - + of their library top cards of zone, de su biblioteca - + of %1's library top cards of zone - + de la biblioteca de %1 - + their library reveal zone su biblioteca - + %1's library reveal zone - + la biblioteca de %1 - + their library shuffle su biblioteca - + %1's library shuffle - + la biblioteca de %1 - + their library nominative su biblioteca - + %1's library nominative - + la biblioteca de %1 - + their graveyard nominative su cementerio - + %1's graveyard nominative - + el cementerio de %1 - + their exile nominative su zona de exilio - + %1's exile nominative - + exilio de %1 - + their sideboard look at zone su banquillo - + %1's sideboard look at zone - + el banquillo de %1 - + their sideboard nominative su banquillo - + %1's sideboard nominative - + el banquillo de %1 - + their custom zone '%1' nominative - + su zona personalizada '%1' - + %1's custom zone '%2' nominative - + la zona personalizada de %1 '%2' @@ -958,48 +1095,458 @@ Se almacenará unicamente para moderadores y no podrá ser visto por la persona - CustomZoneMenu + ColorButton - - C&ustom Zones + + Click to pick a color - - - View custom zone '%1' + + Pick colour + + ConnectionController + + + + The server has reached its maximum user capacity, please check back later. + + + + + There are too many concurrent connections from your address. + + + + + Banned by moderator + + + + + Expected end time: %1 + + + + + This ban lasts indefinitely. + + + + + Scheduled server shutdown. + + + + + + Invalid username. + + + + + You have been logged out due to logging in at another location. + + + + + Connection closed + + + + + The server has terminated your connection. +Reason: %1 + + + + + The server is going to be restarted in %n minute(s). +All running games will be lost. +Reason for shutdown: %1 + + + + + Scheduled server shutdown + + + + + Failed Login + + + + + Your client seems to be missing features this server requires for connection. + + + + + To update your client, go to 'Help -> Check for Client Updates'. + + + + + + + + + + + + + + + + + + + + + + Error + + + + + Incorrect username or password. Please check your authentication information and try again. + + + + + There is already an active session using this user name. +Please close that session first and re-login. + + + + + + You are banned until %1. + + + + + + You are banned indefinitely. + + + + + This server requires user registration. Do you want to register now? + + + + + This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. +Please close and reopen your client to try again. + + + + + An internal error has occurred, please close and reopen Cockatrice before trying again. +If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. + + + + + Account activation + + + + + Your account has not been activated yet. +You need to provide the activation token received in the activation email. + + + + + Server Full + + + + + Unknown login error: %1 + + + + + + +This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. + + + + + + + + + + Registration denied + + + + + Registration is currently disabled on this server + + + + + There is already an existing account with the same user name. + + + + + It's mandatory to specify a valid email address when registering. + + + + + It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. + + + + + Password too short. + + + + + Registration failed for a technical problem on the server. + + + + + The connection to the server has been lost. + + + + + Unknown registration error: %1 + + + + + Account activation failed + + + + + Socket error: %1 + + + + + Server timeout + + + + + You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. +Local version is %1, remote version is %2. + + + + + Your Cockatrice client is obsolete. Please update your Cockatrice version. +Local version is %1, remote version is %2. + + + + + + Success + + + + + Registration accepted. +Will now login. + + + + + Account activation accepted. +Will now login. + + + + + Information + + + + + This server supports additional features that your client doesn't have. +This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. + +To update your client, go to Help -> Check for Updates. + + + + + + + Reset Password + + + + + Your password has been reset successfully, you can now log in using the new credentials. + + + + + Failed to reset user account password, please contact the server operator to reset your password. + + + + + Activation request received, please check your email for an activation token. + + + + + Connecting to %1... + + + + + Registering to %1 as %2... + + + + + Disconnected + + + + + Connected, logging in at %1 + + + + + Requesting forgotten password to %1 as %2... + + + + + Your username must respect these rules: + + + + + is %1 - %2 characters long + + + + + can %1 contain lowercase characters + + + + + + + + NOT + + + + + can %1 contain uppercase characters + + + + + can %1 contain numeric characters + + + + + can contain the following punctuation: %1 + + + + + first character can %1 be a punctuation mark + + + + + no unacceptable language as specified by these server rules: + note that the following lines will not be translated + + + + + can not contain any of the following words: %1 + + + + + can not match any of the following expressions: %1 + + + + + You may only use A-Z, a-z, 0-9, _, ., and - in your username. + + + + + CustomZoneMenu + + + C&ustom Zones + Zonas Personalizadas + + + + + View custom zone '%1' + Ver la zona personalizada '%1' + + DeckAnalyticsWidget - + Add Panel - + Añadir panel - + Remove Panel - + Borrar panel - + Save Layout - + Guardar diseño - + Load Layout + Cargar diseño + + + + Include Sideboard DeckEditorCardDatabaseDockWidget - + Card Database - + Base de datos de cartas @@ -1013,47 +1560,17 @@ Se almacenará unicamente para moderadores y no podrá ser visto por la persona DeckEditorDatabaseDisplayWidget - + Search by card name (or search expressions) Buscar por nombre de carta (o buscar expresiones) - - Add to Deck - Añadir al Mazo - - - - Add to Sideboard - Añadir al Banquillo - - - - Select Printing - - - - - Show on EDHRec (Commander) - - - - - Show on EDHRec (Card) - - - - - Show Related cards - Mostrar cartas Relacionadas - - - + Add card to &maindeck Añadir carta al &mazo principal - + Add card to &sideboard Añadir carta al &banquillo @@ -1063,7 +1580,7 @@ Se almacenará unicamente para moderadores y no podrá ser visto por la persona Loading Database... - + Cargando la base de datos... @@ -1086,72 +1603,72 @@ Se almacenará unicamente para moderadores y no podrá ser visto por la persona Colores - + Select Printing - + Deck Mazo - + Deck &name: &Nombre del mazo: - + Banner Card/Tags Visibility Settings - + Show banner card selection menu - + Show tags selection menu - + &Comments: &Comentarios: - + Group by: Agrupar por: - + Format: - + Formato: - + Hash: - + Hash: - + &Increment number &Incrementar número - + &Decrement number &Reducir número - + &Remove row &Quitar fila - + Swap card to/from sideboard @@ -1159,17 +1676,17 @@ Se almacenará unicamente para moderadores y no podrá ser visto por la persona DeckEditorFilterDockWidget - + Filters Filtros - + &Clear all filters &Limpiar todos los filtros - + Delete selected Borrar seleccionado @@ -1251,12 +1768,12 @@ Se almacenará unicamente para moderadores y no podrá ser visto por la persona &Print deck... - + Imprimir mazo... Load deck from online service... - + Cargar mazo desde el servicio en línea... @@ -1300,186 +1817,130 @@ Se almacenará unicamente para moderadores y no podrá ser visto por la persona DeckEditorSettingsPage - - + + Update Spoilers Actualizar Spoilers - - + Success Éxito - + Download URLs have been reset. Las URL de descarga se han reiniciado. - - Downloaded card pictures have been reset. - Las imágenes de las cartas descargadas se han reiniciado. - - - - Error - Error - - - - One or more downloaded card pictures could not be cleared. - Una o más imágenes descargadas pueden no ser claras - - - + Add URL Añadir URL - - + + URL: URL: - - + + Edit URL Editar URL - - Network Cache Size: - - - - - Redirect Cache TTL: - - - - - How long cached redirects for urls are valid for. - - - - - Picture Cache Size: - - - - + Add New URL Añadir URL - + Remove URL Borrar URL - - Day(s) - Día(s) - - - + Updating... Actualizando... - + Choose path Elegir camino - + URL Download Priority Prioridad de descarga de URL - + Spoilers Spoilers - + Download Spoilers Automatically Descargar Spoilers automáticamente - + Spoiler Location: Localización del Spoiler: - + Last Change Último cambio - + Spoilers download automatically on launch Spoilers se descargan automáticamente en lanzamiento - + Press the button to manually update without relaunching Presione el botón para actualizar manualmente sin reiniciar - + Do not close settings until manual update is complete No cierre la ventana de configuración hasta que la actualización manual este completa. - + Download card pictures on the fly Descargar imagenes de las cartas rapidamente - + How to add a custom URL Cómo agregar una URL personalizada - - Delete Downloaded Images - Eliminar imágenes descargadas - - - + Reset Download URLs Reiniciar descarga de URLs - - - On-disk cache for downloaded pictures - - - - - In-memory cache for pictures not currently on screen - - DeckListHistoryManagerWidget Undo - + Deshacer Redo - + Rehacer Undo/Redo history - + Historial de deshacer/rehacer @@ -1489,12 +1950,12 @@ Se almacenará unicamente para moderadores y no podrá ser visto por la persona [redo] - + [rehacer] [undo] - + [deshacer] @@ -1502,12 +1963,12 @@ Se almacenará unicamente para moderadores y no podrá ser visto por la persona Count - + Conteo Set - + Expansión @@ -1528,14 +1989,14 @@ Se almacenará unicamente para moderadores y no podrá ser visto por la persona DeckLoader - + Common deck formats (%1) - + All files (*.*) - + Todos los archivos (*.*) @@ -1543,12 +2004,12 @@ Se almacenará unicamente para moderadores y no podrá ser visto por la persona Mode: Exact Match - + Modo: Coincidencia Exacta Mode: Includes - + Modo: Incluye @@ -1614,12 +2075,12 @@ Se almacenará unicamente para moderadores y no podrá ser visto por la persona Tag name cannot be empty! - + ¡Nombre de etiqueta no puede estar vacío! Duplicate Tag - + Etiqueta Duplicada @@ -1637,7 +2098,7 @@ Se almacenará unicamente para moderadores y no podrá ser visto por la persona Open in deck editor - + Abrir en editor de mazos @@ -1652,7 +2113,7 @@ Se almacenará unicamente para moderadores y no podrá ser visto por la persona Save Deck to Clipboard - + Guardar mazo al portapapeles @@ -1752,32 +2213,32 @@ Se almacenará unicamente para moderadores y no podrá ser visto por la persona Added (%1): %2 (%3) %4 - + Añadido (%1): %2 (%3) %4 - + Moved to %1 1 × "%2" (%3) - + Removed "%1" (all copies) - + %1 1 × "%2" (%3) - + Added - + Añadido - + Removed - + Borrado @@ -1797,74 +2258,74 @@ Se almacenará unicamente para moderadores y no podrá ser visto por la persona DeckViewContainer - + Load deck... Cargar mazo... - + Load remote deck... Cargar mazo remoto... - + Load from clipboard... Cargar desde el portapapeles... - + Load from website... - + Cargar desde el sitio web... - + Unload deck - + Ready to start Listo para empezar - + Force start Forzar inicio - + Sideboard unlocked Banquillo desbloqueado - + Sideboard locked Banquillo bloqueado - - + + Error Error - + The selected file could not be loaded. El fichero seleccionado no pudo cargarse. - + Deck is greater than maximum file size. - + Are you sure you want to force start? This will kick all non-ready players from the game. - + Cockatrice Cockatrice @@ -1963,7 +2424,7 @@ This will kick all non-ready players from the game. - + Webpage Página web @@ -2003,37 +2464,37 @@ This will kick all non-ready players from the game. Conectar al Servidor - + Server URL dirección URL del servidor - + Communication Port Puerto de comunicación - + Unique Server Name Nombre único para el servidor - + Connection Warning Advertencia de Conexión - + You need to name your new connection profile. Necesita dar nombre a su nuevo perfil de conexión. - + Connect Warning Advertencia de conexión - + The player name can't be empty. El nombre del jugador no puede estar vacío. @@ -2146,17 +2607,17 @@ This will kick all non-ready players from the game. Crear partida - + Game information Información de la partida - + Error Error - + Server error. Error del servidor. @@ -2164,97 +2625,97 @@ This will kick all non-ready players from the game. DlgCreateToken - + &Name: &Nombre: - + Token Ficha - + C&olor: &Color: - + white blanco - + blue azul - + black negro - + red rojo - + green verde - + multicolor multicolor - + colorless incoloro - + &P/T: &F/R: - + &Annotation: &Anotación: - + &Destroy token when it leaves the table &Destruir la ficha cuando deje la mesa - + Create face-down (Only hides name) - + Token data Datos de la ficha - + Show &all tokens Mostrar &todas las fichas - + Show tokens from this &deck Mostrar todas las fichas de este &mazo - + Choose token from list Elegir ficha de la lista - + Create token Crear ficha @@ -2288,7 +2749,7 @@ This will kick all non-ready players from the game. - + @@ -2303,12 +2764,12 @@ This will kick all non-ready players from the game. El nombre de la etiqueta no puede estar vacío! - + Duplicate Tag Duplicar etiqueta - + This tag already exists. Esta etiqueta ya existe. @@ -2359,7 +2820,7 @@ Para eliminar tu avatar actual, confirma sin elegir una nueva imagen. Edit deck in clipboard - + Editar mazo en el portapapeles @@ -2495,12 +2956,12 @@ Para eliminar tu avatar actual, confirma sin elegir una nueva imagen.Por favor, introduzca el nombre de la ficha: - + Error Error - + The chosen name conflicts with an existing card or token. Make sure to enable the 'Token' set in the "Manage sets" dialog to display them correctly. El nombre elegido ya existe y está siendo usado por una carta o ficha. @@ -2525,12 +2986,12 @@ Asegúrese de habilitar la edición 'Fichas' en la opción "Mante Indefinido - + Real name: Nombre real: - + Edit user profile Editar perfil de usuario @@ -2568,113 +3029,113 @@ Asegúrese de habilitar la edición 'Fichas' en la opción "Mante 2 horas - + Hide 'buddies only' games - + Esconder los partidos de solo amigos - + Hide full games - + Esconder los partidos llenos - + Hide games that have started - + Hide password protected games - + Hide 'ignored user' games - + Esconder los partidos de usuarios ignorados - + Hide games not created by buddies Hide games not created by buddy - + Esconder los partidos no creados por amigos - + Hide games with forced open decklists - + &Newer than: &Más nuevo que: - + Game &description: &Descipción de la partida: - + &Creator name: &Nombre del creador: - + General General - + &Game types Tipos de &partida - + at &least: al &menos: - + at &most: &como máximo: - + Maximum player count Número máximo de jugadores - + Restrictions Restricciones - + Show games only if &spectators can watch Mostrar partidas solo si &espectadores pueden ver - + Show spectator password p&rotected games Mostrar contraseña de espectador para juegos p&rotegidos - + Show only if spectators can ch&at Mostrar solo si los espectadores pueden ha&blar - + Show only if spectators can see &hands Mostrar solo si los espectadores pueden ver &manos - + Spectators Espectadores - + Filter games Filtrar partidas @@ -2898,7 +3359,7 @@ Asegúrese de habilitar la edición 'Fichas' en la opción "Mante Load Deck from Website - + Cargar mazo desde el sitio web @@ -2946,17 +3407,17 @@ https://tappedout.net/mtg-decks/your-deck-name/ Players: - + Jugadores: General - + General Starting life total: - + Total de vidas inicial: @@ -2966,7 +3427,7 @@ https://tappedout.net/mtg-decks/your-deck-name/ Remember settings - + Recordar preferencias @@ -2977,37 +3438,37 @@ https://tappedout.net/mtg-decks/your-deck-name/ DlgMoveTopCardsUntil - + Card name (or search expressions): - + Nombre de carta (o buscar expresiones): - + Number of hits: - + Auto play hits - + Put top cards on stack until... - + No cards matching the search expression exists in the card database. Proceed anyways? - + Cockatrice Cockatrice - + Invalid filter Filtro no válido @@ -3067,40 +3528,40 @@ Tu correo electrónico se utilizará para verificar tu cuenta. Indefinido - + Real name: Nombre real: - + Register to server Registrarse en el servidor - - - - + + + + Registration Warning Advertencia durante el registro - + Your password is too short. Tu contraseña es demasiado corta. - + Your passwords do not match, please try again. Tus contraseñas no coinciden, por favor inténtalo de nuevo. - + Your email addresses do not match, please try again. Tus direcciones de correo electrónico no coinciden, por favor inténtalo de nuevo. - + The player name can't be empty. El nombre del jugador no puede estar vacío. @@ -3108,17 +3569,17 @@ Tu correo electrónico se utilizará para verificar tu cuenta. DlgRollDice - + Number of sides: - + Number of dice: Número de dado: - + Roll Dice Lanzar dados @@ -3169,12 +3630,12 @@ Tu correo electrónico se utilizará para verificar tu cuenta. DlgSettings - + Unknown Error loading card database Error desconocido al cargar la base de datos de cartas. - + Your card database is invalid. Cockatrice may not function correctly with an invalid database @@ -3191,7 +3652,7 @@ Podrías necesitar volver a ejecutar oracle para actualizar tu base de datos de ¿Quieres cambiar la ubicación de tu base de datos? - + Your card database version is too old. This can cause problems loading card information or images @@ -3208,7 +3669,7 @@ Normalmente esto se soluciona volviendo a ejecutar oracle para actualizar tu bas ¿Quieres cambiar la ubicación de tu base de datos? - + Your card database did not finish loading Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues with your cards.xml attached @@ -3221,7 +3682,7 @@ Por favor, envía un ticket en https://github.com/Cockatrice/Cockatrice/issues c ¿Te gustaría cambiar la configuración de la ubicación de tu base de datos? - + File Error loading your card database. Would you like to change your database location setting? @@ -3230,7 +3691,7 @@ Would you like to change your database location setting? ¿Quieres cambiar la ubicación de tu base de datos? - + Your card database was loaded but contains no cards. Would you like to change your database location setting? @@ -3239,7 +3700,7 @@ Would you like to change your database location setting? ¿Quieres cambiar la ubicación de tu base de datos? - + Unknown card database load status Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues @@ -3252,59 +3713,64 @@ Por favor, envía un ticket en https://github.com/Cockatrice/Cockatrice/issues ¿Te gustaría cambiar la configuración de la ubicación de tu base de datos? - - - + + + Error Error - + The path to your deck directory is invalid. Would you like to go back and set the correct path? La ruta a tu directorio de mazos no es válida. ¿Deseas volver y seleccionar la ruta correcta? - + The path to your card pictures directory is invalid. Would you like to go back and set the correct path? La ruta a tu directorio de imágenes de las cartas no es válida. ¿Deseas volver y seleccionar la ruta correcta? - + Settings Preferencias - + General General - + Appearance Apariencia - + User Interface Interfaz de usuario - + + Storage + + + + Card Sources Origen de la carta - + Chat Chat - + Sound Sonido - + Shortcuts Atajos de teclado @@ -3336,7 +3802,7 @@ You can always change this behavior in the 'General' settings tab. Run in background and always from now on - + Ejecutar en segundo plano y siempre a partir de ahora @@ -3396,9 +3862,9 @@ You can always change this behavior in the 'General' settings tab. - - - + + + Error Error @@ -3451,31 +3917,31 @@ Por favor, visita la página de descargas para actualizarlo manualmente. - + Update Available Actualización disponible - + A new version of Cockatrice is available! ¡Una nueva version de Cockatrice está disponible! - + New version Nueva versión - + Released Lanzado - + Changelog Registro de cambios @@ -3485,50 +3951,50 @@ Por favor, visita la página de descargas para actualizarlo manualmente.¿Quieres actualizar ahora? - + Unfortunately, the automatic updater failed to find a compatible download. You may have to manually download the new version. - + Please check the <a href="%1">releases page</a> on our Github and download the build for your system. - - - + + + Update Error Error en la actualización - + An error occurred while checking for updates: Ha ocurrido un error mientras se buscaban actualizaciones: - + An error occurred while downloading an update: Ha ocurrido un error mientras se descargaba una actualización: - + Installing... Instalando... - + Cockatrice is unable to open the installer. Cockatrice es incapaz de abrir el instalador. - + Try to update manually by closing Cockatrice and running the installer. Intente actualizar manualmente cerrando Cockatrice y ejecutando el instalador. - + Download location Ubicación de descarga @@ -3543,7 +4009,7 @@ You may have to manually download the new version. Copy to clipboard - + Copiar al portapapeles @@ -3566,17 +4032,17 @@ You may have to manually download the new version. Card Name - + Nombre de la carta Type - + Tipo Subtype - + Subtipo @@ -3591,7 +4057,7 @@ You may have to manually download the new version. At least - + Al menos @@ -3612,73 +4078,73 @@ You may have to manually download the new version. cards - + cartas DrawProbabilityWidget - + Draw Probability - + Probability of drawing - + Card Name - + Nombre de la carta + + + + Type + Tipo + + + + Subtype + Subtipo - Type - - - - - Subtype - - - - Mana Value - + At least - + Al menos - + Exactly - + card(s) having drawn at least - + cards - + cartas - + Category - + Qty - + Odds (%) @@ -3741,7 +4207,7 @@ You may have to manually download the new version. Tcgplayer - + TCGplayer @@ -3792,7 +4258,7 @@ You may have to manually download the new version. FilterBuilder - + Type your filter here Escriba su filtro aquí @@ -3807,7 +4273,7 @@ You may have to manually download the new version. Are you sure you want to delete the filter '%1'? - + ¿Estás seguro/a de que quieres eliminar el filtro '%1'? @@ -3823,22 +4289,22 @@ You may have to manually download the new version. GameEventHandler - + kicked by game host or moderator expulsado por el anfitrión del juego o moderador - + player left the game jugador ha dejado la partida - + player disconnected from server jugador se ha desconectado del servidor - + reason unknown razón desconocida @@ -3846,140 +4312,140 @@ You may have to manually download the new version. GameSelector - - - - - - - - - + + + + + + + + + Error Error - + Please join the appropriate room first. Por favor, entre a la sala adecuada primero. - + Wrong password. Contraseña incorrecta. - + Spectators are not allowed in this game. No se permiten espectadores en esta partida. - + The game is already full. La partida no tiene plazas libres. - + The game does not exist any more. La partida ya no existe. - + This game is only open to registered users. Esta partida está abierta sólo a usuarios registrados. - + This game is only open to its creator's buddies. Esta partida está abierta sólo a los amigos del creador. - + You are being ignored by the creator of this game. Estás siendo ignorado por el creador de la partida. - + Join Game Unirse a partida - + Spectate Game Espectar partida - + Game Information Información de la partida - + Join Game as Judge - + Spectate Game as Judge - + Join game Entrar en la partida - + Password: Contraseña: - + Please join the respective room first. Por favor, entre primero en la sala respectiva. - + &Filter games &Filtrar partidas - + C&lear filter &Limpiar filtro - + C&reate C&rear - + &Join Unirse (&J) - + Join as judge - + J&oin as spectator Entrar como e&spectador - + Join as judge spectator - + Games shown: %1 / %2 Juegos mostrados: %1 / %2 - + Games Partidas @@ -3987,32 +4453,32 @@ You may have to manually download the new version. GameSelectorQuickFilterToolBar - + All types - + Todos los tipos - + Filter by game name... - + Filtrar por nombre de partida... - + Filter by game type/format - + Hide games not created by buddies - + Esconder los partidos no creados por amigos - + Hide full games - + Hide started games @@ -4020,105 +4486,105 @@ You may have to manually download the new version. GamesModel - + >1 day >1 day - + %1%2 hr short age in hours %1%2 hora%1%2 horas%1%2 horas - + new nuevo - + %1%2 min short age in minutes %1%2 min%1%2 min%1%2 min - + password contraseña - + buddies only solo amigos - + reg. users only solo usuarios registrados - + open decklists - + can chat pueden usar el chat - + see hands ver manos - + can see hands puede ver manos - + not allowed no permitidos - + Room Sala - + Age Edad - + Description Descripción - + Creator Creador - + Type Tipo - + Restrictions Restricciones - + Players Jugadores - + Spectators Espectadores @@ -4126,143 +4592,158 @@ You may have to manually download the new version. GeneralSettingsPage - + Reset all paths Reiniciar todos los caminos - + All paths have been reset Todos los caminos se han reiniciado - - - - - - - + + + + + + + Choose path Elija ruta - - Personal settings - Preferencias personales + + Language settings + - + Language: Idioma: - + + Version settings + + + + + Card database + + + + + Startup settings + + + + Paths (editing disabled in portable mode) Rutas (edición deshabilitada en modo portatil) - + Paths Rutas - + How to help with translations Cómo ayudar con las traducciones - + Decks directory: Directorio de mazos: - + Filters directory: Directorio de filtros: - + Replays directory: Directorio de repeticiones: - + Pictures directory: Directorio de imágenes: - + Card database: Base de datos de cartas: - + Custom database directory: Directorio de base de datos personalizado: - + Token database: Base de datos de tokens: - + Update channel Actualizar canal - + Check for client updates on startup Comprobar actualizaciones del cliente al arrancar - + Check for card database updates on startup Comprobar actualizaciones de la base de datos de cartas al arrancar - + Don't check No comprobar - + Prompt for update - + Always update in the background Siempre actualizar en segundo plano - + Check for card database updates every Comprobar actualizaciones de la base de datos de cartas cada - + days días - + Notify if a feature supported by the server is missing in my client Notificar si hace falta en mi cliente alguna característica soportada por el servidor - + Automatically run Oracle when running a new version of Cockatrice Oracle se ejecutará automáticamente cuando se ejecuté una nueva versión de Cockatrice - + Show tips on startup Mostrar tips al inicio - + Last update check on %1 (%2 days ago) @@ -4270,47 +4751,47 @@ You may have to manually download the new version. GraveyardMenu - + &Graveyard &Cementerio - + &View graveyard Ver &cementerio - + &Move graveyard to... &Mover cementerio a... - + &Top of library &Parte superior de la biblioteca - + &Bottom of library &Parte inferior de la biblioteca - + &All players - + Todos los jugadores - + &Hand &Mano - + &Exile &Exilio - + Reveal random card to... Mostrar una carta al azar a... @@ -4318,88 +4799,88 @@ You may have to manually download the new version. HandMenu - + &Hand &Mano - + &View hand &Ver mano - + Sort hand by... - + Name - + Nombre - + Type - + Tipo - + Mana Value - + Take &mulligan (Choose hand size) - + Take mulligan (Same hand size) - + Take mulligan (Hand size - 1) - + &Move hand to... &Mover mano a... - + &Top of library &Parte superior de la biblioteca - + &Bottom of library &Parte inferior de la biblioteca - + &Graveyard &Cementerio - + &Exile &Exilio - + &Reveal hand to... &Mostrar mano a... - - + + All players - + Todos los jugadores - + Reveal r&andom card to... Mostrar una carta al azar a... @@ -4407,52 +4888,52 @@ You may have to manually download the new version. HomeWidget - + Create New Deck Crear nuevo mazo - + Browse Decks Buscar mazos - + Browse Card Database Buscar base de datos de cartas - + Browse EDHRec - + Buscar EDHRec - + Browse Archidekt - + Buscar Archidekt - + View Replays Ver repeticiones - + Quit Quitar - + Connecting... Conectando... - + Connect Conectar - + Play Jugar @@ -4460,213 +4941,213 @@ You may have to manually download the new version. LibraryMenu - + &Library &Biblioteca - + &View library &Ver biblioteca - + View &top cards of library... Ver cartas de la parte superior de la biblioteca - + View bottom cards of library... Ver cartas de la parte inferior de la biblioteca - + Reveal &library to... Mostrar &biblioteca a... - + Lend library to... - + Prestar la biblioteca a... - + Reveal &top cards to... Mostrar &cartas de la parte superior a... - + &Top of library... &Parte superior de la biblioteca... - + &Bottom of library... &Parte inferior de la biblioteca... - + &Always reveal top card - + Mostrar siempre la carta superior - + &Always look at top card - + Ver siempre la carta superior - + &Open deck in deck editor - + Abrir mazo en editor de mazos - + &Draw card &Robar carta - + D&raw cards... - + &Undo last draw - + Deshacer último robo - + Shuffle Barajar - + &Play top card &Jugar carta de la parte superior - + Play top card &face down - + Jugar carta de la parte superior boca abajo - + Put top card on &bottom - + Move top card to grave&yard - + Mover la carta superior al cementario - + Move top card to e&xile - + Mover la carta superior al exilio - + Move top cards to &graveyard... - + Mover las cartas superiores al cementario... - + Move top cards to graveyard face down... - + Mover las cartas superiores al cementario boca abajo... - + Move top cards to &exile... - + Mover las cartas superiores al exilio... - + Move top cards to exile face down... - + Mover las cartas superiores al exilio boca abajo... - + Put top cards on stack &until... - + Shuffle top cards... Barajar cartas de la parte superior... - + &Draw bottom card - &Robar carta de la parte inferior + &Robar carta inferior - + D&raw bottom cards... - + Robar cartas inferiores... - + &Play bottom card - &Jugar carta de la parte inferior + &Jugar carta inferior - + Play bottom card &face down - + Jugar carta inferior boca abajo - + Move bottom card to grave&yard - + Mover la carta inferior al cementario - + Move bottom card to e&xile - + Mover la carta inferior al exilio - + Move bottom cards to &graveyard... - + Mover las cartas inferiores al cementario... - + Move bottom cards to graveyard face down... - + Mover las cartas inferiores al cementario boca abajo... - + Move bottom cards to &exile... - + Mover las cartas inferiores al exilio... - + Move bottom cards to exile face down... - + Mover las cartas inferiores al exilio boca abajo... - + Put bottom card on &top - Poner carta de la parte inferior en la parte superior + Poner carta inferior en la parte superior - + Shuffle bottom cards... Barajar cartas de la parte inferior... - - + + &All players - + Todos los jugadores - + Reveal top cards of library - + Number of cards: (max. %1) @@ -4674,640 +5155,273 @@ You may have to manually download the new version. MainWindow - - - The server has reached its maximum user capacity, please check back later. - Este servidor ha alcanzado su capacidad máxima de usuarios, por favor inténtalo más tarde. - - - - There are too many concurrent connections from your address. - Hay demasiadas conexiones simultaneas desde tu dirección. - - - - Banned by moderator - Baneado por el moderador - - - - Expected end time: %1 - Tiempo de finalización estimado: %1 - - - - This ban lasts indefinitely. - Este ban permanecerá indefinidamente. - - - - Scheduled server shutdown. - Apagado programado del servidor. - - - - - Invalid username. - El nombre de usuario no es válido. - - - - You have been logged out due to logging in at another location. - Se ha cerrado tu sesión debido a que se ha iniciado en otra ubicación. - - - - Connection closed - Conexión cerrada - - - - The server has terminated your connection. -Reason: %1 - El servidor ha finalizado tu conexión. -Motivo: %1 - - - - The server is going to be restarted in %n minute(s). -All running games will be lost. -Reason for shutdown: %1 - El servidor será reiniciado en un minuto. -Todas las partidas en curso se cerraran. -Motivo para el apagado: %1El servidor será reiniciado en %n minuto(s). -Todas las partidas en curso se cerraran. -Motivo para el apagado: %1El servidor será reiniciado en %n minuto(s). -Todas las partidas en curso se cerraran. -Motivo para el apagado: %1 - - - - Scheduled server shutdown - Apagado programado del servidor - - - - - Success - Éxito - - - - Registration accepted. -Will now login. - Registro aceptado. -Iniciando sesión. - - - - Account activation accepted. -Will now login. - Activación de la cuenta aceptada. -Iniciando sesión. - - - - + + Player %1 Jugador %1 - + Load replay Cargar repetición - + About Cockatrice Acerca de Cockatrice - + Version Versión - + Cockatrice Webpage Página de Cockatrice - + Project Manager: Jefe de Proyecto: - + Past Project Managers: Antiguos Jefes de Proyecto: - + Developers: Desarrolladores: - + Our Developers Nuestros desarrolladores - + Help Develop! ¡Ayuda a desarrollar! - + Translators: Traductores: - + Our Translators Nuestros Traductores - + Help Translate! ¡Ayuda a traducir! - + Support: Soporte: - + Report an Issue Informa de un problema - + Troubleshooting Problemas frecuentes - + F.A.Q. Preguntas frecuentes - - - - - - - - - - - - - - - - - - - - + + Error Error - - Server timeout - Tiempo de espera del servidor agotado - - - - Failed Login - Inicio de sesión fallido - - - - Your client seems to be missing features this server requires for connection. - Tu cliente parece no tener las características requeridas para la conexión de este servidor. - - - - To update your client, go to 'Help -> Check for Client Updates'. - Para actualizar tu cliente, ir a 'Ayuda->Marcar para Actualizaciones del Cliente' - - - - Incorrect username or password. Please check your authentication information and try again. - Nombre de usuario o contraseña incorrecto. Por favor, verifica tu información de autentificación y vuelve a intentarlo. - - - - There is already an active session using this user name. -Please close that session first and re-login. - Ya existe una sesión activa usando ese nombre de usuario. -Por favor, cierra esa sesión primero y reintentalo. - - - - - You are banned until %1. - Estás baneado hasta %1. - - - - - You are banned indefinitely. - Estás baneado indefinidamente. - - - - This server requires user registration. Do you want to register now? - Este servidor requiere el registro de usuarios. ¿Quieres registrarte ahora? - - - - This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. -Please close and reopen your client to try again. - Este servidor exige IDs de cliente. Tu cliente no está generando un ID o estás ejecutando un cliente modificado. Cierra y vuelve a abrir el cliente para intentarlo de nuevo. - - - - An internal error has occurred, please close and reopen Cockatrice before trying again. -If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. - Ha ocurrido un error interno. Cierra y vuelve a abrir Cockatrice antes de intentar de nuevo. -Si el error persiste, asegúrate de que estás ejecutando la versión más reciente del software y contacta a los desarrolladores del software si es necesario. - - - - Account activation - Activación de cuenta - - - - Your account has not been activated yet. -You need to provide the activation token received in the activation email. - Tu cuenta aún no ha sido activada. -Tienes que proporcionar el código recibido en el email de activación - - - - Server Full - Servidor Lleno - - - - Unknown login error: %1 - Error de inicio de sesión desconocido: %1 - - - - - -This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. - -Esto suele ser debido a que tu versión del cliente está desfasada y el servidor ha enviado una respuesta que tu cliente no comprende. - - - - Your username must respect these rules: - Tu nombre de usuario debe: - - - - is %1 - %2 characters long - Tener de %1 a %2 caracteres de longitud. - - - - can %1 contain lowercase characters - %1 Puede tener minúsculas. - - - - - - - NOT - NO - - - - can %1 contain uppercase characters - %1 Puede tener mayúsculas. - - - - can %1 contain numeric characters - %1 Puede tener caracteres númericos. - - - - can contain the following punctuation: %1 - Puede tener los siguientes signos de puntuación: %1 - - - - first character can %1 be a punctuation mark - El primer caracter %1 puede ser un signo de puntuación. - - - - no unacceptable language as specified by these server rules: - note that the following lines will not be translated - - - - - can not contain any of the following words: %1 - No puede contener ninguna de las siguientes palabras: %1 - - - - can not match any of the following expressions: %1 - No puede coincidir ninguna de las siguientes expresiones: %1 - - - - You may only use A-Z, a-z, 0-9, _, ., and - in your username. - Sólo puedes usar A-Z, a-z, 0-9, _, ., y - en tu nombre de usuario. - - - - - - - - - Registration denied - Registro denegado - - - - Registration is currently disabled on this server - El registro está actualmente deshabilitado en este servidor - - - - There is already an existing account with the same user name. - Ya existe una cuenta con ese nombre. - - - - It's mandatory to specify a valid email address when registering. - Es obligatorio especificar una dirección de correo válida al registrarse. - - - - It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. - Parece ser que usted está intentando registrar una cuenta nueva en este servidor; sin embargo usted ya tiene una cuetna registrada con el correo provisto. Este servidor restringe el número de cuentas que un usuario puede registrar por correo electrónico. Por favor contacte al operador del servidor para asistencia o para obtener la información de sus credenciales. - - - - Password too short. - La contraseña es demasiado corta. - - - - Registration failed for a technical problem on the server. - El registro falló debido a un problema técnico en el servidor. - - - - The connection to the server has been lost. - Se ha perdido la conexión con el servidor. - - - - Unknown registration error: %1 - Error de registro desconocido: %1 - - - - Account activation failed - La activación de cuenta falló. - - - - Socket error: %1 - Error del Socket: %1 - - - - You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. -Local version is %1, remote version is %2. - Estás intentando conectar a un servidor obsoleto. Por favor, usa una versión anterior de Cockatrice o conecta a un servidor apropiado. -La versión local es %1, la versión remota es %2. - - - - Your Cockatrice client is obsolete. Please update your Cockatrice version. -Local version is %1, remote version is %2. - Tu cliente de Cockatrice está obsoleto. Por favor, actualiza tu versión de Cockatrice. -La versión local es %1, la versión remota es %2. - - - - Connecting to %1... - Conectando a %1... - - - - Registering to %1 as %2... - Registrando en %1 como %2... - - - - Disconnected - Desconectado - - - - Connected, logging in at %1 - Conectado, hora de inicio de sesión: %1 - - - - - - Requesting forgotten password to %1 as %2... - Solicitando contraseña olvidada de %1 como %2... - - - + &Connect... &Conectar... - + &Disconnect &Desconectar - + Start &local game... Empezar partida &local... - + &Watch replay... &Ver repetición... - + &Full screen &Pantalla completa - + &Register to server... &Registrarse en el servidor... - + &Restore password... &Restaurar contraseña... - + &Settings... &Preferencias... - + &Exit &Salir - + A&ctions A&cciones - + &Cockatrice &Cockatrice - + C&ard Database Base de datos de c&artas - + &Manage sets... &Mantenedor de ediciones... - + Edit custom &tokens... Editar &fichas personalizadas... - + Open custom image folder Abrir carpeta de imágenes personalizadas - + Open custom sets folder Abrir carpeta de ediciones personalizadas - + Add custom sets/cards Agregar cartas/ediciones personalizadas - + Reload card database Recargar la base de datos de cartas - + Tabs Pestañas - + &Help A&yuda - + &About Cockatrice &Acerca de Cockatrice - + &Tip of the Day &Tip del Día - + Check for Client Updates Buscar Actualizaciones de Cliente - + Check for Card Updates... Comprobar actualizaciones de cartas... - + Check for Card Updates (Automatic) Comprobar actualizaciones de cartas (Automático) - + Show Status Bar Mostrar barra de estatus - + View &Debug Log Ver &registro de debugs - + Open Settings Folder Abrir carpeta de preferencias - + Show/Hide Mostrar/Ocultar - + New Version Nueva Versión - + Congratulations on updating to Cockatrice %1! Oracle will now launch to update your card database. Felicitaciones se ha actualizado Cockatrice %1! Oracle actualizará tu base de datos de cartas. - + Cockatrice installed Cockatrice está instalado. - + Congratulations on installing Cockatrice %1! Oracle will now launch to install the initial card database. ¡Felicitaciones! Se ha instalado Cockatrice %1 Ahora se ejecutará Oracle para instalar la base de datos de cartas inicial. - + Card database Base de datos de cartas - + Cockatrice is unable to load the card database. Do you want to update your card database now? If unsure or first time user, choose "Yes" @@ -5316,29 +5430,29 @@ If unsure or first time user, choose "Yes" Si no está seguro o es su primera vez, seleccione "Sí" - - + + Yes Si - - + + No No - + Open settings Abrir preferencias - + New sets found Nuevas ediciones encontradas - + %n new set(s) found in the card database Set code(s): %1 Do you want to enable it/them? @@ -5351,17 +5465,22 @@ Códigos de sets: %1 ¿Quieres habilitarlos? - + + Yes, always enable + + + + View sets Ver ediciones - + Welcome Bienvenido/a - + Hi! It seems like you're running this version of Cockatrice for the first time. All the sets in the card database have been enabled. Read more about changing the set order or disabling specific sets and consequent effects in the "Manage Sets" dialog. @@ -5370,165 +5489,132 @@ Todas las ediciones en la base de datos de cartas han sido habilitadas. Aprenda más sobre cambiar el orden de la edición o deshabilitando ediciones especificas y otras preferencias en la opción "Mantenedor de ediciones". - - + Information Información - + A card database update is already running. La actualización de la base de datos de cartas ya está en marcha. - + Unable to run the card database updater: Imposible iniciar el actualizador de la base de datos de cartas: - + Card database update running. - + Failed to start. The file might be missing, or permissions might be incorrect. - + The process crashed some time after starting successfully. - + Timed out. The process took too long to respond. The last waitFor...() function timed out. - + An error occurred when attempting to write to the process. For example, the process may not be running, or it may have closed its input channel. - + An error occurred when attempting to read from the process. For example, the process may not be running. - + Unknown error occurred. Ocurrió un error desconocido. - + The card database updater exited with an error: %1 - - This server supports additional features that your client doesn't have. -This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. - -To update your client, go to Help -> Check for Updates. - Este servidor soporta características adicionales que tu cliente no tiene. -Esto no debería ser un problema, pero este mensaje podría significar que hay una nueva versión de Cockatrice disponible o este servidor está usando una versión personalizada o de prelanzamiento. -Para actualizar tu cliente, ve a Ayuda -> Buscar Actualizaciones. - - - - - - - + + + + + Load sets/cards Cargar ediciones/cartas - + Selected file cannot be found. El archivo seleccionado no fue encontrado. - + You can only import XML databases at this time. Solo puedes importar bases de datos en XML en este momento. - + The new sets/cards have been added successfully. Cockatrice will now reload the card database. Las nuevas ediciones/cartas se han agregado correctamente. Cockatrice está recargando la base de datos de cartas. - + Sets/cards failed to import. Las ediciones/cartas fallaron al ser importadas. - - - - - Reset Password - Reiniciar contraseña - - - - Your password has been reset successfully, you can now log in using the new credentials. - Tu contraseña se ha reiniciado con éxito, ahora puedes conectarte con las nuevas credenciales. - - - - Failed to reset user account password, please contact the server operator to reset your password. - No se pudo reiniciar la contraseña, por favor contacte con el operador del servidor para reiniciar su contraseña. - - - - Activation request received, please check your email for an activation token. - Petición de activación recibida, por favor comprueba tu email para el código de activación. - ManaBaseConfigDialog - + Mana Base Configuration - + Display type: - + pie - + bar - + combinedBar - + Filter Colors (optional): - + OK - + Cancel @@ -5551,27 +5637,27 @@ Cockatrice está recargando la base de datos de cartas. type - + tipo color - + color subtype - + subtipo power - + fuerza toughness - + resistencia @@ -5600,27 +5686,27 @@ Cockatrice está recargando la base de datos de cartas. ManaDevotionConfigDialog - + Display type: - + pie - + bar - + combinedBar - + Filter Colors (optional): @@ -5690,611 +5776,616 @@ Cockatrice está recargando la base de datos de cartas. MessageLogWidget - + from play del juego - + from their graveyard de su cementerio - + from exile del exilio - + from their hand de su mano - + the top card of %1's library la carta superior de la biblioteca de %1 - + the top card of their library la parte superior de su biblioteca - + from the top of %1's library desde la parte superior de la biblioteca de %1 - + from the top of their library Desde la parte superior de su biblioteca - + the bottom card of %1's library la carta de la parte inferior de la biblioteca de %1 - + the bottom card of their library La carta de la parte inferior de su biblioteca - + from the bottom of %1's library desde la parte inferior de la biblioteca de %1 - + from the bottom of their library Desde la parte inferior de su biblioteca - + from %1's library desde la biblioteca de %1 - + from their library desde su biblioteca - + from sideboard desde el banquillo - + from the stack desde la pila - + from custom zone '%1' - + desde la zona personalizada '%1' - + %1 is now keeping the top card %2 revealed. %1 está manteniendo la carta superior %2 revelada. - + %1 is not revealing the top card %2 any longer. %1 ya no mantiene revelada la carta superior %2. - + %1 can now look at top card %2 at any time. %1 ahora puede ver la carta superior de %2 en cualquier momento. - + %1 no longer can look at top card %2 at any time. %1 ya no puede ver la carta superior de %2 en cualquier momento. - + %1 attaches %2 to %3's %4. %1 anexa %2 a %3's %4. - + %1 has conceded the game. %1 ha concedido la partida. - + %1 has unconceded the game. %1 ha concedido la partida - + %1 has restored connection to the game. %1 ha recuperado la conexión a la partida. - + %1 has lost connection to the game. %1 ha perdido la conexión a la partida. - + %1 points from their %2 to themselves. %1 apunta desde su %2 a si mismo. - + %1 points from their %2 to %3. %1 apunta desde su %2 a %3. - + %1 points from %2's %3 to themselves. %1 apunta desde el %3 de %2 a si mismo. - + %1 points from %2's %3 to %4. %1 apunta desde el %3 de %2 a %4. - + %1 points from their %2 to their %3. %1 apunta de su %2 a su %3. - + %1 points from their %2 to %3's %4. %1 apunta desde su %2 al %4 de %3. - + %1 points from %2's %3 to their own %4. %1 apunta desde el %3 de %2 a su %4. - + %1 points from %2's %3 to %4's %5. %1 apunta desde el %3 de %2 al %5 de %4. - + %1 creates a face down token. - + %1 creates token: %2%3. %1 crea una ficha: %2%3. - + %1 has loaded a deck (%2). %1 ha cargado un mazo (%2). - + %1 has loaded a deck with %2 sideboard cards (%3). %1 ha cargado un mazo con %2 cartas en el banquillo (%3). - + %1 destroys %2. %1 destruye %2. - + a card una carta - + %1 gives %2 control over %3. %1 entrega a %2 el control sobre %3. - + %1 puts %2 into play%3 face down. %1 pone %2 en juego %3 bocabajo. - + %1 puts %2 into play%3. %1 pone %2 en juego %3. - + %1 puts %2%3 into their graveyard face down. - + %1 pone %2%3 en su cementerio boca abajo. - + %1 puts %2%3 into their graveyard. %1 pone %2%3 en su cementerio. - + %1 exiles %2%3 face down. - + %1 exilia %2%3 boca abajo. - + %1 exiles %2%3. %1 exilia %2%3. - + %1 moves %2%3 to their hand. %1 mueve %2%3 a su mano. - + %1 puts %2%3 into their library. %1 pone %2%3 en su biblioteca. - + %1 puts %2%3 onto the bottom of their library. %1 pone %2%3 al fondo de su biblioteca. - + %1 puts %2%3 on top of their library. %1 pone %2%3 en la parte superior de su biblioteca. - + %1 puts %2%3 into their library %4 cards from the top. %1 pone %2%3 en la biblioteca de su propietario en el %4 lugar desde la parte superior - + %1 moves %2%3 to sideboard. %1 mueve %2%3 al banquillo. - + %1 plays %2%3 face down. - + %1 juga %2%3 boca abajo. - + %1 plays %2%3. %1 juega %2%3. - + %1 moves %2%3 to custom zone '%4' face down. - + %1 mueve %2%3 a la zona personalizada '%4' boca abajo. - + %1 moves %2%3 to custom zone '%4'. - + %1 mueve %2%3 a la zona personalizada '%4' - + %1 tries to draw from an empty library - + %1 draws %2 card(s). %1 roba %2 carta(s).%1 roba %2 c%1 roba %2 c - + %1 is looking at %2. %1 está mirando a %2. - + %1 is looking at the %4 %3 card(s) %2. top card for singular, top %3 cards for plural - + bottom parte inferior - + top parte superior - + %1 turns %2 face-down. %1 pone %2 boca abajo. - + %1 turns %2 face-up. %1 pone %2 boca arriba. - + The game has been closed. La partida ha sido cerrada. - + The game has started. La partida ha comenzado. - + You are flooding the game. Please wait a couple of seconds. - + %1 has joined the game. %1 se ha unido a la partida. - + %1 is now watching the game. %1 está ahora observando la partida. - + You have been kicked out of the game. Has sido expulsado de la partida. - + %1 has left the game (%2). %1 ha dejado la partida (%2). - + %1 is not watching the game any more (%2). %1 ya no está viendo el juego (%2). - + %1 is not ready to start the game any more. %1 está preparado/a para empezar la partida. - + %1 shuffles their deck and draws a new hand of %2 card(s). %1 barajea su mazo y roba una nueva mano de %2 carta(s).%1 barajea su mazo y roba una nueva mano de %2 carta(s)%1 barajea su mazo y roba una nueva mano de %2 carta(s) - + %1 shuffles their deck and draws a new hand. %1 Baraja su maso y robas una nueva mano. - + You are watching a replay of game #%1. Estás viendo una repetición de la partida #%1. - + %1 is ready to start the game. %1 está preparado/a para empezar la partida. - + cards an unknown amount of cards cartas - + %1 card(s) a card for singular, %1 cards for plural %1 carta(s)%1 cars%1 cars - + %1 lends %2 to %3. - + %1 presta %2 a %3. - + %1 reveals %2 to %3. %1 revela %2 a %3. - + %1 reveals %2. %1 revela %2. - + %1 randomly reveals %2%3 to %4. %1 revela al azar %2%3 a %4. - + %1 randomly reveals %2%3. %1 revela al azar %2%3. - + %1 peeks at face down card #%2. %1 está mirando la carta #%2 boca abajo. - + %1 peeks at face down card #%2: %3. %1 mira la carta boca abajo #%2: %3. - + %1 reveals %2%3 to %4. %1 revela %2%3 a %4. - + %1 reveals %2%3. %1 revela %2%3. - + %1 reversed turn order, now it's %2. %1 Orden del turno invertido, ahora es %2. - + reversed Invertido. - + normal Normal. - + Heads Cara - + Tails Cruz - + %1 flipped a coin. It landed as %2. %1 tira una moneda. Ha salido %2. - + %1 rolls a %2 with a %3-sided die. %1 sacó un %2 con un dado de %3 caras. - + %1 flips %2 coins. There are %3 heads and %4 tails. - + %1 rolls a %2-sided dice %3 times: %4. - + %1's turn. Turno %1. - + %1 sets annotation of %2 to %3. %1 sets annotation of %2 to %3. - - %1 places %2 "%3" counter(s) on %4 (now %5). + + %1 places %2 %3%4 counter(s) on %5 (now %6). - - %1 removes %2 "%3" counter(s) from %4 (now %5). + + %1 removes %2 %3%4 counter(s) from %5 (now %6). - + + %1 failed to undo their last draw. + + + + %1 sets counter %2 to %3 (%4%5). %1 establece el contador %2 a %3 (%4%5). - + %1 sets %2 to not untap normally. %1 establece que %2 no se endereze normalmente. - + %1 sets %2 to untap normally. %1 establece que %2 se endereze normalmente. - + %1 removes the PT of %2. %1 elimina la F/R de %2 - + %1 changes the PT of %2 from nothing to %4. %1 cambia la F/R de %2 de nada a %4. - + %1 changes the PT of %2 from %3 to %4. %1 cambia la F/R de %2 de %3 a %4. - + %1 has locked their sideboard. %1 ha bloqueado su banquillo. - + %1 has unlocked their sideboard. %1 ha desbloqueado su banquillo. - + %1 taps their permanents. %1 gira sus permanentes. - + %1 untaps their permanents. %1 ha enderezado sus permanentes. - + %1 taps %2. %1 gira %2. - + %1 untaps %2. %1 endereza %2. - + %1 shuffles %2. %1 baraja %2. - + %1 shuffles the bottom %3 cards of %2. %1 barajea las últimas %3 cartas de %2. - + %1 shuffles the top %3 cards of %2. %1 barajea las primeras %3 cartas de %2. - + %1 shuffles cards %3 - %4 of %2. %1 barajea las cartas %3 - %4 de %2. - + %1 unattaches %2. %1 desanexa %2. - + %1 undoes their last draw. %1 deshace su último robo. - + %1 undoes their last draw (%2). %1 deshace su último robo (%2). @@ -6302,110 +6393,115 @@ Cockatrice está recargando la base de datos de cartas. MessagesSettingsPage - + Word1 Word2 Word3 Palabra1 Palabra2 Palabra3 - + Add New Message Añadir nuevo mensaje - + Edit Message Editar mensaje - + Remove Message Borrar mensaje - + Add message Añadir mensaje - - + + Message: Mensaje: - + Edit message Editar mensaje - + Chat settings Preferencias de Chat - + Custom alert words Palabras de alerta personalizadas - + Enable chat mentions Habilitar las menciones en el chat - + Enable mention completer Habilitar completado de menciones - + In-game message macros Macros para mensajes durante la partida - + How to use in-game message macros - + Ignore chat room messages sent by unregistered users Ignorar mensajes de sala de usuarios sin registrar - + Ignore private messages sent by unregistered users Ignorar mensajes privados de usuarios sin registrar - - + + Ignore private messages sent by non-buddy users + + + + + Invert text color Invertir el color del texto - + Enable desktop notifications for private messages Habilitar notificaciones de escritorio para los mensajes privados - + Enable desktop notification for mentions Habilitar notificaciones de escritorio para menciones - + Enable room message history on join Habilitar historial de mensajes de sala al unirse - - + + (Color is hexadecimal) (El color es hexadecimal) - + Separate words with a space, alphanumeric characters only Separa las palabras con un espacio, solo caracteres alfanuméricos @@ -6413,42 +6509,42 @@ Cockatrice está recargando la base de datos de cartas. MoveMenu - + Move to Mover a - + &Top of library in random order - + X cards from the top of library... - + &Bottom of library in random order - + T&able - + Tabla - + &Hand &Mano - + &Graveyard &Cementerio - + &Exile &Exilio @@ -6461,47 +6557,47 @@ Cockatrice está recargando la base de datos de cartas. Tipo de carta - + Mana Value Valor de maná - + Color(s) Color(es) - + Loyalty Lealtad - + Main Card Type Tipo principal de carta - + Mana Cost Coste de maná - + P/T F/R - + Side Lado - + Layout Diseño - + Color Identity Identidad de color @@ -6524,6 +6620,152 @@ Cockatrice está recargando la base de datos de cartas. + + PaletteEditorDialog + + + + Reset + + + + + + Apply + + + + + + Save && Apply + + + + + ▼ Edit Palette + + + + + + ▶ Edit Palette + + + + + Palette Editor — %1 + + + + + <b>Palette Editor</b> &nbsp;·&nbsp; %1 + + + + + This theme ships no default palette files + + + + + Replace current colours with the theme author's defaults + + + + + Switch between the light and dark palette files + + + + + Editing: + + + + + Show or hide the per-role colour grid for manual tweaks + + + + + ↺ Revert to theme default + + + + + Discard unsaved edits and restore the last saved palette + + + + + Preview this palette without saving to disk + + + + + Write palette-%1.toml and reload the theme + + + + + Cannot save: this theme has no directory on disk + + + + + Save failed + + + + + Could not write %1 to: +%2 + + + + + No default found + + + + + No default palette file found for the "%1" scheme. + + + + + PaletteGridWidget + + + Active + + + + + Disabled + + + + + Inactive + + + + + Normal interactive state + + + + + Widget is disabled / not interactive + + + + + Window is in background / unfocused + + + Phase @@ -6590,57 +6832,57 @@ Cockatrice está recargando la base de datos de cartas. PhasesToolbar - + Untap step Paso de enderezar - + Upkeep step Paso de mantenimiento - + Draw step Paso de robar - + First main phase Primera fase principal - + Beginning of combat step Paso de inicio del combate - + Declare attackers step Paso de declarar atacantes - + Declare blockers step Paso de declarar bloqueadores - + Combat damage step Paso de daño de combate - + End of combat step Paso de final del combate - + Second main phase Segunda fase principal - + End of turn step Paso final @@ -6648,7 +6890,7 @@ Cockatrice está recargando la base de datos de cartas. PictureLoader - + en code for scryfall's language property, not available for all languages es @@ -6657,153 +6899,144 @@ Cockatrice está recargando la base de datos de cartas. PlayerActions - + + + + + grave + cementerio + + + + + + + exile + exilio + + + + PlayerDialogs + + View top cards of library - Ver cartas de la parte superior de la biblioteca + - - - - - - - - - + + + + + + + + + Number of cards: (max. %1) - Número de cartas: (máx. %1) + - + View bottom cards of library - Ver cartas de la parte inferior de la biblioteca + - + Shuffle top cards of library - + Shuffle bottom cards of library - + Draw hand - Robar mano + - + 0 and lower are in comparison to current hand size - + Draw cards - Robar cartas - - - - - - - grave - - - - - exile - - - - + Move top cards to %1 - + Move bottom cards to %1 - + Draw bottom cards - - - C&reate another %1 token + + Create tokens - - Create tokens - Crear fichas - - - - + Number: - Número: + - + Place card X cards from top of library - + Which position should this card be placed: - + (max. %1) - + Change power/toughness - Cambiar fuerza/resistencia + - + Change stats to: - + Set annotation - + Please enter the new annotation: - - - Set counters - Establecer contadores - PlayerMenu - + Player "%1" - + Jugador(a) "%1" - + &Counters - + Contadores @@ -6831,7 +7064,7 @@ This setting means you'll only see the default printing for each card, instead o - + Printing Selector @@ -6839,22 +7072,22 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelectorCardOverlayWidget - + Preference - + Preferencia - + Pin Printing - + Unpin Printing - + Show Related cards @@ -6895,7 +7128,7 @@ This setting means you'll only see the default printing for each card, instead o Preference - + Preferencia @@ -6925,57 +7158,57 @@ This setting means you'll only see the default printing for each card, instead o PtMenu - + Power / toughness Fuerza / resistencia - + &Increase power &Incrementar fuerza - + &Decrease power &Reducir fuerza - + I&ncrease toughness I&ncrementar resistencia - + D&ecrease toughness R&educir resistencia - + In&crease power and toughness In&crementar fuerza y resistencia - + Dec&rease power and toughness Red&ucir fuerza y resistencia - + Increase power and decrease toughness Incrementa fuerza y reducir resistencia - + Decrease power and increase toughness Reducir fuerza e incrementar resistencia - + Set &power and toughness... Establecer &fuerza y resistencia... - + Reset p&ower and toughness Reiniciar f&uerza y resistencia @@ -6983,37 +7216,37 @@ This setting means you'll only see the default printing for each card, instead o QMenuBar - + Services Servicios - + Hide %1 Ocultar %1 - + Hide Others Ocultar otros - + Show All Mostrar todo - + Preferences... Preferencias... - + Quit %1 Salir %1 - + About %1 Sobre %1 @@ -7021,17 +7254,17 @@ This setting means you'll only see the default printing for each card, instead o QObject - + Cockatrice card database (*.xml) Base de datos de cartas de Cockatrice (*.xml) - + All files (*.*) Todos los archivos (*.*) - + Cockatrice replays (*.cor) Repeticiones de Cockatrice (*.cor) @@ -7053,12 +7286,12 @@ This setting means you'll only see the default printing for each card, instead o Overwrite Existing File? - Sobrescribir archivo existente? + ¿Sobrescribir archivo existente? A .cod version of this deck already exists. Overwrite it? - Ya existe una versión .cod de este mazo. Sobrescribirla? + Ya existe una versión .cod de este mazo. ¿Sobrescribirla? @@ -7091,110 +7324,177 @@ Are you sure you would like to disable this feature? QPlatformTheme - + OK OK - + Save Guardar - + Save All Guardar todo - + Open Abrir - + &Yes &Sí - + Yes to &All Sí a &Todo - + &No &No - + N&o to All N&o a todo - + Abort Abortar - + Retry Volver a intentar - + Ignore Ignorar - + Close Cerrar - + Cancel Cancelar - + Discard Descartar - + Help Ayuda - + Apply Aplicar - + Reset Reiniciar - + Restore Defaults Restaurar predeterminados + + QuickSetupPanel + + + %1% + + + + + <b>Quick Setup</b> + + + + + Generate all palette roles automatically from a single accent colour + + + + + Accent: + + + + + Primary hue. Used directly for highlights and links. +At high intensity it also tints buttons and backgrounds. + + + + + Intensity: + + + + + Subtle + + + + + Full colour + + + + + 0–30 Subtle tint — only highlights and links change hue +30–70 Accented — buttons, tooltips, and borders join in +70–100 Full colour — backgrounds, everything + + + + + 70% + + + + + Generate ↓ + + + + + Derive all palette roles from the accent colour above. +Fine-tune individual colours in the grid afterwards. + + + RemoteDeckList_TreeModel - + Name Nombre - + ID ID - + Upload time Tiempo de subida @@ -7202,32 +7502,32 @@ Are you sure you would like to disable this feature? RemoteReplayList_TreeModel - + ID ID - + Name Nombre - + Players Jugadores - + Keep Mantener - + Time started Tiempo de inicio - + Duration (sec) Duración (seg.) @@ -7235,37 +7535,37 @@ Are you sure you would like to disable this feature? RfgMenu - + &Exile &Exilio - + &View exile Ver &exilio - + &Move exile to... &Mover exilio a... - + &Top of library &Parte superior de la biblioteca - + &Bottom of library &Parte inferior de la biblioteca - + &Hand &Mano - + &Graveyard &Cementerio @@ -7311,9 +7611,9 @@ Are you sure you would like to disable this feature? SayMenu - + S&ay - + Decir @@ -7352,27 +7652,27 @@ Are you sure you would like to disable this feature? SetsModel - + Enabled Activado - + Set type Tipo de set - + Set code Código de set - + Long name Nombre largo - + Release date Fecha de lanzamiento @@ -7380,53 +7680,53 @@ Are you sure you would like to disable this feature? ShortcutSettingsPage - - + + Restore all default shortcuts Restaurar atajos por defecto - + Do you really want to restore all default shortcuts? ¿Realmente quieres restablecer los atajos predeterminados? - + Clear all default shortcuts Limpiar todos los atajos predeterminados - + Do you really want to clear all shortcuts? ¿Realmente quieres limpiar los atajos predeterminados? - + Section: Sección: - + Action: Acción: - + Shortcut: Atajo: - + How to set custom shortcuts Cómo fijar atajos personalizados - + Clear all shortcuts - + Search by shortcut name @@ -7481,40 +7781,40 @@ Por favor, revise su configuración de atajos. SideboardMenu - + &Sideboard &Banquillo - + &View sideboard - + Ver banquillo SoundSettingsPage - + Enable &sounds Activar &sonidos - + Current sounds theme: Tema de sonidos actual: - + Test system sound engine Probar el audio del sistema - + Sound settings Preferencias de sonido - + Master volume Volumen maestro @@ -7586,16 +7886,146 @@ Por favor, revise su configuración de atajos. Respuesta invalida del servidor de lanzamiento de actualizaciones. - + No reply received from the tag update server. No se recibe respuesta del servidor de etiquetas de actualizaciones. - + Invalid reply received from the tag update server. Respuesta invalida del servidor de etiquetas de actualizaciones. + + StorageSettingsPage + + + + + Success + + + + + Cached card pictures have been reset. + + + + + Downloaded card pictures have been reset. + + + + + Error + + + + + One or more downloaded card pictures could not be cleared. + + + + + In-memory (currently loaded) card pictures have been reset. + + + + + Card Picture Loader Caching Method: + + + + + The network cache is the preferred way of storing images. Downloaded images are stored here until the size of the cache exceeds the configured size. Cockatrice automatically monitors this cache and deletes the least recently seen card images to ensure the cache does not exceed the configured size. + + + + + Writing card images directly to a folder on your hard drive is another way of storing images. This does not change how Cockatrice accesses or downloads images. Cockatrice will NOT automatically monitor and clear this folder, so if you enable this option, it is up to you to ensure sufficient available space. It should also be noted that if a provider outage causes you to download the wrong picture (i.e. wrong printing) you will be stuck with it until you manually delete the file, as opposed to using the network cache, which automatically rotates and thus correct errors after a while. + + + + + This is the in-memory picture cache used by the application at runtime. It determines how much memory (RAM) Cockatrice can use before it has to fetch card images from the hard disk again. Increasing this will allow more card images to be displayed at once but shouldn't be necessary. Clearing this will make Cockatrice reload all images from the network cache or the disk. + + + + + Delete Cached Images + + + + + Delete Saved Images + + + + + Clear In-Memory Images + + + + + Card Picture Loader Cache Method + + + + + Network Cache + + + + + Filesystem + + + + + In-Memory Picture Cache + + + + + Network Cache Size: + + + + + On-disk cache for downloaded pictures + + + + + Redirect Cache TTL: + + + + + How long cached redirects for urls are valid for. + + + + + Picture Cache Size: + + + + + In-memory cache for pictures not currently on screen + + + + + Naming scheme: + + + + + Day(s) + + + TabAccount @@ -7606,7 +8036,7 @@ Por favor, revise su configuración de atajos. Add to Ignore List - + Añadir a la Lista de Ignorados @@ -7649,7 +8079,7 @@ Por favor, revise su configuración de atajos. Replay ID - + ID de repetición @@ -7731,119 +8161,119 @@ Por favor, revise su configuración de atajos. TabArchidekt - - + + Desc. - - + + AND - + Y - - + + Require ALL selected colors - - + + Deck name... - + Nombre del mazo... - - + + Owner... - - + + Packages - - + + Advanced Filters - + Bracket: - - + + Any - + Alguno - - + + Contains card... - - + + Commander... - + Comandante... - - + + Tag... - - + + Deck Size - + Tamaño de mazo - + Cards: - + Cartas: - - + + Asc. - + Sort by: - + Filter by: - + Filtrar por: - + Display Settings - - + + Search - - + + Formats - + Formatos @@ -7851,6 +8281,54 @@ Por favor, revise su configuración de atajos. + + TabCardArtRules + + + Card: + + + + + ProviderId: + + + + + Mode: + + + + + Reason: + + + + + Type a card name... + + + + + Add rule + + + + + Remove rule + + + + + Refresh + + + + + Card Art Rules + + + TabDeckEditor @@ -7876,7 +8354,7 @@ Por favor, revise su configuración de atajos. Card Database - + Base de datos de cartas @@ -7899,7 +8377,7 @@ Por favor, revise su configuración de atajos. Reinicializar la disposición - + Deck: %1 Mazo: %1 @@ -7907,55 +8385,49 @@ Por favor, revise su configuración de atajos. TabDeckEditorVisual - + Visual Deck: %1 - + Mazo visual: %1 - + &Visual Deck Editor - + Editor de mazos visual - - + + Card Info Información de la carta - - + + Deck Mazo - - - Filters - - - - + &View - + Ver - + Printing - + Visible Visible - + Floating - + Reset layout @@ -7963,22 +8435,22 @@ Por favor, revise su configuración de atajos. TabDeckEditorVisualTabWidget - + Visual Deck View - + Vista de mazos visual - + Visual Database Display - + Deck Analytics - + Análisis de mazo - + Sample Hand @@ -7986,141 +8458,141 @@ Por favor, revise su configuración de atajos. TabDeckStorage - + Local file system Sistema de archivos local - + Server deck storage Almacen de mazos del servidor - - + + Open in deck editor Abrir en el editor de mazos - + Rename deck or folder - + Renombrar mazo o carpeta - + Upload deck Subir mazo - + Download deck Descargar mazo - - - + + + New folder Nueva carpeta - + Delete Borrar - + Open decks folder - + Abrir carpeta de mazos - + Rename local folder - + Renombrar carpeta local - + Rename local file - + New name: - + Nombre nuevo: - - - - + + + + Error Error - + Rename failed - - + + Invalid deck file Archivo de baraja inválido - + Enter deck name Introduzca el nombre del mazo - + This decklist does not have a name. Please enter a name: Esta lista de mazo no tiene un nombre. Por favor, introduce un nombre: - + Unnamed deck Mazo sin nombre - + Failed to upload deck to server Fallo al subir baraja al servidor - + Delete local file Borrar fichero local - + Are you sure you want to delete the selected files? - + ¿Estás seguro/a de que quieres eliminar los archivos seleccionados? - + Delete remote decks - + Borrar mazos remotos - + Are you sure you want to delete the selected decks? - + ¿Estás seguro/a de que quieres eliminar los mazos seleccionados? - - + + Name of new folder: Nombre de la nueva carpeta: Deck Storage - + Almacén de mazos @@ -8128,7 +8600,7 @@ Por favor, introduce un nombre: Visual Deck Storage - + Almacén de mazos visual @@ -8138,7 +8610,7 @@ Por favor, introduce un nombre: Could not open deck at %1 - + No se pudo abrir mazo en %1 @@ -8146,7 +8618,7 @@ Por favor, introduce un nombre: EDHREC: - + EDHREC: @@ -8179,197 +8651,197 @@ Por favor, introduce un nombre: EDHRec: - + EDHRec: TabGame - - - + + + Replay Repetir - - + + Game Partida - - + + Player List Lista de jugadores - - + + Card Info Información de la carta - - + + Messages Mensajes - - + + Replay Timeline Historial de partidas - + &Phases &Fases - + &Game &Partida - + Next &phase Próxima &fase - + Next phase with &action Próxima fase con &acción - + Next &turn Próximo &turno - + Reverse turn order Orden del turno invertido. - + &Remove all local arrows &Retirar todas las flechas locales - + Rotate View Cl&ockwise Girar en sentido horario - + Rotate View Co&unterclockwise Girar en sentido antih&orario (&U) - + Game &information &Información de la partida - + Un&concede - - - + + + &Concede &Conceder - + &Leave game &Abandonar la partida - + C&lose replay &Cerrar repetición - + &Focus Chat &Resaltar chat - + &Say: &Decir: - + Selected cards Cartas seleccionadas - + &View &Ver - + Visible Visible - + Floating Flotante - + Reset layout Resetear la distribución - + Concede Conceder - + Are you sure you want to concede this game? ¿Estás seguro de que quieres conceder esta partida? - + Unconcede Revertir rendición - + You have already conceded. Do you want to return to this game? Ya te rendiste. ¿Quieres regresar a este juego? - + Leave game Abandonar la partida - + Are you sure you want to leave this game? ¿Estás seguro de que quieres abandonar la partida? - + A player has joined game #%1 Un jugador se ha unido a la partida #%1 - + %1 has joined the game %1 se ha unido a la partida - + You have been kicked out of the game. Has sido expulsado de la partida. @@ -8428,115 +8900,115 @@ Por favor, introduce un nombre: Debes seleccionar un número de días válido para buscar. - + Username: Nombre de usuario: - + IP Address: Dirección IP: - + Game Name: Nombre de partida: - + GameID: ID de partida: - + Message: Mensaje: - + Main Room Sala Principal - + Game Room Sala de Partida - + Private Chat Chat privado - + Past X Days: Pasados X días: - + Today Hoy - + Last Hour Última hora - + Maximum Results: Resultados máximos: - + At least one filter is required. The more information you put in, the more specific your results will be. Se requiere un filtro como mínimo. Cuando más información introduzcas, más específicos serán los resultados. - + Get User Logs Ver registros de usuario - + Clear Filters Borrar filtros - + Filters Filtros - + Log Locations Localización de registro. - + Date Range Alcance de fecha - + Maximum Results Resultados máximos - - + + Message History Historial de mensajes - + Failed to collect message history information. Error al recopilar información del historial de mensajes. - + There are no messages for the selected filters. No hay mensajes con los filtros seleccionados @@ -8554,27 +9026,27 @@ Cuando más información introduzcas, más específicos serán los resultados.&Cerrar - + %1 - Private chat %1 - Chat privado - + This user is ignoring you, they cannot see your messages in main chat and you cannot join their games. El usuario te esta ignorando, los usuarios no podrán ver tus mensajes en el chat principal y no podrás unirte a sus partidas. - + Private message from Mensaje privado de - + %1 has left the server. %1 ha abandonado el servidor. - + %1 has joined the server. %1 se ha unido al servidor. @@ -8582,180 +9054,180 @@ Cuando más información introduzcas, más específicos serán los resultados. TabReplays - + Local file system Sistema de archivos local - + Server replay storage Almacén de repeticiones del servidor - - + + Watch replay Ver repetición - + Rename Renombrar - - + + New folder Nueva carpeta - - + + Delete Borrar - + Open replays folder - + Abrir carpeta de repeticiones - + Download replay Descargar repetición - + Toggle expiration lock Alternar expiración del bloqueo - + Get replay share code - - + + Look up replay by share code - + Rename local folder - + Renombrar carpeta local - + Rename local file - + New name: - + Error Error - + Rename failed - + Name of new folder: - + Nombre de nueva carpeta - + Delete local file Borrar fichero local - + Are you sure you want to delete the selected files? - + ¿Estás seguro/a de que quieres eliminar los archivos seleccionados? - + Are you sure you want to delete the selected replays? - + ¿Estás seguro/a de que quieres eliminar los repeticiones seleccionados? - + Failed to get code - - + + Either this server does not support replay sharing, or does not permit replay sharing for you. - - - + + + Failed Fallido - + Could not get replay code - + Replay Share Code - + Others can use this code to add the replay to their list of remote replays: %1 - + Copy to clipboard - + Copiar al portapapeles - + Replay share code - + Replay code found - + Replay was added, or you already had access to it. - + Replay code not found - + Failed to submit code - + Unexpected error - + Delete remote replay Borrar repetición remota @@ -8768,47 +9240,62 @@ Cuando más información introduzcas, más específicos serán los resultados. TabRoom - + + Friends + + + + + Online + + + + + Ignored + + + + &Say: &Decir: - + Chat Chat - + &Room &Sala - + &Leave room &Dejar sala - + &Clear chat &Limpiar chat - + Chat Settings... Preferencias del Chat... - + mentioned you. te ha mencionado. - + Click to view Click para ver - + You are flooding the chat. Please wait a couple of seconds. Estás saturando el chat. Por favor, espera unos segundos. @@ -8821,30 +9308,30 @@ Cuando más información introduzcas, más específicos serán los resultados.Servidor - - - - + + + + Error Error - + Failed to join the server room: it doesn't exist on the server. Fallo al unirse a la sala del servidor: no existe en el servidor. - + The server thinks you are in the server room but your client is unable to display it. Try restarting your client. El servidor cree que estás en la sala del servidor pero tu cliente es incapaz de mostrarlo. Intenta reiniciar tu cliente. - + You do not have the required permission to join this server room. No tienes los permisos requeridos para unirte a esta sala del servidor. - + Failed to join the server room due to an unknown error: %1. Fallo al unirse a la sala del servidor debido a un error desconocido: %1. @@ -8852,97 +9339,97 @@ Cuando más información introduzcas, más específicos serán los resultados. TabSupervisor - + Deck Editor Editor de mazos - + Visual Deck Editor - + Editor de mazos visual - + EDHRec - + EDHRec - + Archidekt - + Home Inicio - + &Visual Deck Storage - + Almacén de mazos visual - + Visual Database Display - + Server Servidor - + Account Cuenta - + Deck Storage - + Almacén de mazos - + Game Replays - + Repetición de partidas - + Administration Administración - + Logs - + Are you sure? ¿Estás seguro? - + There are still open games. Are you sure you want to quit? Todavía hay partidas abiertas. ¿Estás seguro/a de que quieres salir? - + Click to view Click para ver - + Your buddy %1 has signed on! Tu amigo %1 se ha conectado! - + Unknown Event Evento desconocido - + The server has sent you a message that your client does not understand. This message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -8952,39 +9439,39 @@ Este mensaje podría significar que hay una nueva versión de Cockatrice disponi Para actualizar tu cliente, ve a Ayuda -> Buscar Actualizaciones. - + Idle Timeout Tiempo de ausencia agotado - + You are about to be logged out due to inactivity. Va a ser desconectado por inactividad. - + Promotion Promoción - + You have been promoted. Please log out and back in for changes to take effect. Has sido promocionado. Por favor cierra la sesión y vuelve a iniciarla para que los cambios surtan efecto. - + Warned Advertido - + You have received a warning due to %1. Please refrain from engaging in this activity or further actions may be taken against you. If you have any questions, please private message a moderator. Has recibido una advertencia debido a %1 Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas contra usted. Si tiene alguna pregunta, por favor envíe un mensaje privado a un moderador. - + You have received the following message from the server. (custom messages like these could be untranslated) Ha recibido los siguientes mensajes del servidor. @@ -8994,12 +9481,12 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas TabVisualDatabaseDisplay - + Database Display - + Visual Database Display @@ -9036,42 +9523,42 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas TranslateCounterName - + Life Vida - + White Blanco - + Blue Azul - + Black Negro - + Red Rojo - + Green Verde - + Colorless Sin Color - + Other Otro @@ -9079,11 +9566,69 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas UpdateDownloader - + Could not open the file for reading. No se pudo abrir el archivo para su lectura. + + UserCardArtSettingsDialog + + + Card Art Settings + + + + + Type a card name... + + + + + Card name: + + + + + Card ProviderId: + + + + + Left margin (%): + + + + + Right margin (%): + + + + + Vertical offset: + + + + + Zoom: + + + + + Parameters + + + + + Preview + + + + + Remove Banner Card + + + UserContextMenu @@ -9181,12 +9726,12 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas This user does not exist. - + Este usuario no existe. You are being ignored by %1 and can't see their games. - + Estás siendo ignorado por %1 y no puedes ver sus partidos. @@ -9201,7 +9746,7 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas - + Ban History Historial de baneo @@ -9216,77 +9761,87 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas El usuario nunca ha sido baneado - + Failed to collect ban information. Error al recopilar informacion de baneos. - - - + + + Warning History Historial de advertencias - + Warning Time;Moderator;User Name;Reason Warning Time;Moderator;User Name;Reason - + User has never been warned. El usuario nunca ha sido advertido - + Failed to collect warning information. Error al recopilar información de advertencias. - + Failed to get admin notes. - - + + Success Éxito - + Successfully promoted user. Usuario promocionado con éxito. - + Successfully demoted user. Usuario degradado con éxito. - - - + + Kick Player + + + + + Are you sure you want to kick this player from the game? + + + + + + Failed Fallo - + Failed to promote user. Fallo al promocionar usuario. - + Failed to demote user. Fallo al degradar usuario. - + Copy hash to clipboard Copiar al portapapeles - + Remove this user's messages Borrar mensajes de este usuario @@ -9294,326 +9849,554 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas UserInfoBox - + Location: Localización: - + Account Age: Tiempo de la cuenta: - + Edit Editar - + Change password Cambiar contraseña - + Change avatar Cambiar avatar - + Administrator Administrador - + Moderator Moderador - + Registered user Usuario registrado - - + + Unregistered user Usuario no registrado - + Judge Juez - + Unknown Desconocido - + The entered password does not match your account. La contraseña introducida no coincide con tu cuenta - - - + + + + Information Información - + User information updated. Información del usuario actualizada. - - - - - - - - - - + + + + + + + + + + + Error Error - + User Information Información de usuario - + Real Name: Nombre real: - + User Level: Nivel de usuario: + + + Edit Banner Card + + - + %n Year(s), amount of years (only shown if more than 0) %n año,%n años,%n año(s), - + %10%n Day(s) %20 amount of years (if more than 0), amount of days, date in local short format %10%n día %20%10%n días %20%10%n días %20 - + Enter Password Introducir contraseña - + Password verification is required in order to change your email address Se requiere verificación de la contraseña para cambiar tu dirección de correo electrónico - - - + + + An error occurred while trying to update your user information. Un error ha ocurrido mientras intentaba actualizarse tu información de usuario. - + + The selected card is blacklisted on this server or another error occurred. + + + + + Banner card removed. + + + + + Banner card updated. + + + + This server does not permit you to update your user informations. Este servidor no permite actualizar su información de usuario. - + Password changed. Tu contraseña ha sido cambiada. - + This server does not permit you to change your password. Este servidor no permite cambiar tu contraseña. - + The new password is too short. La nueva contraseña es muy corta - + The old password is incorrect. La contraseña anterior no es correcta. - + Avatar updated. Avatar actualizado - + This server does not permit you to update your avatar. Este servidor no permite actualizar avatar. + + + An error occured while trying to update your avatar. + + - An error occured while trying to updater your avatar. - Ocurrió un error mientras se actualizaba tu avatar. + This server does not permit you to update your user informations. + An error occured while trying to updater your avatar. + Este servidor no permite actualizar su información de usuario. + + + + UserInfoPopup + + + + Games + + + + + Chat + + + + + Open private chat + + + + + Profile + + + + + View user profile + + + + + Show this user's games + + + + + − Buddy + + + + + Remove from buddy list + + + + + + Buddy + + + + + Add to buddy list + + + + + − Ignore + + + + + Remove from ignore list + + + + + + Ignore + + + + + Add to ignore list + + + + + Ban + + + + + Ban from server + + + + + Warn + + + + + Warn user + + + + + Ban log + + + + + View ban history + + + + + Warn log + + + + + View warning history + + + + + Notes + + + + + View admin notes + + + + + − Mod + + + + + Demote from moderator + + + + + + Mod + + + + + Promote to moderator + + + + + − Judge + + + + + Demote from judge + + + + + + Judge + + + + + Promote to judge + + + + + Join game + + + + + Spectate + + + + + + Loading games… + + + + + Could not load games. + + + + + No active games. + UserInterfaceSettingsPage - + General interface settings Preferencias generales de la interfaz - + &Double-click cards to play them (instead of single-click) &Doble click en las cartas para jugarlas (en lugar de un solo click) - + &Clicking plays all selected cards (instead of just the clicked card) - + &Play all nonlands onto the stack (not the battlefield) by default &Jugar todas las cartas que no sean tierras en la pila (en lugar de en el campo de batalla) por defecto. - + Do not delete &arrows inside of subphases - + Close card view window when last card is removed - + Auto focus search bar when card view window is opened - + Annotate card text on tokens Anotar en las fichas. - - Show selection counter during drag selection + + Show selection count during drag selection - - Show total selection counter + + Show total selection count - + + Show subtype breakdown in selection tally + + + + Use tear-off menus, allowing right click menus to persist on screen Usar menus flotantes, permitir click derecho para anclar menus en la pantalla - + + Keep game chat focused when clicking in game (Note: disables card view search bar) + + + + Notifications settings Ajustes de notificaciones - + Enable notifications in taskbar Habilitar notificaciones en la barra de tareas - + Notify in the taskbar for game events while you are spectating Notificar en la barra de tareas los eventos de la partida mientras estás como espectador - + Notify in the taskbar when users in your buddy list connect Recibir una notificación en la barra de tareas cuando alguien de tu lista de amigos se conecte - + Animation settings Preferencias de animación - + &Tap/untap animation Animación de &girar/enderezar - + Deck editor/storage settings - + Preferencias de editor/almacén de mazos - + Open deck in new tab by default - + Use visual deck storage in game lobby - + Usar almacén de mazos visual en el vestíbulo de juego - + Use selection animation for Visual Deck Storage - + When adding a tag in the visual deck storage to a .txt deck: - + do nothing no hacer nada - + ask to convert to .cod - + always convert to .cod - + Default deck editor type - + Classic Deck Editor - + Visual Deck Editor - + Editor de mazos visual - + Replay settings - + Preferencias de repetición - + Buffer time for backwards skip via shortcut: @@ -9621,56 +10404,66 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas UserListWidget - + Users connected to server: %1 - + Users in this room: %1 - + Buddies online: %1 / %2 - + Amigos en línea: %1 / %2 - + Ignored users online: %1 / %2 - + Usuarios ignorados en línea: %1 / %2 UtilityMenu - + Increment all card counters - + &Untap all permanents - + R&oll die... &Lanzar dado... - + + Flip coin + + + + &Create token... - + Crear ficha... - + C&reate another token + Crear otra ficha + + + + Cr&eate predefined token - - Cr&eate predefined token + + C&reate another %1 token @@ -9679,7 +10472,7 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas Exact match - + Coincidencia exacta @@ -9724,166 +10517,166 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas VisualDatabaseDisplayFilterToolbarWidget - + Sort by - - - Filter by - - - Save and load filters - - - - - Filter by exact card name - + Filter by + Filtrar por - Filter by card main-type - + Save and load filters + Guardar y cargar filtros - Filter by card sub-type - + Filter by exact card name + Filtrar por nombre de carta exacto - Filter by set - + Filter by card main-type + Filtrar por tipo principal de carta + Filter by card sub-type + Filtrar por subtipo de carta + + + + Filter by set + Filtrar por expansión + + + Filter by format legality - - - Save/Load - - - - - Name - - - Main Type - + Save/Load + Guardar/Cargar - Sub Type - + Name + Nombre - Sets - + Main Type + Tipo principal + Sub Type + Subtipo + + + + Sets + Ediciones + + + Formats - + Formatos VisualDatabaseDisplayFormatLegalityFilterWidget - - - Show formats with at least: - - - cards - + Show formats with at least: + Mostrar formatos con al menos: + cards + cartas + + + Do not display formats with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) Mode: Exact Match - + Modo: Coincidencia Exacta Mode: Includes - + Modo: Incluye VisualDatabaseDisplayMainTypeFilterWidget - + Show main types with at least: - + cards - + cartas - + Do not display card main-types with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) - + Mode: Exact Match - + Modo: Coincidencia Exacta - + Mode: Includes - + Modo: Incluye VisualDatabaseDisplayNameFilterWidget - + Filter by name... (Exact match) - + Filtrar por nombre de partida... (Coincidencia exacta) - + Load from Deck Cargar desde mazo - + Apply all card names in currently loaded deck as exact match name filters - + Load from Clipboard - + Cargar desde el portapapeles - + Apply all card names in clipboard as exact match name filters @@ -9891,7 +10684,7 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas VisualDatabaseDisplayRecentSetFilterSettingsWidget - + Filter to most recent sets @@ -9899,88 +10692,88 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas VisualDatabaseDisplaySetFilterWidget - + Search sets... - + Buscar ediciones... - - + + Mode: Exact Match - + Modo: Coincidencia Exacta - - + + Mode: Includes - + Modo: Incluye VisualDatabaseDisplaySubTypeFilterWidget - + Search subtypes... - + Buscar subtipos... - + Show sub types with at least: - + cards - + cartas - + Do not display card sub-types with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) - + Mode: Exact Match - + Modo: Coincidencia Exacta - + Mode: Includes - + Modo: Incluye VisualDatabaseDisplayWidget - + Search by card name (or search expressions) - + Buscar por nombre de carta (o buscar expresiones) - + Visual - + Visual - + Loading database ... - + Cargando la base de datos... - + Clear all filters Limpiar todos los filtros Table - + Tabla @@ -10012,17 +10805,17 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas - + Toggle Layout: Overlap - + Change how cards are displayed within zones (i.e. overlapped or fully visible.) - + Toggle Layout: Flat @@ -10051,17 +10844,17 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas VisualDeckEditorWidget - + Type a card name here for suggestions from the database... - + Quick search and add card - + Search for closest match in the database (with auto-suggestions) and add preferred printing to the deck on pressing enter @@ -10071,7 +10864,7 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas Deck Storage - + Almacén de mazos @@ -10079,7 +10872,7 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas Show Folders - + Mostrar las carpetas @@ -10163,7 +10956,7 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas Loading database ... - + Cargando la base de datos... @@ -10173,49 +10966,49 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas Visual Deck Storage Settings - + Preferencias de almacén de mazos visual WarningDialog - + Which warning would you like to send? ¿Qué advertencia quieres enviar? - + Redact all messages from this user in all rooms Redactar todos los mensajes de este usuario en todas las salas - + &OK &OK - + &Cancel &Cancelar - + Warn user for misconduct Usuario advertido por conducta inadecuada - - + + Error Error - + User name to send a warning to can not be blank, please specify a user to warn. El nombre de usuario no puede quedarse en blanco, por favor especifique un usuario al que advertir. - + Warning to use can not be blank, please select a valid warning to send. La advertencia no puede quedarse en blanco, por favor seleccione una advertencia válida. @@ -10223,133 +11016,133 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas WndSets - + Move selected set to the top Mover el set seleccionado al principio - + Move selected set up Mover arriba el set seleccionado - + Move selected set down Mover abajo el set seleccionado - + Move selected set to the bottom Mover el set seleccionado al final - + Search by set name, code, or type Buscar por nombre de edición, código, o tipo - + Default order Orden por defecto - + Restore original art priority order Restaurar orden predeterminado de arte original - + Enable all sets Seleccionar todos los sets - + Disable all sets Deseleccionar todos los sets - + Enable selected set(s) Habilitar set(s) seleccionados - + Disable selected set(s) Deshabilitar set(s) seleccionados - + Deck Editor Editor de mazo - + Use CTRL+A to select all sets in the view. Usa CTRL+A para seleccionar todas las colecciones aquí mostradas. - + Only cards in enabled sets will appear in the card list of the deck editor. Solo las cartas en las colecciones permitidas aparecerán en la lista de cartas del editor de mazos. - + Image priority is decided in the following order: La prioridad de las imágenes es decidida en el siguiente orden: - + first the CUSTOM Folder (%1), then the Enabled Sets in this dialog (Top to Bottom) %1 is a link to the wiki primero la Carpeta PERSONALIZADA (%1), luego las Ediciones Habilitadas en este mensaje (de Arriba a Abajo) - + Include cards rebalanced for Alchemy [requires restart] - + Card Art Arte de carta - + How to use custom card art Cómo usar arte de carta personalizada. - + Hints Consejos - + Note Nota - + Sorting by column allows you to find a set while not changing set priority. Ordenar por columna te permite encontrar una edición sin cambiar la prioridad de edición. - + To enable ordering again, click the column header until this message disappears. Para habilitar el orden de nuevo, haga click en el encabezado de la columna hasta que este mensaje deje de aparecer. - + Use the current sorting as the set priority instead Usa el filtro actual como la prioridad de edición - + Sorts the set priority using the same column Ordena la prioridad de edición usando la misma columna - + Manage sets Mantenedor de ediciones @@ -10357,72 +11150,72 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas ZoneViewWidget - + Search by card name (or search expressions) - + Buscar por nombre de carta (o buscar expresiones) - + Ungrouped Desagrupado - + Group by Type Agrupar por tipo - + Group by Mana Value Agrupar por valor de maná - + Group by Color Agrupar por color - + Unsorted Desordenado - + Sort by Name Ordenar por nombre - + Sort by Type Ordenar por tipo - + Sort by Mana Cost Ordenar por coste de maná - + Sort by Colors Ordenar por colores - + Sort by P/T - + Sort by Set - + shuffle when closing barajar al cerrar - + pile view vista de la pila @@ -10430,7 +11223,7 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas i18n - + English Español (Spanish) @@ -10438,12 +11231,12 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas main - + Connect on startup Conectar al inicio - + Debug to file Depurar a archivo @@ -10457,7 +11250,7 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas - + Deck Editor Editor de mazos @@ -10538,7 +11331,7 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas - + Replays Repeticiones @@ -10653,129 +11446,129 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas Cargar mazo... - - + + Load Deck from Clipboard... Cargar mazo desde el portapapeles - - - Edit Deck in Clipboard, Annotated - - - Edit Deck in Clipboard - + Edit Deck in Clipboard, Annotated + Editar mazo en el portapapeles, Comentado - + + Edit Deck in Clipboard + Editar mazo en el portapapeles + + + New Deck Nuevo mazo - + Open Custom Pictures Folder Abrir carpeta de imágenes personalizadas - + Print Deck... Imprimir mazo... - + Delete Card Borrar carta - - + + Reset Layout Restablecer disposición - + Save Deck Guardar mazo - + Save Deck as... Guarda mazo como... - + Save Deck to Clipboard, Annotated Guarda mazo al portapapeles, Comentado - + Save Deck to Clipboard, Annotated (No Set Info) - + Save Deck to Clipboard Guardar mazo al portapapeles - + Save Deck to Clipboard (No Set Info) - + Load Local Deck... Cargar mazo local... - + Load Remote Deck... Carga mazo remoto... - + Set Ready to Start Marcar listo para empezar - + Toggle Sideboard Lock Permitir bloqueo de banquillo - + Add Green Counter Añadir contador verde - + Remove Green Counter Retirar un contador verde - + Set Green Counters... Establecer contadores verdes... - + Add Red Counter Agregar contador rojo - + Remove Red Counter Retirar un contador rojo - + Set Red Counters... Establecer contadores rojos... - + Add Life Counter Añadir contador de vida @@ -10785,724 +11578,744 @@ Por favor, absténgase de realizar de nuevo esta actividad o se tomarán medidas - + + Load deck from online service... + + + + + Load from website... + + + + Unload Deck - + Force Start Forzar inicio - + Add Card Counter (F) - + Añadir contador de carta (F) - + Remove Card Counter (F) - + Retirar un contador de carta (F) - + Set Card Counters (F)... - + Establecer contadores de carta (F)... - + Add Card Counter (E) - + Añadir contador de carta (E) - + Remove Card Counter (E) - + Retirar un contador de carta (E) - + Set Card Counters (E)... - + Establecer contadores de carta (E)... - + Add Card Counter(D) - + Añadir contador de carta (D) - + Remove Card Counter (D) - + Retirar un contador de carta (D) - + Set Card Counters (D)... - + Establecer contadores de carta (D)... - + Add Card Counter (C) - + Añadir contador de carta (D) - + Remove Card Counter (C) - + Retirar un contador de carta (C) - + Set Card Counters (C)... - + Establecer contadores de carta (C)... - + Add Card Counter (B) - + Añadir contador de carta (B) - + Remove Card Counter (B) - + Retirar un contador de carta (B) - + Set Card Counters (B)... - + Establecer contadores de carta (B)... - + Add Card Counter (A) - + Añadir contador de carta (A) - + Remove Card Counter (A) - + Retirar un contador de carta (A) - + Set Card Counters (A)... - + Establecer contadores de carta (A)... - + Remove Life Counter Retirar un contador de vida - + Set Life Counters... Establecer contadores de vida... - + Add White Counter Agregar contador blanco - + Remove White Counter Retirar un contador blanco... - + Set White Counters... Establecer contadores blancos... - + Add Blue Counter Agregar contador azul - + Remove Blue Counter Retirar un contador azul... - + Set Blue Counters... Establecer contadores azules... - + Add Black Counter Agregar contador negro - + Remove Black Counter Quitar contador negro - + Set Black Counters... Establecer contadores negros... - + Add Colorless Counter Agregar contador incoloro - + Remove Colorless Counter Retirar contador incoloro - + Set Colorless Counters... Establecer contadores incoloros... - + Add Other Counter Añadir otro contador - + Remove Other Counter Borrar otro contador - + Set Other Counters... Colocar otros contadores... - + Increment all card counters - + Add Power (+1/+0) Agregar fuerza (+1/+0) - + Remove Power (-1/-0) Reducir fuerza (-1/-0) - + Move Toughness to Power (+1/-1) Pasar de resistencia a fuerza (+1/-1) - + Add Toughness (+0/+1) Agregar resistencia (+0/+1) - + Remove Toughness (-0/-1) Reducir resistencia (-0/-1) - + Move Power to Toughness (-1/+1) Pasar de fuerza a resistencia (-1/+1) - + Add Power and Toughness (+1/+1) Agregar fuerza y resistencia (+1/+1) - + Remove Power and Toughness (-1/-1) Reducir fuerza y resistencia (-1/-1) - + Set Power and Toughness... Establecer fuerza y resistencia... - + Reset Power and Toughness Restablecer fuerza y resistencia - + Untap Enderezar - + Upkeep Mantenimiento - + Draw Robo - + First Main Phase Primera fase principal - + Start Combat Comenzar combate - + Attack Ataque - + Block Bloqueo - + Damage Daño - + End Combat Terminar combate - + Second Main Phase Segunda fase principal - + End Final - + Next Phase Próxima fase - + Next Phase Action Próxima fase de acción - + Next Turn Próximo turno - + Hide Card in Reveal Window - + Tap / Untap Card Girar / Enderezar Carta - + Untap All Enderezar todas - + Toggle Skip Untapping Toggle Untap - + Turn Card Over Girar carta - + Peek Card Mirar carta - + Play Card Jugar carta - + Play Card, Face Down - + Jugar Carta, Boca Abajo - + Attach Card... Anexar carta... - + Unattach Card Desanexar carta... - + Clone Card Clonar carta - + Create Token... Crear ficha... - + Create All Related Tokens Crear todas las fichas relacionadas - + Create Another Token Crear otra ficha - + Set Annotation... Escribir anotación... - + + Reduce Life by Power + + + + Select All Cards in Zone - + Select All Cards in Row - + Select All Cards in Column - + Reveal Selected Cards to All Players - - + + Bottom of Library Parte inferior de la biblioteca - - - - + + + + Exile Exilio - - - - + + + + Graveyard Cementerio - - + + Hand Mano - - + + Top of Library Parte superior de la biblioteca - - + + Battlefield, Face Down Campo de batalla, boca abajo - + Battlefield Campo de batalla - + Library Biblioteca - + Sideboard Banquillo - + Top Cards of Library Carta de la parte superior de la biblioteca - + Bottom Cards of Library - + Close Recent View Cerrar vista reciente - - + + Stack Apilar - - + + Graveyard (Multiple) Cementerio (Multiple) - - + + Graveyard (Multiple), Face Down - + Cementerio (Multiple), Boca Abajo - - + + Exile (Multiple) Exiliar (Multiple) - - + + Exile (Multiple), Face Down - + Exilio (Multiple), Boca Abajo - + Stack Until Found - + Draw Bottom Card Robar carta de la parte inferior - + Draw Multiple Cards from Bottom... Robar múltiples cartas de la parte inferior... - + Draw Arrow... Dibujar flecha - + Remove Local Arrows Eliminar flechas - + Leave Game Abandonar partida - + Concede Conceder - + Roll Dice... Lanzar dado... - + + Flip Coin + + + + Shuffle Library Barajar biblioteca - + Shuffle Top Cards of Library - + Shuffle Bottom Cards of Library - + Mulligan Mulligan - + Mulligan (Same hand size) - + Mulligan (Hand size - 1) - + Draw a Card Robar una carta - + Draw Multiple Cards... Robar múltiples cartas... - + Undo Draw Deshacer último robo - + Always Reveal Top Card Mostrar siempre la carta superior - + Always Look At Top Card Mirar siempre la carta superior - + Sort Hand by Name - + Sort Hand by Type - + Sort Hand by Mana Value - + Reveal Hand to All Players - + Reveal Random Card to All Players - + Rotate View Clockwise Rotar vista en sentido horario - + Rotate View Counterclockwise Rotar vista en sentido antihorario - + Unfocus Text Box No resaltar la caja de texto - + Focus Chat Resaltar el chat - + Clear Chat Borrar chat - + Refresh Actualizar - + Skip Forward - + Skip Backward - + Skip Forward by a lot - + Skip Backward by a lot - + Play/Pause - + Toggle Fast Forward - + Home Inicio - + Visual Deck Storage - + Almacén de mazos visual - + Deck Storage Almacenamiento de mazos - + Server Servidor - + Account Cuenta - + Administration Administración - + Logs Historial diff --git a/cockatrice/translations/cockatrice_fi.ts b/cockatrice/translations/cockatrice_fi.ts index 1202e1f1e..c317d967a 100644 --- a/cockatrice/translations/cockatrice_fi.ts +++ b/cockatrice/translations/cockatrice_fi.ts @@ -2,7 +2,7 @@ AbstractCounter - + &Set counter... &Lisää countteri... @@ -10,12 +10,12 @@ AbstractCounterDialog - + Set counter Lisää countteri - + New value for counter '%1': Countterin uusi arvo '%1': @@ -36,60 +36,60 @@ AbstractTabDeckEditor - + Open in new tab - + Are you sure? - + The decklist has been modified. Do you want to save the changes? - - - - - - + + + + + + Error - + Could not open deck at %1 - + Could not save remote deck - - + + The deck could not be saved. Please check that the directory is writable and try again. - + Save deck - + The deck could not be saved. - + There are no cards in your deck to be exported @@ -105,12 +105,12 @@ Please check that the directory is writable and try again. AdminNotesDialog - + Update Notes - + Admin Notes for %1 @@ -127,172 +127,227 @@ Please check that the directory is writable and try again. Sideboard + + + Tokens + + AppearanceSettingsPage - + seconds - + Error - + Could not create themes directory at '%1'. - + Theme settings Teeman asetukset - + Current theme: Nykyinen teema: - + Open themes folder - + Home tab background source: - + Home tab background shuffle frequency: - + Disabled - - Display card name of background in bottom right: - - - - + Menu settings - + Show keyboard shortcuts in right-click menus - + Show game filter toolbar above list in room tab - + Card rendering Kortin renderöinti - + Display card names on cards having a picture Näytä kortin nimi korteissa, joissa on kuva - + Auto-Rotate cards with sideways layout - + Override all card art with personal set preference (Pre-ProviderID change behavior) - + + Light + + + + + Dark + + + + + System + + + + + Active theme palette: + + + + + Edit theme palette + + + + + Home tab settings + + + + + Display card name of background in bottom right + + + + + Styling settings + + + + + Style user list + + + + + Card printings + + + + Bump sets that the deck contains cards from to the top in the printing selector - + Scale cards on mouse over Suurenna kortti kun hiiri on kortin päällä - + Use rounded card corners - + + Card layout + + + + Minimum overlap percentage of cards on the stack and in vertical hand - + Maximum initial height for card view window: - - + + rows - + Maximum expanded height for card view window: - + Card counters - + Counter %1 - + Hand layout Käden layout - + Display hand horizontally (wastes space) Näytä käsikortit vierekkäin vaakatasossa (vie enemmän tilaa) - + Enable left justification Järjestä vasemmalta - + Table grid layout Pöydän grid-asetelma - + Invert vertical coordinate Käänteiset pystykoordinaatit - + Minimum player count for multi-column layout: Pelaajien vähimmäismäärä moniosaisessa layoutissa: - + Maximum font size for information displayed on cards: Suurin fonttikoko korteissa näkyville infoteksteille: @@ -300,12 +355,12 @@ Please check that the directory is writable and try again. ArchidektApiResponseDeckDisplayWidget - + Back to results - + Open Deck in Deck Editor @@ -331,112 +386,112 @@ Please check that the directory is writable and try again. BanDialog - + ban &user name Bännää &käyttäjä - + ban &IP address Bännää &IP osoite - + ban client I&D Bännää client I&D - + Ban type Bännäyksen tyyppi - + &permanent ban Bännätty pysyvästi - + &temporary ban Bännätty väliaikaisesti - + &Days: Päivät: - + &Hours: Tunnit: - + &Minutes: Minuutit: - + Duration of the ban Bännin kesto - + Please enter the reason for the ban. This is only saved for moderators and cannot be seen by the banned person. Kerro bännäyksen syy. Tallennetaan moderaattoreita varten. Bännätty henkilö ei näe tätä. - + Please enter the reason for the ban that will be visible to the banned person. Kerro bännäyksen syy. Tämä näkyy bännätylle henkilölle. - + Redact all messages from this user in all rooms - + &OK &OK - + &Cancel &Peruuta - + Ban user from server Bännää pelaaja serveriltä - - - - + + + + Error Virhe - + You have to select a name-based, IP-based, clientId based, or some combination of the three to place a ban. Valitse nimi, IP -osoite, clientid tai joku näiden yhdistelmä asettaaksesi bännin. - + You must have a value in the name ban when selecting the name ban checkbox. Bännää nimi kentässä pitää olla arvo ennen valintaruudun valitsemista - + You must have a value in the ip ban when selecting the ip ban checkbox. Bännää ip kentässä pitää olla arvo ennen valintaruudun valitsemista. - + You must have a value in the clientid ban when selecting the clientid ban checkbox. Bännää ClientID kentässä pitää olla arvo ennen valintaruudun valitsemista. @@ -444,59 +499,123 @@ Tallennetaan moderaattoreita varten. Bännätty henkilö ei näe tätä. BetaReleaseChannel - + Beta - + No reply received from the release update server. Ei vastausta päivitysserveriltä. - + Invalid reply received from the release update server. Päivitysserveri vastasi virheellisesti - + No reply received from the file update server. Ei vastausta tiedoston päivitysserveriltä. + + CardArtPreviewWidget + + + No card selected + + + + + CardArtRulesModel + + + Card + + + + + ProviderId + + + + + Mode + + + + + Reason + + + CardDatabaseModel - + Name Nimi - + Sets Setit - + Mana cost Mana cost - + Card type Korttityyppi - + P/T P/T - + Color(s) Väri(t) + + CardDatabaseView + + + Add to Deck + + + + + Add to Sideboard + + + + + Select Printing + + + + + Show on EDHRec (Commander) + + + + + Show on EDHRec (Card) + + + + + Show Related cards + + + CardFilter @@ -625,22 +744,22 @@ Tallennetaan moderaattoreita varten. Bännätty henkilö ei näe tätä. CardInfoPictureWidget - + View related cards - + Add card to deck - + Mainboard - + Sideboard @@ -663,12 +782,12 @@ Tallennetaan moderaattoreita varten. Bännätty henkilö ei näe tätä. - + Related cards: - + Unknown card: @@ -676,128 +795,146 @@ Tallennetaan moderaattoreita varten. Bännätty henkilö ei näe tätä. CardMenu - + Re&veal to... - + &All players - + View related cards - + Token: - + All tokens - + &Select All - + S&elect Row - + S&elect Column - + &Play - + &Hide - + Play &Face Down - + &Tap / Untap Turn sideways or back again - + Skip &untapping - + T&urn Over Turn face up/face down - + &Peek at card face - + &Clone - + Attac&h to card... - + Unattac&h - + &Draw arrow... - + &Set annotation... - + + Reduce life by power + + + + Ca&rd counters - + &Add counter (%1) - + &Remove counter (%1) - + &Set counters (%1)... + + CardPictureLoaderCacheMethod + + + Network Cache + + + + + Filesystem + + + CardSizeWidget @@ -809,133 +946,133 @@ Tallennetaan moderaattoreita varten. Bännätty henkilö ei näe tätä. CardZoneLogic - + their hand nominative - + %1's hand nominative - + their library look at zone - + %1's library look at zone - + of their library top cards of zone, - + of %1's library top cards of zone - + their library reveal zone - + %1's library reveal zone - + their library shuffle - + %1's library shuffle - + their library nominative - + %1's library nominative - + their graveyard nominative - + %1's graveyard nominative - + their exile nominative - + %1's exile nominative - + their sideboard look at zone - + %1's sideboard look at zone - + their sideboard nominative - + %1's sideboard nominative - + their custom zone '%1' nominative - + %1's custom zone '%2' nominative @@ -957,16 +1094,421 @@ Tallennetaan moderaattoreita varten. Bännätty henkilö ei näe tätä. + + ColorButton + + + Click to pick a color + + + + + Pick colour + + + + + ConnectionController + + + + The server has reached its maximum user capacity, please check back later. + + + + + There are too many concurrent connections from your address. + + + + + Banned by moderator + + + + + Expected end time: %1 + + + + + This ban lasts indefinitely. + + + + + Scheduled server shutdown. + + + + + + Invalid username. + + + + + You have been logged out due to logging in at another location. + + + + + Connection closed + + + + + The server has terminated your connection. +Reason: %1 + + + + + The server is going to be restarted in %n minute(s). +All running games will be lost. +Reason for shutdown: %1 + + + + + Scheduled server shutdown + + + + + Failed Login + + + + + Your client seems to be missing features this server requires for connection. + + + + + To update your client, go to 'Help -> Check for Client Updates'. + + + + + + + + + + + + + + + + + + + + + + Error + + + + + Incorrect username or password. Please check your authentication information and try again. + + + + + There is already an active session using this user name. +Please close that session first and re-login. + + + + + + You are banned until %1. + + + + + + You are banned indefinitely. + + + + + This server requires user registration. Do you want to register now? + + + + + This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. +Please close and reopen your client to try again. + + + + + An internal error has occurred, please close and reopen Cockatrice before trying again. +If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. + + + + + Account activation + + + + + Your account has not been activated yet. +You need to provide the activation token received in the activation email. + + + + + Server Full + + + + + Unknown login error: %1 + + + + + + +This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. + + + + + + + + + + Registration denied + + + + + Registration is currently disabled on this server + + + + + There is already an existing account with the same user name. + + + + + It's mandatory to specify a valid email address when registering. + + + + + It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. + + + + + Password too short. + + + + + Registration failed for a technical problem on the server. + + + + + The connection to the server has been lost. + + + + + Unknown registration error: %1 + + + + + Account activation failed + + + + + Socket error: %1 + + + + + Server timeout + + + + + You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. +Local version is %1, remote version is %2. + + + + + Your Cockatrice client is obsolete. Please update your Cockatrice version. +Local version is %1, remote version is %2. + + + + + + Success + + + + + Registration accepted. +Will now login. + + + + + Account activation accepted. +Will now login. + + + + + Information + + + + + This server supports additional features that your client doesn't have. +This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. + +To update your client, go to Help -> Check for Updates. + + + + + + + Reset Password + + + + + Your password has been reset successfully, you can now log in using the new credentials. + + + + + Failed to reset user account password, please contact the server operator to reset your password. + + + + + Activation request received, please check your email for an activation token. + + + + + Connecting to %1... + + + + + Registering to %1 as %2... + + + + + Disconnected + + + + + Connected, logging in at %1 + + + + + Requesting forgotten password to %1 as %2... + + + + + Your username must respect these rules: + + + + + is %1 - %2 characters long + + + + + can %1 contain lowercase characters + + + + + + + + NOT + + + + + can %1 contain uppercase characters + + + + + can %1 contain numeric characters + + + + + can contain the following punctuation: %1 + + + + + first character can %1 be a punctuation mark + + + + + no unacceptable language as specified by these server rules: + note that the following lines will not be translated + + + + + can not contain any of the following words: %1 + + + + + can not match any of the following expressions: %1 + + + + + You may only use A-Z, a-z, 0-9, _, ., and - in your username. + + + CustomZoneMenu - + C&ustom Zones - - + + View custom zone '%1' @@ -974,30 +1516,35 @@ Tallennetaan moderaattoreita varten. Bännätty henkilö ei näe tätä. DeckAnalyticsWidget - + Add Panel - + Remove Panel - + Save Layout - + Load Layout + + + Include Sideboard + + DeckEditorCardDatabaseDockWidget - + Card Database @@ -1013,47 +1560,17 @@ Tallennetaan moderaattoreita varten. Bännätty henkilö ei näe tätä. DeckEditorDatabaseDisplayWidget - + Search by card name (or search expressions) - - Add to Deck - - - - - Add to Sideboard - - - - - Select Printing - - - - - Show on EDHRec (Commander) - - - - - Show on EDHRec (Card) - - - - - Show Related cards - - - - + Add card to &maindeck - + Add card to &sideboard @@ -1086,72 +1603,72 @@ Tallennetaan moderaattoreita varten. Bännätty henkilö ei näe tätä. - + Select Printing - + Deck - + Deck &name: - + Banner Card/Tags Visibility Settings - + Show banner card selection menu - + Show tags selection menu - + &Comments: - + Group by: - + Format: - + Hash: - + &Increment number - + &Decrement number - + &Remove row - + Swap card to/from sideboard @@ -1159,17 +1676,17 @@ Tallennetaan moderaattoreita varten. Bännätty henkilö ei näe tätä. DeckEditorFilterDockWidget - + Filters - + &Clear all filters - + Delete selected @@ -1300,169 +1817,113 @@ Tallennetaan moderaattoreita varten. Bännätty henkilö ei näe tätä. DeckEditorSettingsPage - - + + Update Spoilers Päivitä spoilerit - - + Success Onnistui - + Download URLs have been reset. Latauksen URL:t on resetoitu - - Downloaded card pictures have been reset. - Ladattujen korttien kuvat on resetoitu. - - - - Error - Virhe - - - - One or more downloaded card pictures could not be cleared. - Ei voitu poistaa yhtä tai useampaa kortin kuvaa - - - + Add URL Lisää URL - - + + URL: URL: - - + + Edit URL Muokkaa URL - - Network Cache Size: - - - - - Redirect Cache TTL: - - - - - How long cached redirects for urls are valid for. - - - - - Picture Cache Size: - - - - + Add New URL - + Remove URL - - Day(s) - - - - + Updating... Päivitetään... - + Choose path Valitse polku - + URL Download Priority URL Latausprioriteetti - + Spoilers Spoilerit - + Download Spoilers Automatically Lataa spoilerit automaattisesti - + Spoiler Location: Spoilerien sijainti: - + Last Change Muokattu viimeksi - + Spoilers download automatically on launch Lataa spoilerit latauksen yhteydessä - + Press the button to manually update without relaunching Lataa spoilerit manuaalisesti ilman uudelleenkäynnistystä - + Do not close settings until manual update is complete Älä sulje asetuksia, ennen kuin manuaalinen päivitys on valmis - + Download card pictures on the fly Lataa korttikuvat kun niitä tarvitaan - + How to add a custom URL Miten lisään oman URL:n - - Delete Downloaded Images - Poista ladatut kuvat - - - + Reset Download URLs Resetoi latausten URL:t - - - On-disk cache for downloaded pictures - - - - - In-memory cache for pictures not currently on screen - - DeckListHistoryManagerWidget @@ -1528,12 +1989,12 @@ Tallennetaan moderaattoreita varten. Bännätty henkilö ei näe tätä. DeckLoader - + Common deck formats (%1) - + All files (*.*) @@ -1755,27 +2216,27 @@ Tallennetaan moderaattoreita varten. Bännätty henkilö ei näe tätä. - + Moved to %1 1 × "%2" (%3) - + Removed "%1" (all copies) - + %1 1 × "%2" (%3) - + Added - + Removed @@ -1797,74 +2258,74 @@ Tallennetaan moderaattoreita varten. Bännätty henkilö ei näe tätä. DeckViewContainer - + Load deck... Lataa pakka... - + Load remote deck... Lataa remote pakka... - + Load from clipboard... - + Load from website... - + Unload deck - + Ready to start Ready to start - + Force start - + Sideboard unlocked Sideboard unlocked - + Sideboard locked Sideboard locked - - + + Error Virhe - + The selected file could not be loaded. Valittua tiedostoa ei voi avata. - + Deck is greater than maximum file size. - + Are you sure you want to force start? This will kick all non-ready players from the game. - + Cockatrice @@ -1963,7 +2424,7 @@ This will kick all non-ready players from the game. - + Webpage Websivu @@ -2003,37 +2464,37 @@ This will kick all non-ready players from the game. Yhdistä serveriin - + Server URL Serverin URL - + Communication Port Tietoliikenneportti - + Unique Server Name Uniikki serverin nimi - + Connection Warning Yhteysvaroitus - + You need to name your new connection profile. Anna nimi uudelle yhteysprofiilillesi - + Connect Warning Yhdistämisvaroitus - + The player name can't be empty. Pelaajan nimi ei voi olla tyhjä. @@ -2146,17 +2607,17 @@ This will kick all non-ready players from the game. Luo peli - + Game information Tietoa pelistä - + Error Virhe - + Server error. Serverivirhe. @@ -2164,97 +2625,97 @@ This will kick all non-ready players from the game. DlgCreateToken - + &Name: &Nimi: - + Token Tokeni - + C&olor: &Väri - + white valkoinen - + blue sininen - + black musta - + red punainen - + green vihreä - + multicolor monivärinen - + colorless väritön - + &P/T: P/T: - + &Annotation: &Kommentti: - + &Destroy token when it leaves the table &Tuhoa tokeni kun se lähtee pöydästä - + Create face-down (Only hides name) - + Token data Tokenin data - + Show &all tokens Näytä &kaikki tokenit - + Show tokens from this &deck Näytä kaikki tämän &pakan tokenit - + Choose token from list Valitse tokeni listasta - + Create token Luo token @@ -2288,7 +2749,7 @@ This will kick all non-ready players from the game. - + @@ -2303,12 +2764,12 @@ This will kick all non-ready players from the game. - + Duplicate Tag - + This tag already exists. @@ -2495,12 +2956,12 @@ Poistaaksesi nykyisen avatarisi: Hyväksy valitsematta uutta kuvaa.Anna tokenin nimi: - + Error Virhe - + The chosen name conflicts with an existing card or token. Make sure to enable the 'Token' set in the "Manage sets" dialog to display them correctly. Nimi on ristiriidassa olemassaolevan kortin tai tokenin kanssa. @@ -2525,12 +2986,12 @@ Varmista että 'Token' setti on asetettu näkyväksi 'Muokkaa set Ei määritelty - + Real name: Oikea nimi: - + Edit user profile Muokkaa käyttäjäprofiilia @@ -2568,113 +3029,113 @@ Varmista että 'Token' setti on asetettu näkyväksi 'Muokkaa set - + Hide 'buddies only' games - + Hide full games - + Hide games that have started - + Hide password protected games - + Hide 'ignored user' games - + Hide games not created by buddies Hide games not created by buddy - + Hide games with forced open decklists - + &Newer than: - + Game &description: Pelin &kuvaus: - + &Creator name: &Tekijän nimi: - + General Yleinen - + &Game types &Pelimuodot - + at &least: &vähintään: - + at &most: &enintään: - + Maximum player count Suurin pelaajamäärä - + Restrictions Ehdot - + Show games only if &spectators can watch - + Show spectator password p&rotected games - + Show only if spectators can ch&at - + Show only if spectators can see &hands - + Spectators - + Filter games Suodata pelit @@ -2977,37 +3438,37 @@ https://tappedout.net/mtg-decks/your-deck-name/ DlgMoveTopCardsUntil - + Card name (or search expressions): - + Number of hits: - + Auto play hits - + Put top cards on stack until... - + No cards matching the search expression exists in the card database. Proceed anyways? - + Cockatrice - + Invalid filter @@ -3066,40 +3527,40 @@ Your email will be used to verify your account. Ei määritelty - + Real name: Oikea nimi: - + Register to server Rekisteröidy serverille - - - - + + + + Registration Warning Rekisteröintivaroitus - + Your password is too short. - + Your passwords do not match, please try again. Salasanat eivät täsmää, yritä uudelleen - + Your email addresses do not match, please try again. Sähköpostiosoitteet eivät täsmää, yritä uudelleen. - + The player name can't be empty. Pelaajan nimi ei voi olla tyhjä. @@ -3107,17 +3568,17 @@ Your email will be used to verify your account. DlgRollDice - + Number of sides: - + Number of dice: - + Roll Dice @@ -3168,12 +3629,12 @@ Your email will be used to verify your account. DlgSettings - + Unknown Error loading card database Tuntematon virhe ladattaessa korttitietokantaa - + Your card database is invalid. Cockatrice may not function correctly with an invalid database @@ -3190,7 +3651,7 @@ Voit joutua ajamaan Oraclen ja päivittämään korttitietokannan uudelleen Haluatko vaihtaa tietokantasi sijaintia? - + Your card database version is too old. This can cause problems loading card information or images @@ -3207,7 +3668,7 @@ Yleensä tämä voidaan korjata palaamalla oracleen päivittämään korttitieto Haluatko vaihtaa tietokantasi sijaintia? - + Your card database did not finish loading Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues with your cards.xml attached @@ -3216,7 +3677,7 @@ Would you like to change your database location setting? - + File Error loading your card database. Would you like to change your database location setting? @@ -3225,7 +3686,7 @@ Would you like to change your database location setting? Haluatko vaihtaa tietokantasi sijaintia? - + Your card database was loaded but contains no cards. Would you like to change your database location setting? @@ -3234,7 +3695,7 @@ Would you like to change your database location setting? Haluatko vaihtaa tietokantasi sijaintia? - + Unknown card database load status Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues @@ -3243,59 +3704,64 @@ Would you like to change your database location setting? - - - + + + Error Virhe - + The path to your deck directory is invalid. Would you like to go back and set the correct path? Pakkojen tiedostosijainti on väärä. Haluatko palata takaisin ja asettaa oikean sijainnin pakat -kansiolle? - + The path to your card pictures directory is invalid. Would you like to go back and set the correct path? Kuvien tiedostosijainti on väärä. Haluatko palata takaisin ja asettaa oikean sijainnin kuvat -kansiolle? - + Settings Asetukset - + General Yleinen - + Appearance Ulkonäkö - + User Interface Käyttöliittymä - + + Storage + + + + Card Sources Korttien lähteet - + Chat Chat - + Sound Äänet - + Shortcuts Pikanäppäimet @@ -3387,9 +3853,9 @@ You can always change this behavior in the 'General' settings tab. - - - + + + Error Virhe @@ -3442,31 +3908,31 @@ Vieraile lataussivulla päivittääksesi ohjelman manuaalisesti. - + Update Available Päivitys Saatavilla - + A new version of Cockatrice is available! Uusi versio Cockatricesta on saatavilla! - + New version Uusi versio - + Released Julkaistu - + Changelog Muutosloki @@ -3476,50 +3942,50 @@ Vieraile lataussivulla päivittääksesi ohjelman manuaalisesti. Haluatko päivittää nyt? - + Unfortunately, the automatic updater failed to find a compatible download. You may have to manually download the new version. - + Please check the <a href="%1">releases page</a> on our Github and download the build for your system. - - - + + + Update Error Päivitysvirhe - + An error occurred while checking for updates: Virhe päivityksiä tarkistettaessa: - + An error occurred while downloading an update: Virhe tiedostoa ladatessa: - + Installing... Asennetaan... - + Cockatrice is unable to open the installer. Cockatrice ei voi avata asennustiedostoa. - + Try to update manually by closing Cockatrice and running the installer. Yritä asentaa manuaalisesti sulkemalla Cockatrice ja käynnistämällä asennusohjelma. - + Download location Latausten sijainti @@ -3609,67 +4075,67 @@ You may have to manually download the new version. DrawProbabilityWidget - + Draw Probability - + Probability of drawing - + Card Name - + Type - + Subtype - + Mana Value - + At least - + Exactly - + card(s) having drawn at least - + cards - + Category - + Qty - + Odds (%) @@ -3783,7 +4249,7 @@ You may have to manually download the new version. FilterBuilder - + Type your filter here Anna suodatin tähän @@ -3814,22 +4280,22 @@ You may have to manually download the new version. GameEventHandler - + kicked by game host or moderator - + player left the game - + player disconnected from server - + reason unknown @@ -3837,140 +4303,140 @@ You may have to manually download the new version. GameSelector - - - - - - - - - + + + + + + + + + Error Virhe - + Please join the appropriate room first. Liity ensin oikeaan huoneeseen. - + Wrong password. Väärä salasana. - + Spectators are not allowed in this game. Tähän peliin ei päästetä katsojia. - + The game is already full. Peli on jo täynnä. - + The game does not exist any more. Peliä ei enää ole. - + This game is only open to registered users. Peli on tarkoitettu vain rekisteröityneille käyttäjille. - + This game is only open to its creator's buddies. Peli on tarkoitettu vain sen tekijän kavereille. - + You are being ignored by the creator of this game. Pelin luoja ei huomioi sinua. - + Join Game - + Spectate Game - + Game Information - + Join Game as Judge - + Spectate Game as Judge - + Join game Liity peliin - + Password: Salasana: - + Please join the respective room first. Liity ensin asianmukaiseen huoneeseen. - + &Filter games &Suodata pelit - + C&lear filter &Poista suodattimet - + C&reate &Luo - + &Join &Liity - + Join as judge - + J&oin as spectator Liity &katsojana - + Join as judge spectator - + Games shown: %1 / %2 Games shown: %1 / %2 - + Games Pelit @@ -3978,32 +4444,32 @@ You may have to manually download the new version. GameSelectorQuickFilterToolBar - + All types - + Filter by game name... - + Filter by game type/format - + Hide games not created by buddies - + Hide full games - + Hide started games @@ -4011,105 +4477,105 @@ You may have to manually download the new version. GamesModel - + >1 day - + %1%2 hr short age in hours - + new - + %1%2 min short age in minutes - + password salasana - + buddies only vain kaverit - + reg. users only vain rekisteröityneet käyttäjät - + open decklists - + can chat voi jutella - + see hands katso käsikortit - + can see hands voi nähdä käsikortit - + not allowed ei sallittu - + Room Huone - + Age Ikä - + Description Kuvaus - + Creator Luoja - + Type Muoto - + Restrictions Ehdot - + Players Pelaajat - + Spectators Katsojat @@ -4117,143 +4583,158 @@ You may have to manually download the new version. GeneralSettingsPage - + Reset all paths - + All paths have been reset - - - - - - - + + + + + + + Choose path Valitse polku - - Personal settings - Henkilökohtaiset asetukset + + Language settings + - + Language: Kieli: - + + Version settings + + + + + Card database + + + + + Startup settings + + + + Paths (editing disabled in portable mode) Polut (editoiminen poistettu käytöstä kannettavassa versiossa) - + Paths Polut - + How to help with translations - + Decks directory: Pakkojen kansio: - + Filters directory: - + Replays directory: Replay kansio: - + Pictures directory: Kuvakansio: - + Card database: Korttitietokanta: - + Custom database directory: - + Token database: Tokenitietokanta: - + Update channel Päivitykset - + Check for client updates on startup - + Check for card database updates on startup - + Don't check - + Prompt for update - + Always update in the background - + Check for card database updates every - + days - + Notify if a feature supported by the server is missing in my client Ilmoita jos ohjelmastani puuttuu jokin serverin tarjoama ominaisuus - + Automatically run Oracle when running a new version of Cockatrice Suorita Oracle automaattisesti kun Cockatricen uusi versio on asennettu - + Show tips on startup Näytä vinkit käynnistyksen yhteydessä - + Last update check on %1 (%2 days ago) @@ -4261,47 +4742,47 @@ You may have to manually download the new version. GraveyardMenu - + &Graveyard - + &View graveyard - + &Move graveyard to... - + &Top of library - + &Bottom of library - + &All players - + &Hand - + &Exile - + Reveal random card to... @@ -4309,88 +4790,88 @@ You may have to manually download the new version. HandMenu - + &Hand - + &View hand - + Sort hand by... - + Name - + Type - + Mana Value - + Take &mulligan (Choose hand size) - + Take mulligan (Same hand size) - + Take mulligan (Hand size - 1) - + &Move hand to... - + &Top of library - + &Bottom of library - + &Graveyard - + &Exile - + &Reveal hand to... - - + + All players - + Reveal r&andom card to... @@ -4398,52 +4879,52 @@ You may have to manually download the new version. HomeWidget - + Create New Deck - + Browse Decks - + Browse Card Database - + Browse EDHRec - + Browse Archidekt - + View Replays - + Quit - + Connecting... - + Connect - + Play @@ -4451,213 +4932,213 @@ You may have to manually download the new version. LibraryMenu - + &Library - + &View library - + View &top cards of library... - + View bottom cards of library... - + Reveal &library to... - + Lend library to... - + Reveal &top cards to... - + &Top of library... - + &Bottom of library... - + &Always reveal top card - + &Always look at top card - + &Open deck in deck editor - + &Draw card - + D&raw cards... - + &Undo last draw - + Shuffle - + &Play top card - + Play top card &face down - + Put top card on &bottom - + Move top card to grave&yard - + Move top card to e&xile - + Move top cards to &graveyard... - + Move top cards to graveyard face down... - + Move top cards to &exile... - + Move top cards to exile face down... - + Put top cards on stack &until... - + Shuffle top cards... - + &Draw bottom card - + D&raw bottom cards... - + &Play bottom card - + Play bottom card &face down - + Move bottom card to grave&yard - + Move bottom card to e&xile - + Move bottom cards to &graveyard... - + Move bottom cards to graveyard face down... - + Move bottom cards to &exile... - + Move bottom cards to exile face down... - + Put bottom card on &top - + Shuffle bottom cards... - - + + &All players - + Reveal top cards of library - + Number of cards: (max. %1) @@ -4665,638 +5146,273 @@ You may have to manually download the new version. MainWindow - - - The server has reached its maximum user capacity, please check back later. - Serveri on täynnä, yritä myöhemmin uudelleen. - - - - There are too many concurrent connections from your address. - Liian monta samanaikaista yhteyttä osoitteestasi. - - - - Banned by moderator - Bännätty moderaattorin toimesta - - - - Expected end time: %1 - Arvioitu päättymisaika: %1 - - - - This ban lasts indefinitely. - Bänni kestää loputtomasti. - - - - Scheduled server shutdown. - Ajastettu serverin sammutus. - - - - - Invalid username. - Väärä käyttäjänimi. - - - - You have been logged out due to logging in at another location. - Sinut kirjattiin ulos koska kirjauduit sisään toisesta paikasta. - - - - Connection closed - Yhteys suljettu - - - - The server has terminated your connection. -Reason: %1 - Serveri katkaisi yhteyden. -Syy: %1 - - - - The server is going to be restarted in %n minute(s). -All running games will be lost. -Reason for shutdown: %1 - Serveri käynnistetään uudelleen minuutin kuluttua( ) -Kaikki pelit joudutaan lopettamaan -Syy: %1Serveri käynnistetään uudelleen %n minuutin kuluttua( ) -Kaikki pelit joudutaan lopettamaan -Syy: %1 - - - - Scheduled server shutdown - Ajastettu serverin sammutus. - - - - - Success - Onnistui - - - - Registration accepted. -Will now login. - Rekisteröinti hyväksytty -Kirjautuu. - - - - Account activation accepted. -Will now login. - Tilin aktivointi hyväksytty. -Kirjautuu. - - - - + + Player %1 Pelaaja %1 - + Load replay Lataa replay - + About Cockatrice Tieto sovelluksesta - + Version Versio - + Cockatrice Webpage Cockatricen Webbsivut - + Project Manager: Projektipäällikkö: - + Past Project Managers: Edelliset projektipäälliköt: - + Developers: Kehittäjät: - + Our Developers Kehittäjämme - + Help Develop! Auta kehittämään! - + Translators: Kääntäjät: - + Our Translators Kääntäjät - + Help Translate! Auta Kääntämään! - + Support: Tuki: - + Report an Issue Ilmoita puute - + Troubleshooting Vianhaku - + F.A.Q. F.A.Q. - - - - - - - - - - - - - - - - - - - - + + Error Virhe - - Server timeout - Serverin aikakatkaisu - - - - Failed Login - Kirjautuminen epäonnistui - - - - Your client seems to be missing features this server requires for connection. - Sovelluksestasi puuttuu ominaisuuksia, joita serveri vaatii yhteyden muodostamiseksi. - - - - To update your client, go to 'Help -> Check for Client Updates'. - Päivitä sovellus: 'Apua' -> 'Tarkasta onko päivityksiä'. - - - - Incorrect username or password. Please check your authentication information and try again. - Väärä käyttäjänimi tai salasana. Tarkista todennustietosi ja yritä uudelleen. - - - - There is already an active session using this user name. -Please close that session first and re-login. - Tällä käyttäjänimellä on jo aktiivinen sessio käynnissä. Sulje se ensin ja kirjaudu sisään uudelleen - - - - - You are banned until %1. - Sinut on bännätty %1 saakka. - - - - - You are banned indefinitely. - Sinut on bännätty lopullisesti - - - - This server requires user registration. Do you want to register now? - Serveri vaatii rekisteröitymisen. Haluatko rekisteröityä nyt? - - - - This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. -Please close and reopen your client to try again. - This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. -Please close and reopen your client to try again. - - - - An internal error has occurred, please close and reopen Cockatrice before trying again. -If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. - Sisäinen virhe! Sulje ja käynnistä Cockatrice uudelleen ennenkuin yrität uudestaan. -Jos ongelma jatkuu, tarkasta että käytössäsi on sovelluksen uusin versio ja tarvittaessa ota yhteyttä sovelluksen kehittäjiin. - - - - Account activation - Tilin aktivointi - - - - Your account has not been activated yet. -You need to provide the activation token received in the activation email. - Tiliäsi ei ole vielä aktivoitu -Ole hyvä ja anna aktivointikoodi, jonka sait sähköpostiisi. - - - - Server Full - Serveri on täynnä - - - - Unknown login error: %1 - Tuntematon kirjautumisvirhe: %1 - - - - - -This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. - -Yleensä tämä tarkoittaa, että sovelluksesi on päivittämättä ja serveri lähetti vastauksen, jota sovellus ei ymmärrä. - - - - Your username must respect these rules: - Käyttäjänimesi täytyy olla näiden sääntöjen mukainen: - - - - is %1 - %2 characters long - %1 - %2 merkkiä pitkä - - - - can %1 contain lowercase characters - voi sisältää %1 pientä kirjainta - - - - - - - NOT - EI - - - - can %1 contain uppercase characters - voi sisältää %1 isoa kirjainta - - - - can %1 contain numeric characters - voi sisältää %1 numeroa - - - - can contain the following punctuation: %1 - voi sisältää seuraavat merkit: %1 - - - - first character can %1 be a punctuation mark - ensimmäinen kirjain ei voi olla %1 merkki - - - - no unacceptable language as specified by these server rules: - note that the following lines will not be translated - - - - - can not contain any of the following words: %1 - ei voi sisältää näitä sanoja: %1 - - - - can not match any of the following expressions: %1 - ei voi sisältää seuraavia asioita: %1 - - - - You may only use A-Z, a-z, 0-9, _, ., and - in your username. - Voit käyttää käyttäjänimessä merkkejä A-Z, a-z, 0-9, _, ., ja - - - - - - - - - - Registration denied - Rekisteröinti evätty - - - - Registration is currently disabled on this server - Tämä serveri ei ota tällä hetkellä vastaan rekisteröintejä - - - - There is already an existing account with the same user name. - Käyttäjänimi on jo olemassa - - - - It's mandatory to specify a valid email address when registering. - Rekisteröintiin on annettava toimiva sähköpostiosoite. - - - - It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. - Näyttää siltä että yrität rekisteröidä uutta tiliä tälle serverille, vaikka sinulla on tällä sähköpostilla jo olemassaoleva tili. Tämä serveri rajoittaa tilien määrää, jotka tulevat samasta osoitteesta. Ota yhteyttä serverin tukeen pyytääksesi apua tai saadaksesi kirjautumistietosi. - - - - Password too short. - Salasana on liian lyhyt. - - - - Registration failed for a technical problem on the server. - Rekisteröinti epäonnistui serverin vian vuoksi. - - - - The connection to the server has been lost. - - - - - Unknown registration error: %1 - Tuntematon rekisteröintivirhe: %1 - - - - Account activation failed - Tilin aktivointi epäonnistui - - - - Socket error: %1 - Pistokevirhe: %1 - - - - You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. -Local version is %1, remote version is %2. - Yritit yhdistää vanhentuneeseen palvelimeen. Käytä vanhempaa versiota Cockatricesta tai yhdistä sopivaan palvelimeen. -Paikallinen versio on %1, etäversio on %2. - - - - Your Cockatrice client is obsolete. Please update your Cockatrice version. -Local version is %1, remote version is %2. - Cockatrice-ohjelmasi on vanhentunut. Päivitä Cockatrice-versiosi. -Paikallinen versio on %1, etäversio on %2. - - - - Connecting to %1... - Yhdistetään: %1... - - - - Registering to %1 as %2... - Rekisteröidytään %1:een %2:na - - - - Disconnected - Yhteys katkaistu - - - - Connected, logging in at %1 - Yhteys luotu, kirjaudutaan %1:n - - - - - - Requesting forgotten password to %1 as %2... - Requesting forgotten password to %1 as %2... - - - + &Connect... &Yhdistä... - + &Disconnect &Katkaise yhteys - + Start &local game... Aloita &paikallinen peli... - + &Watch replay... &Katso replay... - + &Full screen K&oko näyttö - + &Register to server... &Rekisteröidy serverille... - + &Restore password... - + &Settings... &Asetukset - + &Exit &Exit - + A&ctions &Toiminnot - + &Cockatrice &Cockatrice - + C&ard Database &Korttitietokanta - + &Manage sets... &Manage sets... - + Edit custom &tokens... Edit custom &tokens... - + Open custom image folder Avaa mukautettu kuvakansio - + Open custom sets folder Avaa mukautettu setit -kansio - + Add custom sets/cards Lisää mukautettuja settejä/kortteja - + Reload card database - + Tabs - + &Help &Apua - + &About Cockatrice &Tietoa Cockatricesta - + &Tip of the Day &Päivän vinkki - + Check for Client Updates Etsi päivityksiä - + Check for Card Updates... - + Check for Card Updates (Automatic) - + Show Status Bar - + View &Debug Log - + Open Settings Folder - + Show/Hide - + New Version Uusi versio - + Congratulations on updating to Cockatrice %1! Oracle will now launch to update your card database. Congratulations on updating to Cockatrice %1! Oracle will now launch to update your card database. - + Cockatrice installed Cockatrice asennettu - + Congratulations on installing Cockatrice %1! Oracle will now launch to install the initial card database. Congratulations on installing Cockatrice %1! Oracle will now launch to install the initial card database. - + Card database Korttitietokanta - + Cockatrice is unable to load the card database. Do you want to update your card database now? If unsure or first time user, choose "Yes" @@ -5305,46 +5421,51 @@ Do you want to update your card database now? If unsure or first time user, choose "Yes" - - + + Yes Kyllä - - + + No Ei - + Open settings Avaa asetukset - + New sets found Löytyi uusia settejä! - + %n new set(s) found in the card database Set code(s): %1 Do you want to enable it/them? - + + Yes, always enable + + + + View sets Näytä setit - + Welcome Tervetuloa - + Hi! It seems like you're running this version of Cockatrice for the first time. All the sets in the card database have been enabled. Read more about changing the set order or disabling specific sets and consequent effects in the "Manage Sets" dialog. @@ -5353,166 +5474,132 @@ All the sets in the card database have been enabled. Read more about changing the set order or disabling specific sets and consequent effects in the "Manage Sets" dialog. - - + Information Tietoa - + A card database update is already running. Korttitietokannan päivitys on jo käynnissä - + Unable to run the card database updater: Unable to run the card database updater: - + Card database update running. - + Failed to start. The file might be missing, or permissions might be incorrect. - + The process crashed some time after starting successfully. - + Timed out. The process took too long to respond. The last waitFor...() function timed out. - + An error occurred when attempting to write to the process. For example, the process may not be running, or it may have closed its input channel. - + An error occurred when attempting to read from the process. For example, the process may not be running. - + Unknown error occurred. - + The card database updater exited with an error: %1 - - This server supports additional features that your client doesn't have. -This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. - -To update your client, go to Help -> Check for Updates. - This server supports additional features that your client doesn't have. -This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. - -To update your client, go to Help -> Check for Updates. - - - - - - - + + + + + Load sets/cards Lataa setit/kortit - + Selected file cannot be found. Selected file cannot be found. - + You can only import XML databases at this time. You can only import XML databases at this time. - + The new sets/cards have been added successfully. Cockatrice will now reload the card database. The new sets/cards have been added successfully. Cockatrice will now reload the card database. - + Sets/cards failed to import. Sets/cards failed to import. - - - - - Reset Password - - - - - Your password has been reset successfully, you can now log in using the new credentials. - Salasanasi on muutettu, voit nyt kirjautua sisään uudella salasanallasi. - - - - Failed to reset user account password, please contact the server operator to reset your password. - Failed to reset user account password, please contact the server operator to reset your password. - - - - Activation request received, please check your email for an activation token. - Activation request received, please check your email for an activation token. - ManaBaseConfigDialog - + Mana Base Configuration - + Display type: - + pie - + bar - + combinedBar - + Filter Colors (optional): - + OK - + Cancel @@ -5584,27 +5671,27 @@ Cockatrice will now reload the card database. ManaDevotionConfigDialog - + Display type: - + pie - + bar - + combinedBar - + Filter Colors (optional): @@ -5674,610 +5761,615 @@ Cockatrice will now reload the card database. MessageLogWidget - + from play pelistä - + from their graveyard Graveyardilta - + from exile exilestä - + from their hand kädestään - + the top card of %1's library %1n pakan päällimmäinen kortti - + the top card of their library pakan päällimmäisen kortin - + from the top of %1's library %1n pakan päältä - + from the top of their library Pakkansa päältä - + the bottom card of %1's library %1n pakan alimmainen kortti - + the bottom card of their library pakan alimmaisen kortin - + from the bottom of %1's library %1n pakan pohjalta - + from the bottom of their library pakan alta - + from %1's library %1n pakasta - + from their library pakasta - + from sideboard sideboardilta - + from the stack stäkistä - + from custom zone '%1' - + %1 is now keeping the top card %2 revealed. %1 pelaa pakan päällimmäinen kortti %2 esillä. - + %1 is not revealing the top card %2 any longer. %1 ei pelaa enää pakan päällimmäinen kortti %2 esillä, - + %1 can now look at top card %2 at any time. - + %1 no longer can look at top card %2 at any time. - + %1 attaches %2 to %3's %4. %1 attaches %2 to %3's %4. - + %1 has conceded the game. %1 luovutti. - + %1 has unconceded the game. %1 has unconceded the game. - + %1 has restored connection to the game. %1 on jälleen yhteydessä peliin. - + %1 has lost connection to the game. %1 menetti yhteyden peliin. - + %1 points from their %2 to themselves. %1 osoittaa kortista %2 itseensä. - + %1 points from their %2 to %3. %1 osoittaa kortista %2 korttiin %3. - + %1 points from %2's %3 to themselves. %1 osoittaa %2n kortista %3 itseensä. - + %1 points from %2's %3 to %4. %1 osoittaa %2n kortista %3 korttiin %4 - + %1 points from their %2 to their %3. %1 osoittaa kortistaan %2 korttiin %3. - + %1 points from their %2 to %3's %4. %1 osoittaa kortistaan %2 pelaajan %3 korttiin %4. - + %1 points from %2's %3 to their own %4. %1 osoittaa pelaajan %2 kortista %3 korttiin %4. - + %1 points from %2's %3 to %4's %5. %1 points from %2's %3 to %4's %5. - + %1 creates a face down token. - + %1 creates token: %2%3. %1 tekee tokenin: %2%3. - + %1 has loaded a deck (%2). %1 on ladannut pakan (%2). - + %1 has loaded a deck with %2 sideboard cards (%3). %1 on ladannut pakan, jossa on %2 sideboard korttia (%3) - + %1 destroys %2. %1 tuhoaa kortin %2. - + a card kortti - + %1 gives %2 control over %3. %1 antaa pelaajalle %2 kontrollin korttiin %3. - + %1 puts %2 into play%3 face down. - + %1 puts %2 into play%3. %1 laittaa kortin %2 peliin%3. - + %1 puts %2%3 into their graveyard face down. - + %1 puts %2%3 into their graveyard. %1 laittaa kortin %2%3 graveyardille - + %1 exiles %2%3 face down. - + %1 exiles %2%3. %1 laittaa exileen kortin %2%3. - + %1 moves %2%3 to their hand. %1 laittaa kortin %2%3 käteen. - + %1 puts %2%3 into their library. %1 laittaa kortin %2%3 pakkaan. - + %1 puts %2%3 onto the bottom of their library. - + %1 puts %2%3 on top of their library. %1 laittaa kortin %2%3 pakan päälle. - + %1 puts %2%3 into their library %4 cards from the top. %1 laittaa kortin %2%3 pakkaansa %4 kortiksi päältä lukien. - + %1 moves %2%3 to sideboard. %1 siirtää kortin %2%3 sideboardille. - + %1 plays %2%3 face down. - + %1 plays %2%3. %1 pelaa kortin %2%3. - + %1 moves %2%3 to custom zone '%4' face down. - + %1 moves %2%3 to custom zone '%4'. - + %1 tries to draw from an empty library - + %1 draws %2 card(s). - + %1 is looking at %2. %1 is looking at %2. - + %1 is looking at the %4 %3 card(s) %2. top card for singular, top %3 cards for plural - + bottom - + top - + %1 turns %2 face-down. %1 turns %2 face-down. - + %1 turns %2 face-up. %1 turns %2 face-up. - + The game has been closed. Peli on suljettu. - + The game has started. Peli alkaa. - + You are flooding the game. Please wait a couple of seconds. - + %1 has joined the game. %1 on liittynyt peliin. - + %1 is now watching the game. %1 seuraa peliä. - + You have been kicked out of the game. Sinut on kickattu pois pelistä. - + %1 has left the game (%2). %1 on lähtenyt pelistä. (%2). - + %1 is not watching the game any more (%2). %1 ei enää seuraa peliä (%2) - + %1 is not ready to start the game any more. %1 ei ole enää valmis aloittamaan peliä. - + %1 shuffles their deck and draws a new hand of %2 card(s). - + %1 shuffles their deck and draws a new hand. %1 sekoittaa pakkansa ja nostaa uuden käden. - + You are watching a replay of game #%1. Katsot pelin #%1 replayta. - + %1 is ready to start the game. %1 on valmis aloittamaan pelin. - + cards an unknown amount of cards cards - + %1 card(s) a card for singular, %1 cards for plural - + %1 lends %2 to %3. - + %1 reveals %2 to %3. %1 reveals %2 to %3. - + %1 reveals %2. %1 reveals %2. - + %1 randomly reveals %2%3 to %4. %1 randomly reveals %2%3 to %4. - + %1 randomly reveals %2%3. %1 randomly reveals %2%3. - + %1 peeks at face down card #%2. %1 peeks at face down card #%2. - + %1 peeks at face down card #%2: %3. %1 peeks at face down card #%2: %3. - + %1 reveals %2%3 to %4. %1 reveals %2%3 to %4. - + %1 reveals %2%3. %1 reveals %2%3. - + %1 reversed turn order, now it's %2. %1 käänsi vuorojärjestyksen; nyt se on %2. - + reversed käänteinen - + normal normaali - + Heads Kruuna - + Tails Klaava - + %1 flipped a coin. It landed as %2. %1 heitti kolikkoa ja tulokseksi tuli %2. - + %1 rolls a %2 with a %3-sided die. %1 heitti tuloksen %2. %3 -sivuisella nopalla - + %1 flips %2 coins. There are %3 heads and %4 tails. - + %1 rolls a %2-sided dice %3 times: %4. - + %1's turn. %1n vuoro. - + %1 sets annotation of %2 to %3. %1 sets annotation of %2 to %3. - - %1 places %2 "%3" counter(s) on %4 (now %5). + + %1 places %2 %3%4 counter(s) on %5 (now %6). - - %1 removes %2 "%3" counter(s) from %4 (now %5). + + %1 removes %2 %3%4 counter(s) from %5 (now %6). - + + %1 failed to undo their last draw. + + + + %1 sets counter %2 to %3 (%4%5). %1 asettaa countterin %2 arvoon %3 (%4%5). - + %1 sets %2 to not untap normally. %1 sets %2 to not untap normally. - + %1 sets %2 to untap normally. %1 sets %2 to untap normally. - + %1 removes the PT of %2. %1 removes the PT of %2. - + %1 changes the PT of %2 from nothing to %4. %1 changes the PT of %2 from nothing to %4. - + %1 changes the PT of %2 from %3 to %4. %1 changes the PT of %2 from %3 to %4. - + %1 has locked their sideboard. %1 on lukinnut sideboardinsa. - + %1 has unlocked their sideboard. %1 on avannut sideboardinsa. - + %1 taps their permanents. %1 täppää kaikki permanentit. - + %1 untaps their permanents. %1 untäppää kaikki permanentit. - + %1 taps %2. %1 täppää kortin %2. - + %1 untaps %2. %1 untäppää kortin %2. - + %1 shuffles %2. %1 sekoittaa %2. - + %1 shuffles the bottom %3 cards of %2. %1 shuffles the bottom %3 cards of %2. - + %1 shuffles the top %3 cards of %2. %1 shuffles the top %3 cards of %2. - + %1 shuffles cards %3 - %4 of %2. %1 shuffles cards %3 - %4 of %2. - + %1 unattaches %2. %1 irrottaa kortin %2. - + %1 undoes their last draw. %1 peruuttaa viimeisimmän kortinnostonsa. - + %1 undoes their last draw (%2). %1 peruuttaa viimeisimmän kortinnostonsa. (%2). @@ -6285,110 +6377,115 @@ Cockatrice will now reload the card database. MessagesSettingsPage - + Word1 Word2 Word3 Word1 Word2 Word3 - + Add New Message - + Edit Message - + Remove Message - + Add message Lisää viesti - - + + Message: Viesti: - + Edit message Edit message - + Chat settings Keskusteluasetukset - + Custom alert words Custom alert words - + Enable chat mentions Salli keskustelumaininnat - + Enable mention completer Enable mention completer - + In-game message macros Pelinsisäiset viestimakrot - + How to use in-game message macros - + Ignore chat room messages sent by unregistered users Ignore chat room messages sent by unregistered users - + Ignore private messages sent by unregistered users Ignore private messages sent by unregistered users - - + + Ignore private messages sent by non-buddy users + + + + + Invert text color Käänteinen tekstin väri - + Enable desktop notifications for private messages Enable desktop notifications for private messages - + Enable desktop notification for mentions Enable desktop notification for mentions - + Enable room message history on join Enable room message history on join - - + + (Color is hexadecimal) (Väri on heksadesimaaliluku) - + Separate words with a space, alphanumeric characters only Separate words with a space, alphanumeric characters only @@ -6396,42 +6493,42 @@ Cockatrice will now reload the card database. MoveMenu - + Move to - + &Top of library in random order - + X cards from the top of library... - + &Bottom of library in random order - + T&able - + &Hand - + &Graveyard - + &Exile @@ -6444,47 +6541,47 @@ Cockatrice will now reload the card database. Card Type - + Mana Value - + Color(s) Color(s) - + Loyalty Loyalty - + Main Card Type Main Card Type - + Mana Cost Mana Cost - + P/T P/T - + Side Side - + Layout Layout - + Color Identity Color Identity @@ -6507,6 +6604,152 @@ Cockatrice will now reload the card database. + + PaletteEditorDialog + + + + Reset + + + + + + Apply + + + + + + Save && Apply + + + + + ▼ Edit Palette + + + + + + ▶ Edit Palette + + + + + Palette Editor — %1 + + + + + <b>Palette Editor</b> &nbsp;·&nbsp; %1 + + + + + This theme ships no default palette files + + + + + Replace current colours with the theme author's defaults + + + + + Switch between the light and dark palette files + + + + + Editing: + + + + + Show or hide the per-role colour grid for manual tweaks + + + + + ↺ Revert to theme default + + + + + Discard unsaved edits and restore the last saved palette + + + + + Preview this palette without saving to disk + + + + + Write palette-%1.toml and reload the theme + + + + + Cannot save: this theme has no directory on disk + + + + + Save failed + + + + + Could not write %1 to: +%2 + + + + + No default found + + + + + No default palette file found for the "%1" scheme. + + + + + PaletteGridWidget + + + Active + + + + + Disabled + + + + + Inactive + + + + + Normal interactive state + + + + + Widget is disabled / not interactive + + + + + Window is in background / unfocused + + + Phase @@ -6573,57 +6816,57 @@ Cockatrice will now reload the card database. PhasesToolbar - + Untap step Untap-kohta - + Upkeep step Upkeep-kohta - + Draw step Nostokohta - + First main phase First main phase - + Beginning of combat step Beginning of combat step - + Declare attackers step Declare attackers step - + Declare blockers step Declare blockers step - + Combat damage step Combat damage step - + End of combat step End of combat step - + Second main phase Second main phase - + End of turn step End of turn step @@ -6631,7 +6874,7 @@ Cockatrice will now reload the card database. PictureLoader - + en code for scryfall's language property, not available for all languages @@ -6640,151 +6883,142 @@ Cockatrice will now reload the card database. PlayerActions - - View top cards of library - - - - - - - - - - - - - Number of cards: (max. %1) - - - - - View bottom cards of library - - - - - Shuffle top cards of library - - - - - Shuffle bottom cards of library - - - - - Draw hand - - - - - 0 and lower are in comparison to current hand size - - - - - Draw cards - - - - - - - + + + + grave - - - - + + + + exile + + + PlayerDialogs - + + View top cards of library + + + + + + + + + + + + + Number of cards: (max. %1) + + + + + View bottom cards of library + + + + + Shuffle top cards of library + + + + + Shuffle bottom cards of library + + + + + Draw hand + + + + + 0 and lower are in comparison to current hand size + + + + + Draw cards + + + + Move top cards to %1 - + Move bottom cards to %1 - + Draw bottom cards - - - C&reate another %1 token - - - - + Create tokens - - + Number: - + Place card X cards from top of library - + Which position should this card be placed: - + (max. %1) - + Change power/toughness - + Change stats to: - + Set annotation - + Please enter the new annotation: - - - Set counters - - PlayerMenu - + Player "%1" - + &Counters @@ -6814,7 +7048,7 @@ This setting means you'll only see the default printing for each card, instead o - + Printing Selector @@ -6822,22 +7056,22 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelectorCardOverlayWidget - + Preference - + Pin Printing - + Unpin Printing - + Show Related cards @@ -6908,57 +7142,57 @@ This setting means you'll only see the default printing for each card, instead o PtMenu - + Power / toughness - + &Increase power - + &Decrease power - + I&ncrease toughness - + D&ecrease toughness - + In&crease power and toughness - + Dec&rease power and toughness - + Increase power and decrease toughness - + Decrease power and increase toughness - + Set &power and toughness... - + Reset p&ower and toughness @@ -6966,37 +7200,37 @@ This setting means you'll only see the default printing for each card, instead o QMenuBar - + Services Services - + Hide %1 Hide %1 - + Hide Others Hide Others - + Show All Show All - + Preferences... Preferences... - + Quit %1 Quit %1 - + About %1 About %1 @@ -7004,17 +7238,17 @@ This setting means you'll only see the default printing for each card, instead o QObject - + Cockatrice card database (*.xml) Cockatrice card database (*.xml) - + All files (*.*) All files (*.*) - + Cockatrice replays (*.cor) Cockatrice replays (*.cor) @@ -7074,110 +7308,177 @@ Are you sure you would like to disable this feature? QPlatformTheme - + OK OK - + Save Save - + Save All Save All - + Open Avaa - + &Yes &Yes - + Yes to &All Yes to &All - + &No &Ei - + N&o to All N&o to All - + Abort Abort - + Retry Retry - + Ignore Sivuuta - + Close Close - + Cancel Cancel - + Discard Discard - + Help Help - + Apply Apply - + Reset Nollaa - + Restore Defaults Restore Defaults + + QuickSetupPanel + + + %1% + + + + + <b>Quick Setup</b> + + + + + Generate all palette roles automatically from a single accent colour + + + + + Accent: + + + + + Primary hue. Used directly for highlights and links. +At high intensity it also tints buttons and backgrounds. + + + + + Intensity: + + + + + Subtle + + + + + Full colour + + + + + 0–30 Subtle tint — only highlights and links change hue +30–70 Accented — buttons, tooltips, and borders join in +70–100 Full colour — backgrounds, everything + + + + + 70% + + + + + Generate ↓ + + + + + Derive all palette roles from the accent colour above. +Fine-tune individual colours in the grid afterwards. + + + RemoteDeckList_TreeModel - + Name Nimi - + ID ID - + Upload time Upload time @@ -7185,32 +7486,32 @@ Are you sure you would like to disable this feature? RemoteReplayList_TreeModel - + ID ID - + Name Nimi - + Players Players - + Keep Keep - + Time started Time started - + Duration (sec) Duration (sec) @@ -7218,37 +7519,37 @@ Are you sure you would like to disable this feature? RfgMenu - + &Exile - + &View exile - + &Move exile to... - + &Top of library - + &Bottom of library - + &Hand - + &Graveyard @@ -7294,7 +7595,7 @@ Are you sure you would like to disable this feature? SayMenu - + S&ay @@ -7335,27 +7636,27 @@ Are you sure you would like to disable this feature? SetsModel - + Enabled Enabled - + Set type Set type - + Set code Set code - + Long name Long name - + Release date Release date @@ -7363,53 +7664,53 @@ Are you sure you would like to disable this feature? ShortcutSettingsPage - - + + Restore all default shortcuts Restore all default shortcuts - + Do you really want to restore all default shortcuts? Do you really want to restore all default shortcuts? - + Clear all default shortcuts Clear all default shortcuts - + Do you really want to clear all shortcuts? Do you really want to clear all shortcuts? - + Section: Section: - + Action: Action: - + Shortcut: Shortcut: - + How to set custom shortcuts How to set custom shortcuts - + Clear all shortcuts - + Search by shortcut name @@ -7465,12 +7766,12 @@ Please check your shortcut settings! SideboardMenu - + &Sideboard - + &View sideboard @@ -7478,27 +7779,27 @@ Please check your shortcut settings! SoundSettingsPage - + Enable &sounds Enable &sounds - + Current sounds theme: Current sounds theme: - + Test system sound engine Test system sound engine - + Sound settings Sound settings - + Master volume Master volume @@ -7570,16 +7871,146 @@ Please check your shortcut settings! Invalid reply received from the release update server. - + No reply received from the tag update server. No reply received from the tag update server. - + Invalid reply received from the tag update server. Invalid reply received from the tag update server. + + StorageSettingsPage + + + + + Success + + + + + Cached card pictures have been reset. + + + + + Downloaded card pictures have been reset. + + + + + Error + + + + + One or more downloaded card pictures could not be cleared. + + + + + In-memory (currently loaded) card pictures have been reset. + + + + + Card Picture Loader Caching Method: + + + + + The network cache is the preferred way of storing images. Downloaded images are stored here until the size of the cache exceeds the configured size. Cockatrice automatically monitors this cache and deletes the least recently seen card images to ensure the cache does not exceed the configured size. + + + + + Writing card images directly to a folder on your hard drive is another way of storing images. This does not change how Cockatrice accesses or downloads images. Cockatrice will NOT automatically monitor and clear this folder, so if you enable this option, it is up to you to ensure sufficient available space. It should also be noted that if a provider outage causes you to download the wrong picture (i.e. wrong printing) you will be stuck with it until you manually delete the file, as opposed to using the network cache, which automatically rotates and thus correct errors after a while. + + + + + This is the in-memory picture cache used by the application at runtime. It determines how much memory (RAM) Cockatrice can use before it has to fetch card images from the hard disk again. Increasing this will allow more card images to be displayed at once but shouldn't be necessary. Clearing this will make Cockatrice reload all images from the network cache or the disk. + + + + + Delete Cached Images + + + + + Delete Saved Images + + + + + Clear In-Memory Images + + + + + Card Picture Loader Cache Method + + + + + Network Cache + + + + + Filesystem + + + + + In-Memory Picture Cache + + + + + Network Cache Size: + + + + + On-disk cache for downloaded pictures + + + + + Redirect Cache TTL: + + + + + How long cached redirects for urls are valid for. + + + + + Picture Cache Size: + + + + + In-memory cache for pictures not currently on screen + + + + + Naming scheme: + + + + + Day(s) + + + TabAccount @@ -7715,117 +8146,117 @@ Please check your shortcut settings! TabArchidekt - - + + Desc. - - + + AND - - + + Require ALL selected colors - - + + Deck name... - - + + Owner... - - + + Packages - - + + Advanced Filters - + Bracket: - - + + Any - - + + Contains card... - - + + Commander... - - + + Tag... - - + + Deck Size - + Cards: - - + + Asc. - + Sort by: - + Filter by: - + Display Settings - - + + Search - - + + Formats @@ -7835,6 +8266,54 @@ Please check your shortcut settings! + + TabCardArtRules + + + Card: + + + + + ProviderId: + + + + + Mode: + + + + + Reason: + + + + + Type a card name... + + + + + Add rule + + + + + Remove rule + + + + + Refresh + + + + + Card Art Rules + + + TabDeckEditor @@ -7883,7 +8362,7 @@ Please check your shortcut settings! Reset layout - + Deck: %1 Deck: %1 @@ -7891,55 +8370,49 @@ Please check your shortcut settings! TabDeckEditorVisual - + Visual Deck: %1 - + &Visual Deck Editor - - + + Card Info - - + + Deck - - - Filters - - - - + &View - + Printing - + Visible - + Floating - + Reset layout @@ -7947,22 +8420,22 @@ Please check your shortcut settings! TabDeckEditorVisualTabWidget - + Visual Deck View - + Visual Database Display - + Deck Analytics - + Sample Hand @@ -7970,134 +8443,134 @@ Please check your shortcut settings! TabDeckStorage - + Local file system Local file system - + Server deck storage Server deck storage - - + + Open in deck editor Open in deck editor - + Rename deck or folder - + Upload deck Upload deck - + Download deck Download deck - - - + + + New folder New folder - + Delete Delete - + Open decks folder - + Rename local folder - + Rename local file - + New name: - - - - + + + + Error - + Rename failed - - + + Invalid deck file - + Enter deck name Enter deck name - + This decklist does not have a name. Please enter a name: This decklist does not have a name. Please enter a name: - + Unnamed deck Unnamed deck - + Failed to upload deck to server - + Delete local file Delete local file - + Are you sure you want to delete the selected files? - + Delete remote decks - + Are you sure you want to delete the selected decks? - - + + Name of new folder: Name of new folder: @@ -8169,191 +8642,191 @@ Please enter a name: TabGame - - - + + + Replay Replay - - + + Game Game - - + + Player List Player List - - + + Card Info Card Info - - + + Messages Messages - - + + Replay Timeline Replay Timeline - + &Phases &Phases - + &Game &Game - + Next &phase Next &phase - + Next phase with &action Next phase with &action - + Next &turn Next &turn - + Reverse turn order Käännä vuorojärjestys - + &Remove all local arrows &Remove all local arrows - + Rotate View Cl&ockwise Rotate View Cl&ockwise - + Rotate View Co&unterclockwise Rotate View Co&unterclockwise - + Game &information Game &information - + Un&concede - - - + + + &Concede &Concede - + &Leave game &Leave game - + C&lose replay C&lose replay - + &Focus Chat &Focus Chat - + &Say: &Say: - + Selected cards - + &View &View - + Visible Visible - + Floating Floating - + Reset layout Reset layout - + Concede Concede - + Are you sure you want to concede this game? Are you sure you want to concede this game? - + Unconcede Unconcede - + You have already conceded. Do you want to return to this game? You have already conceded. Do you want to return to this game? - + Leave game Poistu pelistä - + Are you sure you want to leave this game? Haluatko varmasti poistua pelistä? - + A player has joined game #%1 Pelaaja on littynyt peliin #%1 - + %1 has joined the game - + You have been kicked out of the game. You have been kicked out of the game. @@ -8412,115 +8885,115 @@ Please enter a name: You have to select a valid number of days to locate. - + Username: Käyttäjätunnus: - + IP Address: IP-osoite: - + Game Name: Pelin Nimi: - + GameID: Peli-ID: - + Message: Viesti: - + Main Room Main Room - + Game Room Game Room - + Private Chat Private Chat - + Past X Days: Past X Days: - + Today Tänään - + Last Hour Last Hour - + Maximum Results: Maximum Results: - + At least one filter is required. The more information you put in, the more specific your results will be. At least one filter is required. The more information you put in, the more specific your results will be. - + Get User Logs Get User Logs - + Clear Filters Clear Filters - + Filters Filters - + Log Locations Log Locations - + Date Range Date Range - + Maximum Results Maximum Results - - + + Message History Message History - + Failed to collect message history information. Failed to collect message history information. - + There are no messages for the selected filters. There are no messages for the selected filters. @@ -8538,27 +9011,27 @@ The more information you put in, the more specific your results will be.&Leave - + %1 - Private chat %1 - Private chat - + This user is ignoring you, they cannot see your messages in main chat and you cannot join their games. This user is ignoring you, they cannot see your messages in main chat and you cannot join their games. - + Private message from Private message from - + %1 has left the server. %1 has left the server. - + %1 has joined the server. %1 has joined the server. @@ -8566,180 +9039,180 @@ The more information you put in, the more specific your results will be. TabReplays - + Local file system Local file system - + Server replay storage Server replay storage - - + + Watch replay Watch replay - + Rename - - + + New folder - - + + Delete Delete - + Open replays folder - + Download replay Download replay - + Toggle expiration lock Toggle expiration lock - + Get replay share code - - + + Look up replay by share code - + Rename local folder - + Rename local file - + New name: - + Error - + Rename failed - + Name of new folder: - + Delete local file Delete local file - + Are you sure you want to delete the selected files? - + Are you sure you want to delete the selected replays? - + Failed to get code - - + + Either this server does not support replay sharing, or does not permit replay sharing for you. - - - + + + Failed - + Could not get replay code - + Replay Share Code - + Others can use this code to add the replay to their list of remote replays: %1 - + Copy to clipboard - + Replay share code - + Replay code found - + Replay was added, or you already had access to it. - + Replay code not found - + Failed to submit code - + Unexpected error - + Delete remote replay Delete remote replay @@ -8752,47 +9225,62 @@ The more information you put in, the more specific your results will be. TabRoom - + + Friends + + + + + Online + + + + + Ignored + + + + &Say: &Say: - + Chat Chat - + &Room &Room - + &Leave room &Leave room - + &Clear chat &Clear chat - + Chat Settings... Chat Settings... - + mentioned you. mentioned you. - + Click to view Click to view - + You are flooding the chat. Please wait a couple of seconds. You are flooding the chat. Please wait a couple of seconds. @@ -8805,30 +9293,30 @@ The more information you put in, the more specific your results will be.Server - - - - + + + + Error Virhe - + Failed to join the server room: it doesn't exist on the server. - + The server thinks you are in the server room but your client is unable to display it. Try restarting your client. - + You do not have the required permission to join this server room. - + Failed to join the server room due to an unknown error: %1. @@ -8836,97 +9324,97 @@ The more information you put in, the more specific your results will be. TabSupervisor - + Deck Editor - + Visual Deck Editor - + EDHRec - + Archidekt - + Home - + &Visual Deck Storage - + Visual Database Display - + Server - + Account - + Deck Storage - + Game Replays - + Administration - + Logs - + Are you sure? Oletko varma? - + There are still open games. Are you sure you want to quit? There are still open games. Are you sure you want to quit? - + Click to view Click to view - + Your buddy %1 has signed on! Your buddy %1 has signed on! - + Unknown Event Tuntematon Tapahtuma - + The server has sent you a message that your client does not understand. This message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -8937,39 +9425,39 @@ This message might mean there is a new version of Cockatrice available or this s To update your client, go to Help -> Check for Updates. - + Idle Timeout Idle Timeout - + You are about to be logged out due to inactivity. You are about to be logged out due to inactivity. - + Promotion Promotion - + You have been promoted. Please log out and back in for changes to take effect. You have been promoted. Please log out and back in for changes to take effect. - + Warned Warned - + You have received a warning due to %1. Please refrain from engaging in this activity or further actions may be taken against you. If you have any questions, please private message a moderator. You have received a warning due to %1. Please refrain from engaging in this activity or further actions may be taken against you. If you have any questions, please private message a moderator. - + You have received the following message from the server. (custom messages like these could be untranslated) You have received the following message from the server. @@ -8979,12 +9467,12 @@ Please refrain from engaging in this activity or further actions may be taken ag TabVisualDatabaseDisplay - + Database Display - + Visual Database Display @@ -9023,42 +9511,42 @@ Please refrain from engaging in this activity or further actions may be taken ag TranslateCounterName - + Life Life - + White Valkoinen - + Blue Sininen - + Black Musta - + Red Punainen - + Green Vihreä - + Colorless Väritön - + Other Muu @@ -9066,11 +9554,69 @@ Please refrain from engaging in this activity or further actions may be taken ag UpdateDownloader - + Could not open the file for reading. Could not open the file for reading. + + UserCardArtSettingsDialog + + + Card Art Settings + + + + + Type a card name... + + + + + Card name: + + + + + Card ProviderId: + + + + + Left margin (%): + + + + + Right margin (%): + + + + + Vertical offset: + + + + + Zoom: + + + + + Parameters + + + + + Preview + + + + + Remove Banner Card + + + UserContextMenu @@ -9188,7 +9734,7 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Ban History Ban History @@ -9203,77 +9749,87 @@ Please refrain from engaging in this activity or further actions may be taken ag User has never been banned. - + Failed to collect ban information. Failed to collect ban information. - - - + + + Warning History Warning History - + Warning Time;Moderator;User Name;Reason Warning Time;Moderator;User Name;Reason - + User has never been warned. User has never been warned. - + Failed to collect warning information. Failed to collect warning information. - + Failed to get admin notes. - - + + Success Success - + Successfully promoted user. Successfully promoted user. - + Successfully demoted user. Successfully demoted user. - - - + + Kick Player + + + + + Are you sure you want to kick this player from the game? + + + + + + Failed Failed - + Failed to promote user. Failed to promote user. - + Failed to demote user. Failed to demote user. - + Copy hash to clipboard Copy hash to clipboard - + Remove this user's messages @@ -9281,326 +9837,554 @@ Please refrain from engaging in this activity or further actions may be taken ag UserInfoBox - + Location: Location: - + Account Age: Account Age: - + Edit Muokkaa - + Change password Vaihda Salasana - + Change avatar Vaihda avatar - + Administrator Administrator - + Moderator Moderator - + Registered user Registered user - - + + Unregistered user Unregistered user - + Judge Judge - + Unknown Unknown - + The entered password does not match your account. - - - + + + + Information Information - + User information updated. User information updated. - - - - - - - - - - + + + + + + + + + + + Error Virhe - + User Information - + Real Name: - + User Level: + + + Edit Banner Card + + - + %n Year(s), amount of years (only shown if more than 0) - + %10%n Day(s) %20 amount of years (if more than 0), amount of days, date in local short format - + Enter Password - + Password verification is required in order to change your email address - - - + + + An error occurred while trying to update your user information. - + + The selected card is blacklisted on this server or another error occurred. + + + + + Banner card removed. + + + + + Banner card updated. + + + + This server does not permit you to update your user informations. This server does not permit you to update your user informations. - + Password changed. Password changed. - + This server does not permit you to change your password. This server does not permit you to change your password. - + The new password is too short. The new password is too short. - + The old password is incorrect. The old password is incorrect. - + Avatar updated. Avatar updated. - + This server does not permit you to update your avatar. This server does not permit you to update your avatar. + + + An error occured while trying to update your avatar. + + - An error occured while trying to updater your avatar. - An error occured while trying to updater your avatar. + This server does not permit you to update your user informations. + An error occured while trying to updater your avatar. + This server does not permit you to update your user informations. + + + + UserInfoPopup + + + + Games + + + + + Chat + + + + + Open private chat + + + + + Profile + + + + + View user profile + + + + + Show this user's games + + + + + − Buddy + + + + + Remove from buddy list + + + + + + Buddy + + + + + Add to buddy list + + + + + − Ignore + + + + + Remove from ignore list + + + + + + Ignore + + + + + Add to ignore list + + + + + Ban + + + + + Ban from server + + + + + Warn + + + + + Warn user + + + + + Ban log + + + + + View ban history + + + + + Warn log + + + + + View warning history + + + + + Notes + + + + + View admin notes + + + + + − Mod + + + + + Demote from moderator + + + + + + Mod + + + + + Promote to moderator + + + + + − Judge + + + + + Demote from judge + + + + + + Judge + + + + + Promote to judge + + + + + Join game + + + + + Spectate + + + + + + Loading games… + + + + + Could not load games. + + + + + No active games. + UserInterfaceSettingsPage - + General interface settings General interface settings - + &Double-click cards to play them (instead of single-click) &Double-click cards to play them (instead of single-click) - + &Clicking plays all selected cards (instead of just the clicked card) - + &Play all nonlands onto the stack (not the battlefield) by default &Play all nonlands onto the stack (not the battlefield) by default - + Do not delete &arrows inside of subphases - + Close card view window when last card is removed - + Auto focus search bar when card view window is opened - + Annotate card text on tokens Annotate card text on tokens - - Show selection counter during drag selection + + Show selection count during drag selection - - Show total selection counter + + Show total selection count - + + Show subtype breakdown in selection tally + + + + Use tear-off menus, allowing right click menus to persist on screen Use tear-off menus, allowing right click menus to persist on screen - + + Keep game chat focused when clicking in game (Note: disables card view search bar) + + + + Notifications settings Ilmoitusasetukset - + Enable notifications in taskbar Ota tehtäväpalkin ilmoitukset käyttöön - + Notify in the taskbar for game events while you are spectating Notify in the taskbar for game events while you are spectating - + Notify in the taskbar when users in your buddy list connect Notify in the taskbar when users in your buddy list connect - + Animation settings Animaatioasetukset - + &Tap/untap animation &Tap/untap animation - + Deck editor/storage settings - + Open deck in new tab by default - + Use visual deck storage in game lobby - + Use selection animation for Visual Deck Storage - + When adding a tag in the visual deck storage to a .txt deck: - + do nothing - + ask to convert to .cod - + always convert to .cod - + Default deck editor type - + Classic Deck Editor - + Visual Deck Editor - + Replay settings - + Buffer time for backwards skip via shortcut: @@ -9608,22 +10392,22 @@ Please refrain from engaging in this activity or further actions may be taken ag UserListWidget - + Users connected to server: %1 - + Users in this room: %1 - + Buddies online: %1 / %2 - + Ignored users online: %1 / %2 @@ -9631,35 +10415,45 @@ Please refrain from engaging in this activity or further actions may be taken ag UtilityMenu - + Increment all card counters - + &Untap all permanents - + R&oll die... - + + Flip coin + + + + &Create token... - + C&reate another token - + Cr&eate predefined token + + + C&reate another %1 token + + VisualDatabaseDisplayColorFilterWidget @@ -9711,72 +10505,72 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayFilterToolbarWidget - + Sort by - + Filter by - + Save and load filters - + Filter by exact card name - + Filter by card main-type - + Filter by card sub-type - + Filter by set - + Filter by format legality - + Save/Load - + Name - + Main Type - + Sub Type - + Sets - + Formats @@ -9784,22 +10578,22 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayFormatLegalityFilterWidget - + Show formats with at least: - + cards - + Do not display formats with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) @@ -9817,32 +10611,32 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayMainTypeFilterWidget - + Show main types with at least: - + cards - + Do not display card main-types with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) - + Mode: Exact Match - + Mode: Includes @@ -9850,27 +10644,27 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayNameFilterWidget - + Filter by name... (Exact match) - + Load from Deck - + Apply all card names in currently loaded deck as exact match name filters - + Load from Clipboard - + Apply all card names in clipboard as exact match name filters @@ -9878,7 +10672,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayRecentSetFilterSettingsWidget - + Filter to most recent sets @@ -9886,19 +10680,19 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplaySetFilterWidget - + Search sets... - - + + Mode: Exact Match - - + + Mode: Includes @@ -9906,37 +10700,37 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplaySubTypeFilterWidget - + Search subtypes... - + Show sub types with at least: - + cards - + Do not display card sub-types with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) - + Mode: Exact Match - + Mode: Includes @@ -9944,23 +10738,23 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayWidget - + Search by card name (or search expressions) - + Visual - + Loading database ... - + Clear all filters @@ -9999,17 +10793,17 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Toggle Layout: Overlap - + Change how cards are displayed within zones (i.e. overlapped or fully visible.) - + Toggle Layout: Flat @@ -10038,17 +10832,17 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckEditorWidget - + Type a card name here for suggestions from the database... - + Quick search and add card - + Search for closest match in the database (with auto-suggestions) and add preferred printing to the deck on pressing enter @@ -10166,43 +10960,43 @@ Please refrain from engaging in this activity or further actions may be taken ag WarningDialog - + Which warning would you like to send? Which warning would you like to send? - + Redact all messages from this user in all rooms - + &OK &OK - + &Cancel &Cancel - + Warn user for misconduct Warn user for misconduct - - + + Error Virhe - + User name to send a warning to can not be blank, please specify a user to warn. User name to send a warning to can not be blank, please specify a user to warn. - + Warning to use can not be blank, please select a valid warning to send. Warning to use can not be blank, please select a valid warning to send. @@ -10210,133 +11004,133 @@ Please refrain from engaging in this activity or further actions may be taken ag WndSets - + Move selected set to the top Move selected set to the top - + Move selected set up Move selected set up - + Move selected set down Move selected set down - + Move selected set to the bottom Move selected set to the bottom - + Search by set name, code, or type Search by set name, code, or type - + Default order Default order - + Restore original art priority order Restore original art priority order - + Enable all sets Enable all sets - + Disable all sets Disable all sets - + Enable selected set(s) Enable selected set(s) - + Disable selected set(s) Disable selected set(s) - + Deck Editor Deck Editor - + Use CTRL+A to select all sets in the view. - + Only cards in enabled sets will appear in the card list of the deck editor. - + Image priority is decided in the following order: - + first the CUSTOM Folder (%1), then the Enabled Sets in this dialog (Top to Bottom) %1 is a link to the wiki - + Include cards rebalanced for Alchemy [requires restart] - + Card Art Card Art - + How to use custom card art How to use custom card art - + Hints Hints - + Note Note - + Sorting by column allows you to find a set while not changing set priority. Sorting by column allows you to find a set while not changing set priority. - + To enable ordering again, click the column header until this message disappears. To enable ordering again, click the column header until this message disappears. - + Use the current sorting as the set priority instead Use the current sorting as the set priority instead - + Sorts the set priority using the same column Sorts the set priority using the same column - + Manage sets Manage sets @@ -10344,72 +11138,72 @@ Please refrain from engaging in this activity or further actions may be taken ag ZoneViewWidget - + Search by card name (or search expressions) - + Ungrouped - + Group by Type - + Group by Mana Value - + Group by Color - + Unsorted - + Sort by Name - + Sort by Type - + Sort by Mana Cost - + Sort by Colors - + Sort by P/T - + Sort by Set - + shuffle when closing shuffle when closing - + pile view pile view @@ -10417,7 +11211,7 @@ Please refrain from engaging in this activity or further actions may be taken ag i18n - + English Suomi (Finnish) @@ -10425,12 +11219,12 @@ Please refrain from engaging in this activity or further actions may be taken ag main - + Connect on startup Connect on startup - + Debug to file Debug to file @@ -10444,7 +11238,7 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Deck Editor Deck Editor @@ -10525,7 +11319,7 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Replays @@ -10640,129 +11434,129 @@ Please refrain from engaging in this activity or further actions may be taken ag Load Deck... - - + + Load Deck from Clipboard... Load Deck from Clipboard... - + Edit Deck in Clipboard, Annotated - + Edit Deck in Clipboard - + New Deck New Deck - + Open Custom Pictures Folder Open Custom Pictures Folder - + Print Deck... Print Deck... - + Delete Card Delete Card - - + + Reset Layout Reset Layout - + Save Deck Tallenna Pakka - + Save Deck as... Save Deck as... - + Save Deck to Clipboard, Annotated Save Deck to Clipboard, Annotated - + Save Deck to Clipboard, Annotated (No Set Info) - + Save Deck to Clipboard Save Deck to Clipboard - + Save Deck to Clipboard (No Set Info) - + Load Local Deck... Load Local Deck... - + Load Remote Deck... Load Remote Deck... - + Set Ready to Start Set Ready to Start - + Toggle Sideboard Lock Toggle Sideboard Lock - + Add Green Counter Add Green Counter - + Remove Green Counter Remove Green Counter - + Set Green Counters... Set Green Counters... - + Add Red Counter Add Red Counter - + Remove Red Counter Remove Red Counter - + Set Red Counters... Set Red Counters... - + Add Life Counter Add Life Counter @@ -10772,724 +11566,744 @@ Please refrain from engaging in this activity or further actions may be taken ag - + + Load deck from online service... + + + + + Load from website... + + + + Unload Deck - + Force Start - + Add Card Counter (F) - + Remove Card Counter (F) - + Set Card Counters (F)... - + Add Card Counter (E) - + Remove Card Counter (E) - + Set Card Counters (E)... - + Add Card Counter(D) - + Remove Card Counter (D) - + Set Card Counters (D)... - + Add Card Counter (C) - + Remove Card Counter (C) - + Set Card Counters (C)... - + Add Card Counter (B) - + Remove Card Counter (B) - + Set Card Counters (B)... - + Add Card Counter (A) - + Remove Card Counter (A) - + Set Card Counters (A)... - + Remove Life Counter Remove Life Counter - + Set Life Counters... Set Life Counters... - + Add White Counter Add White Counter - + Remove White Counter Remove White Counter - + Set White Counters... Set White Counters... - + Add Blue Counter Add Blue Counter - + Remove Blue Counter Remove Blue Counter - + Set Blue Counters... Set Blue Counters... - + Add Black Counter Add Black Counter - + Remove Black Counter Remove Black Counter - + Set Black Counters... Set Black Counters... - + Add Colorless Counter Add Colorless Counter - + Remove Colorless Counter Remove Colorless Counter - + Set Colorless Counters... Set Colorless Counters... - + Add Other Counter - + Remove Other Counter - + Set Other Counters... - + Increment all card counters - + Add Power (+1/+0) Add Power (+1/+0) - + Remove Power (-1/-0) Remove Power (-1/-0) - + Move Toughness to Power (+1/-1) Move Toughness to Power (+1/-1) - + Add Toughness (+0/+1) Add Toughness (+0/+1) - + Remove Toughness (-0/-1) Remove Toughness (-0/-1) - + Move Power to Toughness (-1/+1) Move Power to Toughness (-1/+1) - + Add Power and Toughness (+1/+1) Add Power and Toughness (+1/+1) - + Remove Power and Toughness (-1/-1) Remove Power and Toughness (-1/-1) - + Set Power and Toughness... Set Power and Toughness... - + Reset Power and Toughness Reset Power and Toughness - + Untap Untap - + Upkeep Upkeep - + Draw Draw - + First Main Phase First Main Phase - + Start Combat Start Combat - + Attack Attack - + Block Block - + Damage Damage - + End Combat End Combat - + Second Main Phase Second Main Phase - + End End - + Next Phase Next Phase - + Next Phase Action Next Phase Action - + Next Turn Next Turn - + Hide Card in Reveal Window - + Tap / Untap Card Tap / Untap Card - + Untap All Untap All - + Toggle Skip Untapping Toggle Untap - + Turn Card Over Turn Card Over - + Peek Card Peek Card - + Play Card Play Card - + Play Card, Face Down - + Attach Card... Attach Card... - + Unattach Card Unattach Card - + Clone Card Clone Card - + Create Token... Create Token... - + Create All Related Tokens Create All Related Tokens - + Create Another Token Create Another Token - + Set Annotation... Set Annotation... - + + Reduce Life by Power + + + + Select All Cards in Zone - + Select All Cards in Row - + Select All Cards in Column - + Reveal Selected Cards to All Players - - + + Bottom of Library Bottom of Library - - - - + + + + Exile Exile - - - - + + + + Graveyard Graveyard - - + + Hand Hand - - + + Top of Library Top of Library - - + + Battlefield, Face Down Battlefield, Face Down - + Battlefield Battlefield - + Library Library - + Sideboard Sideboard - + Top Cards of Library Top Cards of Library - + Bottom Cards of Library - + Close Recent View Close Recent View - - + + Stack Stack - - + + Graveyard (Multiple) Graveyard (Multiple) - - + + Graveyard (Multiple), Face Down - - + + Exile (Multiple) Exile (Multiple) - - + + Exile (Multiple), Face Down - + Stack Until Found - + Draw Bottom Card - + Draw Multiple Cards from Bottom... - + Draw Arrow... Draw Arrow... - + Remove Local Arrows Remove Local Arrows - + Leave Game Leave Game - + Concede Concede - + Roll Dice... Roll Dice... - + + Flip Coin + + + + Shuffle Library Shuffle Library - + Shuffle Top Cards of Library - + Shuffle Bottom Cards of Library - + Mulligan Mulligan - + Mulligan (Same hand size) - + Mulligan (Hand size - 1) - + Draw a Card Draw a Card - + Draw Multiple Cards... Draw Multiple Cards... - + Undo Draw Undo Draw - + Always Reveal Top Card Always Reveal Top Card - + Always Look At Top Card - + Sort Hand by Name - + Sort Hand by Type - + Sort Hand by Mana Value - + Reveal Hand to All Players - + Reveal Random Card to All Players - + Rotate View Clockwise Rotate View Clockwise - + Rotate View Counterclockwise Rotate View Counterclockwise - + Unfocus Text Box Unfocus Text Box - + Focus Chat Focus Chat - + Clear Chat Clear Chat - + Refresh Refresh - + Skip Forward - + Skip Backward - + Skip Forward by a lot - + Skip Backward by a lot - + Play/Pause - + Toggle Fast Forward - + Home - + Visual Deck Storage - + Deck Storage - + Server - + Account - + Administration - + Logs diff --git a/cockatrice/translations/cockatrice_fr.ts b/cockatrice/translations/cockatrice_fr.ts index f50d3afd6..c85b6fbdd 100644 --- a/cockatrice/translations/cockatrice_fr.ts +++ b/cockatrice/translations/cockatrice_fr.ts @@ -2,7 +2,7 @@ AbstractCounter - + &Set counter... Nombre de marqueur&s… @@ -10,12 +10,12 @@ AbstractCounterDialog - + Set counter Nombre de marqueurs - + New value for counter '%1': Nouvelle valeur des marqueurs « %1 » : @@ -36,62 +36,62 @@ AbstractTabDeckEditor - + Open in new tab Ouvrir dans un nouvel onglet - + Are you sure? Êtes-vous sûr ? - + The decklist has been modified. Do you want to save the changes? La liste de deck a été modifiée. Voulez-vous enregistrer les modifications ? - - - - - - + + + + + + Error Erreur - + Could not open deck at %1 N'a pas pu ouvrir le deck à %1 - + Could not save remote deck N'a pas pu sauvegarder le deck distant - - + + The deck could not be saved. Please check that the directory is writable and try again. Le deck n'a pas pu être enregistré. Vérifiez que le répertoire ne soit pas en lecture seule et réessayez. - + Save deck Sauvegarder le deck - + The deck could not be saved. Le deck n'a pas pu être enregistré. - + There are no cards in your deck to be exported Il n'y a pas de cartes dans le deck à exporter @@ -101,18 +101,18 @@ Vérifiez que le répertoire ne soit pas en lecture seule et réessayez. Add Analytics Panel - + Ajouter un panneau d'analyse AdminNotesDialog - + Update Notes Notes de mises à jour - + Admin Notes for %1 Notes d'administrateur pour %1 @@ -129,172 +129,227 @@ Vérifiez que le répertoire ne soit pas en lecture seule et réessayez.Sideboard Réserve + + + Tokens + Jetons + AppearanceSettingsPage - + seconds secondes - + Error Erreur - + Could not create themes directory at '%1'. N'a pas pu créer le dossier Thèmes à '%1' - + Theme settings Paramètres du thème - + Current theme: Thème actuel : - + Open themes folder Ouvrir le dossier des thèmes - + Home tab background source: Source d'arrière-plan de l'onglet Accueil: - + Home tab background shuffle frequency: Fréquence de mélange de l'arrière-plan de l'onglet Accueil: - + Disabled Désactivé - - Display card name of background in bottom right: - - - - + Menu settings Options du Menu - + Show keyboard shortcuts in right-click menus Montrer les raccourcis clavier dans les menus clic-droit - + Show game filter toolbar above list in room tab - + Afficher les barres d'outil et de recherche au-dessus de la liste des parties dans l'onglet salon. - + Card rendering Rendu des cartes - + Display card names on cards having a picture Afficher les noms des cartes ayant une image - + Auto-Rotate cards with sideways layout Rotation automatique des cartes avec disposition horizontale - + Override all card art with personal set preference (Pre-ProviderID change behavior) Remplacer toutes les illustrations de cartes par les préférences personnelles (comportement avant modification du ProviderID) - + + Light + + + + + Dark + + + + + System + Système + + + + Active theme palette: + + + + + Edit theme palette + + + + + Home tab settings + + + + + Display card name of background in bottom right + Afficher le nom de la carte d'arrière-plan en bas à droite + + + + Styling settings + + + + + Style user list + + + + + Card printings + Images de la carte + + + Bump sets that the deck contains cards from to the top in the printing selector Mettre en avant que ce deck contient des cartes depuis le sommet, dans le sélecteur d'impression - + Scale cards on mouse over Agrandir les cartes lors du survol du curseur - + Use rounded card corners Utiliser des bords de cartes arrondis - + + Card layout + Disposition de la carte + + + Minimum overlap percentage of cards on the stack and in vertical hand Pourcentage minimum de chevauchement des cartes dans la pile et dans la main verticale - + Maximum initial height for card view window: Hauteur initiale maximale pour la fenêtre d'affichage des cartes : - - + + rows lignes - + Maximum expanded height for card view window: Hauteur étendue maximale pour la fenêtre d'affichage des cartes : - + Card counters Marqueurs sur la carte - + Counter %1 Marqueur %1 - + Hand layout Disposition de la main - + Display hand horizontally (wastes space) Afficher la main horizontalement (perte d'espace) - + Enable left justification Activer la justification à gauche - + Table grid layout Disposition en forme de grille - + Invert vertical coordinate Inverser la disposition du champ de bataille - + Minimum player count for multi-column layout: Nombre minimum de joueurs pour la disposition multi-colonnes : - + Maximum font size for information displayed on cards: Taille maximale de la police pour les informations affichées sur les cartes  : @@ -302,14 +357,14 @@ Vérifiez que le répertoire ne soit pas en lecture seule et réessayez. ArchidektApiResponseDeckDisplayWidget - + Back to results - + Retour aux résultats - + Open Deck in Deck Editor - + Ouvrir le deck dans l'éditeur de deck @@ -333,112 +388,112 @@ Vérifiez que le répertoire ne soit pas en lecture seule et réessayez. BanDialog - + ban &user name bannir &nom d'utilisateur - + ban &IP address bannir &adresse IP - + ban client I&D bannir &ID du client - + Ban type Type du bannissement - + &permanent ban &bannissement permanent - + &temporary ban bannissement &temporaire - + &Days: &Jours : - + &Hours: &Heures : - + &Minutes: &Minutes : - + Duration of the ban Durée du bannissement - + Please enter the reason for the ban. This is only saved for moderators and cannot be seen by the banned person. Veuillez expliquer la raison du bannissement. Cette information sera consultable uniquement par les modérateurs et ne sera pas visible par la personne bannie. - + Please enter the reason for the ban that will be visible to the banned person. Veuillez expliquer la raison du bannissement. Cette information sera visible par la personne bannie. - + Redact all messages from this user in all rooms Censurer tous les messages de cet utilisateur dans toutes les salles - + &OK &OK - + &Cancel &Annuler - + Ban user from server Bannir le joueur du serveur - - - - + + + + Error Erreur - + You have to select a name-based, IP-based, clientId based, or some combination of the three to place a ban. Vous devez choisir un bannissement à partir du nom, de l'adresse IP, de l'ID du client, ou une combinaison des trois. - + You must have a value in the name ban when selecting the name ban checkbox. Vous devez remplir le champ du nom du banni en le choisissant dans la liste. - + You must have a value in the ip ban when selecting the ip ban checkbox. Vous devez saisir l'adresse IP du banni en le choisissant dans la liste. - + You must have a value in the clientid ban when selecting the clientid ban checkbox. Vous devez saisir l'ID du client banni en le choisissant dans la liste. @@ -446,59 +501,123 @@ Cette information sera consultable uniquement par les modérateurs et ne sera pa BetaReleaseChannel - + Beta Beta - + No reply received from the release update server. Le serveur de mise à jour ne répond pas. - + Invalid reply received from the release update server. Réponse reçue du serveur de mise à jour de version invalide . - + No reply received from the file update server. Le serveur de mise à jour de fichier ne répond pas. + + CardArtPreviewWidget + + + No card selected + Aucune carte sélectionné + + + + CardArtRulesModel + + + Card + Carte + + + + ProviderId + + + + + Mode + Mode + + + + Reason + Raison + + CardDatabaseModel - + Name Nom - + Sets Éditions - + Mana cost Coût de mana - + Card type Type de carte - + P/T F/E - + Color(s) Couleur(s) + + CardDatabaseView + + + Add to Deck + Ajouter au deck + + + + Add to Sideboard + + + + + Select Printing + Choisir l'image + + + + Show on EDHRec (Commander) + + + + + Show on EDHRec (Card) + + + + + Show Related cards + + + CardFilter @@ -533,7 +652,7 @@ Cette information sera consultable uniquement par les modérateurs et ne sera pa Name (Exact) - + Nom (Exact) @@ -627,22 +746,22 @@ Cette information sera consultable uniquement par les modérateurs et ne sera pa CardInfoPictureWidget - + View related cards Voir les cartes associées - + Add card to deck Ajouter la carte au deck - + Mainboard Deck principal - + Sideboard Réserve @@ -657,7 +776,7 @@ Cette information sera consultable uniquement par les modérateurs et ne sera pa Set: - + Édition : @@ -665,12 +784,12 @@ Cette information sera consultable uniquement par les modérateurs et ne sera pa - + Related cards: Cartes associées : - + Unknown card: Carte inconnue : @@ -678,128 +797,146 @@ Cette information sera consultable uniquement par les modérateurs et ne sera pa CardMenu - + Re&veal to... Révéler à... - + &All players &Tous les joueurs - + View related cards Voir les cartes associées - + Token: Jeton : - + All tokens Tout les jetons - + &Select All &Sélectionner Tout - + S&elect Row Sél&ectionne une ligne - + S&elect Column Sél&ectionne une colonne - + &Play &Jouer - + &Hide &Cacher - + Play &Face Down Jouer &Face Cachée - + &Tap / Untap Turn sideways or back again &Engager / Dégager - + Skip &untapping - + Ne pas dégager - + T&urn Over Turn face up/face down Reto&urner - + &Peek at card face &Regarder furtivement la carte face cachée - + &Clone &Cloner - + Attac&h to card... Attac&her à une carte... - + Unattac&h Détac&her - + &Draw arrow... &Tracer une flèche... - + &Set annotation... &Annoter... - + + Reduce life by power + + + + Ca&rd counters Ma&rqueurs sur la carte - + &Add counter (%1) &Ajouter un marqueur (%1) - + &Remove counter (%1) &Retirer un marqueur (%1) - + &Set counters (%1)... &Changer le nombre de marqueurs (%1)... + + CardPictureLoaderCacheMethod + + + Network Cache + + + + + Filesystem + + + CardSizeWidget @@ -811,133 +948,133 @@ Cette information sera consultable uniquement par les modérateurs et ne sera pa CardZoneLogic - + their hand nominative sa main - + %1's hand nominative main de %1 - + their library look at zone sa bibliothèque - + %1's library look at zone bibliothèque de %1 - + of their library top cards of zone, de sa bibliothèque - + of %1's library top cards of zone de la bibliothèque de %1 - + their library reveal zone sa bibliothèque - + %1's library reveal zone bibliothèque de %1 - + their library shuffle sa bibliothèque - + %1's library shuffle bibliothèque de %1 - + their library nominative sa bibliothèque - + %1's library nominative bibliothèque de %1 - + their graveyard nominative son cimetière - + %1's graveyard nominative le cimetière de %1 - + their exile nominative sa zone d'exil - + %1's exile nominative la zone d'exil de %1 - + their sideboard look at zone sa réserve - + %1's sideboard look at zone la réserve de %1 - + their sideboard nominative sa réserve - + %1's sideboard nominative la réserve de %1 - + their custom zone '%1' nominative sa zone personnalisée '%1' - + %1's custom zone '%2' nominative La zone personnalisée '%2' de %1 @@ -959,16 +1096,421 @@ Cette information sera consultable uniquement par les modérateurs et ne sera pa Erreur de lecture à la ligne %1 (colonne %2) + + ColorButton + + + Click to pick a color + + + + + Pick colour + Choisir la couleur + + + + ConnectionController + + + + The server has reached its maximum user capacity, please check back later. + + + + + There are too many concurrent connections from your address. + + + + + Banned by moderator + Banni par le modérateur + + + + Expected end time: %1 + Heure de fin estimé: %1 + + + + This ban lasts indefinitely. + Ce bannissement dure indéfiniment. + + + + Scheduled server shutdown. + + + + + + Invalid username. + + + + + You have been logged out due to logging in at another location. + + + + + Connection closed + + + + + The server has terminated your connection. +Reason: %1 + + + + + The server is going to be restarted in %n minute(s). +All running games will be lost. +Reason for shutdown: %1 + + + + + Scheduled server shutdown + + + + + Failed Login + + + + + Your client seems to be missing features this server requires for connection. + + + + + To update your client, go to 'Help -> Check for Client Updates'. + + + + + + + + + + + + + + + + + + + + + + Error + + + + + Incorrect username or password. Please check your authentication information and try again. + + + + + There is already an active session using this user name. +Please close that session first and re-login. + + + + + + You are banned until %1. + + + + + + You are banned indefinitely. + + + + + This server requires user registration. Do you want to register now? + + + + + This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. +Please close and reopen your client to try again. + + + + + An internal error has occurred, please close and reopen Cockatrice before trying again. +If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. + + + + + Account activation + + + + + Your account has not been activated yet. +You need to provide the activation token received in the activation email. + + + + + Server Full + + + + + Unknown login error: %1 + + + + + + +This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. + + + + + + + + + + Registration denied + + + + + Registration is currently disabled on this server + + + + + There is already an existing account with the same user name. + + + + + It's mandatory to specify a valid email address when registering. + + + + + It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. + + + + + Password too short. + + + + + Registration failed for a technical problem on the server. + + + + + The connection to the server has been lost. + + + + + Unknown registration error: %1 + + + + + Account activation failed + + + + + Socket error: %1 + + + + + Server timeout + + + + + You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. +Local version is %1, remote version is %2. + + + + + Your Cockatrice client is obsolete. Please update your Cockatrice version. +Local version is %1, remote version is %2. + + + + + + Success + + + + + Registration accepted. +Will now login. + + + + + Account activation accepted. +Will now login. + + + + + Information + Information + + + + This server supports additional features that your client doesn't have. +This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. + +To update your client, go to Help -> Check for Updates. + + + + + + + Reset Password + Réinitialiser le mot de passe + + + + Your password has been reset successfully, you can now log in using the new credentials. + Votre mot de passe à été réinitialisé avec succes, vous pouvez maintenant vous connecter avec le nouveau mot de passe. + + + + Failed to reset user account password, please contact the server operator to reset your password. + Échec de la réinitialisation du mot de passe. Veuillez contacter l'administrateur du serveur pour réinitialiser votre mot de passe. + + + + Activation request received, please check your email for an activation token. + + + + + Connecting to %1... + + + + + Registering to %1 as %2... + + + + + Disconnected + + + + + Connected, logging in at %1 + + + + + Requesting forgotten password to %1 as %2... + + + + + Your username must respect these rules: + + + + + is %1 - %2 characters long + + + + + can %1 contain lowercase characters + + + + + + + + NOT + + + + + can %1 contain uppercase characters + + + + + can %1 contain numeric characters + + + + + can contain the following punctuation: %1 + + + + + first character can %1 be a punctuation mark + + + + + no unacceptable language as specified by these server rules: + note that the following lines will not be translated + + + + + can not contain any of the following words: %1 + + + + + can not match any of the following expressions: %1 + + + + + You may only use A-Z, a-z, 0-9, _, ., and - in your username. + + + CustomZoneMenu - + C&ustom Zones Zones pe&rsonnalisés - - + + View custom zone '%1' Voir la zone personnalisée '%1' @@ -976,32 +1518,37 @@ Cette information sera consultable uniquement par les modérateurs et ne sera pa DeckAnalyticsWidget - + Add Panel - + Ajouter un panneau - + Remove Panel - + Enlever un Panneau - + Save Layout - + Sauver cette disposition - + Load Layout + Charger une disposition + + + + Include Sideboard DeckEditorCardDatabaseDockWidget - + Card Database - + Base de données de cartes @@ -1015,47 +1562,17 @@ Cette information sera consultable uniquement par les modérateurs et ne sera pa DeckEditorDatabaseDisplayWidget - + Search by card name (or search expressions) Rechercher par nom de carte (ou expression de recherche) - - Add to Deck - Ajouter au deck - - - - Add to Sideboard - Ajouter à la réserve - - - - Select Printing - Choisir l'impression - - - - Show on EDHRec (Commander) - Afficher sur EDHRec (Commandant) - - - - Show on EDHRec (Card) - Afficher sur EDHRec (carte) - - - - Show Related cards - Afficher les cartes associées - - - + Add card to &maindeck Ajouter la carte au &deck - + Add card to &sideboard Ajouter carte à la ré&serve @@ -1065,7 +1582,7 @@ Cette information sera consultable uniquement par les modérateurs et ne sera pa Loading Database... - + Chargement de la base de données... @@ -1088,72 +1605,72 @@ Cette information sera consultable uniquement par les modérateurs et ne sera pa Couleurs - + Select Printing Choisir l'impression - + Deck Deck - + Deck &name: &Nom du deck : - + Banner Card/Tags Visibility Settings Paramètres de visibilité de la carte bannière et des étiquettes - + Show banner card selection menu Montrer le menu de sélection de carte bannière - + Show tags selection menu Montrer le menu de sélection d'étiquettes - + &Comments: &Commentaires : - + Group by: Grouper par : - + Format: - + Format : - + Hash: Empreinte : - + &Increment number Augmenter quant&ité - + &Decrement number &Diminuer quantité - + &Remove row &Retirer la ligne - + Swap card to/from sideboard Échanger la carte vers/depuis la réserve @@ -1161,17 +1678,17 @@ Cette information sera consultable uniquement par les modérateurs et ne sera pa DeckEditorFilterDockWidget - + Filters Filtres - + &Clear all filters &Effacer tous les filtres - + Delete selected Enlever la sélection @@ -1302,201 +1819,145 @@ Cette information sera consultable uniquement par les modérateurs et ne sera pa DeckEditorSettingsPage - - + + Update Spoilers Mettre à jour les spoilers - - + Success Réussite - + Download URLs have been reset. Les URLs de téléchargement ont été réinitialisées. - - Downloaded card pictures have been reset. - Les images des cartes téléchargées ont été supprimées. - - - - Error - Erreur - - - - One or more downloaded card pictures could not be cleared. - Une ou plusieurs images de cartes téléchargées n'ont pas pu être supprimées. - - - + Add URL Ajouter une URL - - + + URL: URL : - - + + Edit URL Modifier l'URL - - Network Cache Size: - Taille du cache du réseau : - - - - Redirect Cache TTL: - Rediriger le TTL du cache : - - - - How long cached redirects for urls are valid for. - Combien de temps les redirections mises en cache pour les URL sont-elles valides. - - - - Picture Cache Size: - Taille du cache des images : - - - + Add New URL Ajouter Nouveau Lien - + Remove URL Effacer Lien - - Day(s) - Jour(s) - - - + Updating... Mise à jour... - + Choose path Choisir le chemin - + URL Download Priority URL de téléchargement prioritaire - + Spoilers Spoilers - + Download Spoilers Automatically Télécharger automatiquement les spoilers - + Spoiler Location: Emplacement des spoilers : - + Last Change Dernier changement - + Spoilers download automatically on launch Télécharger automatiquement les spoilers au lancement - + Press the button to manually update without relaunching Appuyez sur le bouton pour mettre à jour manuellement sans redémarrer. - + Do not close settings until manual update is complete Ne pas fermer la fenêtre des paramètres avant la mise à jour manuelle complète. - + Download card pictures on the fly Télécharger les images des cartes à la volée - + How to add a custom URL Comment ajouter une URL personnalisée - - Delete Downloaded Images - Supprimer les images téléchargées - - - + Reset Download URLs Réinitialiser les URL de téléchargement - - - On-disk cache for downloaded pictures - Cache pour les images téléchargées - - - - In-memory cache for pictures not currently on screen - Cache en mémoire pour les imges non affichées actuellement - DeckListHistoryManagerWidget Undo - + Défaire Redo - + Refaire Undo/Redo history - + Historique Click on an entry to revert to that point in the history. - + Clickez sur une ligne pour retourner à ce point dans l'historique. [redo] - + [refaire] [undo] - + [défaire] @@ -1530,12 +1991,12 @@ Cette information sera consultable uniquement par les modérateurs et ne sera pa DeckLoader - + Common deck formats (%1) Formats de decks courants (%1) - + All files (*.*) Tous les fichiers (*.*) @@ -1729,57 +2190,57 @@ Cette information sera consultable uniquement par les modérateurs et ne sera pa Rename deck to "%1" from "%2" - + Renommer le deck de "%2" à "%1" Updated comments (was %1 chars, now %2 chars) - + Mis à jour les commentaires (étaient %1 caractères, maintenant %2) Set banner card to %1 (%2) - + Définir %1 (%2) comme carte de couverture Tags changed - + Tags modifiés Set format to %1 - + Changer le format à %1 Added (%1): %2 (%3) %4 - + Ajouté (%1): %2 (%3) %4 - + Moved to %1 1 × "%2" (%3) - + Bougé %1 1 × "%2" (%3) - + Removed "%1" (all copies) - + Retiré "%1" (toutes les copies) - + %1 1 × "%2" (%3) - + %1 1 × "%2" (%3) - + Added - + Ajoutée - + Removed - + Retirée @@ -1799,75 +2260,75 @@ Cette information sera consultable uniquement par les modérateurs et ne sera pa DeckViewContainer - + Load deck... Charger un deck... - + Load remote deck... Charger un deck distant... - + Load from clipboard... Charger depuis le presse-papier... - + Load from website... Charger depuis un site web... - + Unload deck Annuler le chargement du deck - + Ready to start Prêt à démarrer - + Force start Forcer le démarrage - + Sideboard unlocked Réserve déverrouillée - + Sideboard locked Réserve verrouillée - - + + Error Erreur - + The selected file could not be loaded. Le fichier sélectionné n'a pas pu être chargé. - + Deck is greater than maximum file size. Le deck est plus grand que la taille maximum de fichier. - + Are you sure you want to force start? This will kick all non-ready players from the game. Êtes-vous sûr de vouloir forcer le démarrage ? Cela va éjecter de la partie tous les joueurs non prêts à démarrer. - + Cockatrice Cockatrice @@ -1968,7 +2429,7 @@ Voulez-vous convertir le deck au format .cod ? - + Webpage Page web @@ -2008,37 +2469,37 @@ Voulez-vous convertir le deck au format .cod ? Se connecter au serveur - + Server URL URL du serveur - + Communication Port Port de communication - + Unique Server Name Nom de serveur unique - + Connection Warning Avertissement de connexion - + You need to name your new connection profile. Vous devez attribuer un nom à votre nouveau profil de connexion. - + Connect Warning Avertissement de connexion - + The player name can't be empty. Le nom du joueur ne peut être vide. @@ -2133,7 +2594,7 @@ Voulez-vous convertir le deck au format .cod ? Create game as judge - + Créer une partie en tant que juge @@ -2151,17 +2612,17 @@ Voulez-vous convertir le deck au format .cod ? Créer une partie - + Game information Informations sur la partie - + Error Erreur - + Server error. Erreur serveur. @@ -2169,97 +2630,97 @@ Voulez-vous convertir le deck au format .cod ? DlgCreateToken - + &Name: &Nom : - + Token Jeton - + C&olor: C&ouleur : - + white blanc - + blue bleu - + black noir - + red rouge - + green vert - + multicolor multicolore - + colorless incolore - + &P/T: &F/E : - + &Annotation: &Annotation : - + &Destroy token when it leaves the table &Detruire le jeton lorsqu'il quitte le champ de bataille - + Create face-down (Only hides name) Créer face-cachée (cache seulement le nom) - + Token data Données du jeton - + Show &all tokens Afficher &tous les jetons - + Show tokens from this &deck Afficher les jetons présents dans ce &deck - + Choose token from list Choisir un jeton dans la liste - + Create token Créer un jeton @@ -2293,7 +2754,7 @@ Voulez-vous convertir le deck au format .cod ? - + @@ -2308,12 +2769,12 @@ Voulez-vous convertir le deck au format .cod ? Le nom de l'étiquette ne peut pas être vide ! - + Duplicate Tag Étiquette dupliquée - + This tag already exists. Cette étiquette existe déjà. @@ -2500,12 +2961,12 @@ Pour enlever votre avatar actuel, confirmez sans choisir une nouvelle image.Entrez le nom du jeton : - + Error Erreur - + The chosen name conflicts with an existing card or token. Make sure to enable the 'Token' set in the "Manage sets" dialog to display them correctly. Le nom choisi est en conflit avec une carte ou un jeton existant. @@ -2530,12 +2991,12 @@ Assurez-vous d'activer l'édition « Fausse édition contenant les je Indéfini - + Real name: Nom réel : - + Edit user profile Modifier le profil utilisateur @@ -2573,113 +3034,113 @@ Assurez-vous d'activer l'édition « Fausse édition contenant les je 2 heures - + Hide 'buddies only' games Ne pas afficher les parties « amis uniquement » - + Hide full games Cacher les parties pleines - + Hide games that have started Cacher les parties qui ont commencées - + Hide password protected games Cacher les parties protégées par un mot de passe - + Hide 'ignored user' games Cacher les parties des utilisateurs ignorés - + Hide games not created by buddies Hide games not created by buddy - + Cacher les parties non-crées par vos amis - + Hide games with forced open decklists Masquer les parties avec les listes de decks ouvertes forcées - + &Newer than: &Créées avant : - + Game &description: &Description de la partie : - + &Creator name: Nom du &créateur : - + General Général - + &Game types Types de &partie - + at &least: au &moins : - + at &most: au &plus : - + Maximum player count Nombre de joueurs maximum - + Restrictions Restrictions - + Show games only if &spectators can watch Montrer les parties uniquement si les %spectateurs peuvent observer - + Show spectator password p&rotected games Montrer les parties où les spectateurs doivent ent&rer un mot de passe - + Show only if spectators can ch&at Montrer uniquement si les spectateurs peuvent p&arler - + Show only if spectators can see &hands Montrer uniquement si les spectateurs peuvent voir les &mains - + Spectators Spectateurs - + Filter games Flitrer les parties @@ -2957,68 +3418,68 @@ https://tappedout.net/mtg-decks/your-deck-name/ Players: - + Joueurs : General - + Général Starting life total: - + Total de vie initial : Game setup options - + Options de configuration de la partie Remember settings - + Conserver les paramètres Local game options - + Options de configuration de partie locale DlgMoveTopCardsUntil - + Card name (or search expressions): Nom de la carte (ou expression de recherche) : - + Number of hits: Nombre de cartes trouvées : - + Auto play hits Jouer les cartes trouvées automatiquement - + Put top cards on stack until... Placer la carte du dessus sur la pile jusqu'à... - + No cards matching the search expression exists in the card database. Proceed anyways? Aucune carte ne correspondant à l'expression recherchée n'existe dans la base de données de cartes. Continuer malgré tout ? - + Cockatrice Cockatrice - + Invalid filter Filtre invalide @@ -3077,40 +3538,40 @@ Your email will be used to verify your account. Indéfini - + Real name: Nom réel : - + Register to server S'enregistrer sur le serveur - - - - + + + + Registration Warning Avertissement d'enregistrement - + Your password is too short. Mot de passe trop court - + Your passwords do not match, please try again. Vos mots de passe ne correspondent pas, veuillez réessayer. - + Your email addresses do not match, please try again. Vos adresses mail ne correspondent pas, veuillez réessayer. - + The player name can't be empty. Le nom du joueur ne peut pas être vide. @@ -3118,17 +3579,17 @@ Your email will be used to verify your account. DlgRollDice - + Number of sides: Nombre de faces: - + Number of dice: Nombre de dés: - + Roll Dice Lancer les dés @@ -3163,28 +3624,28 @@ Your email will be used to verify your account. Bulk modified printings. - + Impressions modifiées en groupe. Cleared all printing information. - + Toutes les informations d'impressions ont été supprimées. Set all printings to preferred. - + Définir toutes les impressions comme préférées. DlgSettings - + Unknown Error loading card database Erreur inconnue lors du chargement de la base de données de cartes. - + Your card database is invalid. Cockatrice may not function correctly with an invalid database @@ -3201,7 +3662,7 @@ Vous devrez peut-être redémarrer Oracle pour mettre à jour votre base de donn Voulez-vous changer l'emplacement de votre base de données ? - + Your card database version is too old. This can cause problems loading card information or images @@ -3218,7 +3679,7 @@ Généralement, il suffit de redémarrer oracle pour mettre à jour votre base d Voulez-vous changer l'emplacement de votre base de données ? - + Your card database did not finish loading Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues with your cards.xml attached @@ -3231,7 +3692,7 @@ Veuillez ouvrir un ticket sur https://github.com/Cockatrice/Cockatrice/issues av Voulez-vous changer l'emplacement des données ? - + File Error loading your card database. Would you like to change your database location setting? @@ -3240,7 +3701,7 @@ Would you like to change your database location setting? Voulez-vous changer l'emplacement de votre base de données ? - + Your card database was loaded but contains no cards. Would you like to change your database location setting? @@ -3249,7 +3710,7 @@ Would you like to change your database location setting? Voulez-vous changer l'emplacement de votre base de données ? - + Unknown card database load status Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues @@ -3262,59 +3723,64 @@ Veuillez ouvrir un ticket sur https://github.com/Cockatrice/Cockatrice/issues Voulez-vous changer l'emplacement de votre base de données ? - - - + + + Error Erreur - + The path to your deck directory is invalid. Would you like to go back and set the correct path? Le chemin d'accès vers votre répertoire de decks est invalide. Voulez-vous redéfinir le chemin d'accès ? - + The path to your card pictures directory is invalid. Would you like to go back and set the correct path? Le chemin d'accès vers votre répertoire d'illustrations de cartes est invalide. Voulez-vous redéfinir le chemin d'accès ? - + Settings Paramètres - + General Général - + Appearance Apparence - + User Interface Interface utilisateur - + + Storage + + + + Card Sources Origine de carte - + Chat Chat - + Sound Son - + Shortcuts Raccourcis @@ -3408,9 +3874,9 @@ Vous pouvez toujours modifier ce comportement dans l'onglet « Général » - - - + + + Error Erreur @@ -3463,31 +3929,31 @@ Veuillez s'il vous plaît visiter la page de téléchargement, pour mettre - + Update Available Mise à jour disponible - + A new version of Cockatrice is available! Une nouvelle version de Cockatrice est disponible ! - + New version Nouvelle version - + Released Sortie - + Changelog Notes de version @@ -3497,51 +3963,51 @@ Veuillez s'il vous plaît visiter la page de téléchargement, pour mettre Voulez-vous mettre à jour maintenant ? - + Unfortunately, the automatic updater failed to find a compatible download. You may have to manually download the new version. Malheureusement, la mise à jour automatique n'a pas pu trouver un téléchargement compatible. Vous allez peut-être devoir télécharger manuellement la nouvelle version. - + Please check the <a href="%1">releases page</a> on our Github and download the build for your system. Veuillez vérifier la <a href="%1">page "releases"</a> sur notre Github et télécharger le programme pour votre système. - - - + + + Update Error Erreur de mise à jour - + An error occurred while checking for updates: Une erreur est survenur pendant la vérification des mises à jour : - + An error occurred while downloading an update: Une erreur est survenue pendant le téléchargement de la mise à jour : - + Installing... Installation... - + Cockatrice is unable to open the installer. Cockatrice n'a pas pu ouvrir l'installateur. - + Try to update manually by closing Cockatrice and running the installer. Essayer de mettre à jour manuellement en fermant Cockatrice et en exécutant l'installateur. - + Download location Répertoire de téléchargement @@ -3569,131 +4035,131 @@ Vous allez peut-être devoir télécharger manuellement la nouvelle version. Draw Probability Settings - + Options de probabilité de pioche Criteria: - + Critère : Card Name - + Nom de la carte Type - + Type Subtype - + Sous-type Mana Value - + Valeur de Mana Exactness: - + Exactitude : At least - + Au moins Exactly - + Exactement Quantity (N): - + Quantité (N) : Cards drawn (M): - + Cartes piochées (M) : cards - + cartes DrawProbabilityWidget - + Draw Probability - + Probabilité de pioche + + + + Probability of drawing + Probabilité de piocher - Probability of drawing - + Card Name + Nom de la carte + + + + Type + Type - Card Name - + Subtype + Sous-type - Type - - - - - Subtype - + Mana Value + Valeur de Mana - Mana Value - + At least + Au moins - - At least - + + Exactly + Exactement - Exactly - + card(s) having drawn at least + Carte(s) ayant au moins pioché - - card(s) having drawn at least - + + cards + cartes - cards - - - - Category - + Catégorie - + Qty - + Qté - + Odds (%) - + Chances (%) @@ -3770,7 +4236,7 @@ Vous allez peut-être devoir télécharger manuellement la nouvelle version. Game Changers - + Cartes à impact @@ -3778,7 +4244,7 @@ Vous allez peut-être devoir télécharger manuellement la nouvelle version. Budget - + Budget @@ -3805,7 +4271,7 @@ Vous allez peut-être devoir télécharger manuellement la nouvelle version. FilterBuilder - + Type your filter here Tapez votre filtre ici @@ -3836,22 +4302,22 @@ Vous allez peut-être devoir télécharger manuellement la nouvelle version. GameEventHandler - + kicked by game host or moderator Éjecté par l'hôte de la partie ou un modérateur - + player left the game joueur a quitté la partie - + player disconnected from server joueur déconnecté du serveur - + reason unknown raison inconnue @@ -3859,140 +4325,140 @@ Vous allez peut-être devoir télécharger manuellement la nouvelle version. GameSelector - - - - - - - - - + + + + + + + + + Error Erreur - + Please join the appropriate room first. Veuillez d'abord rejoindre le bon salon. - + Wrong password. Mot de passe incorrect. - + Spectators are not allowed in this game. Les spectateurs ne sont pas autorisés dans cette partie. - + The game is already full. Cette partie est déjà complète. - + The game does not exist any more. La partie n'existe plus. - + This game is only open to registered users. Cette partie n'est accessible qu'aux joueurs enregistrés. - + This game is only open to its creator's buddies. Cette partie n'est accessible qu'aux amis de son créateur. - + You are being ignored by the creator of this game. Vous avez été ignoré par le créateur de la partie. - + Join Game Rejoindre la partie - + Spectate Game Rejoindre la partie comme spectateur - + Game Information Informations sur la partie - + Join Game as Judge - + Joindre la partie comme juge - + Spectate Game as Judge - + Joindre la partie comme juge spectateur - + Join game Rejoindre partie - + Password: Mot de passe : - + Please join the respective room first. Veuillez d'abord rejoindre le bon salon. - + &Filter games &Filtrer les jeux - + C&lear filter Effacer les fi&ltres - + C&reate C&réer - + &Join Re&joindre - + Join as judge - + Joindre en tant que juge - + J&oin as spectator Rej&oindre en tant que spectateur - + Join as judge spectator - + Joindre comme juge spectateur - + Games shown: %1 / %2 Parties affichées : %1 / %2 - + Games Parties @@ -4000,138 +4466,138 @@ Vous allez peut-être devoir télécharger manuellement la nouvelle version. GameSelectorQuickFilterToolBar - + All types - + Tous les types - + Filter by game name... - + Filtrer par nom de partie... - + Filter by game type/format - + Filtrer par type/format de partie.... - + Hide games not created by buddies - + Cacher les parties non-crées par vos amis - + Hide full games - + Cacher les parties pleines - + Hide started games - + Cacher les parties commencées GamesModel - + >1 day > 1 jour - + %1%2 hr short age in hours %1%2 h%1%2 h%1%2 h - + new Nouveau - + %1%2 min short age in minutes %1%2 min%1%2 min%1%2 min - + password mot de passe - + buddies only amis uniquement - + reg. users only joueurs enregistrés uniquement - + open decklists Ouvrir les listes de deck - + can chat peut discuter - + see hands voir les mains - + can see hands peut voir les mains - + not allowed non autorisé - + Room Salon - + Age Âge - + Description Description - + Creator Créateur - + Type Type - + Restrictions Restrictions - + Players Joueurs - + Spectators Spectateurs @@ -4139,143 +4605,158 @@ Vous allez peut-être devoir télécharger manuellement la nouvelle version. GeneralSettingsPage - + Reset all paths Réinitialiser tous les chemins - + All paths have been reset Les chemins ont été réinitialisés - - - - - - - + + + + + + + Choose path Choisir un chemin d'accès - - Personal settings - Paramètres personnels + + Language settings + - + Language: Langue : - + + Version settings + + + + + Card database + + + + + Startup settings + + + + Paths (editing disabled in portable mode) Chemins (édition impossible en mode portable) - + Paths Chemins d’accès - + How to help with translations Comment aider avec les traductions - + Decks directory: Répertoire des decks : - + Filters directory: Répertoire des filtres : - + Replays directory: Répertoire des replays : - + Pictures directory: Répertoire des images : - + Card database: Base de données des cartes : - + Custom database directory: Répertoire de la base de données personnalisé - + Token database: Bases de données des jetons : - + Update channel Branche de la mise à jour : - + Check for client updates on startup Vérifier les mises à jour du client au démarrage - + Check for card database updates on startup Vérifier les mises à jour de la base de données des cartes au démarrage... - + Don't check Ne pas vérifier - + Prompt for update Confirmer les mises à jour - + Always update in the background Toujours mettre à jour en tâche de fond - + Check for card database updates every Vérifier les mises à jour de la base de données des cartes tous les - + days jour(s) - + Notify if a feature supported by the server is missing in my client M'avertir si une fonctionnalité supportée par le serveur est manquante sur mon client. - + Automatically run Oracle when running a new version of Cockatrice Lancer Oracle automatiquement quand Cockatrice a été mis à jour. - + Show tips on startup Afficher les astuces au démarrage. - + Last update check on %1 (%2 days ago) Dernière vérification de mise à jour le %1 (il y a %2 jours) @@ -4283,47 +4764,47 @@ Vous allez peut-être devoir télécharger manuellement la nouvelle version. GraveyardMenu - + &Graveyard &Cimetière - + &View graveyard &Voir le cimetière - + &Move graveyard to... &Déplacer le cimetière vers... - + &Top of library &Dessus de la bibliothèque - + &Bottom of library &Dessous de la bibliothèque - + &All players &Tous les joueurs - + &Hand &Main - + &Exile &Exil - + Reveal random card to... Révéler une carte au hasard à... @@ -4331,88 +4812,88 @@ Vous allez peut-être devoir télécharger manuellement la nouvelle version. HandMenu - + &Hand &Main - + &View hand &Voir la main - + Sort hand by... - + Trier la main par... - + Name - + Nom - + Type - + Type - + Mana Value - + Valeur de Mana - + Take &mulligan (Choose hand size) - + Mulligan (choix du nombre de cartes) - + Take mulligan (Same hand size) - + Mulligan (Même nombre de cartes) - + Take mulligan (Hand size - 1) - + Mulligan (-1 carte) - + &Move hand to... &Déplacer la main vers… - + &Top of library &Dessus de la bibliothèque - + &Bottom of library &Dessous de la bibliothèque - + &Graveyard &Cimetière - + &Exile &Exil - + &Reveal hand to... &Révéler la main à... - - + + All players - + Tous les joueurs - + Reveal r&andom card to... Révéler une c&arte au hasard à... @@ -4420,52 +4901,52 @@ Vous allez peut-être devoir télécharger manuellement la nouvelle version. HomeWidget - + Create New Deck Créer un Nouveau Deck - + Browse Decks Parcourir les Decks - + Browse Card Database Parcourir la Base de Carte - + Browse EDHRec Parcourir EDHRec - + Browse Archidekt - + Parcourir Archidekt - + View Replays Voir les replays - + Quit Quitter - + Connecting... Connection... - + Connect Se connecter - + Play Jouer @@ -4473,213 +4954,213 @@ Vous allez peut-être devoir télécharger manuellement la nouvelle version. LibraryMenu - + &Library &Bibliothèque - + &View library &Voir la bibliothèque - + View &top cards of library... Voir les cartes du &dessus de la bibliothèque... - + View bottom cards of library... Voir les cartes du dessous de la bibliothèque... - + Reveal &library to... Révéler la &bibliothèque à... - + Lend library to... Prêter la bibliothèque à... - + Reveal &top cards to... Révéler les cartes du &dessus à... - + &Top of library... &Dessus de la bibliothèque... - + &Bottom of library... &Dessous de la bibliothèque... - + &Always reveal top card &Toujours révéler la carte du dessus - + &Always look at top card &Toujours voir la carte du dessus - + &Open deck in deck editor &Ouvrir le deck dans l'éditeur - + &Draw card &Piocher une carte - + D&raw cards... P&iocher plusieurs cartes... - + &Undo last draw &Annuler la dernière pioche - + Shuffle Mélanger - + &Play top card &Jouer la carte du dessus - + Play top card &face down Jouer la carte du dessus &face cachée - + Put top card on &bottom Placer la carte du dessus au-&dessous - + Move top card to grave&yard Mettre la carte du dessus dans le cime&tière - + Move top card to e&xile E&xiler la carte du dessus - + Move top cards to &graveyard... Mettre les cartes du dessus dans le &cimetière... - + Move top cards to graveyard face down... - + Mettre les cartes du dessus dans le cimetière face cachées... - + Move top cards to &exile... &Exiler les cartes du dessus... - + Move top cards to exile face down... - + Exiler les cartes du dessus face cachées... - + Put top cards on stack &until... Placer les cartes du dessus sur la pile &jusqu'à... - + Shuffle top cards... Mélanger les cartes du dessus... - + &Draw bottom card &Piocher la carte du dessous - + D&raw bottom cards... P&iocher les cartes du dessous... - + &Play bottom card &Jouer la carte du dessous - + Play bottom card &face down Jouer la carte du dessous &face cachée - + Move bottom card to grave&yard Mettre la carte du dessous dans le cime&tière - + Move bottom card to e&xile E&xiler la carte du dessous - + Move bottom cards to &graveyard... Mettre les cartes du dessus dans le &cimetière... - + Move bottom cards to graveyard face down... - + Mettre les cartes du dessous dans le cimetières face cachées... - + Move bottom cards to &exile... &Exiler les cartes du dessous... - + Move bottom cards to exile face down... - + Exiler les cartes du dessous face cachées... - + Put bottom card on &top Placer la carte du dessous au-&dessus - + Shuffle bottom cards... Mélanger les cartes du dessous... - - + + &All players &Tous les joueurs - + Reveal top cards of library Révéler les cartes du dessus de la bibliothèque - + Number of cards: (max. %1) Nombre de cartes : (max. %1) @@ -4687,641 +5168,273 @@ Vous allez peut-être devoir télécharger manuellement la nouvelle version. MainWindow - - - The server has reached its maximum user capacity, please check back later. - Le serveur a atteint sa capacité maximale de joueurs simultanés, veuillez réessayer plus tard. - - - - There are too many concurrent connections from your address. - Il y a trop de connexions simultanées depuis votre ordinateur. - - - - Banned by moderator - Banni par un modérateur - - - - Expected end time: %1 - Fin théorique : %1 - - - - This ban lasts indefinitely. - Banni définitivement. - - - - Scheduled server shutdown. - Fermeture prévue du serveur. - - - - - Invalid username. - Nom d'utilisateur non valide. - - - - You have been logged out due to logging in at another location. - Vous avez avez été déconnecté pour avoir changé d'endroit. - - - - Connection closed - Connexion fermée - - - - The server has terminated your connection. -Reason: %1 - Le serveur a coupé votre connexion. -Raison: %1 - - - - The server is going to be restarted in %n minute(s). -All running games will be lost. -Reason for shutdown: %1 - Le serveur redémarrera dans %n minute. -Aucune partie en cours ne sera sauvegardée. -Raison de la fermeture : %1Le serveur redémarrera dans %n minutes. -Aucune partie en cours ne sera sauvegardée. -Raison de la fermeture : %1Le serveur redémarrera dans %n minutes. -Aucune partie en cours ne sera sauvegardée. -Raison de la fermeture : %1 - - - - Scheduled server shutdown - Fermeture prévue du serveur - - - - - Success - Réussite - - - - Registration accepted. -Will now login. - Enregistrement accepté. -Connexion en cours. - - - - Account activation accepted. -Will now login. - Activation du compte acceptée. -Connexion en cours. - - - - + + Player %1 Joueur %1 - + Load replay Charger replay - + About Cockatrice À propos de Cockatrice - + Version Version - + Cockatrice Webpage Page web de Cockatrice - + Project Manager: Chef de projet: - + Past Project Managers: Anciens chefs de projet: - + Developers: Développeurs : - + Our Developers Nos développeurs - + Help Develop! Aidez à développer ! - + Translators: Traducteurs : - + Our Translators Nos traducteurs - + Help Translate! Aidez à traduire ! - + Support: Assistance : - + Report an Issue Signaler un problème - + Troubleshooting Dépannage - + F.A.Q. F.À.Q. - - - - - - - - - - - - - - - - - - - - + + Error Erreur - - Server timeout - Délai de la demande dépassé - - - - Failed Login - Connexion échouée - - - - Your client seems to be missing features this server requires for connection. - Il semble que certaines fonctionnalités nécessaires à la connexion à ce serveur sont absentes de votre client. - - - - To update your client, go to 'Help -> Check for Client Updates'. - Pour mettre à jour Cockatrice, allez dans « Aide -> Vérifier les mises à jour' ». - - - - Incorrect username or password. Please check your authentication information and try again. - Nom d'utilisateur ou mot de passe incorrect. Veuillez vérifier vos identifiants et réessayer. - - - - There is already an active session using this user name. -Please close that session first and re-login. - Il y a déjà une session ouverte avec le même nom d'utilisateur. -Fermez cette session puis reconnectez vous. - - - - - You are banned until %1. - Vous êtes banni jusqu'au %1 - - - - - You are banned indefinitely. - Vous êtes banni indéfiniment. - - - - This server requires user registration. Do you want to register now? - Vous devez être enregistré pour accéder à ce serveur. Voulez-vous vous enregistrer ? - - - - This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. -Please close and reopen your client to try again. - Ce serveur requiert un ID de client. Soit votre client a échoué lors de la génération d'un ID, soit vous utilisez un client modifié. -Veuillez fermer et rouvrir votre client pour réessayer. - - - - An internal error has occurred, please close and reopen Cockatrice before trying again. -If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. - Une erreur interne est survenue. Veuillez fermer et rouvrir Cockatrice avant de réessayer. -Si l'erreur persiste, vérifiez que utilisez la dernière version du logiciel, ou contactez les développeurs si nécessaire. - - - - Account activation - Activation du compte - - - - Your account has not been activated yet. -You need to provide the activation token received in the activation email. - Votre compte n'a pas encore été activé. -Vous devez activer le jeton de l'activation reçu dans le mail d'activation. - - - - Server Full - Serveur complet - - - - Unknown login error: %1 - Erreur de connexion inconnue : %1 - - - - - -This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. - -Cela veut généralement dire que votre client n'est plus à jour, et que le serveur a envoyé une réponse que votre client ne comprend pas. - - - - Your username must respect these rules: - Votre nom d'utilisateur doit respecter ces règles : - - - - is %1 - %2 characters long - est long de %1 - %2 caractères - - - - can %1 contain lowercase characters - peut %1 contenir des caractères minuscules - - - - - - - NOT - PAS - - - - can %1 contain uppercase characters - peut %1 contenir des caractères majuscules - - - - can %1 contain numeric characters - peut %1 contenir des caractères numériques - - - - can contain the following punctuation: %1 - peut contenir la ponctuation suivante : %1 - - - - first character can %1 be a punctuation mark - le premier caractère peut %1 être une marque de ponctuation - - - - no unacceptable language as specified by these server rules: - note that the following lines will not be translated - Pas de langage inacceptable comme spécifié par ces règles du serveur: - - - - can not contain any of the following words: %1 - ne peut contenir les mots suivants : %1 - - - - can not match any of the following expressions: %1 - ne correspond à aucune des expressions suivantes : %1 - - - - You may only use A-Z, a-z, 0-9, _, ., and - in your username. - Vous pouvez utiliser seulement A-Z, a-z, 0-9, _, ., et - dans votre nom d'utilisateur - - - - - - - - - Registration denied - Enregistrement refusé - - - - Registration is currently disabled on this server - L'enregistrement est désactivé sur ce serveur - - - - There is already an existing account with the same user name. - Il existe déjà un compte avec le même nom d'utilisateur. - - - - It's mandatory to specify a valid email address when registering. - Il est obligatoire de spécifier une adresse e-mail valide lors de l'enregistrement. - - - - It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. - Il semble que vous essayez d'enregistrer un nouveau compte alors que vous en avez déjà un enregistré avec cet e-mail sur ce serveur. Ce serveur restreint le nombre de comptes par e-mail. Veuillez contacter l'administrateur du serveur pour obtenir vos identifiants ou plus d'informations. - - - - Password too short. - Mot de passe trop court. - - - - Registration failed for a technical problem on the server. - Enregistrement échoué lié à un problème technique du serveur. - - - - The connection to the server has been lost. - La connexion au serveur a été perdue. - - - - Unknown registration error: %1 - Erreur d'enregistrement inconnue : %1 - - - - Account activation failed - Activation du compte échouée - - - - Socket error: %1 - Erreur de socket : %1 - - - - You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. -Local version is %1, remote version is %2. - Vous tentez de vous connecter à un serveur obsolète. Veuillez utiliser une ancienne version de Cockatrice, ou connectez-vous à un serveur approprié. -La version locale est %1, la version distante est %2. - - - - Your Cockatrice client is obsolete. Please update your Cockatrice version. -Local version is %1, remote version is %2. - Votre client Cockatrice est obsolète. Veuillez mettre à jour votre version de Cockatrice. -La version locale est %1, la nouvelle version est %2. - - - - Connecting to %1... - Connexion à %1... - - - - Registering to %1 as %2... - Enregistrement de %1 en tant que %2... - - - - Disconnected - Déconnecté - - - - Connected, logging in at %1 - Connecté, connexion à %1 - - - - - - Requesting forgotten password to %1 as %2... - Envoi de la requête de mot de passe oublié à %1 en tant que %2... - - - + &Connect... &Connecter... - + &Disconnect &Déconnecter - + Start &local game... Démarrer une partie &locale... - + &Watch replay... &Regarder un replay... - + &Full screen &Plein écran - + &Register to server... S'enregistrer sur le serveur... - + &Restore password... &Restaurer le mot de passe... - + &Settings... &Paramètres... - + &Exit &Quitter - + A&ctions Actions - + &Cockatrice &Cockatrice - + C&ard Database B&ase de données de cartes - + &Manage sets... &Gérer les éditions... - + Edit custom &tokens... Éditer les &jetons personnalisés… - + Open custom image folder Ouvrir le dossier d'images personnalisées - + Open custom sets folder Ouvrir le dossier d'éditions personnalisées - + Add custom sets/cards Ajouter des éditions / cartes personnalisées - + Reload card database Recharger la base de cartes - + Tabs Onglets - + &Help A&ide - + &About Cockatrice À propos de Cock&atrice - + &Tip of the Day &Conseil du jour - + Check for Client Updates Vérifier les mises à jour - + Check for Card Updates... Vérifier les mises à jour des cartes… - + Check for Card Updates (Automatic) Vérifier les mises à jour des cartes (automatiquement) - + Show Status Bar Montrer la barre de statut - + View &Debug Log Voir journal de &Debug - + Open Settings Folder Ouvrir le dossier des paramètres - + Show/Hide Montrer/Cacher - + New Version Nouvelle version - + Congratulations on updating to Cockatrice %1! Oracle will now launch to update your card database. Bravo pour avoir mis à jour Cockatrice %1 ! Oracle va maintenant se lancer pour mettre à jour votre base de données de cartes. - + Cockatrice installed Installation de Cockatrice terminée - + Congratulations on installing Cockatrice %1! Oracle will now launch to install the initial card database. Félicitations ! Vous venez d'installer Cockatrice %1 ! Oracle va maintenant se lancer pour installer la base de données de cartes initiale. - + Card database Base de données de cartes - + Cockatrice is unable to load the card database. Do you want to update your card database now? If unsure or first time user, choose "Yes" @@ -5330,29 +5443,29 @@ Voulez-vous la mettre à jour maintenant ? Si vous n’êtes pas sûr, ou si c'est la première fois que vous jouez, choisissez « Oui ». - - + + Yes Oui - - + + No Non - + Open settings Ouvrir les paramètres - + New sets found Nouvelles éditions détectées - + %n new set(s) found in the card database Set code(s): %1 Do you want to enable it/them? @@ -5365,17 +5478,22 @@ Code(s) d'édition(s) : %1 Voulez-vous l'(les) activer ? - + + Yes, always enable + + + + View sets Voir les éditions - + Welcome Bienvenue - + Hi! It seems like you're running this version of Cockatrice for the first time. All the sets in the card database have been enabled. Read more about changing the set order or disabling specific sets and consequent effects in the "Manage Sets" dialog. @@ -5384,169 +5502,135 @@ Toutes les éditions de la base de données de cartes ont été activées. Pour plus d'informations sur la modification de l'ordre des éditions, la désactivation d'éditions spécifiques, et leurs effets, consultez le menu « Gérer les éditions… ». - - + Information Informations - + A card database update is already running. Une mise à jour de la base de données de cartes est déjà en cours. - + Unable to run the card database updater: Impossible de lancer la mise à jour de la base de données de cartes : - + Card database update running. Une mise à jour de la base de données de cartes est en cours. - + Failed to start. The file might be missing, or permissions might be incorrect. Échec au démarrage. Le fichier peut être manquant, ou les permissions sont incorrectes. - + The process crashed some time after starting successfully. Le processus a échoué quelque temps après avoir démarré avec succès. - + Timed out. The process took too long to respond. The last waitFor...() function timed out. Temps limite dépassé. Le processus a pris trop longtemps pour répondre. La dernière fonction waitFor...() a terminé. - + An error occurred when attempting to write to the process. For example, the process may not be running, or it may have closed its input channel. Une erreur a eu lieu en essayant d'écrire vers le processus. Par exemple, le processus pourrait ne pas tourner, ou il pourrait avoir son canal d'entrée fermé. - + An error occurred when attempting to read from the process. For example, the process may not be running. Une erreur a eu lieu en essayant d'écrire vers le processus. Par exemple, le processus pourrait ne pas tourner, ou il pourrait avoir son canal de sortie inactif. - + Unknown error occurred. Une erreur inconnue est arrivée. - + The card database updater exited with an error: %1 L'outil de mise à jour de la base de données de cartes s'est arrêté avec l'erreur : %1 - - This server supports additional features that your client doesn't have. -This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. - -To update your client, go to Help -> Check for Updates. - Le serveur supporte des fonctionnalités que votre client n'a pas. -Ce n'est probablement pas un problème, mais ce message peut signifier qu'il y a une nouvelle version de Cockatrice disponible, ou que ce serveur utilise une version pre-release ou personnalisée. - -Pour mettre à jour votre client, allez dans « Aide -> Vérifier les mises à jour ». - - - - - - - + + + + + Load sets/cards Charger des éditions/cartes - + Selected file cannot be found. Le fichier sélectionné n'a pas pu être trouvé. - + You can only import XML databases at this time. Il n'est actuellement possible que d'importer des bases de données XML. - + The new sets/cards have been added successfully. Cockatrice will now reload the card database. Les nouvelles éditions/cartes ont été ajoutées avec succès. Cockactrice va maintenant recharger la base de données de cartes. - + Sets/cards failed to import. Échec de l'importation des éditions/cartes. - - - - - Reset Password - Réinitialiser le mot de passe - - - - Your password has been reset successfully, you can now log in using the new credentials. - Votre mot de passe a été réinitialisé avec succès. Vous pouvez maintenant vous connecter en utilisant vos nouveaux identifiants. - - - - Failed to reset user account password, please contact the server operator to reset your password. - Échec de la réinitialisation du mot de passe du compte. Veuillez contacter l'administrateur du serveur pour réinitialiser votre mot de passe. - - - - Activation request received, please check your email for an activation token. - Demande d'activation reçue, vérifiez votre courrier électronique pour un jeton d'activation. - ManaBaseConfigDialog - + Mana Base Configuration - + Display type: - + pie - + camembert - + bar - + barre - + combinedBar - + barreCombinée - + Filter Colors (optional): - - - - - OK - + Filtrer par couleurs (optionnel) : + OK + OK + + + Cancel - + Annuler @@ -5562,47 +5646,47 @@ Cockactrice va maintenant recharger la base de données de cartes. Group By: - + Grouper par : type - + type color - + couleur subtype - + sous-type power - + force toughness - + endurance Filters (optional): - + Filtres (optionnel) : Show main bar row - + Montrer la ligne d'aggrégat principale Show per-category rows - + Monter les lignes de chaque catégorie @@ -5615,30 +5699,30 @@ Cockactrice va maintenant recharger la base de données de cartes. ManaDevotionConfigDialog - - - Display type: - - - pie - + Display type: + Type de charte : - bar - + pie + camembert - combinedBar - + bar + barre - + + combinedBar + barre combinée + + + Filter Colors (optional): - + Filtrer par couleurs (optionnel) : @@ -5659,22 +5743,22 @@ Cockactrice va maintenant recharger la base de données de cartes. pie - + camembert bar - + barre Colors: - + Couleurs : Show per-color rows - + Montrer une ligne par couleur @@ -5687,7 +5771,7 @@ Cockactrice va maintenant recharger la base de données de cartes. %1 mana (%2 cards) - + %1 mana (%2 cartes) @@ -5695,621 +5779,626 @@ Cockactrice va maintenant recharger la base de données de cartes. Mana Production + Devotion - + Production de Mana + Dévotion Mana Distribution Settings - + Options pour Distribution de Mana MessageLogWidget - + from play depuis le jeu - + from their graveyard depuis son cimetière - + from exile depuis l'exil - + from their hand depuis sa main - + the top card of %1's library la carte du dessus de la bibliothèque de %1 - + the top card of their library le carte du dessus de sa bibliothèque - + from the top of %1's library à partir du dessus de la bibliothèque de %1 - + from the top of their library du dessus de sa bibliothèque - + the bottom card of %1's library la carte du dessous de la bibliothèque de %1 - + the bottom card of their library la carte du dessous de sa bibliothèque - + from the bottom of %1's library à partir du dessous de la bibliothèque de %1 - + from the bottom of their library du dessous de sa bibliothèque - + from %1's library de la bibliothèque de %1 - + from their library depuis sa bibliothèque - + from sideboard depuis sa réserve - + from the stack depuis la pile - + from custom zone '%1' depuis la zone personnalisée '%1' - + %1 is now keeping the top card %2 revealed. %1 garde maintenant la carte du dessus de sa bibliothèque %2 révélée. - + %1 is not revealing the top card %2 any longer. %1 ne révèle plus la carte du dessus de sa bibliothèque %2. - + %1 can now look at top card %2 at any time. %1 peut maintenant regarder la carte du dessus de sa bibliothèque %2 à n'importe quel moment. - + %1 no longer can look at top card %2 at any time. %1 ne peut plus regarder la carte du dessus de sa bibliothèque %2 à n'importe quel moment. - + %1 attaches %2 to %3's %4. %1 attache %2 à %4 de %3. - + %1 has conceded the game. %1 a concédé la partie. - + %1 has unconceded the game. %1 reste dans la partie. - + %1 has restored connection to the game. %1 est revenu dans la partie. - + %1 has lost connection to the game. %1 a perdu la connexion à la partie. - + %1 points from their %2 to themselves. %1 se cible avec son %2. - + %1 points from their %2 to %3. %1 cible %3 avec son %2. - + %1 points from %2's %3 to themselves. %1 se cible avec %3 de %2. - + %1 points from %2's %3 to %4. %1 cible %4 avec %3 de %2. - + %1 points from their %2 to their %3. %1 cible de son %3 vers son %2. - + %1 points from their %2 to %3's %4. %1 cible %3 de %4 avec %2. - + %1 points from %2's %3 to their own %4. %1 cible son %4 avec %3 de %2. - + %1 points from %2's %3 to %4's %5. %1 cible %5 de %4 avec %3 de %2. - + %1 creates a face down token. %1 crée un jeton face-cachée. - + %1 creates token: %2%3. %1 crée un jeton %2%3. - + %1 has loaded a deck (%2). %1 a chargé un deck (%2). - + %1 has loaded a deck with %2 sideboard cards (%3). %1 a chargé un deck avec %2 cartes en réserve (%3). - + %1 destroys %2. %1 détruit %2. - + a card une carte - + %1 gives %2 control over %3. %1 donne le contrôle de %2 à %3. - + %1 puts %2 into play%3 face down. %1 met %2 en jeu%3 face cachée. - + %1 puts %2 into play%3. %1 met %2 en jeu %3. - + %1 puts %2%3 into their graveyard face down. - + %1 met %2%3 dans leur cimetière face cachée. - + %1 puts %2%3 into their graveyard. %1 met %2%3 dans son cimetière. - + %1 exiles %2%3 face down. - + %1 exile %2%3 face cachée. - + %1 exiles %2%3. %1 exile %2%3. - + %1 moves %2%3 to their hand. %1 met %2%3 dans sa main. - + %1 puts %2%3 into their library. %1 met %2%3 dans sa bibliothèque. - + %1 puts %2%3 onto the bottom of their library. %1 met %2%3 au-dessous de sa bibliothèque. - + %1 puts %2%3 on top of their library. %1 met %2%3 au-dessus de sa bibliothèque. - + %1 puts %2%3 into their library %4 cards from the top. %1 met %2%3 dans sa bibliothèque %4 cartes depuis le dessus - + %1 moves %2%3 to sideboard. %1 met %2%3 à sa réserve. - + %1 plays %2%3 face down. - + %1 joue %2%3 face cachée. - + %1 plays %2%3. %1 joue %2%3. - + %1 moves %2%3 to custom zone '%4' face down. - + %1 déplace %2%3 vers la zone personnalisée %4 face cachée. - + %1 moves %2%3 to custom zone '%4'. %1 déplace %2%3 vers la zone personnalisée '%4'. - + %1 tries to draw from an empty library %1 essaie de piocher dans une bibliothèque vide - + %1 draws %2 card(s). %1 pioche %2 carte.%1 pioche %2 cartes.%1 pioche %2 carte(s). - + %1 is looking at %2. %1 regarde %2. - + %1 is looking at the %4 %3 card(s) %2. top card for singular, top %3 cards for plural %1 regarde la %4 %3 carte %2.%1 regarde les %4 %3 cartes %2.%1 regarde la / les %4 %3 carte(s) %2. - + bottom dessous - + top dessus - + %1 turns %2 face-down. %1 retourne %2 face cachée. - + %1 turns %2 face-up. %1 retourne %2 face visible. - + The game has been closed. La partie a été fermée. - + The game has started. La partie commence. - + You are flooding the game. Please wait a couple of seconds. Vous floodez la partie. Veuillez patienter quelques secondes. - + %1 has joined the game. %1 a rejoint la partie. - + %1 is now watching the game. %1 est maintenant spectateur. - + You have been kicked out of the game. Vous avez été expulsé de la partie. - + %1 has left the game (%2). %1 a quitté la partie (%2). - + %1 is not watching the game any more (%2). %1 n'observe plus la partie (%2). - + %1 is not ready to start the game any more. %1 n'est plus prêt à démarrer la partie. - + %1 shuffles their deck and draws a new hand of %2 card(s). %1 mélange son deck et pioche une nouvelle main de %2 carte.%1 mélange sa bibliothèque et pioche une nouvelle main de %2 cartes.%1 mélange sa bibliothèque et pioche une nouvelle main de %2 carte(s). - + %1 shuffles their deck and draws a new hand. %1 mélange sa bibliothèque et pioche une nouvelle main. - + You are watching a replay of game #%1. Vous regardez un replay de la partie n° %1. - + %1 is ready to start the game. %1 est prêt à démarrer la partie. - + cards an unknown amount of cards cartes - + %1 card(s) a card for singular, %1 cards for plural %1 carte%1 cartes%1 carte(s) - + %1 lends %2 to %3. %1 prêtes %2 à %3. - + %1 reveals %2 to %3. %1 révèle %2 à %3. - + %1 reveals %2. %1 révèle %2. - + %1 randomly reveals %2%3 to %4. %1 révèle au hasard %2%3 à %4. - + %1 randomly reveals %2%3. %1 révèle au hasard %2%3. - + %1 peeks at face down card #%2. %1 regarde furtivement la carte face cachée n° %2. - + %1 peeks at face down card #%2: %3. %1 regarde furtivement la carte face cachée n° %2 : %3. - + %1 reveals %2%3 to %4. %1 révèle %2%3 à %4. - + %1 reveals %2%3. %1 révèle %2%3. - + %1 reversed turn order, now it's %2. %1 a inversé l'ordre des tours, c'est maintenant %2. - + reversed inversé - + normal normal - + Heads Face - + Tails Pile - + %1 flipped a coin. It landed as %2. %1 a lancé une pièce. Il a fait %2. - + %1 rolls a %2 with a %3-sided die. %1 a fait %2 avec un dé %3 faces. - + %1 flips %2 coins. There are %3 heads and %4 tails. %1 lances %2 pièces. Il y'a %3 faces et %4 piles. - + %1 rolls a %2-sided dice %3 times: %4. %1 lances un dé à %2-faces %3 fois: %4. - + %1's turn. Tour de %1 - + %1 sets annotation of %2 to %3. %1 met l'annotation %3 à %2. - - %1 places %2 "%3" counter(s) on %4 (now %5). - %1 place %2 "%3" compteur sur %4 (désormais %5).%1 place %2 "%3" compteurs sur %4 (désormais %5).%1 place %2 "%3" compteur(s) sur %4 (désormais %5). + + %1 places %2 %3%4 counter(s) on %5 (now %6). + - - %1 removes %2 "%3" counter(s) from %4 (now %5). - %1 retire %2 "%3" compteur sur %4 (désormais %5).%1 retire %2 "%3" compteurs sur %4 (désormais %5).%1 retire %2 "%3" compteur(s) sur %4 (désormais %5). + + %1 removes %2 %3%4 counter(s) from %5 (now %6). + - + + %1 failed to undo their last draw. + + + + %1 sets counter %2 to %3 (%4%5). %1 met les marqueurs %2 à %3 (%4%5). - + %1 sets %2 to not untap normally. %2 de %1 ne se dégagera pas lors de l'étape de dégagement. - + %1 sets %2 to untap normally. %2 de %1 se dégagera lors de l'étape de dégagement. - + %1 removes the PT of %2. %1 retire la F/E de %2. - + %1 changes the PT of %2 from nothing to %4. %1 met la F/E de %2 à %4. - + %1 changes the PT of %2 from %3 to %4. %1 passe la F/E de %2 de %3 à %4. - + %1 has locked their sideboard. %1 a verrouillé sa réserve. - + %1 has unlocked their sideboard. %1 a déverrouillé sa réserve. - + %1 taps their permanents. %1 engage ses permanents. - + %1 untaps their permanents. %1 dégage ses permanents. - + %1 taps %2. %1 engage %2. - + %1 untaps %2. %1 dégage %2. - + %1 shuffles %2. %1 mélange %2. - + %1 shuffles the bottom %3 cards of %2. %1 mélange les %3 cartes du dessous de la bibliothèque de %2. - + %1 shuffles the top %3 cards of %2. %1 mélange les %3 cartes du dessus de la bibliothèque de %2. - + %1 shuffles cards %3 - %4 of %2. %1 mélange les cartes %3 - %4 de %2. - + %1 unattaches %2. %1 détache %2. - + %1 undoes their last draw. %1 annule sa dernière pioche. - + %1 undoes their last draw (%2). %1 annule sa dernière pioche (%2). @@ -6317,110 +6406,115 @@ Cockactrice va maintenant recharger la base de données de cartes. MessagesSettingsPage - + Word1 Word2 Word3 Mot1 Mot2 Mot3 - + Add New Message Ajouter un message - + Edit Message Éditer le message - + Remove Message Supprimer le message - + Add message Ajouter message - - + + Message: Message : - + Edit message Éditer le message - + Chat settings Paramètres du chat - + Custom alert words Mots d'alertes personnalisées - + Enable chat mentions Activer les mentions dans le chat - + Enable mention completer Activer les mentions dans le chat - + In-game message macros Macros de message en jeu - + How to use in-game message macros Comment utiliser les macro pour les messages en jeu - + Ignore chat room messages sent by unregistered users Ignorer les messages dans le chat principal envoyés par des joueurs non enregistrés. - + Ignore private messages sent by unregistered users Ignorer les messages privés envoyés par des joueurs non enregistrés. - - + + Ignore private messages sent by non-buddy users + + + + + Invert text color Inverser la couleur du texte - + Enable desktop notifications for private messages Activer les notifications de bureau pour les messages privés. - + Enable desktop notification for mentions Activer les notifications de bureau pour les mentions. - + Enable room message history on join Activer l'historique des messages du chat principal dès la connexion - - + + (Color is hexadecimal) (La couleur est hexadécimale) - + Separate words with a space, alphanumeric characters only Séparer les mots avec un espace, seulement les caractères alphanumériques @@ -6428,42 +6522,42 @@ Cockactrice va maintenant recharger la base de données de cartes. MoveMenu - + Move to Déplacer vers - + &Top of library in random order &Dessus de la bibliothèque dans un ordre aléatoire - + X cards from the top of library... X cartes du dessus de la bibliothèque... - + &Bottom of library in random order &Dessous de la bibliothèque dans un ordre aléatoire - + T&able - + Tableau - + &Hand &Main - + &Graveyard &Cimetière - + &Exile &Exil @@ -6476,47 +6570,47 @@ Cockactrice va maintenant recharger la base de données de cartes. Type de carte - + Mana Value Valeur de Mana - + Color(s) Couleur(s) - + Loyalty Loyauté - + Main Card Type Type de carte principal - + Mana Cost Coût de mana - + P/T F/E - + Side Côté - + Layout Design - + Color Identity Identité couleur @@ -6539,6 +6633,152 @@ Cockactrice va maintenant recharger la base de données de cartes. Direction de superposition : + + PaletteEditorDialog + + + + Reset + Réinitialiser + + + + + Apply + Appliquer + + + + + Save && Apply + Sauvegarder && Appliquer + + + + ▼ Edit Palette + + + + + + ▶ Edit Palette + + + + + Palette Editor — %1 + + + + + <b>Palette Editor</b> &nbsp;·&nbsp; %1 + + + + + This theme ships no default palette files + + + + + Replace current colours with the theme author's defaults + + + + + Switch between the light and dark palette files + + + + + Editing: + + + + + Show or hide the per-role colour grid for manual tweaks + + + + + ↺ Revert to theme default + + + + + Discard unsaved edits and restore the last saved palette + + + + + Preview this palette without saving to disk + + + + + Write palette-%1.toml and reload the theme + + + + + Cannot save: this theme has no directory on disk + + + + + Save failed + + + + + Could not write %1 to: +%2 + + + + + No default found + + + + + No default palette file found for the "%1" scheme. + + + + + PaletteGridWidget + + + Active + Actif + + + + Disabled + Désactivé + + + + Inactive + Inactif + + + + Normal interactive state + + + + + Widget is disabled / not interactive + + + + + Window is in background / unfocused + + + Phase @@ -6605,57 +6845,57 @@ Cockactrice va maintenant recharger la base de données de cartes. PhasesToolbar - + Untap step Étape de dégagement - + Upkeep step Étape d'entretien - + Draw step Étape de pioche - + First main phase Première phase principale - + Beginning of combat step Étape de début de combat - + Declare attackers step Étape de déclaration des attaquants - + Declare blockers step Étape de déclaration des bloqueurs - + Combat damage step Étape d'assignation des blessures de combat - + End of combat step Étape de fin de combat - + Second main phase Seconde phase principale - + End of turn step Phase de fin de tour @@ -6663,7 +6903,7 @@ Cockactrice va maintenant recharger la base de données de cartes. PictureLoader - + en code for scryfall's language property, not available for all languages fr @@ -6672,151 +6912,142 @@ Cockactrice va maintenant recharger la base de données de cartes. PlayerActions - - View top cards of library - Voir les cartes du dessus de la bibliothèque - - - - - - - - - - - - Number of cards: (max. %1) - Nombre de cartes : (max. %1) - - - - View bottom cards of library - Voir les cartes du dessous de la bibliothèque - - - - Shuffle top cards of library - Mélanger les cartes du dessus de la bibliothèque - - - - Shuffle bottom cards of library - Mélanger les cartes du dessous de la bibliothèque - - - - Draw hand - Piocher une main - - - - 0 and lower are in comparison to current hand size - 0 et moins sont en comparaison avec la taille actuelle de la main - - - - Draw cards - Piocher des cartes - - - - - - + + + + grave - + cimetière - - - - + + + + exile + exile + + + + PlayerDialogs + + + View top cards of library - + + + + + + + + + + Number of cards: (max. %1) + + + + + View bottom cards of library + + + + + Shuffle top cards of library + + + + + Shuffle bottom cards of library + + + + + Draw hand + + + + + 0 and lower are in comparison to current hand size + + + + + Draw cards + + + + Move top cards to %1 - + Move bottom cards to %1 - + Draw bottom cards - Piocher les cartes du dessous + - - - C&reate another %1 token - C&réer un autre jeton %1 - - - + Create tokens - Créer des jetons + - - + Number: - Nombre : + - + Place card X cards from top of library - Placer les X cartes depuis le dessus de la bibliothèque + - + Which position should this card be placed: - Dans quelle position cette carte doit-elle être placée : + - + (max. %1) - (max. %1) + - + Change power/toughness - Changer la force/l'endurance + - + Change stats to: - Changer les stats pour : + - + Set annotation - Annoter + - + Please enter the new annotation: - Veuillez entrer la nouvelle annotation : - - - - Set counters - Définir les marqueurs + PlayerMenu - + Player "%1" Joueur "%1" - + &Counters &Marqueurs @@ -6830,12 +7061,16 @@ Cockactrice va maintenant recharger la base de données de cartes. This setting means you'll only see the default printing for each card, instead of being able to select a printing, and will not see the printings other people have selected. - + Le sélecteur d'impressions est désactivé car l'option d'écraser toutes les impressions avec des éditions personnalisées est activée. + +Cette option fait que vous ne verrez que l'impression par défaut pour chaque carte, au lieu de pouvoir sélectionner une impression. Vous ne verrez pas les impressions que les autres joueurs ont sélectionnés pour eux-mêmes. + + Enable printings again - + Ré-activer les impressions @@ -6846,30 +7081,30 @@ This setting means you'll only see the default printing for each card, instead o Afficher les boutons de navigation - + Printing Selector - + Sélection d'impression PrintingSelectorCardOverlayWidget - + Preference Préférence - + Pin Printing Épingler Imprimer - + Unpin Printing Désépingler Imprimer - + Show Related cards Afficher les cartes associées @@ -6934,63 +7169,63 @@ This setting means you'll only see the default printing for each card, instead o Select a card to view its available printings - + Sélectionnez une carte pour voir ses impressions PtMenu - + Power / toughness Force / Endurance - + &Increase power &Augmenter la force - + &Decrease power &Diminuer la force - + I&ncrease toughness A&ugmenter l'endurance - + D&ecrease toughness D&iminuer l'endurance - + In&crease power and toughness Au&gmenter la force et l'endurance - + Dec&rease power and toughness Dim&inuer la force et l'endurance - + Increase power and decrease toughness Augmenter la force et diminuer l'endurance - + Decrease power and increase toughness Diminuer la force et augmenter l'endurance - + Set &power and toughness... Changer la &force et l'endurance... - + Reset p&ower and toughness Réinitialiser la f&orce et l'endurance @@ -6998,37 +7233,37 @@ This setting means you'll only see the default printing for each card, instead o QMenuBar - + Services Services - + Hide %1 Cacher %1 - + Hide Others Cacher les autres - + Show All Montrer tout - + Preferences... Préférences... - + Quit %1 Quitter %1 - + About %1 À propos %1 @@ -7036,17 +7271,17 @@ This setting means you'll only see the default printing for each card, instead o QObject - + Cockatrice card database (*.xml) Base de données de cartes Cockatrice (*.xml) - + All files (*.*) Tous les fichiers (*.*) - + Cockatrice replays (*.cor) Replays Cockatrice (*.cor) @@ -7084,7 +7319,13 @@ You will not be able to manage printing preferences on a per-deck basis, or see You will have to use the Set Manager, available through Card Database -> Manage Sets. Are you sure you would like to enable this feature? - + L'activation de cette option désactivera l'utilisation du sélecteur d'impression. + +Vous ne pourrez plus gérer les préférences d'impression pour chaque deck ni voir les impressions sélectionnées par d'autres joueurs. + +Vous devrez utiliser le Gestionnaire d'extensions, accessible via : Base de données de cartes -> Gérer les extensions. + +Êtes-vous sûr de vouloir activer cette option ? @@ -7095,121 +7336,194 @@ You can now choose printings on a per-deck basis in the Deck Editor and configur You can also use the Set Manager to adjust custom sort order for printings in the Printing Selector (other sort orders like alphabetical or release date are available). Are you sure you would like to disable this feature? - + Désactiver cette option activera la sélection d'impression. + +Vous pouvez désormais choisir les impressions pour chaque deck dans l'éditeur de deck et configurer l'impression ajoutée par défaut en l'épinglant dans la sélection d'impression. + +Vous pouvez également utiliser le gestionnaire de d'extension pour personnaliser l'ordre de tri des impressions dans la sélection d'impression (d'autres ordres de tri, comme l'ordre alphabétique ou la date de parution, sont disponibles). + +Êtes-vous sûr de vouloir désactiver cette option ? Confirm Change - + Confirmer le changement QPlatformTheme - + OK OK - + Save Sauvegarder - + Save All Sauvegarder tout - + Open Ouvrir - + &Yes &Oui - + Yes to &All Dire oui à tout - + &No &Non - + N&o to All Dire non à tout - + Abort Abandonner - + Retry Réessayer - + Ignore Ignorer - + Close Fermer - + Cancel Annuler - + Discard Effacer - + Help Aide - + Apply Appliquer - + Reset Réinitialiser - + Restore Defaults Remettre à zéro + + QuickSetupPanel + + + %1% + + + + + <b>Quick Setup</b> + + + + + Generate all palette roles automatically from a single accent colour + + + + + Accent: + + + + + Primary hue. Used directly for highlights and links. +At high intensity it also tints buttons and backgrounds. + + + + + Intensity: + + + + + Subtle + + + + + Full colour + + + + + 0–30 Subtle tint — only highlights and links change hue +30–70 Accented — buttons, tooltips, and borders join in +70–100 Full colour — backgrounds, everything + + + + + 70% + + + + + Generate ↓ + + + + + Derive all palette roles from the accent colour above. +Fine-tune individual colours in the grid afterwards. + + + RemoteDeckList_TreeModel - + Name Nom - + ID ID - + Upload time Temps de chargement @@ -7217,32 +7531,32 @@ Are you sure you would like to disable this feature? RemoteReplayList_TreeModel - + ID ID - + Name Nom - + Players Joueurs - + Keep Garder - + Time started Le temps a commencé - + Duration (sec) Durée (sec) @@ -7250,37 +7564,37 @@ Are you sure you would like to disable this feature? RfgMenu - + &Exile &Exil - + &View exile &Voir la zone d'exil - + &Move exile to... &Déplacer la zone d'exil vers... - + &Top of library &Dessus de la bibliothèque - + &Bottom of library &Dessous de la bibliothèque - + &Hand &Main - + &Graveyard &Cimetière @@ -7326,9 +7640,9 @@ Are you sure you would like to disable this feature? SayMenu - + S&ay - + Dire @@ -7367,27 +7681,27 @@ Are you sure you would like to disable this feature? SetsModel - + Enabled Activée - + Set type Type - + Set code Code - + Long name Nom complet - + Release date Date de sortie @@ -7395,53 +7709,53 @@ Are you sure you would like to disable this feature? ShortcutSettingsPage - - + + Restore all default shortcuts Réinitialiser tous les raccourcis - + Do you really want to restore all default shortcuts? Êtes-vous sûr de vouloir réinitialiser tous les raccourcis ? - + Clear all default shortcuts Effacer tous les raccourcis par défaut - + Do you really want to clear all shortcuts? Êtes-vous sûr de vouloir effacer tous les raccourcis ? - + Section: Section : - + Action: Action : - + Shortcut: Raccourci : - + How to set custom shortcuts Comment assigner des raccourcis personnalisés - + Clear all shortcuts Effacer tous les raccourcis - + Search by shortcut name Rechercher par nom de raccourci @@ -7496,12 +7810,12 @@ Please check your shortcut settings! SideboardMenu - + &Sideboard &Réserve - + &View sideboard &Voir la réserve @@ -7509,27 +7823,27 @@ Please check your shortcut settings! SoundSettingsPage - + Enable &sounds Activer les &sons - + Current sounds theme: Thème sonore actuel: - + Test system sound engine Tester les effets sonores - + Sound settings Paramètres de son - + Master volume Volume principal @@ -7601,16 +7915,146 @@ Please check your shortcut settings! Réponse invalide reçue de la part du serveur de mise à jour. - + No reply received from the tag update server. Aucune réponse reçue de la part du serveur de mise à jour des tags. - + Invalid reply received from the tag update server. Réponse invalide reçue de la part du serveur de mise à jour des tags. + + StorageSettingsPage + + + + + Success + + + + + Cached card pictures have been reset. + + + + + Downloaded card pictures have been reset. + + + + + Error + + + + + One or more downloaded card pictures could not be cleared. + + + + + In-memory (currently loaded) card pictures have been reset. + + + + + Card Picture Loader Caching Method: + + + + + The network cache is the preferred way of storing images. Downloaded images are stored here until the size of the cache exceeds the configured size. Cockatrice automatically monitors this cache and deletes the least recently seen card images to ensure the cache does not exceed the configured size. + + + + + Writing card images directly to a folder on your hard drive is another way of storing images. This does not change how Cockatrice accesses or downloads images. Cockatrice will NOT automatically monitor and clear this folder, so if you enable this option, it is up to you to ensure sufficient available space. It should also be noted that if a provider outage causes you to download the wrong picture (i.e. wrong printing) you will be stuck with it until you manually delete the file, as opposed to using the network cache, which automatically rotates and thus correct errors after a while. + + + + + This is the in-memory picture cache used by the application at runtime. It determines how much memory (RAM) Cockatrice can use before it has to fetch card images from the hard disk again. Increasing this will allow more card images to be displayed at once but shouldn't be necessary. Clearing this will make Cockatrice reload all images from the network cache or the disk. + + + + + Delete Cached Images + + + + + Delete Saved Images + + + + + Clear In-Memory Images + + + + + Card Picture Loader Cache Method + + + + + Network Cache + + + + + Filesystem + + + + + In-Memory Picture Cache + + + + + Network Cache Size: + + + + + On-disk cache for downloaded pictures + + + + + Redirect Cache TTL: + + + + + How long cached redirects for urls are valid for. + + + + + Picture Cache Size: + + + + + In-memory cache for pictures not currently on screen + + + + + Naming scheme: + + + + + Day(s) + Jour(s) + + TabAccount @@ -7746,123 +8190,171 @@ Please check your shortcut settings! TabArchidekt - - + + Desc. - - - - - - AND - + Desc. - - Require ALL selected colors - - - - - Deck name... - + AND + ET - + - Owner... - + Require ALL selected colors + TOUTES les couleurs sélectionnées obligatoires - - + + + Deck name... + Nom du deck... + + + + + Owner... + Propriétaire... + + + + Packages - - - Advanced Filters - - - - - Bracket: - - - - - - Any - - - - - - Contains card... - - - - + + Advanced Filters + Filtres avancés + + + + Bracket: + Catégorie : + + + + + Any + Tous + + + + + Contains card... + Contient la carte... + + + + Commander... - + Commandant... - - - Tag... - - - - + + Tag... + Tag... + + + + Deck Size - + Taille du deck - + Cards: - + Cartes : - - + + Asc. - - - - - Sort by: - + Asc. + Sort by: + Trier par : + + + Filter by: - + Filtrer par : - + Display Settings - + Options d'affichage - - + + Search - + Rechercher - - + + Formats - + Formats Archidekt: + Archidekt : + + + + TabCardArtRules + + + Card: + Carte: + + + + ProviderId: + + + + + Mode: + + + + + Reason: + + + + + Type a card name... + + + + + Add rule + + + + + Remove rule + + + + + Refresh + + + + + Card Art Rules @@ -7891,7 +8383,7 @@ Please check your shortcut settings! Card Database - + Base de données de cartes @@ -7914,7 +8406,7 @@ Please check your shortcut settings! Réinitialiser l'interface - + Deck: %1 Deck : %1 @@ -7922,55 +8414,49 @@ Please check your shortcut settings! TabDeckEditorVisual - + Visual Deck: %1 Deck visuel : %1 - + &Visual Deck Editor &Éditeur de deck visuel - - + + Card Info Infos de la carte - - + + Deck Deck - - - Filters - Filtres - - - + &View &Voir - + Printing Imprimer - + Visible Visible - + Floating Flottant - + Reset layout Réinitialiser l'interface @@ -7978,22 +8464,22 @@ Please check your shortcut settings! TabDeckEditorVisualTabWidget - + Visual Deck View Vue de deck visuel - + Visual Database Display Affichage visuel de la base de données - + Deck Analytics Analyse de deck - + Sample Hand Exemple de main @@ -8001,134 +8487,134 @@ Please check your shortcut settings! TabDeckStorage - + Local file system Système de fichier local - + Server deck storage Serveur de stockage de deck - - + + Open in deck editor Ouvrir dans l'éditeur de deck - + Rename deck or folder Renommer le deck ou le dossier - + Upload deck Envoyer deck - + Download deck Télécharger deck - - - + + + New folder Nouveau dossier - + Delete Supprimer - + Open decks folder Ouvrir le dossier des decks - + Rename local folder Renommer le dossier local - + Rename local file Renommer le fichier local - + New name: Nouveau nom : - - - - + + + + Error Érreur - + Rename failed Renommage échoué - - + + Invalid deck file Fichier de deck invalide - + Enter deck name Entrez le nom du deck - + This decklist does not have a name. Please enter a name: Ce deck n'a pas de nom. Veuillez entrer un nom: - + Unnamed deck Deck sans nom - + Failed to upload deck to server Erreur lors de l'importation du deck sur le serveur - + Delete local file Supprimer fichier local - + Are you sure you want to delete the selected files? Êtes-vous certain de vouloir supprimer les fichiers sélectionnés ? - + Delete remote decks Supprimer les decks dinstants - + Are you sure you want to delete the selected decks? Êtes-vous certain de vouloir supprimer les decks sélectionnés ? - - + + Name of new folder: Nom du nouveau dossier : @@ -8200,191 +8686,191 @@ Veuillez entrer un nom: TabGame - - - + + + Replay Replay - - + + Game Partie - - + + Player List Liste des joueurs - - + + Card Info Infos de la carte - - + + Messages Messages - - + + Replay Timeline Historique des replays - + &Phases &Phases - + &Game &Partie - + Next &phase Étape &suivante - + Next phase with &action Étape suivante avec &action - + Next &turn &Tour suivant - + Reverse turn order Inverser l'ordre des tours - + &Remove all local arrows &Retirer toutes les flèches locales - + Rotate View Cl&ockwise Pivoter la vue dans le sens h&oraire - + Rotate View Co&unterclockwise Pivoter la vue dans le sens &anti-horaire - + Game &information &Informations sur la partie - + Un&concede Annuler la &concession - - - + + + &Concede &Concéder - + &Leave game &Quitter la partie - + C&lose replay Fermer &le replay - + &Focus Chat &Focus sur le chat - + &Say: &Dire : - + Selected cards Cartes sélectionnées - + &View &Voir - + Visible Visible - + Floating Flottant - + Reset layout Réinitialiser l'interface - + Concede Concéder - + Are you sure you want to concede this game? Êtes-vous sûr de vouloir concéder la partie ? - + Unconcede Rester dans la partie - + You have already conceded. Do you want to return to this game? Vous avez déjà concédé la partie. Voulez-vous la reprendre ? - + Leave game Quitter la partie - + Are you sure you want to leave this game? Êtes-vous sûr de vouloir quitter la partie ? - + A player has joined game #%1 Un joueur a rejoint la partie #%1. - + %1 has joined the game %1 a rejoint la partie. - + You have been kicked out of the game. Vous avez été expulsé de la partie. @@ -8443,115 +8929,115 @@ Veuillez entrer un nom: Vous devez sélectionner un nombre valide de jours à identifier. - + Username: Nom d'utilisateur: - + IP Address: Adresse IP : - + Game Name: Nom de la partie : - + GameID: ID de la partie : - + Message: Message : - + Main Room Salon principal - + Game Room Salon de jeu - + Private Chat Chat privé - + Past X Days: Les X derniers jours : - + Today Aujourd'hui - + Last Hour La dernière heure - + Maximum Results: Résultats maximum : - + At least one filter is required. The more information you put in, the more specific your results will be. Au moins un filtre est requis. Plus vous entrez d'informations, meilleurs seront les résultats. - + Get User Logs Obtenir les journaux de l'utilisateur - + Clear Filters Effacer les filtres - + Filters Filtres - + Log Locations Emplacement des journaux - + Date Range Intervalle de dates - + Maximum Results Résultats maximum - - + + Message History Historique des messages - + Failed to collect message history information. Impossible de récupérer l'historique des messages. - + There are no messages for the selected filters. Il n'y a pas de messages pour les filtres sélectionnés. @@ -8569,27 +9055,27 @@ Plus vous entrez d'informations, meilleurs seront les résultats.&Quitter - + %1 - Private chat %1 - Chat privé - + This user is ignoring you, they cannot see your messages in main chat and you cannot join their games. Cet utilisateur vous ignore, il ne peut pas voir vos messages dans le chat principal et vous ne pouvez pas rejoindre ses parties. - + Private message from Message privé de - + %1 has left the server. %1 a quitté le serveur. - + %1 has joined the server. %1 a rejoint le serveur. @@ -8597,180 +9083,180 @@ Plus vous entrez d'informations, meilleurs seront les résultats. TabReplays - + Local file system Système de fichier local - + Server replay storage Stockage du serveur de replays - - + + Watch replay Voir replay - + Rename Renommer - - + + New folder Nouveau fichier - - + + Delete Supprimer - + Open replays folder Ouvrir le dossier des Replays - + Download replay Télécharger replay - + Toggle expiration lock Changer le verrouillage de l'expiration - + Get replay share code Obtenir le code de partage du replay - - + + Look up replay by share code Regarder un replay depuis un code de partage - + Rename local folder Renommer le dossier local - + Rename local file Renommer le fichier local - + New name: Nouveau nom: - + Error Erreur - + Rename failed Renommage échoué - + Name of new folder: Nom du nouveau fichier: - + Delete local file Supprimer fichier local - + Are you sure you want to delete the selected files? Êtes-vous certain de vouloir supprimer les fichiers sélectionnés? - + Are you sure you want to delete the selected replays? Êtes-vous certain de vouloir supprimer cet enregistrement? - + Failed to get code Echec d'obtention du code - - + + Either this server does not support replay sharing, or does not permit replay sharing for you. Soit ce serveur ne prend pas en charge le partage de replay, soit il ne vous autorise pas à partager des replays. - - - + + + Failed Échec - + Could not get replay code Impossible d'obtenir le code de replay - + Replay Share Code Code de Partage de Replay - + Others can use this code to add the replay to their list of remote replays: %1 D'autres peuvent utiliser ce code pour ajouter la replay à leur liste de replay distantes :%1 - + Copy to clipboard Copier vers le presse-papier - + Replay share code Code de partage de replay - + Replay code found Code de replay trouvé - + Replay was added, or you already had access to it. Le replay a été ajoutée, ou vous y aviez déjà accès. - + Replay code not found Code de replay non trouvé - + Failed to submit code Échec de l'envoi du code - + Unexpected error Erreur inattendue - + Delete remote replay Supprimer le replay distant @@ -8783,47 +9269,62 @@ Plus vous entrez d'informations, meilleurs seront les résultats. TabRoom - + + Friends + + + + + Online + + + + + Ignored + + + + &Say: &Dire : - + Chat Chat - + &Room &Salon - + &Leave room &Quitter le salon - + &Clear chat Effacer le &Chat - + Chat Settings... Paramètres du chat... - + mentioned you. vous a mentionné. - + Click to view Cliquer pour voir - + You are flooding the chat. Please wait a couple of seconds. Vous floodez le chat. Veuillez patienter quelques secondes. @@ -8836,30 +9337,30 @@ Plus vous entrez d'informations, meilleurs seront les résultats.Serveur - - - - + + + + Error Erreur - + Failed to join the server room: it doesn't exist on the server. Impossible de rejoindre le salon: il n'existe pas sur le serveur - + The server thinks you are in the server room but your client is unable to display it. Try restarting your client. Le serveur considère que vous êtes dans le salon mais votre client est incapable de l'afficher. Essayez en relançant votre client. - + You do not have the required permission to join this server room. Vous n'avez pas la permission requise pour rejoindre ce salon. - + Failed to join the server room due to an unknown error: %1. Impossible de rejoindre le salon à cause d'un erreur inconnue: %1 @@ -8867,97 +9368,97 @@ Plus vous entrez d'informations, meilleurs seront les résultats. TabSupervisor - + Deck Editor Éditeur de deck - + Visual Deck Editor Éditeur de deck visuel - + EDHRec EDHrec - + Archidekt - + Archidekt - + Home Accueil - + &Visual Deck Storage Stockage &visuel de decks - + Visual Database Display Affichage visuel de la base de données - + Server Serveur - + Account Compte - + Deck Storage Stockage de deck - + Game Replays Replays de parties - + Administration Administration - + Logs Journaux - + Are you sure? Êtes-vous sûr ? - + There are still open games. Are you sure you want to quit? Il y a encore des parties en cours. Êtes-vous sûr de vouloir quitter ? - + Click to view Cliquer pour regarder - + Your buddy %1 has signed on! Votre ami %1 s'est inscrit ! - + Unknown Event Événement inconnu - + The server has sent you a message that your client does not understand. This message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -8968,39 +9469,39 @@ Ce message peut signifier qu'il y a une nouvelle version de Cockatrice disp Pour mettre à jour votre client, allez dans « Aide -> Vérifier les mises à jour ». - + Idle Timeout Temporisation inactive - + You are about to be logged out due to inactivity. Vous allez d'être déconnecté en raison de l'inactivité. - + Promotion Promotion - + You have been promoted. Please log out and back in for changes to take effect. Vous avez été promu modérateur. Veuillez vous reloguer pour que les changements prennent effet. - + Warned Averti - + You have received a warning due to %1. Please refrain from engaging in this activity or further actions may be taken against you. If you have any questions, please private message a moderator. You avez reçu un avertissement pour la raison : %1. Merci de ne pas recommencer ou d'autres mesures peuvent être prises contre vous. Si vous avez des questions, veuillez contacter un modérateur en privé. - + You have received the following message from the server. (custom messages like these could be untranslated) Vous avez reçu le message suivant du serveur. @@ -9010,12 +9511,12 @@ Merci de ne pas recommencer ou d'autres mesures peuvent être prises contre TabVisualDatabaseDisplay - + Database Display - + Affichage visuel de la bade de données - + Visual Database Display Affichage visuel de la base de données @@ -9054,42 +9555,42 @@ Merci de ne pas recommencer ou d'autres mesures peuvent être prises contre TranslateCounterName - + Life Points de vie - + White Blanc - + Blue Bleu - + Black Noir - + Red Rouge - + Green Vert - + Colorless Incolore - + Other Autre @@ -9097,11 +9598,69 @@ Merci de ne pas recommencer ou d'autres mesures peuvent être prises contre UpdateDownloader - + Could not open the file for reading. Impossible d'ouvrir le fichier en lecture. + + UserCardArtSettingsDialog + + + Card Art Settings + + + + + Type a card name... + + + + + Card name: + + + + + Card ProviderId: + + + + + Left margin (%): + + + + + Right margin (%): + + + + + Vertical offset: + + + + + Zoom: + + + + + Parameters + + + + + Preview + + + + + Remove Banner Card + + + UserContextMenu @@ -9219,7 +9778,7 @@ Merci de ne pas recommencer ou d'autres mesures peuvent être prises contre - + Ban History Historique des bannissements @@ -9234,77 +9793,87 @@ Merci de ne pas recommencer ou d'autres mesures peuvent être prises contre Le joueur n'a jamais été banni. - + Failed to collect ban information. Impossible de collecter les informations de bannissement. - - - + + + Warning History Historique des avertissements - + Warning Time;Moderator;User Name;Reason Heure d'avertissement;Modérateur;Nom d'utilisateur;Raison - + User has never been warned. Le joueur n'a jamais reçu d'avertissement. - + Failed to collect warning information. Impossible de collecter les informations d'avertissements. - + Failed to get admin notes. Impossible de récupérer les notes d'administrateur. - - + + Success Succès - + Successfully promoted user. L'utilisateur a été promu avec succès. - + Successfully demoted user. L'utilisateur a été rétrogradé avec succès. - - - + + Kick Player + + + + + Are you sure you want to kick this player from the game? + + + + + + Failed Échec - + Failed to promote user. L'utilisateur n'a pas été promu. - + Failed to demote user. L'utilisateur n'a pas été rétrogradé. - + Copy hash to clipboard Copier le hash - + Remove this user's messages Supprimer les messages de cet utilisateur @@ -9312,326 +9881,554 @@ Merci de ne pas recommencer ou d'autres mesures peuvent être prises contre UserInfoBox - + Location: Localisation : - + Account Age: Ancienneté du compte : - + Edit Éditer - + Change password Changer le mot de passe - + Change avatar Changer l'avatar - + Administrator Administrateur - + Moderator Modérateur - + Registered user Joueur enregistré - - + + Unregistered user Joueur non enregistré - + Judge Arbitre - + Unknown Inconnu - + The entered password does not match your account. Le mot de passe est incorrect - - - + + + + Information Information - + User information updated. Informations de l'utilisateur mises à jour. - - - - - - - - - - + + + + + + + + + + + Error Erreur - + User Information Informations utilisateur - + Real Name: Nom réel : - + User Level: Rang : + + + Edit Banner Card + + - + %n Year(s), amount of years (only shown if more than 0) %n année.%n années.%n année(s). - + %10%n Day(s) %20 amount of years (if more than 0), amount of days, date in local short format %10%n jour %20%10%n jours %20%10%n jour(s) %20 - + Enter Password Saisir le mot de passe - + Password verification is required in order to change your email address La vérification du mot de passe est requise pour changer votre adresse e-mail - - - + + + An error occurred while trying to update your user information. Une erreur est survenue lors de la mise à jour de vos informations personnelles. - + + The selected card is blacklisted on this server or another error occurred. + + + + + Banner card removed. + + + + + Banner card updated. + + + + This server does not permit you to update your user informations. Ce serveur ne vous permet pas de mettre à jour vos informations personnelles. - + Password changed. Mot de passe changé. - + This server does not permit you to change your password. Ce serveur ne vous permet pas de changer votre mot de passe. - + The new password is too short. Le nouveau mot de passe est trop court. - + The old password is incorrect. L'ancien mot de passe est incorrect. - + Avatar updated. Avatar mis à jour. - + This server does not permit you to update your avatar. Ce serveur ne vous permet pas de mettre à jour votre avatar. + + + An error occured while trying to update your avatar. + + - An error occured while trying to updater your avatar. - Une erreur est survenue lors de la mise à jour de votre avatar. + This server does not permit you to update your user informations. + An error occured while trying to updater your avatar. + Ce serveur ne vous permet pas de mettre à jour vos informations personnelles. + + + + UserInfoPopup + + + + Games + + + + + Chat + + + + + Open private chat + + + + + Profile + + + + + View user profile + + + + + Show this user's games + + + + + − Buddy + + + + + Remove from buddy list + + + + + + Buddy + + + + + Add to buddy list + + + + + − Ignore + + + + + Remove from ignore list + + + + + + Ignore + + + + + Add to ignore list + + + + + Ban + + + + + Ban from server + + + + + Warn + + + + + Warn user + + + + + Ban log + + + + + View ban history + Voir l'historique de bannissements + + + + Warn log + + + + + View warning history + Voir l'historique d'avertissements + + + + Notes + + + + + View admin notes + + + + + − Mod + + + + + Demote from moderator + + + + + + Mod + + + + + Promote to moderator + + + + + − Judge + + + + + Demote from judge + + + + + + Judge + + + + + Promote to judge + + + + + Join game + + + + + Spectate + + + + + + Loading games… + + + + + Could not load games. + + + + + No active games. + UserInterfaceSettingsPage - + General interface settings Paramètres généraux de l'interface - + &Double-click cards to play them (instead of single-click) &Double cliquer sur les cartes pour les jouer (au lieu d'un simple clic) - + &Clicking plays all selected cards (instead of just the clicked card) &Cliquer joue toutes les cartes sélectionnées (à la place de seulement la carte cliquée) - + &Play all nonlands onto the stack (not the battlefield) by default &Jouer toutes les cartes non terrain dans la pile (pas sur le champ de bataille) par défaut - + Do not delete &arrows inside of subphases - + Ne pas effacer les flèches lors de sous-phases - + Close card view window when last card is removed Fermer la fenêtre d'affichage des cartes lorsque la dernière carte est supprimée - + Auto focus search bar when card view window is opened Focalise automatiquement la barre de recherche lorsque la fenêtre d'affichage des cartes est ouverte - + Annotate card text on tokens Annoter le texte des cartes sur les jetons - - Show selection counter during drag selection + + Show selection count during drag selection - - Show total selection counter + + Show total selection count - + + Show subtype breakdown in selection tally + + + + Use tear-off menus, allowing right click menus to persist on screen Utiliser les menus détachables, permettant les clics droits sur les rendre persistants - + + Keep game chat focused when clicking in game (Note: disables card view search bar) + + + + Notifications settings Paramètres des notifications - + Enable notifications in taskbar Activer les notifications dans la barre des tâches - + Notify in the taskbar for game events while you are spectating Notifier dans la barre des tâches pour les évènements des parties quand vous y êtes spectateur - + Notify in the taskbar when users in your buddy list connect Notifier dans la barre des tâches quand un ami se connecte - + Animation settings Paramètres des animations - + &Tap/untap animation &Animation d'engagement / dégagement - + Deck editor/storage settings Paramètres d'éditeur et de stockage de deck - + Open deck in new tab by default Ouvre le deck dans un nouvel onglet par défaut - + Use visual deck storage in game lobby Utiliser le stockage visuel de decks dans le lobby des parties - + Use selection animation for Visual Deck Storage Utilisez l'animation de sélection pour le stockage visuel des deck - + When adding a tag in the visual deck storage to a .txt deck: Lorsque vous ajoutez une étiquette dans le stockage visuel du deck à un deck stocké comme un fichier .txt : - + do nothing ne rien faire - + ask to convert to .cod demander à convertir en fichier .cod - + always convert to .cod toujours convertir en fichier .cod - + Default deck editor type Type d'éditeur de deck par défaut - + Classic Deck Editor Éditeur de deck classique - + Visual Deck Editor Éditeur de deck visuel - + Replay settings Paramètres du replay - + Buffer time for backwards skip via shortcut: Temps de tampon pour les sauts en arrière via un raccourci : @@ -9639,22 +10436,22 @@ Merci de ne pas recommencer ou d'autres mesures peuvent être prises contre UserListWidget - + Users connected to server: %1 Joueurs connectés au serveur : %1 - + Users in this room: %1 Joueurs dans ce salon : %1 - + Buddies online: %1 / %2 Amis connectés : %1 / %2 - + Ignored users online: %1 / %2 Joueurs sur liste noire connectés : %1 / %2 @@ -9662,58 +10459,68 @@ Merci de ne pas recommencer ou d'autres mesures peuvent être prises contre UtilityMenu - + Increment all card counters Incrémenter tous les marqueurs de la carte - + &Untap all permanents &Dégager tous les permanents - + R&oll die... L&ancer un dé... - + + Flip coin + + + + &Create token... &Créer un jeton... - + C&reate another token C&réer un autre jeton - + Cr&eate predefined token C&réer un jeton prédéfini + + + C&reate another %1 token + + VisualDatabaseDisplayColorFilterWidget Exact match - + Correspondance exacte Includes - + Inclus Include / Exclude Mode: Includes - + Inclure / Exclure How selected and unselected colors are combined in the filter - + Comment les couleurs sélectionnées ou non sont combinées dans le filtre @@ -9721,7 +10528,7 @@ Merci de ne pas recommencer ou d'autres mesures peuvent être prises contre Search filter... - + Filtre de recherche... @@ -9742,138 +10549,138 @@ Merci de ne pas recommencer ou d'autres mesures peuvent être prises contre VisualDatabaseDisplayFilterToolbarWidget - + Sort by - - - - - Filter by - + Trier par - Save and load filters - - - - - Filter by exact card name - + Filter by + Filtrer par - Filter by card main-type - + Save and load filters + Sauvegarder et charger les filtres - Filter by card sub-type - + Filter by exact card name + Filtrer par nom exact de carte - Filter by set - + Filter by card main-type + Filtrer par type principal de carte - Filter by format legality - + Filter by card sub-type + Filtrer par sous-type de carte + + + + Filter by set + Filtrer par extension - Save/Load - - - - - Name - + Filter by format legality + Filtrer par légalité dans le format - Main Type - + Save/Load + Sauvegarder/Charger - Sub Type - + Name + Nom - Sets - + Main Type + Type Principal + Sub Type + Sous-type + + + + Sets + Éditions + + + Formats - + Formats VisualDatabaseDisplayFormatLegalityFilterWidget - - - Show formats with at least: - - - cards - + Show formats with at least: + Montrer les formats avec au moins : - Do not display formats with less than this amount of cards in the database - + cards + cartes + Do not display formats with less than this amount of cards in the database + Ne pas afficher les formats dont le nombre est inférieur à ce nombre dans la base de données + + + Filter mode (AND/OR/NOT conjunctions of filters) - + Mode de filtre (conjonctions AND/OR/NOT de filtres) Mode: Exact Match - + Mode : correspondance exacte Mode: Includes - + Mode : inclure VisualDatabaseDisplayMainTypeFilterWidget - - - Show main types with at least: - - - cards - + Show main types with at least: + Montrer les types principaux avec au moins : + cards + cartes + + + Do not display card main-types with less than this amount of cards in the database Ne pas afficher les types principaux de cartes dont le nombre est inférieur à ce montant dans la base de données - + Filter mode (AND/OR/NOT conjunctions of filters) Mode de filtre (conjonctions AND/OR/NOT de filtres) - + Mode: Exact Match Mode : correspondance exacte - + Mode: Includes Mode : inclure @@ -9881,27 +10688,27 @@ Merci de ne pas recommencer ou d'autres mesures peuvent être prises contre VisualDatabaseDisplayNameFilterWidget - + Filter by name... (Exact match) - + Filtrer par nom... (Correspondance exacte) - + Load from Deck Charger depuis un deck - + Apply all card names in currently loaded deck as exact match name filters Appliquer tous les noms de cartes du deck actuellement chargé comme filtres de nom exacts - + Load from Clipboard Charger depuis le presse-papier... - + Apply all card names in clipboard as exact match name filters Appliquer tous les noms de cartes du presse-papier comme filtres de nom exacts @@ -9909,7 +10716,7 @@ Merci de ne pas recommencer ou d'autres mesures peuvent être prises contre VisualDatabaseDisplayRecentSetFilterSettingsWidget - + Filter to most recent sets Filtrer par extensions récentes @@ -9917,19 +10724,19 @@ Merci de ne pas recommencer ou d'autres mesures peuvent être prises contre VisualDatabaseDisplaySetFilterWidget - + Search sets... Rechercher les extensions... - - + + Mode: Exact Match Mode : correspondance exacte - - + + Mode: Includes Mode : inclure @@ -9937,37 +10744,37 @@ Merci de ne pas recommencer ou d'autres mesures peuvent être prises contre VisualDatabaseDisplaySubTypeFilterWidget - + Search subtypes... Rechercher par sous-types... - - - Show sub types with at least: - - - cards - + Show sub types with at least: + Montrer les sous-types avec au moins : + cards + cartes + + + Do not display card sub-types with less than this amount of cards in the database Ne pas afficher les sous-types de cartes dont le nombre est inférieur à ce montant dans la base de données - + Filter mode (AND/OR/NOT conjunctions of filters) Mode de filtre (conjonctions AND/OR/NOT de filtres) - + Mode: Exact Match Mode : correspondance exacte - + Mode: Includes Mode : inclure @@ -9975,30 +10782,30 @@ Merci de ne pas recommencer ou d'autres mesures peuvent être prises contre VisualDatabaseDisplayWidget - + Search by card name (or search expressions) Rechercher par nom de carte (ou expression de recherche) - + Visual - + Visuel - + Loading database ... Chargement de la base de données... - + Clear all filters Effacer tous les filtres Table - + Tableau @@ -10006,43 +10813,43 @@ Merci de ne pas recommencer ou d'autres mesures peuvent être prises contre Group by: - + Grouper par : Change how cards are divided into categories/groups. - + Configurez comment les cartes sont divisées en groupes/catégories. Sort by: - + Trier par : Click and drag to change the sort order within the groups - + Cliquer et glisser pour changer l'ordre de tri dans les groupes. Configure how cards are sorted within their groups - + Configurez le mode de tri des cartes dans leurs groupes - + Toggle Layout: Overlap - + Mode d'affichage : Superposé - + Change how cards are displayed within zones (i.e. overlapped or fully visible.) - + Modifier l'affichage des cartes dans les zones (i.e. superposées ou entièrement visibles). - + Toggle Layout: Flat - + Mode d'affichage : Plat @@ -10050,7 +10857,7 @@ Merci de ne pas recommencer ou d'autres mesures peuvent être prises contre Add cards using the search bar or database tab to have them appear here - + Ajouter des cartes via la barre de recherche ou l'onglet base de données pour qu'elles apparaissent ici @@ -10069,17 +10876,17 @@ Merci de ne pas recommencer ou d'autres mesures peuvent être prises contre VisualDeckEditorWidget - + Type a card name here for suggestions from the database... - + Tapez un nom de carte ici pour des suggestions de la base de données... - + Quick search and add card Recherche et ajout rapide d'une carte - + Search for closest match in the database (with auto-suggestions) and add preferred printing to the deck on pressing enter Recherchez la correspondance la plus proche dans la base de données (avec suggestions automatiques) et ajoutez l'impression souhaitée au deck en appuyant sur Entrée @@ -10107,7 +10914,7 @@ Merci de ne pas recommencer ou d'autres mesures peuvent être prises contre Show Color Identity - + Montrer l'identité de couleur @@ -10197,43 +11004,43 @@ Merci de ne pas recommencer ou d'autres mesures peuvent être prises contre WarningDialog - + Which warning would you like to send? Quel avertissement voudriez-vous envoyer ? - + Redact all messages from this user in all rooms Censurer tous les messages de cet utilisateur dans toutes les salles - + &OK &OK - + &Cancel &Annuler - + Warn user for misconduct Envoyer un avertissement au joueur pour mauvaise conduite - - + + Error Erreur - + User name to send a warning to can not be blank, please specify a user to warn. Le nom d'utilisateur du joueur à avertir ne peut pas être vide, merci d'entrer un joueur à avertir. - + Warning to use can not be blank, please select a valid warning to send. L'avertissement à utiliser ne peut pas être vide, merci de sélectionner un avertissement valide à envoyer. @@ -10241,133 +11048,133 @@ Merci de ne pas recommencer ou d'autres mesures peuvent être prises contre WndSets - + Move selected set to the top Déplacer l'édition sélectionnée tout en haut - + Move selected set up Déplacer l'édition sélectionnée vers le haut - + Move selected set down Déplacer l'édition sélectionnée vers le bas - + Move selected set to the bottom Déplacer l'édition sélectionnée tout en bas - + Search by set name, code, or type Rechercher par ensemble de nom, code ou type - + Default order Ordre par défaut - + Restore original art priority order Restaurer l'ordre original de priorité artistique - + Enable all sets Activer toutes les éditions - + Disable all sets Désactiver toutes les éditions - + Enable selected set(s) Activer l(es) édition(s) sélectionnée(s) - + Disable selected set(s) Désactiver l(es) éditions(s) sélectionné(s) - + Deck Editor Éditeur de deck - + Use CTRL+A to select all sets in the view. Utiliser Ctrl+A pour sélectionner toutes les éditions dans la vue actuelle. - + Only cards in enabled sets will appear in the card list of the deck editor. Seules les cartes des éditions activées apparaitront dans la liste de cartes de l'éditeur de deck. - + Image priority is decided in the following order: La priorité des images est décidé dans l'ordre suivant: - + first the CUSTOM Folder (%1), then the Enabled Sets in this dialog (Top to Bottom) %1 is a link to the wiki D'abord le dossier PERSONNALISÉ (%1), puis les éditions activées dans cette fenêtre (de haut en bas) - + Include cards rebalanced for Alchemy [requires restart] Inclure les cartes rééquilibrées pour Alchemy [nécessite un redémarrage] - + Card Art Images des cartes - + How to use custom card art Comment utiliser une illustration personnalisée de carte - + Hints Astuces - + Note Note - + Sorting by column allows you to find a set while not changing set priority. Trier par colonne vous permet de trouver une édition sans changer la priorité des éditions. - + To enable ordering again, click the column header until this message disappears. Pour trier à nouveau, cliquez sur l'entête de la colonne jusqu'à ce que ce message disparaisse. - + Use the current sorting as the set priority instead Utiliser le tri actuel comme ordre de priorité des éditions - + Sorts the set priority using the same column Sortir la priorité sur la même colonne - + Manage sets Gérer les éditions @@ -10375,72 +11182,72 @@ Merci de ne pas recommencer ou d'autres mesures peuvent être prises contre ZoneViewWidget - + Search by card name (or search expressions) Rechercher par nom de carte (ou expression de recherche) - + Ungrouped Non groupé - + Group by Type Grouper par Types - + Group by Mana Value Grouper par Valeur de Mana - + Group by Color Grouper par Couleurs - + Unsorted Non trié - + Sort by Name Trier par Nom - + Sort by Type Trier par Type - + Sort by Mana Cost Trier par Coût de Mana - + Sort by Colors Trier par Couleurs - + Sort by P/T Trier par F/E - + Sort by Set Trier par extension - + shuffle when closing mélanger en quittant - + pile view vue de la pile @@ -10448,7 +11255,7 @@ Merci de ne pas recommencer ou d'autres mesures peuvent être prises contre i18n - + English Français (French) @@ -10456,12 +11263,12 @@ Merci de ne pas recommencer ou d'autres mesures peuvent être prises contre main - + Connect on startup Connecter au démarrage - + Debug to file Débuguer dans journal @@ -10475,7 +11282,7 @@ Merci de ne pas recommencer ou d'autres mesures peuvent être prises contre - + Deck Editor Éditeur de deck @@ -10556,7 +11363,7 @@ Merci de ne pas recommencer ou d'autres mesures peuvent être prises contre - + Replays Replays @@ -10671,129 +11478,129 @@ Merci de ne pas recommencer ou d'autres mesures peuvent être prises contre Charger un deck… - - + + Load Deck from Clipboard... Charger un deck depuis le presse-papier… - + Edit Deck in Clipboard, Annotated Éditer le deck dans le presse-papier, avec annotations - + Edit Deck in Clipboard Éditer le deck dans le presse-papier - + New Deck Nouveau deck - + Open Custom Pictures Folder Ouvrir le dossier d'images personnalisées - + Print Deck... Imprimer le deck… - + Delete Card Supprimer la carte - - + + Reset Layout Réinitialiser l'interface - + Save Deck Sauvegarder le deck - + Save Deck as... Sauvegarder le deck sous… - + Save Deck to Clipboard, Annotated Copier le deck annoté - + Save Deck to Clipboard, Annotated (No Set Info) Copier le deck, avec annotations (sans information d'extension) - + Save Deck to Clipboard Copier le deck - + Save Deck to Clipboard (No Set Info) Copier le deck (sans information d'extension) - + Load Local Deck... Charger un deck local… - + Load Remote Deck... Charger un deck distant… - + Set Ready to Start Marquer prêt à démarrer - + Toggle Sideboard Lock Verrouiller / déverrouiller la réserve - + Add Green Counter Ajouter un marqueur vert - + Remove Green Counter Retirer un marqueur vert - + Set Green Counters... Nombre de marqueurs verts… - + Add Red Counter Ajouter un marqueur rouge - + Remove Red Counter Retirer un marqueur rouge - + Set Red Counters... Nombre de marqueurs rouges… - + Add Life Counter Gagner 1 point de vie @@ -10803,724 +11610,744 @@ Merci de ne pas recommencer ou d'autres mesures peuvent être prises contre Montrer la barre de statut - + + Load deck from online service... + + + + + Load from website... + + + + Unload Deck Annuler le chargement du deck - + Force Start Forcer le démarrage - + Add Card Counter (F) Ajouter un marqueur de carte (F) - + Remove Card Counter (F) Retirer un marqueur de carte (F) - + Set Card Counters (F)... Nombre de marqueurs de carte (F)... - + Add Card Counter (E) Ajouter un marqueur de carte (E) - + Remove Card Counter (E) Retirer un marqueur de carte (E) - + Set Card Counters (E)... Nombre de marqueurs de carte (E)... - + Add Card Counter(D) Ajouter un marqueur de carte (D) - + Remove Card Counter (D) Retirer un marqueur de carte (D) - + Set Card Counters (D)... Nombre de marqueurs de carte (D)... - + Add Card Counter (C) Ajouter un marqueur de carte (C) - + Remove Card Counter (C) Retirer un marqueur de carte (C) - + Set Card Counters (C)... Nombre de marqueurs de carte (C)... - + Add Card Counter (B) Ajouter un marqueur de carte (B) - + Remove Card Counter (B) Retirer un marqueur de carte (B) - + Set Card Counters (B)... Nombre de marqueurs de carte (B)... - + Add Card Counter (A) Ajouter un marqueur de carte (A) - + Remove Card Counter (A) Retirer un marqueur de carte (A) - + Set Card Counters (A)... Nombre de marqueurs de carte (A)... - + Remove Life Counter Perdre 1 point de vie - + Set Life Counters... Nombre de points de vie… - + Add White Counter Ajouter un marqueur blanc - + Remove White Counter Retirer un marqueur blanc - + Set White Counters... Nombre de marqueurs blancs… - + Add Blue Counter Ajouter un marqueur bleu - + Remove Blue Counter Retirer un marqueur bleu - + Set Blue Counters... Nombre de marqueurs bleus… - + Add Black Counter Ajouter un marqueur noir - + Remove Black Counter Retirer un marqueur noir - + Set Black Counters... Nombre de marqueurs noirs… - + Add Colorless Counter Ajouter un marqueur incolore - + Remove Colorless Counter Retirer un marqueur incolore - + Set Colorless Counters... Nombre de marqueurs incolores… - + Add Other Counter Ajouter un autre marqueur - + Remove Other Counter Supprimer un autre marqueur - + Set Other Counters... Mettre d'autres compteurs... - + Increment all card counters Incrémenter tous les marqueurs de la carte - + Add Power (+1/+0) Augmenter la force (+1/+0) - + Remove Power (-1/-0) Diminuer la force (-1/-0) - + Move Toughness to Power (+1/-1) Endurance vers force (+1/-1) - + Add Toughness (+0/+1) Augmenter l'endurance (+0/+1) - + Remove Toughness (-0/-1) Diminuer l'endurance (-0/-1) - + Move Power to Toughness (-1/+1) Force vers endurance (-1/+1) - + Add Power and Toughness (+1/+1) Augmenter la force et l'endurance (+1/+1) - + Remove Power and Toughness (-1/-1) Diminuer la force et l'endurance (-1/-1) - + Set Power and Toughness... Changer la force et l'endurance… - + Reset Power and Toughness Réinitialiser la force et l'endurance - + Untap Dégager - + Upkeep Entretien - + Draw Piocher - + First Main Phase Première phase principale - + Start Combat Début de combat - + Attack Attaquer - + Block Bloquer - + Damage Blessures - + End Combat Fin de combat - + Second Main Phase Seconde phase principale - + End Fin - + Next Phase Phase suivante - + Next Phase Action Étape suivante avec action - + Next Turn Tour suivant - + Hide Card in Reveal Window Cache la carte dans la fenêtre Révéler - + Tap / Untap Card Engage / dégager la carte - + Untap All Tout dégager - + Toggle Skip Untapping Toggle Untap - + Activer / désactiver le désengagment - + Turn Card Over Retourner la carte - + Peek Card Regarder la carte - + Play Card Jouer la carte - + Play Card, Face Down - + Jouer la carte, face cachée - + Attach Card... Attacher la carte… - + Unattach Card Détacher la carte - + Clone Card Cloner la carte - + Create Token... Créer un jeton… - + Create All Related Tokens Créer tous les jetons associés - + Create Another Token Créer un autre jeton - + Set Annotation... Annoter… - + + Reduce Life by Power + + + + Select All Cards in Zone Sélectionne toutes les cartes dans la zone - + Select All Cards in Row Sélectionne toutes les cartes dans la ligne - + Select All Cards in Column Sélectionne toutes les cartes dans la colonne - + Reveal Selected Cards to All Players - + Révéler les cartes sélectionnées à tous les joueurs - - + + Bottom of Library Dessous de la bibliothèque - - - - + + + + Exile Exil - - - - + + + + Graveyard Cimetière - - + + Hand Main - - + + Top of Library Dessus de la bibliothèque - - + + Battlefield, Face Down Champ de bataille, face cachée - + Battlefield Champ de bataille - + Library Bibliothèque - + Sideboard Réserve - + Top Cards of Library Cartes du dessus de la bibliothèque - + Bottom Cards of Library Cartes du dessous de la bibliothèque - + Close Recent View Fermer la vue récente - - + + Stack Pile - - + + Graveyard (Multiple) Cimetière (plusieurs) - - + + Graveyard (Multiple), Face Down - + Cimetière (plusieurs), faces cachées - - + + Exile (Multiple) Exil (plusieurs) - - + + Exile (Multiple), Face Down - + Exiler(plusieurs), faces cachées - + Stack Until Found Empiler jusqu'à ce que trouvé - + Draw Bottom Card Piocher la carte du dessous - + Draw Multiple Cards from Bottom... Piocher plusieurs cartes du dessous... - + Draw Arrow... Tracer une flèche… - + Remove Local Arrows Retirer les flèches locales - + Leave Game Quitter la partie - + Concede Concéder - + Roll Dice... Lancer un dé… - + + Flip Coin + + + + Shuffle Library Mélanger la bibliothèque - + Shuffle Top Cards of Library Mélanger les cartes du dessus de la bibliothèque - + Shuffle Bottom Cards of Library Mélanger les cartes du dessous de la bibliothèque - + Mulligan Mulligan - + Mulligan (Same hand size) - + Mulligan (Même nombre de cartes) - + Mulligan (Hand size - 1) - + Mulligan (-1 carte) - + Draw a Card Piocher une carte - + Draw Multiple Cards... Piocher plusieurs cartes… - + Undo Draw Annuler la dernière pioche - + Always Reveal Top Card Toujours révéler la carte du dessus - + Always Look At Top Card Toujours révéler la carte du dessus - + Sort Hand by Name - + Trier la main par nom - + Sort Hand by Type - + Trier la main par type - + Sort Hand by Mana Value - + Trier la main par Valeur de Mana - + Reveal Hand to All Players - + Révéler la main à tous les joueurs - + Reveal Random Card to All Players - + Révéler une carte aléatoire à tous les joueurs - + Rotate View Clockwise Pivoter la vue dans le sens horaire - + Rotate View Counterclockwise Pivoter la vue dans le sens anti-horaire - + Unfocus Text Box Lâcher le focus de la boîte de texte - + Focus Chat Focus sur le chat - + Clear Chat Effacer le chat - + Refresh Rafraichir - + Skip Forward Avance en avant - + Skip Backward Avant en arrière - + Skip Forward by a lot Avance beaucoup en avant - + Skip Backward by a lot Avance beaucoup en arrière - + Play/Pause Jouer/Pause - + Toggle Fast Forward Activer l'avance rapide - + Home Accueil - + Visual Deck Storage Stockage visuel de deck - + Deck Storage Stockage de deck - + Server Serveur - + Account Compte - + Administration Administration - + Logs Journaux diff --git a/cockatrice/translations/cockatrice_it.ts b/cockatrice/translations/cockatrice_it.ts index 3990d532f..e370878b9 100644 --- a/cockatrice/translations/cockatrice_it.ts +++ b/cockatrice/translations/cockatrice_it.ts @@ -2,7 +2,7 @@ AbstractCounter - + &Set counter... Imposta &contatore... @@ -10,12 +10,12 @@ AbstractCounterDialog - + Set counter Imposta contatore - + New value for counter '%1': Nuovo valore per il contatore '%1': @@ -36,62 +36,62 @@ AbstractTabDeckEditor - + Open in new tab Apri in una nuova scheda - + Are you sure? Sei sicuro? - + The decklist has been modified. Do you want to save the changes? La lista del mazzo è stata modificata. Vuoi salvare i cambiamenti? - - - - - - + + + + + + Error Errore - + Could not open deck at %1 Impossibile aprire il mazzo a %1 - + Could not save remote deck Impossibile salvare mazzo remoto - - + + The deck could not be saved. Please check that the directory is writable and try again. Il mazzo non può essere salvato. Controlla se la cartella è valida e prova ancora. - + Save deck Salva mazzo - + The deck could not be saved. Il mazzo non può essere salvato. - + There are no cards in your deck to be exported Non ci sono carte da esportare nel tuo mazzo @@ -107,12 +107,12 @@ Controlla se la cartella è valida e prova ancora. AdminNotesDialog - + Update Notes Aggiorna note - + Admin Notes for %1 Note amministratori su %1 @@ -129,172 +129,227 @@ Controlla se la cartella è valida e prova ancora. Sideboard Sideboard + + + Tokens + + AppearanceSettingsPage - + seconds secondi - + Error Errore - + Could not create themes directory at '%1'. Impossibile creare la cartella dei temi in '%1'. - + Theme settings Impostazioni temi - + Current theme: Tema attuale: - + Open themes folder Apri cartella temi - + Home tab background source: Sorgente dello sfondo della Home: - + Home tab background shuffle frequency: Frequenza di modifica dello sfondo della Home: - + Disabled Disattivato - - Display card name of background in bottom right: - Mostra nome della carta di sfondo in basso a destra - - - + Menu settings Impostazioni menù - + Show keyboard shortcuts in right-click menus Mostra scorciatoie da tastiera nel menù del tasto destro del mouse - + Show game filter toolbar above list in room tab Mostra barra di filtro sopra la lista delle partite - + Card rendering Visualizzazione delle carte - + Display card names on cards having a picture Visualizza nome delle carte sopra le immagini - + Auto-Rotate cards with sideways layout Ruota automaticamente carte con disposizione orizzontale - + Override all card art with personal set preference (Pre-ProviderID change behavior) Determina l'illustrazione della carta attraverso la priorità dei set (come prima dell'implementazione del ProviderID) - + + Light + + + + + Dark + + + + + System + + + + + Active theme palette: + + + + + Edit theme palette + + + + + Home tab settings + + + + + Display card name of background in bottom right + + + + + Styling settings + + + + + Style user list + + + + + Card printings + + + + Bump sets that the deck contains cards from to the top in the printing selector Nel selettore di stampa, mostra per primi i set delle carte che il mazzo contiene - + Scale cards on mouse over Ingrandisci la carta sotto il mouse - + Use rounded card corners Usa i bordi delle carte arrotondati - + + Card layout + + + + Minimum overlap percentage of cards on the stack and in vertical hand Sovrapposizione % minima delle carte in pila e nella mano verticale: - + Maximum initial height for card view window: Altezza iniziale massima per la finestra di visualizzazione delle carte: - - + + rows file - + Maximum expanded height for card view window: Altezza massima per la finestra di visualizzazione delle carte: - + Card counters Segnalini della carta - + Counter %1 Segnalino %1 - + Hand layout Disposizione della mano - + Display hand horizontally (wastes space) Disponi la mano orizzontalmente (spreca spazio) - + Enable left justification Allinea a sinistra - + Table grid layout Disposizione delle aree di gioco - + Invert vertical coordinate Inverti disposizione verticale - + Minimum player count for multi-column layout: Numero di giocatori minimo per disposizione multicolonna: - + Maximum font size for information displayed on cards: Dimensione massima carattere per le informazioni mostrate sulle carte: @@ -302,12 +357,12 @@ Controlla se la cartella è valida e prova ancora. ArchidektApiResponseDeckDisplayWidget - + Back to results Torna ai risultati - + Open Deck in Deck Editor Apri mazzo nell'editor dei mazzi @@ -333,112 +388,112 @@ Controlla se la cartella è valida e prova ancora. BanDialog - + ban &user name Banna &user name - + ban &IP address Banna indirizzo &IP - + ban client I&D Banna client I&D - + Ban type Tipo di ban - + &permanent ban Ban &permanente - + &temporary ban Ban &temporaneo - + &Days: &Giorni: - + &Hours: &Ore: - + &Minutes: &Minuti: - + Duration of the ban Durata del ban - + Please enter the reason for the ban. This is only saved for moderators and cannot be seen by the banned person. Per favore inserisci la ragione del ban. Questa è visibile solo ai moderatori e non alla persona bannata. - + Please enter the reason for the ban that will be visible to the banned person. Per favore inserisci la ragione del ban che sarà visibile alla persona bannata. - + Redact all messages from this user in all rooms Elimina tutti i messaggi di questo utente in tutte le stanze - + &OK &OK - + &Cancel &Annulla - + Ban user from server Banna utente dal server - - - - + + + + Error Errore - + You have to select a name-based, IP-based, clientId based, or some combination of the three to place a ban. Seleziona un nome, un IP, un client ID o una combinazione di questi per impostare un ban. - + You must have a value in the name ban when selecting the name ban checkbox. Devi inserire un valore nel campo nome quando il ban per nome è selezionato. - + You must have a value in the ip ban when selecting the ip ban checkbox. Devi inserire un valore nel campo ip quando il ban per ip è selezionato. - + You must have a value in the clientid ban when selecting the clientid ban checkbox. Devi inserire un valore nel campo clientid quando il ban per clientid è selezionato. @@ -446,59 +501,123 @@ Questa è visibile solo ai moderatori e non alla persona bannata. BetaReleaseChannel - + Beta Beta - + No reply received from the release update server. Nessuna risposta ricevuta dal server degli aggiornamenti. - + Invalid reply received from the release update server. Risposta non valida ricevuta dal server degli aggiornamenti. - + No reply received from the file update server. Nessuna risposta ricevuta dal server degli aggiornamenti. + + CardArtPreviewWidget + + + No card selected + + + + + CardArtRulesModel + + + Card + + + + + ProviderId + + + + + Mode + + + + + Reason + + + CardDatabaseModel - + Name Nome - + Sets Set - + Mana cost Costo - + Card type Tipo - + P/T F/C - + Color(s) Colore + + CardDatabaseView + + + Add to Deck + + + + + Add to Sideboard + + + + + Select Printing + + + + + Show on EDHRec (Commander) + + + + + Show on EDHRec (Card) + + + + + Show Related cards + + + CardFilter @@ -627,22 +746,22 @@ Questa è visibile solo ai moderatori e non alla persona bannata. CardInfoPictureWidget - + View related cards Guarda carte correlate - + Add card to deck Aggiungi la carta al &mazzo - + Mainboard Mazzo - + Sideboard Sideboard @@ -665,12 +784,12 @@ Questa è visibile solo ai moderatori e non alla persona bannata. Numero di collezione: - + Related cards: Carte correlate: - + Unknown card: Carta sconosciuta: @@ -678,128 +797,146 @@ Questa è visibile solo ai moderatori e non alla persona bannata. CardMenu - + Re&veal to... Ri&vela a... - + &All players &Tutti i giocatori - + View related cards Guarda carte correlate - + Token: Pedina: - + All tokens Tutte le pedine - + &Select All &Seleziona tutto - + S&elect Row S&eleziona fila - + S&elect Column S&eleziona colonna - + &Play &Gioca - + &Hide &Nascondi - + Play &Face Down Gioca a &faccia in giù - + &Tap / Untap Turn sideways or back again &TAPpa/STAPpa - + Skip &untapping Salta lo &STAP - + T&urn Over Turn face up/face down Capovolgi - + &Peek at card face &Sbircia la faccia della carta - + &Clone &Copia - + Attac&h to card... Asse&gna alla carta... - + Unattac&h Tog&li - + &Draw arrow... &Disegna una freccia... - + &Set annotation... &Imposta note... - + + Reduce life by power + + + + Ca&rd counters Segnalini delle ca&rte - + &Add counter (%1) &Aggiungi segnalino (%1) - + &Remove counter (%1) &Rimuovi segnalino (%1) - + &Set counters (%1)... Imposta &segnalini (%1)... + + CardPictureLoaderCacheMethod + + + Network Cache + + + + + Filesystem + + + CardSizeWidget @@ -811,133 +948,133 @@ Questa è visibile solo ai moderatori e non alla persona bannata. CardZoneLogic - + their hand nominative la sua mano - + %1's hand nominative Mano di %1 - + their library look at zone il suo grimorio - + %1's library look at zone Grimorio di %1 - + of their library top cards of zone, del suo grimorio - + of %1's library top cards of zone del grimorio di %1 - + their library reveal zone il suo grimorio - + %1's library reveal zone Grimorio di %1 - + their library shuffle il suo grimorio - + %1's library shuffle Grimorio di %1 - + their library nominative il suo grimorio - + %1's library nominative Grimorio di %1 - + their graveyard nominative il suo cimitero - + %1's graveyard nominative Cimitero di %1 - + their exile nominative la sua zona di esilio - + %1's exile nominative Esilio di %1 - + their sideboard look at zone la sua sideboard - + %1's sideboard look at zone Sideboard di %1 - + their sideboard nominative la sua sideboard - + %1's sideboard nominative Sideboard di %1 - + their custom zone '%1' nominative la sua zona personalizzata '%1' - + %1's custom zone '%2' nominative la zona personalizzata '%2' di %1 @@ -959,16 +1096,421 @@ Questa è visibile solo ai moderatori e non alla persona bannata. Errore di lettura alla riga %1 posizione %2: + + ColorButton + + + Click to pick a color + + + + + Pick colour + + + + + ConnectionController + + + + The server has reached its maximum user capacity, please check back later. + + + + + There are too many concurrent connections from your address. + + + + + Banned by moderator + + + + + Expected end time: %1 + + + + + This ban lasts indefinitely. + + + + + Scheduled server shutdown. + + + + + + Invalid username. + + + + + You have been logged out due to logging in at another location. + + + + + Connection closed + + + + + The server has terminated your connection. +Reason: %1 + + + + + The server is going to be restarted in %n minute(s). +All running games will be lost. +Reason for shutdown: %1 + + + + + Scheduled server shutdown + + + + + Failed Login + + + + + Your client seems to be missing features this server requires for connection. + + + + + To update your client, go to 'Help -> Check for Client Updates'. + + + + + + + + + + + + + + + + + + + + + + Error + + + + + Incorrect username or password. Please check your authentication information and try again. + + + + + There is already an active session using this user name. +Please close that session first and re-login. + + + + + + You are banned until %1. + + + + + + You are banned indefinitely. + + + + + This server requires user registration. Do you want to register now? + + + + + This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. +Please close and reopen your client to try again. + + + + + An internal error has occurred, please close and reopen Cockatrice before trying again. +If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. + + + + + Account activation + + + + + Your account has not been activated yet. +You need to provide the activation token received in the activation email. + + + + + Server Full + + + + + Unknown login error: %1 + + + + + + +This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. + + + + + + + + + + Registration denied + + + + + Registration is currently disabled on this server + + + + + There is already an existing account with the same user name. + + + + + It's mandatory to specify a valid email address when registering. + + + + + It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. + + + + + Password too short. + + + + + Registration failed for a technical problem on the server. + + + + + The connection to the server has been lost. + + + + + Unknown registration error: %1 + + + + + Account activation failed + + + + + Socket error: %1 + + + + + Server timeout + + + + + You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. +Local version is %1, remote version is %2. + + + + + Your Cockatrice client is obsolete. Please update your Cockatrice version. +Local version is %1, remote version is %2. + + + + + + Success + + + + + Registration accepted. +Will now login. + + + + + Account activation accepted. +Will now login. + + + + + Information + + + + + This server supports additional features that your client doesn't have. +This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. + +To update your client, go to Help -> Check for Updates. + + + + + + + Reset Password + + + + + Your password has been reset successfully, you can now log in using the new credentials. + + + + + Failed to reset user account password, please contact the server operator to reset your password. + + + + + Activation request received, please check your email for an activation token. + + + + + Connecting to %1... + + + + + Registering to %1 as %2... + + + + + Disconnected + + + + + Connected, logging in at %1 + + + + + Requesting forgotten password to %1 as %2... + + + + + Your username must respect these rules: + + + + + is %1 - %2 characters long + + + + + can %1 contain lowercase characters + + + + + + + + NOT + + + + + can %1 contain uppercase characters + + + + + can %1 contain numeric characters + + + + + can contain the following punctuation: %1 + + + + + first character can %1 be a punctuation mark + + + + + no unacceptable language as specified by these server rules: + note that the following lines will not be translated + + + + + can not contain any of the following words: %1 + + + + + can not match any of the following expressions: %1 + + + + + You may only use A-Z, a-z, 0-9, _, ., and - in your username. + + + CustomZoneMenu - + C&ustom Zones &Zone personalizzate - - + + View custom zone '%1' Visualizza la zona personalizzata '%1' @@ -976,30 +1518,35 @@ Questa è visibile solo ai moderatori e non alla persona bannata. DeckAnalyticsWidget - + Add Panel Aggiungi pannello - + Remove Panel Rimuovi pannello - + Save Layout Salva disposizione - + Load Layout Carica disposizione + + + Include Sideboard + + DeckEditorCardDatabaseDockWidget - + Card Database Database carte @@ -1015,47 +1562,17 @@ Questa è visibile solo ai moderatori e non alla persona bannata. DeckEditorDatabaseDisplayWidget - + Search by card name (or search expressions) Cerca per nome della carta (o espressioni di ricerca) - - Add to Deck - Aggiungi al mazzo - - - - Add to Sideboard - Aggiungi alla sideboard - - - - Select Printing - Seleziona stampa - - - - Show on EDHRec (Commander) - Mostra su EDHRec (Commander) - - - - Show on EDHRec (Card) - Mostra su EDHRec (Carta) - - - - Show Related cards - Mostra carte correlate - - - + Add card to &maindeck Aggiungi carta al &mazzo - + Add card to &sideboard Aggiungi carta alla &sideboard @@ -1088,72 +1605,72 @@ Questa è visibile solo ai moderatori e non alla persona bannata. Colori - + Select Printing Seleziona stampa - + Deck Mazzo - + Deck &name: &Nome mazzo: - + Banner Card/Tags Visibility Settings Impostazioni carta copertina/etichette - + Show banner card selection menu Visualizza menu di selezione carta copertina - + Show tags selection menu Visualizza menu di selezione etichette - + &Comments: &Commenti: - + Group by: Raggruppa per: - + Format: Formato: - + Hash: Hash: - + &Increment number &Aumenta il numero - + &Decrement number &Diminuisci il numero - + &Remove row &Rimuovi carta - + Swap card to/from sideboard Sposta carta in maindeck/sideboard @@ -1161,17 +1678,17 @@ Questa è visibile solo ai moderatori e non alla persona bannata. DeckEditorFilterDockWidget - + Filters Filtri - + &Clear all filters &Elimina tutti i filtri - + Delete selected Elimina filtro @@ -1302,169 +1819,113 @@ Questa è visibile solo ai moderatori e non alla persona bannata. DeckEditorSettingsPage - - + + Update Spoilers Aggiorna Spoiler - - + Success Fatto - + Download URLs have been reset. Gli indirizzi di download sono stati resettati. - - Downloaded card pictures have been reset. - Le immagini delle carte scaricate sono state eliminate. - - - - Error - Errore - - - - One or more downloaded card pictures could not be cleared. - Non è stato possibile eliminare alcune delle immagini delle carte scaricate. - - - + Add URL Aggiungi indirizzo - - + + URL: Indirizzo: - - + + Edit URL Modifica indirizzo: - - Network Cache Size: - Dimensione cache di rete: - - - - Redirect Cache TTL: - TTL cache dei reindirizzamenti: - - - - How long cached redirects for urls are valid for. - Per quanto tempo sono validi i reindirizzamenti per gli URL memorizzati nella cache. - - - - Picture Cache Size: - Dimensione cache immagini: - - - + Add New URL Aggiungi indirizzo URL - + Remove URL Elimina indirizzo URL - - Day(s) - Giorno/i - - - + Updating... Aggiornando... - + Choose path Scegli il percorso - + URL Download Priority Ordine di priorità degli indirizzi - + Spoilers Spoiler - + Download Spoilers Automatically Scarica spoiler automaticamente - + Spoiler Location: Indirizzo spoiler: - + Last Change Ultima modifica - + Spoilers download automatically on launch Scarica spoiler automaticamente all'avvio - + Press the button to manually update without relaunching Premi il pulsante per aggiornare manualmente senza riavviare - + Do not close settings until manual update is complete Non chiudere le impostazioni fino a che l'aggiornamento manuale sia completato - + Download card pictures on the fly Scarica immagini delle carte in tempo reale - + How to add a custom URL Come aggiungere indirizzi personalizzati - - Delete Downloaded Images - Elimina immagini scaricate - - - + Reset Download URLs Resetta indirizzi di download - - - On-disk cache for downloaded pictures - Cache su disco immagini scaricate - - - - In-memory cache for pictures not currently on screen - Cache in memoria per immagini non attualmente su schermo - DeckListHistoryManagerWidget @@ -1530,12 +1991,12 @@ Questa è visibile solo ai moderatori e non alla persona bannata. DeckLoader - + Common deck formats (%1) Formati di mazzo comuni (%1) - + All files (*.*) Tutti i file (*.*) @@ -1757,27 +2218,27 @@ Questa è visibile solo ai moderatori e non alla persona bannata. Aggiunto (%1): %2 (%3) %4 - + Moved to %1 1 × "%2" (%3) Spostato in %1 1 × "%2" (%3) - + Removed "%1" (all copies) Rimosso "%1" (tutte le copie) - + %1 1 × "%2" (%3) %1 1 × "%2" (%3) - + Added Aggiunto - + Removed Rimosso @@ -1799,75 +2260,75 @@ Questa è visibile solo ai moderatori e non alla persona bannata. DeckViewContainer - + Load deck... Carica mazzo... - + Load remote deck... Carica mazzo remoto... - + Load from clipboard... Carica dagli appunti... - + Load from website... Carica dal sito... - + Unload deck Deseleziona mazzo - + Ready to start Pronto ad iniziare - + Force start Forza avvio - + Sideboard unlocked Sideboard sbloccata - + Sideboard locked Sideboard bloccata - - + + Error Errore - + The selected file could not be loaded. I file selezionati non posso essere caricati. - + Deck is greater than maximum file size. Il mazzo è più grande della dimensione massima del file consentita. - + Are you sure you want to force start? This will kick all non-ready players from the game. Sicuro di voler forzare l'avvio? Ciò espellerà dalla partita tutti i giocatori che non sono pronti. - + Cockatrice Cockatrice @@ -1968,7 +2429,7 @@ Vuoi convertire il mazzo al formato .cod? - + Webpage Sito web @@ -2008,37 +2469,37 @@ Vuoi convertire il mazzo al formato .cod? Connetti al server - + Server URL URL Server - + Communication Port Numero porta - + Unique Server Name Nome univoco del server - + Connection Warning Avviso di connessione - + You need to name your new connection profile. È necessario impostare un nome per il nuovo profilo di connessione. - + Connect Warning Problema di connessione - + The player name can't be empty. Il nome del giocatore non può essere vuoto. @@ -2151,17 +2612,17 @@ Vuoi convertire il mazzo al formato .cod? Crea partita - + Game information Informazioni partita - + Error Errore - + Server error. Errore del server. @@ -2169,97 +2630,97 @@ Vuoi convertire il mazzo al formato .cod? DlgCreateToken - + &Name: &Nome: - + Token Pedina - + C&olor: C&olore: - + white bianco - + blue blu - + black nero - + red rosso - + green verde - + multicolor multicolore - + colorless incolore - + &P/T: &F/C: - + &Annotation: &Note: - + &Destroy token when it leaves the table &Elimina la pedina quando lascia il campo - + Create face-down (Only hides name) Crea a faccia in giù (nasconde solo il nome) - + Token data Dati pedina - + Show &all tokens Mostra &tutte le pedine - + Show tokens from this &deck Mostra pedine di questo &mazzo - + Choose token from list Scegli pedina dalla lista - + Create token Crea pedina @@ -2293,7 +2754,7 @@ Vuoi convertire il mazzo al formato .cod? - + @@ -2308,12 +2769,12 @@ Vuoi convertire il mazzo al formato .cod? Il nome dell'etichetta non può essere vuoto! - + Duplicate Tag Duplica Etichetta - + This tag already exists. Questa etichetta esiste già. @@ -2500,12 +2961,12 @@ Per rimuovere il tuo avatar attuale, conferma senza scegliere una nuova immagine Inserisci il nome della pedina: - + Error Errore - + The chosen name conflicts with an existing card or token. Make sure to enable the 'Token' set in the "Manage sets" dialog to display them correctly. Il nome scelto è in conflitto con una carta o pedina esistente. @@ -2530,12 +2991,12 @@ Assicurati di abilitare il set "Pedine" nella finestra "Gestisci Non definito - + Real name: Nome reale: - + Edit user profile Modifica profilo utente @@ -2573,113 +3034,113 @@ Assicurati di abilitare il set "Pedine" nella finestra "Gestisci 2 ore - + Hide 'buddies only' games Nascondi partite 'solo per amici' - + Hide full games Nascondi partite al completo - + Hide games that have started Nascondi partite iniziate - + Hide password protected games Nascondi partite protette da password - + Hide 'ignored user' games Nascondi partite degli utenti ignorati - + Hide games not created by buddies Hide games not created by buddy Nascondi partite non create dagli amici - + Hide games with forced open decklists Nascondi partite con liste di mazzo rivelate - + &Newer than: &Più nuovo di: - + Game &description: &Descrizione partita: - + &Creator name: Nome &creatore: - + General Generale - + &Game types Tipi di &partita - + at &least: Minimo: - + at &most: Massimo: - + Maximum player count Numero di giocatori - + Restrictions Restrizioni - + Show games only if &spectators can watch Mostra le partite solo se &gli spettatori possono guardare - + Show spectator password p&rotected games Mostra partite protette da password - + Show only if spectators can ch&at Mostra le partite solo se gli spettatori possono guardare - + Show only if spectators can see &hands Mostra le partite solo se gli spettatori possono guardare le mani - + Spectators Spettatori - + Filter games Filtro partite @@ -2990,37 +3451,37 @@ https://tappedout.net/mtg-decks/nome-del-tuo-mazzo/ DlgMoveTopCardsUntil - + Card name (or search expressions): Nome della carta (o ricerca espressioni): - + Number of hits: Numero di risultati: - + Auto play hits Riproduzione automatica dei successi - + Put top cards on stack until... Metti le carte in cima alla pila fino a... - + No cards matching the search expression exists in the card database. Proceed anyways? Non esiste alcuna carta corrispondente agli estremi di ricerca nel database. Procedere comunque? - + Cockatrice Cockatrice - + Invalid filter Filtro non valido @@ -3080,40 +3541,40 @@ La tua email verrà utilizzata per verificare il tuo account. Non definito - + Real name: Nome reale: - + Register to server Registrati sul server - - - - + + + + Registration Warning Avviso Registrazione - + Your password is too short. La tua password è troppo corta. - + Your passwords do not match, please try again. Le password non combaciano, riprova. - + Your email addresses do not match, please try again. Gli indirizzi email non combaciano, riprova. - + The player name can't be empty. Il nome del giocatore non può essere vuoto. @@ -3121,17 +3582,17 @@ La tua email verrà utilizzata per verificare il tuo account. DlgRollDice - + Number of sides: Numero di facce: - + Number of dice: Numero di dadi: - + Roll Dice Lancia dado @@ -3182,12 +3643,12 @@ La tua email verrà utilizzata per verificare il tuo account. DlgSettings - + Unknown Error loading card database Errore sconosciuto durante il caricamento del database delle carte - + Your card database is invalid. Cockatrice may not function correctly with an invalid database @@ -3204,7 +3665,7 @@ Ti consigliamo di avviare oracle per aggiornare il tuo database delle carte. Vuoi modificare le impostazioni della posizione del database della carte? - + Your card database version is too old. This can cause problems loading card information or images @@ -3221,7 +3682,7 @@ Ti consigliamo di avviare oracle per aggiornare il tuo database delle carte. Vuoi modificare le impostazioni della posizione del database della carte? - + Your card database did not finish loading Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues with your cards.xml attached @@ -3233,7 +3694,7 @@ Per favore crea un ticket di assistenza su https://github.com/Cockatrice/Cockatr Desideri modificare l'impostazione della posizione del database? - + File Error loading your card database. Would you like to change your database location setting? @@ -3242,7 +3703,7 @@ Would you like to change your database location setting? Vuoi modificare le impostazioni della posizione del database della carte? - + Your card database was loaded but contains no cards. Would you like to change your database location setting? @@ -3251,7 +3712,7 @@ Would you like to change your database location setting? Vuoi modificare le impostazioni della posizione del database della carte? - + Unknown card database load status Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues @@ -3264,59 +3725,64 @@ https://github.com/Cockatrice/Cockatrice/issues Desideri modificare l'impostazione della posizione del database? - - - + + + Error Errore - + The path to your deck directory is invalid. Would you like to go back and set the correct path? Il percorso della cartella del mazzo non è valido. Vuoi tornare in dietro e impostare il percorso corretto? - + The path to your card pictures directory is invalid. Would you like to go back and set the correct path? Il percorso della cartella delle immagini delle carte è invilido. Vuoi tornare indietro e impostare il percorso corretto? - + Settings Impostazioni - + General Generale - + Appearance Aspetto - + User Interface Interfaccia - + + Storage + + + + Card Sources Immagini carte - + Chat Chat - + Sound Suoni - + Shortcuts Scorciatoie @@ -3410,9 +3876,9 @@ Potrai comunque cambiarla in seguito dalle impostazioni, nella scheda 'Gene - - - + + + Error Errore @@ -3466,31 +3932,31 @@ Per favore, visitate la pagina di download per aggiornare manualmente. - + Update Available Aggiornamento disponibile - + A new version of Cockatrice is available! Una nuova versione di Cockatrice è disponibile! - + New version Nuova versione - + Released Data di rilascio - + Changelog Changelog @@ -3500,51 +3966,51 @@ Per favore, visitate la pagina di download per aggiornare manualmente.Vuoi aggiornare adesso? - + Unfortunately, the automatic updater failed to find a compatible download. You may have to manually download the new version. Purtroppo l'aggiornamento automatico non è riuscito a trovare una versione compatibile. Dovrai scaricare la nuova versione manualmente. - + Please check the <a href="%1">releases page</a> on our Github and download the build for your system. Per favore, controlla la <a href="%1">pagina delle release</a> sul nostro Github e scarica la versione giusta per il tuo sistema. - - - + + + Update Error Errore durante l'aggiornamento - + An error occurred while checking for updates: Errore durante il controllo degli aggiornamenti: - + An error occurred while downloading an update: Errore durante il download di un aggiornamento: - + Installing... Installazione... - + Cockatrice is unable to open the installer. Cockatrice non riesce ad aprire il file di installazione. - + Try to update manually by closing Cockatrice and running the installer. Prova ad aggiornare manualmente chiudendo Cockatrice ed eseguento il file di installazione. - + Download location Percorso download @@ -3634,67 +4100,67 @@ Dovrai scaricare la nuova versione manualmente. DrawProbabilityWidget - + Draw Probability Probabilità di pescare - + Probability of drawing Probabilità di pescare - + Card Name Nome carta - + Type Tipo - + Subtype Sottotipo - + Mana Value Valore di mana - + At least Almeno - + Exactly Esattamente - + card(s) having drawn at least carta/e avendo pescato almeno - + cards carte - + Category Categoria - + Qty Quantità - + Odds (%) Probabilità (%) @@ -3808,7 +4274,7 @@ Dovrai scaricare la nuova versione manualmente. FilterBuilder - + Type your filter here Scrivi il filtro qui @@ -3839,22 +4305,22 @@ Dovrai scaricare la nuova versione manualmente. GameEventHandler - + kicked by game host or moderator espulso da proprietario del gioco o moderatore - + player left the game il giocatore ha lasciato la partita - + player disconnected from server il giocatore si è disconnesso dal server - + reason unknown ragione sconosciuta @@ -3862,140 +4328,140 @@ Dovrai scaricare la nuova versione manualmente. GameSelector - - - - - - - - - + + + + + + + + + Error Errore - + Please join the appropriate room first. Si prega di entrare prima in una stanza adeguata. - + Wrong password. Password errata. - + Spectators are not allowed in this game. Spettatori non ammessi in questa partita. - + The game is already full. La partita è piena. - + The game does not exist any more. Questa partita non esiste più. - + This game is only open to registered users. Questa partita è solo per utenti registrati. - + This game is only open to its creator's buddies. Questa stanza è aperta solo agli amici del suo creatore. - + You are being ignored by the creator of this game. Sei stato ingnorato dal creatore di questa partita. - + Join Game Entra nella partita - + Spectate Game Osserva partita - + Game Information Informazioni partita - + Join Game as Judge Unisciti a una partita come arbitro - + Spectate Game as Judge Osserva partita come arbitro - + Join game Entra nella partita - + Password: Password: - + Please join the respective room first. Si prega di entrare prima nella rispettiva stanza. - + &Filter games &Filtra partite - + C&lear filter E&limina filtri - + C&reate Cr&ea - + &Join &Entra - + Join as judge Unisciti come arbitro - + J&oin as spectator Entra c&ome spettatore - + Join as judge spectator Unisciti come arbitro spettatore - + Games shown: %1 / %2 Partite mostrate: %1 / %2 - + Games Partite @@ -4003,32 +4469,32 @@ Dovrai scaricare la nuova versione manualmente. GameSelectorQuickFilterToolBar - + All types Tutti i tipi - + Filter by game name... Filtra per nome della partita... - + Filter by game type/format Filtra per tipo / formato di partita - + Hide games not created by buddies Nascondi le partite non create da amici - + Hide full games Nascondi partite al completo - + Hide started games Nascondi partite iniziate @@ -4036,105 +4502,105 @@ Dovrai scaricare la nuova versione manualmente. GamesModel - + >1 day >1 giorno - + %1%2 hr short age in hours %1%2 ora%1%2 ore%1%2 ora(e) - + new nuovo - + %1%2 min short age in minutes %1%2 min%1%2 min%1%2 min - + password password - + buddies only solo amici - + reg. users only solo utenti registrati - + open decklists Apri impostazioni - + can chat può chattare - + see hands vede mani - + can see hands può vedere mani - + not allowed non ammessi - + Room Stanza - + Age Età - + Description Descrizione - + Creator Creatore - + Type Tipo - + Restrictions Restrizioni - + Players Giocatori - + Spectators Spettatori @@ -4142,143 +4608,158 @@ Dovrai scaricare la nuova versione manualmente. GeneralSettingsPage - + Reset all paths Reimposta tutti i percorsi - + All paths have been reset I percorsi sono stati resettati - - - - - - - + + + + + + + Choose path Seleziona il percorso - - Personal settings - Impostazioni personali + + Language settings + - + Language: Lingua: - + + Version settings + + + + + Card database + + + + + Startup settings + + + + Paths (editing disabled in portable mode) Destinazioni (non si può personalizzare in modalità portatile) - + Paths Percorsi - + How to help with translations Come aiutare con le traduzioni - + Decks directory: Cartella mazzi: - + Filters directory: Cartella filtri: - + Replays directory: Cartella replay: - + Pictures directory: Cartella immagini: - + Card database: Database carte: - + Custom database directory: Cartella database personalizzata: - + Token database: Database pedine: - + Update channel Canale di aggiornamento: - + Check for client updates on startup Controlla gli aggiornamenti del client all'avvio - + Check for card database updates on startup Controlla gli aggiornamenti delle carte all'avvio - + Don't check Non controllare - + Prompt for update Chiedi se aggiornare - + Always update in the background Aggiorna sempre in background - + Check for card database updates every Controlla gli aggiornamenti delle carte ogni - + days giorni - + Notify if a feature supported by the server is missing in my client Avvisami se una funzionalità supportata dal server manca nel mio programma - + Automatically run Oracle when running a new version of Cockatrice Avvia automaticamente Oracle se Cockatrice è stato aggiornato - + Show tips on startup Mostra suggerimenti all'avvio - + Last update check on %1 (%2 days ago) Ultimo controllo %1 (%2 giorni fa) @@ -4286,47 +4767,47 @@ Dovrai scaricare la nuova versione manualmente. GraveyardMenu - + &Graveyard &Cimitero - + &View graveyard &Guarda il cimitero - + &Move graveyard to... &Muovi cimitero in... - + &Top of library &Cima al grimorio - + &Bottom of library &Fondo al grimorio - + &All players &Tutti i giocatori - + &Hand &Mano - + &Exile &Esilio - + Reveal random card to... Rivela carta casuale a... @@ -4334,88 +4815,88 @@ Dovrai scaricare la nuova versione manualmente. HandMenu - + &Hand &Mano - + &View hand &Vedi mano - + Sort hand by... Ordina mano per... - + Name Nome - + Type Tipo - + Mana Value Valore di mana - + Take &mulligan (Choose hand size) Effettua un &mulligan (Scegli numero di carte) - + Take mulligan (Same hand size) Effettua un mulligan (Stessa dimensione della mano) - + Take mulligan (Hand size - 1) Effettua un mulligan (Dimensione della mano - 1) - + &Move hand to... &Sposta mano in... - + &Top of library &Cima al grimorio - + &Bottom of library &Fondo al grimorio - + &Graveyard &Cimitero - + &Exile &Esilio - + &Reveal hand to... &Rivela mano a... - - + + All players Tutti i giocatori - + Reveal r&andom card to... Rivela carta c&asuale a... @@ -4423,52 +4904,52 @@ Dovrai scaricare la nuova versione manualmente. HomeWidget - + Create New Deck Crea un nuovo mazzo - + Browse Decks Esplora i mazzi - + Browse Card Database Esplora il database delle carte - + Browse EDHRec Esplora EDHRec - + Browse Archidekt Esplora Archidekt - + View Replays Guarda i replay - + Quit Esci - + Connecting... Connessione in corso... - + Connect Connetti - + Play Gioca @@ -4476,213 +4957,213 @@ Dovrai scaricare la nuova versione manualmente. LibraryMenu - + &Library &Grimorio - + &View library &Guarda il grimorio - + View &top cards of library... Guarda &le carte in cima al grimorio... - + View bottom cards of library... Guarda le carte in fondo al grimorio... - + Reveal &library to... Rive&la grimorio a... - + Lend library to... Presta il grimorio a... - + Reveal &top cards to... Rivela le &prime carte a... - + &Top of library... &Dalla cima del grimorio... - + &Bottom of library... &Dal fondo del grimorio... - + &Always reveal top card Rivela &sempre la prima carta - + &Always look at top card &Guarda sempre la prima carta - + &Open deck in deck editor &Apri il mazzo nell'editor dei mazzi - + &Draw card &Pesca una carta - + D&raw cards... P&esca carte... - + &Undo last draw &Annulla l'ultima pescata - + Shuffle Mescola - + &Play top card &Gioca la prima carta - + Play top card &face down Gioca la prima carta a &faccia in giù - + Put top card on &bottom Metti la prima carta in fondo - + Move top card to grave&yard Metti la prima carta nel c&imitero - + Move top card to e&xile E&silia la prima carta - + Move top cards to &graveyard... Metti le prime carte nel &cimitero... - + Move top cards to graveyard face down... Metti le prime carte nel cimitero a faccia in giù... - + Move top cards to &exile... &Esilia le prime carte... - + Move top cards to exile face down... Metti le prime carte in esilio a faccia in giù... - + Put top cards on stack &until... Metti le carte in cima alla pila fino a... - + Shuffle top cards... Mescola le carte in cima... - + &Draw bottom card &Pesca l'ultima carta dal fondo - + D&raw bottom cards... P&esca carte dal fondo... - + &Play bottom card &Gioca la carta in fondo - + Play bottom card &face down Gioca la carta in fondo a &faccia in giù - + Move bottom card to grave&yard Metti l'ultima carta nel c&imitero - + Move bottom card to e&xile Metti l'ultima carta in esilio - + Move bottom cards to &graveyard... Metti le ultime carte nel cimitero... - + Move bottom cards to graveyard face down... Metti le ultime carte nel cimitero a faccia in giù... - + Move bottom cards to &exile... &Esilia le ultime carte... - + Move bottom cards to exile face down... Metti le ultime carte in esilio a faccia in giù... - + Put bottom card on &top Metti l'ultima carta in cima - + Shuffle bottom cards... Mescola le carte in fondo... - - + + &All players &Tutti i giocatori - + Reveal top cards of library Rivela le carte in cima al grimorio - + Number of cards: (max. %1) Numero di carte: (max. %1) @@ -4690,641 +5171,273 @@ Dovrai scaricare la nuova versione manualmente. MainWindow - - - The server has reached its maximum user capacity, please check back later. - Il server ha raggiunto la sua massima capacità di utenti, riprova più tardi. - - - - There are too many concurrent connections from your address. - Ci sono troppe connessioni contemporanee dal tuo indirizzo. - - - - Banned by moderator - Bannato dal moderatore - - - - Expected end time: %1 - Fine prevista: %1 - - - - This ban lasts indefinitely. - Questo ban dura a tempo indeterminato. - - - - Scheduled server shutdown. - Spegnimento del server in programma. - - - - - Invalid username. - Nome utente non valido. - - - - You have been logged out due to logging in at another location. - Sei stato disconnesso: ti sei connesso da un'altra postazione. - - - - Connection closed - Connessione chiusa - - - - The server has terminated your connection. -Reason: %1 - Hai perso la connessione con il server. -Ragione: %1 - - - - The server is going to be restarted in %n minute(s). -All running games will be lost. -Reason for shutdown: %1 - Il server si riavvierà in un minuto. -Tutti le partite saranno perse. -Ragione dello spegnimento: %1Il server si riavvierà in %n minuti. -Tutti le partite saranno perse. -Ragione dello spegnimento: %1Il server si riavvierà in %n minuti. -Tutti le partite saranno perse. -Ragione dello spegnimento: %1 - - - - Scheduled server shutdown - Spegnimento del server in programma - - - - - Success - Successo - - - - Registration accepted. -Will now login. - Registrazione accettata. -Login in corso. - - - - Account activation accepted. -Will now login. - Attivazione account accettata. -Login in corso. - - - - + + Player %1 Giocatore %1 - + Load replay Carica replay - + About Cockatrice Info su Cockatrice - + Version Versione - + Cockatrice Webpage Sito di Cockatrice - + Project Manager: Manager del progetto: - + Past Project Managers: Manager precedenti: - + Developers: Sviluppatori: - + Our Developers I nostri sviluppatori - + Help Develop! Aiutaci nello sviluppo! - + Translators: Traduttori: - + Our Translators I nostri traduttori - + Help Translate! Aiutaci nella traduzione! - + Support: Supporto: - + Report an Issue Segnala un problema - + Troubleshooting Risoluzione dei problemi - + F.A.Q. F.A.Q. - - - - - - - - - - - - - - - - - - - - + + Error Errore - - Server timeout - Timeout del server - - - - Failed Login - Login fallito - - - - Your client seems to be missing features this server requires for connection. - La tua versione del programma non supporta funzionalità richieste dal server, aggiorna il programma e riprova. - - - - To update your client, go to 'Help -> Check for Client Updates'. - Per aggiornare la tua versione di Cockatrice, vai su 'Aiuto -> Controlla aggiornamenti client' - - - - Incorrect username or password. Please check your authentication information and try again. - Nome utente o password non validi. Ricontrolla i tuoi dati di accesso e riprova. - - - - There is already an active session using this user name. -Please close that session first and re-login. - Sei già loggato in un'altra sessione con questo username. -Chiudi prima quella sessione e riprova a loggare. - - - - - You are banned until %1. - Sei bannato fino a %1. - - - - - You are banned indefinitely. - Sei stato bannato per tempo indeterminato. - - - - This server requires user registration. Do you want to register now? - Questo server richiede la registrazione degli utenti. Vuoi registrarti adesso? - - - - This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. -Please close and reopen your client to try again. - Il server richiede un client ID. Il tuo programma non ha generato un client ID. -Riprova dopo aver chiuso e riaperto il programma. - - - - An internal error has occurred, please close and reopen Cockatrice before trying again. -If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. - Si è verificato un errore, riprova dopo aver chiuso e riaperto il programma. -Se l'errore persiste, aggiorna il programma all'ultima versione e se necessario chiedi assistenza agli sviluppatori. - - - - Account activation - Attivazione account - - - - Your account has not been activated yet. -You need to provide the activation token received in the activation email. - Il tuo account non è ancora stato attivato. -Per attivarlo inserisci il codice di attivazione ricevuto tramite email, - - - - Server Full - Server pieno - - - - Unknown login error: %1 - Errore di login sconosciuto: %1 - - - - - -This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. - -Solitamente questo significa che stai usando una vecchia versione del programma, e il server ha inviato un messaggio che il tuo programma non è in grado di comprendere. - - - - Your username must respect these rules: - Il nome utente deve seguire queste regole: - - - - is %1 - %2 characters long - essere lungo %1 - %2 caratteri - - - - can %1 contain lowercase characters - %1 può contenere caratteri minuscoli - - - - - - - NOT - NON - - - - can %1 contain uppercase characters - %1 può contenere caratteri maiuscoli - - - - can %1 contain numeric characters - %1 può contenere numeri - - - - can contain the following punctuation: %1 - può contenere la punteggiatura: %1 - - - - first character can %1 be a punctuation mark - il primo carattere %1 può essere punteggiatura - - - - no unacceptable language as specified by these server rules: - note that the following lines will not be translated - non usare un linguaggio inaccettabile, come specificato nelle regole del server: - - - - can not contain any of the following words: %1 - non può contenere le seguenti parole: %1 - - - - can not match any of the following expressions: %1 - non può corrispondere alle seguenti espressioni: %1 - - - - You may only use A-Z, a-z, 0-9, _, ., and - in your username. - Puoi usare solo i caratteri A-Z, a-z, 0-9, _, ., e - nel tuo nome utente. - - - - - - - - - Registration denied - Registrazione negata - - - - Registration is currently disabled on this server - La registrazione è disabilitata su questo server - - - - There is already an existing account with the same user name. - Esiste già un account con lo stesso nome utente. - - - - It's mandatory to specify a valid email address when registering. - E' obbligatorio specificare un indirizzo email valido per la registrazione. - - - - It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. - Sembra che tu stia tentando di registrare un nuovo account su questo server ma hai già un account registrato sull'indirizzo email fornito. Questo server restringe il numero di account un utente più avere per indirizzo. Per favore contatta l'amministratore del server per ulteriore assistenza o per ottenere le tue credenziali di accesso. - - - - Password too short. - Password troppo corta. - - - - Registration failed for a technical problem on the server. - La registrazione è fallita a causa di un problema tecnico sul server. - - - - The connection to the server has been lost. - La connessione al server è stata interrotta. - - - - Unknown registration error: %1 - Errore di registrazione sconosciuto: %1 - - - - Account activation failed - Attivazione account fallita - - - - Socket error: %1 - Errore nella connessione: %1 - - - - You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. -Local version is %1, remote version is %2. - Stai cercando di connetterti a un server obsoleto. Declassa la versione di Cockatrice per farlo funzionare o connetti ad un altro server. -La tua versione è la %1, la versione remota è la %2. - - - - Your Cockatrice client is obsolete. Please update your Cockatrice version. -Local version is %1, remote version is %2. - La tua versione di Cockatrice è obsoleta. Aggiorna la tua versione di Cockatrice. -La tua versione è la %1, la versione online è la %2. - - - - Connecting to %1... - Connessione a %1... - - - - Registering to %1 as %2... - Registrazione su %1 come %2... - - - - Disconnected - Disconnesso - - - - Connected, logging in at %1 - Connesso, login in corso su %1 - - - - - - Requesting forgotten password to %1 as %2... - Richiesta recupero password su %1 come %2... - - - + &Connect... &Connetti... - + &Disconnect &Disconnetti - + Start &local game... Inizia partita in &locale... - + &Watch replay... &Guarda replay... - + &Full screen &Schermo intero - + &Register to server... &Registrati sul server... - + &Restore password... Recupera &password... - + &Settings... &Impostazioni... - + &Exit &Esci - + A&ctions A&zioni - + &Cockatrice &Cockatrice - + C&ard Database &Database carte - + &Manage sets... &Gestisci Espansioni... - + Edit custom &tokens... Modifica pedine personalizzate - + Open custom image folder Apri cartella immagini personalizzate - + Open custom sets folder Apri cartella set personalizzati - + Add custom sets/cards Aggiungi set di carte personalizzato - + Reload card database Ricarica database carte - + Tabs &Schede - + &Help &Aiuto - + &About Cockatrice &Info su Cockatrice - + &Tip of the Day Suggerimento del giorno - + Check for Client Updates Controlla aggiornamenti client - + Check for Card Updates... Controlla aggiornamenti carte... - + Check for Card Updates (Automatic) Controlla aggiornamenti carte in background - + Show Status Bar Mostra barra di stato - + View &Debug Log Vedi Registro di &debug - + Open Settings Folder Apri cartella impostazioni - + Show/Hide Mostra/Nascondi - + New Version Nuova versione - + Congratulations on updating to Cockatrice %1! Oracle will now launch to update your card database. Congratulazioni per aver aggiornato Cockatrice alla versione %1! Oracle verrà avviato per aggiornare anche il database delle carte. - + Cockatrice installed Cockatrice installata - + Congratulations on installing Cockatrice %1! Oracle will now launch to install the initial card database. Congratulazioni per aver installato Cockatrice %1! Oracle verrà avviato per installare il database delle carte. - + Card database Database carte - + Cockatrice is unable to load the card database. Do you want to update your card database now? If unsure or first time user, choose "Yes" @@ -5333,29 +5446,29 @@ Vuoi provare ad aggiornare il database adesso? Se è la prima volta che usi Cockatrice o non sei sicuro, scegli "Sì" - - + + Yes - - + + No No - + Open settings Apri impostazioni - + New sets found Nuovi set trovati - + %n new set(s) found in the card database Set code(s): %1 Do you want to enable it/them? @@ -5368,17 +5481,22 @@ Codici dei set: %1 Desideri attivarli? - + + Yes, always enable + + + + View sets Vedi set - + Welcome Benvenuto - + Hi! It seems like you're running this version of Cockatrice for the first time. All the sets in the card database have been enabled. Read more about changing the set order or disabling specific sets and consequent effects in the "Manage Sets" dialog. @@ -5387,167 +5505,133 @@ Tutti i set nell'archivio delle carte sono stati abilitati. Scopri metodi alternativi per visualizzare i set o disabilitare set ed effetti nella finestra "Gestisci Espansioni". - - + Information Informazione - + A card database update is already running. L'aggiornamento delle carte è già in corso. - + Unable to run the card database updater: Impossibile avviare l'aggiornamento delle carte: - + Card database update running. Aggiornamento delle carte in corso. - + Failed to start. The file might be missing, or permissions might be incorrect. Avvio non riuscito. Il file potrebbe essere assente, o i permessi errati. - + The process crashed some time after starting successfully. Il processo si è arrestato in modo anomalo poco dopo essersi avviato con successo. - + Timed out. The process took too long to respond. The last waitFor...() function timed out. Richiesta scaduta. Il processo ci ha messo troppo a rispondere. L'ultima funzione waitFor...() è scaduta. - + An error occurred when attempting to write to the process. For example, the process may not be running, or it may have closed its input channel. Si è verificato un errore nel cercare di scrivere al processo. Ad esempio, il processo potrebbe non essere in esecuzione, o potrebbe aver chiuso il suo canale di input. - + An error occurred when attempting to read from the process. For example, the process may not be running. Si è verificato un errore nel cercare di leggere dal processo. Ad esempio, il processo potrebbe non essere in esecuzione. - + Unknown error occurred. Si è verificato un errore sconosciuto. - + The card database updater exited with an error: %1 L'aggiornamento del database delle carte è terminato con un errore: %1 - - This server supports additional features that your client doesn't have. -This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. - -To update your client, go to Help -> Check for Updates. - Questo server supporta funzioni aggiuntive non previste dalla tua versione dell'applicazione. -Solitamente non si dovrebbe incorrere in problemi, ma questo messaggio può significare che è disponibile una nuova versione di Cockatrice o che il server si basi su una versione personalizzata o una non ancora rilasciata. - -Per aggiornare il tuo client, vai su Aiuto -> Controlla aggiornamenti client - - - - - - - + + + + + Load sets/cards Carica set di carte - + Selected file cannot be found. Il file selezionato non può essere caricato. - + You can only import XML databases at this time. E' possibile importare solamente database XML. - + The new sets/cards have been added successfully. Cockatrice will now reload the card database. Il nuovo set di carte è stato aggiunto correttamente. Il database delle carte verrà ricaricato. - + Sets/cards failed to import. Il nuovo set di carte non è stato importato a causa di un errore. - - - - - Reset Password - Reimposta Password - - - - Your password has been reset successfully, you can now log in using the new credentials. - La tua password è stata reimpostata correttamente, ora puoi accedere utilizzando le nuove credenziali. - - - - Failed to reset user account password, please contact the server operator to reset your password. - La reimpostazione della password è fallita, contatta l'amministratore del server per reimpostare la password. - - - - Activation request received, please check your email for an activation token. - Richiesta di attivazione ricevuta, controlla il tuo indirizzo email per il codice di attivazione. - ManaBaseConfigDialog - + Mana Base Configuration Configurazione Base di mana - + Display type: Modalità di visualizzazione: - + pie a torta - + bar a barre - + combinedBar a barre combinate - + Filter Colors (optional): Filtra per colori (opzionale): - + OK OK - + Cancel Annulla @@ -5619,27 +5703,27 @@ Il database delle carte verrà ricaricato. ManaDevotionConfigDialog - + Display type: Modalità di visualizzazione: - + pie A torta - + bar A barre - + combinedBar A barra combinata - + Filter Colors (optional): Filtri per colore (opzionali): @@ -5709,610 +5793,615 @@ Il database delle carte verrà ricaricato. MessageLogWidget - + from play dal campo di battaglia - + from their graveyard dal suo cimitero - + from exile dall'esilio - + from their hand dalla sua mano - + the top card of %1's library la prima carta del grimorio di %1 - + the top card of their library la prima carta del suo grimorio - + from the top of %1's library dalla cima del grimorio di %1 - + from the top of their library dalla cima del suo grimorio - + the bottom card of %1's library l'ultima carta del grimorio di %1 - + the bottom card of their library l'ultima carta del suo grimorio - + from the bottom of %1's library dal fondo del grimorio di %1 - + from the bottom of their library dal fondo del suo grimorio - + from %1's library dal grimorio di %1 - + from their library dal suo grimorio - + from sideboard dalla sideboard - + from the stack dalla pila - + from custom zone '%1' dalla zona personalizzata '%1' - + %1 is now keeping the top card %2 revealed. %1 sta tenendo la prima carta %2 rivelata. - + %1 is not revealing the top card %2 any longer. %1 non sta più rivelando la prima carta %2. - + %1 can now look at top card %2 at any time. %1 può guardare la prima carta %2 della libreria in qualunque momento. - + %1 no longer can look at top card %2 at any time. %1 non può più guardare la prima carta %2 del mazzo in qualunque momento. - + %1 attaches %2 to %3's %4. %1 assegna %2 a %4 di %3. - + %1 has conceded the game. %1 ha concesso la partita. - + %1 has unconceded the game. %1 ha annullato la concessione della partita. - + %1 has restored connection to the game. %1 ha ripristinato il collegamento alla partita. - + %1 has lost connection to the game. %1 ha perso il collegamento alla partita. - + %1 points from their %2 to themselves. %1 disegna una freccia dal suo %2 a sé stesso. - + %1 points from their %2 to %3. %1 disegna una freccia dal suo %2 a %3. - + %1 points from %2's %3 to themselves. %1 disegna una freccia dal %3 di %2 a sé stesso. - + %1 points from %2's %3 to %4. %1 disegna una freccia dal %3 di %2 a %4. - + %1 points from their %2 to their %3. %1 disegna una freccia dal suo %2 al suo %3. - + %1 points from their %2 to %3's %4. %1 disegna una freccia dal suo %2 a %4 di %3. - + %1 points from %2's %3 to their own %4. %1 disegna una freccia da %3 di %2 al suo %4. - + %1 points from %2's %3 to %4's %5. %1 disegna una freccia dal %3 di %2 al %5 di %4. - + %1 creates a face down token. %1 crea una pedina a faccia in giù. - + %1 creates token: %2%3. %1 crea una pedina: %2%3. - + %1 has loaded a deck (%2). %1 ha caricato un mazzo (%2). - + %1 has loaded a deck with %2 sideboard cards (%3). %1 ha caricato un mazzo con %2 carte nella sideboard (%3). - + %1 destroys %2. %1 distrugge %2. - + a card una carta - + %1 gives %2 control over %3. %1 da il controllo di %3 a %2. - + %1 puts %2 into play%3 face down. %1 mette %2 sul campo di battaglia %3 a faccia in giù. - + %1 puts %2 into play%3. %1 mette %2 sul campo di battaglia%3. - + %1 puts %2%3 into their graveyard face down. %1 mette %2%3 nel suo cimitero a faccia in giù. - + %1 puts %2%3 into their graveyard. %1 mette %2 nel suo cimitero%3. - + %1 exiles %2%3 face down. %1 esilia %2%3 a faccia in giù. - + %1 exiles %2%3. %1 esilia %2%3. - + %1 moves %2%3 to their hand. %1 mette %2 in mano%3. - + %1 puts %2%3 into their library. %1 mette %2 nel suo grimorio%3. - + %1 puts %2%3 onto the bottom of their library. %1 mette %2%3 in fondo al proprio grimorio. - + %1 puts %2%3 on top of their library. %1 mette %2 in cima al suo grimorio%3. - + %1 puts %2%3 into their library %4 cards from the top. %1 mette %2%3 nel suo grimorio %4 carte dalla cima. - + %1 moves %2%3 to sideboard. %1 mette %2 nella sideboard%3. - + %1 plays %2%3 face down. %1 gioca %2%3 a faccia in giù. - + %1 plays %2%3. %1 gioca %2%3. - + %1 moves %2%3 to custom zone '%4' face down. %1 mette %2 nella zona personalizzata '%4'%3 a faccia in giù. - + %1 moves %2%3 to custom zone '%4'. %1 mette %2 nella zona personalizzata '%4'%3. - + %1 tries to draw from an empty library %1 prova a pescare da un grimorio vuoto - + %1 draws %2 card(s). %1 pesca una carta.%1 pesca %2 carte.%1 pesca %2 carte. - + %1 is looking at %2. %1 sta guardando %2. - + %1 is looking at the %4 %3 card(s) %2. top card for singular, top %3 cards for plural %1 sta guardando %3 carta %4 %2.%1 sta guardando %3 carte %4 %2.%1 sta guardando %3 carte %4 %2. - + bottom in fondo - + top in cima - + %1 turns %2 face-down. %1 gira %2 a faccia in giù. - + %1 turns %2 face-up. %1 gira %2 a faccia in su. - + The game has been closed. La partita è stata chiusa. - + The game has started. La partita è iniziata. - + You are flooding the game. Please wait a couple of seconds. Stai spammando la partita. Attendi un paio di secondi. - + %1 has joined the game. %1 è entrato nella partita. - + %1 is now watching the game. %1 sta osservando la partita. - + You have been kicked out of the game. Sei stato cacciato dalla partita. - + %1 has left the game (%2). %1 ha abbandonato la partita (%2). - + %1 is not watching the game any more (%2). %1 non sta più guardando la partita (%2). - + %1 is not ready to start the game any more. %1 non è più pronto a iniziare la partita. - + %1 shuffles their deck and draws a new hand of %2 card(s). %1 mischia il proprio mazzo e pesca una nuova mano di %2 carta.%1 mischia il proprio mazzo e pesca una nuova mano di %2 carte.%1 mischia il proprio mazzo e pesca una nuova mano di %2 carte. - + %1 shuffles their deck and draws a new hand. %1 mischia il proprio mazzo e pesca una nuova mano. - + You are watching a replay of game #%1. Stai guardando il replay della partita #%1. - + %1 is ready to start the game. %1 è pronto a iniziare la partita. - + cards an unknown amount of cards carte - + %1 card(s) a card for singular, %1 cards for plural una carta%1 carte%1 carte - + %1 lends %2 to %3. %1 presta %2 a %3. - + %1 reveals %2 to %3. %1 rivela %2 a %3. - + %1 reveals %2. %1 rivela %2. - + %1 randomly reveals %2%3 to %4. %1 rivela a caso %2%3 a %4. - + %1 randomly reveals %2%3. %1 rivela a caso %2%3. - + %1 peeks at face down card #%2. %1 sbircia la carta a faccia in giù #%2. - + %1 peeks at face down card #%2: %3. %1 sbircia la carta a faccia in giù #%2: %3. - + %1 reveals %2%3 to %4. %1 rivela %2%3 a %4. - + %1 reveals %2%3. %1 rivela %2%3. - + %1 reversed turn order, now it's %2. %1 ha rovesciato l'ordine dei turni, ora è %2. - + reversed invertito - + normal normale - + Heads Testa - + Tails Croce - + %1 flipped a coin. It landed as %2. %1 ha lanciato una moneta. Il risultato è %2. - + %1 rolls a %2 with a %3-sided die. %1 lancia un dado a %3 facce e ottiene %2. - + %1 flips %2 coins. There are %3 heads and %4 tails. %1 lancia %2 monete. Sono uscite %3 teste e %4 croci. - + %1 rolls a %2-sided dice %3 times: %4. %1 lancia un dado a %2 facce %3 volte: %4. - + %1's turn. Turno di %1 - + %1 sets annotation of %2 to %3. %1 imposta le note di %2 a %3. - - %1 places %2 "%3" counter(s) on %4 (now %5). - %1 mette %2 segnalino "%3" su %4 (totale %5).%1 mette %2 segnalini "%3" su %4 (totale %5).%1 mette %2 segnalino(i) "%3" su %4 (totale %5). + + %1 places %2 %3%4 counter(s) on %5 (now %6). + - - %1 removes %2 "%3" counter(s) from %4 (now %5). - %1 toglie %2 segnalino "%3" su %4 (totale %5).%1 toglie %2 segnalini "%3" su %4 (totale %5).%1 toglie %2 segnalino(i) "%3" su %4 (totale %5). + + %1 removes %2 %3%4 counter(s) from %5 (now %6). + - + + %1 failed to undo their last draw. + + + + %1 sets counter %2 to %3 (%4%5). %1 imposta il contatore %2 a %3 (%4%5). - + %1 sets %2 to not untap normally. %1 imposta che %2 non STAPpi normalmente. - + %1 sets %2 to untap normally. %1 imposta che %2 STAPpi normalmente. - + %1 removes the PT of %2. %1 elimina i valori F/C di %2. - + %1 changes the PT of %2 from nothing to %4. %1 cambia F/C di %2 da vuota a %4. - + %1 changes the PT of %2 from %3 to %4. %1 cambia F/C di %2 da %3 a %4. - + %1 has locked their sideboard. %1 ha bloccato la sua sideboard. - + %1 has unlocked their sideboard. %1 ha sbloccato la sua sideboard. - + %1 taps their permanents. %1 TAPpa i suoi permanenti. - + %1 untaps their permanents. %1 STAPpa i suoi permanenti. - + %1 taps %2. %1 TAPpa %2. - + %1 untaps %2. %1 STAPpa %2. - + %1 shuffles %2. %1 mescola %2. - + %1 shuffles the bottom %3 cards of %2. %1 mescola le %3 carte sul fondo da %2. - + %1 shuffles the top %3 cards of %2. %1 mescola le prime %3 carte da %2. - + %1 shuffles cards %3 - %4 of %2. %1 mescola le carte %3 - %4 da %2. - + %1 unattaches %2. %1 toglie %2. - + %1 undoes their last draw. %1 annulla la sua ultima pescata. - + %1 undoes their last draw (%2). %1 annulla la sua ultima pescata (%2). @@ -6320,110 +6409,115 @@ Il database delle carte verrà ricaricato. MessagesSettingsPage - + Word1 Word2 Word3 Parola1 Parola2 Parola3 - + Add New Message Aggiungi Nuovo Messaggio - + Edit Message Modifica Messaggio - + Remove Message Rimuovi Messaggio - + Add message Aggiungi messaggio - - + + Message: Messaggio: - + Edit message Modifica messaggio - + Chat settings Impostazioni chat - + Custom alert words Lista parole evidenziate - + Enable chat mentions Abilita menzioni in chat - + Enable mention completer Abilita completamento menzioni - + In-game message macros Messaggi rapidi in partita - + How to use in-game message macros Come usare i messaggi macro in gioco - + Ignore chat room messages sent by unregistered users Ignora i messaggi in chat inviati dagli utenti non registrati - + Ignore private messages sent by unregistered users Ignora i messaggi privati inviati dagli utenti non registrati - - + + Ignore private messages sent by non-buddy users + + + + + Invert text color Inverti colore testo - + Enable desktop notifications for private messages Abilita notifiche desktop per i messaggi privati - + Enable desktop notification for mentions Abilita notifiche sul desktop per le menzioni - + Enable room message history on join Abilita messaggi recenti all'ingresso - - + + (Color is hexadecimal) (Colore in esadecimale) - + Separate words with a space, alphanumeric characters only Separare le parole con uno spazio; solo caratteri alfanumerici @@ -6431,42 +6525,42 @@ Il database delle carte verrà ricaricato. MoveMenu - + Move to &Metti in - + &Top of library in random order &Cima al grimorio in ordine casuale - + X cards from the top of library... Posizione X dalla cima del grimorio... - + &Bottom of library in random order &Fondo al grimorio in ordine casuale - + T&able Tavolo - + &Hand &Mano - + &Graveyard &Cimitero - + &Exile &Esilio @@ -6479,47 +6573,47 @@ Il database delle carte verrà ricaricato. Tipo - + Mana Value Valore di Mana - + Color(s) Colore - + Loyalty Fedeltà - + Main Card Type Tipo principale - + Mana Cost Costo di mana - + P/T F/C - + Side Lato - + Layout Disposizione - + Color Identity Identità di colore @@ -6542,6 +6636,152 @@ Il database delle carte verrà ricaricato. Direzione di sovrapposizione: + + PaletteEditorDialog + + + + Reset + + + + + + Apply + + + + + + Save && Apply + + + + + ▼ Edit Palette + + + + + + ▶ Edit Palette + + + + + Palette Editor — %1 + + + + + <b>Palette Editor</b> &nbsp;·&nbsp; %1 + + + + + This theme ships no default palette files + + + + + Replace current colours with the theme author's defaults + + + + + Switch between the light and dark palette files + + + + + Editing: + + + + + Show or hide the per-role colour grid for manual tweaks + + + + + ↺ Revert to theme default + + + + + Discard unsaved edits and restore the last saved palette + + + + + Preview this palette without saving to disk + + + + + Write palette-%1.toml and reload the theme + + + + + Cannot save: this theme has no directory on disk + + + + + Save failed + + + + + Could not write %1 to: +%2 + + + + + No default found + + + + + No default palette file found for the "%1" scheme. + + + + + PaletteGridWidget + + + Active + + + + + Disabled + + + + + Inactive + + + + + Normal interactive state + + + + + Widget is disabled / not interactive + + + + + Window is in background / unfocused + + + Phase @@ -6608,57 +6848,57 @@ Il database delle carte verrà ricaricato. PhasesToolbar - + Untap step Sottofase di STAP - + Upkeep step Sottofase di mantenimento - + Draw step Sottofase di acquisizione - + First main phase Prima fase principale - + Beginning of combat step Sottofase di inizio combattimento - + Declare attackers step Sottofase di dichiarazione degli attaccanti - + Declare blockers step Sottofase di dichiarazione dei bloccanti - + Combat damage step Sottofase del danno da combattimento - + End of combat step Sottofase di fine combattimento - + Second main phase Seconda fase principale - + End of turn step Sottofase finale @@ -6666,7 +6906,7 @@ Il database delle carte verrà ricaricato. PictureLoader - + en code for scryfall's language property, not available for all languages it @@ -6675,151 +6915,142 @@ Il database delle carte verrà ricaricato. PlayerActions - - View top cards of library - Guarda le carte in cima al grimorio - - - - - - - - - - - - Number of cards: (max. %1) - Numero di carte: (max %1) - - - - View bottom cards of library - Guarda le carte in fondo al grimorio - - - - Shuffle top cards of library - Mescola le carte in cima al grimorio - - - - Shuffle bottom cards of library - Mescola le carte in fondo al grimorio - - - - Draw hand - Pesca mano - - - - 0 and lower are in comparison to current hand size - 0 e inferiore sono relativi al numero attuale di carte in mano - - - - Draw cards - Pesca carte - - - - - - + + + + grave cimitero - - - - + + + + exile esilio + + + PlayerDialogs - + + View top cards of library + + + + + + + + + + + + + Number of cards: (max. %1) + + + + + View bottom cards of library + + + + + Shuffle top cards of library + + + + + Shuffle bottom cards of library + + + + + Draw hand + + + + + 0 and lower are in comparison to current hand size + + + + + Draw cards + + + + Move top cards to %1 - + Move bottom cards to %1 - + Draw bottom cards - Pesca le ultime carte + - - - C&reate another %1 token - C&rea un'altra pedina %1 - - - + Create tokens - Crea pedine + - - + Number: - Numero: + - + Place card X cards from top of library - Metti la carta in posizione X dalla cima del grimorio + - + Which position should this card be placed: - In che posizione dovrebbe essere messa questa carta: + - + (max. %1) - (max %1) + - + Change power/toughness - Cambia forza/costituzione + - + Change stats to: - Cambia valori a: + - + Set annotation - Imposta note + - + Please enter the new annotation: - Inserisci le nuove note: - - - - Set counters - Imposta i segnalini + PlayerMenu - + Player "%1" Giocatore "%1" - + &Counters &Contatori @@ -6852,7 +7083,7 @@ Questo significa che vedrai solo la stampa predefinita per ciascuna scarta anzic Visualizza pulsanti di navigazione - + Printing Selector Selettore di stampa @@ -6860,22 +7091,22 @@ Questo significa che vedrai solo la stampa predefinita per ciascuna scarta anzic PrintingSelectorCardOverlayWidget - + Preference Preferenza - + Pin Printing Fissa stampa in cima alla lista - + Unpin Printing Rimuovi stampa dalla cima della lista - + Show Related cards Mostra carte correlate @@ -6946,57 +7177,57 @@ Questo significa che vedrai solo la stampa predefinita per ciascuna scarta anzic PtMenu - + Power / toughness Forza / costituzione - + &Increase power &Aumenta forza - + &Decrease power &Diminuisci forza - + I&ncrease toughness A&umenta costituzione - + D&ecrease toughness D&iminuisci costituzione - + In&crease power and toughness Au&menta forza e costituzione - + Dec&rease power and toughness Dimi&nuisci forza e costituzione - + Increase power and decrease toughness Aumenta forza e diminuisci costituzione - + Decrease power and increase toughness Diminuisci forza e aumenta costituzione - + Set &power and toughness... Imposta &forza e costituzione... - + Reset p&ower and toughness Resetta f&orza e costituzione @@ -7004,37 +7235,37 @@ Questo significa che vedrai solo la stampa predefinita per ciascuna scarta anzic QMenuBar - + Services Servizi - + Hide %1 Nascondi %1 - + Hide Others Nascondi altre - + Show All Mostra tutte - + Preferences... Preferenze... - + Quit %1 Esci da %1 - + About %1 Informazioni su %1 @@ -7042,17 +7273,17 @@ Questo significa che vedrai solo la stampa predefinita per ciascuna scarta anzic QObject - + Cockatrice card database (*.xml) Database delle carte di Cockatrice (*.xml) - + All files (*.*) Tutti i file (*.*) - + Cockatrice replays (*.cor) Replay di Cockatrice (*.cor) @@ -7124,110 +7355,177 @@ Sicuro di voler disabilitare questa funzione? QPlatformTheme - + OK Conferma - + Save Salva - + Save All Salva tutto - + Open Apri - + &Yes &Sì - + Yes to &All Sì a &tutto - + &No &No - + N&o to All N&o a tutto - + Abort Interrompi - + Retry Riprova - + Ignore Ignora - + Close Chiudi - + Cancel Annulla - + Discard Abbandona - + Help Aiuto - + Apply Applica - + Reset Resetta - + Restore Defaults Ripristina predefiniti + + QuickSetupPanel + + + %1% + + + + + <b>Quick Setup</b> + + + + + Generate all palette roles automatically from a single accent colour + + + + + Accent: + + + + + Primary hue. Used directly for highlights and links. +At high intensity it also tints buttons and backgrounds. + + + + + Intensity: + + + + + Subtle + + + + + Full colour + + + + + 0–30 Subtle tint — only highlights and links change hue +30–70 Accented — buttons, tooltips, and borders join in +70–100 Full colour — backgrounds, everything + + + + + 70% + + + + + Generate ↓ + + + + + Derive all palette roles from the accent colour above. +Fine-tune individual colours in the grid afterwards. + + + RemoteDeckList_TreeModel - + Name Nome - + ID ID - + Upload time Tempo di upload @@ -7235,32 +7533,32 @@ Sicuro di voler disabilitare questa funzione? RemoteReplayList_TreeModel - + ID ID - + Name Nome - + Players Giocatori - + Keep Tieni - + Time started Inizio - + Duration (sec) Durata (sec) @@ -7268,37 +7566,37 @@ Sicuro di voler disabilitare questa funzione? RfgMenu - + &Exile &Esilio - + &View exile &Guarda la zona di esilio - + &Move exile to... &Muovi esilio in... - + &Top of library &Cima al grimorio - + &Bottom of library &Fondo al grimorio - + &Hand &Mano - + &Graveyard &Cimitero @@ -7344,7 +7642,7 @@ Sicuro di voler disabilitare questa funzione? SayMenu - + S&ay Invi&a @@ -7385,27 +7683,27 @@ Sicuro di voler disabilitare questa funzione? SetsModel - + Enabled Abilitato - + Set type Tipo set - + Set code Codice set - + Long name Nome esteso - + Release date Data di rilascio @@ -7413,53 +7711,53 @@ Sicuro di voler disabilitare questa funzione? ShortcutSettingsPage - - + + Restore all default shortcuts Ripristina scorciatoie predefinite - + Do you really want to restore all default shortcuts? Sei scuro di voler ripristinare tutte le scorciatoie predefinite? - + Clear all default shortcuts Rimuovi tutte le scorciatoie predefinite - + Do you really want to clear all shortcuts? Sei sicuro di voler rimuovere tutte le scorciatoie? - + Section: Sezione: - + Action: Azione: - + Shortcut: Scorciatoia: - + How to set custom shortcuts Come impostare scorciatoie personalizzate - + Clear all shortcuts Elimina tutte le scorciatoie - + Search by shortcut name Cerca per nome della scorciatoia @@ -7515,12 +7813,12 @@ Controlla le impostazioni! SideboardMenu - + &Sideboard &Sideboard - + &View sideboard &Guarda la sideboard @@ -7528,27 +7826,27 @@ Controlla le impostazioni! SoundSettingsPage - + Enable &sounds Abilita &suoni - + Current sounds theme: Tema sonoro attuale: - + Test system sound engine Prova il funzionamento dei suoni - + Sound settings Impostazioni suoni - + Master volume Volume @@ -7620,16 +7918,146 @@ Controlla le impostazioni! Risposta non valida ricevuta dal server degli aggiornamenti di versione. - + No reply received from the tag update server. Nessuna risposta ricevuta dal server dei tag di aggiornamento. - + Invalid reply received from the tag update server. Risposta non valida ricevuta dal server dei tag di aggiornamento. + + StorageSettingsPage + + + + + Success + + + + + Cached card pictures have been reset. + + + + + Downloaded card pictures have been reset. + + + + + Error + + + + + One or more downloaded card pictures could not be cleared. + + + + + In-memory (currently loaded) card pictures have been reset. + + + + + Card Picture Loader Caching Method: + + + + + The network cache is the preferred way of storing images. Downloaded images are stored here until the size of the cache exceeds the configured size. Cockatrice automatically monitors this cache and deletes the least recently seen card images to ensure the cache does not exceed the configured size. + + + + + Writing card images directly to a folder on your hard drive is another way of storing images. This does not change how Cockatrice accesses or downloads images. Cockatrice will NOT automatically monitor and clear this folder, so if you enable this option, it is up to you to ensure sufficient available space. It should also be noted that if a provider outage causes you to download the wrong picture (i.e. wrong printing) you will be stuck with it until you manually delete the file, as opposed to using the network cache, which automatically rotates and thus correct errors after a while. + + + + + This is the in-memory picture cache used by the application at runtime. It determines how much memory (RAM) Cockatrice can use before it has to fetch card images from the hard disk again. Increasing this will allow more card images to be displayed at once but shouldn't be necessary. Clearing this will make Cockatrice reload all images from the network cache or the disk. + + + + + Delete Cached Images + + + + + Delete Saved Images + + + + + Clear In-Memory Images + + + + + Card Picture Loader Cache Method + + + + + Network Cache + + + + + Filesystem + + + + + In-Memory Picture Cache + + + + + Network Cache Size: + + + + + On-disk cache for downloaded pictures + + + + + Redirect Cache TTL: + + + + + How long cached redirects for urls are valid for. + + + + + Picture Cache Size: + + + + + In-memory cache for pictures not currently on screen + + + + + Naming scheme: + + + + + Day(s) + + + TabAccount @@ -7765,117 +8193,117 @@ Controlla le impostazioni! TabArchidekt - - + + Desc. Decresc. - - + + AND AND - - + + Require ALL selected colors Richiede TUTTI i colori selezionati - - + + Deck name... Nome mazzo... - - + + Owner... Proprietario... - - + + Packages Package - - + + Advanced Filters Filtri avanzati - + Bracket: Fascia: - - + + Any Qualsiasi - - + + Contains card... Contiene carta... - - + + Commander... Comandante... - - + + Tag... Tag... - - + + Deck Size Dimensione mazzo - + Cards: Carte: - - + + Asc. Cresc. - + Sort by: Ordina per: - + Filter by: Filtra per: - + Display Settings Impostazioni di visualizzazione - - + + Search Cerca - - + + Formats Formati @@ -7885,6 +8313,54 @@ Controlla le impostazioni! Archidekt: + + TabCardArtRules + + + Card: + + + + + ProviderId: + + + + + Mode: + + + + + Reason: + + + + + Type a card name... + + + + + Add rule + + + + + Remove rule + + + + + Refresh + + + + + Card Art Rules + + + TabDeckEditor @@ -7933,7 +8409,7 @@ Controlla le impostazioni! Reimposta disposizione - + Deck: %1 Mazzo: %1 @@ -7941,55 +8417,49 @@ Controlla le impostazioni! TabDeckEditorVisual - + Visual Deck: %1 Mazzo visuale: %1 - + &Visual Deck Editor Editor &visuale - - + + Card Info Info carta - - + + Deck Mazzo - - - Filters - Filtri - - - + &View &Visualizza - + Printing Stampa - + Visible Mostra - + Floating Separata - + Reset layout Reimposta disposizione @@ -7997,22 +8467,22 @@ Controlla le impostazioni! TabDeckEditorVisualTabWidget - + Visual Deck View Mazzo visuale - + Visual Database Display Database visuale - + Deck Analytics Analisi mazzo - + Sample Hand Mano di esempio @@ -8020,133 +8490,133 @@ Controlla le impostazioni! TabDeckStorage - + Local file system File locali - + Server deck storage Mazzi su server - - + + Open in deck editor Apri nell'editor dei mazzi - + Rename deck or folder Rinomina mazzo o cartella - + Upload deck Upload mazzo - + Download deck Scarica mazzo - - - + + + New folder Nuova cartella - + Delete Elimina - + Open decks folder Apri cartella mazzi - + Rename local folder Rinomina cartella locale - + Rename local file Rinomina file locale - + New name: Nuovo nome: - - - - + + + + Error Errore - + Rename failed Rinomina non riuscita - - + + Invalid deck file File mazzo non valido - + Enter deck name Inserisci il nome del mazzo - + This decklist does not have a name. Please enter a name: Questo mazzo non ha un nome. Per favore inserisci un nome: - + Unnamed deck Mazzo senza nome - + Failed to upload deck to server Impossibile caricare il mazzo sul server - + Delete local file Elimina file locali - + Are you sure you want to delete the selected files? Vuoi davvero eliminare i file selezionati? - + Delete remote decks Elimina i deck remoti - + Are you sure you want to delete the selected decks? Vuoi davvero eliminare i mazzi selezionati? - - + + Name of new folder: Nome della nuova cartella: @@ -8218,191 +8688,191 @@ Please enter a name: TabGame - - - + + + Replay Replay - - + + Game Partita - - + + Player List Giocatori - - + + Card Info Info carta - - + + Messages Messaggi - - + + Replay Timeline Replay - + &Phases &Fasi - + &Game &Partita - + Next &phase Prossima &fase - + Next phase with &action Prossima sottofase + &azione - + Next &turn Prossimo &turno - + Reverse turn order Inverti l'ordine dei turni - + &Remove all local arrows &Rimuovi tutte le frecce - + Rotate View Cl&ockwise Ruota vista in senso &orario - + Rotate View Co&unterclockwise R&uota vista in senso antiorario - + Game &information &Informazioni partita - + Un&concede Rientra in gioco - - - + + + &Concede &Concedi - + &Leave game &Lascia partita - + C&lose replay C&hiudi replay - + &Focus Chat Vai alla &chat - + &Say: &Parla: - + Selected cards Carte selezionate - + &View &Visualizza - + Visible Mostra - + Floating Separata - + Reset layout Reimposta disposizione - + Concede Concedi - + Are you sure you want to concede this game? Vuoi veramente concedere la partita? - + Unconcede Annulla concedi - + You have already conceded. Do you want to return to this game? Hai già concesso. Vuoi ritornare in questa partita? - + Leave game Lascia la partita - + Are you sure you want to leave this game? Sei sicuro di voler lasciare la partita? - + A player has joined game #%1 Un giocatore si è unito alla partita #%1 - + %1 has joined the game %1 si è unito alla partita - + You have been kicked out of the game. Sei stato kickato fuori dalla partita. @@ -8461,115 +8931,115 @@ Please enter a name: Seleziona un numero di giorni valido. - + Username: Nome utente: - + IP Address: Indirizzo IP: - + Game Name: Nome partita: - + GameID: ID Partita: - + Message: Messaggio: - + Main Room Stanza principale - + Game Room Stanza partita - + Private Chat Chat privata - + Past X Days: Ultimi X giorni: - + Today Oggi - + Last Hour Ultima ora - + Maximum Results: Massimo numero di risultati: - + At least one filter is required. The more information you put in, the more specific your results will be. E' richiesto perlomeno un filtro. Più informazioni inserisci, più specifici saranno i risultati. - + Get User Logs Ottieni log utente - + Clear Filters Elimina filtri - + Filters Filtri - + Log Locations Posizioni registro - + Date Range Periodo - + Maximum Results Massimo numero di risultati - - + + Message History Storico messaggi - + Failed to collect message history information. Impossibile recuperare la storia dei ban. - + There are no messages for the selected filters. Nessun messaggio corrisponde ai filtri selezionati. @@ -8587,27 +9057,27 @@ Più informazioni inserisci, più specifici saranno i risultati. &Lascia - + %1 - Private chat %1 - Chat privata - + This user is ignoring you, they cannot see your messages in main chat and you cannot join their games. Questo utente ti sta ignorando, non può vedere i tuoi messaggi nella chat principale e tu non puoi entrare nelle sue partite. - + Private message from Messaggio privato da - + %1 has left the server. %1 è uscito dal server. - + %1 has joined the server. %1 è entrato nel server. @@ -8615,181 +9085,181 @@ Più informazioni inserisci, più specifici saranno i risultati. TabReplays - + Local file system File locali - + Server replay storage Replay sul server - - + + Watch replay Guarda replay - + Rename Rinomina - - + + New folder Nuova cartella - - + + Delete Elimina - + Open replays folder Apri cartella replay - + Download replay Scarica replay - + Toggle expiration lock Metti/togli blocco scadenza - + Get replay share code Ottieni codice di condivisione replay - - + + Look up replay by share code Cerca replay dal codice di condivisione - + Rename local folder Rinomina cartella locale - + Rename local file Rinomina file locale - + New name: Nuovo nome: - + Error Errore - + Rename failed Rinomina non riuscita - + Name of new folder: Nome della nuova cartella: - + Delete local file Elimina il file locale - + Are you sure you want to delete the selected files? Vuoi davvero eliminare i file selezionati? - + Are you sure you want to delete the selected replays? Vuoi davvero eliminare i replay selezionati? - + Failed to get code Impossibile ottenere il codice - - + + Either this server does not support replay sharing, or does not permit replay sharing for you. O questo server non supporta la condivisione dei replay, o non consente la condivisione dei replay per te. - - - + + + Failed Fallito - + Could not get replay code Impossibile ottenere il codice replay - + Replay Share Code Codice di condivisione replay - + Others can use this code to add the replay to their list of remote replays: %1 Altre persone possono usare questo codice per aggiungere il replay alla loro lista di replay remoti: %1 - + Copy to clipboard Copia negli appunti - + Replay share code Codice di condivisione replay - + Replay code found Codice replay trovato - + Replay was added, or you already had access to it. Il replay è stato aggiunto oppure ne avevi già accesso. - + Replay code not found Codice replay non trovato - + Failed to submit code Impossibile inviare il codice - + Unexpected error Errore inatteso - + Delete remote replay Elimina replay remoto @@ -8802,47 +9272,62 @@ Più informazioni inserisci, più specifici saranno i risultati. TabRoom - + + Friends + + + + + Online + + + + + Ignored + + + + &Say: &Parla: - + Chat Chat - + &Room &Stanza - + &Leave room &Lascia stanza - + &Clear chat Pulisci &chat - + Chat Settings... Impostazioni chat... - + mentioned you. ti ha menzionato. - + Click to view Clicca per visualizzare - + You are flooding the chat. Please wait a couple of seconds. Stai spammando la chat. Attendi un paio di secondi. @@ -8855,30 +9340,30 @@ Più informazioni inserisci, più specifici saranno i risultati. Server - - - - + + + + Error Errore - + Failed to join the server room: it doesn't exist on the server. Impossibile unirsi alla stanza: non esiste sul server. - + The server thinks you are in the server room but your client is unable to display it. Try restarting your client. Il server pensa che tu sia nella stanza ma il tuo client non è in grado di mostrarlo. Prova a riavviare il client. - + You do not have the required permission to join this server room. Non hai i requisiti necessari per unirti a questa stanza. - + Failed to join the server room due to an unknown error: %1. Impossibile unirsi alla stanza a causa di un errore sconosciuto: %1. @@ -8886,97 +9371,97 @@ Più informazioni inserisci, più specifici saranno i risultati. TabSupervisor - + Deck Editor &Editor dei mazzi - + Visual Deck Editor Editor visuale - + EDHRec EDHRec - + Archidekt Archidekt - + Home Home - + &Visual Deck Storage &Galleria mazzi - + Visual Database Display Galleria database - + Server Server - + Account Account - + Deck Storage &Archivio mazzi - + Game Replays &Replay partite - + Administration Amministrazione - + Logs Registri - + Are you sure? Sei sicuro? - + There are still open games. Are you sure you want to quit? Ci sono ancora delle partite aperte. Sei sicuro di voler uscire? - + Click to view Clicca per visualizzare - + Your buddy %1 has signed on! Il tuo amico %1 si è collegato - + Unknown Event Evento sconosciuto. - + The server has sent you a message that your client does not understand. This message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -8987,39 +9472,39 @@ Questo messaggio può significare che è disponibile una nuove versione di Cocka Per aggiornare il tuo client, vai su Aiuto -> Controlla aggiornamenti client - + Idle Timeout Timeout inattività - + You are about to be logged out due to inactivity. Stai per essere disconnesso per inattività. - + Promotion Promozione - + You have been promoted. Please log out and back in for changes to take effect. Sei stato promosso. Esci e rientra per dare effetto alle modifiche. - + Warned Avviso - + You have received a warning due to %1. Please refrain from engaging in this activity or further actions may be taken against you. If you have any questions, please private message a moderator. Hai ricevuto un avviso a causa di %1. Se pregato di evitare di continuare questa attività o potrebbero venire presi ulteriori provvedimenti nel tuoi confronti. Per qualsiasi domanda, manda un messaggio ad un moderatore. - + You have received the following message from the server. (custom messages like these could be untranslated) Hai ricevuto il seguente messaggio dal server. @@ -9029,12 +9514,12 @@ Se pregato di evitare di continuare questa attività o potrebbero venire presi u TabVisualDatabaseDisplay - + Database Display Visualizzazione Database - + Visual Database Display Galleria database @@ -9073,42 +9558,42 @@ Se pregato di evitare di continuare questa attività o potrebbero venire presi u TranslateCounterName - + Life Vita - + White Bianco - + Blue Blu - + Black Nero - + Red Rosso - + Green Verde - + Colorless Incolore - + Other Altro @@ -9116,11 +9601,69 @@ Se pregato di evitare di continuare questa attività o potrebbero venire presi u UpdateDownloader - + Could not open the file for reading. Impossibile aprire il file in lettura. + + UserCardArtSettingsDialog + + + Card Art Settings + + + + + Type a card name... + + + + + Card name: + + + + + Card ProviderId: + + + + + Left margin (%): + + + + + Right margin (%): + + + + + Vertical offset: + + + + + Zoom: + + + + + Parameters + + + + + Preview + + + + + Remove Banner Card + + + UserContextMenu @@ -9238,7 +9781,7 @@ Se pregato di evitare di continuare questa attività o potrebbero venire presi u - + Ban History Storico ban @@ -9253,77 +9796,87 @@ Se pregato di evitare di continuare questa attività o potrebbero venire presi u L'utente non è mai stato bannato. - + Failed to collect ban information. Impossibile recuperare le informazioni sui ban. - - - + + + Warning History Storico avvisi - + Warning Time;Moderator;User Name;Reason Ora Avviso;Moderatore;Nome Utente;Ragione - + User has never been warned. L'utente non ha mai ricevuto avvisi. - + Failed to collect warning information. Impossibile recuperare le informazioni sugli avvisi. - + Failed to get admin notes. Impossibile ottenere le note degli amministratori - - + + Success Successo - + Successfully promoted user. Utente promosso. - + Successfully demoted user. Utente degradato. - - - + + Kick Player + + + + + Are you sure you want to kick this player from the game? + + + + + + Failed Fallito - + Failed to promote user. Promozione fallita. - + Failed to demote user. Degradazione fallita. - + Copy hash to clipboard Copia hash negli appunti - + Remove this user's messages Rimuovi i messaggi di questo utente @@ -9331,326 +9884,554 @@ Se pregato di evitare di continuare questa attività o potrebbero venire presi u UserInfoBox - + Location: Posizione: - + Account Age: Età account: - + Edit Modifica - + Change password Cambia password - + Change avatar Cambia avatar - + Administrator Amministratore - + Moderator Moderatore - + Registered user Utente registrato - - + + Unregistered user Utente non registrato - + Judge Arbitro - + Unknown Sconosciuta - + The entered password does not match your account. La password inserita non corrisponde al tuo accound. - - - + + + + Information Informazione - + User information updated. Informazioni utente aggiornate. - - - - - - - - - - + + + + + + + + + + + Error Errore - + User Information Informazioni Utente - + Real Name: Nome Reale: - + User Level: Livello dell'Utente: + + + Edit Banner Card + + - + %n Year(s), amount of years (only shown if more than 0) 1 Anno.%n Anni.%n Anno(i). - + %10%n Day(s) %20 amount of years (if more than 0), amount of days, date in local short format %10%n Giorno %20%10%n Giorni %20%10%n Giorno(i) %20 - + Enter Password Inserisci Password - + Password verification is required in order to change your email address La verifica della password è necessaria per cambiare il tuo indirizzo email - - - + + + An error occurred while trying to update your user information. E' stato riscontrato un errore durante l'aggiornamento delle tue informazioni utente. - + + The selected card is blacklisted on this server or another error occurred. + + + + + Banner card removed. + + + + + Banner card updated. + + + + This server does not permit you to update your user informations. Questo server non ti permette di aggiornare le tue informazioni utente. - + Password changed. Password modificata. - + This server does not permit you to change your password. Questo server non ti permette di modificare la password. - + The new password is too short. La nuova password è troppo corta. - + The old password is incorrect. La vecchia password è sbagliata. - + Avatar updated. Avatar modificato. - + This server does not permit you to update your avatar. Questo server non ti permette di aggiornare il tuo avatar. + + + An error occured while trying to update your avatar. + + - An error occured while trying to updater your avatar. - Si è verificato un errore durante l'aggiornamento del tuo avatar. + This server does not permit you to update your user informations. + An error occured while trying to updater your avatar. + Questo server non ti permette di aggiornare le tue informazioni utente. + + + + UserInfoPopup + + + + Games + + + + + Chat + + + + + Open private chat + + + + + Profile + + + + + View user profile + + + + + Show this user's games + + + + + − Buddy + + + + + Remove from buddy list + + + + + + Buddy + + + + + Add to buddy list + + + + + − Ignore + + + + + Remove from ignore list + + + + + + Ignore + + + + + Add to ignore list + + + + + Ban + + + + + Ban from server + + + + + Warn + + + + + Warn user + + + + + Ban log + + + + + View ban history + + + + + Warn log + + + + + View warning history + + + + + Notes + + + + + View admin notes + + + + + − Mod + + + + + Demote from moderator + + + + + + Mod + + + + + Promote to moderator + + + + + − Judge + + + + + Demote from judge + + + + + + Judge + + + + + Promote to judge + + + + + Join game + + + + + Spectate + + + + + + Loading games… + + + + + Could not load games. + + + + + No active games. + UserInterfaceSettingsPage - + General interface settings Impostazioni generali di interfaccia - + &Double-click cards to play them (instead of single-click) &Doppio click sulle carte per giocarle (anzichè un solo click) - + &Clicking plays all selected cards (instead of just the clicked card) &Cliccare gioca tutte le carte selezionate (anziché solo quella cliccata) - + &Play all nonlands onto the stack (not the battlefield) by default Gioca tutte le carte non terra nella &pila (invece che sul campo di battaglia) - + Do not delete &arrows inside of subphases Non eliminare le &freccie al cambio di sottofase - + Close card view window when last card is removed Chiudi la finestra di visualizzazione carte quando l'ultima carta viene rimossa - + Auto focus search bar when card view window is opened Passa alla barra di ricerca quando si apre la finestra di visualizzazione carta - + Annotate card text on tokens Annota il testo della carta sulle pedine - - Show selection counter during drag selection - Mostra un contatore quando selezioni per trascinamento + + Show selection count during drag selection + - - Show total selection counter - Mostra un contatore di elementi selezionati + + Show total selection count + - + + Show subtype breakdown in selection tally + + + + Use tear-off menus, allowing right click menus to persist on screen Usa menù a strappo, permettendo ai menù del tasto destro del mouse di rimanere sullo schermo - + + Keep game chat focused when clicking in game (Note: disables card view search bar) + + + + Notifications settings Impostazioni notifiche - + Enable notifications in taskbar Abilita notifiche nella barra delle applicazioni - + Notify in the taskbar for game events while you are spectating Notifica anche per le partite in cui si è solo uno spettatore - + Notify in the taskbar when users in your buddy list connect Notifca quando utenti nella tua lista amici si connettono - + Animation settings Impostazioni delle animazioni - + &Tap/untap animation Animazioni &TAPpa/STAPpa - + Deck editor/storage settings Impostazioni editor/archivio mazzi - + Open deck in new tab by default Apri il mazzo in una nuova scheda automaticamente - + Use visual deck storage in game lobby Usa galleria mazzi nella lobby - + Use selection animation for Visual Deck Storage Mostra animazione al passaggio del mouse nella galleria mazzi - + When adding a tag in the visual deck storage to a .txt deck: Quando aggiungi un'etichetta ad un mazzo in formato .txt nella Galleria mazzi: - + do nothing non fare nulla - + ask to convert to .cod chiedi se convertire in file .cod - + always convert to .cod converti sempre in file .cod - + Default deck editor type Editor dei mazzi predefinito - + Classic Deck Editor Editor classico - + Visual Deck Editor Editor visuale - + Replay settings Impostazioni di riproduzione - + Buffer time for backwards skip via shortcut: Tempo di attesa per saltare indietro dopo aver premuto la scorciatoia @@ -9658,22 +10439,22 @@ Se pregato di evitare di continuare questa attività o potrebbero venire presi u UserListWidget - + Users connected to server: %1 Utenti connessi al server: %1 - + Users in this room: %1 Utenti in questa stanza: %1 - + Buddies online: %1 / %2 Amici online: %1 / %2 - + Ignored users online: %1 / %2 Utenti ignorati online: %1 / %2 @@ -9681,35 +10462,45 @@ Se pregato di evitare di continuare questa attività o potrebbero venire presi u UtilityMenu - + Increment all card counters Aumenta tutti i segnalini della carta - + &Untap all permanents &STAPpa tutti i permanenti - + R&oll die... L&ancia un dado... - + + Flip coin + + + + &Create token... &Crea una pedina... - + C&reate another token C&rea un'altra pedina - + Cr&eate predefined token Cr&ea pedina predefinita + + + C&reate another %1 token + + VisualDatabaseDisplayColorFilterWidget @@ -9761,72 +10552,72 @@ Se pregato di evitare di continuare questa attività o potrebbero venire presi u VisualDatabaseDisplayFilterToolbarWidget - + Sort by Ordina per - + Filter by Filtra per - + Save and load filters Salva e carica filtri - + Filter by exact card name Filtra per nome della carta esatto - + Filter by card main-type Filtra per tipo - + Filter by card sub-type Filtra per sottotipo - + Filter by set Filtra per set - + Filter by format legality Filtra per legalità di formato - + Save/Load Salva/Carica - + Name Nome - + Main Type Tipo - + Sub Type Sottotipo - + Sets Set - + Formats Formati @@ -9834,22 +10625,22 @@ Se pregato di evitare di continuare questa attività o potrebbero venire presi u VisualDatabaseDisplayFormatLegalityFilterWidget - + Show formats with at least: Mostra formati con almeno: - + cards carte - + Do not display formats with less than this amount of cards in the database Non mostrare i formati con meno di questo ammontare di carte nel database - + Filter mode (AND/OR/NOT conjunctions of filters) Modalità di filtraggio (utilizzabile con connettivi logici AND/OR/NOT) @@ -9867,32 +10658,32 @@ Se pregato di evitare di continuare questa attività o potrebbero venire presi u VisualDatabaseDisplayMainTypeFilterWidget - + Show main types with at least: Mostra tipi con almeno: - + cards carte - + Do not display card main-types with less than this amount of cards in the database Non mostrare i tipi di carta che hanno meno occorrenze nel database di questo numero - + Filter mode (AND/OR/NOT conjunctions of filters) Modalità di filtraggio (utilizzabile con connettivi logici AND/OR/NOT) - + Mode: Exact Match Modalità: Corrispondenza esatta - + Mode: Includes Modalità: Include @@ -9900,27 +10691,27 @@ Se pregato di evitare di continuare questa attività o potrebbero venire presi u VisualDatabaseDisplayNameFilterWidget - + Filter by name... (Exact match) Filtra per nome... (Corrispondenza esatta) - + Load from Deck Carica da mazzo - + Apply all card names in currently loaded deck as exact match name filters Usa i nomi delle carte nel mazzo caricato come insieme di filtri a corrispondenza esatta - + Load from Clipboard Carica dagli appunti - + Apply all card names in clipboard as exact match name filters Usa i nomi delle carte negli appunti come insieme di filtri a corrispondenza esatta @@ -9928,7 +10719,7 @@ Se pregato di evitare di continuare questa attività o potrebbero venire presi u VisualDatabaseDisplayRecentSetFilterSettingsWidget - + Filter to most recent sets Filtra per i set più recenti @@ -9936,19 +10727,19 @@ Se pregato di evitare di continuare questa attività o potrebbero venire presi u VisualDatabaseDisplaySetFilterWidget - + Search sets... Ricerca set... - - + + Mode: Exact Match Modalità: Corrispondenza esatta - - + + Mode: Includes Modalità: Include @@ -9956,37 +10747,37 @@ Se pregato di evitare di continuare questa attività o potrebbero venire presi u VisualDatabaseDisplaySubTypeFilterWidget - + Search subtypes... Ricerca sottotipi... - + Show sub types with at least: Mostra sottotipi con almeno: - + cards carte - + Do not display card sub-types with less than this amount of cards in the database Non mostrare i sottotipi che hanno meno occorrenze nel database di questo numero - + Filter mode (AND/OR/NOT conjunctions of filters) Modalità di filtraggio (utilizzabile con connettivi logici AND/OR/NOT) - + Mode: Exact Match Modalità: Corrispondenza esatta - + Mode: Includes Modalità: Include @@ -9994,23 +10785,23 @@ Se pregato di evitare di continuare questa attività o potrebbero venire presi u VisualDatabaseDisplayWidget - + Search by card name (or search expressions) Cerca per nome della carta (o espressioni di ricerca) - + Visual Visuale - + Loading database ... Caricamento database... - + Clear all filters Elimina tutti i filtri @@ -10049,17 +10840,17 @@ Se pregato di evitare di continuare questa attività o potrebbero venire presi u - + Toggle Layout: Overlap Disposizione: Sovrapposta - + Change how cards are displayed within zones (i.e. overlapped or fully visible.) Modifica come le carte vengono mostrate nelle zone (es. sovrapposte o completamente visibili.) - + Toggle Layout: Flat Disposizione: Piatta @@ -10088,17 +10879,17 @@ Se pregato di evitare di continuare questa attività o potrebbero venire presi u VisualDeckEditorWidget - + Type a card name here for suggestions from the database... Scrivi un nome di carta qui per avere suggerimenti dal database... - + Quick search and add card Cerca e aggiungi carta al mazzo - + Search for closest match in the database (with auto-suggestions) and add preferred printing to the deck on pressing enter Cerca per la corrispondenza più prossima nel database (con auto-completamento) e premendo Invio, aggiungi la stampa preferita della carta al mazzo. @@ -10216,43 +11007,43 @@ Se pregato di evitare di continuare questa attività o potrebbero venire presi u WarningDialog - + Which warning would you like to send? Che avviso vorresti inviare? - + Redact all messages from this user in all rooms Ottieni tutti i messaggi di questo utente in tutte le stanze - + &OK &OK - + &Cancel &Annulla - + Warn user for misconduct Avvisa l'utente per cattivo comportamento - - + + Error Errore - + User name to send a warning to can not be blank, please specify a user to warn. Il nome utente a cui inviare l'avviso non può essere vuoto. - + Warning to use can not be blank, please select a valid warning to send. L'avviso da inviare non può essere vuoto. @@ -10260,133 +11051,133 @@ Se pregato di evitare di continuare questa attività o potrebbero venire presi u WndSets - + Move selected set to the top Muovi set selezionato in cima - + Move selected set up Muovi set selezionato su - + Move selected set down Muovi set selezionato giù - + Move selected set to the bottom Muovi set selezionato in fondo - + Search by set name, code, or type Cerca per nome, codice o tipo del set - + Default order Ordine predefinito - + Restore original art priority order Ripristina ordine originale priorità immagini - + Enable all sets Abilita tutti i set - + Disable all sets Disabilita tutti i set - + Enable selected set(s) Abilita i set selezionati - + Disable selected set(s) Disabilita i set selezionati - + Deck Editor Editor dei mazzi - + Use CTRL+A to select all sets in the view. Usa CTRL+A per selezionare le espansioni nella visuale. - + Only cards in enabled sets will appear in the card list of the deck editor. Solo carte in espansioni abilitate appariranno nella lista carte dell'editor di mazzo. - + Image priority is decided in the following order: La priorità di immagine è decisa nell'ordine seguente: - + first the CUSTOM Folder (%1), then the Enabled Sets in this dialog (Top to Bottom) %1 is a link to the wiki innanzitutto la cartella CUSTOM (%1), poi le Espansioni abilitate in questo dialogo (da cima a fondo) - + Include cards rebalanced for Alchemy [requires restart] Includi carte ribilanciate per Alchemy [richiede riavvio] - + Card Art Immagine carta - + How to use custom card art Come usare immagini personalizzate per le carte - + Hints Suggerimenti - + Note Note - + Sorting by column allows you to find a set while not changing set priority. L'ordinamento per colonne ti permette di trovare un set senza cambiare il loro ordine di priorità. - + To enable ordering again, click the column header until this message disappears. Per riabilitare l'ordinamento, clicca sull'intestazione della colonna finché questo messaggio non scompare. - + Use the current sorting as the set priority instead Usa l'ordinamento corrente per impostare la priorità dei set - + Sorts the set priority using the same column Imposta priorità dei set usando l'ordinamento per colonna - + Manage sets Organizza set @@ -10394,72 +11185,72 @@ Se pregato di evitare di continuare questa attività o potrebbero venire presi u ZoneViewWidget - + Search by card name (or search expressions) Cerca per nome della carta (o espressioni di ricerca) - + Ungrouped Non raggruppare - + Group by Type Raggruppa per tipo - + Group by Mana Value Raggruppa per costo - + Group by Color Raggruppa per colore - + Unsorted Non ordinato - + Sort by Name Ordina per nome - + Sort by Type Ordina per tipo - + Sort by Mana Cost Ordina per costo - + Sort by Colors Ordina per colori - + Sort by P/T Ordina per F/C - + Sort by Set Ordina per Set - + shuffle when closing Mescola alla chiusura - + pile view Raggruppa per tipo @@ -10467,7 +11258,7 @@ Se pregato di evitare di continuare questa attività o potrebbero venire presi u i18n - + English Italiano (Italian) @@ -10475,12 +11266,12 @@ Se pregato di evitare di continuare questa attività o potrebbero venire presi u main - + Connect on startup Connetti all'avvio - + Debug to file Debug su file @@ -10494,7 +11285,7 @@ Se pregato di evitare di continuare questa attività o potrebbero venire presi u - + Deck Editor Editor dei mazzi @@ -10575,7 +11366,7 @@ Se pregato di evitare di continuare questa attività o potrebbero venire presi u - + Replays Replay @@ -10690,129 +11481,129 @@ Se pregato di evitare di continuare questa attività o potrebbero venire presi u Carica Mazzo... - - + + Load Deck from Clipboard... Carica il Mazzo dagli Appunti... - + Edit Deck in Clipboard, Annotated Modifica Mazzo negli Appunti con Annotazioni - + Edit Deck in Clipboard Modifica Mazzo negli Appunti - + New Deck Nuovo Mazzo - + Open Custom Pictures Folder Apri Cartella Immagini Personalizzate - + Print Deck... Stampa Mazzo... - + Delete Card Elimina carta - - + + Reset Layout Reimposta Disposizione - + Save Deck Salva Mazzo - + Save Deck as... Salva Mazzo con Nome... - + Save Deck to Clipboard, Annotated Salva Mazzo in Appunti, Annotato - + Save Deck to Clipboard, Annotated (No Set Info) Salva Mazzo negli Appunti con Annotazioni (Senza Info Set) - + Save Deck to Clipboard Salva Mazzo in Appunti - + Save Deck to Clipboard (No Set Info) Salva Mazzo negli Appunti (Senza Info Set) - + Load Local Deck... Carica Mazzo Locale... - + Load Remote Deck... Carica Mazzo da Remoto... - + Set Ready to Start Imposta Pronto a Iniziare - + Toggle Sideboard Lock Attiva Blocco della Sideboard - + Add Green Counter Aumenta Segnalino Verde - + Remove Green Counter Riduci Segnalino Verde - + Set Green Counters... Imposta Segnalino Verde... - + Add Red Counter Aumenta Segnalino Rosso - + Remove Red Counter Riduci Segnalino Rosso - + Set Red Counters... Imposta Segnalino Rosso... - + Add Life Counter Aumenta Contatore Vita @@ -10822,724 +11613,744 @@ Se pregato di evitare di continuare questa attività o potrebbero venire presi u Mostra barra di stato - + + Load deck from online service... + + + + + Load from website... + + + + Unload Deck Deseleziona mazzo - + Force Start Forza avvio - + Add Card Counter (F) Aggiungi Segnalino (F) - + Remove Card Counter (F) Togli Segnalino (F) - + Set Card Counters (F)... Imposta Segnalini (F)... - + Add Card Counter (E) Aggiungi Segnalino (E) - + Remove Card Counter (E) Togli Segnalino (E) - + Set Card Counters (E)... Imposta Segnalini (E)... - + Add Card Counter(D) Aggiungi Segnalino(D) - + Remove Card Counter (D) Togli Segnalino (D) - + Set Card Counters (D)... Imposta Segnalini (D)... - + Add Card Counter (C) Aggiungi Segnalino (C) - + Remove Card Counter (C) Togli Segnalino (C) - + Set Card Counters (C)... Imposta Segnalini (C)... - + Add Card Counter (B) Aggiungi Segnalino (B) - + Remove Card Counter (B) Togli Segnalino (B) - + Set Card Counters (B)... Imposta Segnalini (B)... - + Add Card Counter (A) Aggiungi Segnalino (A) - + Remove Card Counter (A) Togli Segnalino (A) - + Set Card Counters (A)... Imposta Segnalini (A)... - + Remove Life Counter Riduci Contatore Vita - + Set Life Counters... Imposta Contatore Vita... - + Add White Counter Aumenta Contatore Bianco - + Remove White Counter Riduci Contatore Bianco - + Set White Counters... Imposta Contatore Bianco... - + Add Blue Counter Aumenta Contatore Blu - + Remove Blue Counter Riduci Contatore Blu - + Set Blue Counters... Imposta Contatore Blu... - + Add Black Counter Aumenta Contatore Nero - + Remove Black Counter Riduci Contatore Nero - + Set Black Counters... Imposta Contatore Nero... - + Add Colorless Counter Aumenta Contatore Incolore - + Remove Colorless Counter Riduci Contatore Incolore - + Set Colorless Counters... Imposta Contatore Incolore... - + Add Other Counter Aumenta Contatore Altro - + Remove Other Counter Riduci Contatore Altro - + Set Other Counters... Imposta Contatore Altro... - + Increment all card counters Aumenta tutti i segnalini della carta - + Add Power (+1/+0) Aumenta Forza (+1/+0) - + Remove Power (-1/-0) Diminuisci Forza (-1/-0) - + Move Toughness to Power (+1/-1) Sposta Costituzione a Forza (+1/-1) - + Add Toughness (+0/+1) Aumenta Costituzione (+0/+1) - + Remove Toughness (-0/-1) Riduci Costituzione (-0/-1) - + Move Power to Toughness (-1/+1) Sposta Forza a Costituzione (-1/+1) - + Add Power and Toughness (+1/+1) Aumenta Forza e Costituzione (+1/+1) - + Remove Power and Toughness (-1/-1) Diminuisci Forza e Costituzione (-1/-1) - + Set Power and Toughness... Imposta Forza e Costituzione... - + Reset Power and Toughness Resetta Forza e Costituzione - + Untap STAP - + Upkeep Mantenimento - + Draw Acquisizione - + First Main Phase Prima Fase Principale - + Start Combat Inizio Combattimento - + Attack Dichiarazione attaccanti - + Block Dichiarazione bloccanti - + Damage Danno da combattimento - + End Combat Fine Combattimento - + Second Main Phase Seconda Fase Principale - + End Finale - + Next Phase Fase Successiva - + Next Phase Action Azione della Fase Successiva - + Next Turn Turno Successivo - + Hide Card in Reveal Window Nascondi Carta nella Finestra di Visualizzazione - + Tap / Untap Card TAPpa / STAPpa carta - + Untap All STAPpa Tutto - + Toggle Skip Untapping Toggle Untap Salta lo STAP - + Turn Card Over Gira Carta - + Peek Card Sbircia Carta - + Play Card Gioca Carta - + Play Card, Face Down Gioca la carta a faccia in giù - + Attach Card... Assegna Carta... - + Unattach Card Togli Carta - + Clone Card Clona Carta - + Create Token... Crea Pedina... - + Create All Related Tokens Crea Tutte le Pedine Correlate - + Create Another Token Crea un'Altra Pedina - + Set Annotation... Imposta Note... - + + Reduce Life by Power + + + + Select All Cards in Zone Seleziona Tutte le Carte nella Zona - + Select All Cards in Row Seleziona Tutte le Carte nella Riga - + Select All Cards in Column Seleziona Tutte le Carte nella Colonna - + Reveal Selected Cards to All Players Rivela Carta Selezionata a Tutti i Giocatori - - + + Bottom of Library Fondo al Grimorio - - - - + + + + Exile Esilio - - - - + + + + Graveyard Cimitero - - + + Hand Mano - - + + Top of Library Cima al Grimorio - - + + Battlefield, Face Down Campo di Battaglia, faccia in giù - + Battlefield Campo di Battaglia - + Library Grimorio - + Sideboard Sideboard - + Top Cards of Library Carte in Cima al Grimorio - + Bottom Cards of Library Carte in Fondo al Grimorio - + Close Recent View Chiudi Viste di Recente - - + + Stack Pila - - + + Graveyard (Multiple) Cimitero (Multiple) - - + + Graveyard (Multiple), Face Down Cimitero (Multiple), a faccia in giù - - + + Exile (Multiple) Esilia (Multiple) - - + + Exile (Multiple), Face Down Esilio (Multiple), a faccia in giù - + Stack Until Found Impila Fino a Trovare - + Draw Bottom Card Pesca Carta in Fondo - + Draw Multiple Cards from Bottom... Pesca Multiple Carte in Fondo - + Draw Arrow... Disegna Freccia... - + Remove Local Arrows Rimuovi Frecce Locali - + Leave Game Abbandona Partita - + Concede Concedi - + Roll Dice... Tira un Dado... - + + Flip Coin + + + + Shuffle Library Mescola il Grimorio - + Shuffle Top Cards of Library Mescola Carte in Cima al Grimorio - + Shuffle Bottom Cards of Library Mescola Carte in Fondo al Grimorio - + Mulligan Mulligan - + Mulligan (Same hand size) Mulligan (Come la mano attuale) - + Mulligan (Hand size - 1) Mulligan (Dimensione della mano - 1) - + Draw a Card Pesca una Carta - + Draw Multiple Cards... Pesca più Carte... - + Undo Draw Annulla Pescata - + Always Reveal Top Card Rivela Sempre la Carta in Cima - + Always Look At Top Card Guarda Sempre la Carta in Cima - + Sort Hand by Name Ordina mano per Nome - + Sort Hand by Type Ordina mano per Tipo - + Sort Hand by Mana Value Ordina mano per Valore di Mana - + Reveal Hand to All Players Rivela Mano a Tutti i Giocatori - + Reveal Random Card to All Players Rivela Carta Casuale a Tutti i Giocatori - + Rotate View Clockwise Ruota Vista in Senso Orario - + Rotate View Counterclockwise Ruota Vista in Senso Antiorario - + Unfocus Text Box Togli Focus dalla Casella di Testo - + Focus Chat Vai alla chat - + Clear Chat Cancella chat - + Refresh Aggiorna - + Skip Forward Salta Avanti - + Skip Backward Salta Indietro - + Skip Forward by a lot Salta Avanti di Molto - + Skip Backward by a lot Salta Indietro di Molto - + Play/Pause Riproduci/Pausa - + Toggle Fast Forward Attiva/Disattiva Avanzamento Rapido - + Home Home - + Visual Deck Storage Galleria mazzi - + Deck Storage Archivio mazzi - + Server Server - + Account Account - + Administration Amministrazione - + Logs Registri diff --git a/cockatrice/translations/cockatrice_ja.ts b/cockatrice/translations/cockatrice_ja.ts index a53588fc1..322c70ac3 100644 --- a/cockatrice/translations/cockatrice_ja.ts +++ b/cockatrice/translations/cockatrice_ja.ts @@ -2,7 +2,7 @@ AbstractCounter - + &Set counter... (&S)カウンターの数を決める... @@ -10,12 +10,12 @@ AbstractCounterDialog - + Set counter カウンターの設定 - + New value for counter '%1': '%1'カウンターの新しい値: @@ -36,61 +36,61 @@ AbstractTabDeckEditor - + Open in new tab 新しいタブで開く - + Are you sure? よろしいですか? - + The decklist has been modified. Do you want to save the changes? デッキリストが変更されています。 変更を保存しますか? - - - - - - + + + + + + Error エラー - + Could not open deck at %1 - + Could not save remote deck - - + + The deck could not be saved. Please check that the directory is writable and try again. - + Save deck - + The deck could not be saved. - + There are no cards in your deck to be exported @@ -106,12 +106,12 @@ Please check that the directory is writable and try again. AdminNotesDialog - + Update Notes リリースノート - + Admin Notes for %1 @@ -128,172 +128,227 @@ Please check that the directory is writable and try again. Sideboard サイドボード + + + Tokens + + AppearanceSettingsPage - + seconds - + Error エラー - + Could not create themes directory at '%1'. '%1' にテーマディレクトリを作成できませんでした。 - + Theme settings テーマ設定 - + Current theme: 現在のテーマ - + Open themes folder テーマフォルダを開く - + Home tab background source: - + Home tab background shuffle frequency: - + Disabled - - Display card name of background in bottom right: - - - - + Menu settings メニュー設定 - + Show keyboard shortcuts in right-click menus 右クリックメニューにキーボードショートカットを表示する - + Show game filter toolbar above list in room tab - + Card rendering カードの描画 - + Display card names on cards having a picture 画像のあるカードにカード名を表示 - + Auto-Rotate cards with sideways layout 横向きレイアウトのカードを自動回転 - + Override all card art with personal set preference (Pre-ProviderID change behavior) - + + Light + + + + + Dark + + + + + System + + + + + Active theme palette: + + + + + Edit theme palette + + + + + Home tab settings + + + + + Display card name of background in bottom right + + + + + Styling settings + + + + + Style user list + + + + + Card printings + + + + Bump sets that the deck contains cards from to the top in the printing selector デッキに含まれるカードを印刷セレクターの一番上に上げる - + Scale cards on mouse over マウスオーバーでカードを拡大 - + Use rounded card corners - + + Card layout + + + + Minimum overlap percentage of cards on the stack and in vertical hand スタックと手札垂直配置時の重なり率の最小値 - + Maximum initial height for card view window: カードビューウィンドウの初期高さの最大値 - - + + rows - + Maximum expanded height for card view window: - + Card counters - + Counter %1 - + Hand layout 手札のレイアウト - + Display hand horizontally (wastes space) 手札を横に並べる - + Enable left justification 左揃えを有効 - + Table grid layout テーブルのレイアウト - + Invert vertical coordinate カード配置の垂直反転 - + Minimum player count for multi-column layout: プレイヤーを複数列レイアウトにする最少人数: - + Maximum font size for information displayed on cards: カードに表示する情報の最大のフォントサイズ: @@ -301,12 +356,12 @@ Please check that the directory is writable and try again. ArchidektApiResponseDeckDisplayWidget - + Back to results - + Open Deck in Deck Editor @@ -332,112 +387,112 @@ Please check that the directory is writable and try again. BanDialog - + ban &user name ユーザー名でBAN - + ban &IP address IPアドレスでBAN - + ban client I&D クライアントIDでBAN - + Ban type BANタイプ - + &permanent ban 永久BAN - + &temporary ban 一時BAN - + &Days: - + &Hours: 時間 - + &Minutes: - + Duration of the ban BAN期間 - + Please enter the reason for the ban. This is only saved for moderators and cannot be seen by the banned person. BANの理由を入力してください。 これはモデレーターによって保存されBANされた人には見えません。 - + Please enter the reason for the ban that will be visible to the banned person. BANの理由を入力してください。これはBANされる人に通知されます。 - + Redact all messages from this user in all rooms すべてのルームでこのユーザーからのすべてのメッセージを編集します - + &OK OK - + &Cancel キャンセル - + Ban user from server サーバーからBANされたユーザーです - - - - + + + + Error エラー - + You have to select a name-based, IP-based, clientId based, or some combination of the three to place a ban. BANの理由を3 つの組み合わせを選択する必要があります:不適切な名前、不適切なIPアドレス、クライアントID - + You must have a value in the name ban when selecting the name ban checkbox. 名前BANチェックボックスを選択するときは、名前BANの値を持っている必要があります。 - + You must have a value in the ip ban when selecting the ip ban checkbox. IP BANチェックボックスを選択するときは、IP BANの値を持っている必要があります。 - + You must have a value in the clientid ban when selecting the clientid ban checkbox. クライアントID BANチェックボックスを選択するときは、クライアントID BANに値を持っている必要があります。 @@ -445,59 +500,123 @@ This is only saved for moderators and cannot be seen by the banned person. BetaReleaseChannel - + Beta Beta(開発中バージョン) - + No reply received from the release update server. リリース更新サーバーから応答がありませんでした。 - + Invalid reply received from the release update server. リリース更新サーバーから無効な応答が受信されました。 - + No reply received from the file update server. ファイル更新サーバーからの応答がありませんでした。 + + CardArtPreviewWidget + + + No card selected + + + + + CardArtRulesModel + + + Card + + + + + ProviderId + + + + + Mode + + + + + Reason + + + CardDatabaseModel - + Name カード名 - + Sets カードセット - + Mana cost マナ・コスト - + Card type カード・タイプ - + P/T P/T - + Color(s) + + CardDatabaseView + + + Add to Deck + + + + + Add to Sideboard + + + + + Select Printing + + + + + Show on EDHRec (Commander) + + + + + Show on EDHRec (Card) + + + + + Show Related cards + + + CardFilter @@ -626,22 +745,22 @@ This is only saved for moderators and cannot be seen by the banned person. CardInfoPictureWidget - + View related cards 関連カードを見る - + Add card to deck デッキにカードを追加 - + Mainboard メインボード - + Sideboard サイドボード @@ -664,12 +783,12 @@ This is only saved for moderators and cannot be seen by the banned person. - + Related cards: 関連カード: - + Unknown card: 謎のカード: @@ -677,128 +796,146 @@ This is only saved for moderators and cannot be seen by the banned person. CardMenu - + Re&veal to... - + &All players - + View related cards - + Token: - + All tokens - + &Select All - + S&elect Row - + S&elect Column - + &Play - + &Hide - + Play &Face Down - + &Tap / Untap Turn sideways or back again - + Skip &untapping - + T&urn Over Turn face up/face down - + &Peek at card face - + &Clone - + Attac&h to card... - + Unattac&h - + &Draw arrow... - + &Set annotation... - + + Reduce life by power + + + + Ca&rd counters - + &Add counter (%1) - + &Remove counter (%1) - + &Set counters (%1)... + + CardPictureLoaderCacheMethod + + + Network Cache + + + + + Filesystem + + + CardSizeWidget @@ -810,133 +947,133 @@ This is only saved for moderators and cannot be seen by the banned person. CardZoneLogic - + their hand nominative - + %1's hand nominative - + their library look at zone - + %1's library look at zone - + of their library top cards of zone, - + of %1's library top cards of zone - + their library reveal zone - + %1's library reveal zone - + their library shuffle - + %1's library shuffle - + their library nominative - + %1's library nominative - + their graveyard nominative - + %1's graveyard nominative - + their exile nominative - + %1's exile nominative - + their sideboard look at zone - + %1's sideboard look at zone - + their sideboard nominative - + %1's sideboard nominative - + their custom zone '%1' nominative - + %1's custom zone '%2' nominative @@ -958,16 +1095,421 @@ This is only saved for moderators and cannot be seen by the banned person. + + ColorButton + + + Click to pick a color + + + + + Pick colour + + + + + ConnectionController + + + + The server has reached its maximum user capacity, please check back later. + + + + + There are too many concurrent connections from your address. + + + + + Banned by moderator + + + + + Expected end time: %1 + + + + + This ban lasts indefinitely. + + + + + Scheduled server shutdown. + + + + + + Invalid username. + + + + + You have been logged out due to logging in at another location. + + + + + Connection closed + + + + + The server has terminated your connection. +Reason: %1 + + + + + The server is going to be restarted in %n minute(s). +All running games will be lost. +Reason for shutdown: %1 + + + + + Scheduled server shutdown + + + + + Failed Login + + + + + Your client seems to be missing features this server requires for connection. + + + + + To update your client, go to 'Help -> Check for Client Updates'. + + + + + + + + + + + + + + + + + + + + + + Error + + + + + Incorrect username or password. Please check your authentication information and try again. + + + + + There is already an active session using this user name. +Please close that session first and re-login. + + + + + + You are banned until %1. + + + + + + You are banned indefinitely. + + + + + This server requires user registration. Do you want to register now? + + + + + This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. +Please close and reopen your client to try again. + + + + + An internal error has occurred, please close and reopen Cockatrice before trying again. +If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. + + + + + Account activation + + + + + Your account has not been activated yet. +You need to provide the activation token received in the activation email. + + + + + Server Full + + + + + Unknown login error: %1 + + + + + + +This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. + + + + + + + + + + Registration denied + + + + + Registration is currently disabled on this server + + + + + There is already an existing account with the same user name. + + + + + It's mandatory to specify a valid email address when registering. + + + + + It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. + + + + + Password too short. + + + + + Registration failed for a technical problem on the server. + + + + + The connection to the server has been lost. + + + + + Unknown registration error: %1 + + + + + Account activation failed + + + + + Socket error: %1 + + + + + Server timeout + + + + + You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. +Local version is %1, remote version is %2. + + + + + Your Cockatrice client is obsolete. Please update your Cockatrice version. +Local version is %1, remote version is %2. + + + + + + Success + + + + + Registration accepted. +Will now login. + + + + + Account activation accepted. +Will now login. + + + + + Information + + + + + This server supports additional features that your client doesn't have. +This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. + +To update your client, go to Help -> Check for Updates. + + + + + + + Reset Password + + + + + Your password has been reset successfully, you can now log in using the new credentials. + + + + + Failed to reset user account password, please contact the server operator to reset your password. + + + + + Activation request received, please check your email for an activation token. + + + + + Connecting to %1... + + + + + Registering to %1 as %2... + + + + + Disconnected + + + + + Connected, logging in at %1 + + + + + Requesting forgotten password to %1 as %2... + + + + + Your username must respect these rules: + + + + + is %1 - %2 characters long + + + + + can %1 contain lowercase characters + + + + + + + + NOT + + + + + can %1 contain uppercase characters + + + + + can %1 contain numeric characters + + + + + can contain the following punctuation: %1 + + + + + first character can %1 be a punctuation mark + + + + + no unacceptable language as specified by these server rules: + note that the following lines will not be translated + + + + + can not contain any of the following words: %1 + + + + + can not match any of the following expressions: %1 + + + + + You may only use A-Z, a-z, 0-9, _, ., and - in your username. + + + CustomZoneMenu - + C&ustom Zones - - + + View custom zone '%1' @@ -975,30 +1517,35 @@ This is only saved for moderators and cannot be seen by the banned person. DeckAnalyticsWidget - + Add Panel - + Remove Panel - + Save Layout - + Load Layout + + + Include Sideboard + + DeckEditorCardDatabaseDockWidget - + Card Database @@ -1014,47 +1561,17 @@ This is only saved for moderators and cannot be seen by the banned person. DeckEditorDatabaseDisplayWidget - + Search by card name (or search expressions) - - Add to Deck - - - - - Add to Sideboard - - - - - Select Printing - - - - - Show on EDHRec (Commander) - - - - - Show on EDHRec (Card) - - - - - Show Related cards - - - - + Add card to &maindeck - + Add card to &sideboard @@ -1087,72 +1604,72 @@ This is only saved for moderators and cannot be seen by the banned person. - + Select Printing - + Deck - + Deck &name: - + Banner Card/Tags Visibility Settings - + Show banner card selection menu - + Show tags selection menu - + &Comments: - + Group by: - + Format: - + Hash: - + &Increment number - + &Decrement number - + &Remove row - + Swap card to/from sideboard @@ -1160,17 +1677,17 @@ This is only saved for moderators and cannot be seen by the banned person. DeckEditorFilterDockWidget - + Filters - + &Clear all filters - + Delete selected @@ -1301,169 +1818,113 @@ This is only saved for moderators and cannot be seen by the banned person. DeckEditorSettingsPage - - + + Update Spoilers スポイラーを更新 - - + Success 完了 - + Download URLs have been reset. ダウンロードURLをリセットしました。 - - Downloaded card pictures have been reset. - ダウンロードしたカード画像を削除しました。 - - - - Error - エラー - - - - One or more downloaded card pictures could not be cleared. - 一部のカード画像が削除できませんでした。 - - - + Add URL URLを追加 - - + + URL: URL: - - + + Edit URL URLを編集 - - Network Cache Size: - ネットワークキャッシュサイズ: - - - - Redirect Cache TTL: - リダイレクトキャッシュの有効期限: - - - - How long cached redirects for urls are valid for. - キャッシュされたURLの転送の有効期間。 - - - - Picture Cache Size: - 画像キャッシュサイズ: - - - + Add New URL 新しいURLを追加 - + Remove URL URLを削除 - - Day(s) - - - - + Updating... 更新中... - + Choose path パスを選ぶ - + URL Download Priority ダウンロードURL優先設定 - + Spoilers スポイラー - + Download Spoilers Automatically スポイラーを自動的にダウンロード - + Spoiler Location: スポイラーの保存場所: - + Last Change 最新に更新 - + Spoilers download automatically on launch スポイラーは起動時に自動的にダウンロードされます。 - + Press the button to manually update without relaunching ボタンを押すと再起動せずに手動で更新します。 - + Do not close settings until manual update is complete 手動更新が完了するまでこのウィンドウを閉じないでください。 - + Download card pictures on the fly カード画像を自動的にダウンロード(英語) - + How to add a custom URL URLの追加方法について(英語) - - Delete Downloaded Images - ダウンロードした画像を削除 - - - + Reset Download URLs ダウンロードURLをデフォルトに戻す - - - On-disk cache for downloaded pictures - ダウンロードした画像のディスク上キャッシュ - - - - In-memory cache for pictures not currently on screen - 現在画面に表示されていない画像のメモリ内キャッシュ - DeckListHistoryManagerWidget @@ -1529,12 +1990,12 @@ This is only saved for moderators and cannot be seen by the banned person. DeckLoader - + Common deck formats (%1) - + All files (*.*) @@ -1756,27 +2217,27 @@ This is only saved for moderators and cannot be seen by the banned person. - + Moved to %1 1 × "%2" (%3) - + Removed "%1" (all copies) - + %1 1 × "%2" (%3) - + Added - + Removed @@ -1798,74 +2259,74 @@ This is only saved for moderators and cannot be seen by the banned person. DeckViewContainer - + Load deck... デッキを開く... - + Load remote deck... サーバーのデッキを開く... - + Load from clipboard... - + Load from website... - + Unload deck デッキを撤去 - + Ready to start 準備完了!( ・`д・´) - + Force start 強制開始 - + Sideboard unlocked サイドボード使用可能 - + Sideboard locked サイドボードロック中 - - + + Error エラー - + The selected file could not be loaded. 選択したファイルが開けませんでした。 - + Deck is greater than maximum file size. - + Are you sure you want to force start? This will kick all non-ready players from the game. - + Cockatrice @@ -1966,7 +2427,7 @@ This will kick all non-ready players from the game. - + Webpage ウェブページ @@ -2006,37 +2467,37 @@ This will kick all non-ready players from the game. サーバーに接続 - + Server URL サーバーURL - + Communication Port 通信ポート - + Unique Server Name ユニークサーバー名 - + Connection Warning 接続の警告 - + You need to name your new connection profile. 新しい接続プロファイルに名前を付ける必要があります。 - + Connect Warning 接続の警告 - + The player name can't be empty. プレイヤー名を入力して下さい。 @@ -2149,17 +2610,17 @@ This will kick all non-ready players from the game. ゲームを作成 - + Game information ゲーム情報 - + Error エラー - + Server error. サーバーエラー。 @@ -2167,97 +2628,97 @@ This will kick all non-ready players from the game. DlgCreateToken - + &Name: トークン名: - + Token トークン - + C&olor: 色: - + white - + blue - + black - + red - + green - + multicolor 多色 - + colorless 無色 - + &P/T: P/T: - + &Annotation: 注釈: - + &Destroy token when it leaves the table 戦場を離れる際に破棄 - + Create face-down (Only hides name) - + Token data トークン設定 - + Show &all tokens すべてのトークンを見る - + Show tokens from this &deck このデッキのトークンを見る - + Choose token from list 一覧からトークンを作成 - + Create token トークンを作成 @@ -2291,7 +2752,7 @@ This will kick all non-ready players from the game. - + @@ -2306,12 +2767,12 @@ This will kick all non-ready players from the game. - + Duplicate Tag - + This tag already exists. @@ -2498,12 +2959,12 @@ To remove your current avatar, confirm without choosing a new image. トークン名を入力: - + Error エラー - + The chosen name conflicts with an existing card or token. Make sure to enable the 'Token' set in the "Manage sets" dialog to display them correctly. 選択された名前は、既存のカードやトークンと競合しています。 @@ -2528,12 +2989,12 @@ Make sure to enable the 'Token' set in the "Manage sets" dia 指定なし - + Real name: 本名: - + Edit user profile ユーザー情報編集 @@ -2571,113 +3032,113 @@ Make sure to enable the 'Token' set in the "Manage sets" dia 2時間 - + Hide 'buddies only' games 「フレンドのみ」のゲームを非表示 - + Hide full games 満員のゲームを非表示 - + Hide games that have started 進行中のゲームを非表示 - + Hide password protected games パスワードが必要なゲームを非表示 - + Hide 'ignored user' games 無視ユーザーのゲームを非表示 - + Hide games not created by buddies Hide games not created by buddy - + Hide games with forced open decklists - + &Newer than: 部屋の経過時間: - + Game &description: 説明でフィルター: - + &Creator name: 作成者でフィルター: - + General 全般 - + &Game types ゲームタイプ - + at &least: 最少: - + at &most: 最大: - + Maximum player count プレイヤー人数: - + Restrictions 制限 - + Show games only if &spectators can watch 観戦できるゲームのみを表示 - + Show spectator password p&rotected games 観戦にパスワードが必要なゲームも表示 - + Show only if spectators can ch&at 観戦者がチャットできるゲームのみを表示 - + Show only if spectators can see &hands 手札が閲覧できるゲームのみを表示 - + Spectators 観戦 - + Filter games ゲームフィルタ @@ -2980,37 +3441,37 @@ https://tappedout.net/mtg-decks/your-deck-name/ DlgMoveTopCardsUntil - + Card name (or search expressions): カード名または検索条件: - + Number of hits: 枚数 - + Auto play hits 自動的にプレイ - + Put top cards on stack until... 見つかるまで上からスタックに置く... - + No cards matching the search expression exists in the card database. Proceed anyways? 条件に一致するカードはデータベースに存在しません。続行しますか? - + Cockatrice 悪魔からの助言 - + Invalid filter 不正な条件 @@ -3070,40 +3531,40 @@ Your email will be used to verify your account. 指定なし - + Real name: 本名: - + Register to server サーバーに登録 - - - - + + + + Registration Warning 登録に関する警告 - + Your password is too short. パスワードが短すぎます。 - + Your passwords do not match, please try again. パスワードが一致しませんでした。もう一度入力して下さい。 - + Your email addresses do not match, please try again. メールアドレスが一致しませんでした。もう一度入力して下さい。 - + The player name can't be empty. プレイヤー名を入力して下さい。 @@ -3111,17 +3572,17 @@ Your email will be used to verify your account. DlgRollDice - + Number of sides: 面の数: - + Number of dice: ダイスの数 - + Roll Dice 賽は投げられそう @@ -3172,12 +3633,12 @@ Your email will be used to verify your account. DlgSettings - + Unknown Error loading card database カードデータベース読み込みの不明なエラー。 - + Your card database is invalid. Cockatrice may not function correctly with an invalid database @@ -3194,7 +3655,7 @@ Oracle Importerでデータベースを更新する必要があります。 データベースの場所の設定を変更してみてください。 - + Your card database version is too old. This can cause problems loading card information or images @@ -3211,7 +3672,7 @@ Oracle Importerでデータベースを更新する必要があります。 データベースの場所の設定を変更してみてください。 - + Your card database did not finish loading Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues with your cards.xml attached @@ -3224,7 +3685,7 @@ Would you like to change your database location setting? データベースの場所の設定を変更しますか? - + File Error loading your card database. Would you like to change your database location setting? @@ -3233,7 +3694,7 @@ Would you like to change your database location setting? データベースの場所の設定を変更してみてください。 - + Your card database was loaded but contains no cards. Would you like to change your database location setting? @@ -3242,7 +3703,7 @@ Would you like to change your database location setting? データベースの場所の設定を変更してみてください。 - + Unknown card database load status Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues @@ -3255,59 +3716,64 @@ Would you like to change your database location setting? データベースの場所設定を変更しますか? - - - + + + Error エラー - + The path to your deck directory is invalid. Would you like to go back and set the correct path? あなたのデッキディレクトリへのパスは無効です。前に戻って正しいパスを設定してください。 - + The path to your card pictures directory is invalid. Would you like to go back and set the correct path? あなたのカード画像ディレクトリへのパスは無効です。前に戻って正しいパスを設定してください。 - + Settings 設定 - + General 全般 - + Appearance 外観 - + User Interface UI - + + Storage + + + + Card Sources カードダウンロード - + Chat チャット - + Sound サウンド - + Shortcuts ショートカット @@ -3401,9 +3867,9 @@ You can always change this behavior in the 'General' settings tab. - - - + + + Error エラー @@ -3456,31 +3922,31 @@ Please visit the download page to update manually. - + Update Available 更新できます!!! - + A new version of Cockatrice is available! 新しいバージョンのCockatriceが利用できます! - + New version 新しいバージョン - + Released 公開日時 - + Changelog 変更履歴 @@ -3490,51 +3956,51 @@ Please visit the download page to update manually. 今すぐ更新しますか? - + Unfortunately, the automatic updater failed to find a compatible download. You may have to manually download the new version. 残念ながら、自動アップデータは互換性のあるダウンロードを見つけることができませんでした。 You may have to manually download the new version. - + Please check the <a href="%1">releases page</a> on our Github and download the build for your system. - - - + + + Update Error 更新エラー - + An error occurred while checking for updates: 更新のチェック中にエラーが発生しました: - + An error occurred while downloading an update: 更新のダウンロード中にエラーが発生しました: - + Installing... インストール中... - + Cockatrice is unable to open the installer. インストーラーが開けませんでした。 - + Try to update manually by closing Cockatrice and running the installer. Cockatriceを終了した後、インストーラーから手動で更新してください。 - + Download location ダウンロード場所 @@ -3624,67 +4090,67 @@ You may have to manually download the new version. DrawProbabilityWidget - + Draw Probability - + Probability of drawing - + Card Name - + Type - + Subtype - + Mana Value - + At least - + Exactly - + card(s) having drawn at least - + cards - + Category - + Qty - + Odds (%) @@ -3798,7 +4264,7 @@ You may have to manually download the new version. FilterBuilder - + Type your filter here ここにフィルタを入力 @@ -3829,22 +4295,22 @@ You may have to manually download the new version. GameEventHandler - + kicked by game host or moderator - + player left the game - + player disconnected from server - + reason unknown @@ -3852,140 +4318,140 @@ You may have to manually download the new version. GameSelector - - - - - - - - - + + + + + + + + + Error エラー - + Please join the appropriate room first. 適切な部屋に参加してください。 - + Wrong password. パスワードが間違っています。 - + Spectators are not allowed in this game. このゲームは観戦出来ません。 - + The game is already full. このゲームはすでに満員です。 - + The game does not exist any more. このゲームはすでに存在しません。 - + This game is only open to registered users. このゲームは登録済みプレイヤーのみ参加できます。 - + This game is only open to its creator's buddies. このゲームは作成者のフレンドのみ参加できます。 - + You are being ignored by the creator of this game. あなたはゲームの作成者によって参加拒否されています。 - + Join Game 参加 - + Spectate Game 観戦 - + Game Information ゲーム情報 - + Join Game as Judge - + Spectate Game as Judge - + Join game 参加 - + Password: パスワード: - + Please join the respective room first. 最初にルームに参加してください。 - + &Filter games ゲームフィルタ - + C&lear filter フィルタ解除 - + C&reate ゲームを作成 - + &Join 参加 - + Join as judge - + J&oin as spectator 観戦 - + Join as judge spectator - + Games shown: %1 / %2 表示されているゲーム: %1 / %2 - + Games ゲーム @@ -3993,32 +4459,32 @@ You may have to manually download the new version. GameSelectorQuickFilterToolBar - + All types - + Filter by game name... - + Filter by game type/format - + Hide games not created by buddies - + Hide full games - + Hide started games @@ -4026,105 +4492,105 @@ You may have to manually download the new version. GamesModel - + >1 day >1 日 - + %1%2 hr short age in hours %1%2 時 - + new できたて - + %1%2 min short age in minutes %1%2 分 - + password パスワード - + buddies only フレンドのみ - + reg. users only 登録ユーザーのみ - + open decklists - + can chat チャット可 - + see hands 手札閲覧可 - + can see hands 手札閲覧可 - + not allowed 観戦不可 - + Room ルーム - + Age 作成時 - + Description 説明 - + Creator 作成者 - + Type タイプ - + Restrictions 制限 - + Players 人数 - + Spectators 観戦者 @@ -4132,143 +4598,158 @@ You may have to manually download the new version. GeneralSettingsPage - + Reset all paths すべてのパスをリセット - + All paths have been reset すべてのパスをリセットしました - - - - - - - + + + + + + + Choose path パスを選択 - - Personal settings - 個人設定 + + Language settings + - + Language: 言語: - + + Version settings + + + + + Card database + + + + + Startup settings + + + + Paths (editing disabled in portable mode) パス(ポータブルモードでの編集は無効) - + Paths パス - + How to help with translations - + Decks directory: デッキフォルダ: - + Filters directory: - + Replays directory: リプレイフォルダ: - + Pictures directory: カード画像フォルダ: - + Card database: カードデータベース: - + Custom database directory: カスタムデータベースフォルダ: - + Token database: トークンデータベース: - + Update channel 更新チャンネル - + Check for client updates on startup 起動時にクライアントの更新をチェック - + Check for card database updates on startup - + Don't check - + Prompt for update - + Always update in the background - + Check for card database updates every - + days - + Notify if a feature supported by the server is missing in my client サーバーでサポートされている機能がクライアントに存在しない場合に通知する - + Automatically run Oracle when running a new version of Cockatrice 新バージョンのCockatriceを起動したら自動的にOracleも起動する - + Show tips on startup 起動時にヒントを表示 - + Last update check on %1 (%2 days ago) @@ -4276,47 +4757,47 @@ You may have to manually download the new version. GraveyardMenu - + &Graveyard - + &View graveyard - + &Move graveyard to... - + &Top of library - + &Bottom of library - + &All players - + &Hand - + &Exile - + Reveal random card to... @@ -4324,88 +4805,88 @@ You may have to manually download the new version. HandMenu - + &Hand - + &View hand - + Sort hand by... - + Name - + Type - + Mana Value - + Take &mulligan (Choose hand size) - + Take mulligan (Same hand size) - + Take mulligan (Hand size - 1) - + &Move hand to... - + &Top of library - + &Bottom of library - + &Graveyard - + &Exile - + &Reveal hand to... - - + + All players - + Reveal r&andom card to... @@ -4413,52 +4894,52 @@ You may have to manually download the new version. HomeWidget - + Create New Deck - + Browse Decks - + Browse Card Database - + Browse EDHRec - + Browse Archidekt - + View Replays - + Quit - + Connecting... - + Connect - + Play @@ -4466,213 +4947,213 @@ You may have to manually download the new version. LibraryMenu - + &Library - + &View library - + View &top cards of library... - + View bottom cards of library... - + Reveal &library to... - + Lend library to... - + Reveal &top cards to... - + &Top of library... - + &Bottom of library... - + &Always reveal top card - + &Always look at top card - + &Open deck in deck editor - + &Draw card - + D&raw cards... - + &Undo last draw - + Shuffle - + &Play top card - + Play top card &face down - + Put top card on &bottom - + Move top card to grave&yard - + Move top card to e&xile - + Move top cards to &graveyard... - + Move top cards to graveyard face down... - + Move top cards to &exile... - + Move top cards to exile face down... - + Put top cards on stack &until... - + Shuffle top cards... - + &Draw bottom card - + D&raw bottom cards... - + &Play bottom card - + Play bottom card &face down - + Move bottom card to grave&yard - + Move bottom card to e&xile - + Move bottom cards to &graveyard... - + Move bottom cards to graveyard face down... - + Move bottom cards to &exile... - + Move bottom cards to exile face down... - + Put bottom card on &top - + Shuffle bottom cards... - - + + &All players - + Reveal top cards of library - + Number of cards: (max. %1) @@ -4680,638 +5161,273 @@ You may have to manually download the new version. MainWindow - - - The server has reached its maximum user capacity, please check back later. - サーバーが満員です。 - - - - There are too many concurrent connections from your address. - あなたのアドレスからの同時接続が多すぎます。 - - - - Banned by moderator - モデレーターによるBAN - - - - Expected end time: %1 - 予想終了時間: %1 - - - - This ban lasts indefinitely. - 永久BANされています。 - - - - Scheduled server shutdown. - サーバーシャットダウン予定時間。 - - - - - Invalid username. - ユーザー名が無効です。 - - - - You have been logged out due to logging in at another location. - 別の場所でログインしているため、ログアウトしました。 - - - - Connection closed - 通信切断 - - - - The server has terminated your connection. -Reason: %1 - サーバーはあなたの接続を切断しました。 -理由: %1 - - - - The server is going to be restarted in %n minute(s). -All running games will be lost. -Reason for shutdown: %1 - このサーバーは%n分で再起動します。 -全ての進行中のゲームは失われます。 -シャットダウンの理由: %1 - - - - Scheduled server shutdown - サーバーシャットダウン予定時刻 - - - - - Success - 成功 - - - - Registration accepted. -Will now login. - 登録が完了しました。 -ログインします。 - - - - Account activation accepted. -Will now login. - アカウント有効化が完了しました。 -ログインします。 - - - - + + Player %1 プレイヤー %1 - + Load replay リプレイを開く - + About Cockatrice Cockatriceについて - + Version バージョン - + Cockatrice Webpage Cockatriceウェブページ - + Project Manager: プロジェクトマネージャ: - + Past Project Managers: 元プロジェクトマネージャ: - + Developers: デベロッパ: - + Our Developers リンク先参照 - + Help Develop! 開発者募集中! - + Translators: 翻訳者: - + Our Translators 翻訳者一覧 - + Help Translate! 翻訳者募集中! - + Support: サポート: - + Report an Issue 問題を報告 - + Troubleshooting トラブルシューティング - + F.A.Q. F.A.Q. - - - - - - - - - - - - - - - - - - - - + + Error エラー - - Server timeout - サーバータイムアウト - - - - Failed Login - ログイン失敗 - - - - Your client seems to be missing features this server requires for connection. - あなたのクライアントは、サーバーが必要とする機能をサポートしていません。クライアントを更新して再度お試しください。 - - - - To update your client, go to 'Help -> Check for Client Updates'. - クライアントをアップデートするには、メニューの「ヘルプ」から「クライアントの更新チェック」をクリックします。 - - - - Incorrect username or password. Please check your authentication information and try again. - ユーザー名かパスワードが正しくありません。 - - - - There is already an active session using this user name. -Please close that session first and re-login. - 既にこのユーザー名を使用しているアクティブなセッションが存在します。 -アクティブなセッションを閉じて再ログインしてください。 - - - - - You are banned until %1. - あなたは%1までBANされています。 - - - - - You are banned indefinitely. - あなたは無期限BANされています。 - - - - This server requires user registration. Do you want to register now? - このサーバーはユーザー登録が必要です。 -今すぐ登録しますか? - - - - This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. -Please close and reopen your client to try again. - このサーバはクライアントのIDを必要とします。あなたのクライアントはIDの生成に失敗しているか、変更されたクライアントを実行しています。 -クライアントを再起動してもう一度お試し下さい。 - - - - An internal error has occurred, please close and reopen Cockatrice before trying again. -If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. - 内部エラーが発生しました。Cockatriceを再起動して再度お試しください。 -エラーが解決しない場合は、ソフトウェアの最新バージョンを実行していることを確認し、必要に応じてソフトウェア開発者にお問い合わせください。 - - - - Account activation - アカウント有効化 - - - - Your account has not been activated yet. -You need to provide the activation token received in the activation email. - このアカウントはまだ有効化されていません。 -登録されたアドレスに送信されているアクティベーショントークンを入力して下さい。 - - - - Server Full - サーバーが満員です。 - - - - Unknown login error: %1 - 不明なログインエラー: %1 - - - - - -This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. - -これは通常、あなたのクライアントのバージョンが古くなっていて、サーバーがあなたのクライアントを認識できないと返答してきていることを意味しています。 - - - - Your username must respect these rules: - ユーザー名は、これらの規則を遵守する必要があります: - - - - is %1 - %2 characters long - 文字数は%1~%2文字まで - - - - can %1 contain lowercase characters - 小文字%1可 - - - - - - - NOT - - - - - can %1 contain uppercase characters - 大文字%1可 - - - - can %1 contain numeric characters - 数字%1可 - - - - can contain the following punctuation: %1 - 次の記号が使用可:%1 - - - - first character can %1 be a punctuation mark - 最初の文字を記号にすることは%1可能 - - - - no unacceptable language as specified by these server rules: - note that the following lines will not be translated - サーバールールで定められている不適切な言葉は使用できません: - - - - can not contain any of the following words: %1 - 次の単語を含めることは不可能:%1 - - - - can not match any of the following expressions: %1 - 次の文字列を含めることは不可能:%1 - - - - You may only use A-Z, a-z, 0-9, _, ., and - in your username. - ユーザー名には『半角英数字』、および半角の「_」「.」「-」のみが使えます。 - - - - - - - - - Registration denied - 登録が拒否されました。 - - - - Registration is currently disabled on this server - 現在このサーバーでは登録が無効になっています。 - - - - There is already an existing account with the same user name. - 既に同じユーザー名のアカウントが登録されています。 - - - - It's mandatory to specify a valid email address when registering. - 登録時に有効な電子メールアドレスを指定する必要があります。 - - - - It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. - このサーバーに新しいアカウントを登録しようとしていますが、提供されたメールアドレスに登録されているアカウントが既に存在しています。このサーバーは、ユーザーがアドレスごとに登録できるアカウントの数を制限しています。詳細なサポートや資格情報の入手については、サーバーのオペレーターにお問い合わせください。 - - - - Password too short. - パスワードが短すぎます。 - - - - Registration failed for a technical problem on the server. - ユーザー登録に失敗しました(サーバーの技術的問題)。 - - - - The connection to the server has been lost. - - - - - Unknown registration error: %1 - 不明な登録エラー: %1 - - - - Account activation failed - アカウントの有効化に失敗しました。 - - - - Socket error: %1 - ソケットエラー: %1 - - - - You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. -Local version is %1, remote version is %2. - あなたは古いバージョンのサーバーに接続しようとしています。Cockatriceのバージョンをダウングレードするか適切なサーバーに接続してください。 -ローカルVer %1,サーバーVer %2 - - - - Your Cockatrice client is obsolete. Please update your Cockatrice version. -Local version is %1, remote version is %2. - あなたのCockatriceのバージョンがサーバーのバージョンより古いです。Cockatriceをアップデートしてください。 -ローカルVer %1,サーバーVer %2 - - - - Connecting to %1... - %1へ接続しています... - - - - Registering to %1 as %2... - %1に%2として登録中... - - - - Disconnected - 切断されました - - - - Connected, logging in at %1 - 接続完了、%1にログイン中 - - - - - - Requesting forgotten password to %1 as %2... - 忘れたパスワードを %1 に %2 として要求しています... - - - + &Connect... サーバーに接続... - + &Disconnect サーバーから切断 - + Start &local game... ローカルゲームを開始... - + &Watch replay... リプレイを見る... - + &Full screen フルスクリーン - + &Register to server... サーバーに登録する - + &Restore password... パスワードリセット... - + &Settings... 設定... - + &Exit 終了 - + A&ctions アクション - + &Cockatrice Cockatrice - + C&ard Database カードデータベース - + &Manage sets... セットを管理... - + Edit custom &tokens... カスタムトークンを編集... - + Open custom image folder カスタム画像フォルダを開く - + Open custom sets folder カスタムセットフォルダを開く - + Add custom sets/cards カスタムセット/カードを追加 - + Reload card database カードデータベースを再読み込み - + Tabs タブ - + &Help ヘルプ - + &About Cockatrice Cockatriceについて - + &Tip of the Day 今日のヒント - + Check for Client Updates クライアントの更新チェック - + Check for Card Updates... カードデータベース更新 - + Check for Card Updates (Automatic) - + Show Status Bar - + View &Debug Log デバッグログを見る - + Open Settings Folder 設定フォルダを開く - + Show/Hide 表示/非表示 - + New Version 新しいバージョン - + Congratulations on updating to Cockatrice %1! Oracle will now launch to update your card database. Cockatrice %1へのアップデートおめでとうございます! Oracleが起動し、カードデータベースが更新されます。 - + Cockatrice installed Cockatriceがインストールされました - + Congratulations on installing Cockatrice %1! Oracle will now launch to install the initial card database. Cockatrice %1のインストールおめでとうございます! Oracleを起動して、初期カードデータベースをインストールします。 - + Card database カードデータベース - + Cockatrice is unable to load the card database. Do you want to update your card database now? If unsure or first time user, choose "Yes" @@ -5320,29 +5436,29 @@ If unsure or first time user, choose "Yes" よくわからない場合、初回起動時などは「はい」を押して下さい。 - - + + Yes はい - - + + No いいえ - + Open settings 設定を開く - + New sets found 新しいセットが見つかりました - + %n new set(s) found in the card database Set code(s): %1 Do you want to enable it/them? @@ -5351,17 +5467,22 @@ Do you want to enable it/them? 有効にしますか? - + + Yes, always enable + + + + View sets セットを見る - + Welcome ようこそ - + Hi! It seems like you're running this version of Cockatrice for the first time. All the sets in the card database have been enabled. Read more about changing the set order or disabling specific sets and consequent effects in the "Manage Sets" dialog. @@ -5370,165 +5491,132 @@ Read more about changing the set order or disabling specific sets and consequent 「セットの設定」ウィンドウ内の特定のセットを無効にしたり、順序を変更をする方法の詳細をお読みください。 - - + Information 情報 - + A card database update is already running. データベース更新は既に起動中です。 - + Unable to run the card database updater: データベース更新が実行できませんでした: - + Card database update running. - + Failed to start. The file might be missing, or permissions might be incorrect. - + The process crashed some time after starting successfully. - + Timed out. The process took too long to respond. The last waitFor...() function timed out. - + An error occurred when attempting to write to the process. For example, the process may not be running, or it may have closed its input channel. プロセスへの書き込み中にエラーが発生しました。プロセスが実行されていないか、入力チャネルが閉じられている等の可能性があります。 - + An error occurred when attempting to read from the process. For example, the process may not be running. - + Unknown error occurred. - + The card database updater exited with an error: %1 - - This server supports additional features that your client doesn't have. -This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. - -To update your client, go to Help -> Check for Updates. - このサーバーは、クライアントにはない追加機能をサポートしています。通常、これは問題ではありませんが、新しいバージョンのCockatriceが使用可能であるか、このサーバーがカスタム版またはプレリリース版を実行している可能性があります。 - -クライアントを更新するには、ヘルプ→クライアントの更新のチェックをクリックしてください。 - - - - - - - + + + + + Load sets/cards セット/カードを読み込む - + Selected file cannot be found. 選択したファイルが見つかりませんでした。 - + You can only import XML databases at this time. ここではXMLデータベースのみをインポートできます。 - + The new sets/cards have been added successfully. Cockatrice will now reload the card database. 新しいセット/カードが正常に追加されました。 データベースが再読込されます。 - + Sets/cards failed to import. セット/カードのインポートに失敗 - - - - - Reset Password - パスワードリセット - - - - Your password has been reset successfully, you can now log in using the new credentials. - パスワードをリセットしました。 - - - - Failed to reset user account password, please contact the server operator to reset your password. - パスワードをリセットできませんでした。サーバー管理者にリセットしてもらってください。 - - - - Activation request received, please check your email for an activation token. - アクティベーションリクエストを受信しました。メールでアクティベーショントークンを確認してください。 - ManaBaseConfigDialog - + Mana Base Configuration - + Display type: - + pie - + bar - + combinedBar - + Filter Colors (optional): - + OK - + Cancel @@ -5600,27 +5688,27 @@ Cockatrice will now reload the card database. ManaDevotionConfigDialog - + Display type: - + pie - + bar - + combinedBar - + Filter Colors (optional): @@ -5690,610 +5778,615 @@ Cockatrice will now reload the card database. MessageLogWidget - + from play スタックから - + from their graveyard 墓地から - + from exile 追放領域から - + from their hand 手札から - + the top card of %1's library %1のライブラリーの一番上のカード - + the top card of their library ライブラリーの一番上のカード - + from the top of %1's library %1のライブラリーの一番上から - + from the top of their library ライブラリーの一番上から - + the bottom card of %1's library %1のライブラリーの一番下のカード - + the bottom card of their library ライブラリーの一番下のカード - + from the bottom of %1's library %1のライブラリーの一番下から - + from the bottom of their library ライブラリーの一番下のカード - + from %1's library %1のライブラリーから - + from their library ライブラリーから - + from sideboard サイドボードから - + from the stack スタックから - + from custom zone '%1' - + %1 is now keeping the top card %2 revealed. %1 は%2一番上のカードを公開した状態でゲームをプレイしている。 - + %1 is not revealing the top card %2 any longer. %1 は%2一番上のカードの公開を終えた。 - + %1 can now look at top card %2 at any time. %1はいつでも%2一番上のカードを見ることができる。 - + %1 no longer can look at top card %2 at any time. %1はもう%2一番上のカードを見ることができない。 - + %1 attaches %2 to %3's %4. %1は%3の%4に%2をつけた。 - + %1 has conceded the game. %1 が投了した!!! - + %1 has unconceded the game. %1が投了を撤回した。 - + %1 has restored connection to the game. %1 がゲームに再接続した。 - + %1 has lost connection to the game. %1 はゲームから切断された。 - + %1 points from their %2 to themselves. %1 は %2 から自分自身へ対象を指定した。 - + %1 points from their %2 to %3. %1 は %2 から %3 へ対象を指定した。 - + %1 points from %2's %3 to themselves. %1 は %2 の %3 から自分自身へ対象を指定した。 - + %1 points from %2's %3 to %4. %1 は %2 の %3 から %4 へ対象を指定した。 - + %1 points from their %2 to their %3. %1 は %2 から %3 へ対象を指定した。 - + %1 points from their %2 to %3's %4. %1 は %2 から %3 の %4 へ対象を指定した。 - + %1 points from %2's %3 to their own %4. %1 は %2 の %3 から %4 へ対象を指定した。 - + %1 points from %2's %3 to %4's %5. %1 は %2 の %3 から %4 の %5 へ対象を指定した。 - + %1 creates a face down token. - + %1 creates token: %2%3. %1 はトークン: %2 を%3作成した。 - + %1 has loaded a deck (%2). %1 はデッキをロードした。ハッシュ:%2 - + %1 has loaded a deck with %2 sideboard cards (%3). %1 はデッキをロードした。サイドボード数:%2, ハッシュ:%3 - + %1 destroys %2. %1 は %2 を取り除いた。 - + a card カード - + %1 gives %2 control over %3. %1 は %2 に %3 のコントロールを渡した。 - + %1 puts %2 into play%3 face down. %1 は %2 を%3裏向きでプレイした。 - + %1 puts %2 into play%3. %1 は %2 を%3プレイした。 - + %1 puts %2%3 into their graveyard face down. - + %1 puts %2%3 into their graveyard. %1 は %2 を%3墓地に置いた。 - + %1 exiles %2%3 face down. - + %1 exiles %2%3. %1 は %2 を%3追放した。 - + %1 moves %2%3 to their hand. %1は %2 を%3手札に加えた。 - + %1 puts %2%3 into their library. %1 は %2 を%3ライブラリーに加えた。 - + %1 puts %2%3 onto the bottom of their library. %1 は %2 を%3ライブラリーの一番下に置いた。 - + %1 puts %2%3 on top of their library. %1 は %2 を%3ライブラリーの一番上に置いた。 - + %1 puts %2%3 into their library %4 cards from the top. %1 は %2 を%3ライブラリーの一番上から%4枚目に置いた。 - + %1 moves %2%3 to sideboard. %1 は %2 を%3サイドボードに置いた。 - + %1 plays %2%3 face down. - + %1 plays %2%3. %1 は %2 を%3プレイした。 - + %1 moves %2%3 to custom zone '%4' face down. - + %1 moves %2%3 to custom zone '%4'. - + %1 tries to draw from an empty library %1 は……カードがないライブラリーからカードを引こうとした! - + %1 draws %2 card(s). %1は%2枚カードを引いた。 - + %1 is looking at %2. %1 は%2を見ている。 - + %1 is looking at the %4 %3 card(s) %2. top card for singular, top %3 cards for plural %1 は%2%4%3枚のカードを見ている。 - + bottom 下から - + top 上から - + %1 turns %2 face-down. %1 は %2 を裏向きにした。 - + %1 turns %2 face-up. %1 は %2 を表向きにした。 - + The game has been closed. ゲームがクローズされました。 - + The game has started. ◇◇ゲーム開始!◇◇ - + You are flooding the game. Please wait a couple of seconds. - + %1 has joined the game. %1 がゲームに参加した。 - + %1 is now watching the game. %1 が観戦に参加した。 - + You have been kicked out of the game. ゲームからキックされました。 - + %1 has left the game (%2). %1 はゲームから離脱した(%2)。 - + %1 is not watching the game any more (%2). %1 が観戦から離脱した(%2)。 - + %1 is not ready to start the game any more. %1 は準備完了を解除した。 - + %1 shuffles their deck and draws a new hand of %2 card(s). %1はライブラリーを切り直し、新たにカードを%2枚引いた。 - + %1 shuffles their deck and draws a new hand. %1はライブラリーを切り直し、新たに手札を引いた。 - + You are watching a replay of game #%1. ゲーム#%1のリプレイを再生しています。 - + %1 is ready to start the game. %1 はゲーム開始の準備が完了した!! - + cards an unknown amount of cards カード - + %1 card(s) a card for singular, %1 cards for plural %1枚のカード - + %1 lends %2 to %3. %1 は %3 に%2を貸した。 - + %1 reveals %2 to %3. %1 は %3 に%2を見せた。 - + %1 reveals %2. %1 は%2を公開した。 - + %1 randomly reveals %2%3 to %4. %1 は%3無作為に %2 を選び、%4に見せた。 - + %1 randomly reveals %2%3. %1 は%3無作為に %2 を選び、公開した。 - + %1 peeks at face down card #%2. %1 は裏向きのカード#%2の表面を確認した。 - + %1 peeks at face down card #%2: %3. %1 は裏向きのカード#%2の表面を確認した ( %3 ) 。 - + %1 reveals %2%3 to %4. %1 は%3 %2 を %4 に見せた。 - + %1 reveals %2%3. %1 は%3 %2 を公開した。 - + %1 reversed turn order, now it's %2. %1はターン順を逆にした(%2)。 - + reversed 逆転 - + normal 順転 - + Heads - + Tails - + %1 flipped a coin. It landed as %2. %1 はコインを投げた。結果は……【%2】だ! - + %1 rolls a %2 with a %3-sided die. %1 は%3面ダイスをふり、【%2】を出した。 - + %1 flips %2 coins. There are %3 heads and %4 tails. %1 は%2枚のコインを投げた。表が【%3】枚、裏が【%4】枚出た。 - + %1 rolls a %2-sided dice %3 times: %4. %1 は%2面体サイコロを%3個振り、それぞれ【%4】を出した。 - + %1's turn. ■ %1 のターン■ - + %1 sets annotation of %2 to %3. %1 は %2 に注釈をつけた (%3)。 - - %1 places %2 "%3" counter(s) on %4 (now %5). + + %1 places %2 %3%4 counter(s) on %5 (now %6). - - %1 removes %2 "%3" counter(s) from %4 (now %5). + + %1 removes %2 %3%4 counter(s) from %5 (now %6). - + + %1 failed to undo their last draw. + + + + %1 sets counter %2 to %3 (%4%5). %1 は%2カウンターを%3に設定した (%4%5)。 - + %1 sets %2 to not untap normally. %1 は %2 をアンタップ・ステップの間にアンタップしないようにした。 - + %1 sets %2 to untap normally. %1 は %2 を通常どおりアンタップするように設定した。 - + %1 removes the PT of %2. %1 は %2 のP/Tを取り除いた。 - + %1 changes the PT of %2 from nothing to %4. %1 は %2 のP/Tを%4にした。 - + %1 changes the PT of %2 from %3 to %4. %1 は %2 のP/Tを%3から%4にした。 - + %1 has locked their sideboard. %1 はサイドボードをロックした。 - + %1 has unlocked their sideboard. %1 はサイドボードを解禁した。 - + %1 taps their permanents. %1 は自分のコントロールするパーマネントをタップした。 - + %1 untaps their permanents. %1 は自分のコントロールするパーマネントをアンタップした。 - + %1 taps %2. %1 は %2 をタップした。 - + %1 untaps %2. %1 は %2 をアンタップした。 - + %1 shuffles %2. %1 は%2を切り直した。 - + %1 shuffles the bottom %3 cards of %2. %1 は%2の一番下の%3枚のカードをに無作為の順番で置いた。 - + %1 shuffles the top %3 cards of %2. %1 は%2の一番上の%3枚のカードをに無作為の順番で置いた。 - + %1 shuffles cards %3 - %4 of %2. %1 は%2の %3 - %4 枚を無作為の順番で置いた。 - + %1 unattaches %2. %1 は %2 をはずした。 - + %1 undoes their last draw. %1 は最後に引いたカードを戻した。 - + %1 undoes their last draw (%2). %1 は最後に引いたカード ( %2 ) を戻した 。 @@ -6301,110 +6394,115 @@ Cockatrice will now reload the card database. MessagesSettingsPage - + Word1 Word2 Word3 ここに入力してください - + Add New Message 定型文を追加 - + Edit Message 定型文を編集 - + Remove Message 定型文を削除 - + Add message 定型文を追加 - - + + Message: 定型文: - + Edit message 定型文を編集 - + Chat settings チャット設定 - + Custom alert words 反応するキーワード - + Enable chat mentions メンション機能を有効 - + Enable mention completer メンション補完を有効 - + In-game message macros 定型文 - + How to use in-game message macros ゲーム内メッセージマクロの使い方 - + Ignore chat room messages sent by unregistered users 未登録ユーザーのルームメッセージを無視 - + Ignore private messages sent by unregistered users 未登録ユーザーの個人チャットを無視 - - + + Ignore private messages sent by non-buddy users + + + + + Invert text color 反転テキストの色 - + Enable desktop notifications for private messages 個人チャットのデスクトップ通知を有効 - + Enable desktop notification for mentions メンションのデスクトップ通知を有効 - + Enable room message history on join 参加時にルームのメッセージ履歴を表示 - - + + (Color is hexadecimal) (色は16進数) - + Separate words with a space, alphanumeric characters only 英数字のみ使用可能。単語を半角スペースで区切って下さい。 @@ -6412,42 +6510,42 @@ Cockatrice will now reload the card database. MoveMenu - + Move to - + &Top of library in random order - + X cards from the top of library... - + &Bottom of library in random order - + T&able - + &Hand - + &Graveyard - + &Exile @@ -6460,47 +6558,47 @@ Cockatrice will now reload the card database. カード・タイプ - + Mana Value マナ総量 - + Color(s) - + Loyalty 忠誠度 - + Main Card Type メインのカード・タイプ - + Mana Cost マナ・コスト - + P/T P/T - + Side 表裏 - + Layout レイアウト - + Color Identity 固有色 @@ -6523,6 +6621,152 @@ Cockatrice will now reload the card database. + + PaletteEditorDialog + + + + Reset + + + + + + Apply + + + + + + Save && Apply + + + + + ▼ Edit Palette + + + + + + ▶ Edit Palette + + + + + Palette Editor — %1 + + + + + <b>Palette Editor</b> &nbsp;·&nbsp; %1 + + + + + This theme ships no default palette files + + + + + Replace current colours with the theme author's defaults + + + + + Switch between the light and dark palette files + + + + + Editing: + + + + + Show or hide the per-role colour grid for manual tweaks + + + + + ↺ Revert to theme default + + + + + Discard unsaved edits and restore the last saved palette + + + + + Preview this palette without saving to disk + + + + + Write palette-%1.toml and reload the theme + + + + + Cannot save: this theme has no directory on disk + + + + + Save failed + + + + + Could not write %1 to: +%2 + + + + + No default found + + + + + No default palette file found for the "%1" scheme. + + + + + PaletteGridWidget + + + Active + + + + + Disabled + + + + + Inactive + + + + + Normal interactive state + + + + + Widget is disabled / not interactive + + + + + Window is in background / unfocused + + + Phase @@ -6589,57 +6833,57 @@ Cockatrice will now reload the card database. PhasesToolbar - + Untap step アンタップ・ステップ - + Upkeep step アップキープ・ステップ - + Draw step ドロー・ステップ - + First main phase 戦闘前メイン・フェイズ - + Beginning of combat step 戦闘開始ステップ - + Declare attackers step 攻撃クリーチャー指定ステップ - + Declare blockers step ブロック・クリーチャー指定ステップ - + Combat damage step 戦闘ダメージ・ステップ - + End of combat step 戦闘終了ステップ - + Second main phase 戦闘後メイン・フェイズ - + End of turn step 最終フェイズ @@ -6647,7 +6891,7 @@ Cockatrice will now reload the card database. PictureLoader - + en code for scryfall's language property, not available for all languages ja @@ -6656,151 +6900,142 @@ Cockatrice will now reload the card database. PlayerActions - - View top cards of library - - - - - - - - - - - - - Number of cards: (max. %1) - - - - - View bottom cards of library - - - - - Shuffle top cards of library - - - - - Shuffle bottom cards of library - - - - - Draw hand - - - - - 0 and lower are in comparison to current hand size - - - - - Draw cards - - - - - - - + + + + grave - - - - + + + + exile + + + PlayerDialogs - + + View top cards of library + + + + + + + + + + + + + Number of cards: (max. %1) + + + + + View bottom cards of library + + + + + Shuffle top cards of library + + + + + Shuffle bottom cards of library + + + + + Draw hand + + + + + 0 and lower are in comparison to current hand size + + + + + Draw cards + + + + Move top cards to %1 - + Move bottom cards to %1 - + Draw bottom cards - - - C&reate another %1 token - - - - + Create tokens - - + Number: - + Place card X cards from top of library - + Which position should this card be placed: - + (max. %1) - + Change power/toughness - + Change stats to: - + Set annotation - + Please enter the new annotation: - - - Set counters - - PlayerMenu - + Player "%1" - + &Counters @@ -6830,7 +7065,7 @@ This setting means you'll only see the default printing for each card, instead o - + Printing Selector @@ -6838,22 +7073,22 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelectorCardOverlayWidget - + Preference 優先設定 - + Pin Printing ピン留めする - + Unpin Printing ピン留めを外す - + Show Related cards 関連カードを見る @@ -6924,57 +7159,57 @@ This setting means you'll only see the default printing for each card, instead o PtMenu - + Power / toughness - + &Increase power - + &Decrease power - + I&ncrease toughness - + D&ecrease toughness - + In&crease power and toughness - + Dec&rease power and toughness - + Increase power and decrease toughness - + Decrease power and increase toughness - + Set &power and toughness... - + Reset p&ower and toughness @@ -6982,37 +7217,37 @@ This setting means you'll only see the default printing for each card, instead o QMenuBar - + Services サービス - + Hide %1 %1を隠す - + Hide Others 他を隠す - + Show All すべて表示 - + Preferences... 設定... - + Quit %1 %1から出る - + About %1 %1について @@ -7020,17 +7255,17 @@ This setting means you'll only see the default printing for each card, instead o QObject - + Cockatrice card database (*.xml) Cockatriceカードデータベース(*.xml) - + All files (*.*) 全てのファイル (*.*) - + Cockatrice replays (*.cor) Cockatriceリプレイファイル (*.cor) @@ -7090,110 +7325,177 @@ Are you sure you would like to disable this feature? QPlatformTheme - + OK OK - + Save 保存 - + Save All 全て保存 - + Open 開く - + &Yes はい - + Yes to &All すべてはい - + &No いいえ - + N&o to All すべていいえ - + Abort 中止 - + Retry 再試行 - + Ignore 無視 - + Close 閉じる - + Cancel キャンセル - + Discard カードを捨てる - + Help ヘルプ - + Apply 適用 - + Reset リセット - + Restore Defaults デフォルトを復元 + + QuickSetupPanel + + + %1% + + + + + <b>Quick Setup</b> + + + + + Generate all palette roles automatically from a single accent colour + + + + + Accent: + + + + + Primary hue. Used directly for highlights and links. +At high intensity it also tints buttons and backgrounds. + + + + + Intensity: + + + + + Subtle + + + + + Full colour + + + + + 0–30 Subtle tint — only highlights and links change hue +30–70 Accented — buttons, tooltips, and borders join in +70–100 Full colour — backgrounds, everything + + + + + 70% + + + + + Generate ↓ + + + + + Derive all palette roles from the accent colour above. +Fine-tune individual colours in the grid afterwards. + + + RemoteDeckList_TreeModel - + Name デッキ名 - + ID ID - + Upload time アップロード日 @@ -7201,32 +7503,32 @@ Are you sure you would like to disable this feature? RemoteReplayList_TreeModel - + ID ID - + Name 説明 - + Players 参加プレイヤー - + Keep ロック - + Time started 開始日時 - + Duration (sec) 期間 (秒) @@ -7234,37 +7536,37 @@ Are you sure you would like to disable this feature? RfgMenu - + &Exile - + &View exile - + &Move exile to... - + &Top of library - + &Bottom of library - + &Hand - + &Graveyard @@ -7310,7 +7612,7 @@ Are you sure you would like to disable this feature? SayMenu - + S&ay @@ -7351,27 +7653,27 @@ Are you sure you would like to disable this feature? SetsModel - + Enabled 可能 - + Set type セットタイプ - + Set code セット略称 - + Long name 正式名称 - + Release date 公開日時 @@ -7379,53 +7681,53 @@ Are you sure you would like to disable this feature? ShortcutSettingsPage - - + + Restore all default shortcuts すべてのショートカットを元に戻す - + Do you really want to restore all default shortcuts? 本当にすべてのショートカットキーを元に戻しますか? - + Clear all default shortcuts すべてのショートカットをクリア - + Do you really want to clear all shortcuts? 本当にすべてのショートカットキーをクリアしますか? - + Section: セクション: - + Action: アクション: - + Shortcut: ショートカット: - + How to set custom shortcuts ショートカットキーの設定方法について(英語) - + Clear all shortcuts すべてのショートカットをクリア - + Search by shortcut name ショートカット名で検索 @@ -7481,12 +7783,12 @@ Please check your shortcut settings! SideboardMenu - + &Sideboard - + &View sideboard @@ -7494,27 +7796,27 @@ Please check your shortcut settings! SoundSettingsPage - + Enable &sounds サウンドを有効 - + Current sounds theme: 現在のサウンドテーマ: - + Test system sound engine サウンドテスト - + Sound settings サウンド設定 - + Master volume 音量 @@ -7586,16 +7888,146 @@ Please check your shortcut settings! リリース更新サーバーから無効な応答が受信されました。 - + No reply received from the tag update server. タグ更新サーバーからの応答がありませんでした。 - + Invalid reply received from the tag update server. タグ更新サーバーから無効な応答が受信されました。 + + StorageSettingsPage + + + + + Success + + + + + Cached card pictures have been reset. + + + + + Downloaded card pictures have been reset. + + + + + Error + + + + + One or more downloaded card pictures could not be cleared. + + + + + In-memory (currently loaded) card pictures have been reset. + + + + + Card Picture Loader Caching Method: + + + + + The network cache is the preferred way of storing images. Downloaded images are stored here until the size of the cache exceeds the configured size. Cockatrice automatically monitors this cache and deletes the least recently seen card images to ensure the cache does not exceed the configured size. + + + + + Writing card images directly to a folder on your hard drive is another way of storing images. This does not change how Cockatrice accesses or downloads images. Cockatrice will NOT automatically monitor and clear this folder, so if you enable this option, it is up to you to ensure sufficient available space. It should also be noted that if a provider outage causes you to download the wrong picture (i.e. wrong printing) you will be stuck with it until you manually delete the file, as opposed to using the network cache, which automatically rotates and thus correct errors after a while. + + + + + This is the in-memory picture cache used by the application at runtime. It determines how much memory (RAM) Cockatrice can use before it has to fetch card images from the hard disk again. Increasing this will allow more card images to be displayed at once but shouldn't be necessary. Clearing this will make Cockatrice reload all images from the network cache or the disk. + + + + + Delete Cached Images + + + + + Delete Saved Images + + + + + Clear In-Memory Images + + + + + Card Picture Loader Cache Method + + + + + Network Cache + + + + + Filesystem + + + + + In-Memory Picture Cache + + + + + Network Cache Size: + + + + + On-disk cache for downloaded pictures + + + + + Redirect Cache TTL: + + + + + How long cached redirects for urls are valid for. + + + + + Picture Cache Size: + + + + + In-memory cache for pictures not currently on screen + + + + + Naming scheme: + + + + + Day(s) + + + TabAccount @@ -7731,117 +8163,117 @@ Please check your shortcut settings! TabArchidekt - - + + Desc. - - + + AND - - + + Require ALL selected colors - - + + Deck name... - - + + Owner... - - + + Packages - - + + Advanced Filters - + Bracket: - - + + Any - - + + Contains card... - - + + Commander... - - + + Tag... - - + + Deck Size - + Cards: - - + + Asc. - + Sort by: - + Filter by: - + Display Settings - - + + Search - - + + Formats @@ -7851,6 +8283,54 @@ Please check your shortcut settings! + + TabCardArtRules + + + Card: + + + + + ProviderId: + + + + + Mode: + + + + + Reason: + + + + + Type a card name... + + + + + Add rule + + + + + Remove rule + + + + + Refresh + + + + + Card Art Rules + + + TabDeckEditor @@ -7899,7 +8379,7 @@ Please check your shortcut settings! レイアウトをリセット - + Deck: %1 デッキ: %1 @@ -7907,55 +8387,49 @@ Please check your shortcut settings! TabDeckEditorVisual - + Visual Deck: %1 - + &Visual Deck Editor - - + + Card Info - - + + Deck - - - Filters - - - - + &View - + Printing - + Visible - + Floating - + Reset layout @@ -7963,22 +8437,22 @@ Please check your shortcut settings! TabDeckEditorVisualTabWidget - + Visual Deck View - + Visual Database Display - + Deck Analytics - + Sample Hand @@ -7986,134 +8460,134 @@ Please check your shortcut settings! TabDeckStorage - + Local file system ローカルのデッキ - + Server deck storage サーバーに保存されているデッキ - - + + Open in deck editor デッキエディターで開く - + Rename deck or folder - + Upload deck デッキをアップロード - + Download deck デッキをダウンロード - - - + + + New folder 新しいフォルダーを作成する - + Delete 削除 - + Open decks folder - + Rename local folder - + Rename local file - + New name: - - - - + + + + Error エラー - + Rename failed - - + + Invalid deck file 無効なデッキファイル - + Enter deck name デッキの名前を入力 - + This decklist does not have a name. Please enter a name: このデッキリストには名前がありません。 名前を入力してください: - + Unnamed deck 無名のデッキ - + Failed to upload deck to server - + Delete local file ローカルファイルの削除 - + Are you sure you want to delete the selected files? - + Delete remote decks - + Are you sure you want to delete the selected decks? 選択したデッキを削除してもよろしいですか? - - + + Name of new folder: 新しいフォルダの名前: @@ -8185,191 +8659,191 @@ Please enter a name: TabGame - - - + + + Replay リプレイ - - + + Game ゲーム - - + + Player List プレイヤーリスト - - + + Card Info カード情報 - - + + Messages メッセージ - - + + Replay Timeline リプレイタイムライン - + &Phases フェイズ - + &Game ゲーム - + Next &phase 次のフェイズ - + Next phase with &action ターン起因処理をしながら次のフェイズ - + Next &turn 次のターン - + Reverse turn order ゲームのターン順を逆転する - + &Remove all local arrows 全ての対象指定を消す - + Rotate View Cl&ockwise 席を時計回りに回転 - + Rotate View Co&unterclockwise 席を反時計回りに回転 - + Game &information ゲーム情報 - + Un&concede 投了を撤回 - - - + + + &Concede 投了する - + &Leave game ゲームから離脱する - + C&lose replay リプレイを閉じる - + &Focus Chat - + &Say: 発言欄: - + Selected cards - + &View レイアウト - + Visible 表示する - + Floating 別ウィンドウにする - + Reset layout レイアウトをリセット - + Concede 投了する - + Are you sure you want to concede this game? 本当にこのゲームを投了しますか? - + Unconcede 投了を取り消す - + You have already conceded. Do you want to return to this game? あなたはすでに投了しています! 本当にゲームに復帰しますか? - + Leave game ゲームから離脱する - + Are you sure you want to leave this game? 本当にこのゲームから離脱しますか? - + A player has joined game #%1 プレイヤーがゲーム#%1に参加した。 - + %1 has joined the game %1 がゲームに参加した - + You have been kicked out of the game. ゲームからキックされました。 @@ -8428,115 +8902,115 @@ Please enter a name: 検索するには、有効な日数を選択する必要があります。 - + Username: ユーザー名: - + IP Address: IPアドレス: - + Game Name: ゲーム名: - + GameID: ゲームID: - + Message: メッセージ: - + Main Room メインルーム - + Game Room ゲームルーム - + Private Chat プライベートチャット - + Past X Days: X日経過: - + Today 今日 - + Last Hour - + Maximum Results: - + At least one filter is required. The more information you put in, the more specific your results will be. 少なくとも一つのフィルタが必要です。 情報を入力すればするほど詳細な検索結果になります。 - + Get User Logs ユーザーログを取得 - + Clear Filters フィルタ解除 - + Filters フィルタ - + Log Locations ログの場所 - + Date Range 日付指定 - + Maximum Results - - + + Message History メッセージ履歴 - + Failed to collect message history information. メッセージ履歴情報の収集に失敗しました。 - + There are no messages for the selected filters. 選択したフィルタのためのメッセージはありません。 @@ -8554,27 +9028,27 @@ The more information you put in, the more specific your results will be.退出する - + %1 - Private chat %1 - 個人チャット - + This user is ignoring you, they cannot see your messages in main chat and you cannot join their games. このユーザーはあなたを無視しています。メインチャットのメッセージは表示されず、ゲームに参加することもできません。 - + Private message from 個人チャット: - + %1 has left the server. %1はサーバーから退出しました。 - + %1 has joined the server. %1がサーバーに参加しました。 @@ -8582,180 +9056,180 @@ The more information you put in, the more specific your results will be. TabReplays - + Local file system ローカルのリプレイ - + Server replay storage サーバーに保存されたリプレイ - - + + Watch replay リプレイを再生 - + Rename 名前変更 - - + + New folder 新しいフォルダ - - + + Delete 削除 - + Open replays folder リプレイフォルダを開く - + Download replay リプレイをダウンロード - + Toggle expiration lock 有効期限ロックの切り替え - + Get replay share code - - + + Look up replay by share code - + Rename local folder ローカルフォルダの名前を変更 - + Rename local file ローカルファイルの名前を変更 - + New name: 新しい名前: - + Error エラー - + Rename failed 変更失敗 - + Name of new folder: 新しいフォルダの名前: - + Delete local file ローカルファイルの削除 - + Are you sure you want to delete the selected files? 選択したファイルを削除してもよろしいですか? - + Are you sure you want to delete the selected replays? 選択したリプレイを削除してもよろしいですか? - + Failed to get code - - + + Either this server does not support replay sharing, or does not permit replay sharing for you. - - - + + + Failed - + Could not get replay code - + Replay Share Code - + Others can use this code to add the replay to their list of remote replays: %1 - + Copy to clipboard - + Replay share code - + Replay code found - + Replay was added, or you already had access to it. - + Replay code not found - + Failed to submit code - + Unexpected error - + Delete remote replay サーバーファイルの削除 @@ -8768,47 +9242,62 @@ The more information you put in, the more specific your results will be. TabRoom - + + Friends + + + + + Online + + + + + Ignored + + + + &Say: 発言欄: - + Chat チャット - + &Room ルーム - + &Leave room ルームから出る - + &Clear chat チャットをクリア - + Chat Settings... チャット設定... - + mentioned you. あなたにメンションした。 - + Click to view クリックで見る - + You are flooding the chat. Please wait a couple of seconds. あなたはチャットルームから弾かれました。少々お待ちください。 @@ -8821,30 +9310,30 @@ The more information you put in, the more specific your results will be.サーバー - - - - + + + + Error エラー - + Failed to join the server room: it doesn't exist on the server. サーバールームに参加できませんでした: サーバーに存在しません。 - + The server thinks you are in the server room but your client is unable to display it. Try restarting your client. サーバーからサーバールームに入室中の応答がありましたが、クライアントが表示できませんでした。クライアントを再起動してみてください。 - + You do not have the required permission to join this server room. あなたはこのサーバールームに参加するための権限がありません。 - + Failed to join the server room due to an unknown error: %1. 原因不明のエラーによってサーバールームに参加できませんでした: %1。 @@ -8852,97 +9341,97 @@ The more information you put in, the more specific your results will be. TabSupervisor - + Deck Editor デッキエディター - + Visual Deck Editor - + EDHRec - + Archidekt - + Home - + &Visual Deck Storage ビジュアルデッキストレージ - + Visual Database Display - + Server サーバー - + Account アカウント - + Deck Storage デッキストレージ - + Game Replays リプレイ - + Administration 管理 - + Logs ログ - + Are you sure? よろしいですか? - + There are still open games. Are you sure you want to quit? ゲームがまだ開いています。本当に退出しますか? - + Click to view クリックで見る - + Your buddy %1 has signed on! %1がオンライン! - + Unknown Event 不明なイベント - + The server has sent you a message that your client does not understand. This message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -8953,39 +9442,39 @@ To update your client, go to Help -> Check for Updates. クライアントを更新するには、ヘルプ→更新のチェックをクリックしてください。 - + Idle Timeout 放置タイムアウト - + You are about to be logged out due to inactivity. 非アクティブのためログアウトしています。 - + Promotion 昇格 - + You have been promoted. Please log out and back in for changes to take effect. あなたは昇格されました。変更を有効にするためにログインしなおして下さい。 - + Warned 警告 - + You have received a warning due to %1. Please refrain from engaging in this activity or further actions may be taken against you. If you have any questions, please private message a moderator. 以下の理由によって警告を受けました: %1。 これらのような行動はご遠慮頂ますようお願い致します。質問がある場合は、モデレーターにプライベートメッセージを送信してください。 - + You have received the following message from the server. (custom messages like these could be untranslated) サーバーから次のメッセージを受け取りました。 @@ -8995,12 +9484,12 @@ Please refrain from engaging in this activity or further actions may be taken ag TabVisualDatabaseDisplay - + Database Display - + Visual Database Display @@ -9039,42 +9528,42 @@ Please refrain from engaging in this activity or further actions may be taken ag TranslateCounterName - + Life ライフ - + White - + Blue - + Black - + Red - + Green - + Colorless 無色 - + Other その他 @@ -9082,11 +9571,69 @@ Please refrain from engaging in this activity or further actions may be taken ag UpdateDownloader - + Could not open the file for reading. ファイルが開けませんでした。 + + UserCardArtSettingsDialog + + + Card Art Settings + + + + + Type a card name... + + + + + Card name: + + + + + Card ProviderId: + + + + + Left margin (%): + + + + + Right margin (%): + + + + + Vertical offset: + + + + + Zoom: + + + + + Parameters + + + + + Preview + + + + + Remove Banner Card + + + UserContextMenu @@ -9204,7 +9751,7 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Ban History BAN履歴 @@ -9219,77 +9766,87 @@ Please refrain from engaging in this activity or further actions may be taken ag ユーザーはBANされていません。 - + Failed to collect ban information. BAN情報の収集に失敗しました。 - - - + + + Warning History 警告履歴 - + Warning Time;Moderator;User Name;Reason 警告回数;モデレーター;ユーザー名;理由 - + User has never been warned. ユーザーは警告されていません。 - + Failed to collect warning information. 警告情報の収集に失敗しました。 - + Failed to get admin notes. 管理者ノートの取得に失敗しました。 - - + + Success 成功 - + Successfully promoted user. ユーザーを昇格しました。 - + Successfully demoted user. ユーザーを降格しました。 - - - + + Kick Player + + + + + Are you sure you want to kick this player from the game? + + + + + + Failed 失敗 - + Failed to promote user. 昇格に失敗しました。 - + Failed to demote user. 降格に失敗しました。 - + Copy hash to clipboard クリップボードにハッシュをコピー - + Remove this user's messages このユーザーのメッセージを削除 @@ -9297,326 +9854,554 @@ Please refrain from engaging in this activity or further actions may be taken ag UserInfoBox - + Location: 現在地: - + Account Age: アカウント年齢: - + Edit 編集 - + Change password パスワード変更 - + Change avatar アバター変更 - + Administrator 管理者 - + Moderator モデレーター - + Registered user 登録ユーザー - - + + Unregistered user 未登録ユーザー - + Judge ジャッジ - + Unknown 不明 - + The entered password does not match your account. 入力されたパスワードはアカウントと一致しません。 - - - + + + + Information 情報 - + User information updated. ユーザー情報が更新されました。 - - - - - - - - - - + + + + + + + + + + + Error エラー - + User Information ユーザー情報 - + Real Name: 本名: - + User Level: ユーザー種別: + + + Edit Banner Card + + - + %n Year(s), amount of years (only shown if more than 0) %n年 - + %10%n Day(s) %20 amount of years (if more than 0), amount of days, date in local short format %10%n日%20 - + Enter Password パスワードを入力 - + Password verification is required in order to change your email address メールアドレスを変更するには、パスワード認証が必要です - - - + + + An error occurred while trying to update your user information. ユーザー情報の更新中にエラーが発生しました。 - + + The selected card is blacklisted on this server or another error occurred. + + + + + Banner card removed. + + + + + Banner card updated. + + + + This server does not permit you to update your user informations. このサーバーはユーザーの情報を更新することができません。 - + Password changed. パスワードが変更されました。 - + This server does not permit you to change your password. このサーバーはパスワードを変更することができません。 - + The new password is too short. 新しいパスワードが短すぎます。 - + The old password is incorrect. 前のパスワードが一致しませんでした。 - + Avatar updated. アバターを更新しました。 - + This server does not permit you to update your avatar. このサーバーはアバターを更新することができません。 + + + An error occured while trying to update your avatar. + + - An error occured while trying to updater your avatar. - アバターの更新中にエラーが発生しました。 + This server does not permit you to update your user informations. + An error occured while trying to updater your avatar. + このサーバーはユーザーの情報を更新することができません。 + + + + UserInfoPopup + + + + Games + + + + + Chat + + + + + Open private chat + + + + + Profile + + + + + View user profile + + + + + Show this user's games + + + + + − Buddy + + + + + Remove from buddy list + + + + + + Buddy + + + + + Add to buddy list + + + + + − Ignore + + + + + Remove from ignore list + + + + + + Ignore + + + + + Add to ignore list + + + + + Ban + + + + + Ban from server + + + + + Warn + + + + + Warn user + + + + + Ban log + + + + + View ban history + + + + + Warn log + + + + + View warning history + + + + + Notes + + + + + View admin notes + + + + + − Mod + + + + + Demote from moderator + + + + + + Mod + + + + + Promote to moderator + + + + + − Judge + + + + + Demote from judge + + + + + + Judge + + + + + Promote to judge + + + + + Join game + + + + + Spectate + + + + + + Loading games… + + + + + Could not load games. + + + + + No active games. + UserInterfaceSettingsPage - + General interface settings インターフェース全般設定 - + &Double-click cards to play them (instead of single-click) ダブルクリックでカードをプレイする (シングルクリックの代わり) - + &Clicking plays all selected cards (instead of just the clicked card) クリックしたカードだけでなく、選択したすべてのカードをプレイする。 - + &Play all nonlands onto the stack (not the battlefield) by default 土地でないカードをプレイする時、すぐに戦場に出さずにスタックに置く - + Do not delete &arrows inside of subphases - + Close card view window when last card is removed 最後のカードが削除されたときにカード表示ウィンドウを閉じる - + Auto focus search bar when card view window is opened - + Annotate card text on tokens トークンのカードテキストを注釈にもつける - - Show selection counter during drag selection + + Show selection count during drag selection - - Show total selection counter + + Show total selection count - + + Show subtype breakdown in selection tally + + + + Use tear-off menus, allowing right click menus to persist on screen ティアオフメニューを使用して、右クリックメニューを画面に表示したままにする - + + Keep game chat focused when clicking in game (Note: disables card view search bar) + + + + Notifications settings 通知設定 - + Enable notifications in taskbar タスクバーの通知を有効 - + Notify in the taskbar for game events while you are spectating タスクバーに観戦中のゲームイベントを通知する - + Notify in the taskbar when users in your buddy list connect フレンドリストのユーザーが接続したときにタスクバーで通知する - + Animation settings アニメーション設定 - + &Tap/untap animation タップ/アンタップアニメーション - + Deck editor/storage settings デッキエディタ/ストレージ設定 - + Open deck in new tab by default デフォルトでデッキを新しいタブで開く - + Use visual deck storage in game lobby ゲームロビーでビジュアルデッキストレージを使用 - + Use selection animation for Visual Deck Storage - + When adding a tag in the visual deck storage to a .txt deck: - + do nothing - + ask to convert to .cod - + always convert to .cod - + Default deck editor type - + Classic Deck Editor - + Visual Deck Editor - + Replay settings リプレイ設定 - + Buffer time for backwards skip via shortcut: @@ -9624,22 +10409,22 @@ Please refrain from engaging in this activity or further actions may be taken ag UserListWidget - + Users connected to server: %1 ユーザーがサーバーに接続: %1 - + Users in this room: %1 ルームのユーザー数: %1 - + Buddies online: %1 / %2 フレンドオンライン: %1 / %2 - + Ignored users online: %1 / %2 無視ユーザーオンライン: %1 / %2 @@ -9647,35 +10432,45 @@ Please refrain from engaging in this activity or further actions may be taken ag UtilityMenu - + Increment all card counters - + &Untap all permanents - + R&oll die... - + + Flip coin + + + + &Create token... - + C&reate another token - + Cr&eate predefined token + + + C&reate another %1 token + + VisualDatabaseDisplayColorFilterWidget @@ -9727,72 +10522,72 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayFilterToolbarWidget - + Sort by - + Filter by - + Save and load filters - + Filter by exact card name - + Filter by card main-type - + Filter by card sub-type - + Filter by set - + Filter by format legality - + Save/Load - + Name - + Main Type - + Sub Type - + Sets - + Formats @@ -9800,22 +10595,22 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayFormatLegalityFilterWidget - + Show formats with at least: - + cards - + Do not display formats with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) @@ -9833,32 +10628,32 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayMainTypeFilterWidget - + Show main types with at least: - + cards - + Do not display card main-types with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) - + Mode: Exact Match - + Mode: Includes @@ -9866,27 +10661,27 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayNameFilterWidget - + Filter by name... (Exact match) - + Load from Deck - + Apply all card names in currently loaded deck as exact match name filters - + Load from Clipboard - + Apply all card names in clipboard as exact match name filters @@ -9894,7 +10689,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayRecentSetFilterSettingsWidget - + Filter to most recent sets @@ -9902,19 +10697,19 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplaySetFilterWidget - + Search sets... - - + + Mode: Exact Match - - + + Mode: Includes @@ -9922,37 +10717,37 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplaySubTypeFilterWidget - + Search subtypes... - + Show sub types with at least: - + cards - + Do not display card sub-types with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) - + Mode: Exact Match - + Mode: Includes @@ -9960,23 +10755,23 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayWidget - + Search by card name (or search expressions) - + Visual - + Loading database ... - + Clear all filters @@ -10015,17 +10810,17 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Toggle Layout: Overlap - + Change how cards are displayed within zones (i.e. overlapped or fully visible.) - + Toggle Layout: Flat @@ -10054,17 +10849,17 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckEditorWidget - + Type a card name here for suggestions from the database... - + Quick search and add card - + Search for closest match in the database (with auto-suggestions) and add preferred printing to the deck on pressing enter データベース内で最も近いものを検索し(自動提案付き)、Enter キーを押すと、優先印刷がデッキに追加されます。 @@ -10182,43 +10977,43 @@ Please refrain from engaging in this activity or further actions may be taken ag WarningDialog - + Which warning would you like to send? どの警告を送信しますか? - + Redact all messages from this user in all rooms すべてのルームでこのユーザーからのすべてのメッセージを編集します - + &OK OK - + &Cancel キャンセル - + Warn user for misconduct 不正行為をユーザーに警告 - - + + Error エラー - + User name to send a warning to can not be blank, please specify a user to warn. ユーザー名は空欄に出来ません。有効なユーザー名を選択して下さい。 - + Warning to use can not be blank, please select a valid warning to send. 警告は空欄に出来ません。有効な警告を選択して下さい。 @@ -10226,133 +11021,133 @@ Please refrain from engaging in this activity or further actions may be taken ag WndSets - + Move selected set to the top 選択したセットを一番上へ - + Move selected set up 選択中のセットを上へ - + Move selected set down 選択中のセットを下へ - + Move selected set to the bottom 選択したセットを一番下へ - + Search by set name, code, or type セット名、略称、タイプで検索 - + Default order 初期順に戻す - + Restore original art priority order 元のアートの優先順位を復元する - + Enable all sets すべてのセットを有効 - + Disable all sets すべてのセットを無効 - + Enable selected set(s) 選択したセットを有効 - + Disable selected set(s) 選択したセットを無効 - + Deck Editor デッキエディター - + Use CTRL+A to select all sets in the view. Ctrl + A キーですべてのセットを選択します。 - + Only cards in enabled sets will appear in the card list of the deck editor. 有効にしたセットのカードのみがデッキエディタのカードリストに表示されます。 - + Image priority is decided in the following order: 画像の優先順位は、次の順序で決定されます: - + first the CUSTOM Folder (%1), then the Enabled Sets in this dialog (Top to Bottom) %1 is a link to the wiki 最優先はCUSTOMフォルダ(%1)、以降はこのダイアログで有効化されたセットの並び順 - + Include cards rebalanced for Alchemy [requires restart] - + Card Art カードアート - + How to use custom card art カスタムカードアートの使い方 - + Hints ヒント - + Note 注意 - + Sorting by column allows you to find a set while not changing set priority. 列でソートすると、セットの優先度設定を変えずにセットを探すことができます。 - + To enable ordering again, click the column header until this message disappears. 優先度設定を行うには、このメッセージが消えるまで同じ列見出しをクリックしてください。 - + Use the current sorting as the set priority instead 現在のソートを優先度設定として使用する - + Sorts the set priority using the same column 同じ列を使用して優先度をソートする - + Manage sets セットを管理 @@ -10360,72 +11155,72 @@ Please refrain from engaging in this activity or further actions may be taken ag ZoneViewWidget - + Search by card name (or search expressions) - + Ungrouped グループなし - + Group by Type タイプ別グループ - + Group by Mana Value マナ・コスト別グループ - + Group by Color 色別グループ - + Unsorted 順不同 - + Sort by Name 名前順 - + Sort by Type タイプ順 - + Sort by Mana Cost マナ・コスト順 - + Sort by Colors 色順 - + Sort by P/T P/T順 - + Sort by Set セット順 - + shuffle when closing 閉じる時に切り直す - + pile view タイプ別に重ねて表示 @@ -10433,7 +11228,7 @@ Please refrain from engaging in this activity or further actions may be taken ag i18n - + English 日本語 (Japanese) @@ -10441,12 +11236,12 @@ Please refrain from engaging in this activity or further actions may be taken ag main - + Connect on startup 起動時に接続 - + Debug to file @@ -10460,7 +11255,7 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Deck Editor デッキエディター @@ -10541,7 +11336,7 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Replays リプレイ @@ -10656,129 +11451,129 @@ Please refrain from engaging in this activity or further actions may be taken ag デッキを開く... - - + + Load Deck from Clipboard... クリップボードからデッキを開く... - + Edit Deck in Clipboard, Annotated - + Edit Deck in Clipboard - + New Deck 新しいデッキ - + Open Custom Pictures Folder カスタム画像フォルダを開く - + Print Deck... デッキを印刷... - + Delete Card カードを削除 - - + + Reset Layout レイアウトをリセット - + Save Deck デッキを保存 - + Save Deck as... 名前を付けて保存... - + Save Deck to Clipboard, Annotated - + Save Deck to Clipboard, Annotated (No Set Info) - + Save Deck to Clipboard クリップボードにデッキをコピー - + Save Deck to Clipboard (No Set Info) クリップボードにデッキをコピー(セット情報なし) - + Load Local Deck... デッキ読み込み... - + Load Remote Deck... サーバーのデッキを開く... - + Set Ready to Start 準備完了する - + Toggle Sideboard Lock サイドボードロックの切り替え - + Add Green Counter 緑カウンターを置く - + Remove Green Counter 緑カウンターを取り除く - + Set Green Counters... 緑カウンターの数を決める... - + Add Red Counter 赤カウンターを置く - + Remove Red Counter 赤カウンターを取り除く - + Set Red Counters... 赤カウンターの数を決める... - + Add Life Counter ライフを得る @@ -10788,724 +11583,744 @@ Please refrain from engaging in this activity or further actions may be taken ag - + + Load deck from online service... + + + + + Load from website... + + + + Unload Deck - + Force Start - + Add Card Counter (F) - + Remove Card Counter (F) - + Set Card Counters (F)... - + Add Card Counter (E) - + Remove Card Counter (E) - + Set Card Counters (E)... - + Add Card Counter(D) - + Remove Card Counter (D) - + Set Card Counters (D)... - + Add Card Counter (C) - + Remove Card Counter (C) - + Set Card Counters (C)... - + Add Card Counter (B) - + Remove Card Counter (B) - + Set Card Counters (B)... - + Add Card Counter (A) - + Remove Card Counter (A) - + Set Card Counters (A)... - + Remove Life Counter ライフを失う - + Set Life Counters... ライフ総量を決める... - + Add White Counter 白カウンターを置く - + Remove White Counter 白カウンターを取り除く - + Set White Counters... - + Add Blue Counter - + Remove Blue Counter - + Set Blue Counters... - + Add Black Counter - + Remove Black Counter - + Set Black Counters... - + Add Colorless Counter - + Remove Colorless Counter - + Set Colorless Counters... - + Add Other Counter - + Remove Other Counter - + Set Other Counters... - + Increment all card counters - + Add Power (+1/+0) - + Remove Power (-1/-0) - + Move Toughness to Power (+1/-1) - + Add Toughness (+0/+1) - + Remove Toughness (-0/-1) - + Move Power to Toughness (-1/+1) - + Add Power and Toughness (+1/+1) - + Remove Power and Toughness (-1/-1) - + Set Power and Toughness... - + Reset Power and Toughness - + Untap アンタップ - + Upkeep アップキープ - + Draw ドロー - + First Main Phase 戦闘前メイン・フェイズ - + Start Combat - + Attack 攻撃 - + Block ブロック - + Damage ダメージ - + End Combat - + Second Main Phase 戦闘後メイン・フェイズ - + End エンド - + Next Phase 次のフェイズ - + Next Phase Action 処理しながら次のフェイズ - + Next Turn 次のターン - + Hide Card in Reveal Window - + Tap / Untap Card タップ / アンタップ - + Untap All - + Toggle Skip Untapping Toggle Untap - + Turn Card Over - + Peek Card - + Play Card カードをプレイ - + Play Card, Face Down - + Attach Card... - + Unattach Card - + Clone Card カードをコピー - + Create Token... トークンを生成する... - + Create All Related Tokens - + Create Another Token - + Set Annotation... - + + Reduce Life by Power + + + + Select All Cards in Zone - + Select All Cards in Row - + Select All Cards in Column - + Reveal Selected Cards to All Players - - + + Bottom of Library ライブラリーの一番下 - - - - + + + + Exile 追放領域 - - - - + + + + Graveyard 墓地 - - + + Hand 手札 - - + + Top of Library ライブラリーの一番上 - - + + Battlefield, Face Down - + Battlefield 戦場 - + Library 自分のライブラリー - + Sideboard サイドボード - + Top Cards of Library - + Bottom Cards of Library - + Close Recent View - - + + Stack スタック - - + + Graveyard (Multiple) - - + + Graveyard (Multiple), Face Down - - + + Exile (Multiple) - - + + Exile (Multiple), Face Down - + Stack Until Found - + Draw Bottom Card - + Draw Multiple Cards from Bottom... - + Draw Arrow... - + Remove Local Arrows - + Leave Game ゲームから退出 - + Concede 投了 - + Roll Dice... - + + Flip Coin + + + + Shuffle Library - + Shuffle Top Cards of Library - + Shuffle Bottom Cards of Library - + Mulligan マリガン - + Mulligan (Same hand size) - + Mulligan (Hand size - 1) - + Draw a Card - + Draw Multiple Cards... - + Undo Draw - + Always Reveal Top Card - + Always Look At Top Card - + Sort Hand by Name - + Sort Hand by Type - + Sort Hand by Mana Value - + Reveal Hand to All Players - + Reveal Random Card to All Players - + Rotate View Clockwise - + Rotate View Counterclockwise - + Unfocus Text Box - + Focus Chat - + Clear Chat - + Refresh 更新 - + Skip Forward - + Skip Backward - + Skip Forward by a lot - + Skip Backward by a lot - + Play/Pause - + Toggle Fast Forward - + Home - + Visual Deck Storage - + Deck Storage - + Server - + Account - + Administration - + Logs diff --git a/cockatrice/translations/cockatrice_ko.ts b/cockatrice/translations/cockatrice_ko.ts index 5ec1dac77..4b06f2a5e 100644 --- a/cockatrice/translations/cockatrice_ko.ts +++ b/cockatrice/translations/cockatrice_ko.ts @@ -2,7 +2,7 @@ AbstractCounter - + &Set counter... 카운터 설정... @@ -10,12 +10,12 @@ AbstractCounterDialog - + Set counter 카운터 설정 - + New value for counter '%1': 카운터 '%1'의 값을 지정해 주세요: @@ -36,60 +36,60 @@ AbstractTabDeckEditor - + Open in new tab - + Are you sure? - + The decklist has been modified. Do you want to save the changes? - - - - - - + + + + + + Error - + Could not open deck at %1 - + Could not save remote deck - - + + The deck could not be saved. Please check that the directory is writable and try again. - + Save deck - + The deck could not be saved. - + There are no cards in your deck to be exported @@ -105,12 +105,12 @@ Please check that the directory is writable and try again. AdminNotesDialog - + Update Notes - + Admin Notes for %1 @@ -127,172 +127,227 @@ Please check that the directory is writable and try again. Sideboard + + + Tokens + + AppearanceSettingsPage - + seconds - + Error 오류 - + Could not create themes directory at '%1'. - + Theme settings 테마 설정 - + Current theme: 현재 테마: - + Open themes folder - + Home tab background source: - + Home tab background shuffle frequency: - + Disabled - - Display card name of background in bottom right: - - - - + Menu settings - + Show keyboard shortcuts in right-click menus - + Show game filter toolbar above list in room tab - + Card rendering 카드 렌더링 - + Display card names on cards having a picture 이미지가 존재하는 카드에도 카드 이름 표시 - + Auto-Rotate cards with sideways layout - + Override all card art with personal set preference (Pre-ProviderID change behavior) - + + Light + + + + + Dark + + + + + System + + + + + Active theme palette: + + + + + Edit theme palette + + + + + Home tab settings + + + + + Display card name of background in bottom right + + + + + Styling settings + + + + + Style user list + + + + + Card printings + + + + Bump sets that the deck contains cards from to the top in the printing selector - + Scale cards on mouse over 마우스 오버 시 카드 확대 - + Use rounded card corners - + + Card layout + + + + Minimum overlap percentage of cards on the stack and in vertical hand - + Maximum initial height for card view window: - - + + rows - + Maximum expanded height for card view window: - + Card counters - + Counter %1 - + Hand layout 손 레이아웃 - + Display hand horizontally (wastes space) 손의 카드를 가로로 정렬 - + Enable left justification 손의 카드를 좌측으로 정렬 - + Table grid layout 테이블 격자 레이아웃 - + Invert vertical coordinate 전장의 상하배치를 전환 (대지를 앞에 배치) - + Minimum player count for multi-column layout: 다열 레이아웃를 위한 최소 플레이어 인원 (4명 이상 권장) - + Maximum font size for information displayed on cards: 카드 정보에 표시할 폰트의 최대 크기: @@ -300,12 +355,12 @@ Please check that the directory is writable and try again. ArchidektApiResponseDeckDisplayWidget - + Back to results - + Open Deck in Deck Editor @@ -331,112 +386,112 @@ Please check that the directory is writable and try again. BanDialog - + ban &user name 사용자 계정 추방 - + ban &IP address IP 주소 추방 - + ban client I&D 클라이언트 ID 추방 - + Ban type 추방 유형 - + &permanent ban 영구 추방 - + &temporary ban 기간제 추방 - + &Days: 일: - + &Hours: 시간: - + &Minutes: 분: - + Duration of the ban 추방 기간 - + Please enter the reason for the ban. This is only saved for moderators and cannot be seen by the banned person. 추방 사유를 적어주세요. 해당 사유는 관리자들만 볼 수 있고 추방 당한 사용자는 볼 수 없습니다. - + Please enter the reason for the ban that will be visible to the banned person. 추방된 사용자가 보게 될 추방 사유를 적어주세요. - + Redact all messages from this user in all rooms - + &OK 확인 - + &Cancel 취소 - + Ban user from server 사용자를 서버에서 추방 - - - - + + + + Error 오류 - + You have to select a name-based, IP-based, clientId based, or some combination of the three to place a ban. 추방의 유형을 선택해 주세요. - + You must have a value in the name ban when selecting the name ban checkbox. 사용자명 추방을 하기 위해선 사용자명을 입력하셔야 합니다. - + You must have a value in the ip ban when selecting the ip ban checkbox. 아이피 추방을 하기 위해선 아이피 주소를 입력하셔야 합니다. - + You must have a value in the clientid ban when selecting the clientid ban checkbox. 클라이언트 ID 추방을 하기 위해선 클라이언트 ID를 입력하셔야 합니다. @@ -444,59 +499,123 @@ This is only saved for moderators and cannot be seen by the banned person. BetaReleaseChannel - + Beta - + No reply received from the release update server. - + Invalid reply received from the release update server. - + No reply received from the file update server. + + CardArtPreviewWidget + + + No card selected + + + + + CardArtRulesModel + + + Card + + + + + ProviderId + + + + + Mode + + + + + Reason + + + CardDatabaseModel - + Name 카드 이름 - + Sets 확장판 - + Mana cost 마나 비용 - + Card type 카드 유형 - + P/T 공/방 - + Color(s) 색(들) + + CardDatabaseView + + + Add to Deck + + + + + Add to Sideboard + + + + + Select Printing + + + + + Show on EDHRec (Commander) + + + + + Show on EDHRec (Card) + + + + + Show Related cards + + + CardFilter @@ -625,22 +744,22 @@ This is only saved for moderators and cannot be seen by the banned person. CardInfoPictureWidget - + View related cards - + Add card to deck - + Mainboard - + Sideboard @@ -663,12 +782,12 @@ This is only saved for moderators and cannot be seen by the banned person. - + Related cards: - + Unknown card: @@ -676,128 +795,146 @@ This is only saved for moderators and cannot be seen by the banned person. CardMenu - + Re&veal to... - + &All players - + View related cards - + Token: - + All tokens - + &Select All - + S&elect Row - + S&elect Column - + &Play - + &Hide - + Play &Face Down - + &Tap / Untap Turn sideways or back again - + Skip &untapping - + T&urn Over Turn face up/face down - + &Peek at card face - + &Clone - + Attac&h to card... - + Unattac&h - + &Draw arrow... - + &Set annotation... - + + Reduce life by power + + + + Ca&rd counters - + &Add counter (%1) - + &Remove counter (%1) - + &Set counters (%1)... + + CardPictureLoaderCacheMethod + + + Network Cache + + + + + Filesystem + + + CardSizeWidget @@ -809,133 +946,133 @@ This is only saved for moderators and cannot be seen by the banned person. CardZoneLogic - + their hand nominative - + %1's hand nominative - + their library look at zone - + %1's library look at zone - + of their library top cards of zone, - + of %1's library top cards of zone - + their library reveal zone - + %1's library reveal zone - + their library shuffle - + %1's library shuffle - + their library nominative - + %1's library nominative - + their graveyard nominative - + %1's graveyard nominative - + their exile nominative - + %1's exile nominative - + their sideboard look at zone - + %1's sideboard look at zone - + their sideboard nominative - + %1's sideboard nominative - + their custom zone '%1' nominative - + %1's custom zone '%2' nominative @@ -957,16 +1094,421 @@ This is only saved for moderators and cannot be seen by the banned person. + + ColorButton + + + Click to pick a color + + + + + Pick colour + + + + + ConnectionController + + + + The server has reached its maximum user capacity, please check back later. + + + + + There are too many concurrent connections from your address. + + + + + Banned by moderator + + + + + Expected end time: %1 + + + + + This ban lasts indefinitely. + + + + + Scheduled server shutdown. + + + + + + Invalid username. + + + + + You have been logged out due to logging in at another location. + + + + + Connection closed + + + + + The server has terminated your connection. +Reason: %1 + + + + + The server is going to be restarted in %n minute(s). +All running games will be lost. +Reason for shutdown: %1 + + + + + Scheduled server shutdown + + + + + Failed Login + + + + + Your client seems to be missing features this server requires for connection. + + + + + To update your client, go to 'Help -> Check for Client Updates'. + + + + + + + + + + + + + + + + + + + + + + Error + + + + + Incorrect username or password. Please check your authentication information and try again. + + + + + There is already an active session using this user name. +Please close that session first and re-login. + + + + + + You are banned until %1. + + + + + + You are banned indefinitely. + + + + + This server requires user registration. Do you want to register now? + + + + + This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. +Please close and reopen your client to try again. + + + + + An internal error has occurred, please close and reopen Cockatrice before trying again. +If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. + + + + + Account activation + + + + + Your account has not been activated yet. +You need to provide the activation token received in the activation email. + + + + + Server Full + + + + + Unknown login error: %1 + + + + + + +This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. + + + + + + + + + + Registration denied + + + + + Registration is currently disabled on this server + + + + + There is already an existing account with the same user name. + + + + + It's mandatory to specify a valid email address when registering. + + + + + It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. + + + + + Password too short. + + + + + Registration failed for a technical problem on the server. + + + + + The connection to the server has been lost. + + + + + Unknown registration error: %1 + + + + + Account activation failed + + + + + Socket error: %1 + + + + + Server timeout + + + + + You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. +Local version is %1, remote version is %2. + + + + + Your Cockatrice client is obsolete. Please update your Cockatrice version. +Local version is %1, remote version is %2. + + + + + + Success + + + + + Registration accepted. +Will now login. + + + + + Account activation accepted. +Will now login. + + + + + Information + + + + + This server supports additional features that your client doesn't have. +This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. + +To update your client, go to Help -> Check for Updates. + + + + + + + Reset Password + + + + + Your password has been reset successfully, you can now log in using the new credentials. + + + + + Failed to reset user account password, please contact the server operator to reset your password. + + + + + Activation request received, please check your email for an activation token. + + + + + Connecting to %1... + + + + + Registering to %1 as %2... + + + + + Disconnected + + + + + Connected, logging in at %1 + + + + + Requesting forgotten password to %1 as %2... + + + + + Your username must respect these rules: + + + + + is %1 - %2 characters long + + + + + can %1 contain lowercase characters + + + + + + + + NOT + + + + + can %1 contain uppercase characters + + + + + can %1 contain numeric characters + + + + + can contain the following punctuation: %1 + + + + + first character can %1 be a punctuation mark + + + + + no unacceptable language as specified by these server rules: + note that the following lines will not be translated + + + + + can not contain any of the following words: %1 + + + + + can not match any of the following expressions: %1 + + + + + You may only use A-Z, a-z, 0-9, _, ., and - in your username. + + + CustomZoneMenu - + C&ustom Zones - - + + View custom zone '%1' @@ -974,30 +1516,35 @@ This is only saved for moderators and cannot be seen by the banned person. DeckAnalyticsWidget - + Add Panel - + Remove Panel - + Save Layout - + Load Layout + + + Include Sideboard + + DeckEditorCardDatabaseDockWidget - + Card Database @@ -1013,47 +1560,17 @@ This is only saved for moderators and cannot be seen by the banned person. DeckEditorDatabaseDisplayWidget - + Search by card name (or search expressions) - - Add to Deck - - - - - Add to Sideboard - - - - - Select Printing - - - - - Show on EDHRec (Commander) - - - - - Show on EDHRec (Card) - - - - - Show Related cards - - - - + Add card to &maindeck - + Add card to &sideboard @@ -1086,72 +1603,72 @@ This is only saved for moderators and cannot be seen by the banned person. - + Select Printing - + Deck - + Deck &name: - + Banner Card/Tags Visibility Settings - + Show banner card selection menu - + Show tags selection menu - + &Comments: - + Group by: - + Format: - + Hash: - + &Increment number - + &Decrement number - + &Remove row - + Swap card to/from sideboard @@ -1159,17 +1676,17 @@ This is only saved for moderators and cannot be seen by the banned person. DeckEditorFilterDockWidget - + Filters - + &Clear all filters - + Delete selected @@ -1300,169 +1817,113 @@ This is only saved for moderators and cannot be seen by the banned person. DeckEditorSettingsPage - - + + Update Spoilers - - + Success 성공 - + Download URLs have been reset. - - Downloaded card pictures have been reset. - - - - - Error - 오류 - - - - One or more downloaded card pictures could not be cleared. - - - - + Add URL URL 추가 - - + + URL: URL: - - + + Edit URL URL 수정 - - Network Cache Size: - - - - - Redirect Cache TTL: - - - - - How long cached redirects for urls are valid for. - - - - - Picture Cache Size: - - - - + Add New URL 새 URL 추가 - + Remove URL URL 제거 - - Day(s) - - - - + Updating... - + Choose path - + URL Download Priority - + Spoilers - + Download Spoilers Automatically - + Spoiler Location: - + Last Change - + Spoilers download automatically on launch - + Press the button to manually update without relaunching - + Do not close settings until manual update is complete - + Download card pictures on the fly - + How to add a custom URL - - Delete Downloaded Images - - - - + Reset Download URLs - - - On-disk cache for downloaded pictures - - - - - In-memory cache for pictures not currently on screen - - DeckListHistoryManagerWidget @@ -1528,12 +1989,12 @@ This is only saved for moderators and cannot be seen by the banned person. DeckLoader - + Common deck formats (%1) - + All files (*.*) @@ -1755,27 +2216,27 @@ This is only saved for moderators and cannot be seen by the banned person. - + Moved to %1 1 × "%2" (%3) - + Removed "%1" (all copies) - + %1 1 × "%2" (%3) - + Added - + Removed @@ -1797,74 +2258,74 @@ This is only saved for moderators and cannot be seen by the banned person. DeckViewContainer - + Load deck... 덱 불러오기 - + Load remote deck... 원격 덱 불러오기 - + Load from clipboard... - + Load from website... - + Unload deck - + Ready to start 시작 준비 - + Force start - + Sideboard unlocked 사이드보드 카드 교체 가능 - + Sideboard locked 사이드보드 카드 교체 불가능 - - + + Error 오류 - + The selected file could not be loaded. 지정하신 파일을 불러올 수 없었습니다. - + Deck is greater than maximum file size. - + Are you sure you want to force start? This will kick all non-ready players from the game. - + Cockatrice @@ -1963,7 +2424,7 @@ This will kick all non-ready players from the game. - + Webpage @@ -2003,37 +2464,37 @@ This will kick all non-ready players from the game. - + Server URL - + Communication Port - + Unique Server Name - + Connection Warning - + You need to name your new connection profile. - + Connect Warning - + The player name can't be empty. 사용자명은 비어있을 수 없습니다. @@ -2146,17 +2607,17 @@ This will kick all non-ready players from the game. 게임 만들기 - + Game information 게임 정보 - + Error 오류 - + Server error. 서버 오류. @@ -2164,97 +2625,97 @@ This will kick all non-ready players from the game. DlgCreateToken - + &Name: 토큰 이름: - + Token 토큰 - + C&olor: 토큰 색: - + white 백색 - + blue 청색 - + black 흑색 - + red 적색 - + green 녹색 - + multicolor 다색 - + colorless 무색 - + &P/T: 공/방 - + &Annotation: 주석: - + &Destroy token when it leaves the table 토큰이 전장을 떠나면 게임에서 제거됨 - + Create face-down (Only hides name) - + Token data 토큰 정보 - + Show &all tokens 모든 토큰 보기 - + Show tokens from this &deck 이 덱에서 사용하는 토큰 보기 - + Choose token from list 목록에서 토큰 선택 - + Create token 토큰 생성 @@ -2288,7 +2749,7 @@ This will kick all non-ready players from the game. - + @@ -2303,12 +2764,12 @@ This will kick all non-ready players from the game. - + Duplicate Tag - + This tag already exists. @@ -2495,12 +2956,12 @@ To remove your current avatar, confirm without choosing a new image. 토큰의 이름을 입력해 주세요. - + Error 오류 - + The chosen name conflicts with an existing card or token. Make sure to enable the 'Token' set in the "Manage sets" dialog to display them correctly. @@ -2524,12 +2985,12 @@ Make sure to enable the 'Token' set in the "Manage sets" dia 미지정 - + Real name: 본명: - + Edit user profile 사용자 프로필 변경 @@ -2567,113 +3028,113 @@ Make sure to enable the 'Token' set in the "Manage sets" dia 2 시간 - + Hide 'buddies only' games - + Hide full games - + Hide games that have started - + Hide password protected games - + Hide 'ignored user' games - + Hide games not created by buddies Hide games not created by buddy - + Hide games with forced open decklists - + &Newer than: - + Game &description: 게임 이름: - + &Creator name: 방장 이름: - + General 일반 - + &Game types 게임 종류 - + at &least: 최소 참가 인원: - + at &most: 최대 참가 인원: - + Maximum player count 최대 플레이어 인원 - + Restrictions - + Show games only if &spectators can watch - + Show spectator password p&rotected games - + Show only if spectators can ch&at - + Show only if spectators can see &hands - + Spectators 관전자 - + Filter games 게임 필터 @@ -2976,37 +3437,37 @@ https://tappedout.net/mtg-decks/your-deck-name/ DlgMoveTopCardsUntil - + Card name (or search expressions): - + Number of hits: - + Auto play hits - + Put top cards on stack until... - + No cards matching the search expression exists in the card database. Proceed anyways? - + Cockatrice - + Invalid filter @@ -3065,40 +3526,40 @@ Your email will be used to verify your account. 미지정 - + Real name: 실명: - + Register to server 서버에 가입 - - - - + + + + Registration Warning 가입 실패 - + Your password is too short. - + Your passwords do not match, please try again. 입력된 비밀번호가 서로 다릅니다. 다시 시도하여 주십시오. - + Your email addresses do not match, please try again. 입력된 이메일이 서로 다릅니다. 다시 시도하여 주십시오. - + The player name can't be empty. 사용자명은 비어있을 수 없습니다. @@ -3106,17 +3567,17 @@ Your email will be used to verify your account. DlgRollDice - + Number of sides: - + Number of dice: - + Roll Dice @@ -3167,12 +3628,12 @@ Your email will be used to verify your account. DlgSettings - + Unknown Error loading card database 데이터베이스를 불러올 때 미상의 오류가 발생하였습니다. - + Your card database is invalid. Cockatrice may not function correctly with an invalid database @@ -3189,7 +3650,7 @@ Would you like to change your database location setting? 데이터베이스 경로를 다시 설정하시겠습니까? - + Your card database version is too old. This can cause problems loading card information or images @@ -3206,7 +3667,7 @@ Would you like to change your database location setting? 데이터베이스 경로를 다시 설정하시겠습니까? - + Your card database did not finish loading Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues with your cards.xml attached @@ -3215,7 +3676,7 @@ Would you like to change your database location setting? - + File Error loading your card database. Would you like to change your database location setting? @@ -3224,7 +3685,7 @@ Would you like to change your database location setting? 데이터베이스 경로를 다시 설정하시겠습니까? - + Your card database was loaded but contains no cards. Would you like to change your database location setting? @@ -3233,7 +3694,7 @@ Would you like to change your database location setting? 데이터베이스 경로를 다시 설정하시겠습니까? - + Unknown card database load status Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues @@ -3242,61 +3703,66 @@ Would you like to change your database location setting? - - - + + + Error 오류 - + The path to your deck directory is invalid. Would you like to go back and set the correct path? 덱 파일을 보관하는 디렉토리의 경로가 잘못되었습니다. 경로를 다시 설정하시겠습니까? - + The path to your card pictures directory is invalid. Would you like to go back and set the correct path? 카드 이미지 파일을 보관하는 디렉토리의 경로가 잘못되었습니다. 경로를 다시 설정하시겠습니까? - + Settings 환경설정 - + General 일반 - + Appearance 외형 - + User Interface 인터페이스 - + + Storage + + + + Card Sources - + Chat 대화 - + Sound 소리 - + Shortcuts 단축키 @@ -3388,9 +3854,9 @@ You can always change this behavior in the 'General' settings tab. - - - + + + Error 오류 @@ -3443,31 +3909,31 @@ Please visit the download page to update manually. - + Update Available - + A new version of Cockatrice is available! - + New version 새로운 버젼 - + Released - + Changelog 변경점 @@ -3477,50 +3943,50 @@ Please visit the download page to update manually. 업데이트 하시겠습니까? - + Unfortunately, the automatic updater failed to find a compatible download. You may have to manually download the new version. - + Please check the <a href="%1">releases page</a> on our Github and download the build for your system. - - - + + + Update Error 업데이트 오류 - + An error occurred while checking for updates: - + An error occurred while downloading an update: - + Installing... 설치중... - + Cockatrice is unable to open the installer. - + Try to update manually by closing Cockatrice and running the installer. - + Download location 다운로드 위치 @@ -3610,67 +4076,67 @@ You may have to manually download the new version. DrawProbabilityWidget - + Draw Probability - + Probability of drawing - + Card Name - + Type - + Subtype - + Mana Value - + At least - + Exactly - + card(s) having drawn at least - + cards - + Category - + Qty - + Odds (%) @@ -3784,7 +4250,7 @@ You may have to manually download the new version. FilterBuilder - + Type your filter here @@ -3815,22 +4281,22 @@ You may have to manually download the new version. GameEventHandler - + kicked by game host or moderator - + player left the game - + player disconnected from server - + reason unknown @@ -3838,140 +4304,140 @@ You may have to manually download the new version. GameSelector - - - - - - - - - + + + + + + + + + Error 오류 - + Please join the appropriate room first. 해당 게임이 열려있는 채널에 먼저 들어가시기 바랍니다. - + Wrong password. 잘못된 비밀번호를 입력하셨습니다. - + Spectators are not allowed in this game. 관전이 허용되지 않은 게임입니다. - + The game is already full. 게임 인원이 전부 찼습니다. - + The game does not exist any more. 게임이 더 이상 존재하지 않습니다. - + This game is only open to registered users. 서버에 가입한 사용자만 참가 할 수 있습니다. - + This game is only open to its creator's buddies. 방장의 친구 목록에 등록된 사용자만 참가 할 수 있습니다. - + You are being ignored by the creator of this game. 방장이 당신을 차단하였습니다. - + Join Game - + Spectate Game - + Game Information - + Join Game as Judge - + Spectate Game as Judge - + Join game 게임 참가 - + Password: 비밀번호: - + Please join the respective room first. 해당 게임이 열려있는 채널에 먼저 들어가시기 바랍니다. - + &Filter games 게임 필터 - + C&lear filter 필터 해제 - + C&reate 게임 생성 - + &Join 게임 참가 - + Join as judge - + J&oin as spectator 관전자로 참가하기 - + Join as judge spectator - + Games shown: %1 / %2 - + Games 게임 목록 @@ -3979,32 +4445,32 @@ You may have to manually download the new version. GameSelectorQuickFilterToolBar - + All types - + Filter by game name... - + Filter by game type/format - + Hide games not created by buddies - + Hide full games - + Hide started games @@ -4012,105 +4478,105 @@ You may have to manually download the new version. GamesModel - + >1 day >1 일 - + %1%2 hr short age in hours - + new - + %1%2 min short age in minutes - + password 비밀번호 필요 - + buddies only 친구만 - + reg. users only 가입한 사용자 전용 - + open decklists - + can chat 관전자 대화 가능 - + see hands 손 공개 - + can see hands 관전자에게 손 공개 - + not allowed 허용 안됨 - + Room 게임 - + Age - + Description 게임 이름 - + Creator 방장 - + Type - + Restrictions 입장 제한 - + Players 플레이어 - + Spectators 관전자 @@ -4118,143 +4584,158 @@ You may have to manually download the new version. GeneralSettingsPage - + Reset all paths - + All paths have been reset - - - - - - - + + + + + + + Choose path 경로 선택 - - Personal settings - 개인 설정 + + Language settings + - + Language: 언어: - + + Version settings + + + + + Card database + + + + + Startup settings + + + + Paths (editing disabled in portable mode) - + Paths 디렉토리 경로 - + How to help with translations - + Decks directory: 덱 파일 경로: - + Filters directory: - + Replays directory: 리플레이 파일 경로: - + Pictures directory: 카드 이미지 경로: - + Card database: 카드 데이터베이스 경로: - + Custom database directory: - + Token database: 토큰 데이터베이스 경로: - + Update channel - + Check for client updates on startup - + Check for card database updates on startup - + Don't check - + Prompt for update - + Always update in the background - + Check for card database updates every - + days - + Notify if a feature supported by the server is missing in my client 서버에서 서포트하는 기능이 클라이언트에 존재하지 않을 경우 알림 - + Automatically run Oracle when running a new version of Cockatrice - + Show tips on startup - + Last update check on %1 (%2 days ago) @@ -4262,47 +4743,47 @@ You may have to manually download the new version. GraveyardMenu - + &Graveyard - + &View graveyard - + &Move graveyard to... - + &Top of library - + &Bottom of library - + &All players - + &Hand - + &Exile - + Reveal random card to... @@ -4310,88 +4791,88 @@ You may have to manually download the new version. HandMenu - + &Hand - + &View hand - + Sort hand by... - + Name - + Type - + Mana Value - + Take &mulligan (Choose hand size) - + Take mulligan (Same hand size) - + Take mulligan (Hand size - 1) - + &Move hand to... - + &Top of library - + &Bottom of library - + &Graveyard - + &Exile - + &Reveal hand to... - - + + All players - + Reveal r&andom card to... @@ -4399,52 +4880,52 @@ You may have to manually download the new version. HomeWidget - + Create New Deck - + Browse Decks - + Browse Card Database - + Browse EDHRec - + Browse Archidekt - + View Replays - + Quit - + Connecting... - + Connect - + Play @@ -4452,213 +4933,213 @@ You may have to manually download the new version. LibraryMenu - + &Library - + &View library - + View &top cards of library... - + View bottom cards of library... - + Reveal &library to... - + Lend library to... - + Reveal &top cards to... - + &Top of library... - + &Bottom of library... - + &Always reveal top card - + &Always look at top card - + &Open deck in deck editor - + &Draw card - + D&raw cards... - + &Undo last draw - + Shuffle - + &Play top card - + Play top card &face down - + Put top card on &bottom - + Move top card to grave&yard - + Move top card to e&xile - + Move top cards to &graveyard... - + Move top cards to graveyard face down... - + Move top cards to &exile... - + Move top cards to exile face down... - + Put top cards on stack &until... - + Shuffle top cards... - + &Draw bottom card - + D&raw bottom cards... - + &Play bottom card - + Play bottom card &face down - + Move bottom card to grave&yard - + Move bottom card to e&xile - + Move bottom cards to &graveyard... - + Move bottom cards to graveyard face down... - + Move bottom cards to &exile... - + Move bottom cards to exile face down... - + Put bottom card on &top - + Shuffle bottom cards... - - + + &All players - + Reveal top cards of library - + Number of cards: (max. %1) @@ -4666,845 +5147,453 @@ You may have to manually download the new version. MainWindow - - - The server has reached its maximum user capacity, please check back later. - 서버가 꽉 찼습니다. 나중에 다시 접속하여 주십시오. - - - - There are too many concurrent connections from your address. - 해당 IP주소에서 동시에 연결된 회선이 너무 많습니다. - - - - Banned by moderator - 관리자에 의해 서버에서 추방 당하였습니다. - - - - Expected end time: %1 - 예상 추방 종료 시간: %1 - - - - This ban lasts indefinitely. - 영구적 추방입니다. - - - - Scheduled server shutdown. - 정기 점검 중입니다. - - - - - Invalid username. - 잘못된 사용자명입니다. - - - - You have been logged out due to logging in at another location. - 다른 장소에서 로그인하여 접속이 해제되었습니다. - - - - Connection closed - 서버와의 연결이 끊어졌습니다. - - - - The server has terminated your connection. -Reason: %1 - 서버에서 연결을 끊었습니다. -사유: %1 - - - - The server is going to be restarted in %n minute(s). -All running games will be lost. -Reason for shutdown: %1 - 서버가 %n분 후 재시작 할 예정입니다. -진행 중인 게임은 저장되지 않습니다. -서버 재시작 사유: %1 - - - - Scheduled server shutdown - 정기 점검 - - - - - Success - 성공 - - - - Registration accepted. -Will now login. - 가입이 승인되었습니다. -로그인합니다. - - - - Account activation accepted. -Will now login. - 계정이 활성화 되었습니다. -로그인합니다. - - - - + + Player %1 플레이어 %1 - + Load replay 리플레이 불러오기 - + About Cockatrice 코카트리스에 관하여 - + Version - + Cockatrice Webpage 코카트리스 홈페이지 - + Project Manager: 현 프로젝트 매니저: - + Past Project Managers: 전 프로젝트 매니저: - + Developers: 개발진: - + Our Developers 기여자 목록 - + Help Develop! 개발을 도와주세요! - + Translators: 번역진: - + Our Translators 번역진: - + Help Translate! 번역을 도와주세요! - + Support: 기술 지원: - + Report an Issue 문제점 보고 - + Troubleshooting 문제 해결 - + F.A.Q. 자주 묻는 질문 - - - - - - - - - - - - - - - - - - - - + + Error 오류 - - Server timeout - 서버 응답시간 초과 - - - - Failed Login - 로그인 실패 - - - - Your client seems to be missing features this server requires for connection. - - - - - To update your client, go to 'Help -> Check for Client Updates'. - - - - - Incorrect username or password. Please check your authentication information and try again. - 잘못된 사용자명이나 비밀번호입니다. -확인 후 다시 시도해 주세요. - - - - There is already an active session using this user name. -Please close that session first and re-login. - 해당 사용자명으로 연결된 다른 세션이 있습니다. -해당 세션을 종료 한 후에 다시 시도해 주세요. - - - - - You are banned until %1. - %1까지 추방 당하였습니다. - - - - - You are banned indefinitely. - 당신은 무기한 추방 당하였습니다. - - - - This server requires user registration. Do you want to register now? - 본 서버는 가입이 필요합니다. -지금 가입하시겠습니까? - - - - This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. -Please close and reopen your client to try again. - - - - - An internal error has occurred, please close and reopen Cockatrice before trying again. -If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. - - - - - Account activation - 계정 활성화 - - - - Your account has not been activated yet. -You need to provide the activation token received in the activation email. - 계정이 아직 활성화 되지 않았습니다. -가입 시 기입한 메일 주소에서 계정 활성화 토큰을 확인하여 주십시오. - - - - Server Full - - - - - Unknown login error: %1 - 알 수 없는 로그인 오류: %1 - - - - - -This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. - -대부분 클라이언트가 오래되어서 서버와 클라이언트가 말이 제대로 통하지 않을때 발생하는 문제입니다. - - - - Your username must respect these rules: - 사용자명은 다음 조건을 만족해야 합니다: - - - - is %1 - %2 characters long - %1에서 %2자 사이 - - - - can %1 contain lowercase characters - 소문자 사용 %1가능 - - - - - - - NOT - - - - - can %1 contain uppercase characters - 대문자 사용 %1가능 - - - - can %1 contain numeric characters - 숫자 사용 %1가능 - - - - can contain the following punctuation: %1 - 다음 문장 부호를 사용 가능 : - - - - first character can %1 be a punctuation mark - 첫 글자를 문장 부호로 입력 %1가능 - - - - no unacceptable language as specified by these server rules: - note that the following lines will not be translated - - - - - can not contain any of the following words: %1 - - - - - can not match any of the following expressions: %1 - - - - - You may only use A-Z, a-z, 0-9, _, ., and - in your username. - 사용자명에는 영어 대소문자, 숫자, _(밑줄), .(마침표)나 -(대쉬)만 사용하실 수 있습니다. - - - - - - - - - Registration denied - 가입 실패 - - - - Registration is currently disabled on this server - 본 서버는 현재 가입을 받지 않습니다. - - - - There is already an existing account with the same user name. - 이미 존재하는 사용자명 입니다. - - - - It's mandatory to specify a valid email address when registering. - 사용 가능한 이메일 주소를 작성하셔야 합니다. - - - - It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. - - - - - Password too short. - 비밀번호가 너무 짧습니다. - - - - Registration failed for a technical problem on the server. - 서버의 기술적 문제로 가입에 실패하였습니다. - - - - The connection to the server has been lost. - - - - - Unknown registration error: %1 - 가입 중 알 수 없는 오류 발생: %1 - - - - Account activation failed - 계정 활성화 실패 - - - - Socket error: %1 - 소켓 오류: %1 - - - - You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. -Local version is %1, remote version is %2. - 서버 버전이 클라이언트보다 오래되었습니다. -클라이언트를 다운그레이드하거나 다른 서버에 연결해 주시기 바랍니다. -클라이언트 버전 %1, 서버 버전 %2. - - - - Your Cockatrice client is obsolete. Please update your Cockatrice version. -Local version is %1, remote version is %2. - 코카트리스 클라이언트 버전이 오래되었습니다. -클라이언트 업데이트를 해 주세요. -클라이언트 버전 %1, 서버 버전 %2. - - - - Connecting to %1... - %1로 연결 시도 중... - - - - Registering to %1 as %2... - 서버 %1에 %2(으)로 가입 중... - - - - Disconnected - 연결 안됨 - - - - Connected, logging in at %1 - 연결됨, %1로 로그인 시도 중 - - - - - - Requesting forgotten password to %1 as %2... - - - - + &Connect... 서버로 연결 - + &Disconnect 서버와의 연결 해제 - + Start &local game... 오프라인 게임 시작 - + &Watch replay... 리플레이 재생 - + &Full screen 전체 화면 - + &Register to server... 서버에 가입 - + &Restore password... - + &Settings... 환경설정 - + &Exit 끝내기 - + A&ctions 액션 - + &Cockatrice 코카트리스 - + C&ard Database - + &Manage sets... - + Edit custom &tokens... - + Open custom image folder 커스텀 이미지 폴더 열기 - + Open custom sets folder 커스텀 셋 폴더 열기 - + Add custom sets/cards 커스텀 셋/카드 추가 - + Reload card database - + Tabs - + &Help 도움말 - + &About Cockatrice 코카트리스에 관하여 - + &Tip of the Day - + Check for Client Updates 클라이언트 업데이트 확인 - + Check for Card Updates... - + Check for Card Updates (Automatic) - + Show Status Bar - + View &Debug Log - + Open Settings Folder - + Show/Hide - + New Version - + Congratulations on updating to Cockatrice %1! Oracle will now launch to update your card database. - + Cockatrice installed - + Congratulations on installing Cockatrice %1! Oracle will now launch to install the initial card database. - + Card database 카드 데이터베이스 - + Cockatrice is unable to load the card database. Do you want to update your card database now? If unsure or first time user, choose "Yes" - - + + Yes - - + + No 아니오 - + Open settings 설정 열기 - + New sets found - + %n new set(s) found in the card database Set code(s): %1 Do you want to enable it/them? - + + Yes, always enable + + + + View sets 셋 보기 - + Welcome 환영합니다 - + Hi! It seems like you're running this version of Cockatrice for the first time. All the sets in the card database have been enabled. Read more about changing the set order or disabling specific sets and consequent effects in the "Manage Sets" dialog. - - + Information 알림 - + A card database update is already running. 이미 카드 데이터베이스 업데이트가 진행 중입니다. - + Unable to run the card database updater: 카드 데이터베이스 업데이트를 진행할 수 없습니다: - + Card database update running. - + Failed to start. The file might be missing, or permissions might be incorrect. - + The process crashed some time after starting successfully. - + Timed out. The process took too long to respond. The last waitFor...() function timed out. - + An error occurred when attempting to write to the process. For example, the process may not be running, or it may have closed its input channel. - + An error occurred when attempting to read from the process. For example, the process may not be running. - + Unknown error occurred. - + The card database updater exited with an error: %1 - - This server supports additional features that your client doesn't have. -This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. - -To update your client, go to Help -> Check for Updates. - - - - - - - - + + + + + Load sets/cards - + Selected file cannot be found. - + You can only import XML databases at this time. - + The new sets/cards have been added successfully. Cockatrice will now reload the card database. - + Sets/cards failed to import. - - - - - Reset Password - - - - - Your password has been reset successfully, you can now log in using the new credentials. - - - - - Failed to reset user account password, please contact the server operator to reset your password. - - - - - Activation request received, please check your email for an activation token. - - ManaBaseConfigDialog - + Mana Base Configuration - + Display type: - + pie - + bar - + combinedBar - + Filter Colors (optional): - + OK - + Cancel @@ -5576,27 +5665,27 @@ Cockatrice will now reload the card database. ManaDevotionConfigDialog - + Display type: - + pie - + bar - + combinedBar - + Filter Colors (optional): @@ -5666,610 +5755,615 @@ Cockatrice will now reload the card database. MessageLogWidget - + from play 전장에 - + from their graveyard - + from exile 추방 영역에 - + from their hand 자신의 손에 - + the top card of %1's library %1의 서고 맨 위의 카드 - + the top card of their library - + from the top of %1's library %1의 서고 맨 위에 - + from the top of their library - + the bottom card of %1's library %1의 서고 맨 밑의 카드 - + the bottom card of their library - + from the bottom of %1's library %1의 서고 맨 밑에 - + from the bottom of their library - + from %1's library %1의 서고에 - + from their library - + from sideboard 사이드보드에 - + from the stack 스택에 - + from custom zone '%1' - + %1 is now keeping the top card %2 revealed. %1이(가) %2 맨 위 카드를 공개합니다. - + %1 is not revealing the top card %2 any longer. %1이(가) %2 맨 위 카드를 더 이상 공개하지 않습니다. - + %1 can now look at top card %2 at any time. - + %1 no longer can look at top card %2 at any time. - + %1 attaches %2 to %3's %4. - + %1 has conceded the game. - + %1 has unconceded the game. - + %1 has restored connection to the game. - + %1 has lost connection to the game. - + %1 points from their %2 to themselves. - + %1 points from their %2 to %3. - + %1 points from %2's %3 to themselves. - + %1 points from %2's %3 to %4. - + %1 points from their %2 to their %3. %1님이 자신의 %2에서 자신의 %3을(를) 가리켰습니다. - + %1 points from their %2 to %3's %4. %1님이 자신의 %2에서 %3님의 %4을(를) 가리켰습니다. - + %1 points from %2's %3 to their own %4. - + %1 points from %2's %3 to %4's %5. - + %1 creates a face down token. - + %1 creates token: %2%3. %1님이 토큰 생성: %2%3. - + %1 has loaded a deck (%2). %1이(가) 덱을 불러왔습니다. (해시값: %2) - + %1 has loaded a deck with %2 sideboard cards (%3). %1이(가) %2장의 사이드보드가 있는 덱을 불러왔습니다. (해시값: %3) - + %1 destroys %2. - + a card 카드 한 장 - + %1 gives %2 control over %3. %1이(가) %3의 조종권을 %2에게 넘깁니다. - + %1 puts %2 into play%3 face down. - + %1 puts %2 into play%3. %1이(가) %3서 %2을(를) 전장에 놓았습니다. - + %1 puts %2%3 into their graveyard face down. - + %1 puts %2%3 into their graveyard. - + %1 exiles %2%3 face down. - + %1 exiles %2%3. %1이(가) %3 있던 %2을(를) 추방합니다. - + %1 moves %2%3 to their hand. - + %1 puts %2%3 into their library. - + %1 puts %2%3 onto the bottom of their library. - + %1 puts %2%3 on top of their library. - + %1 puts %2%3 into their library %4 cards from the top. - + %1 moves %2%3 to sideboard. %1이(가) %3 있던 %2을(를) 서고 맨 밑에 넣었습니다. - + %1 plays %2%3 face down. - + %1 plays %2%3. %1이(가) %3서 %2을(를) 플레이 하였습니다. - + %1 moves %2%3 to custom zone '%4' face down. - + %1 moves %2%3 to custom zone '%4'. - + %1 tries to draw from an empty library - + %1 draws %2 card(s). - + %1 is looking at %2. - + %1 is looking at the %4 %3 card(s) %2. top card for singular, top %3 cards for plural - + bottom - + top - + %1 turns %2 face-down. - + %1 turns %2 face-up. - + The game has been closed. 게임이 종료되었습니다. - + The game has started. 게임이 시작되었습니다. - + You are flooding the game. Please wait a couple of seconds. - + %1 has joined the game. %1님이 게임에 입장했습니다. - + %1 is now watching the game. %1이(가) 관전을 시작하였습니다. - + You have been kicked out of the game. 게임에서 강제 퇴장 당하였습니다. - + %1 has left the game (%2). - + %1 is not watching the game any more (%2). - + %1 is not ready to start the game any more. - + %1 shuffles their deck and draws a new hand of %2 card(s). - + %1 shuffles their deck and draws a new hand. - + You are watching a replay of game #%1. - + %1 is ready to start the game. %1님이 게임 시작 준비를 끝냈습니다. - + cards an unknown amount of cards - + %1 card(s) a card for singular, %1 cards for plural - + %1 lends %2 to %3. - + %1 reveals %2 to %3. - + %1 reveals %2. %1님이 %2을(를) 공개. - + %1 randomly reveals %2%3 to %4. - + %1 randomly reveals %2%3. - + %1 peeks at face down card #%2. - + %1 peeks at face down card #%2: %3. - + %1 reveals %2%3 to %4. - + %1 reveals %2%3. - + %1 reversed turn order, now it's %2. - + reversed - + normal - + Heads 앞면 - + Tails 뒷면 - + %1 flipped a coin. It landed as %2. - + %1 rolls a %2 with a %3-sided die. - + %1 flips %2 coins. There are %3 heads and %4 tails. - + %1 rolls a %2-sided dice %3 times: %4. - + %1's turn. %1의 차례입니다. - + %1 sets annotation of %2 to %3. - - %1 places %2 "%3" counter(s) on %4 (now %5). + + %1 places %2 %3%4 counter(s) on %5 (now %6). - - %1 removes %2 "%3" counter(s) from %4 (now %5). + + %1 removes %2 %3%4 counter(s) from %5 (now %6). - + + %1 failed to undo their last draw. + + + + %1 sets counter %2 to %3 (%4%5). - + %1 sets %2 to not untap normally. - + %1 sets %2 to untap normally. - + %1 removes the PT of %2. - + %1 changes the PT of %2 from nothing to %4. - + %1 changes the PT of %2 from %3 to %4. - + %1 has locked their sideboard. - + %1 has unlocked their sideboard. - + %1 taps their permanents. %1님이 자신의 지속물을 탭. - + %1 untaps their permanents. %1님이 자신의 지속물을 언탭. - + %1 taps %2. %1님이 %2을(를) 탭. - + %1 untaps %2. %1님이 %2을(를) 언탭. - + %1 shuffles %2. %1님이 %2를 셔플했습니다. - + %1 shuffles the bottom %3 cards of %2. - + %1 shuffles the top %3 cards of %2. - + %1 shuffles cards %3 - %4 of %2. - + %1 unattaches %2. %1님이 %2을(를) 뗴어냈습니다. - + %1 undoes their last draw. - + %1 undoes their last draw (%2). @@ -6277,110 +6371,115 @@ Cockatrice will now reload the card database. MessagesSettingsPage - + Word1 Word2 Word3 - + Add New Message - + Edit Message - + Remove Message - + Add message 메세지 추가 - - + + Message: 메세지: - + Edit message - + Chat settings 대화 설정 - + Custom alert words 키워드 알림 - + Enable chat mentions 대화 중 본인의 사용자명 멘션 시 해당 문장을 강조 - + Enable mention completer 멘션 자동완성 기능 - + In-game message macros 게임 내 대화 매크로 - + How to use in-game message macros - + Ignore chat room messages sent by unregistered users 서버에 가입하지 않은 사용자의 채널 대화 차단 - + Ignore private messages sent by unregistered users 서버에 가입하지 않은 사용자가 보낸 1:1 대화 차단 - - + + Ignore private messages sent by non-buddy users + + + + + Invert text color 문장 색 반전 - + Enable desktop notifications for private messages 1:1 대화를 받을 시 데스크탑 알림 설정 - + Enable desktop notification for mentions 자신의 사용자명 멘션 시 데스크탑 알림 설정 - + Enable room message history on join 채널 입장 시 이전 대화 기록 표시 - - + + (Color is hexadecimal) (16진수 색상 코드) - + Separate words with a space, alphanumeric characters only 각 단어마다 스페이스바로 띄어써주세요. 문자 및 숫자만 가능합니다. @@ -6388,42 +6487,42 @@ Cockatrice will now reload the card database. MoveMenu - + Move to - + &Top of library in random order - + X cards from the top of library... - + &Bottom of library in random order - + T&able - + &Hand - + &Graveyard - + &Exile @@ -6436,47 +6535,47 @@ Cockatrice will now reload the card database. - + Mana Value - + Color(s) - + Loyalty - + Main Card Type - + Mana Cost - + P/T - + Side - + Layout - + Color Identity @@ -6499,6 +6598,152 @@ Cockatrice will now reload the card database. + + PaletteEditorDialog + + + + Reset + + + + + + Apply + + + + + + Save && Apply + + + + + ▼ Edit Palette + + + + + + ▶ Edit Palette + + + + + Palette Editor — %1 + + + + + <b>Palette Editor</b> &nbsp;·&nbsp; %1 + + + + + This theme ships no default palette files + + + + + Replace current colours with the theme author's defaults + + + + + Switch between the light and dark palette files + + + + + Editing: + + + + + Show or hide the per-role colour grid for manual tweaks + + + + + ↺ Revert to theme default + + + + + Discard unsaved edits and restore the last saved palette + + + + + Preview this palette without saving to disk + + + + + Write palette-%1.toml and reload the theme + + + + + Cannot save: this theme has no directory on disk + + + + + Save failed + + + + + Could not write %1 to: +%2 + + + + + No default found + + + + + No default palette file found for the "%1" scheme. + + + + + PaletteGridWidget + + + Active + + + + + Disabled + + + + + Inactive + + + + + Normal interactive state + + + + + Widget is disabled / not interactive + + + + + Window is in background / unfocused + + + Phase @@ -6565,57 +6810,57 @@ Cockatrice will now reload the card database. PhasesToolbar - + Untap step 언탭단 - + Upkeep step 유지단 - + Draw step 뽑기단 - + First main phase 첫번째 본단계 - + Beginning of combat step 전투 시작단 - + Declare attackers step 공격자 선언단 - + Declare blockers step 방어자 선언단 - + Combat damage step 전투 피해단 - + End of combat step 전투 종료단 - + Second main phase 두번째 본단계 - + End of turn step 종료단 @@ -6623,7 +6868,7 @@ Cockatrice will now reload the card database. PictureLoader - + en code for scryfall's language property, not available for all languages ko @@ -6632,151 +6877,142 @@ Cockatrice will now reload the card database. PlayerActions - - View top cards of library - - - - - - - - - - - - - Number of cards: (max. %1) - - - - - View bottom cards of library - - - - - Shuffle top cards of library - - - - - Shuffle bottom cards of library - - - - - Draw hand - - - - - 0 and lower are in comparison to current hand size - - - - - Draw cards - - - - - - - + + + + grave - - - - + + + + exile + + + PlayerDialogs - + + View top cards of library + + + + + + + + + + + + + Number of cards: (max. %1) + + + + + View bottom cards of library + + + + + Shuffle top cards of library + + + + + Shuffle bottom cards of library + + + + + Draw hand + + + + + 0 and lower are in comparison to current hand size + + + + + Draw cards + + + + Move top cards to %1 - + Move bottom cards to %1 - + Draw bottom cards - - - C&reate another %1 token - - - - + Create tokens - - + Number: - + Place card X cards from top of library - + Which position should this card be placed: - + (max. %1) - + Change power/toughness - + Change stats to: - + Set annotation - + Please enter the new annotation: - - - Set counters - - PlayerMenu - + Player "%1" - + &Counters @@ -6806,7 +7042,7 @@ This setting means you'll only see the default printing for each card, instead o - + Printing Selector @@ -6814,22 +7050,22 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelectorCardOverlayWidget - + Preference - + Pin Printing - + Unpin Printing - + Show Related cards @@ -6900,57 +7136,57 @@ This setting means you'll only see the default printing for each card, instead o PtMenu - + Power / toughness - + &Increase power - + &Decrease power - + I&ncrease toughness - + D&ecrease toughness - + In&crease power and toughness - + Dec&rease power and toughness - + Increase power and decrease toughness - + Decrease power and increase toughness - + Set &power and toughness... - + Reset p&ower and toughness @@ -6958,37 +7194,37 @@ This setting means you'll only see the default printing for each card, instead o QMenuBar - + Services 서비스 - + Hide %1 %1 가리기 - + Hide Others 기타 가리기 - + Show All 모두 보기 - + Preferences... 환경설정... - + Quit %1 %1 종료 - + About %1 %1에 관하여 @@ -6996,17 +7232,17 @@ This setting means you'll only see the default printing for each card, instead o QObject - + Cockatrice card database (*.xml) 코카트리스 카드 데이터베이스 파일 (*.xml) - + All files (*.*) 모든 파일 (*.*) - + Cockatrice replays (*.cor) 코카트리스 리플레이 파일 (*.cor) @@ -7066,110 +7302,177 @@ Are you sure you would like to disable this feature? QPlatformTheme - + OK - + Save - + Save All - + Open - + &Yes - + Yes to &All - + &No - + N&o to All - + Abort - + Retry - + Ignore - + Close - + Cancel - + Discard - + Help - + Apply - + Reset - + Restore Defaults + + QuickSetupPanel + + + %1% + + + + + <b>Quick Setup</b> + + + + + Generate all palette roles automatically from a single accent colour + + + + + Accent: + + + + + Primary hue. Used directly for highlights and links. +At high intensity it also tints buttons and backgrounds. + + + + + Intensity: + + + + + Subtle + + + + + Full colour + + + + + 0–30 Subtle tint — only highlights and links change hue +30–70 Accented — buttons, tooltips, and borders join in +70–100 Full colour — backgrounds, everything + + + + + 70% + + + + + Generate ↓ + + + + + Derive all palette roles from the accent colour above. +Fine-tune individual colours in the grid afterwards. + + + RemoteDeckList_TreeModel - + Name 덱 이름 - + ID ID - + Upload time 업로드 시간 @@ -7177,32 +7480,32 @@ Are you sure you would like to disable this feature? RemoteReplayList_TreeModel - + ID ID - + Name 게임 이름 - + Players 플레이어 - + Keep 보존 - + Time started 게임 시작 시간 - + Duration (sec) 게임 길이 @@ -7210,37 +7513,37 @@ Are you sure you would like to disable this feature? RfgMenu - + &Exile - + &View exile - + &Move exile to... - + &Top of library - + &Bottom of library - + &Hand - + &Graveyard @@ -7286,7 +7589,7 @@ Are you sure you would like to disable this feature? SayMenu - + S&ay @@ -7327,27 +7630,27 @@ Are you sure you would like to disable this feature? SetsModel - + Enabled 활성화 - + Set type 확장판 종류 - + Set code 확장판 약자 - + Long name 확장판 이름 - + Release date 발매 일자 @@ -7355,53 +7658,53 @@ Are you sure you would like to disable this feature? ShortcutSettingsPage - - + + Restore all default shortcuts - + Do you really want to restore all default shortcuts? - + Clear all default shortcuts - + Do you really want to clear all shortcuts? - + Section: - + Action: - + Shortcut: - + How to set custom shortcuts - + Clear all shortcuts - + Search by shortcut name @@ -7455,12 +7758,12 @@ Please check your shortcut settings! SideboardMenu - + &Sideboard - + &View sideboard @@ -7468,27 +7771,27 @@ Please check your shortcut settings! SoundSettingsPage - + Enable &sounds 음향 효과 켜기 - + Current sounds theme: 현재 소리 테마: - + Test system sound engine 시스템 사운드 엔진 테스트 - + Sound settings 음향 설정 - + Master volume 주 음량 @@ -7560,16 +7863,146 @@ Please check your shortcut settings! - + No reply received from the tag update server. - + Invalid reply received from the tag update server. + + StorageSettingsPage + + + + + Success + + + + + Cached card pictures have been reset. + + + + + Downloaded card pictures have been reset. + + + + + Error + + + + + One or more downloaded card pictures could not be cleared. + + + + + In-memory (currently loaded) card pictures have been reset. + + + + + Card Picture Loader Caching Method: + + + + + The network cache is the preferred way of storing images. Downloaded images are stored here until the size of the cache exceeds the configured size. Cockatrice automatically monitors this cache and deletes the least recently seen card images to ensure the cache does not exceed the configured size. + + + + + Writing card images directly to a folder on your hard drive is another way of storing images. This does not change how Cockatrice accesses or downloads images. Cockatrice will NOT automatically monitor and clear this folder, so if you enable this option, it is up to you to ensure sufficient available space. It should also be noted that if a provider outage causes you to download the wrong picture (i.e. wrong printing) you will be stuck with it until you manually delete the file, as opposed to using the network cache, which automatically rotates and thus correct errors after a while. + + + + + This is the in-memory picture cache used by the application at runtime. It determines how much memory (RAM) Cockatrice can use before it has to fetch card images from the hard disk again. Increasing this will allow more card images to be displayed at once but shouldn't be necessary. Clearing this will make Cockatrice reload all images from the network cache or the disk. + + + + + Delete Cached Images + + + + + Delete Saved Images + + + + + Clear In-Memory Images + + + + + Card Picture Loader Cache Method + + + + + Network Cache + + + + + Filesystem + + + + + In-Memory Picture Cache + + + + + Network Cache Size: + + + + + On-disk cache for downloaded pictures + + + + + Redirect Cache TTL: + + + + + How long cached redirects for urls are valid for. + + + + + Picture Cache Size: + + + + + In-memory cache for pictures not currently on screen + + + + + Naming scheme: + + + + + Day(s) + + + TabAccount @@ -7705,117 +8138,117 @@ Please check your shortcut settings! TabArchidekt - - + + Desc. - - + + AND - - + + Require ALL selected colors - - + + Deck name... - - + + Owner... - - + + Packages - - + + Advanced Filters - + Bracket: - - + + Any - - + + Contains card... - - + + Commander... - - + + Tag... - - + + Deck Size - + Cards: - - + + Asc. - + Sort by: - + Filter by: - + Display Settings - - + + Search - - + + Formats @@ -7825,6 +8258,54 @@ Please check your shortcut settings! + + TabCardArtRules + + + Card: + + + + + ProviderId: + + + + + Mode: + + + + + Reason: + + + + + Type a card name... + + + + + Add rule + + + + + Remove rule + + + + + Refresh + + + + + Card Art Rules + + + TabDeckEditor @@ -7873,7 +8354,7 @@ Please check your shortcut settings! 화면 레이아웃 초기화 - + Deck: %1 덱 편집기: %1 @@ -7881,55 +8362,49 @@ Please check your shortcut settings! TabDeckEditorVisual - + Visual Deck: %1 - + &Visual Deck Editor - - + + Card Info - - + + Deck - - - Filters - - - - + &View - + Printing - + Visible - + Floating - + Reset layout @@ -7937,22 +8412,22 @@ Please check your shortcut settings! TabDeckEditorVisualTabWidget - + Visual Deck View - + Visual Database Display - + Deck Analytics - + Sample Hand @@ -7960,134 +8435,134 @@ Please check your shortcut settings! TabDeckStorage - + Local file system 로컬 파일 시스템 - + Server deck storage 서버 덱 보관함 - - + + Open in deck editor 덱 편집기로 열기 - + Rename deck or folder - + Upload deck 덱 업로드 - + Download deck 덱 다운로드 - - - + + + New folder 새 폴더 - + Delete 삭제 - + Open decks folder - + Rename local folder - + Rename local file - + New name: - - - - + + + + Error - + Rename failed - - + + Invalid deck file - + Enter deck name 덱 이름 작성 - + This decklist does not have a name. Please enter a name: 이 덱리스트는 이름이 없습니다. 이름을 입력해 주세요: - + Unnamed deck 이름 없는 덱 - + Failed to upload deck to server - + Delete local file 로컬 파일 삭제 - + Are you sure you want to delete the selected files? - + Delete remote decks - + Are you sure you want to delete the selected decks? - - + + Name of new folder: 새 폴더의 이름: @@ -8159,191 +8634,191 @@ Please enter a name: TabGame - - - + + + Replay 리플레이 - - + + Game 게임 - - + + Player List 플레이어 목록 - - + + Card Info 카드 정보 - - + + Messages 메세지 - - + + Replay Timeline 리플레이 타임라인 - + &Phases 단계 - + &Game 게임 - + Next &phase 다음 단계로 진행 - + Next phase with &action - + Next &turn 턴 넘기기 - + Reverse turn order - + &Remove all local arrows 내가 그린 화살표 제거 - + Rotate View Cl&ockwise 플레이어 위치 시계방향으로 조정 - + Rotate View Co&unterclockwise 플레이어 위치 반시계방향으로 조정 - + Game &information 게임 정보 - + Un&concede - - - + + + &Concede 항복 - + &Leave game 게임 나가기 - + C&lose replay 리플레이 닫기 - + &Focus Chat - + &Say: 말하기: - + Selected cards - + &View &보기 - + Visible 표시 - + Floating 띄우기 - + Reset layout 레이아웃 초기화 - + Concede 항복 - + Are you sure you want to concede this game? 정말 게임에서 항복하시겠습니까? - + Unconcede - + You have already conceded. Do you want to return to this game? - + Leave game 게임 나가기 - + Are you sure you want to leave this game? 정말 게임에서 나가시겠습니까? - + A player has joined game #%1 - + %1 has joined the game - + You have been kicked out of the game. 게임에서 강제 퇴장 당하였습니다. @@ -8402,115 +8877,115 @@ Please enter a name: - + Username: 사용자명: - + IP Address: IP 주소: - + Game Name: 게임 이름: - + GameID: 게임 ID: - + Message: 메세지: - + Main Room 대화 채널 - + Game Room 게임 방 - + Private Chat 1:1 대화 - + Past X Days: ~일 전: - + Today 오늘 - + Last Hour 1시간 이내 - + Maximum Results: - + At least one filter is required. The more information you put in, the more specific your results will be. 최소한 한개 이상의 필터를 지정해주세요. 자세히 지정하시면 좀 더 세분화된 결과를 보실 수 있습니다. - + Get User Logs - + Clear Filters - + Filters 필터 - + Log Locations - + Date Range - + Maximum Results - - + + Message History - + Failed to collect message history information. - + There are no messages for the selected filters. @@ -8528,27 +9003,27 @@ The more information you put in, the more specific your results will be.나가기 - + %1 - Private chat %1 - 1:1 대화 - + This user is ignoring you, they cannot see your messages in main chat and you cannot join their games. - + Private message from - + %1 has left the server. %1이(가) 서버와의 연결을 끊었습니다. - + %1 has joined the server. %1이(가) 서버에 연결하였습니다. @@ -8556,180 +9031,180 @@ The more information you put in, the more specific your results will be. TabReplays - + Local file system 로컬 파일 시스템 - + Server replay storage 서버 리플레이 보관함 - - + + Watch replay 리플레이 보기 - + Rename - - + + New folder - - + + Delete 삭제 - + Open replays folder - + Download replay 리플레이 다운로드 - + Toggle expiration lock 자동 삭제 여부 토글 - + Get replay share code - - + + Look up replay by share code - + Rename local folder - + Rename local file - + New name: - + Error - + Rename failed - + Name of new folder: - + Delete local file 로컬 파일 삭제 - + Are you sure you want to delete the selected files? - + Are you sure you want to delete the selected replays? - + Failed to get code - - + + Either this server does not support replay sharing, or does not permit replay sharing for you. - - - + + + Failed - + Could not get replay code - + Replay Share Code - + Others can use this code to add the replay to their list of remote replays: %1 - + Copy to clipboard - + Replay share code - + Replay code found - + Replay was added, or you already had access to it. - + Replay code not found - + Failed to submit code - + Unexpected error - + Delete remote replay 서버에 저장된 리플레이 삭제 @@ -8742,47 +9217,62 @@ The more information you put in, the more specific your results will be. TabRoom - + + Friends + + + + + Online + + + + + Ignored + + + + &Say: 말하기: - + Chat 대화 - + &Room 채널 - + &Leave room 채널에서 나가기 - + &Clear chat 대화 내용 삭제 - + Chat Settings... 대화 설정... - + mentioned you. 이(가) 당신에게 멘션을 보냈습니다. - + Click to view 여기를 눌러 확인 - + You are flooding the chat. Please wait a couple of seconds. 너무 빨리 말하고 있습니다. 조금만 기다려 주세요. @@ -8795,30 +9285,30 @@ The more information you put in, the more specific your results will be.서버 - - - - + + + + Error 오류 - + Failed to join the server room: it doesn't exist on the server. - + The server thinks you are in the server room but your client is unable to display it. Try restarting your client. - + You do not have the required permission to join this server room. - + Failed to join the server room due to an unknown error: %1. @@ -8826,98 +9316,98 @@ The more information you put in, the more specific your results will be. TabSupervisor - + Deck Editor - + Visual Deck Editor - + EDHRec - + Archidekt - + Home - + &Visual Deck Storage - + Visual Database Display - + Server - + Account - + Deck Storage - + Game Replays - + Administration - + Logs - + Are you sure? 확실하십니까? - + There are still open games. Are you sure you want to quit? 참가 중인 게임이 있습니다. 정말로 코카트리스를 종료하시겠습니까? - + Click to view - + Your buddy %1 has signed on! - + Unknown Event - + The server has sent you a message that your client does not understand. This message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -8925,32 +9415,32 @@ To update your client, go to Help -> Check for Updates. - + Idle Timeout - + You are about to be logged out due to inactivity. - + Promotion 승급 - + You have been promoted. Please log out and back in for changes to take effect. - + Warned 경고 - + You have received a warning due to %1. Please refrain from engaging in this activity or further actions may be taken against you. If you have any questions, please private message a moderator. 운영진으로부터 %1의 사유로 경고를 받았습니다. @@ -8958,7 +9448,7 @@ Please refrain from engaging in this activity or further actions may be taken ag 문의사항은 관리자에게 1:1대화로 물어보시기 바랍니다. - + You have received the following message from the server. (custom messages like these could be untranslated) @@ -8967,12 +9457,12 @@ Please refrain from engaging in this activity or further actions may be taken ag TabVisualDatabaseDisplay - + Database Display - + Visual Database Display @@ -9009,42 +9499,42 @@ Please refrain from engaging in this activity or further actions may be taken ag TranslateCounterName - + Life - + White - + Blue - + Black - + Red - + Green - + Colorless - + Other @@ -9052,11 +9542,69 @@ Please refrain from engaging in this activity or further actions may be taken ag UpdateDownloader - + Could not open the file for reading. + + UserCardArtSettingsDialog + + + Card Art Settings + + + + + Type a card name... + + + + + Card name: + + + + + Card ProviderId: + + + + + Left margin (%): + + + + + Right margin (%): + + + + + Vertical offset: + + + + + Zoom: + + + + + Parameters + + + + + Preview + + + + + Remove Banner Card + + + UserContextMenu @@ -9174,7 +9722,7 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Ban History @@ -9189,77 +9737,87 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Failed to collect ban information. - - - + + + Warning History - + Warning Time;Moderator;User Name;Reason - + User has never been warned. 경고를 받은 적이 없는 사용자입니다. - + Failed to collect warning information. - + Failed to get admin notes. - - + + Success 성공 - + Successfully promoted user. 해당 사용자 승급에 성공하였습니다. - + Successfully demoted user. 해당 사용자 강등에 성공하였습니다. - - - + + Kick Player + + + + + Are you sure you want to kick this player from the game? + + + + + + Failed 실패 - + Failed to promote user. 해당 사용자 승급에 실패하였습니다. - + Failed to demote user. 해당 사용자 강등에 실패하였습니다. - + Copy hash to clipboard - + Remove this user's messages @@ -9267,326 +9825,554 @@ Please refrain from engaging in this activity or further actions may be taken ag UserInfoBox - + Location: 거주지: - + Account Age: 계정 나이: - + Edit 프로필 수정 - + Change password 비밀번호 변경 - + Change avatar 아바타 변경 - + Administrator 운영자 - + Moderator 관리자 - + Registered user 서버에 가입한 사용자 - - + + Unregistered user 서버에 가입하지 않은 사용자 - + Judge - + Unknown 미상 - + The entered password does not match your account. - - - + + + + Information 사용자 정보 - + User information updated. 사용자 정보가 갱신되었습니다. - - - - - - - - - - + + + + + + + + + + + Error 오류 - + User Information - + Real Name: - + User Level: + + + Edit Banner Card + + - + %n Year(s), amount of years (only shown if more than 0) - + %10%n Day(s) %20 amount of years (if more than 0), amount of days, date in local short format - + Enter Password - + Password verification is required in order to change your email address - - - + + + An error occurred while trying to update your user information. - + + The selected card is blacklisted on this server or another error occurred. + + + + + Banner card removed. + + + + + Banner card updated. + + + + This server does not permit you to update your user informations. 본 서버는 당신의 사용자 정보를 수정 할 수 없게 설정되어 있습니다. - + Password changed. 비밀번호가 변경되었습니다. - + This server does not permit you to change your password. 본 서버는 당신의 비밀번호를 수정 할 수 없게 설정되어 있습니다. - + The new password is too short. 새로운 비밀번호가 너무 짧습니다. - + The old password is incorrect. 예전 비밀번호가 잘못되었습니다. - + Avatar updated. 아바타를 변경하였습니다. - + This server does not permit you to update your avatar. 본 서버는 당신의 아바타를 변경 할 수 없게 설정되어 있습니다. + + + An error occured while trying to update your avatar. + + - An error occured while trying to updater your avatar. - 아바타 변경 중 오류가 발생하였습니다. + This server does not permit you to update your user informations. + An error occured while trying to updater your avatar. + 본 서버는 당신의 사용자 정보를 수정 할 수 없게 설정되어 있습니다. + + + + UserInfoPopup + + + + Games + + + + + Chat + + + + + Open private chat + + + + + Profile + + + + + View user profile + + + + + Show this user's games + + + + + − Buddy + + + + + Remove from buddy list + + + + + + Buddy + + + + + Add to buddy list + + + + + − Ignore + + + + + Remove from ignore list + + + + + + Ignore + + + + + Add to ignore list + + + + + Ban + + + + + Ban from server + + + + + Warn + + + + + Warn user + + + + + Ban log + + + + + View ban history + + + + + Warn log + + + + + View warning history + + + + + Notes + + + + + View admin notes + + + + + − Mod + + + + + Demote from moderator + + + + + + Mod + + + + + Promote to moderator + + + + + − Judge + + + + + Demote from judge + + + + + + Judge + + + + + Promote to judge + + + + + Join game + + + + + Spectate + + + + + + Loading games… + + + + + Could not load games. + + + + + No active games. + UserInterfaceSettingsPage - + General interface settings 일반 인터페이스 설정 - + &Double-click cards to play them (instead of single-click) 카드를 더블 클릭해서 발동 (해제시 한번만 클릭하면 발동 됨) - + &Clicking plays all selected cards (instead of just the clicked card) - + &Play all nonlands onto the stack (not the battlefield) by default 모든 대지가 아닌 카드를 발동 시에 스택으로 이동 (해제시 전장으로 바로 이동) - + Do not delete &arrows inside of subphases - + Close card view window when last card is removed - + Auto focus search bar when card view window is opened - + Annotate card text on tokens 토큰에 카드 텍스트 주석 자동 추가 - - Show selection counter during drag selection + + Show selection count during drag selection - - Show total selection counter + + Show total selection count - + + Show subtype breakdown in selection tally + + + + Use tear-off menus, allowing right click menus to persist on screen - + + Keep game chat focused when clicking in game (Note: disables card view search bar) + + + + Notifications settings - + Enable notifications in taskbar 상태 표시줄 알림 설정 - + Notify in the taskbar for game events while you are spectating 관전중인 게임의 상태 표시줄 알림 설정 - + Notify in the taskbar when users in your buddy list connect - + Animation settings 애니메이션 설정 - + &Tap/untap animation 탭/언탭 애니메이션 - + Deck editor/storage settings - + Open deck in new tab by default - + Use visual deck storage in game lobby - + Use selection animation for Visual Deck Storage - + When adding a tag in the visual deck storage to a .txt deck: - + do nothing - + ask to convert to .cod - + always convert to .cod - + Default deck editor type - + Classic Deck Editor - + Visual Deck Editor - + Replay settings - + Buffer time for backwards skip via shortcut: @@ -9594,22 +10380,22 @@ Please refrain from engaging in this activity or further actions may be taken ag UserListWidget - + Users connected to server: %1 - + Users in this room: %1 - + Buddies online: %1 / %2 - + Ignored users online: %1 / %2 @@ -9617,35 +10403,45 @@ Please refrain from engaging in this activity or further actions may be taken ag UtilityMenu - + Increment all card counters - + &Untap all permanents - + R&oll die... - + + Flip coin + + + + &Create token... - + C&reate another token - + Cr&eate predefined token + + + C&reate another %1 token + + VisualDatabaseDisplayColorFilterWidget @@ -9697,72 +10493,72 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayFilterToolbarWidget - + Sort by - + Filter by - + Save and load filters - + Filter by exact card name - + Filter by card main-type - + Filter by card sub-type - + Filter by set - + Filter by format legality - + Save/Load - + Name - + Main Type - + Sub Type - + Sets - + Formats @@ -9770,22 +10566,22 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayFormatLegalityFilterWidget - + Show formats with at least: - + cards - + Do not display formats with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) @@ -9803,32 +10599,32 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayMainTypeFilterWidget - + Show main types with at least: - + cards - + Do not display card main-types with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) - + Mode: Exact Match - + Mode: Includes @@ -9836,27 +10632,27 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayNameFilterWidget - + Filter by name... (Exact match) - + Load from Deck - + Apply all card names in currently loaded deck as exact match name filters - + Load from Clipboard - + Apply all card names in clipboard as exact match name filters @@ -9864,7 +10660,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayRecentSetFilterSettingsWidget - + Filter to most recent sets @@ -9872,19 +10668,19 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplaySetFilterWidget - + Search sets... - - + + Mode: Exact Match - - + + Mode: Includes @@ -9892,37 +10688,37 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplaySubTypeFilterWidget - + Search subtypes... - + Show sub types with at least: - + cards - + Do not display card sub-types with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) - + Mode: Exact Match - + Mode: Includes @@ -9930,23 +10726,23 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayWidget - + Search by card name (or search expressions) - + Visual - + Loading database ... - + Clear all filters @@ -9985,17 +10781,17 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Toggle Layout: Overlap - + Change how cards are displayed within zones (i.e. overlapped or fully visible.) - + Toggle Layout: Flat @@ -10024,17 +10820,17 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckEditorWidget - + Type a card name here for suggestions from the database... - + Quick search and add card - + Search for closest match in the database (with auto-suggestions) and add preferred printing to the deck on pressing enter @@ -10152,43 +10948,43 @@ Please refrain from engaging in this activity or further actions may be taken ag WarningDialog - + Which warning would you like to send? 어떤 종류의 경고를 보내시겠습니까? - + Redact all messages from this user in all rooms - + &OK 확인 - + &Cancel 취소 - + Warn user for misconduct 사용자의 부적절한 행동에 경고 - - + + Error 오류 - + User name to send a warning to can not be blank, please specify a user to warn. - + Warning to use can not be blank, please select a valid warning to send. @@ -10196,133 +10992,133 @@ Please refrain from engaging in this activity or further actions may be taken ag WndSets - + Move selected set to the top 선택한 확장판을 맨 위로 올리기 - + Move selected set up 선택한 확장판을 한 단계 올리기 - + Move selected set down 선택한 확장판을 한 단계 내리기 - + Move selected set to the bottom 선택한 확장판을 맨 아래로 내리기 - + Search by set name, code, or type - + Default order - + Restore original art priority order - + Enable all sets 모든 확장판 활성화 - + Disable all sets 모든 확장판 비활성화 - + Enable selected set(s) 선택한 세트 적용 - + Disable selected set(s) 선택한 세트 미적용 - + Deck Editor 덱 편집기 - + Use CTRL+A to select all sets in the view. - + Only cards in enabled sets will appear in the card list of the deck editor. - + Image priority is decided in the following order: - + first the CUSTOM Folder (%1), then the Enabled Sets in this dialog (Top to Bottom) %1 is a link to the wiki - + Include cards rebalanced for Alchemy [requires restart] - + Card Art 카드 이미지 - + How to use custom card art - + Hints - + Note - + Sorting by column allows you to find a set while not changing set priority. - + To enable ordering again, click the column header until this message disappears. - + Use the current sorting as the set priority instead - + Sorts the set priority using the same column - + Manage sets @@ -10330,72 +11126,72 @@ Please refrain from engaging in this activity or further actions may be taken ag ZoneViewWidget - + Search by card name (or search expressions) - + Ungrouped - + Group by Type - + Group by Mana Value - + Group by Color - + Unsorted - + Sort by Name - + Sort by Type - + Sort by Mana Cost - + Sort by Colors - + Sort by P/T - + Sort by Set - + shuffle when closing 닫은 후 덱 섞기 - + pile view 카드 유형별 행 정렬 @@ -10403,7 +11199,7 @@ Please refrain from engaging in this activity or further actions may be taken ag i18n - + English 한국어 (Korean) @@ -10411,12 +11207,12 @@ Please refrain from engaging in this activity or further actions may be taken ag main - + Connect on startup - + Debug to file @@ -10430,7 +11226,7 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Deck Editor 덱 편집기 @@ -10511,7 +11307,7 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Replays @@ -10626,129 +11422,129 @@ Please refrain from engaging in this activity or further actions may be taken ag - - + + Load Deck from Clipboard... - + Edit Deck in Clipboard, Annotated - + Edit Deck in Clipboard - + New Deck - + Open Custom Pictures Folder - + Print Deck... - + Delete Card - - + + Reset Layout - + Save Deck - + Save Deck as... - + Save Deck to Clipboard, Annotated - + Save Deck to Clipboard, Annotated (No Set Info) - + Save Deck to Clipboard - + Save Deck to Clipboard (No Set Info) - + Load Local Deck... - + Load Remote Deck... - + Set Ready to Start - + Toggle Sideboard Lock - + Add Green Counter - + Remove Green Counter - + Set Green Counters... - + Add Red Counter - + Remove Red Counter - + Set Red Counters... - + Add Life Counter @@ -10758,724 +11554,744 @@ Please refrain from engaging in this activity or further actions may be taken ag - + + Load deck from online service... + + + + + Load from website... + + + + Unload Deck - + Force Start - + Add Card Counter (F) - + Remove Card Counter (F) - + Set Card Counters (F)... - + Add Card Counter (E) - + Remove Card Counter (E) - + Set Card Counters (E)... - + Add Card Counter(D) - + Remove Card Counter (D) - + Set Card Counters (D)... - + Add Card Counter (C) - + Remove Card Counter (C) - + Set Card Counters (C)... - + Add Card Counter (B) - + Remove Card Counter (B) - + Set Card Counters (B)... - + Add Card Counter (A) - + Remove Card Counter (A) - + Set Card Counters (A)... - + Remove Life Counter - + Set Life Counters... - + Add White Counter - + Remove White Counter - + Set White Counters... - + Add Blue Counter - + Remove Blue Counter - + Set Blue Counters... - + Add Black Counter - + Remove Black Counter - + Set Black Counters... - + Add Colorless Counter - + Remove Colorless Counter - + Set Colorless Counters... - + Add Other Counter - + Remove Other Counter - + Set Other Counters... - + Increment all card counters - + Add Power (+1/+0) - + Remove Power (-1/-0) - + Move Toughness to Power (+1/-1) - + Add Toughness (+0/+1) - + Remove Toughness (-0/-1) - + Move Power to Toughness (-1/+1) - + Add Power and Toughness (+1/+1) - + Remove Power and Toughness (-1/-1) - + Set Power and Toughness... - + Reset Power and Toughness - + Untap 언탭단 - + Upkeep 유지단 - + Draw 뽑기단 - + First Main Phase - + Start Combat - + Attack 공격자 선언단 - + Block 방어자 선언단 - + Damage 전투 피해단 - + End Combat - + Second Main Phase - + End 종료단 - + Next Phase - + Next Phase Action - + Next Turn - + Hide Card in Reveal Window - + Tap / Untap Card - + Untap All - + Toggle Skip Untapping Toggle Untap - + Turn Card Over - + Peek Card - + Play Card - + Play Card, Face Down - + Attach Card... - + Unattach Card - + Clone Card - + Create Token... - + Create All Related Tokens - + Create Another Token - + Set Annotation... - + + Reduce Life by Power + + + + Select All Cards in Zone - + Select All Cards in Row - + Select All Cards in Column - + Reveal Selected Cards to All Players - - + + Bottom of Library - - - - + + + + Exile 추방 영역 - - - - + + + + Graveyard 무덤 - - + + Hand - - + + Top of Library - - + + Battlefield, Face Down - + Battlefield - + Library 서고 - + Sideboard 사이드보드 - + Top Cards of Library - + Bottom Cards of Library - + Close Recent View - - + + Stack - - + + Graveyard (Multiple) - - + + Graveyard (Multiple), Face Down - - + + Exile (Multiple) - - + + Exile (Multiple), Face Down - + Stack Until Found - + Draw Bottom Card - + Draw Multiple Cards from Bottom... - + Draw Arrow... - + Remove Local Arrows - + Leave Game - + Concede 항복 - + Roll Dice... - + + Flip Coin + + + + Shuffle Library - + Shuffle Top Cards of Library - + Shuffle Bottom Cards of Library - + Mulligan 멀리건 - + Mulligan (Same hand size) - + Mulligan (Hand size - 1) - + Draw a Card - + Draw Multiple Cards... - + Undo Draw - + Always Reveal Top Card - + Always Look At Top Card - + Sort Hand by Name - + Sort Hand by Type - + Sort Hand by Mana Value - + Reveal Hand to All Players - + Reveal Random Card to All Players - + Rotate View Clockwise - + Rotate View Counterclockwise - + Unfocus Text Box - + Focus Chat - + Clear Chat - + Refresh - + Skip Forward - + Skip Backward - + Skip Forward by a lot - + Skip Backward by a lot - + Play/Pause - + Toggle Fast Forward - + Home - + Visual Deck Storage - + Deck Storage - + Server - + Account - + Administration - + Logs diff --git a/cockatrice/translations/cockatrice_nl.ts b/cockatrice/translations/cockatrice_nl.ts index 773b042d2..882e50dae 100644 --- a/cockatrice/translations/cockatrice_nl.ts +++ b/cockatrice/translations/cockatrice_nl.ts @@ -2,7 +2,7 @@ AbstractCounter - + &Set counter... &Teller instellen... @@ -10,12 +10,12 @@ AbstractCounterDialog - + Set counter Teller instellen - + New value for counter '%1': Nieuwe waarde voor teller '%1': @@ -36,62 +36,62 @@ AbstractTabDeckEditor - + Open in new tab Open in nieuw tabblad - + Are you sure? Weet je het zeker? - + The decklist has been modified. Do you want to save the changes? De decklijst is gewijzigd. Wil je de wijzigingen opslaan? - - - - - - + + + + + + Error Foutmelding - + Could not open deck at %1 Kon het deckbestand niet openen: %1 - + Could not save remote deck Kon remote deck niet opslaan - - + + The deck could not be saved. Please check that the directory is writable and try again. Het deck kon niet opgeslagen worden. Kijk na of je schrijfrechten tot de folder hebt, en probeer opnieuw. - + Save deck Deck opslaan - + The deck could not be saved. Het deck kon niet opslagen worden. - + There are no cards in your deck to be exported Er zijn geen kaarten in je deck om te exporteren. @@ -107,12 +107,12 @@ Kijk na of je schrijfrechten tot de folder hebt, en probeer opnieuw. AdminNotesDialog - + Update Notes Update Notities - + Admin Notes for %1 Admin Notities voor %1 @@ -129,172 +129,227 @@ Kijk na of je schrijfrechten tot de folder hebt, en probeer opnieuw.Sideboard Sideboard + + + Tokens + + AppearanceSettingsPage - + seconds secondes - + Error Foutmelding - + Could not create themes directory at '%1'. Kon de themes map niet aanmaken op '%1'. - + Theme settings Thema opties - + Current theme: Huidige thema: - + Open themes folder Themasmap Openen - + Home tab background source: Starttabblad achtergrond bron: - + Home tab background shuffle frequency: Starttabblad achtergrond verversperiode. - + Disabled Uitgeschakeld - - Display card name of background in bottom right: - Kaartnaam van achtergrond rechtsonder weergeven - - - + Menu settings Menu instellingen - + Show keyboard shortcuts in right-click menus Laat snelkoppelingen in rechtermuisknop menus zien - + Show game filter toolbar above list in room tab Spelfilter werkbalk weergeven boven lijst in kamertab - + Card rendering Kaartweergave - + Display card names on cards having a picture Kaartnamen altijd weergeven op kaarten met een afbeelding - + Auto-Rotate cards with sideways layout Zijwaardse kaarten gedraaid weergeven - + Override all card art with personal set preference (Pre-ProviderID change behavior) Overschrijf alle kaart beelden met eigen set voorkeuren (Gedrag van voor implementatie van ProviderID) - + + Light + + + + + Dark + + + + + System + + + + + Active theme palette: + + + + + Edit theme palette + + + + + Home tab settings + + + + + Display card name of background in bottom right + + + + + Styling settings + + + + + Style user list + + + + + Card printings + + + + Bump sets that the deck contains cards from to the top in the printing selector Plaats sets van andere kaarten in het deck bovenin de drukselector - + Scale cards on mouse over Kaarten schalen met de muis - + Use rounded card corners Ronde kaarthoekjes - + + Card layout + + + + Minimum overlap percentage of cards on the stack and in vertical hand Minimum overlap percentage van kaarten op de stack en in verticale handweergave - + Maximum initial height for card view window: Grootste begingrootte voor kaartbekijkvenster - - + + rows rijen - + Maximum expanded height for card view window: Grootste uitgebreide grootte voor kaartbekijkvenster - + Card counters Kaart-counters - + Counter %1 Counter %1 - + Hand layout Handweergave - + Display hand horizontally (wastes space) Hand horizontaal weergeven (gebruikt extra ruimte) - + Enable left justification Linker justificatie inschakelen - + Table grid layout Tafelindeling - + Invert vertical coordinate Verticale coördinaat omkeren - + Minimum player count for multi-column layout: Minimaal aantal spelers voor kolommenindeling: - + Maximum font size for information displayed on cards: Maximale lettergrootte voor informatie die op kaarten wordt weergegeven: @@ -302,12 +357,12 @@ Kijk na of je schrijfrechten tot de folder hebt, en probeer opnieuw. ArchidektApiResponseDeckDisplayWidget - + Back to results Terug naar resultaten - + Open Deck in Deck Editor Open Deck in Deckbewerker @@ -333,112 +388,112 @@ Kijk na of je schrijfrechten tot de folder hebt, en probeer opnieuw. BanDialog - + ban &user name ban &gebruikersnaam - + ban &IP address ban &IP-adres - + ban client I&D ban gebruiker I&D - + Ban type Ban type - + &permanent ban &definitieve ban - + &temporary ban &tijdelijke ban - + &Days: &Dagen: - + &Hours: &Uur: - + &Minutes: &Minuten: - + Duration of the ban Duur van de ban - + Please enter the reason for the ban. This is only saved for moderators and cannot be seen by the banned person. Voer een reden voor de ban in. Dit wordt opgeslagen voor het moderatorteam en zal niet zichtbaar zijn voor de verbannen persoon. - + Please enter the reason for the ban that will be visible to the banned person. Voer een reden in die zichtbaar zal zijn voor de verbannen persoon. - + Redact all messages from this user in all rooms Verwijder alle berichten van deze gebruiker in alle kamers - + &OK &OK - + &Cancel &Annuleren - + Ban user from server Ban gebruiker van server - - - - + + + + Error Fout - + You have to select a name-based, IP-based, clientId based, or some combination of the three to place a ban. U moet een ban gebaseerd op IP, naam, gebruikers ID of een combinatie van de drie. - + You must have a value in the name ban when selecting the name ban checkbox. U moet een naam meegeven als u een naam gebaseerde ban wilt uitvoeren. - + You must have a value in the ip ban when selecting the ip ban checkbox. U moet een IP meegeven als u een IP gebaseerde ban wilt uitvoeren. - + You must have a value in the clientid ban when selecting the clientid ban checkbox. U moet een gebruikers ID meegeven als u een gebruikers ID gebaseerde ban wilt uitvoeren. @@ -446,59 +501,123 @@ Dit wordt opgeslagen voor het moderatorteam en zal niet zichtbaar zijn voor de v BetaReleaseChannel - + Beta Beta - + No reply received from the release update server. Geen antwoord ontvangen van de release update server. - + Invalid reply received from the release update server. Ongeldig antwoord ontvangen van de release update server. - + No reply received from the file update server. Geen antwoord ontvangen van de bestandsupdateserver. + + CardArtPreviewWidget + + + No card selected + + + + + CardArtRulesModel + + + Card + + + + + ProviderId + + + + + Mode + + + + + Reason + + + CardDatabaseModel - + Name Naam - + Sets Sets - + Mana cost Manakosten - + Card type Kaarttype - + P/T P/T - + Color(s) Kleur(en) + + CardDatabaseView + + + Add to Deck + + + + + Add to Sideboard + + + + + Select Printing + + + + + Show on EDHRec (Commander) + + + + + Show on EDHRec (Card) + + + + + Show Related cards + + + CardFilter @@ -627,22 +746,22 @@ Dit wordt opgeslagen voor het moderatorteam en zal niet zichtbaar zijn voor de v CardInfoPictureWidget - + View related cards Bekijk gerelateerde kaarten - + Add card to deck Voeg kaart aan deck toe - + Mainboard Mainboard - + Sideboard Sideboard @@ -665,12 +784,12 @@ Dit wordt opgeslagen voor het moderatorteam en zal niet zichtbaar zijn voor de v Verzamelnummer: - + Related cards: Gerelateerde kaarten: - + Unknown card: Onbekende kaart: @@ -678,128 +797,146 @@ Dit wordt opgeslagen voor het moderatorteam en zal niet zichtbaar zijn voor de v CardMenu - + Re&veal to... &Onthul aan.... - + &All players &Alle spelers - + View related cards Bekijk gerelateerde kaarten - + Token: Token: - + All tokens Alle tokens - + &Select All &Selecteer Alle - + S&elect Row S&electeer Rij - + S&elect Column S&electeer Kolom - + &Play &Speel - + &Hide &Verberg - + Play &Face Down Speel kaart &verdekt - + &Tap / Untap Turn sideways or back again &Tap / Ontap - + Skip &untapping &Ontappen overslaan - + T&urn Over Turn face up/face down &Draai om - + &Peek at card face &Gluren naar de voorkant van kaart - + &Clone &Kloneer - + Attac&h to card... Aan de kaart &bevestigen.... - + Unattac&h &Losmaken - + &Draw arrow... &Teken pijl.... - + &Set annotation... Aantekeningen &zetten.... - + + Reduce life by power + + + + Ca&rd counters Kaart-&counters - + &Add counter (%1) Voeg counter &toe (%1) - + &Remove counter (%1) &Verwijder counter (%1) - + &Set counters (%1)... Counters &instellen (%1)... + + CardPictureLoaderCacheMethod + + + Network Cache + + + + + Filesystem + + + CardSizeWidget @@ -811,133 +948,133 @@ Dit wordt opgeslagen voor het moderatorteam en zal niet zichtbaar zijn voor de v CardZoneLogic - + their hand nominative hun hand - + %1's hand nominative %1s hand - + their library look at zone hun library - + %1's library look at zone %1s library - + of their library top cards of zone, van hun library - + of %1's library top cards of zone van %1s library - + their library reveal zone hun library - + %1's library reveal zone %1s library - + their library shuffle hun library - + %1's library shuffle %1s library - + their library nominative hun library - + %1's library nominative %1s library - + their graveyard nominative hun kerkhof - + %1's graveyard nominative %1s kerkhof - + their exile nominative hun verbannen - + %1's exile nominative %1s verbannen - + their sideboard look at zone hun sideboard - + %1's sideboard look at zone %1s sideboard - + their sideboard nominative hun sideboard - + %1's sideboard nominative %1s sideboard - + their custom zone '%1' nominative hun aangepaste zone '%1' - + %1's custom zone '%2' nominative %1s aangepaste zone '%2' @@ -959,16 +1096,421 @@ Dit wordt opgeslagen voor het moderatorteam en zal niet zichtbaar zijn voor de v Parseerfout op regel %1 kolom %2: + + ColorButton + + + Click to pick a color + + + + + Pick colour + + + + + ConnectionController + + + + The server has reached its maximum user capacity, please check back later. + + + + + There are too many concurrent connections from your address. + + + + + Banned by moderator + + + + + Expected end time: %1 + + + + + This ban lasts indefinitely. + + + + + Scheduled server shutdown. + + + + + + Invalid username. + + + + + You have been logged out due to logging in at another location. + + + + + Connection closed + + + + + The server has terminated your connection. +Reason: %1 + + + + + The server is going to be restarted in %n minute(s). +All running games will be lost. +Reason for shutdown: %1 + + + + + Scheduled server shutdown + + + + + Failed Login + + + + + Your client seems to be missing features this server requires for connection. + + + + + To update your client, go to 'Help -> Check for Client Updates'. + + + + + + + + + + + + + + + + + + + + + + Error + + + + + Incorrect username or password. Please check your authentication information and try again. + + + + + There is already an active session using this user name. +Please close that session first and re-login. + + + + + + You are banned until %1. + + + + + + You are banned indefinitely. + + + + + This server requires user registration. Do you want to register now? + + + + + This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. +Please close and reopen your client to try again. + + + + + An internal error has occurred, please close and reopen Cockatrice before trying again. +If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. + + + + + Account activation + + + + + Your account has not been activated yet. +You need to provide the activation token received in the activation email. + + + + + Server Full + + + + + Unknown login error: %1 + + + + + + +This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. + + + + + + + + + + Registration denied + + + + + Registration is currently disabled on this server + + + + + There is already an existing account with the same user name. + + + + + It's mandatory to specify a valid email address when registering. + + + + + It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. + + + + + Password too short. + + + + + Registration failed for a technical problem on the server. + + + + + The connection to the server has been lost. + + + + + Unknown registration error: %1 + + + + + Account activation failed + + + + + Socket error: %1 + + + + + Server timeout + + + + + You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. +Local version is %1, remote version is %2. + + + + + Your Cockatrice client is obsolete. Please update your Cockatrice version. +Local version is %1, remote version is %2. + + + + + + Success + + + + + Registration accepted. +Will now login. + + + + + Account activation accepted. +Will now login. + + + + + Information + + + + + This server supports additional features that your client doesn't have. +This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. + +To update your client, go to Help -> Check for Updates. + + + + + + + Reset Password + + + + + Your password has been reset successfully, you can now log in using the new credentials. + + + + + Failed to reset user account password, please contact the server operator to reset your password. + + + + + Activation request received, please check your email for an activation token. + + + + + Connecting to %1... + + + + + Registering to %1 as %2... + + + + + Disconnected + + + + + Connected, logging in at %1 + + + + + Requesting forgotten password to %1 as %2... + + + + + Your username must respect these rules: + + + + + is %1 - %2 characters long + + + + + can %1 contain lowercase characters + + + + + + + + NOT + + + + + can %1 contain uppercase characters + + + + + can %1 contain numeric characters + + + + + can contain the following punctuation: %1 + + + + + first character can %1 be a punctuation mark + + + + + no unacceptable language as specified by these server rules: + note that the following lines will not be translated + + + + + can not contain any of the following words: %1 + + + + + can not match any of the following expressions: %1 + + + + + You may only use A-Z, a-z, 0-9, _, ., and - in your username. + + + CustomZoneMenu - + C&ustom Zones &Aangepaste Zone - - + + View custom zone '%1' Aangepaste zone '%1' bekijken @@ -976,30 +1518,35 @@ Dit wordt opgeslagen voor het moderatorteam en zal niet zichtbaar zijn voor de v DeckAnalyticsWidget - + Add Panel Paneel Toevoegen - + Remove Panel Paneel Verwijderen - + Save Layout Indeling Opslaan - + Load Layout Indeling Laden + + + Include Sideboard + + DeckEditorCardDatabaseDockWidget - + Card Database Kaartdatabase @@ -1015,47 +1562,17 @@ Dit wordt opgeslagen voor het moderatorteam en zal niet zichtbaar zijn voor de v DeckEditorDatabaseDisplayWidget - + Search by card name (or search expressions) Zoek op kaartnaam (of zoekexpressies) - - Add to Deck - Toevoegen aan deck - - - - Add to Sideboard - Toevoegen aan sideboard - - - - Select Printing - Selecteer Druk - - - - Show on EDHRec (Commander) - Bekijk op EDHRec (Commander) - - - - Show on EDHRec (Card) - Bekijk op EDHRec (Kaart) - - - - Show Related cards - Gerelateerde kaarten tonen - - - + Add card to &maindeck Kaart aan deck &toevoegen - + Add card to &sideboard Kaart aan &sideboard toevoegen @@ -1088,72 +1605,72 @@ Dit wordt opgeslagen voor het moderatorteam en zal niet zichtbaar zijn voor de v Kleuren - + Select Printing Selecteer Druk - + Deck Deck - + Deck &name: Deck&naam: - + Banner Card/Tags Visibility Settings Vaandelkaart/Tags zichtbaarheidsinstellingen - + Show banner card selection menu Selectiemenu van vaandelkaarten weergeven - + Show tags selection menu Selectiemenu van tags weergeven - + &Comments: &Opmerkingen: - + Group by: Groepeer op: - + Format: Format: - + Hash: Hash: - + &Increment number Ver&hoog getal - + &Decrement number Ver&laag getal - + &Remove row Ver&wijder rij - + Swap card to/from sideboard Wissel kaart van/naar sideboard @@ -1161,17 +1678,17 @@ Dit wordt opgeslagen voor het moderatorteam en zal niet zichtbaar zijn voor de v DeckEditorFilterDockWidget - + Filters Filters - + &Clear all filters &Wis alle filters - + Delete selected Selectie verwijderen @@ -1302,169 +1819,113 @@ Dit wordt opgeslagen voor het moderatorteam en zal niet zichtbaar zijn voor de v DeckEditorSettingsPage - - + + Update Spoilers Update Spoilers - - + Success Succes - + Download URLs have been reset. Download URL's zijn gereset. - - Downloaded card pictures have been reset. - Gedownloade kaartafbeeldingen's zijn gereset. - - - - Error - Fout - - - - One or more downloaded card pictures could not be cleared. - Een of meer gedownloade kaartafbeeldingen konden niet worden gewist. - - - + Add URL URL toevoegen - - + + URL: URL: - - + + Edit URL URL bewerken - - Network Cache Size: - Netwerk Cache Grootte: - - - - Redirect Cache TTL: - Redirect Cache TTL: - - - - How long cached redirects for urls are valid for. - Hoe lang cached redirects voor urls geldig zijn - - - - Picture Cache Size: - Afbeeldingen Cache Grootte: - - - + Add New URL Nieuwe URL toevoegen - + Remove URL URL verwijderen - - Day(s) - Dag(en) - - - + Updating... Bijwerken... - + Choose path Kies locatie - + URL Download Priority URL Download Prioriteit - + Spoilers Spoilers - + Download Spoilers Automatically Spoilers Automatisch Downloaden - + Spoiler Location: Spoiler Locatie: - + Last Change Laatste Wijziging - + Spoilers download automatically on launch Spoilers downloaden automatisch bij opstarten - + Press the button to manually update without relaunching Druk op de knop om handmatig bij te werken zonder opnieuw te starten. - + Do not close settings until manual update is complete Sluit de instellingen niet af voordat de handmatige update is voltooid - + Download card pictures on the fly Kaartafbeeldingen on the fly downloaden - + How to add a custom URL Hoe een aangepaste URL toe te voegen - - Delete Downloaded Images - Gedownloade Afbeeldingen Verwijderen - - - + Reset Download URLs Reset Download URL's - - - On-disk cache for downloaded pictures - Cache op schijf voor gedownloade afbeeldingen - - - - In-memory cache for pictures not currently on screen - Cache in geheugen voor afbeeldingen die momenteel niet op het scherm staan - DeckListHistoryManagerWidget @@ -1530,12 +1991,12 @@ Dit wordt opgeslagen voor het moderatorteam en zal niet zichtbaar zijn voor de v DeckLoader - + Common deck formats (%1) Algemene deckformaten (%1) - + All files (*.*) Alle bestanden (*.*) @@ -1757,27 +2218,27 @@ Dit wordt opgeslagen voor het moderatorteam en zal niet zichtbaar zijn voor de v Toegevoegd (%1): %2 (%3) %4 - + Moved to %1 1 × "%2" (%3) Verplaatst naar %1 1 × "%2" (%3) - + Removed "%1" (all copies) Verwijderd "%1" (alle exemplaren) - + %1 1 × "%2" (%3) %1 1 × "%2" (%3) - + Added Toegevoegd - + Removed Verwijderd @@ -1799,75 +2260,75 @@ Dit wordt opgeslagen voor het moderatorteam en zal niet zichtbaar zijn voor de v DeckViewContainer - + Load deck... Deck laden... - + Load remote deck... Laad extern deck... - + Load from clipboard... Van klembord laden.... - + Load from website... Van website laden... - + Unload deck Deck uitladen - + Ready to start Gereed voor begin - + Force start Geforceerd starten - + Sideboard unlocked Sideboard ontgrendeld - + Sideboard locked Sideboard vergrendeld - - + + Error Fout - + The selected file could not be loaded. Het geselecteerde bestand kon niet geladen worden. - + Deck is greater than maximum file size. Deck is groter dan maximum bestandsgrootte. - + Are you sure you want to force start? This will kick all non-ready players from the game. Weet je zeker dat je geforceerd beginnen wil? Dit zal alle spelers die niet gereed zijn van het spel verwijderen. - + Cockatrice Cockatrice @@ -1968,7 +2429,7 @@ Wil je het deck omzetten naar het .cod-formaat? - + Webpage Website @@ -2008,37 +2469,37 @@ Wil je het deck omzetten naar het .cod-formaat? Verbinding maken met server - + Server URL Server URL - + Communication Port Communicatiepoort - + Unique Server Name Unieke Servernaam - + Connection Warning Verbindingswaarschuwing - + You need to name your new connection profile. U moet het nieuwe verbindingsprofiel een naam geven. - + Connect Warning Verbindingswaarschuwing - + The player name can't be empty. De naam van de speler mag niet leeg zijn. @@ -2151,17 +2612,17 @@ Wil je het deck omzetten naar het .cod-formaat? Spel aanmaken - + Game information Spelgegevens - + Error Fout - + Server error. Server Fout @@ -2169,97 +2630,97 @@ Wil je het deck omzetten naar het .cod-formaat? DlgCreateToken - + &Name: &Naam: - + Token Token - + C&olor: Kle&ur: - + white wit - + blue blauw - + black zwart - + red rood - + green groen - + multicolor veelkleurig - + colorless kleurloos - + &P/T: &P/T: - + &Annotation: &Annotatie: - + &Destroy token when it leaves the table &Vernietig token als het de tafel verlaat - + Create face-down (Only hides name) Verdekt aanmaken (Verbergt alleen naam) - + Token data Token data - + Show &all tokens Laat &alle tokens zien - + Show tokens from this &deck Laat tokens van dit &dek zien - + Choose token from list Kies token uit lijst - + Create token Maak token @@ -2293,7 +2754,7 @@ Wil je het deck omzetten naar het .cod-formaat? - + @@ -2308,12 +2769,12 @@ Wil je het deck omzetten naar het .cod-formaat? Naam van tag mog niet leeg zijn! - + Duplicate Tag Tag Dupliceren - + This tag already exists. Deze tag bestaat al. @@ -2500,12 +2961,12 @@ Om je avatar te verwijderen, accepteer zonder een nieuwe afbeelding te kiezen.Gelieve de naam van de token in te voeren: - + Error Fout - + The chosen name conflicts with an existing card or token. Make sure to enable the 'Token' set in the "Manage sets" dialog to display them correctly. De gekozen naam is al in gebruik door een bestaande kaart of token. @@ -2530,12 +2991,12 @@ Zorg ervoor dat u de 'Token' set in het " Beheer sets" dialo Onbekend - + Real name: Echte naam: - + Edit user profile Gebruikers profiel aanpassen @@ -2573,113 +3034,113 @@ Zorg ervoor dat u de 'Token' set in het " Beheer sets" dialo 2 uur - + Hide 'buddies only' games Verberg 'alleen maatjes' spellen - + Hide full games Verberg volle spellen - + Hide games that have started Verberg spellen die al begonnen zijn - + Hide password protected games Verberg met wachtwoord vergrendelde spellen - + Hide 'ignored user' games Verberg spellen door 'genegeerde' gebruikers - + Hide games not created by buddies Hide games not created by buddy Verberg spellen niet aangemaakt door maatjes - + Hide games with forced open decklists Verberg spellen met gedwongen open decklijsten - + &Newer than: &Nieuwer dan: - + Game &description: Spel &omschrijving: - + &Creator name: Naam van &maker: - + General Algemeen - + &Game types &Spel types - + at &least: ten&minste: - + at &most: op zijn &hoogst: - + Maximum player count Maximale aantal spelers - + Restrictions Restricties - + Show games only if &spectators can watch Laat alleen spellen zien als &toeschouwers kunnen kijken - + Show spectator password p&rotected games Laat spellen zien met &wachtwoord voor toeschouwers - + Show only if spectators can ch&at Laat alleen spellen zien als toeschouwers kunnen &chatten - + Show only if spectators can see &hands Laat alleen spellen zien als toeschouwers &handen kunnen zien - + Spectators Toeschouwers - + Filter games Spellen filteren @@ -2990,37 +3451,37 @@ https://tappedout.net/mtg-decks/your-deck-name/ DlgMoveTopCardsUntil - + Card name (or search expressions): Kaart naam (of zoekexpressies) - + Number of hits: Hoeveelheid resultaten: - + Auto play hits Speel resultaten automatisch - + Put top cards on stack until... Plaats bovenste kaart op stack totdat... - + No cards matching the search expression exists in the card database. Proceed anyways? Er zijn geen kaarten die bij deze zoekopdracht passen in de kaartendatabase. Toch doorgaan? - + Cockatrice Cockatrice - + Invalid filter Ongeldige filter @@ -3080,40 +3541,40 @@ Je email zal gebruikt worden om je account te controleren. Onbekend - + Real name: Echte naam: - + Register to server Registreer op server - - - - + + + + Registration Warning Registratie Waarschuwing - + Your password is too short. Je wachtwoord is te kort. - + Your passwords do not match, please try again. Uw wachtwoorden komen niet overeen, probeer opnieuw. - + Your email addresses do not match, please try again. Uw email adressen komen niet overeen, probeer opnieuw. - + The player name can't be empty. Spelersnaam kan niet leeg zijn. @@ -3121,17 +3582,17 @@ Je email zal gebruikt worden om je account te controleren. DlgRollDice - + Number of sides: Aantal zijden: - + Number of dice: Aantal dobbelstenen: - + Roll Dice Werp Dobbelstenen @@ -3182,12 +3643,12 @@ Je email zal gebruikt worden om je account te controleren. DlgSettings - + Unknown Error loading card database Onbekende Fout in het lezen van de kaart database - + Your card database is invalid. Cockatrice may not function correctly with an invalid database @@ -3204,7 +3665,7 @@ U zou oracle moeten herstarten om uw database te updaten. Zou u de locatie van uw database willen veranderen? - + Your card database version is too old. This can cause problems loading card information or images @@ -3221,7 +3682,7 @@ Dit zou mogelijk opgelost worden door oracle opnieuw te starten en je kaart data Wil je de locatie van je database veranderen? - + Your card database did not finish loading Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues with your cards.xml attached @@ -3234,7 +3695,7 @@ Plaats een klacht op https://github.com/Cockatrice/Cockatrice/issues met uw card Wilt u uw database lokatie aanpassen? - + File Error loading your card database. Would you like to change your database location setting? @@ -3243,7 +3704,7 @@ Would you like to change your database location setting? Wilt u uw database lokatie aanpassen? - + Your card database was loaded but contains no cards. Would you like to change your database location setting? @@ -3252,7 +3713,7 @@ Would you like to change your database location setting? Wilt u uw database lokatie aanpassen? - + Unknown card database load status Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues @@ -3265,59 +3726,64 @@ Plaats een klacht op https://github.com/Cockatrice/Cockatrice/issues asltublieft Wilt u uw database lokatie aanpassen? - - - + + + Error Fout - + The path to your deck directory is invalid. Would you like to go back and set the correct path? Het pad naar uw deck map is niet geldig. Wilt u terug gaan en de correcte pad invullen? - + The path to your card pictures directory is invalid. Would you like to go back and set the correct path? Het pad naar uw kaart afbeelding map is niet geldig. Wil je terug gaan en het juiste pad invullen? - + Settings Instellingen - + General Algemeen - + Appearance Weergave - + User Interface Gebruikersomgeving - + + Storage + + + + Card Sources Kaart Bronnen - + Chat Chat - + Sound Geluid - + Shortcuts Sneltoetsen @@ -3411,9 +3877,9 @@ Je kan altijd dit gedrag aanpassen in de "Algemeen" instellingtab. - - - + + + Error Fout @@ -3466,31 +3932,31 @@ Ga naar de downloadpagina om handmatig bij te werken. - + Update Available Update Beschikbaar - + A new version of Cockatrice is available! Een nieuwe versie van Cockatrice is beschikbaar! - + New version Nieuwe versie - + Released Released - + Changelog Changelog @@ -3500,51 +3966,51 @@ Ga naar de downloadpagina om handmatig bij te werken. Wilt u nu updaten? - + Unfortunately, the automatic updater failed to find a compatible download. You may have to manually download the new version. Helaas, de automatische bewerker is niet geslaagd in het vinden van een toepasselijke download. Je moet misschien handmatig de nieuwe versie downloaden. - + Please check the <a href="%1">releases page</a> on our Github and download the build for your system. Controleer alsjeblieft de <a href="%1">releases pagina</a>op onze Github en download de versie voor jouw systeem. - - - + + + Update Error Update Fout - + An error occurred while checking for updates: Er is een fout opgetreden bij het controleren op updates: - + An error occurred while downloading an update: Er is een fout opgetreden tijdens het downloaden van een update: - + Installing... Installeren... - + Cockatrice is unable to open the installer. Cockatrice kan het installatieprogramma niet openen. - + Try to update manually by closing Cockatrice and running the installer. Probeer handmatig bij te werken door Cockatrice te sluiten en het installatieprogramma uit te voeren. - + Download location Download locatie @@ -3634,67 +4100,67 @@ Je moet misschien handmatig de nieuwe versie downloaden. DrawProbabilityWidget - + Draw Probability Raapkans - + Probability of drawing Kans op rapen - + Card Name Kaartnaam - + Type Type - + Subtype Subtype - + Mana Value Manawaarde - + At least Minstens - + Exactly Precies - + card(s) having drawn at least kaart(en) na het rapen van - + cards kaarten - + Category Kategorie - + Qty Hoeveel - + Odds (%) Kans (%) @@ -3808,7 +4274,7 @@ Je moet misschien handmatig de nieuwe versie downloaden. FilterBuilder - + Type your filter here Typ hier uw filter in @@ -3839,22 +4305,22 @@ Je moet misschien handmatig de nieuwe versie downloaden. GameEventHandler - + kicked by game host or moderator er uit geschopt door spelmaker of moderator - + player left the game speler heeft het spel verlaten - + player disconnected from server speler verbrak verbinding van server - + reason unknown reden onbekend @@ -3862,140 +4328,140 @@ Je moet misschien handmatig de nieuwe versie downloaden. GameSelector - - - - - - - - - + + + + + + + + + Error Fout - + Please join the appropriate room first. Gelieve eerst naar de juiste kamer te gaan. - + Wrong password. Verkeerd wachtwoord. - + Spectators are not allowed in this game. Toeschouwers zijn niet toegelaten in dit spel. - + The game is already full. Dit spel is reeds vol. - + The game does not exist any more. Het spel bestaat niet meer. - + This game is only open to registered users. Dit spel is alleen toegankelijk voor geregistreerde gebruikers. - + This game is only open to its creator's buddies. Dit spel is alleen toegankelijk voor de maatjes van de maker van dit spel. - + You are being ignored by the creator of this game. Je wordt genegeerd door de maker van dit spel. - + Join Game Aansluiten bij spel - + Spectate Game Spel Toeschouwen - + Game Information Spelgegevens - + Join Game as Judge Bij Spel Aansluiten als Judge - + Spectate Game as Judge Spel Toeschouwen als Judge - + Join game Aansluiten bij spel - + Password: Wachtwoord: - + Please join the respective room first. Gelieve eerst bij de betreffende kamer aan te sluiten. - + &Filter games &Filter spellen - + C&lear filter W&is filter - + C&reate C&reëer - + &Join &Aansluiten - + Join as judge Aansluiten als judge - + J&oin as spectator &Aansluiten als toeschouwer - + Join as judge spectator Aansluiten als judge toeschouwer - + Games shown: %1 / %2 Spellen weergegeven: %1 / %2 - + Games Spellen @@ -4003,32 +4469,32 @@ Je moet misschien handmatig de nieuwe versie downloaden. GameSelectorQuickFilterToolBar - + All types Alle types - + Filter by game name... Filter op spelnaam... - + Filter by game type/format Filter op speltype/format - + Hide games not created by buddies Verberg spellen niet gemaakt door maatjes - + Hide full games Verberg volle spellen - + Hide started games Verberg begonnen spellen @@ -4036,105 +4502,105 @@ Je moet misschien handmatig de nieuwe versie downloaden. GamesModel - + >1 day >1 dag - + %1%2 hr short age in hours %1%2 uur%1%2 uur - + new nieuw - + %1%2 min short age in minutes %1%2 min%1%2 min - + password wachtwoord - + buddies only alleen maatjes - + reg. users only enkel geregistreerde gebruikers - + open decklists decklijsten gedeeld - + can chat kan praten - + see hands zie handen - + can see hands kan handen zien - + not allowed niet toegelaten - + Room Kamer - + Age Ouderdom - + Description Omschrijving - + Creator Maker - + Type Type - + Restrictions Restricties - + Players Spelers - + Spectators Toeschouwers @@ -4142,143 +4608,158 @@ Je moet misschien handmatig de nieuwe versie downloaden. GeneralSettingsPage - + Reset all paths Reset alle bestandspaden - + All paths have been reset Alle bestandspaden zijn gereset - - - - - - - + + + + + + + Choose path Kies pad - - Personal settings - Persoonlijke instellingen + + Language settings + - + Language: Taal: - + + Version settings + + + + + Card database + + + + + Startup settings + + + + Paths (editing disabled in portable mode) Locaties (bewerken uitgeschakeld in "portable mode") - + Paths Paden - + How to help with translations Hoe met vertalen te helpen - + Decks directory: Map voor decks: - + Filters directory: Filters map: - + Replays directory: Map voor replays: - + Pictures directory: Map voor afbeeldingen: - + Card database: Kaartdatabase: - + Custom database directory: Custom database map: - + Token database: Token-database - + Update channel Updatekanaal - + Check for client updates on startup Controleren op client-updates bij opstarten - + Check for card database updates on startup Controleren op kaartendatabasebewerkingen bij opstarten - + Don't check Niet controleren - + Prompt for update Vragen om bewerking - + Always update in the background Altijd in achtergrond bewerken - + Check for card database updates every Controleren op kaartendatabasebewerkingen iedere - + days dagen - + Notify if a feature supported by the server is missing in my client Waarschuw als een functie die is ondersteund door de server mist in mijn client - + Automatically run Oracle when running a new version of Cockatrice Oracle automatisch uitvoeren bij het opstarten van een nieuwe versie van Cockatrice - + Show tips on startup Toon tips bij het opstarten - + Last update check on %1 (%2 days ago) Laatste controle op %1 (%2 dagen geleden) @@ -4286,47 +4767,47 @@ Je moet misschien handmatig de nieuwe versie downloaden. GraveyardMenu - + &Graveyard &Graveyard - + &View graveyard &Bekijk graveyard - + &Move graveyard to... &Verplaats graveyard naar... - + &Top of library &Bovenkant van library - + &Bottom of library &Onderkant van library - + &All players &Alle spelers - + &Hand &Hand - + &Exile &Exile - + Reveal random card to... Onthul willekeurige kaart aan... @@ -4334,88 +4815,88 @@ Je moet misschien handmatig de nieuwe versie downloaden. HandMenu - + &Hand &Hand - + &View hand &Bekijk hand - + Sort hand by... Sorteer hand op... - + Name Naam - + Type Type - + Mana Value Manawaarde - + Take &mulligan (Choose hand size) Neem &mulligan (Kies handgrootte) - + Take mulligan (Same hand size) Neem mulligan (Dezelfde handgrootte) - + Take mulligan (Hand size - 1) Neem mulligan (Handgrootte - 1) - + &Move hand to... &Verplaats hand naar.... - + &Top of library &Bovenkant van library - + &Bottom of library &Onderkant van library - + &Graveyard &Graveyard - + &Exile &Exile - + &Reveal hand to... &Onthul hand aan.... - - + + All players Alle spelers - + Reveal r&andom card to... Onthul &willekeurige kaart aan.... @@ -4423,52 +4904,52 @@ Je moet misschien handmatig de nieuwe versie downloaden. HomeWidget - + Create New Deck Maak Nieuw Deck - + Browse Decks Decks Doorbladeren - + Browse Card Database Kaartendatabase Doorbladeren - + Browse EDHRec EDHRec Doorbladeren - + Browse Archidekt Archidekt Doorbladeren - + View Replays Replays Inzien - + Quit Sluiten - + Connecting... Verbinden... - + Connect Verbinden - + Play Spelen @@ -4476,213 +4957,213 @@ Je moet misschien handmatig de nieuwe versie downloaden. LibraryMenu - + &Library &Library - + &View library &Bekijk library - + View &top cards of library... Bekijk &bovenste kaarten van library.... - + View bottom cards of library... Bekijk onderste kaarten van library... - + Reveal &library to... Onthul &library aan... - + Lend library to... Leen library aan... - + Reveal &top cards to... &Onthul bovenste kaarten aan.... - + &Top of library... &Bovenkant van library... - + &Bottom of library... &Onderkant van library... - + &Always reveal top card Toon &altijd bovenste kaart - + &Always look at top card &Altijd bovenste kaart bekijken - + &Open deck in deck editor &Open deck in deckbewerker - + &Draw card &Raap kaart - + D&raw cards... R&aap kaarten... - + &Undo last draw &Ontdoe laatste raap - + Shuffle Schudden - + &Play top card &Speel bovenste kaart - + Play top card &face down Speel bovenste kaart &verdekt - + Put top card on &bottom Leg bovenste kaart aan de &onderkant - + Move top card to grave&yard Verplaats bovenste kaart naar grave&yard - + Move top card to e&xile Verplaats bovenste kaart naar exile - + Move top cards to &graveyard... Verplaats bovenste kaarten naar &graveyard.... - + Move top cards to graveyard face down... Verplaats bovenste kaarten naar &graveyard verdekt.... - + Move top cards to &exile... Verplaats bovenste kaarten naar &exile.... - + Move top cards to exile face down... Verplaats bovenste kaarten naar exile verdekt.... - + Put top cards on stack &until... Plaats bovenste kaart op stack &totdat... - + Shuffle top cards... Schud bovenste kaarten... - + &Draw bottom card &Raap onderste kaart - + D&raw bottom cards... R&aap onderste kaarten... - + &Play bottom card &Speel onderste kaart - + Play bottom card &face down Speel onderste kaart &verdekt - + Move bottom card to grave&yard Verplaats onderste kaart naar grave&yard - + Move bottom card to e&xile Verplaats onderste kaart naar e&xile - + Move bottom cards to &graveyard... Verplaats onderste kaarten naar &graveyard.... - + Move bottom cards to graveyard face down... Verplaats onderste kaarten naar graveyard verdekt.... - + Move bottom cards to &exile... Verplaats onderste kaarten naar &exile... - + Move bottom cards to exile face down... Verplaats onderste kaarten naar exile verdekt... - + Put bottom card on &top Leg onderste kaart &bovenop - + Shuffle bottom cards... Schud onderste kaarten... - - + + &All players &Alle spelers - + Reveal top cards of library Onthul bovenste kaarten van library - + Number of cards: (max. %1) Aantal kaarten: (max. %1) @@ -4690,668 +5171,302 @@ Je moet misschien handmatig de nieuwe versie downloaden. MainWindow - - - The server has reached its maximum user capacity, please check back later. - De server heeft zijn maximale gebruikerscapaciteit bereikt, controleer later nogmaals. - - - - There are too many concurrent connections from your address. - Er zijn te veel gelijktijdige verbindingen vanaf uw adres. - - - - Banned by moderator - Verbannen door moderator - - - - Expected end time: %1 - Verwachte eindtijd: %1 - - - - This ban lasts indefinitely. - Deze verbanning duurt voor onbepaalde tijd. - - - - Scheduled server shutdown. - Geplande stillegging van de server. - - - - - Invalid username. - Ongeldige spelersnaam. - - - - You have been logged out due to logging in at another location. - U bent uitgelogd omdat u op een andere locatie bent ingelogd. - - - - Connection closed - Connectie verbroken - - - - The server has terminated your connection. -Reason: %1 - De server heeft uw verbinding beëindigd. -Reden: %1 - - - - The server is going to be restarted in %n minute(s). -All running games will be lost. -Reason for shutdown: %1 - De server wordt binnen een minuut opnieuw opgestart. -Alle lopende spellen zullen verloren gaan. -Reden voor de sluiting: %1De server wordt binnen %n minuten opnieuw opgestart. -Alle lopende spellen zullen verloren gaan. -Reden voor de sluiting: %1 - - - - Scheduled server shutdown - Geplande stillegging server - - - - - Success - Succesvol - - - - Registration accepted. -Will now login. - Registratie geaccepteerd. -Logt nu in. - - - - Account activation accepted. -Will now login. - Account activering geaccepteerd. -Logt nu in. - - - - + + Player %1 Speler %1 - + Load replay Laad replay - + About Cockatrice Over Cockatrice - + Version Versie - + Cockatrice Webpage Cockatrice Webpagina - + Project Manager: Project Manager: - + Past Project Managers: Vroegere projectmanagers: - + Developers: Ontwikkelaars: - + Our Developers Onze ontwikkelaars - + Help Develop! Help ontwikkelen! - + Translators: Vertalers: - + Our Translators Onze Vertalers - + Help Translate! Help Vertalen! - + Support: Steun: - + Report an Issue Een probleem melden - + Troubleshooting Probleemoplossing - + F.A.Q. F.A.Q. - - - - - - - - - - - - - - - - - - - - + + Error Fout - - Server timeout - Time-out van de server - - - - Failed Login - Gefaalde Login - - - - Your client seems to be missing features this server requires for connection. - Uw client lijkt functies te missen die deze server nodig heeft voor de verbinding. - - - - To update your client, go to 'Help -> Check for Client Updates'. - Om uw client bij te werken, gaat u naar 'Hulp -> Controleer op Client Updates'. - - - - Incorrect username or password. Please check your authentication information and try again. - Onjuiste gebruikersnaam of wachtwoord. Controleer uw verificatiegegevens en probeer het opnieuw. - - - - There is already an active session using this user name. -Please close that session first and re-login. - Er is al een actieve sessie bezig met deze gebruikersnaam. -Gelieve die sessie eerst af te sluiten en opnieuw in te loggen. - - - - - You are banned until %1. - Je bent banned tot %1. - - - - - You are banned indefinitely. - Je bent voor onbepaalde tijd banned. - - - - This server requires user registration. Do you want to register now? - Deze server vereist een gebruikersregistratie. Wilt u zich nu registreren? - - - - This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. -Please close and reopen your client to try again. - Deze server vereist client ID's. Uw client slaagt er niet in een ID te genereren of u heeft een aangepaste client. -Gelieve uw client te sluiten en te heropenen om het opnieuw te proberen. - - - - An internal error has occurred, please close and reopen Cockatrice before trying again. -If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. - Er is een interne fout opgetreden, gelieve uw client te sluiten en te heropenen alvorens het opnieuw te proberen. -Als de fout zich blijft voordoen, controleer dan of u de laatste versie van de software gebruikt en neem indien nodig contact op met de softwareontwikkelaars. - - - - Account activation - Account activering - - - - Your account has not been activated yet. -You need to provide the activation token received in the activation email. - Uw account is nog niet geactiveerd. -U moet de activeringscode die u in de activeringsmail hebt ontvangen, invullen. - - - - Server Full - Server Vol - - - - Unknown login error: %1 - Onbekende aanmeldingsfout: %1 - - - - - -This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. - -Dit betekent meestal dat uw clientversie verouderd is en dat de server een antwoord heeft gestuurd dat uw client niet begrijpt. - - - - Your username must respect these rules: - Uw gebruikersnaam moet aan deze regels voldoen: - - - - is %1 - %2 characters long - is %1 - %2 karakters lang - - - - can %1 contain lowercase characters - kan %1 kleine letters bevatten. - - - - - - - NOT - NIET - - - - can %1 contain uppercase characters - kan %1 hoofdletters bevatten - - - - can %1 contain numeric characters - kan %1 numerieke tekens bevatten - - - - can contain the following punctuation: %1 - kan de volgende leestekens bevatten: %1 - - - - first character can %1 be a punctuation mark - eerste teken kan %1 een leesteken zijn. - - - - no unacceptable language as specified by these server rules: - note that the following lines will not be translated - geen onacceptabel taalgebruik als aangegeven door deze server regels: - - - - can not contain any of the following words: %1 - mag niet een van de volgende woorden bevatten: %1 - - - - can not match any of the following expressions: %1 - mag niet overeenkomen met een van de volgende expressies: %1 - - - - You may only use A-Z, a-z, 0-9, _, ., and - in your username. - U mag alleen A-Z, a-z, 0-9, _, ., en - in uw gebruikersnaam gebruiken. - - - - - - - - - Registration denied - Registratie geweigerd - - - - Registration is currently disabled on this server - Registratie is momenteel uitgeschakeld op deze server. - - - - There is already an existing account with the same user name. - Er bestaat al een bestaand account met dezelfde gebruikersnaam. - - - - It's mandatory to specify a valid email address when registering. - Het is verplicht om een geldig e-mailadres op te geven bij de registratie. - - - - It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. - Het lijkt erop dat u probeert een nieuw account te registreren op deze server, maar dat u al een account hebt geregistreerd met de verstrekte e-mail. Deze server beperkt het aantal accounts dat een gebruiker per adres kan registreren. Neem contact op met de serverbeheerder voor verdere hulp of om uw gegevens te verkrijgen. - - - - Password too short. - Wachtwoord te kort. - - - - Registration failed for a technical problem on the server. - De registratie is mislukt wegens een technisch probleem op de server. - - - - The connection to the server has been lost. - De verbinding met de server is verloren gegaan. - - - - Unknown registration error: %1 - Onbekende registratiefout: %1 - - - - Account activation failed - Account activering mislukt - - - - Socket error: %1 - Socket error: %1 - - - - You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. -Local version is %1, remote version is %2. - U probeert verbinding te maken met een verouderde server. Gelieve uw Cockatrice versie te downgraden of verbinding te maken met een geschikte server. -Lokale versie is %1, externe versie is %2. - - - - Your Cockatrice client is obsolete. Please update your Cockatrice version. -Local version is %1, remote version is %2. - Uw Cockatrice klant is verouderd. Gelieve uw Cockatrice versie bij te werken. -Lokale versie is %1, externe versie is %2. - - - - Connecting to %1... - Verbinding maken met %1.... - - - - Registering to %1 as %2... - Registreren bij %1 als %2.... - - - - Disconnected - Verbinding verbroken - - - - Connected, logging in at %1 - Verbonden, inloggen bij %1 - - - - - - Requesting forgotten password to %1 as %2... - Aanvragen van vergeten wachtwoord tot %1 als %2... - - - + &Connect... &Verbinden.... - + &Disconnect V&erbinding verbreken - + Start &local game... Start &plaatselijk spel.... - + &Watch replay... &Bekijk replay... - + &Full screen &Volledig scherm - + &Register to server... &Registreer bij server... - + &Restore password... &Reset wachtwoord... - + &Settings... &Instellingen.... - + &Exit &Afsluiten - + A&ctions Act&ies - + &Cockatrice &Cockatrice - + C&ard Database &Kaartdatabase: - + &Manage sets... &Beheer sets... - + Edit custom &tokens... Aangepaste &tokens bewerken - + Open custom image folder Open map voor aangepaste afbeeldingen - + Open custom sets folder Open aangepaste sets map - + Add custom sets/cards Aangepaste sets/kaarten toevoegen - + Reload card database Kaartendatabase opniew laden - + Tabs Tabs - + &Help &Hulp - + &About Cockatrice &Over Cockatrice - + &Tip of the Day &Tip van de Dag - + Check for Client Updates Controleren op Client-updates - + Check for Card Updates... Kijk of er kaartupdates zijn... - + Check for Card Updates (Automatic) Controleren op kaartbewerkingen (Automatisch) - + Show Status Bar Statusbalk Weergeven - + View &Debug Log Bekijk &debug log - + Open Settings Folder Instellingsmap Openen - + Show/Hide Tonen/Verbergen - + New Version Niewe Versie - + Congratulations on updating to Cockatrice %1! Oracle will now launch to update your card database. Gefeliciteerd met de update naar Cockatrice %1! Oracle zal nu starten om uw kaartendatabase bij te werken. - + Cockatrice installed Cockatrice geinstalleerd - + Congratulations on installing Cockatrice %1! Oracle will now launch to install the initial card database. Gefeliciteerd met het installeren van Cockatrice %1! Oracle zal nu starten om uw kaartendatabase bij te werken. - + Card database Kaartdatabase - + Cockatrice is unable to load the card database. Do you want to update your card database now? If unsure or first time user, choose "Yes" Cockatrice kan de kaartendatabase niet laden. - - + + Yes Ja - - + + No Nee - + Open settings Open instellingen - + New sets found Nieuwe sets gevonden - + %n new set(s) found in the card database Set code(s): %1 Do you want to enable it/them? @@ -5362,17 +5477,22 @@ Set codes: %1 Wilt u deze inschakelen? - + + Yes, always enable + + + + View sets Bekijk sets - + Welcome Welkom - + Hi! It seems like you're running this version of Cockatrice for the first time. All the sets in the card database have been enabled. Read more about changing the set order or disabling specific sets and consequent effects in the "Manage Sets" dialog. @@ -5381,167 +5501,133 @@ Alle sets in de kaartendatabase zijn ingeschakeld. Lees meer over het wijzigen van de setvolgorde of het uitschakelen van specifieke sets en de daaruit voortvloeiende effecten in het "Beheer sets" dialoogvenster. - - + Information Informatie - + A card database update is already running. Een update van de kaartendatabase is al aan de gang. - + Unable to run the card database updater: Kan het updateprogramma van de kaartendatabase niet uitvoeren: - + Card database update running. Kaartendatabasebewerking in uitvoering. - + Failed to start. The file might be missing, or permissions might be incorrect. Opstarten mislukt. Het bestand mist misschien, of toestemmingen zijn misschien incorrect. - + The process crashed some time after starting successfully. Het proces crashte nadat deze geslaagd is opgestart. - + Timed out. The process took too long to respond. The last waitFor...() function timed out. Tijd verlopen. Het proces nam te lang met antwoorden. De laatste waitfor...() functie verliep. - + An error occurred when attempting to write to the process. For example, the process may not be running, or it may have closed its input channel. Een fout heeft zich voorgedaan tijdens een poging om te schrijven naar het proces. Bijvoorbeeld, het proces is niet in uitvoering misschien, of het heeft misschien het invoerkanaal gesloten. - + An error occurred when attempting to read from the process. For example, the process may not be running. Een fout heeft zich voorgedaan tijdens een poging om te lezen van het proces. Bijvoorbeeld, het proces is niet in uitvoering misschien. - + Unknown error occurred. Een onbekende fout heeft zich voorgedaan. - + The card database updater exited with an error: %1 De kaartendatabasebewerker sloot af met foutmelding. %1 - - This server supports additional features that your client doesn't have. -This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. - -To update your client, go to Help -> Check for Updates. - Deze server ondersteunt extra functies die uw client niet heeft. -Dit is waarschijnlijk geen probleem, maar dit bericht kan betekenen dat er een nieuwe versie van Cockatrice beschikbaar is of dat deze server een aangepaste of pre-release versie draait. - -Om uw client bij te werken, ga naar Hulp -> Controleer op updates. - - - - - - - + + + + + Load sets/cards Laad sets/kaarten - + Selected file cannot be found. Geselecteerd bestand kan niet worden gevonden. - + You can only import XML databases at this time. U kunt op dit moment alleen XML-databases importeren. - + The new sets/cards have been added successfully. Cockatrice will now reload the card database. De nieuwe sets/kaarten zijn succesvol toegevoegd. Cockatrice zal nu de kaartendatabase herladen. - + Sets/cards failed to import. Sets/kaarten zijn niet geïmporteerd. - - - - - Reset Password - Reset Wachtwoord - - - - Your password has been reset successfully, you can now log in using the new credentials. - Uw wachtwoord is succesvol gereset, u kunt nu inloggen met de nieuwe gegevens. - - - - Failed to reset user account password, please contact the server operator to reset your password. - Het opnieuw instellen van het wachtwoord voor uw gebruikersaccount is mislukt, neem contact op met de serverbeheerder om uw wachtwoord opnieuw in te stellen. - - - - Activation request received, please check your email for an activation token. - Activeringsaanvraag ontvangen, kijk in uw e-mail voor een activeringscode. - ManaBaseConfigDialog - + Mana Base Configuration Manabasis Configuratie - + Display type: Soort weergave: - + pie taart - + bar balk - + combinedBar combinatieBalk - + Filter Colors (optional): Filter Kleuren (optioneel): - + OK OK - + Cancel Annuleren @@ -5613,27 +5699,27 @@ Cockatrice zal nu de kaartendatabase herladen. ManaDevotionConfigDialog - + Display type: Soort weergave: - + pie taart - + bar balk - + combinedBar combinatieBalk - + Filter Colors (optional): Filter Kleuren (optioneel): @@ -5703,610 +5789,615 @@ Cockatrice zal nu de kaartendatabase herladen. MessageLogWidget - + from play vanuit spel - + from their graveyard vanuit hun graveyard. - + from exile vanuit exile - + from their hand vanuit hun hand. - + the top card of %1's library de bovenste kaart %1's library - + the top card of their library de bovenste kaart van hun library - + from the top of %1's library van de top van %1's library - + from the top of their library van de bovenkant van hun library - + the bottom card of %1's library de onderste kaart van %1's library - + the bottom card of their library de onderste kaart van hun library - + from the bottom of %1's library van de onderkant van %1's library - + from the bottom of their library van de onderkant van hun library - + from %1's library van %1's library - + from their library van hun library - + from sideboard van sideboard - + from the stack van de stack - + from custom zone '%1' vanaf aangepaste zone '%1' - + %1 is now keeping the top card %2 revealed. %1 houdt nu de bovenste kaart %2 onthuld. - + %1 is not revealing the top card %2 any longer. %1 geeft de bovenste kaart %2 niet meer weer. - + %1 can now look at top card %2 at any time. %1 kan nu altijd de bovenste kaart %2 bekijken. - + %1 no longer can look at top card %2 at any time. %1 kan nu niet meer de bovenste kaart %2 bekijken. - + %1 attaches %2 to %3's %4. %1 bevestigt %2 aan %3s %4. - + %1 has conceded the game. %1 heeft het spel opgegeven. - + %1 has unconceded the game. %1 is terug in het spel. - + %1 has restored connection to the game. %1 heeft de verbinding met het spel hersteld. - + %1 has lost connection to the game. %1 heeft de verbinding met het spel verloren. - + %1 points from their %2 to themselves. %1 wijst van hun %2 naar zichzelf. - + %1 points from their %2 to %3. %1 wijst van hun %2 naar %3. - + %1 points from %2's %3 to themselves. %1 wijst van %2s %3 naar zichzelf. - + %1 points from %2's %3 to %4. %1 wijst van %2s %3 naar %4. - + %1 points from their %2 to their %3. %1 wijst van hun %2 naar hun %3. - + %1 points from their %2 to %3's %4. %1 wijst van hun %2 naar %3s %4. - + %1 points from %2's %3 to their own %4. %1 wijst van %2s %3 naar hun eigen %4. - + %1 points from %2's %3 to %4's %5. %1 wijst van %2s %3 naar %4s %5. - + %1 creates a face down token. %1 creëert een token verdekt. - + %1 creates token: %2%3. %1 creëert een token: %2%3. - + %1 has loaded a deck (%2). %1 heeft een deck geladen (%2). - + %1 has loaded a deck with %2 sideboard cards (%3). %1 heeft een deck met %2 sideboard kaarten geladen (%3). - + %1 destroys %2. %1 vernietigt %2. - + a card een kaart - + %1 gives %2 control over %3. %1 geeft %2 controle over %3. - + %1 puts %2 into play%3 face down. %1 zet %2 gesloten in het spel%3. - + %1 puts %2 into play%3. %1 zet %2 in het spel%3. - + %1 puts %2%3 into their graveyard face down. %1 legt %2%3 in hun graveyard verdekt. - + %1 puts %2%3 into their graveyard. %1 legt %2%3 in hun graveyard. - + %1 exiles %2%3 face down. %1 legt %2%3 in exile verdekt. - + %1 exiles %2%3. %1 legt %2%3 in exile. - + %1 moves %2%3 to their hand. %1 verplaatst %2%3 naar hun hand. - + %1 puts %2%3 into their library. %1 legt %2%3 in hun library. - + %1 puts %2%3 onto the bottom of their library. %1 legt %2%3 aan de onderkant van hun library. - + %1 puts %2%3 on top of their library. %1 legt %2%3 op de bovenkant van hun library. - + %1 puts %2%3 into their library %4 cards from the top. %1 plaatst %2%3 in hun library %4 kaarten vanaf de bovenkant. - + %1 moves %2%3 to sideboard. %1 verplaatst %2%3 naar het sideboard. - + %1 plays %2%3 face down. %1 speelt %2%3 verdekt. - + %1 plays %2%3. %1 speelt %2%3. - + %1 moves %2%3 to custom zone '%4' face down. %1 verplaatst %2%3 naar aangepaste zone '%4' verdekt. - + %1 moves %2%3 to custom zone '%4'. %1 verplaatst %2%3 naar aangepaste zone '%4'. - + %1 tries to draw from an empty library %1 probeert een kaart te nemen van een lege library - + %1 draws %2 card(s). %1 trekt een kaart%1 trekt %2 kaarten - + %1 is looking at %2. %1 is aan het kijken naar %2. - + %1 is looking at the %4 %3 card(s) %2. top card for singular, top %3 cards for plural %1 kijkt naar de %4 %3 kaart %2.%1 kijkt naar de %4 %3 kaarten %2. - + bottom onderkant - + top bovenkant - + %1 turns %2 face-down. %1 draait %2 om met de voorkant naar beneden. - + %1 turns %2 face-up. %1 draait %2 om met de voorkant naar boven. - + The game has been closed. Het spel is afgesloten. - + The game has started. Het spel is begonnen. - + You are flooding the game. Please wait a couple of seconds. Je bent het spel aan het overstelpen. Wacht een paar seconden. - + %1 has joined the game. %1 heeft zich bij het spel aangesloten. - + %1 is now watching the game. %1 kijkt nu naar het spel. - + You have been kicked out of the game. Je bent uit het spel geschopt. - + %1 has left the game (%2). %1 heeft het spel verlaten (%2). - + %1 is not watching the game any more (%2). %1 kijkt niet meer naar het spel (%2). - + %1 is not ready to start the game any more. %1 is niet meer klaar om het spel te starten. - + %1 shuffles their deck and draws a new hand of %2 card(s). %1 schudt zijn of haar deck en raapt een nieuwe hand van %2 kaart.%1 schudt zijn of haar deck en raapt een nieuwe hand van %2 kaarten. - + %1 shuffles their deck and draws a new hand. %1 schudt zijn of haar deck en trekt een nieuwe hand. - + You are watching a replay of game #%1. Je kijkt naar een replay van spel #%1. - + %1 is ready to start the game. %1 is klaar om het spel te starten. - + cards an unknown amount of cards kaarten - + %1 card(s) a card for singular, %1 cards for plural één kaart%1 kaarten - + %1 lends %2 to %3. %1 leent %2 aan %3. - + %1 reveals %2 to %3. %1 onthult %2 naar %3. - + %1 reveals %2. %1 onthult %2. - + %1 randomly reveals %2%3 to %4. %1 onthult %2%3 aan %4 gebaseerd op willekeur. - + %1 randomly reveals %2%3. %1 onthult %2%3 gebaseerd op willekeur. - + %1 peeks at face down card #%2. %1 bekijkt verdekte kaart #%2. - + %1 peeks at face down card #%2: %3. %1 bekijkt verdekte kaart #%2: %3. - + %1 reveals %2%3 to %4. %1 onthult %2%3 aan %4. - + %1 reveals %2%3. %1 onthult %2%3. - + %1 reversed turn order, now it's %2. %1 keert de beurtvolgorde om, deze is nu %2. - + reversed omgekeerd - + normal normaal - + Heads Kop - + Tails Munt - + %1 flipped a coin. It landed as %2. %1 gooit een muntje om. Het landde %2. - + %1 rolls a %2 with a %3-sided die. %1 rolt een %2 met een %3-zijdige dobbelsteen. - + %1 flips %2 coins. There are %3 heads and %4 tails. %1 werpt %2 munten. Er zijn %3 koppen en %4 munten. - + %1 rolls a %2-sided dice %3 times: %4. %1 werpt een %2-zijdige dobbelsteen %3 keer: %4. - + %1's turn. %1s beurt. - + %1 sets annotation of %2 to %3. %1 verandert de annotatie van %2 naar %3. - - %1 places %2 "%3" counter(s) on %4 (now %5). - %1 plaatst %2 "%3" counter op %4 (nu %5).%1 plaatst %2 "%3" counters op %4 (nu %5). + + %1 places %2 %3%4 counter(s) on %5 (now %6). + - - %1 removes %2 "%3" counter(s) from %4 (now %5). - %1 verwijderd %2 "%3" counter van %4 (nu %5).%1 verwijderd %2 "%3" counters van %4 (nu %5). + + %1 removes %2 %3%4 counter(s) from %5 (now %6). + - + + %1 failed to undo their last draw. + + + + %1 sets counter %2 to %3 (%4%5). %1 verandert teller %2 naar %3 (%4%5). - + %1 sets %2 to not untap normally. %1 zet %2 op niet tegelijk ontappen. - + %1 sets %2 to untap normally. %1 zet %2 op tegelijk ontappen. - + %1 removes the PT of %2. %1 verwijdert de stats van %2. - + %1 changes the PT of %2 from nothing to %4. %1 verandert de stats van %2 van niks naar %4. - + %1 changes the PT of %2 from %3 to %4. %1 verandert de stats van %2 van %3 naar %4. - + %1 has locked their sideboard. %1 heeft hun sideboard vergrendeld. - + %1 has unlocked their sideboard. %1 heeft hun sideboard ontgrendeld. - + %1 taps their permanents. %1 tapt alles. - + %1 untaps their permanents. %1 ontapt alles. - + %1 taps %2. %1 tapt %2. - + %1 untaps %2. %1 ontapt %2. - + %1 shuffles %2. %1 schudt %2. - + %1 shuffles the bottom %3 cards of %2. %1 schudt de onderste %3 kaarten van %2. - + %1 shuffles the top %3 cards of %2. %1 schudt de bovenste %3 kaarten van %2. - + %1 shuffles cards %3 - %4 of %2. %1 schudt kaarten %3 - %4 van %2. - + %1 unattaches %2. %1 maakt %2 los. - + %1 undoes their last draw. %1 legt hun laatst getrekte kaart terug. - + %1 undoes their last draw (%2). %1 legt hun laats getrekte kaart terug (%2). @@ -6314,110 +6405,115 @@ Cockatrice zal nu de kaartendatabase herladen. MessagesSettingsPage - + Word1 Word2 Word3 Woord1 Woord2 Woord3 - + Add New Message Bericht toevoegen - + Edit Message Bericht bewerken - + Remove Message Bericht Verwijderen - + Add message Bericht toevoegen - - + + Message: Bericht: - + Edit message Bericht bewerken - + Chat settings Chat instellingen - + Custom alert words Aanpasbare alerteringswoorden - + Enable chat mentions Schakel chat vermeldingen in - + Enable mention completer Vermeldings aanvuller inschakelen - + In-game message macros In-game bericht macro's - + How to use in-game message macros Uitleg gebruik van in-game bericht macro's - + Ignore chat room messages sent by unregistered users Chatroomberichten van niet-geregistreerde gebruikers negeren - + Ignore private messages sent by unregistered users Privéberichten van niet-geregistreerde gebruikers negeren - - + + Ignore private messages sent by non-buddy users + + + + + Invert text color Text kleur inverteren - + Enable desktop notifications for private messages Desktopmeldingen voor privéberichten inschakelen - + Enable desktop notification for mentions Desktopmelding voor vermeldingen inschakelen - + Enable room message history on join Kamerboodschapsgeschiedenis op aansluiting inschakelen - - + + (Color is hexadecimal) (Kleur is hexadecimaal) - + Separate words with a space, alphanumeric characters only Woorden scheiden met een spatie, alleen alfanumerieke tekens @@ -6425,42 +6521,42 @@ Cockatrice zal nu de kaartendatabase herladen. MoveMenu - + Move to Verplaats naar - + &Top of library in random order %bovenkant library in willekeurige volgorde - + X cards from the top of library... X kaarten van bovenkant library... - + &Bottom of library in random order &Onderkant library in willekeurige volgorde - + T&able &Tafel - + &Hand &Hand - + &Graveyard &Graveyard - + &Exile &Exile @@ -6473,47 +6569,47 @@ Cockatrice zal nu de kaartendatabase herladen. Kaart type - + Mana Value Mana Waarde - + Color(s) Kleur(en) - + Loyalty Loyaliteit - + Main Card Type Hoofd Kaarttype - + Mana Cost Mana Kosten - + P/T P/T - + Side Kant - + Layout Indeling - + Color Identity Color Identity @@ -6536,6 +6632,152 @@ Cockatrice zal nu de kaartendatabase herladen. Overlap richting: + + PaletteEditorDialog + + + + Reset + + + + + + Apply + + + + + + Save && Apply + + + + + ▼ Edit Palette + + + + + + ▶ Edit Palette + + + + + Palette Editor — %1 + + + + + <b>Palette Editor</b> &nbsp;·&nbsp; %1 + + + + + This theme ships no default palette files + + + + + Replace current colours with the theme author's defaults + + + + + Switch between the light and dark palette files + + + + + Editing: + + + + + Show or hide the per-role colour grid for manual tweaks + + + + + ↺ Revert to theme default + + + + + Discard unsaved edits and restore the last saved palette + + + + + Preview this palette without saving to disk + + + + + Write palette-%1.toml and reload the theme + + + + + Cannot save: this theme has no directory on disk + + + + + Save failed + + + + + Could not write %1 to: +%2 + + + + + No default found + + + + + No default palette file found for the "%1" scheme. + + + + + PaletteGridWidget + + + Active + + + + + Disabled + + + + + Inactive + + + + + Normal interactive state + + + + + Widget is disabled / not interactive + + + + + Window is in background / unfocused + + + Phase @@ -6602,57 +6844,57 @@ Cockatrice zal nu de kaartendatabase herladen. PhasesToolbar - + Untap step Untap fase - + Upkeep step Upkeep fase - + Draw step Draw fase - + First main phase Eerste hoofdfase - + Beginning of combat step Begin vechtfase - + Declare attackers step Aanvallers aangeven fase - + Declare blockers step Verdedigers aangeven fase - + Combat damage step Gevechtsschade fase - + End of combat step Einde vechtfase - + Second main phase Tweede hoofdfase - + End of turn step Einde van de beurt fase @@ -6660,7 +6902,7 @@ Cockatrice zal nu de kaartendatabase herladen. PictureLoader - + en code for scryfall's language property, not available for all languages en @@ -6669,151 +6911,142 @@ Cockatrice zal nu de kaartendatabase herladen. PlayerActions - - View top cards of library - Bekijk bovenste kaarten van library - - - - - - - - - - - - Number of cards: (max. %1) - Aantal kaarten: (max. %1) - - - - View bottom cards of library - Bekijk onderste kaarten library - - - - Shuffle top cards of library - Schud bovenste kaarten library - - - - Shuffle bottom cards of library - Schud onderste kaarten library - - - - Draw hand - Raap hand - - - - 0 and lower are in comparison to current hand size - 0 en minder zijn in verhouding tot huidige handgrootte - - - - Draw cards - Raap kaarten - - - - - - + + + + grave graveyard - - - - + + + + exile exile + + + PlayerDialogs - + + View top cards of library + + + + + + + + + + + + + Number of cards: (max. %1) + + + + + View bottom cards of library + + + + + Shuffle top cards of library + + + + + Shuffle bottom cards of library + + + + + Draw hand + + + + + 0 and lower are in comparison to current hand size + + + + + Draw cards + + + + Move top cards to %1 - Verplaats bovenste kaarten naar %1 + - + Move bottom cards to %1 - Verplaats onderste kaarten naar %1 + - + Draw bottom cards - Raap onderste kaarten + - - - C&reate another %1 token - &Maak nog een %1 token - - - + Create tokens - Token maken + - - + Number: - Aantal: + - + Place card X cards from top of library - Plaats kaart X kaarten vanaf bovenkant van library + - + Which position should this card be placed: - Hoeveelste van boven moet de kaart geplaatst worden: + - + (max. %1) - (max. %1) + - + Change power/toughness - Verander kracht/hardheid + - + Change stats to: - Verander stats naar: + - + Set annotation - Aantekening instellen + - + Please enter the new annotation: - Voer de nieuwe aantekening in: - - - - Set counters - Counters instellen + PlayerMenu - + Player "%1" Speler "%1" - + &Counters &Counters @@ -6847,7 +7080,7 @@ Deze instelling betekend dat je alleen de geprioritiseerde druk van iedere kaart Navigatieknoppen weergeven - + Printing Selector Drukkiezer @@ -6855,22 +7088,22 @@ Deze instelling betekend dat je alleen de geprioritiseerde druk van iedere kaart PrintingSelectorCardOverlayWidget - + Preference Voorkeur - + Pin Printing Druk Vastpinnen - + Unpin Printing Druk Lospinnen - + Show Related cards Gerelateerde kaarten weergeven @@ -6941,57 +7174,57 @@ Deze instelling betekend dat je alleen de geprioritiseerde druk van iedere kaart PtMenu - + Power / toughness Kracht / hardheid - + &Increase power Ver&hoog kracht - + &Decrease power Ver&laag kracht - + I&ncrease toughness Verh&oog hardheid - + D&ecrease toughness Verl&aag hardheid - + In&crease power and toughness Verhoo&g kracht en hardheid - + Dec&rease power and toughness Verlaa&g kracht en hardheid - + Increase power and decrease toughness Verhoog kracht en verlaag hardheid - + Decrease power and increase toughness Verlaag kracht en verhoog hardheid - + Set &power and toughness... Kracht en hardheid &instellen... - + Reset p&ower and toughness &Herstel kracht en hardheid @@ -6999,37 +7232,37 @@ Deze instelling betekend dat je alleen de geprioritiseerde druk van iedere kaart QMenuBar - + Services Diensten - + Hide %1 Verberg %1 - + Hide Others Verberg Anderen - + Show All Toon Alle - + Preferences... Voorkeuren... - + Quit %1 Stop %1 - + About %1 Over %1 @@ -7037,17 +7270,17 @@ Deze instelling betekend dat je alleen de geprioritiseerde druk van iedere kaart QObject - + Cockatrice card database (*.xml) Cockatrice kaartendatabase (*.xml) - + All files (*.*) Alle bestanden (*.*) - + Cockatrice replays (*.cor) Cockatrice replays (*.cor) @@ -7119,110 +7352,177 @@ Weet je zeker dat je deze functie wil uitschakelen? QPlatformTheme - + OK OK - + Save Opslaan - + Save All Alles Opslaan - + Open Open - + &Yes &Ja - + Yes to &All Ja op A&lle - + &No &Nee - + N&o to All Nee op &Alle - + Abort Afbreken - + Retry Opnieuw proberen - + Ignore Negeren - + Close Sluiten - + Cancel Annuleren - + Discard Afleggen - + Help Hulp - + Apply Toepassen - + Reset Resetten - + Restore Defaults Standaardinstellingen herstellen + + QuickSetupPanel + + + %1% + + + + + <b>Quick Setup</b> + + + + + Generate all palette roles automatically from a single accent colour + + + + + Accent: + + + + + Primary hue. Used directly for highlights and links. +At high intensity it also tints buttons and backgrounds. + + + + + Intensity: + + + + + Subtle + + + + + Full colour + + + + + 0–30 Subtle tint — only highlights and links change hue +30–70 Accented — buttons, tooltips, and borders join in +70–100 Full colour — backgrounds, everything + + + + + 70% + + + + + Generate ↓ + + + + + Derive all palette roles from the accent colour above. +Fine-tune individual colours in the grid afterwards. + + + RemoteDeckList_TreeModel - + Name Naam - + ID ID - + Upload time Upload tijd @@ -7230,32 +7530,32 @@ Weet je zeker dat je deze functie wil uitschakelen? RemoteReplayList_TreeModel - + ID ID - + Name Naam - + Players Spelers - + Keep Bewaar - + Time started Tijd begonnen - + Duration (sec) Duur (sec) @@ -7263,37 +7563,37 @@ Weet je zeker dat je deze functie wil uitschakelen? RfgMenu - + &Exile &Exile - + &View exile &Bekijk exile - + &Move exile to... &Verplaats exile naar... - + &Top of library &Bovenkant library - + &Bottom of library &Onderkant library - + &Hand &Hand - + &Graveyard &Graveyard @@ -7339,7 +7639,7 @@ Weet je zeker dat je deze functie wil uitschakelen? SayMenu - + S&ay &Zeg @@ -7380,27 +7680,27 @@ Weet je zeker dat je deze functie wil uitschakelen? SetsModel - + Enabled Ingeschakeld - + Set type Set type - + Set code Set code - + Long name Lange naam - + Release date releasedatum @@ -7408,53 +7708,53 @@ Weet je zeker dat je deze functie wil uitschakelen? ShortcutSettingsPage - - + + Restore all default shortcuts Alle standaard snelkoppelingen herstellen - + Do you really want to restore all default shortcuts? Wilt u echt alle standaard snelkoppelingen herstellen? - + Clear all default shortcuts Alle standaard snelkoppelingen wissen - + Do you really want to clear all shortcuts? Wil je echt alle snelkoppelingen vrijmaken? - + Section: Sectie: - + Action: Functie: - + Shortcut: Sneltoets: - + How to set custom shortcuts Hoe aangepaste snelkoppelingen in te stellen - + Clear all shortcuts Alle sneltoetsen wissen - + Search by shortcut name Zoek op sneltoetsnaam @@ -7510,12 +7810,12 @@ Controleer uw snelkoppelingsinstellingen! SideboardMenu - + &Sideboard &Sideboard - + &View sideboard &Bekijk sideboard @@ -7523,27 +7823,27 @@ Controleer uw snelkoppelingsinstellingen! SoundSettingsPage - + Enable &sounds &Geluiden inschakelen - + Current sounds theme: Huidige geluiden thema: - + Test system sound engine Test geluid engine van systeem - + Sound settings Geluidsinstellingen - + Master volume Hoofdvolume @@ -7615,16 +7915,146 @@ Controleer uw snelkoppelingsinstellingen! Ongeldig antwoord ontvangen van de release update server. - + No reply received from the tag update server. Geen antwoord ontvangen van de tag update server. - + Invalid reply received from the tag update server. Ongeldig antwoord ontvangen van de tag update server. + + StorageSettingsPage + + + + + Success + + + + + Cached card pictures have been reset. + + + + + Downloaded card pictures have been reset. + + + + + Error + + + + + One or more downloaded card pictures could not be cleared. + + + + + In-memory (currently loaded) card pictures have been reset. + + + + + Card Picture Loader Caching Method: + + + + + The network cache is the preferred way of storing images. Downloaded images are stored here until the size of the cache exceeds the configured size. Cockatrice automatically monitors this cache and deletes the least recently seen card images to ensure the cache does not exceed the configured size. + + + + + Writing card images directly to a folder on your hard drive is another way of storing images. This does not change how Cockatrice accesses or downloads images. Cockatrice will NOT automatically monitor and clear this folder, so if you enable this option, it is up to you to ensure sufficient available space. It should also be noted that if a provider outage causes you to download the wrong picture (i.e. wrong printing) you will be stuck with it until you manually delete the file, as opposed to using the network cache, which automatically rotates and thus correct errors after a while. + + + + + This is the in-memory picture cache used by the application at runtime. It determines how much memory (RAM) Cockatrice can use before it has to fetch card images from the hard disk again. Increasing this will allow more card images to be displayed at once but shouldn't be necessary. Clearing this will make Cockatrice reload all images from the network cache or the disk. + + + + + Delete Cached Images + + + + + Delete Saved Images + + + + + Clear In-Memory Images + + + + + Card Picture Loader Cache Method + + + + + Network Cache + + + + + Filesystem + + + + + In-Memory Picture Cache + + + + + Network Cache Size: + + + + + On-disk cache for downloaded pictures + + + + + Redirect Cache TTL: + + + + + How long cached redirects for urls are valid for. + + + + + Picture Cache Size: + + + + + In-memory cache for pictures not currently on screen + + + + + Naming scheme: + + + + + Day(s) + + + TabAccount @@ -7760,117 +8190,117 @@ Controleer uw snelkoppelingsinstellingen! TabArchidekt - - + + Desc. Afl. - - + + AND EN - - + + Require ALL selected colors Vereis ALLE gekozen kleuren - - + + Deck name... Decknaam... - - + + Owner... Eigenaar... - - + + Packages Pakketten - - + + Advanced Filters Geavanceerde Filters - + Bracket: Bracket: - - + + Any Geen Voorkeur - - + + Contains card... Bevat kaart... - - + + Commander... Commander... - - + + Tag... Tag... - - + + Deck Size Deckgrootte - + Cards: Kaarten: - - + + Asc. Opl. - + Sort by: Sorteer op: - + Filter by: Filter op: - + Display Settings Weergavensinstellingen - - + + Search Zoeken - - + + Formats Formats @@ -7880,6 +8310,54 @@ Controleer uw snelkoppelingsinstellingen! Archidekt: + + TabCardArtRules + + + Card: + + + + + ProviderId: + + + + + Mode: + + + + + Reason: + + + + + Type a card name... + + + + + Add rule + + + + + Remove rule + + + + + Refresh + + + + + Card Art Rules + + + TabDeckEditor @@ -7928,7 +8406,7 @@ Controleer uw snelkoppelingsinstellingen! Indeling resetten - + Deck: %1 Dek: %1 @@ -7936,55 +8414,49 @@ Controleer uw snelkoppelingsinstellingen! TabDeckEditorVisual - + Visual Deck: %1 Visueel Deck: %1 - + &Visual Deck Editor &Visuele Deckbewerker - - + + Card Info Kaarteninfo - - + + Deck Deck - - - Filters - Filters - - - + &View &Bekijk - + Printing Druk - + Visible Zichtbaar - + Floating Zwevend - + Reset layout Herstel indeling @@ -7992,22 +8464,22 @@ Controleer uw snelkoppelingsinstellingen! TabDeckEditorVisualTabWidget - + Visual Deck View Visuele Deckweergave - + Visual Database Display Visuele Databaseweergave - + Deck Analytics Deckanalyses - + Sample Hand Proefhand @@ -8015,133 +8487,133 @@ Controleer uw snelkoppelingsinstellingen! TabDeckStorage - + Local file system Plaatselijk bestandssysteem - + Server deck storage Dekopslag op de server - - + + Open in deck editor Open deck in bewerker - + Rename deck or folder Hernoem deck of map - + Upload deck Upload dek - + Download deck Download dek - - - + + + New folder Nieuwe map - + Delete Verwijder - + Open decks folder Deckmap openen - + Rename local folder Hernoem plaatselijke map - + Rename local file Hernoem plaatselijk bestand - + New name: Nieuwe naam: - - - - + + + + Error Foutmelding - + Rename failed Hernoemen mislukt - - + + Invalid deck file Ongeldig deck bestand - + Enter deck name Geeft dek naam in - + This decklist does not have a name. Please enter a name: De deklijst heeft geen naam. Geef een naam in aub. - + Unnamed deck Onbenoemd dek - + Failed to upload deck to server Uploaded van deck naar server mislukt - + Delete local file Verwijder plaatselijk bestand - + Are you sure you want to delete the selected files? Weet je zeker dat je de opgeslagen bestanden wil verwijderen? - + Delete remote decks Verwijder deck van server - + Are you sure you want to delete the selected decks? Weet je zeker dat je de geselekteerde decks wil verwijderen? - - + + Name of new folder: Naam van de map: @@ -8213,191 +8685,191 @@ Please enter a name: TabGame - - - + + + Replay Replay - - + + Game Spel - - + + Player List Spelerslijst - - + + Card Info Kaartinfo - - + + Messages Berichten - - + + Replay Timeline Replay Tijdslijn - + &Phases Fases - + &Game Spel - + Next &phase Volgende fase - + Next phase with &action Volgende fase met &handeling - + Next &turn Volgende beurt - + Reverse turn order Keer beurtvolgorde om - + &Remove all local arrows Verwijder alle plaatselijke pijlen - + Rotate View Cl&ockwise Draai de weergave met de klok &mee - + Rotate View Co&unterclockwise Draai de weergave &tegen de klok in - + Game &information Spelgegevens - + Un&concede Opgeven &terugdraaien - - - + + + &Concede &Geef op - + &Leave game Verlaat spel - + C&lose replay Sluit opgeslagen spel - + &Focus Chat &Focus Chat - + &Say: Zeg: - + Selected cards Geselecteerde kaarten - + &View &Bekijk - + Visible Zichtbaar - + Floating Zwevend - + Reset layout Reset indeling - + Concede Opgeven - + Are you sure you want to concede this game? Ben je zeker dat je wilt opgeven voor dit spel? - + Unconcede Opgeven ongedaan maken - + You have already conceded. Do you want to return to this game? Je hebt al opgegeven. Wil je nar dit spel terugkeren? - + Leave game Verlaat spel - + Are you sure you want to leave this game? Ben je zeker dat je dit spel wilt verlaten? - + A player has joined game #%1 Een speler sluitte aan bij spel #%1 - + %1 has joined the game %1 sluitte bij het spel aan - + You have been kicked out of the game. Je bent uit het spel gegooid. @@ -8456,115 +8928,115 @@ Please enter a name: U moet een geldig aantal dagen selecteren om te zoeken. - + Username: Gebruikersnaam: - + IP Address: IP-adres: - + Game Name: Spelnaam: - + GameID: GameID: - + Message: Boodschap: - + Main Room Hoofdkamer - + Game Room Spelkamer - + Private Chat Privéchat - + Past X Days: Afgelopen X dagen: - + Today Vandaag - + Last Hour Afgelopen uur - + Maximum Results: Maximale resultaten: - + At least one filter is required. The more information you put in, the more specific your results will be. Er is minstens één filter nodig. Hoe meer informatie je inbrengt, hoe specifieker je resultaten zullen zijn. - + Get User Logs Gebruikerslogboeken ontvangen - + Clear Filters Wis filters - + Filters Filters - + Log Locations Loglokaties - + Date Range Datumbereik - + Maximum Results Maximale resultaten - - + + Message History Bericht Geschiedenis - + Failed to collect message history information. Het is mislukt om informatie over de berichtgeschiedenis te verzamelen. - + There are no messages for the selected filters. Er zijn geen berichten voor de geselecteerde filters. @@ -8582,27 +9054,27 @@ Hoe meer informatie je inbrengt, hoe specifieker je resultaten zullen zijn.Verlaat - + %1 - Private chat %1 - Privaat gesprek - + This user is ignoring you, they cannot see your messages in main chat and you cannot join their games. Deze gebruiker negeert u, ze kunnen uw berichten niet zien in de hoofdchat en u kunt niet deelnemen aan hun spelletjes. - + Private message from Privé bericht van - + %1 has left the server. %1 heeft zich afgemeld van de server. - + %1 has joined the server. %1 heeft zich aangemeld op de server @@ -8610,181 +9082,181 @@ Hoe meer informatie je inbrengt, hoe specifieker je resultaten zullen zijn. TabReplays - + Local file system Plaatselijk bestandssysteem - + Server replay storage Opslag gespeelde spellen op server - - + + Watch replay Bekijk opgeslagen spel - + Rename Hernoemen - - + + New folder Nieuwe map - - + + Delete Verwijder - + Open replays folder Open replays map - + Download replay Download opgeslagen spel. - + Toggle expiration lock Schakel expiratieslot om - + Get replay share code Ontvang replay-deelcode - - + + Look up replay by share code Replay opzoeken met replay-deelcode - + Rename local folder Hernoem plaatselijke map - + Rename local file Hernoem plaatselijk bestand - + New name: Nieuwe naam: - + Error Foutmelding - + Rename failed Hernoemen mislukt - + Name of new folder: Naam van nieuwe map: - + Delete local file Verwijder plaatselijk bestand - + Are you sure you want to delete the selected files? Weet je zeker dat je de geselecteerde bestanden wil verwijderen? - + Are you sure you want to delete the selected replays? Weet je zeker dat je de geselecteerde replays wil verwijderen? - + Failed to get code Ontvangen code mislukt - - + + Either this server does not support replay sharing, or does not permit replay sharing for you. Deze server ondersteund het delen van replays niet of deze staat het niet toe aan jou. - - - + + + Failed Mislukt - + Could not get replay code Ontvangen replay-code niet gelukt - + Replay Share Code Replay-deelcode - + Others can use this code to add the replay to their list of remote replays: %1 Anderen kunnen deze code gebruiken om de replay aan hun lijst van serverreplays toe te voegen: %1 - + Copy to clipboard Kopieer naar klembord - + Replay share code Replay-deelcode - + Replay code found Replay-code gevonden - + Replay was added, or you already had access to it. Replay toegevoegd, of je had misschien al toegang tot deze. - + Replay code not found Replay-code niet gevonden - + Failed to submit code Versturen code mislukt - + Unexpected error Onverwachte foutmelding - + Delete remote replay Verwijder op server opgeslagen spel @@ -8797,47 +9269,62 @@ Hoe meer informatie je inbrengt, hoe specifieker je resultaten zullen zijn. TabRoom - + + Friends + + + + + Online + + + + + Ignored + + + + &Say: Zeg: - + Chat Gesprek - + &Room Kamer - + &Leave room Verlaat kamer - + &Clear chat Maak gespreksgeschiedenis leeg - + Chat Settings... Gespreksinstellingen... - + mentioned you. heeft je genoemd. - + Click to view Klik om weer te geven - + You are flooding the chat. Please wait a couple of seconds. Je bent de chat aan het overstelpen. Wacht enkele seconden a.u.b. @@ -8850,30 +9337,30 @@ Hoe meer informatie je inbrengt, hoe specifieker je resultaten zullen zijn.Server - - - - + + + + Error Fout - + Failed to join the server room: it doesn't exist on the server. Aansluiten bij kamer mislukt: deze bestaat niet op de server. - + The server thinks you are in the server room but your client is unable to display it. Try restarting your client. De server denkt dat je in de kamer bent, maar de client is niet in staat om deze te tonen. Probeer de client opnieuw te starten. - + You do not have the required permission to join this server room. U heeft niet de vereiste toestemming om bij deze kamer aan te sluiten. - + Failed to join the server room due to an unknown error: %1. Mislukt om bij de kamer aan te sluiten door een onbekende fout: %1. @@ -8881,97 +9368,97 @@ Hoe meer informatie je inbrengt, hoe specifieker je resultaten zullen zijn. TabSupervisor - + Deck Editor Deckbewerker - + Visual Deck Editor Visuele Deckbewerker - + EDHRec EDHRec - + Archidekt Archidekt - + Home Thuis - + &Visual Deck Storage &Visuele Deckopslag - + Visual Database Display Visuele Databaseweergave - + Server Server - + Account Account - + Deck Storage Deckopslag - + Game Replays Spel-replays - + Administration Beheer - + Logs Logs - + Are you sure? Weet je het zeker? - + There are still open games. Are you sure you want to quit? Er staan nog spellen open. Ben je zeker dat je wilt afsluiten? - + Click to view Klik om weer te geven - + Your buddy %1 has signed on! Je maatje %1 heeft in gelogd! - + Unknown Event Onbekend Event - + The server has sent you a message that your client does not understand. This message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -8982,39 +9469,39 @@ Dit bericht kan betekenen dat er een nieuwe versie van Cockatrice beschikbaar is Om uw client bij te werken, ga naar Hulp -> Controleer op updates. - + Idle Timeout Inactiviteit Time-out - + You are about to be logged out due to inactivity. U staat op het punt uitgelogd te worden wegens inactiviteit. - + Promotion Promotie - + You have been promoted. Please log out and back in for changes to take effect. Je bent bevorderd. Gelieve uit te loggen en weer in te loggen om de wijzigingen in werking te laten treden. - + Warned Gewaarschuwd - + You have received a warning due to %1. Please refrain from engaging in this activity or further actions may be taken against you. If you have any questions, please private message a moderator. U heeft een waarschuwing ontvangen wegens %1. Gelieve af te zien van deelname aan deze activiteit of er kunnen verdere acties tegen u worden ondernomen. Als u vragen heeft, kunt u een privé-bericht sturen naar een moderator. - + You have received the following message from the server. (custom messages like these could be untranslated) U heeft het volgende bericht van de server ontvangen. @@ -9024,12 +9511,12 @@ Gelieve af te zien van deelname aan deze activiteit of er kunnen verdere acties TabVisualDatabaseDisplay - + Database Display Databaseweergave - + Visual Database Display Visuele Databaseweergave @@ -9068,42 +9555,42 @@ Gelieve af te zien van deelname aan deze activiteit of er kunnen verdere acties TranslateCounterName - + Life Leven - + White Wit - + Blue Blauw - + Black Zwart - + Red Rood - + Green Groen - + Colorless Kleurloos - + Other Overig @@ -9111,11 +9598,69 @@ Gelieve af te zien van deelname aan deze activiteit of er kunnen verdere acties UpdateDownloader - + Could not open the file for reading. Kon het bestand niet openen om te lezen. + + UserCardArtSettingsDialog + + + Card Art Settings + + + + + Type a card name... + + + + + Card name: + + + + + Card ProviderId: + + + + + Left margin (%): + + + + + Right margin (%): + + + + + Vertical offset: + + + + + Zoom: + + + + + Parameters + + + + + Preview + + + + + Remove Banner Card + + + UserContextMenu @@ -9233,7 +9778,7 @@ Gelieve af te zien van deelname aan deze activiteit of er kunnen verdere acties - + Ban History Ban Geschiedenis @@ -9248,77 +9793,87 @@ Gelieve af te zien van deelname aan deze activiteit of er kunnen verdere acties De gebruiker is nog nooit gebant. - + Failed to collect ban information. Mislukt in het verzamelen van gegevens over ban. - - - + + + Warning History Waarschuwingsgeschiedenis - + Warning Time;Moderator;User Name;Reason Waarschuwingstijd;Moderator;Gebruikersnaam;Reden - + User has never been warned. De gebruiker is nog nooit gewaarschuwd. - + Failed to collect warning information. Mislukt in het verzamelen van gegevens over waarschuwing. - + Failed to get admin notes. Ontvangen adminnotities mislukt. - - + + Success Succesvol - + Successfully promoted user. Succesvolle promotie van gebruiker. - + Successfully demoted user. Succesvolle demotie van gebruiker. - - - + + Kick Player + + + + + Are you sure you want to kick this player from the game? + + + + + + Failed Mislukt - + Failed to promote user. Mislukt in het bevorderen van gebruiker. - + Failed to demote user. De gebruiker is niet gedemoteerd. - + Copy hash to clipboard Kopieer hash naar klembord - + Remove this user's messages Verwijder de berichten van deze gebruiker @@ -9326,326 +9881,554 @@ Gelieve af te zien van deelname aan deze activiteit of er kunnen verdere acties UserInfoBox - + Location: Locatie: - + Account Age: Leeftijd lidmaatschap - + Edit Bewerken - + Change password Verander wachtwoord - + Change avatar Verander Icoon - + Administrator Administrator - + Moderator Moderator - + Registered user Geregistreerde gebruiker - - + + Unregistered user Ongeregistreerde gebruiker - + Judge Judge - + Unknown Onbekend - + The entered password does not match your account. Het opgegeven wachtwoord komt niet overeen met jouw account. - - - + + + + Information Gegevens - + User information updated. Gebruikersgegevens bijgewerkt. - - - - - - - - - - + + + + + + + + + + + Error Fout - + User Information Gebruikersgegevens - + Real Name: Echte Naam: - + User Level: Gebruikersniveau: + + + Edit Banner Card + + - + %n Year(s), amount of years (only shown if more than 0) %n jaar, %n jaar, - + %10%n Day(s) %20 amount of years (if more than 0), amount of days, date in local short format %10%n dag %20%10%n Dagen %20 - + Enter Password Wachtwoord Invoeren - + Password verification is required in order to change your email address Wachtwoord controle is vereist om je e-mailadres te veranderen - - - + + + An error occurred while trying to update your user information. Er is een fout opgetreden bij het bijwerken van uw gebruikersgegevens. - + + The selected card is blacklisted on this server or another error occurred. + + + + + Banner card removed. + + + + + Banner card updated. + + + + This server does not permit you to update your user informations. Deze server staat jou niet toe om je gebruikersgegevens bij te werken. - + Password changed. Wachtwoord gewijzigd. - + This server does not permit you to change your password. Deze server staat u niet toe uw wachtwoord te wijzigen. - + The new password is too short. Het nieuwe wachtwoord is te kort. - + The old password is incorrect. Het oude wachtwoord is onjuist. - + Avatar updated. Avatar bijgewerkt. - + This server does not permit you to update your avatar. Deze server laat je niet toe om je avatar bij te werken. + + + An error occured while trying to update your avatar. + + - An error occured while trying to updater your avatar. - Er is een fout opgetreden bij het updaten van uw avatar. + This server does not permit you to update your user informations. + An error occured while trying to updater your avatar. + Deze server staat jou niet toe om je gebruikersgegevens bij te werken. + + + + UserInfoPopup + + + + Games + + + + + Chat + + + + + Open private chat + + + + + Profile + + + + + View user profile + + + + + Show this user's games + + + + + − Buddy + + + + + Remove from buddy list + + + + + + Buddy + + + + + Add to buddy list + + + + + − Ignore + + + + + Remove from ignore list + + + + + + Ignore + + + + + Add to ignore list + + + + + Ban + + + + + Ban from server + + + + + Warn + + + + + Warn user + + + + + Ban log + + + + + View ban history + + + + + Warn log + + + + + View warning history + + + + + Notes + + + + + View admin notes + + + + + − Mod + + + + + Demote from moderator + + + + + + Mod + + + + + Promote to moderator + + + + + − Judge + + + + + Demote from judge + + + + + + Judge + + + + + Promote to judge + + + + + Join game + + + + + Spectate + + + + + + Loading games… + + + + + Could not load games. + + + + + No active games. + UserInterfaceSettingsPage - + General interface settings Algemene weergave-instellingen - + &Double-click cards to play them (instead of single-click) Dubbelklik op kaarten om ze te spelen (in plaats van enkele klik) - + &Clicking plays all selected cards (instead of just the clicked card) &Klikken speelt alle geselecteerde kaarten (in plaats van allen de geklikte kaart) - + &Play all nonlands onto the stack (not the battlefield) by default Speel standaard alle niet-landen op de stapel (niet op het slagveld) - + Do not delete &arrows inside of subphases Verwijder &pijlen niet binnen subphases - + Close card view window when last card is removed Sluit kaartenweergavevenster wanner laatste kaart verwijderd is - + Auto focus search bar when card view window is opened Focus zoekbalk onmiddelijk wanneer kaartenweergavevenster is geopent - + Annotate card text on tokens Maak aantekeningen bij de kaarttekst op tokens - - Show selection counter during drag selection - Geef selectieteller weer tijdens sleepselectie + + Show selection count during drag selection + - - Show total selection counter - Geef totale selectieteller weer + + Show total selection count + - + + Show subtype breakdown in selection tally + + + + Use tear-off menus, allowing right click menus to persist on screen Gebruik tear-off menus, laat rechtermuisknopmenu's op het scherm staan - + + Keep game chat focused when clicking in game (Note: disables card view search bar) + + + + Notifications settings Notificatie instellingen - + Enable notifications in taskbar Activeer meldingen in de taakbalk - + Notify in the taskbar for game events while you are spectating Meld spelgebeurtenissen in de taakbalk terwijl je toekijkt - + Notify in the taskbar when users in your buddy list connect Notificatie in de taakbalk wanneer gebruikers in je buddy lijst verbinding maken - + Animation settings Animatie-instellingen - + &Tap/untap animation &Tap/Untap animatie - + Deck editor/storage settings Deckbewerker/opslag instellingen - + Open deck in new tab by default Deck standaard in niewe tab openen - + Use visual deck storage in game lobby Visuele deckopslag gebruiken in spelontvangstscherm - + Use selection animation for Visual Deck Storage Gebruik selectieanimatie voor Visuele Deckopslag - + When adding a tag in the visual deck storage to a .txt deck: Wanner een tag aan een .txt deck wordt toegevoegd in de visuele deckopslag: - + do nothing niks doen - + ask to convert to .cod vragen om naar .cod om te zetten - + always convert to .cod altijd omzetten naar .cod - + Default deck editor type Standaard deckbewerker soort - + Classic Deck Editor Klassieke Deckbewerker - + Visual Deck Editor Visuele Deckbewerker - + Replay settings Replayinstellingen - + Buffer time for backwards skip via shortcut: @@ -9653,22 +10436,22 @@ Gelieve af te zien van deelname aan deze activiteit of er kunnen verdere acties UserListWidget - + Users connected to server: %1 - + Users in this room: %1 - + Buddies online: %1 / %2 - + Ignored users online: %1 / %2 @@ -9676,35 +10459,45 @@ Gelieve af te zien van deelname aan deze activiteit of er kunnen verdere acties UtilityMenu - + Increment all card counters - + &Untap all permanents - + R&oll die... - + + Flip coin + + + + &Create token... - + C&reate another token - + Cr&eate predefined token + + + C&reate another %1 token + + VisualDatabaseDisplayColorFilterWidget @@ -9756,72 +10549,72 @@ Gelieve af te zien van deelname aan deze activiteit of er kunnen verdere acties VisualDatabaseDisplayFilterToolbarWidget - + Sort by - + Filter by - + Save and load filters - + Filter by exact card name - + Filter by card main-type - + Filter by card sub-type - + Filter by set - + Filter by format legality - + Save/Load - + Name - + Main Type - + Sub Type - + Sets - + Formats @@ -9829,22 +10622,22 @@ Gelieve af te zien van deelname aan deze activiteit of er kunnen verdere acties VisualDatabaseDisplayFormatLegalityFilterWidget - + Show formats with at least: - + cards - + Do not display formats with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) @@ -9862,32 +10655,32 @@ Gelieve af te zien van deelname aan deze activiteit of er kunnen verdere acties VisualDatabaseDisplayMainTypeFilterWidget - + Show main types with at least: - + cards - + Do not display card main-types with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) - + Mode: Exact Match - + Mode: Includes @@ -9895,27 +10688,27 @@ Gelieve af te zien van deelname aan deze activiteit of er kunnen verdere acties VisualDatabaseDisplayNameFilterWidget - + Filter by name... (Exact match) - + Load from Deck - + Apply all card names in currently loaded deck as exact match name filters - + Load from Clipboard - + Apply all card names in clipboard as exact match name filters @@ -9923,7 +10716,7 @@ Gelieve af te zien van deelname aan deze activiteit of er kunnen verdere acties VisualDatabaseDisplayRecentSetFilterSettingsWidget - + Filter to most recent sets @@ -9931,19 +10724,19 @@ Gelieve af te zien van deelname aan deze activiteit of er kunnen verdere acties VisualDatabaseDisplaySetFilterWidget - + Search sets... - - + + Mode: Exact Match - - + + Mode: Includes @@ -9951,37 +10744,37 @@ Gelieve af te zien van deelname aan deze activiteit of er kunnen verdere acties VisualDatabaseDisplaySubTypeFilterWidget - + Search subtypes... - + Show sub types with at least: - + cards - + Do not display card sub-types with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) - + Mode: Exact Match - + Mode: Includes @@ -9989,23 +10782,23 @@ Gelieve af te zien van deelname aan deze activiteit of er kunnen verdere acties VisualDatabaseDisplayWidget - + Search by card name (or search expressions) - + Visual - + Loading database ... - + Clear all filters @@ -10044,17 +10837,17 @@ Gelieve af te zien van deelname aan deze activiteit of er kunnen verdere acties - + Toggle Layout: Overlap - + Change how cards are displayed within zones (i.e. overlapped or fully visible.) - + Toggle Layout: Flat @@ -10083,17 +10876,17 @@ Gelieve af te zien van deelname aan deze activiteit of er kunnen verdere acties VisualDeckEditorWidget - + Type a card name here for suggestions from the database... - + Quick search and add card - + Search for closest match in the database (with auto-suggestions) and add preferred printing to the deck on pressing enter @@ -10211,43 +11004,43 @@ Gelieve af te zien van deelname aan deze activiteit of er kunnen verdere acties WarningDialog - + Which warning would you like to send? Welke waarschuwing wilt u sturen? - + Redact all messages from this user in all rooms Verwijder alle berichten van deze gebruiker in alle kamers - + &OK &OK - + &Cancel &Cancel - + Warn user for misconduct Waarschuw gebruiker voor wangedrag - - + + Error Fout - + User name to send a warning to can not be blank, please specify a user to warn. Gebruikersnaam om een waarschuwing naar te sturen kan niet leeg zijn, geef een gebruiker op om te waarschuwen. - + Warning to use can not be blank, please select a valid warning to send. Te gebruiken waarschuwing kan niet leeg zijn, selecteer een geldige waarschuwing om te verzenden. @@ -10255,133 +11048,133 @@ Gelieve af te zien van deelname aan deze activiteit of er kunnen verdere acties WndSets - + Move selected set to the top Verplaats de geselecteerde set naar boven - + Move selected set up Verplaats de geselecteerde set naar boven - + Move selected set down Verplaats de geselecteerde set naar beneden - + Move selected set to the bottom Verplaats de geselecteerde set naar beneden - + Search by set name, code, or type Zoeken op setnaam, -code of -type - + Default order Standaard volgorde - + Restore original art priority order Herstel oorspronkelijke volgorde prioriteit afbeeldingen - + Enable all sets Alle sets inschakelen - + Disable all sets Alle sets uitschakelen - + Enable selected set(s) Geselecteerde set(s) inschakelen - + Disable selected set(s) Geselecteerde set(s) uitschakelen - + Deck Editor Deckbewerker - + Use CTRL+A to select all sets in the view. Gebruik CTRL+A om alle sets in de lijst te selecteren. - + Only cards in enabled sets will appear in the card list of the deck editor. Alleen kaarten in de ingeschakelde sets zullen verschijnen in de kaartlijst van de deckbewerker. - + Image priority is decided in the following order: De prioriteit voor afbeeldingen wordt in de volgende volgorde vastgesteld: - + first the CUSTOM Folder (%1), then the Enabled Sets in this dialog (Top to Bottom) %1 is a link to the wiki eerst de CUSTOM Map (%1), daarna de Ingeschakelde Sets in deze dialoog (Boven naar Beneden) - + Include cards rebalanced for Alchemy [requires restart] - + Card Art Afbeelding - + How to use custom card art Hoe aangepaste kaart afbeeldingen te gebruiken - + Hints Tips - + Note Opmerking - + Sorting by column allows you to find a set while not changing set priority. Sorteren op kolom stelt u in staat om een set te vinden zonder de ingestelde prioriteit te wijzigen. - + To enable ordering again, click the column header until this message disappears. Om rangschikken weer mogelijk te maken, klik op de kop van de kolom totdat dit bericht verdwijnt. - + Use the current sorting as the set priority instead Gebruik de huidige sortering als de ingestelde prioriteit - + Sorts the set priority using the same column Sorteert de prioriteit van sets met behulp van dezelfde kolom - + Manage sets Beheer sets @@ -10389,72 +11182,72 @@ Gelieve af te zien van deelname aan deze activiteit of er kunnen verdere acties ZoneViewWidget - + Search by card name (or search expressions) - + Ungrouped - + Group by Type - + Group by Mana Value - + Group by Color - + Unsorted - + Sort by Name - + Sort by Type - + Sort by Mana Cost - + Sort by Colors - + Sort by P/T - + Sort by Set - + shuffle when closing Schudden bij afsluiten - + pile view Stapelweergave @@ -10462,7 +11255,7 @@ Gelieve af te zien van deelname aan deze activiteit of er kunnen verdere acties i18n - + English Nederlands (Dutch) @@ -10470,12 +11263,12 @@ Gelieve af te zien van deelname aan deze activiteit of er kunnen verdere acties main - + Connect on startup Verbinding maken bij het opstarten - + Debug to file Debug naar bestand @@ -10489,7 +11282,7 @@ Gelieve af te zien van deelname aan deze activiteit of er kunnen verdere acties - + Deck Editor Deckbewerker @@ -10570,7 +11363,7 @@ Gelieve af te zien van deelname aan deze activiteit of er kunnen verdere acties - + Replays Replays @@ -10685,129 +11478,129 @@ Gelieve af te zien van deelname aan deze activiteit of er kunnen verdere acties Deck Inladen... - - + + Load Deck from Clipboard... Deck Inladen van Klembord... - + Edit Deck in Clipboard, Annotated - + Edit Deck in Clipboard - + New Deck Nieuw Deck - + Open Custom Pictures Folder Open Custom Afbeeldingen Map - + Print Deck... Print Deck... - + Delete Card Verwijder Kaart - - + + Reset Layout Reset Indeling - + Save Deck Bewaar Deck - + Save Deck as... Bewaar Deck Als... - + Save Deck to Clipboard, Annotated Bewaar Deck op Klembord, Geannoteerd - + Save Deck to Clipboard, Annotated (No Set Info) - + Save Deck to Clipboard Bewaar Deck op Klembord - + Save Deck to Clipboard (No Set Info) - + Load Local Deck... Plaatselijk Deck Inladen... - + Load Remote Deck... Remote Deck Inladen... - + Set Ready to Start Gereed om te Beginnen - + Toggle Sideboard Lock Sideboard Vergrendeling Omschakelen - + Add Green Counter Groene Counters Omhoog - + Remove Green Counter Groene Counters Omlaag - + Set Green Counters... Groene Tellers Instellen... - + Add Red Counter Rode Counters Omhoog - + Remove Red Counter Rode Counters Omlaag - + Set Red Counters... Rode Counters Instellen... - + Add Life Counter Levensteller Omhoog @@ -10817,724 +11610,744 @@ Gelieve af te zien van deelname aan deze activiteit of er kunnen verdere acties - + + Load deck from online service... + + + + + Load from website... + + + + Unload Deck - + Force Start - + Add Card Counter (F) - + Remove Card Counter (F) - + Set Card Counters (F)... - + Add Card Counter (E) - + Remove Card Counter (E) - + Set Card Counters (E)... - + Add Card Counter(D) - + Remove Card Counter (D) - + Set Card Counters (D)... - + Add Card Counter (C) - + Remove Card Counter (C) - + Set Card Counters (C)... - + Add Card Counter (B) - + Remove Card Counter (B) - + Set Card Counters (B)... - + Add Card Counter (A) - + Remove Card Counter (A) - + Set Card Counters (A)... - + Remove Life Counter Levensteller Omlaag - + Set Life Counters... Levensteller Instellen... - + Add White Counter Witte Teller Omhoog - + Remove White Counter Witte Teller Omlaag - + Set White Counters... Witte Teller Instellen... - + Add Blue Counter Blauwe Teller Omhoog - + Remove Blue Counter Blauwe Teller Omlaag - + Set Blue Counters... Blauwe Teller Instellen... - + Add Black Counter Zwarte Teller Omhoog - + Remove Black Counter Zwarte Teller Omlaag - + Set Black Counters... Zwarte Teller Instellen... - + Add Colorless Counter Kleurloze Teller Omhoog - + Remove Colorless Counter Kleurloze Teller Omlaag - + Set Colorless Counters... Kleurloze Teller Instellen... - + Add Other Counter Overige Teller Omhoog - + Remove Other Counter Overige Teller Omlaag - + Set Other Counters... Overige Teller Instellen... - + Increment all card counters - + Add Power (+1/+0) Kracht Verhogen (+1/+0) - + Remove Power (-1/-0) Kracht Verlagen (-1/-0) - + Move Toughness to Power (+1/-1) Verplaats Hardheid Naar Kracht (+1/-1) - + Add Toughness (+0/+1) Hardheid Verhogen (+0/+1) - + Remove Toughness (-0/-1) Hardheid Verlagen (-0/-1) - + Move Power to Toughness (-1/+1) Verplaats Kracht Naar Hardheid (-1/+1) - + Add Power and Toughness (+1/+1) Kracht en Hardheid Verhogen (+1/+1) - + Remove Power and Toughness (-1/-1) Kracht en Hardheid Verlagen (-1/-1) - + Set Power and Toughness... Kracht en Hardheid Instellen... - + Reset Power and Toughness Kracht en Hardheid Resetten - + Untap Ontap - + Upkeep Upkeep - + Draw Trek - + First Main Phase Eerste Hoofdfase - + Start Combat Start Gevechtfase - + Attack Aanval - + Block Blokkeren - + Damage Schade - + End Combat Einde Gevechtfase - + Second Main Phase Tweede Hoofdfase - + End Einde - + Next Phase Volgende Fase - + Next Phase Action Volgende Fase Handeling - + Next Turn Volgende Beurt - + Hide Card in Reveal Window - + Tap / Untap Card Tap / Ontap Kaart - + Untap All Untap Alles - + Toggle Skip Untapping Toggle Untap - + Turn Card Over Draai Kaart Om - + Peek Card Bekijk Kaart - + Play Card Speel Kaart - + Play Card, Face Down - + Attach Card... Kaart Bevestigen Aan... - + Unattach Card Kaart Losmaken - + Clone Card Copieer Kaart - + Create Token... Maak Token... - + Create All Related Tokens Maak Alle Gerelateerde Tokens - + Create Another Token Maak Nog een Token - + Set Annotation... Zet Annotatie... - + + Reduce Life by Power + + + + Select All Cards in Zone - + Select All Cards in Row - + Select All Cards in Column - + Reveal Selected Cards to All Players - - + + Bottom of Library Onderkant van Library - - - - + + + + Exile Exile - - - - + + + + Graveyard Graveyard - - + + Hand Hand - - + + Top of Library Bovenkant van Library - - + + Battlefield, Face Down Speelveld, Gesloten - + Battlefield Speelveld - + Library Library - + Sideboard Sideboard - + Top Cards of Library Bovenste Kaarten van Library - + Bottom Cards of Library - + Close Recent View Recent Overzicht Sluiten - - + + Stack Stack - - + + Graveyard (Multiple) Graveyard (Meerdere) - - + + Graveyard (Multiple), Face Down - - + + Exile (Multiple) Exile (Meerdere) - - + + Exile (Multiple), Face Down - + Stack Until Found Stack Totdat Gevonden - + Draw Bottom Card Trek Onderste Kaart - + Draw Multiple Cards from Bottom... Trek Meerdere Kaarten van Onderkant... - + Draw Arrow... Trek Pijl... - + Remove Local Arrows Verwijder Plaatselijke Pijlen - + Leave Game Verlaat Spel - + Concede Opgeven - + Roll Dice... Werp Dobbelstenen... - + + Flip Coin + + + + Shuffle Library Library Schudden - + Shuffle Top Cards of Library - + Shuffle Bottom Cards of Library - + Mulligan Mulligan - + Mulligan (Same hand size) - + Mulligan (Hand size - 1) - + Draw a Card Trek een Kaart - + Draw Multiple Cards... Trek Meerdere Kaarten... - + Undo Draw Kaart Trekken Ongedaan Maken - + Always Reveal Top Card Altijd Bovenste Kaart Onthullen - + Always Look At Top Card Altijd Bovenste Kaart Bekijken - + Sort Hand by Name - + Sort Hand by Type - + Sort Hand by Mana Value - + Reveal Hand to All Players - + Reveal Random Card to All Players - + Rotate View Clockwise Draai Weergave Met de Klok Mee - + Rotate View Counterclockwise Draai Weergave Tegen de Klok In - + Unfocus Text Box Textvak Verlaten - + Focus Chat Focus Naar Chat - + Clear Chat Wis Chat - + Refresh Verversen - + Skip Forward - + Skip Backward - + Skip Forward by a lot - + Skip Backward by a lot - + Play/Pause Start/Stop - + Toggle Fast Forward Versnelling Schakelen - + Home - + Visual Deck Storage - + Deck Storage - + Server Server - + Account Account - + Administration - + Logs diff --git a/cockatrice/translations/cockatrice_pl.ts b/cockatrice/translations/cockatrice_pl.ts index 236eff991..29b7b3896 100644 --- a/cockatrice/translations/cockatrice_pl.ts +++ b/cockatrice/translations/cockatrice_pl.ts @@ -2,7 +2,7 @@ AbstractCounter - + &Set counter... &Ustaw znacznik… @@ -10,12 +10,12 @@ AbstractCounterDialog - + Set counter Ustaw znacznik - + New value for counter '%1': Nowa wartość dla znacznika '%1' @@ -36,60 +36,60 @@ AbstractTabDeckEditor - + Open in new tab - + Are you sure? - + The decklist has been modified. Do you want to save the changes? - - - - - - + + + + + + Error - + Could not open deck at %1 - + Could not save remote deck - - + + The deck could not be saved. Please check that the directory is writable and try again. - + Save deck - + The deck could not be saved. - + There are no cards in your deck to be exported @@ -105,12 +105,12 @@ Please check that the directory is writable and try again. AdminNotesDialog - + Update Notes Aktualności - + Admin Notes for %1 Notatki Administracyjne dla %1 @@ -127,172 +127,227 @@ Please check that the directory is writable and try again. Sideboard Sideboard + + + Tokens + + AppearanceSettingsPage - + seconds - + Error Błąd - + Could not create themes directory at '%1'. Nie można stworzyć folderu motywów w '%1'. - + Theme settings Ustawienia motywu - + Current theme: Obecny motyw: - + Open themes folder Otwórz folder motywów - + Home tab background source: - + Home tab background shuffle frequency: - + Disabled - - Display card name of background in bottom right: - - - - + Menu settings Ustawienia menu - + Show keyboard shortcuts in right-click menus Pokazuj skróty klawiszowe w menu kontekstowym - + Show game filter toolbar above list in room tab - + Card rendering Rendering kart - + Display card names on cards having a picture Wyświetl nazwy kart na kartach z obrazkami - + Auto-Rotate cards with sideways layout Automatycznie obracaj karty horyzontalne - + Override all card art with personal set preference (Pre-ProviderID change behavior) - + + Light + + + + + Dark + + + + + System + + + + + Active theme palette: + + + + + Edit theme palette + + + + + Home tab settings + + + + + Display card name of background in bottom right + + + + + Styling settings + + + + + Style user list + + + + + Card printings + + + + Bump sets that the deck contains cards from to the top in the printing selector Podbijaj dodatki z których karty są w talii. - + Scale cards on mouse over Skaluj karty po najechaniu kursorem myszy - + Use rounded card corners - + + Card layout + + + + Minimum overlap percentage of cards on the stack and in vertical hand Minimalny procent zachodzenia kart na siebie na stosie oraz w ręce pionowej - + Maximum initial height for card view window: Maksymalna podstawowa wysokość okna podglądu karty - - + + rows rzędy - + Maximum expanded height for card view window: - + Card counters - + Counter %1 - + Hand layout Układ ręki - + Display hand horizontally (wastes space) Wyświetl karty na ręce poziomo (marnuje miejsce) - + Enable left justification Wyrównuj karty do lewej strony pola - + Table grid layout Układ stołu - + Invert vertical coordinate Odwróć współrzędne pionowe - + Minimum player count for multi-column layout: Minimalna liczba graczy dla widoku wielokolumnowego - + Maximum font size for information displayed on cards: Maksymalny rozmiar czcionki dla informacji wyświetlanych na kartach @@ -300,12 +355,12 @@ Please check that the directory is writable and try again. ArchidektApiResponseDeckDisplayWidget - + Back to results - + Open Deck in Deck Editor @@ -331,112 +386,112 @@ Please check that the directory is writable and try again. BanDialog - + ban &user name zbanuj nazwę &użytkownika - + ban &IP address zbanuj adres &IP - + ban client I&D ID zablokowanego konta - + Ban type Rodzaj bana - + &permanent ban ban &permanentny - + &temporary ban ban &tymczasowy - + &Days: &Dni: - + &Hours: &Godziny: - + &Minutes: &Minuty: - + Duration of the ban Czas trwania bana - + Please enter the reason for the ban. This is only saved for moderators and cannot be seen by the banned person. Podaj przyczynę bana. Informacja ta zachowywana jest dla moderatorów i nie jest widoczna dla zbanowanej osoby. - + Please enter the reason for the ban that will be visible to the banned person. Podaj przyczynę bana do wglądu dla banowanej osoby. - + Redact all messages from this user in all rooms Redaguj wiadomości od tego użytkownika we wszystkich pokojach - + &OK &OK - + &Cancel &Anuluj - + Ban user from server Zbanuj użytkownika z serwera - - - - + + + + Error Błąd - + You have to select a name-based, IP-based, clientId based, or some combination of the three to place a ban. Aby ustawić bana, musisz wybrać nazwę, IP, ID klienta lub dowolną kombinację tej trójki. - + You must have a value in the name ban when selecting the name ban checkbox. Pole imienia musi być wypełnione dla opcji ban imienia. - + You must have a value in the ip ban when selecting the ip ban checkbox. Pole ip musi być wypełnione dla opcji ban ip. - + You must have a value in the clientid ban when selecting the clientid ban checkbox. Pole id klienta musi być wypełnione dla opcji ban id klienta. @@ -444,59 +499,123 @@ Informacja ta zachowywana jest dla moderatorów i nie jest widoczna dla zbanowan BetaReleaseChannel - + Beta - + No reply received from the release update server. Brak odpowiedzi z serwera uaktualnień wersji. - + Invalid reply received from the release update server. Nieprawidłowa odpowiedź serwera uaktualnień wersji. - + No reply received from the file update server. Brak odpowiedzi z serwera uaktualnień plików. + + CardArtPreviewWidget + + + No card selected + + + + + CardArtRulesModel + + + Card + + + + + ProviderId + + + + + Mode + + + + + Reason + + + CardDatabaseModel - + Name Nazwa - + Sets Dodatki - + Mana cost Koszt many - + Card type Typ karty - + P/T S/W - + Color(s) Kolor(y) + + CardDatabaseView + + + Add to Deck + + + + + Add to Sideboard + + + + + Select Printing + + + + + Show on EDHRec (Commander) + + + + + Show on EDHRec (Card) + + + + + Show Related cards + + + CardFilter @@ -625,22 +744,22 @@ Informacja ta zachowywana jest dla moderatorów i nie jest widoczna dla zbanowan CardInfoPictureWidget - + View related cards - + Add card to deck - + Mainboard - + Sideboard @@ -663,12 +782,12 @@ Informacja ta zachowywana jest dla moderatorów i nie jest widoczna dla zbanowan - + Related cards: Powiązane karty: - + Unknown card: Nieznana karta: @@ -676,128 +795,146 @@ Informacja ta zachowywana jest dla moderatorów i nie jest widoczna dla zbanowan CardMenu - + Re&veal to... - + &All players - + View related cards - + Token: - + All tokens - + &Select All - + S&elect Row - + S&elect Column - + &Play - + &Hide - + Play &Face Down - + &Tap / Untap Turn sideways or back again - + Skip &untapping - + T&urn Over Turn face up/face down - + &Peek at card face - + &Clone - + Attac&h to card... - + Unattac&h - + &Draw arrow... - + &Set annotation... - + + Reduce life by power + + + + Ca&rd counters - + &Add counter (%1) - + &Remove counter (%1) - + &Set counters (%1)... + + CardPictureLoaderCacheMethod + + + Network Cache + + + + + Filesystem + + + CardSizeWidget @@ -809,133 +946,133 @@ Informacja ta zachowywana jest dla moderatorów i nie jest widoczna dla zbanowan CardZoneLogic - + their hand nominative - + %1's hand nominative - + their library look at zone - + %1's library look at zone - + of their library top cards of zone, - + of %1's library top cards of zone - + their library reveal zone - + %1's library reveal zone - + their library shuffle - + %1's library shuffle - + their library nominative - + %1's library nominative - + their graveyard nominative - + %1's graveyard nominative - + their exile nominative - + %1's exile nominative - + their sideboard look at zone - + %1's sideboard look at zone - + their sideboard nominative - + %1's sideboard nominative - + their custom zone '%1' nominative - + %1's custom zone '%2' nominative @@ -957,16 +1094,421 @@ Informacja ta zachowywana jest dla moderatorów i nie jest widoczna dla zbanowan + + ColorButton + + + Click to pick a color + + + + + Pick colour + + + + + ConnectionController + + + + The server has reached its maximum user capacity, please check back later. + + + + + There are too many concurrent connections from your address. + + + + + Banned by moderator + + + + + Expected end time: %1 + + + + + This ban lasts indefinitely. + + + + + Scheduled server shutdown. + + + + + + Invalid username. + + + + + You have been logged out due to logging in at another location. + + + + + Connection closed + + + + + The server has terminated your connection. +Reason: %1 + + + + + The server is going to be restarted in %n minute(s). +All running games will be lost. +Reason for shutdown: %1 + + + + + Scheduled server shutdown + + + + + Failed Login + + + + + Your client seems to be missing features this server requires for connection. + + + + + To update your client, go to 'Help -> Check for Client Updates'. + + + + + + + + + + + + + + + + + + + + + + Error + + + + + Incorrect username or password. Please check your authentication information and try again. + + + + + There is already an active session using this user name. +Please close that session first and re-login. + + + + + + You are banned until %1. + + + + + + You are banned indefinitely. + + + + + This server requires user registration. Do you want to register now? + + + + + This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. +Please close and reopen your client to try again. + + + + + An internal error has occurred, please close and reopen Cockatrice before trying again. +If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. + + + + + Account activation + + + + + Your account has not been activated yet. +You need to provide the activation token received in the activation email. + + + + + Server Full + + + + + Unknown login error: %1 + + + + + + +This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. + + + + + + + + + + Registration denied + + + + + Registration is currently disabled on this server + + + + + There is already an existing account with the same user name. + + + + + It's mandatory to specify a valid email address when registering. + + + + + It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. + + + + + Password too short. + + + + + Registration failed for a technical problem on the server. + + + + + The connection to the server has been lost. + + + + + Unknown registration error: %1 + + + + + Account activation failed + + + + + Socket error: %1 + + + + + Server timeout + + + + + You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. +Local version is %1, remote version is %2. + + + + + Your Cockatrice client is obsolete. Please update your Cockatrice version. +Local version is %1, remote version is %2. + + + + + + Success + + + + + Registration accepted. +Will now login. + + + + + Account activation accepted. +Will now login. + + + + + Information + + + + + This server supports additional features that your client doesn't have. +This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. + +To update your client, go to Help -> Check for Updates. + + + + + + + Reset Password + + + + + Your password has been reset successfully, you can now log in using the new credentials. + + + + + Failed to reset user account password, please contact the server operator to reset your password. + + + + + Activation request received, please check your email for an activation token. + + + + + Connecting to %1... + + + + + Registering to %1 as %2... + + + + + Disconnected + + + + + Connected, logging in at %1 + + + + + Requesting forgotten password to %1 as %2... + + + + + Your username must respect these rules: + + + + + is %1 - %2 characters long + + + + + can %1 contain lowercase characters + + + + + + + + NOT + + + + + can %1 contain uppercase characters + + + + + can %1 contain numeric characters + + + + + can contain the following punctuation: %1 + + + + + first character can %1 be a punctuation mark + + + + + no unacceptable language as specified by these server rules: + note that the following lines will not be translated + + + + + can not contain any of the following words: %1 + + + + + can not match any of the following expressions: %1 + + + + + You may only use A-Z, a-z, 0-9, _, ., and - in your username. + + + CustomZoneMenu - + C&ustom Zones - - + + View custom zone '%1' @@ -974,30 +1516,35 @@ Informacja ta zachowywana jest dla moderatorów i nie jest widoczna dla zbanowan DeckAnalyticsWidget - + Add Panel - + Remove Panel - + Save Layout - + Load Layout + + + Include Sideboard + + DeckEditorCardDatabaseDockWidget - + Card Database @@ -1013,47 +1560,17 @@ Informacja ta zachowywana jest dla moderatorów i nie jest widoczna dla zbanowan DeckEditorDatabaseDisplayWidget - + Search by card name (or search expressions) - - Add to Deck - - - - - Add to Sideboard - - - - - Select Printing - - - - - Show on EDHRec (Commander) - - - - - Show on EDHRec (Card) - - - - - Show Related cards - - - - + Add card to &maindeck - + Add card to &sideboard @@ -1086,72 +1603,72 @@ Informacja ta zachowywana jest dla moderatorów i nie jest widoczna dla zbanowan - + Select Printing - + Deck - + Deck &name: - + Banner Card/Tags Visibility Settings - + Show banner card selection menu - + Show tags selection menu - + &Comments: - + Group by: - + Format: - + Hash: - + &Increment number - + &Decrement number - + &Remove row - + Swap card to/from sideboard @@ -1159,17 +1676,17 @@ Informacja ta zachowywana jest dla moderatorów i nie jest widoczna dla zbanowan DeckEditorFilterDockWidget - + Filters - + &Clear all filters - + Delete selected @@ -1300,169 +1817,113 @@ Informacja ta zachowywana jest dla moderatorów i nie jest widoczna dla zbanowan DeckEditorSettingsPage - - + + Update Spoilers Uaktualnij Spoilery - - + Success Sukces - + Download URLs have been reset. Pobrane URL zostały zresetowane. - - Downloaded card pictures have been reset. - Pobrane obrazy kart zostały zresetowane - - - - Error - Błąd - - - - One or more downloaded card pictures could not be cleared. - Jedno lub więcej pobranych ilustracji kart nie mogło zostać wyczyszczonych. - - - + Add URL Dodaj URL - - + + URL: URL: - - + + Edit URL Edytuj URL - - Network Cache Size: - Rozmiar sieciowej pamięci podręcznej: - - - - Redirect Cache TTL: - TTL przekierowania w pamięci podręcznej: - - - - How long cached redirects for urls are valid for. - Jak długo zapisane przekierowania są ważne. - - - - Picture Cache Size: - Rozmiar Pamięci Podręcznej dla Obrazków: - - - + Add New URL Dodaj Nowy URL - + Remove URL Usuń URL - - Day(s) - Dni - - - + Updating... Trwa aktualizacja... - + Choose path Wybierz ścieżkę - + URL Download Priority Priorytet Pobierania URL - + Spoilers Spoilery - + Download Spoilers Automatically Pobierz spoilery automatycznie - + Spoiler Location: Lokalizacja spoilera - + Last Change Ostatnia zmiana - + Spoilers download automatically on launch Spoilery są pobierane automatycznie w czasie premiery. - + Press the button to manually update without relaunching Wciśnij przycisk żeby ręcznie uaktualnić bez restartu. - + Do not close settings until manual update is complete Nie zamykaj ustawień dopóki aktualizacja manualna się nie skończy - + Download card pictures on the fly Pobierz ilustracje kart w locie - + How to add a custom URL Jak dodać niestandardowe URL - - Delete Downloaded Images - Usuń Pobrane Ilustracje - - - + Reset Download URLs Resetuj Pobieranie URLi - - - On-disk cache for downloaded pictures - Pamięć podręczna na dysku dla pobranych obrazków - - - - In-memory cache for pictures not currently on screen - Pamięć podręczna w pamięci fizycznej dla obrazków które nie są na ekranie - DeckListHistoryManagerWidget @@ -1528,12 +1989,12 @@ Informacja ta zachowywana jest dla moderatorów i nie jest widoczna dla zbanowan DeckLoader - + Common deck formats (%1) - + All files (*.*) @@ -1755,27 +2216,27 @@ Informacja ta zachowywana jest dla moderatorów i nie jest widoczna dla zbanowan - + Moved to %1 1 × "%2" (%3) - + Removed "%1" (all copies) - + %1 1 × "%2" (%3) - + Added - + Removed @@ -1797,74 +2258,74 @@ Informacja ta zachowywana jest dla moderatorów i nie jest widoczna dla zbanowan DeckViewContainer - + Load deck... Wczytaj talię... - + Load remote deck... Wczytaj zdalną talię... - + Load from clipboard... - + Load from website... - + Unload deck - + Ready to start Gotowe do rozpoczęcia - + Force start Wymuś rozpoczęcie - + Sideboard unlocked Biblioteka poboczna odblokowana - + Sideboard locked Biblioteka poboczna zablokowana - - + + Error Błąd - + The selected file could not be loaded. Wybrany plik nie mógł zostać wczytany. - + Deck is greater than maximum file size. - + Are you sure you want to force start? This will kick all non-ready players from the game. - + Cockatrice @@ -1963,7 +2424,7 @@ This will kick all non-ready players from the game. - + Webpage Strona internetowa @@ -2003,37 +2464,37 @@ This will kick all non-ready players from the game. Połącz z Serwerem - + Server URL Serwer URL - + Communication Port Port Komunikacyjny - + Unique Server Name Unikalna Nazwa Serwera - + Connection Warning Ostrzeżenie o połączeniu - + You need to name your new connection profile. Musisz nazwać swój nowy profil połączenia - + Connect Warning Ostrzeżenie połączenia - + The player name can't be empty. Nazwa użytkownika nie może być pusta @@ -2146,17 +2607,17 @@ This will kick all non-ready players from the game. Stwórz grę - + Game information Informacje o grze - + Error Błąd - + Server error. Błąd serwera. @@ -2164,97 +2625,97 @@ This will kick all non-ready players from the game. DlgCreateToken - + &Name: &Nazwa: - + Token Token - + C&olor: &Kolor: - + white biały - + blue niebieski - + black czarny - + red czerwony - + green zielony - + multicolor wielokolorowy - + colorless bezkolorowy - + &P/T: &S/W: - + &Annotation: &Adnotacja: - + &Destroy token when it leaves the table &Zniszcz tokena kiedy opuści stół - + Create face-down (Only hides name) - + Token data Dane tokena - + Show &all tokens Pok&azuj wszystkie tokeny - + Show tokens from this &deck Pokazuj tokeny z tej &talii - + Choose token from list Wybierz token z listy: - + Create token Tworzenie tokena @@ -2288,7 +2749,7 @@ This will kick all non-ready players from the game. - + @@ -2303,12 +2764,12 @@ This will kick all non-ready players from the game. - + Duplicate Tag - + This tag already exists. @@ -2495,12 +2956,12 @@ By usunąć twojego bieżącego awatara potwierdź bez wybierania nowego obrazka Wprowadź nazwę tokena: - + Error Błąd - + The chosen name conflicts with an existing card or token. Make sure to enable the 'Token' set in the "Manage sets" dialog to display them correctly. Wybrana nazwa koliduje z istniejącą kartą lub tokenem. @@ -2525,12 +2986,12 @@ Upewnij się że aktywowano 'Token' w oknie "Zarządzaj dodatkami Niezdefiniowane - + Real name: Prawdziwe imię: - + Edit user profile Edycja profilu użytkownika @@ -2568,113 +3029,113 @@ Upewnij się że aktywowano 'Token' w oknie "Zarządzaj dodatkami 2 godziny - + Hide 'buddies only' games - + Hide full games - + Hide games that have started - + Hide password protected games - + Hide 'ignored user' games - + Hide games not created by buddies Hide games not created by buddy - + Hide games with forced open decklists - + &Newer than: &Nowsze niż: - + Game &description: &Opis gry - + &Creator name: Założy&ciel gry - + General Generalne - + &Game types Typy roz&grywki - + at &least: co &najmniej - + at &most: co naj&wyżej - + Maximum player count Maksymalna liczba graczy - + Restrictions Ograniczenia - + Show games only if &spectators can watch Pokazuj gry tylko jeśli &widzowie mogą oglądać - + Show spectator password p&rotected games Pokazuj gry wymagające hasła dla widzów - + Show only if spectators can ch&at Pokazuj gry tylko jeśli widzowie mogą rozm&awiać - + Show only if spectators can see &hands Pokazuj gry tylko jeśli widzowie mogą oglądać &ręce - + Spectators Widzowie - + Filter games Filtruj gry @@ -2977,37 +3438,37 @@ https://tappedout.net/mtg-decks/your-deck-name/ DlgMoveTopCardsUntil - + Card name (or search expressions): Nazwa karty (lub wyrażenie wyszukiwania): - + Number of hits: Ilość trafień: - + Auto play hits Automatycznie graj trafienia - + Put top cards on stack until... Kładź karty z wierzchu na stosie dopóki... - + No cards matching the search expression exists in the card database. Proceed anyways? W bazie kart nie znaleziono kart które spełniają twoje wyrażenie. Kontynuować mimo to? - + Cockatrice Cockatrice - + Invalid filter Nieprawidłowy filtr @@ -3067,40 +3528,40 @@ Twój email zostanie użyty aby zweryfikować twoje konto. Nie wiadomo - + Real name: Prawdziwe imię: - + Register to server Zarejestruj się - - - - + + + + Registration Warning Ostrzeżenie rejestracji - + Your password is too short. Twoje hasło jest za krótkie. - + Your passwords do not match, please try again. Wpisane hasła nie są zgodne. Spróbuj ponownie. - + Your email addresses do not match, please try again. Wpisane adresy mailowe nie są zgodne. Spróbuj ponownie. - + The player name can't be empty. Nazwa użytkownika nie może być pusta @@ -3108,17 +3569,17 @@ Twój email zostanie użyty aby zweryfikować twoje konto. DlgRollDice - + Number of sides: Liczba ścian: - + Number of dice: Liczba kości: - + Roll Dice Rzuć kośćmi @@ -3169,12 +3630,12 @@ Twój email zostanie użyty aby zweryfikować twoje konto. DlgSettings - + Unknown Error loading card database W trakcie wczytywania bazy kart wystąpił nieznany błąd - + Your card database is invalid. Cockatrice may not function correctly with an invalid database @@ -3191,7 +3652,7 @@ Może istnieć potrzeba uruchomienia Oracle w celu uaktualnienia bazy kart. Czy chcesz zmienić ustawienia położenia bazy kart? - + Your card database version is too old. This can cause problems loading card information or images @@ -3208,7 +3669,7 @@ Zwykle można temu zaradzić poprzez uruchomienie narzędzia Oracle i uaktualnie Czy chcesz zmienić ustawienia położenia bazy kart? - + Your card database did not finish loading Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues with your cards.xml attached @@ -3221,7 +3682,7 @@ Proszę wypełnić zgłoszenie błędu pod adresem https://github.com/Cockatrice Czy chcesz zmienić ustawienia położenia bazy kart? - + File Error loading your card database. Would you like to change your database location setting? @@ -3230,7 +3691,7 @@ Would you like to change your database location setting? Czy chcesz ustawić nową lokalizację bazy kart? - + Your card database was loaded but contains no cards. Would you like to change your database location setting? @@ -3239,7 +3700,7 @@ Would you like to change your database location setting? Czy chcesz ustawić nową lokalizację bazy kart? - + Unknown card database load status Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues @@ -3252,59 +3713,64 @@ Proszę wypełnić zgłoszenie błędu pod adresem https://github.com/Cockatrice Czy chcesz zmienić ustawienia położenia bazy kart? - - - + + + Error Błąd - + The path to your deck directory is invalid. Would you like to go back and set the correct path? Ścieżka dostępu do twojego katalogu z taliami jest nieprawidłowa. Czy chcesz wrócić i ustawić poprawną ścieżkę? - + The path to your card pictures directory is invalid. Would you like to go back and set the correct path? Ścieżka dostępu do twojego katalogu z obrazkami jest nieprawidłowa. Czy chcesz wrócić i ustawić poprawną ścieżkę? - + Settings Ustawienia - + General Ogólne - + Appearance Wygląd - + User Interface Interfejs - + + Storage + + + + Card Sources Źródło Karty - + Chat Czat - + Sound Dźwięk - + Shortcuts Skróty @@ -3396,9 +3862,9 @@ You can always change this behavior in the 'General' settings tab. - - - + + + Error Błąd @@ -3451,31 +3917,31 @@ Odwiedź stronę pobierania żeby uaktualnić ręcznie. - + Update Available Dostępna aktualizacja - + A new version of Cockatrice is available! Nowa wersja Cockatrice jest dostępna! - + New version Nowa wersja - + Released Wydania - + Changelog Zmiany @@ -3485,50 +3951,50 @@ Odwiedź stronę pobierania żeby uaktualnić ręcznie. Chcesz teraz zaktualizować? - + Unfortunately, the automatic updater failed to find a compatible download. You may have to manually download the new version. - + Please check the <a href="%1">releases page</a> on our Github and download the build for your system. - - - + + + Update Error Błąd aktualizacji - + An error occurred while checking for updates: Wystąpił błąd podczas sprawdzania aktualizacji: - + An error occurred while downloading an update: Wystąpił błąd podczas pobierania aktualizacji: - + Installing... Instaluję... - + Cockatrice is unable to open the installer. Cockatrice nie może otworzyć instalatora. - + Try to update manually by closing Cockatrice and running the installer. Spróbuj zaktualizować ręcznie poprzez zamknięcie Cockatrice i włączenie instalatora. - + Download location Lokalizacja pobierania @@ -3618,67 +4084,67 @@ You may have to manually download the new version. DrawProbabilityWidget - + Draw Probability - + Probability of drawing - + Card Name - + Type - + Subtype - + Mana Value - + At least - + Exactly - + card(s) having drawn at least - + cards - + Category - + Qty - + Odds (%) @@ -3792,7 +4258,7 @@ You may have to manually download the new version. FilterBuilder - + Type your filter here Podaj swój filtr @@ -3823,22 +4289,22 @@ You may have to manually download the new version. GameEventHandler - + kicked by game host or moderator - + player left the game - + player disconnected from server - + reason unknown @@ -3846,140 +4312,140 @@ You may have to manually download the new version. GameSelector - - - - - - - - - + + + + + + + + + Error Błąd - + Please join the appropriate room first. Proszę najpierw dołączyć do właściwego pokoju. - + Wrong password. Nieprawidłowe hasło. - + Spectators are not allowed in this game. Ta rozgrywka nie dopuszcza widzów. - + The game is already full. Ta rozgrywka jest już pełna. - + The game does not exist any more. Ta rozgrywka już nie istnieje. - + This game is only open to registered users. Ta rozgrywka jest dostępna tylko dla zarejestrowanych użytkowników. - + This game is only open to its creator's buddies. Ta rozgrywka jest dostępna tylko dla znajomych jej twórcy. - + You are being ignored by the creator of this game. Twórca tej rozgrywki cię ignoruje. - + Join Game - + Spectate Game - + Game Information - + Join Game as Judge - + Spectate Game as Judge - + Join game Dołącz do gry - + Password: Hasło: - + Please join the respective room first. Proszę najpierw dołączyć do odpowiedniego pokoju. - + &Filter games &Filtr rozgrywek - + C&lear filter Usuń fi&ltr - + C&reate Utwó&rz - + &Join &Dołącz - + Join as judge - + J&oin as spectator D&ołącz jako widz - + Join as judge spectator - + Games shown: %1 / %2 Gra pokazana: %1 / %2 - + Games Gry @@ -3987,32 +4453,32 @@ You may have to manually download the new version. GameSelectorQuickFilterToolBar - + All types - + Filter by game name... - + Filter by game type/format - + Hide games not created by buddies - + Hide full games - + Hide started games @@ -4020,105 +4486,105 @@ You may have to manually download the new version. GamesModel - + >1 day >1 dzień - + %1%2 hr short age in hours %1%2 godz%1%2 godz%1%2 godz%1%2 godz - + new Nowa - + %1%2 min short age in minutes %1%2 min%1%2 min%1%2 min%1%2 min - + password hasło - + buddies only tylko znajomi - + reg. users only tylko zarejestrowani użytkownicy - + open decklists - + can chat mogą rozmawiać - + see hands widzą ręce - + can see hands widzą ręce - + not allowed niedozwoleni - + Room Pokój - + Age Wiek - + Description Opis - + Creator Twórca - + Type Typ - + Restrictions Ograniczenia - + Players Gracze - + Spectators Widzowie @@ -4126,143 +4592,158 @@ You may have to manually download the new version. GeneralSettingsPage - + Reset all paths Resetuj wszystkie ścieżki - + All paths have been reset Wszystkie ścieżki zostały zresetowane - - - - - - - + + + + + + + Choose path Wybierz ścieżkę - - Personal settings - Ustawienia osobiste + + Language settings + - + Language: Język: - + + Version settings + + + + + Card database + + + + + Startup settings + + + + Paths (editing disabled in portable mode) Scieżki (edytowanie wyłączone w trybie przenośnym) - + Paths Ścieżki - + How to help with translations - + Decks directory: Katalog z taliami: - + Filters directory: - + Replays directory: Katalog z powtórkami: - + Pictures directory: Katalog z obrazkami: - + Card database: Baza kart: - + Custom database directory: Niestandardowy folder z bazą: - + Token database: Baza tokenów: - + Update channel Aktualizuj kanał - + Check for client updates on startup Sprawdzaj aktualizacje klienta przy uruchomieniu - + Check for card database updates on startup - + Don't check - + Prompt for update - + Always update in the background - + Check for card database updates every - + days - + Notify if a feature supported by the server is missing in my client Powiadom jeśli właściwości wspierane przez serwer, brakują w kliencie - + Automatically run Oracle when running a new version of Cockatrice Automatycznie uruchom Oracle kiedy uruchomiona jest nowa wersja Cockatrice - + Show tips on startup Pokazuj wskazówki po uruchomieniu - + Last update check on %1 (%2 days ago) @@ -4270,47 +4751,47 @@ You may have to manually download the new version. GraveyardMenu - + &Graveyard - + &View graveyard - + &Move graveyard to... - + &Top of library - + &Bottom of library - + &All players - + &Hand - + &Exile - + Reveal random card to... @@ -4318,88 +4799,88 @@ You may have to manually download the new version. HandMenu - + &Hand - + &View hand - + Sort hand by... - + Name - + Type - + Mana Value - + Take &mulligan (Choose hand size) - + Take mulligan (Same hand size) - + Take mulligan (Hand size - 1) - + &Move hand to... - + &Top of library - + &Bottom of library - + &Graveyard - + &Exile - + &Reveal hand to... - - + + All players - + Reveal r&andom card to... @@ -4407,52 +4888,52 @@ You may have to manually download the new version. HomeWidget - + Create New Deck - + Browse Decks - + Browse Card Database - + Browse EDHRec - + Browse Archidekt - + View Replays - + Quit - + Connecting... - + Connect - + Play @@ -4460,213 +4941,213 @@ You may have to manually download the new version. LibraryMenu - + &Library - + &View library - + View &top cards of library... - + View bottom cards of library... - + Reveal &library to... - + Lend library to... - + Reveal &top cards to... - + &Top of library... - + &Bottom of library... - + &Always reveal top card - + &Always look at top card - + &Open deck in deck editor - + &Draw card - + D&raw cards... - + &Undo last draw - + Shuffle - + &Play top card - + Play top card &face down - + Put top card on &bottom - + Move top card to grave&yard - + Move top card to e&xile - + Move top cards to &graveyard... - + Move top cards to graveyard face down... - + Move top cards to &exile... - + Move top cards to exile face down... - + Put top cards on stack &until... - + Shuffle top cards... - + &Draw bottom card - + D&raw bottom cards... - + &Play bottom card - + Play bottom card &face down - + Move bottom card to grave&yard - + Move bottom card to e&xile - + Move bottom cards to &graveyard... - + Move bottom cards to graveyard face down... - + Move bottom cards to &exile... - + Move bottom cards to exile face down... - + Put bottom card on &top - + Shuffle bottom cards... - - + + &All players - + Reveal top cards of library - + Number of cards: (max. %1) @@ -4674,642 +5155,273 @@ You may have to manually download the new version. MainWindow - - - The server has reached its maximum user capacity, please check back later. - Ten serwer osiągnął maksymalną liczbę użytkowników, proszę sprawdzić później. - - - - There are too many concurrent connections from your address. - Trwa zbyt wiele równoległych połączeń z twojego adresu. - - - - Banned by moderator - Zbanowany przez moderatora. - - - - Expected end time: %1 - Oczekiwany czas zakończenia: %1 - - - - This ban lasts indefinitely. - Ten ban jest bezterminowy. - - - - Scheduled server shutdown. - Planowe wyłączenie serwera. - - - - - Invalid username. - Nieprawidłowa nazwa użytkownika. - - - - You have been logged out due to logging in at another location. - Zostałeś wylogowany ze względu inny proces logowania. - - - - Connection closed - Połączenie zakończone - - - - The server has terminated your connection. -Reason: %1 - Serwer zakończył twoje połączenie. -Przyczyna: %1 - - - - The server is going to be restarted in %n minute(s). -All running games will be lost. -Reason for shutdown: %1 - Serwer zostanie wyłączony za jedną minutę. -Wszystkie trwające gry zostaną utracone. -Przyczyna zamknięcia: %1Serwer zostanie wyłączony za %n minut. -Wszystkie trwające gry zostaną utracone. -Przyczyna zamknięcia: %1Serwer zostanie wyłączony za minut: %n. -Wszystkie trwające gry zostaną utracone. -Przyczyna zamknięcia: %1Serwer zostanie wyłączony za minut: %n. -Wszystkie trwające gry zostaną utracone. -Przyczyna zamknięcia: %1 - - - - Scheduled server shutdown - Planowe wyłączenie serwera - - - - - Success - Operacja zakończona pomyślnie - - - - Registration accepted. -Will now login. - Rejestracja zaakceptowana. -Rozpocznij logowanie. - - - - Account activation accepted. -Will now login. - Aktywacja konta zakończona pomyślnie. -Rozpocznij logowanie. - - - - + + Player %1 Gracz %1 - + Load replay Wczytaj powtórkę - + About Cockatrice O Cockatrice - + Version Wersja - + Cockatrice Webpage Oficjalna strona Cocatrice - + Project Manager: Menadżer projektu: - + Past Project Managers: Poprzedni menadżerowie projektu: - + Developers: Twórcy: - + Our Developers Lista współpracowników - + Help Develop! Dołącz do grona twórców! - + Translators: Tłumacze: - + Our Translators Nasi Tłumacze: - + Help Translate! Pomóż w tłumaczeniu! - + Support: Wsparcie: - + Report an Issue Zgłoś problem - + Troubleshooting Rozwiązywanie problemów - + F.A.Q. Najczęściej zadawane pytania - - - - - - - - - - - - - - - - - - - - + + Error Błąd - - Server timeout - Upłynął limit czasu odpowiedzi serwera - - - - Failed Login - Nie udało się zalogować - - - - Your client seems to be missing features this server requires for connection. - Twój klient prawdopodobnie nie spełnia wymagań połączenia z tym serwerem. - - - - To update your client, go to 'Help -> Check for Client Updates'. - Aby zaktualizować twojego klienta, pójdź do 'Pomoc -> Sprawdź ustawienia klienta'. - - - - Incorrect username or password. Please check your authentication information and try again. - Nieprawidłowa nazwa użytkownika lub hasło. Sprawdź swoje dane weryfikacyjne i spróbuj ponownie. - - - - There is already an active session using this user name. -Please close that session first and re-login. - Istnieje już aktywna sesja dla tej nazwy użytkownika. -Zakończ tamtą sesję i zaloguj się ponownie. - - - - - You are banned until %1. - Twój ban trwa do: %1. - - - - - You are banned indefinitely. - Zostałeś zbanowany bezterminowo. - - - - This server requires user registration. Do you want to register now? - Ten serwer wymaga rejestracji użytkownika. Chcesz się teraz zarejestrować? - - - - This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. -Please close and reopen your client to try again. - Ten serwer potrzebuje ID klienta. Twój klient nie może wygenerować ID lub używasz zmodyfikowanego klienta. -Proszę zamknij i otwórz ponownie swojego klienta aby spróbować ponownie. - - - - An internal error has occurred, please close and reopen Cockatrice before trying again. -If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. - Doszło do wewnętrznego błędu, proszę zamknąć i ponownie otworzyć Cockatrice przed ponowną próbą. -Jeśli błąd będzie się utrzymywać, upewnij się, że używasz najnowszej wersji programu oraz jeśli potrzebne skontaktuj się z deweloperami programu. - - - - Account activation - Aktywacja konta - - - - Your account has not been activated yet. -You need to provide the activation token received in the activation email. - Twoje konto nie zostało jeszcze aktywowane. -Musisz podać token aktywujący, otrzymany w wiadomości email. - - - - Server Full - Serwer pełny - - - - Unknown login error: %1 - Nieznany błąd logowania: %1 - - - - - -This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. - To zwykle oznacza że wersja klienta jest nieaktualna i serwer wysłał odpowiedź, której twój klient nie rozpoznaje. - - - - Your username must respect these rules: - Twoja nazwa użytkownika musi być zgodna z poniższymi regułami: - - - - is %1 - %2 characters long - %1 jest o %2 znaków za długi - - - - can %1 contain lowercase characters - może zawierać 1% małych liter - - - - - - - NOT - NIE - - - - can %1 contain uppercase characters - może zawierać %1 dużych liter - - - - can %1 contain numeric characters - może zawierać %1 cyfr - - - - can contain the following punctuation: %1 - Może zawierać następujące interpunkcje: %1 - - - - first character can %1 be a punctuation mark - Pierwszy znak może zawierać %1 - - - - no unacceptable language as specified by these server rules: - note that the following lines will not be translated - zasady serwera nie pozwalają na takie słownictwo: - - - - can not contain any of the following words: %1 - Nie można używać żadnego z podanych wyrazów: %1 - - - - can not match any of the following expressions: %1 - Nie pasuje to żadnego z podanych wyrażeń: %1 - - - - You may only use A-Z, a-z, 0-9, _, ., and - in your username. - Możesz użyć tylko tych znaków: A-Z, a-z, 0-9, _, ., i - w nazwie użytkownika. - - - - - - - - - Registration denied - Rejestracja nieudana - - - - Registration is currently disabled on this server - Rejestracja jest obecnie wyłączona na tym serwerze. - - - - There is already an existing account with the same user name. - Istnieje już konto o tej nazwie. - - - - It's mandatory to specify a valid email address when registering. - Podanie poprawnego adresu email jest wymagane przy rejestracji. - - - - It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. - Zdaje się że próbujesz zarejestrować nowe konto na tym serwerze, posiadając już zarejestrowane konto dla tego adresu. Ten serwer ogranicza liczbę kont, które można zarejestrować dla pojedynczego adresu. Skontaktuj się z operatorem serwera w celu uzyskania pomocy lub otrzymania twoich poufnych danych. - - - - Password too short. - Hasło jest zbyt krótkie. - - - - Registration failed for a technical problem on the server. - Rejestracja nie powiodła się z powodów technicznych na serwerze. - - - - The connection to the server has been lost. - - - - - Unknown registration error: %1 - Nieznany błąd rejestracji: %1 - - - - Account activation failed - Aktywacja konta zakończona niepowodzeniem - - - - Socket error: %1 - Błąd gniazda: %1 - - - - You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. -Local version is %1, remote version is %2. - Próbujesz połączyć się z nieaktualnym serwerem. Zainstaluj starszą wersję Cockatrice lub wybierz odpowiedni serwer. -Lokalna wersja to %1, wersja zdalna to %2. - - - - Your Cockatrice client is obsolete. Please update your Cockatrice version. -Local version is %1, remote version is %2. - Twój klient Cockatrice jest nieaktualny. Uaktualnij Cockatrice do nowszej wersji. -Lokalna wersja to %1, wersja zdalna to %2. - - - - Connecting to %1... - %1 — łączenie… - - - - Registering to %1 as %2... - Rejestrowanie do %1 jako %2... - - - - Disconnected - Rozłączony - - - - Connected, logging in at %1 - Połączenie ustanowione; logowanie jako %1 - - - - - - Requesting forgotten password to %1 as %2... - Proszenie o zapomniane hasło do %1 jako %2... - - - + &Connect... &Połącz… - + &Disconnect &Rozłącz - + Start &local game... Rozpocznij &lokalną grę… - + &Watch replay... &Obejrzyj powtórkę… - + &Full screen Pełny ekra&n - + &Register to server... &Zarejestruj się... - + &Restore password... &Przywróć hasło... - + &Settings... &Ustawienia… - + &Exit &Wyjdź - + A&ctions Ak&cje - + &Cockatrice &Cockatrice - + C&ard Database B&aza kart - + &Manage sets... &Zarządzaj dodatkami... - + Edit custom &tokens... Edytuj niestandardowe %tokens... - + Open custom image folder Otwórz folder ze spersonalizowanymi obrazkami - + Open custom sets folder Otwórz folder ze spersonalizowanymi setami - + Add custom sets/cards Dodaj spersonalizowane sety/karty - + Reload card database Przeładuj bazę kart - + Tabs - + &Help Po&moc - + &About Cockatrice &O Cockatrice - + &Tip of the Day &Wskazówka na Dziś - + Check for Client Updates Sprawdź uaktualnienia klienta - + Check for Card Updates... Uaktualnij bazę kart... - + Check for Card Updates (Automatic) - + Show Status Bar - + View &Debug Log Pokaż log &debugowania - + Open Settings Folder - + Show/Hide Pokaż/Ukryj - + New Version Nowa Wersja - + Congratulations on updating to Cockatrice %1! Oracle will now launch to update your card database. Gratulacje z powodu aktualizacji do Cockatrice %1! Oracle uruchomi się teraz aby zaktualizować bazę danych kart. - + Cockatrice installed Cockatrice zainstalowany - + Congratulations on installing Cockatrice %1! Oracle will now launch to install the initial card database. Gratulacje z powodu instalacji Cockatrice %1! Oracle uruchomi się teraz aby zainstalować początkowe bazy danych kart. - + Card database Baza kart - + Cockatrice is unable to load the card database. Do you want to update your card database now? If unsure or first time user, choose "Yes" @@ -5318,29 +5430,29 @@ Czy chcesz teraz uaktualnić bazę kart? Jeżeli nie masz pewności lub jesteś nowym użytkownikiem, wybierz "Tak" - - + + Yes Tak - - + + No Nie - + Open settings Otwórz ustawienia - + New sets found Znaleziono nowe dodatki - + %n new set(s) found in the card database Set code(s): %1 Do you want to enable it/them? @@ -5355,17 +5467,22 @@ Kody dodatków: %1 Czy chcesz je teraz uaktywnić? - + + Yes, always enable + + + + View sets Pokaż dodatki - + Welcome Witaj - + Hi! It seems like you're running this version of Cockatrice for the first time. All the sets in the card database have been enabled. Read more about changing the set order or disabling specific sets and consequent effects in the "Manage Sets" dialog. @@ -5374,166 +5491,132 @@ Wszystkie dodatki w bazie kart zostały aktywowane. Przeczytaj więcej na temat zmiany kolejności czy wyłączania poszczególnych dodatków i tym podobnych w oknie "Zarządzaj Dodatkami". - - + Information Informacja - + A card database update is already running. Aktualizacja bazy kart jest w trakcie - + Unable to run the card database updater: Nie można uruchomić aktualizacji bazy kart: - + Card database update running. - + Failed to start. The file might be missing, or permissions might be incorrect. - + The process crashed some time after starting successfully. - + Timed out. The process took too long to respond. The last waitFor...() function timed out. - + An error occurred when attempting to write to the process. For example, the process may not be running, or it may have closed its input channel. - + An error occurred when attempting to read from the process. For example, the process may not be running. - + Unknown error occurred. - + The card database updater exited with an error: %1 - - This server supports additional features that your client doesn't have. -This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. - -To update your client, go to Help -> Check for Updates. - Ten serwer wspiera dodatkowe funkcje, których nie posiada twój klient. -Prawdopodobnie nie jest to problem, ale ta wiadomośc może oznaczać że jest dostępna nowa wersja Cockatrice, lub że na tym serwerze działa zmodyfikowana albo nieopublikowana wersja. - -Aby aktualizować klienta, otwórz okno Pomoc -> Sprawdź Aktualizacje - - - - - - - + + + + + Load sets/cards Wczytaj dodatki/karty - + Selected file cannot be found. Wybrany plik nie został znaleziony. - + You can only import XML databases at this time. Możesz zaimportować jedynie bazy danych XML w tym momencie. - + The new sets/cards have been added successfully. Cockatrice will now reload the card database. Nowe dodatki/karty dodane pomyślnie. Cockatrice ponownie załaduje teraz bazę danych. - + Sets/cards failed to import. Dodatki/karty nie mogły być zaimportowane. - - - - - Reset Password - Zresetuj hasło - - - - Your password has been reset successfully, you can now log in using the new credentials. - Twoje hasło zostały pomyślnie zresetowane, możesz teraz zalogować się używając nowego uwierzytelniania. - - - - Failed to reset user account password, please contact the server operator to reset your password. - Nie udało się zresetować hasła użytkownika, skontaktuj się z operatorem serwera w celu wykonania resetu. - - - - Activation request received, please check your email for an activation token. - Otrzymano żądanie aktywacji, odbierz token aktywujący w swojej skrzynce email. - ManaBaseConfigDialog - + Mana Base Configuration - + Display type: - + pie - + bar - + combinedBar - + Filter Colors (optional): - + OK - + Cancel @@ -5605,27 +5688,27 @@ Cockatrice ponownie załaduje teraz bazę danych. ManaDevotionConfigDialog - + Display type: - + pie - + bar - + combinedBar - + Filter Colors (optional): @@ -5695,610 +5778,615 @@ Cockatrice ponownie załaduje teraz bazę danych. MessageLogWidget - + from play z gry - + from their graveyard z jego grobu - + from exile ze strefy wygnania - + from their hand z jego ręki - + the top card of %1's library karta z wierzchu biblioteki gracza %1 - + the top card of their library pierwsza karta jego biblioteki - + from the top of %1's library z wierzchu biblioteki gracza %1 - + from the top of their library z wierzchu jego biblioteki - + the bottom card of %1's library karta z wierzchu biblioteki gracza %1 - + the bottom card of their library ostatnia karta jego biblioteki - + from the bottom of %1's library z dna biblioteki gracza %1 - + from the bottom of their library z dna jego biblioteki - + from %1's library z biblioteki gracza %1 - + from their library z jego biblioteki - + from sideboard z talii pobocznej - + from the stack ze stosu - + from custom zone '%1' - + %1 is now keeping the top card %2 revealed. %1 od teraz gra z odwróconą kartą z wierzchu %2. - + %1 is not revealing the top card %2 any longer. %1 już nie gra z odwróconą kartą z wierzchu %2. - + %1 can now look at top card %2 at any time. %1 może teraz patrzeć na kartę z wierzchu %2 w każdej chwili. - + %1 no longer can look at top card %2 at any time. %1 nie może już patrzeć na kartę z wierzchu %2 w każdej chwili. - + %1 attaches %2 to %3's %4. %1 przyłącza %2 do %4 %3 - + %1 has conceded the game. %1 poddał grę. - + %1 has unconceded the game. %1 nie poddał gry. - + %1 has restored connection to the game. %1 odzyskał połączenie z grą. - + %1 has lost connection to the game. %1 utracił połączenie z grą. - + %1 points from their %2 to themselves. %1 wskazuje z jego karty %2 na samego siebie. - + %1 points from their %2 to %3. %1 wskazuje z jego karty %2 na kartę %3. - + %1 points from %2's %3 to themselves. %1 wskazuje z karty gracza %2 %3 na samą siebie. - + %1 points from %2's %3 to %4. %1 wskazuje z karty gracza %2 %3 na kartę %4. - + %1 points from their %2 to their %3. %1 wskazuje z jego karty %2 na jego kartę %3. - + %1 points from their %2 to %3's %4. %1 wskazuje z jego karty %2 na karty %3's %4. - + %1 points from %2's %3 to their own %4. %1 wskazuje z kart %2's %3 na swoją kartę %4. - + %1 points from %2's %3 to %4's %5. %1 wskazuje z karty gracza %2 %3 na kartę gracza %4 (%5). - + %1 creates a face down token. - + %1 creates token: %2%3. %1 stworzył token: %2%3. - + %1 has loaded a deck (%2). Gracz %1 wczytał talię (%2). - + %1 has loaded a deck with %2 sideboard cards (%3). Gracz %1 wczytał talię oraz %2 kart talii pobocznej (hash: %3). - + %1 destroys %2. %1 niszczy %2. - + a card kartę - + %1 gives %2 control over %3. %1 oddaje %3 pod kontrolę gracza %2. - + %1 puts %2 into play%3 face down. %1 zagrywa kartę %2 na pole bitwy%3 koszulką do góry. - + %1 puts %2 into play%3. %1 zagrywa kartę %2 na pole bitwy %3. - + %1 puts %2%3 into their graveyard face down. - + %1 puts %2%3 into their graveyard. %1 kładzie %2 %3 na cmentarz. - + %1 exiles %2%3 face down. - + %1 exiles %2%3. %1 przenosi kartę %2 do strefy wygnania %3. - + %1 moves %2%3 to their hand. %1 bierze %2 %3 do ręki. - + %1 puts %2%3 into their library. %1 wkłada %2 %3 do biblioteki. - + %1 puts %2%3 onto the bottom of their library. %1 przenosi %2 %3 na dno swojej biblioteki. - + %1 puts %2%3 on top of their library. %1 przenosi %2 %3 na wierzch swojej bibliotek - + %1 puts %2%3 into their library %4 cards from the top. %1 kładzie %2 %3 na jego biblioteke %4 kart z góry. - + %1 moves %2%3 to sideboard. %1 przenosi kartę %2 %3 do talii pobocznej. - + %1 plays %2%3 face down. - + %1 plays %2%3. %1 zagrywa kartę %2 %3. - + %1 moves %2%3 to custom zone '%4' face down. - + %1 moves %2%3 to custom zone '%4'. - + %1 tries to draw from an empty library %1 próbuje dobrać z pustej biblioteki - + %1 draws %2 card(s). %1 dobiera %2 kartę.%1 dobiera %2 kart.%1 dobiera %2 kart.%1 dobiera %2 kart. - + %1 is looking at %2. %1 przegląda %2. - + %1 is looking at the %4 %3 card(s) %2. top card for singular, top %3 cards for plural - + bottom - + top - + %1 turns %2 face-down. %1 kładzie %2 grzbietem do góry. - + %1 turns %2 face-up. %1 kładzie %2 grzbietem do dołu. - + The game has been closed. Gra została zakończona. - + The game has started. Gra została rozpoczęta. - + You are flooding the game. Please wait a couple of seconds. - + %1 has joined the game. %1 dołączył do gry. - + %1 is now watching the game. %1 obserwuje grę. - + You have been kicked out of the game. Wyrzucono cię z gry. - + %1 has left the game (%2). %1 opuścił grę (%2). - + %1 is not watching the game any more (%2). %1 przestał oglądać grę (%2). - + %1 is not ready to start the game any more. %1 nie jest już gotowy do rozpoczęcia gry. - + %1 shuffles their deck and draws a new hand of %2 card(s). %1 tasuje swoją talie i dobiera nową rękę z %2 kartą.%1 tasuje swoją talie i dobiera nową rękę z %2 kartami.%1 tasuje swoją talie i dobiera nową rękę z %2 kartami.%1 tasuje swoją talie i dobiera nową rękę z %2 kartami. - + %1 shuffles their deck and draws a new hand. %1 tasuje swoją talie i dobiera nową rękę. - + You are watching a replay of game #%1. Oglądasz powtórkę gry #%1. - + %1 is ready to start the game. %1 jest gotowy do rozpoczęcia gry. - + cards an unknown amount of cards karty - + %1 card(s) a card for singular, %1 cards for plural jedną kartę%1 kart%1 kart%1 kart - + %1 lends %2 to %3. %1 pożycza %2 graczowi %3. - + %1 reveals %2 to %3. %1 pokazuję kartę %2 graczowi %3. - + %1 reveals %2. %1 pokazuję kartę %2. - + %1 randomly reveals %2%3 to %4. %1 pokazuje losowo kartę %2 %3 graczowi %4. - + %1 randomly reveals %2%3. %1 pokazuje losowo kartę %2 %3. - + %1 peeks at face down card #%2. %1 spogląda na kartę odwróconą koszulką do góry #%2. - + %1 peeks at face down card #%2: %3. %1 spogląda na kartę odwróconą koszulką do góry #%2: %3. - + %1 reveals %2%3 to %4. %1 pokazuje kartę %2 %3 graczowi %4. - + %1 reveals %2%3. %1 pokazuje kartę %2 %3. - + %1 reversed turn order, now it's %2. %1 odwrócona kolejność tur, teraz %2 - + reversed odwrócone - + normal normalne - + Heads Orzeł - + Tails Reszka - + %1 flipped a coin. It landed as %2. %1 podrzucił monetą. Wylądowała na %2. - + %1 rolls a %2 with a %3-sided die. %1 rzuca %2 kostką %3-ścienną. - + %1 flips %2 coins. There are %3 heads and %4 tails. %1 rzuca %2 monetami. Wypadło %3 orłów i %4 reszek. - + %1 rolls a %2-sided dice %3 times: %4. %1 rzuca %2-ścienną kostką %3 razy: %4. - + %1's turn. Tura gracza %1 - + %1 sets annotation of %2 to %3. %1 ustala adnotację karty %2 na %3. - - %1 places %2 "%3" counter(s) on %4 (now %5). + + %1 places %2 %3%4 counter(s) on %5 (now %6). - - %1 removes %2 "%3" counter(s) from %4 (now %5). + + %1 removes %2 %3%4 counter(s) from %5 (now %6). - + + %1 failed to undo their last draw. + + + + %1 sets counter %2 to %3 (%4%5). %1 ustawia licznik %2 na %3 (%4%5). - + %1 sets %2 to not untap normally. %1 blokuje karcie %2 normalne odtapowywanie. - + %1 sets %2 to untap normally. %1 umożliwia karcie %2 normalne odtapowywanie. - + %1 removes the PT of %2. %1 usuwa PT %2. - + %1 changes the PT of %2 from nothing to %4. %1 zmienia PT %2 z niczego na %4 - + %1 changes the PT of %2 from %3 to %4. %1 zmienia PT %2 z %3 na %4. - + %1 has locked their sideboard. %1 zablokował jego bibliotekę poboczną - + %1 has unlocked their sideboard. %1 odblokował jego bibliotekę poboczną - + %1 taps their permanents. %1 tapuje swoje permanenty. - + %1 untaps their permanents. %1 odtapowuje swoje permanenty. - + %1 taps %2. %1 tapuje kartę %2. - + %1 untaps %2. %1 odtapowuje kartę %2. - + %1 shuffles %2. %1 tasuje %2. - + %1 shuffles the bottom %3 cards of %2. %1 tasuje %3 dolne karty z %2. - + %1 shuffles the top %3 cards of %2. %1 tasuje %3 górne karty z %2. - + %1 shuffles cards %3 - %4 of %2. %1 tasuje %3 - %4 z %2 kart. - + %1 unattaches %2. %1 odczepia kartę %2. - + %1 undoes their last draw. %1 cofa ostatnią dobraną kartę - + %1 undoes their last draw (%2). %1 cofa ostatnią dobraną kartę (%2) @@ -6306,110 +6394,115 @@ Cockatrice ponownie załaduje teraz bazę danych. MessagesSettingsPage - + Word1 Word2 Word3 Wyraz1 Wyraz2 Wyraz3 - + Add New Message Dodaj Nową Wiadomość - + Edit Message Edytuj Wiadomość - + Remove Message Usuń Wiadomość - + Add message Dodaj wiadomość - - + + Message: Wiadomość: - + Edit message Edytuj wiadomość - + Chat settings Ustawienia czatu - + Custom alert words Niestandardowy tekst alertu. - + Enable chat mentions Włącz wywołania na czacie - + Enable mention completer Włącz autouzupełnianie wywołania - + In-game message macros Makra wiadomości w trakcie gry - + How to use in-game message macros Jak korzystać z makr wiadomości w trakcie gry - + Ignore chat room messages sent by unregistered users Ignoruj wiadomości na czacie od niezarejestrowanych użytkowników - + Ignore private messages sent by unregistered users Ignoruj prywatne wiadomości od niezarejestrowanych użytkowników - - + + Ignore private messages sent by non-buddy users + + + + + Invert text color Odwróć kolor tekstu - + Enable desktop notifications for private messages Włącz desktopowe notyfikacje dla prywatnych wiadomości - + Enable desktop notification for mentions Włącz desktopowe notyfikacje dla wywołań - + Enable room message history on join Włącz historię wiadomości w pokoju po dołączeniu - - + + (Color is hexadecimal) (Kolor w kodzie heksadecymalnym) - + Separate words with a space, alphanumeric characters only Rozdziel słowa spacją, tylko alfanumeryczne znaki @@ -6417,42 +6510,42 @@ Cockatrice ponownie załaduje teraz bazę danych. MoveMenu - + Move to - + &Top of library in random order - + X cards from the top of library... - + &Bottom of library in random order - + T&able - + &Hand - + &Graveyard - + &Exile @@ -6465,47 +6558,47 @@ Cockatrice ponownie załaduje teraz bazę danych. Typ Karty - + Mana Value Wartość Many - + Color(s) Kolor(y) - + Loyalty Lojalność - + Main Card Type Główny Typ Karty - + Mana Cost Koszt Many - + P/T S/W - + Side Strona - + Layout Ułożenie - + Color Identity Tożsamość Koloru @@ -6528,6 +6621,152 @@ Cockatrice ponownie załaduje teraz bazę danych. Kierunek przykrycia: + + PaletteEditorDialog + + + + Reset + + + + + + Apply + + + + + + Save && Apply + + + + + ▼ Edit Palette + + + + + + ▶ Edit Palette + + + + + Palette Editor — %1 + + + + + <b>Palette Editor</b> &nbsp;·&nbsp; %1 + + + + + This theme ships no default palette files + + + + + Replace current colours with the theme author's defaults + + + + + Switch between the light and dark palette files + + + + + Editing: + + + + + Show or hide the per-role colour grid for manual tweaks + + + + + ↺ Revert to theme default + + + + + Discard unsaved edits and restore the last saved palette + + + + + Preview this palette without saving to disk + + + + + Write palette-%1.toml and reload the theme + + + + + Cannot save: this theme has no directory on disk + + + + + Save failed + + + + + Could not write %1 to: +%2 + + + + + No default found + + + + + No default palette file found for the "%1" scheme. + + + + + PaletteGridWidget + + + Active + + + + + Disabled + + + + + Inactive + + + + + Normal interactive state + + + + + Widget is disabled / not interactive + + + + + Window is in background / unfocused + + + Phase @@ -6594,57 +6833,57 @@ Cockatrice ponownie załaduje teraz bazę danych. PhasesToolbar - + Untap step Krok odtapowania - + Upkeep step Krok utrzymania - + Draw step Krok dobierania - + First main phase Pierwsza faza główna - + Beginning of combat step Krok początku walki - + Declare attackers step Krok deklaracji atakujących - + Declare blockers step Krok deklaracji broniących - + Combat damage step Krok obrażeń bitewnych - + End of combat step Krok zakończenia walki - + Second main phase Druga faza główna - + End of turn step Krok końca tury @@ -6652,7 +6891,7 @@ Cockatrice ponownie załaduje teraz bazę danych. PictureLoader - + en code for scryfall's language property, not available for all languages @@ -6661,151 +6900,142 @@ Cockatrice ponownie załaduje teraz bazę danych. PlayerActions - - View top cards of library - - - - - - - - - - - - - Number of cards: (max. %1) - - - - - View bottom cards of library - - - - - Shuffle top cards of library - - - - - Shuffle bottom cards of library - - - - - Draw hand - - - - - 0 and lower are in comparison to current hand size - - - - - Draw cards - - - - - - - + + + + grave - - - - + + + + exile + + + PlayerDialogs - + + View top cards of library + + + + + + + + + + + + + Number of cards: (max. %1) + + + + + View bottom cards of library + + + + + Shuffle top cards of library + + + + + Shuffle bottom cards of library + + + + + Draw hand + + + + + 0 and lower are in comparison to current hand size + + + + + Draw cards + + + + Move top cards to %1 - + Move bottom cards to %1 - + Draw bottom cards - - - C&reate another %1 token - - - - + Create tokens - - + Number: - + Place card X cards from top of library - + Which position should this card be placed: - + (max. %1) - + Change power/toughness - + Change stats to: - + Set annotation - + Please enter the new annotation: - - - Set counters - - PlayerMenu - + Player "%1" - + &Counters @@ -6835,7 +7065,7 @@ This setting means you'll only see the default printing for each card, instead o - + Printing Selector @@ -6843,22 +7073,22 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelectorCardOverlayWidget - + Preference - + Pin Printing Przypnij wersję - + Unpin Printing Odepnij wersję - + Show Related cards Pokaż powiązane karty @@ -6929,57 +7159,57 @@ This setting means you'll only see the default printing for each card, instead o PtMenu - + Power / toughness - + &Increase power - + &Decrease power - + I&ncrease toughness - + D&ecrease toughness - + In&crease power and toughness - + Dec&rease power and toughness - + Increase power and decrease toughness - + Decrease power and increase toughness - + Set &power and toughness... - + Reset p&ower and toughness @@ -6987,37 +7217,37 @@ This setting means you'll only see the default printing for each card, instead o QMenuBar - + Services Usługi - + Hide %1 Ukryj %1 - + Hide Others Ukryj pozostałe - + Show All Pokaż wszystkie - + Preferences... Preferencje… - + Quit %1 Zakończ %1 - + About %1 O programie %1 @@ -7025,17 +7255,17 @@ This setting means you'll only see the default printing for each card, instead o QObject - + Cockatrice card database (*.xml) Baza kart Cockatrice (*.xml) - + All files (*.*) Wszystkie pliki (*.*) - + Cockatrice replays (*.cor) Powtórki Cockatrice (*.cor) @@ -7095,110 +7325,177 @@ Are you sure you would like to disable this feature? QPlatformTheme - + OK OK - + Save Zapisz - + Save All Zapisz Wszystko - + Open Otwórz - + &Yes Tak - + Yes to &All Tak - wszystko - + &No Nie - + N&o to All Nie - wszystko - + Abort Przerwij - + Retry Powtórz - + Ignore Ignoruj - + Close Zamknij - + Cancel Anuluj - + Discard Odrzuć - + Help Pomoc - + Apply Aplikuj - + Reset Reset - + Restore Defaults Przywróć domyślne + + QuickSetupPanel + + + %1% + + + + + <b>Quick Setup</b> + + + + + Generate all palette roles automatically from a single accent colour + + + + + Accent: + + + + + Primary hue. Used directly for highlights and links. +At high intensity it also tints buttons and backgrounds. + + + + + Intensity: + + + + + Subtle + + + + + Full colour + + + + + 0–30 Subtle tint — only highlights and links change hue +30–70 Accented — buttons, tooltips, and borders join in +70–100 Full colour — backgrounds, everything + + + + + 70% + + + + + Generate ↓ + + + + + Derive all palette roles from the accent colour above. +Fine-tune individual colours in the grid afterwards. + + + RemoteDeckList_TreeModel - + Name Nazwa - + ID ID - + Upload time Data dodania @@ -7206,32 +7503,32 @@ Are you sure you would like to disable this feature? RemoteReplayList_TreeModel - + ID ID - + Name Nazwa - + Players Gracze - + Keep Zachowaj - + Time started Czas rozpoczęcia - + Duration (sec) Czas trwania (sek) @@ -7239,37 +7536,37 @@ Are you sure you would like to disable this feature? RfgMenu - + &Exile - + &View exile - + &Move exile to... - + &Top of library - + &Bottom of library - + &Hand - + &Graveyard @@ -7315,7 +7612,7 @@ Are you sure you would like to disable this feature? SayMenu - + S&ay @@ -7356,27 +7653,27 @@ Are you sure you would like to disable this feature? SetsModel - + Enabled Włączone - + Set type Rodzaj dodatku - + Set code Kod dodatku - + Long name Pełna nazwa - + Release date Data wydania @@ -7384,53 +7681,53 @@ Are you sure you would like to disable this feature? ShortcutSettingsPage - - + + Restore all default shortcuts Przywróć domyślne skróty - + Do you really want to restore all default shortcuts? Czy na pewno chcesz przywrócić wszystkie domyślne skróty? - + Clear all default shortcuts Wyczyść wszystkie domyślne skróty - + Do you really want to clear all shortcuts? Czy na pewno chcesz wyczyścić wszystkie skróty? - + Section: Sekcja: - + Action: Akcja: - + Shortcut: Skrót: - + How to set custom shortcuts Jak ustawić niestandardowe skróty klawiszowe - + Clear all shortcuts Wyczyść wszystkie skróty - + Search by shortcut name Szukaj po nazwie skrótu @@ -7486,12 +7783,12 @@ Sprawdź ustawienia skrótów! SideboardMenu - + &Sideboard - + &View sideboard @@ -7499,27 +7796,27 @@ Sprawdź ustawienia skrótów! SoundSettingsPage - + Enable &sounds Włącz &efekty dźwiękowe - + Current sounds theme: Aktualny zestaw dźwięków: - + Test system sound engine Testuj silnik dźwiękowy systemu - + Sound settings Ustawienia dźwięku - + Master volume Główne ustawienia głośności @@ -7591,16 +7888,146 @@ Sprawdź ustawienia skrótów! Nieprawidłowa odpowiedź serwera uaktualnień wersji. - + No reply received from the tag update server. Brak odpowiedzi z serwera uaktualnień tagów. - + Invalid reply received from the tag update server. Nieprawidłowa odpowiedź serwera uaktualnień tagów. + + StorageSettingsPage + + + + + Success + + + + + Cached card pictures have been reset. + + + + + Downloaded card pictures have been reset. + + + + + Error + + + + + One or more downloaded card pictures could not be cleared. + + + + + In-memory (currently loaded) card pictures have been reset. + + + + + Card Picture Loader Caching Method: + + + + + The network cache is the preferred way of storing images. Downloaded images are stored here until the size of the cache exceeds the configured size. Cockatrice automatically monitors this cache and deletes the least recently seen card images to ensure the cache does not exceed the configured size. + + + + + Writing card images directly to a folder on your hard drive is another way of storing images. This does not change how Cockatrice accesses or downloads images. Cockatrice will NOT automatically monitor and clear this folder, so if you enable this option, it is up to you to ensure sufficient available space. It should also be noted that if a provider outage causes you to download the wrong picture (i.e. wrong printing) you will be stuck with it until you manually delete the file, as opposed to using the network cache, which automatically rotates and thus correct errors after a while. + + + + + This is the in-memory picture cache used by the application at runtime. It determines how much memory (RAM) Cockatrice can use before it has to fetch card images from the hard disk again. Increasing this will allow more card images to be displayed at once but shouldn't be necessary. Clearing this will make Cockatrice reload all images from the network cache or the disk. + + + + + Delete Cached Images + + + + + Delete Saved Images + + + + + Clear In-Memory Images + + + + + Card Picture Loader Cache Method + + + + + Network Cache + + + + + Filesystem + + + + + In-Memory Picture Cache + + + + + Network Cache Size: + + + + + On-disk cache for downloaded pictures + + + + + Redirect Cache TTL: + + + + + How long cached redirects for urls are valid for. + + + + + Picture Cache Size: + + + + + In-memory cache for pictures not currently on screen + + + + + Naming scheme: + + + + + Day(s) + + + TabAccount @@ -7736,117 +8163,117 @@ Sprawdź ustawienia skrótów! TabArchidekt - - + + Desc. - - + + AND - - + + Require ALL selected colors - - + + Deck name... - - + + Owner... - - + + Packages - - + + Advanced Filters - + Bracket: - - + + Any - - + + Contains card... - - + + Commander... - - + + Tag... - - + + Deck Size - + Cards: - - + + Asc. - + Sort by: - + Filter by: - + Display Settings - - + + Search - - + + Formats @@ -7856,6 +8283,54 @@ Sprawdź ustawienia skrótów! + + TabCardArtRules + + + Card: + + + + + ProviderId: + + + + + Mode: + + + + + Reason: + + + + + Type a card name... + + + + + Add rule + + + + + Remove rule + + + + + Refresh + + + + + Card Art Rules + + + TabDeckEditor @@ -7904,7 +8379,7 @@ Sprawdź ustawienia skrótów! Resetuj układ - + Deck: %1 Talia: %1 @@ -7912,55 +8387,49 @@ Sprawdź ustawienia skrótów! TabDeckEditorVisual - + Visual Deck: %1 - + &Visual Deck Editor - - + + Card Info - - + + Deck - - - Filters - - - - + &View - + Printing - + Visible - + Floating - + Reset layout @@ -7968,22 +8437,22 @@ Sprawdź ustawienia skrótów! TabDeckEditorVisualTabWidget - + Visual Deck View - + Visual Database Display - + Deck Analytics - + Sample Hand @@ -7991,134 +8460,134 @@ Sprawdź ustawienia skrótów! TabDeckStorage - + Local file system Pliki lokalne - + Server deck storage Przechowalnia talii na serwerze - - + + Open in deck editor Otwórz w edytorze talii - + Rename deck or folder - + Upload deck Wyślij talię - + Download deck Pobierz talię - - - + + + New folder Nowy folder - + Delete Usuń - + Open decks folder Otwórz folder talii - + Rename local folder - + Rename local file - + New name: - - - - + + + + Error Błąd - + Rename failed - - + + Invalid deck file Nieprawidłowy plik talii - + Enter deck name Podaj nazwę talii - + This decklist does not have a name. Please enter a name: Talia nie posiada nazwy. Wprowadź nazwę: - + Unnamed deck Nienazwana talia - + Failed to upload deck to server Nie udało się wysłać talii na serwer - + Delete local file Usuń plik lokalny - + Are you sure you want to delete the selected files? Czy na pewno chcesz usunąć wybrane pliki? - + Delete remote decks Usuń zdalną talię - + Are you sure you want to delete the selected decks? Czy na pewno chcesz usunąć wybrane talie? - - + + Name of new folder: Nazwa nowego folderu: @@ -8190,191 +8659,191 @@ Wprowadź nazwę: TabGame - - - + + + Replay Powtórka - - + + Game Gra - - + + Player List Lista Graczy - - + + Card Info Informacje o karcie - - + + Messages Wiadomości - - + + Replay Timeline Oś czasu powtórki - + &Phases &Fazy - + &Game &Gra - + Next &phase Następna &faza - + Next phase with &action Następna faza z akcją - + Next &turn Następna &tura - + Reverse turn order Odwróć kolejność tur - + &Remove all local arrows Usuń wszystkie &wskaźniki - + Rotate View Cl&ockwise Obróć widok &zgodnie z ruchem wskazówek zegara - + Rotate View Co&unterclockwise Obróć widok &przeciwnie do ruchu wskazówek zegara - + Game &information &Informacje o grze - + Un&concede Powróć do gry - - - + + + &Concede &Poddaj grę - + &Leave game &Opuść grę - + C&lose replay &Zamknij powtórkę - + &Focus Chat Skup czat - + &Say: &Powiedz: - + Selected cards - + &View &Widok - + Visible Widoczne - + Floating Pływające - + Reset layout Resetuj układ - + Concede Poddaj grę - + Are you sure you want to concede this game? Czy na pewno chcesz poddać tę grę? - + Unconcede Powróć do gry - + You have already conceded. Do you want to return to this game? Poddałeś już się. Czy chcesz powrócić do tej gry? - + Leave game Opuść grę - + Are you sure you want to leave this game? Czy na pewno chcesz zakończyć tę rozgrywkę? - + A player has joined game #%1 Gracz dołączył do gry #%1 - + %1 has joined the game %1 dołączył(a) do gry. - + You have been kicked out of the game. Wyrzucono cię z gry. @@ -8433,115 +8902,115 @@ Wprowadź nazwę: Musisz wybrać poprawną liczbę dni żeby zlokalizować. - + Username: Nazwa użytkownika: - + IP Address: Adres IP: - + Game Name: Nazwa rozgrywki: - + GameID: ID Gry: - + Message: Wiadomość: - + Main Room Pokój Główny - + Game Room Pokój - + Private Chat Rozmowa Prywatna - + Past X Days: Ostatnie X Dni: - + Today Dziś - + Last Hour Ostatnia godzina - + Maximum Results: Maksymalny Wynik: - + At least one filter is required. The more information you put in, the more specific your results will be. Wymagany jest co najmniej jeden filtr. Im więcej podanych informacji, tym bardziej sprecyzowany wynik. - + Get User Logs Pobierz Logi Użytkownika - + Clear Filters Wyczyść Filtry - + Filters Filtry - + Log Locations Loguj Połóżenia - + Date Range Okres - + Maximum Results Maksymalny Wynik - - + + Message History Historia wiadomości - + Failed to collect message history information. Nie udało się zebrać informacji o historii wiadomości. - + There are no messages for the selected filters. Brak wiadomości dla wybranych filtrów. @@ -8559,27 +9028,27 @@ Im więcej podanych informacji, tym bardziej sprecyzowany wynik. &Opuść - + %1 - Private chat %1 - Prywatny chat - + This user is ignoring you, they cannot see your messages in main chat and you cannot join their games. Ten użytkownik Cię ignoruje, nie może zobaczyć twoich wiadomości w głównym czacie oraz nie możesz dołączyć do jego gier. - + Private message from Prywatna wiadomość od - + %1 has left the server. Gracz %1 opuścił serwer. - + %1 has joined the server. Gracz %1 pojawił się na serwerze. @@ -8587,180 +9056,180 @@ Im więcej podanych informacji, tym bardziej sprecyzowany wynik. TabReplays - + Local file system Lokalny system plików - + Server replay storage Powtórki na serwerze - - + + Watch replay Obejrzyj powtórkę - + Rename Zmień nazwę - - + + New folder Nowy folder - - + + Delete Usuń - + Open replays folder Otwórz folder powtórek - + Download replay Pobierz powtórkę - + Toggle expiration lock Wł./Wył. ochronę przed automatycznym usunięciem - + Get replay share code - - + + Look up replay by share code - + Rename local folder Zmień nazwę lokalnego folderu - + Rename local file Zmień nazwę lokalnego pliku - + New name: Nowa nazwa: - + Error Błąd - + Rename failed Zmiana nazwy nie powiodła się - + Name of new folder: Nazwa nowego folderu: - + Delete local file Usuń plik lokalny - + Are you sure you want to delete the selected files? Czy na pewno chcesz usunąć wybrane pliki? - + Are you sure you want to delete the selected replays? Czy na pewno chcesz usunąć wybrane powtórki? - + Failed to get code - - + + Either this server does not support replay sharing, or does not permit replay sharing for you. - - - + + + Failed - + Could not get replay code - + Replay Share Code - + Others can use this code to add the replay to their list of remote replays: %1 - + Copy to clipboard - + Replay share code - + Replay code found - + Replay was added, or you already had access to it. - + Replay code not found - + Failed to submit code - + Unexpected error - + Delete remote replay Usuń przechowywaną powtórkę @@ -8773,47 +9242,62 @@ Im więcej podanych informacji, tym bardziej sprecyzowany wynik. TabRoom - + + Friends + + + + + Online + + + + + Ignored + + + + &Say: &Powiedz: - + Chat Czat - + &Room P&okój - + &Leave room Op&uść pokój - + &Clear chat Wyczyść &czat - + Chat Settings... Ustawienia czatu… - + mentioned you. wspomniał o tobie. - + Click to view Kliknij aby podglądnąć - + You are flooding the chat. Please wait a couple of seconds. Spamujesz. Poczekaj chwilę. @@ -8826,30 +9310,30 @@ Im więcej podanych informacji, tym bardziej sprecyzowany wynik. Serwer - - - - + + + + Error Błąd - + Failed to join the server room: it doesn't exist on the server. Błąd podczas próby dołączenia do pokoju: nie istnieje na serwerze. - + The server thinks you are in the server room but your client is unable to display it. Try restarting your client. Serwer myśli że jesteś w pokoju ale twój klient nie może tego wyświetlić. Spróbuj zrestartować twojego klienta. - + You do not have the required permission to join this server room. Nie masz uprawnień do dołączenia do tego pokoju. - + Failed to join the server room due to an unknown error: %1. Błąd podczas próby dołączenia do pokoju z powodu nieznanego błędu: %1 @@ -8857,97 +9341,97 @@ Im więcej podanych informacji, tym bardziej sprecyzowany wynik. TabSupervisor - + Deck Editor - + Visual Deck Editor - + EDHRec - + Archidekt - + Home - + &Visual Deck Storage - + Visual Database Display - + Server - + Account - + Deck Storage - + Game Replays - + Administration - + Logs - + Are you sure? Czy na pewno? - + There are still open games. Are you sure you want to quit? Ciągle masz otwarte gry. Czy na pewno chcesz wyjść? - + Click to view Kliknij aby podglądnąć - + Your buddy %1 has signed on! Twój znajomy %1 zalogował się! - + Unknown Event Nieznane zdarzenie - + The server has sent you a message that your client does not understand. This message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -8958,39 +9442,39 @@ Ta wiadomośc może oznaczać że jest dostępna nowa wersja Cockatrice, lub że Aby aktualizować klienta, otwórz okno Pomoc -> Sprawdź Aktualizacje - + Idle Timeout Przekroczono czas bezczynności - + You are about to be logged out due to inactivity. Zostaniesz wylogowany ze względu na nieaktywność. - + Promotion Awans - + You have been promoted. Please log out and back in for changes to take effect. Zostałeś awansowany. Proszę wyloguj się i zaloguj ponownie aby zmiany zostały wprowadzone. - + Warned Ostrzeżony - + You have received a warning due to %1. Please refrain from engaging in this activity or further actions may be taken against you. If you have any questions, please private message a moderator. Otrzymałeś ostrzeżenie z powodu %1. Unikaj tego zachowania, w przeciwnym mogą zostać pozwięte dalsze czynności. Jeżeli masz pytania, wyślij prywatną wiadomość do moderatora. - + You have received the following message from the server. (custom messages like these could be untranslated) Otrzymałeś następującą wiadomość od serwera. @@ -9000,12 +9484,12 @@ Unikaj tego zachowania, w przeciwnym mogą zostać pozwięte dalsze czynności. TabVisualDatabaseDisplay - + Database Display - + Visual Database Display @@ -9042,42 +9526,42 @@ Unikaj tego zachowania, w przeciwnym mogą zostać pozwięte dalsze czynności. TranslateCounterName - + Life Życie - + White Biały - + Blue Niebieski - + Black Czarny - + Red Czerwony - + Green Zielony - + Colorless Bezkolorowy - + Other Inne @@ -9085,11 +9569,69 @@ Unikaj tego zachowania, w przeciwnym mogą zostać pozwięte dalsze czynności. UpdateDownloader - + Could not open the file for reading. Nie można było otworzyć pliku do odczytu. + + UserCardArtSettingsDialog + + + Card Art Settings + + + + + Type a card name... + + + + + Card name: + + + + + Card ProviderId: + + + + + Left margin (%): + + + + + Right margin (%): + + + + + Vertical offset: + + + + + Zoom: + + + + + Parameters + + + + + Preview + + + + + Remove Banner Card + + + UserContextMenu @@ -9207,7 +9749,7 @@ Unikaj tego zachowania, w przeciwnym mogą zostać pozwięte dalsze czynności. - + Ban History Historia blokad @@ -9222,77 +9764,87 @@ Unikaj tego zachowania, w przeciwnym mogą zostać pozwięte dalsze czynności. Użytkownik nigdy nie został zablokowany - + Failed to collect ban information. Błąd podczas zbierania informacji o blokadach. - - - + + + Warning History Historia ostrzeżeń - + Warning Time;Moderator;User Name;Reason Ostrzeżenie Czas;Moderator;Nazwa Użytkownika;Powód - + User has never been warned. Użytkownik nigdy nie dostał ostrzeżenia. - + Failed to collect warning information. Błąd podczas zbierania informacji o ostrzeżeniach. - + Failed to get admin notes. Błąd podczas zbierania notatek administratorskich. - - + + Success Operacja zakończona pomyślnie - + Successfully promoted user. Pomyślny awans gracza. - + Successfully demoted user. Pomyślna degradacja gracza. - - - + + Kick Player + + + + + Are you sure you want to kick this player from the game? + + + + + + Failed Wystąpił błąd - + Failed to promote user. Błąd podczas awansu gracza. - + Failed to demote user. Błąd podczas degradacji gracza. - + Copy hash to clipboard Zapisz hash do schowka - + Remove this user's messages Usuń wiadomości tego użytkownika @@ -9300,326 +9852,554 @@ Unikaj tego zachowania, w przeciwnym mogą zostać pozwięte dalsze czynności. UserInfoBox - + Location: Lokacja: - + Account Age: Wiek konta: - + Edit Edytuj - + Change password Zmień hasło - + Change avatar Zmień awatar - + Administrator Administrator - + Moderator Moderator - + Registered user Zarejestrowany użytkownik - - + + Unregistered user Niezarejestrowany użytkownik - + Judge Sędzia - + Unknown Nieznany - + The entered password does not match your account. Wpisane hasło nie pasuje do twojego konta. - - - + + + + Information Informacje - + User information updated. Informacje o użytkowniku zaktualizowane. - - - - - - - - - - + + + + + + + + + + + Error Błąd - + User Information Informacje o użytkowniku - + Real Name: Prawdziwe imię: - + User Level: Rodzaj użytkownika: + + + Edit Banner Card + + - + %n Year(s), amount of years (only shown if more than 0) %n Rok%n Lat%n Lat%n Lat - + %10%n Day(s) %20 amount of years (if more than 0), amount of days, date in local short format %10%n Dzień %20%10%n Dni %20%10%n Dni %20%10%n Dni %20 - + Enter Password Wpisz hasło - + Password verification is required in order to change your email address Weryfikacja hasłem jest wymagana aby zmienić twój adres email. - - - + + + An error occurred while trying to update your user information. Wystąpił błąd podczas aktualizacji informacji o użytkowniku. - + + The selected card is blacklisted on this server or another error occurred. + + + + + Banner card removed. + + + + + Banner card updated. + + + + This server does not permit you to update your user informations. Ten serwer nie pozwala na aktualizację twoich informacji o użytkowniku. - + Password changed. Hasło zmienione. - + This server does not permit you to change your password. Ten serwer nie pozwala na zmianę twojego hasła. - + The new password is too short. Nowe hasło jest za krótkie. - + The old password is incorrect. Stare hasło jest niepoprawne. - + Avatar updated. Awatar zaktualizowany - + This server does not permit you to update your avatar. Ten serwer nie pozwala na aktualizację twojego awatara. + + + An error occured while trying to update your avatar. + + - An error occured while trying to updater your avatar. - Wystąpił błąd podczas aktualizowania twojego awatara. + This server does not permit you to update your user informations. + An error occured while trying to updater your avatar. + Ten serwer nie pozwala na aktualizację twoich informacji o użytkowniku. + + + + UserInfoPopup + + + + Games + + + + + Chat + + + + + Open private chat + + + + + Profile + + + + + View user profile + + + + + Show this user's games + + + + + − Buddy + + + + + Remove from buddy list + + + + + + Buddy + + + + + Add to buddy list + + + + + − Ignore + + + + + Remove from ignore list + + + + + + Ignore + + + + + Add to ignore list + + + + + Ban + + + + + Ban from server + + + + + Warn + + + + + Warn user + + + + + Ban log + + + + + View ban history + + + + + Warn log + + + + + View warning history + + + + + Notes + + + + + View admin notes + + + + + − Mod + + + + + Demote from moderator + + + + + + Mod + + + + + Promote to moderator + + + + + − Judge + + + + + Demote from judge + + + + + + Judge + + + + + Promote to judge + + + + + Join game + + + + + Spectate + + + + + + Loading games… + + + + + Could not load games. + + + + + No active games. + UserInterfaceSettingsPage - + General interface settings Ogólne ustawienia interfejsu - + &Double-click cards to play them (instead of single-click) Zagrywaj karty po&dwójnym kliknięciem (zamiast pojedynczym) - + &Clicking plays all selected cards (instead of just the clicked card) Kliknięcie zagrywa wszystkie wybrane karty (zamiast tylko klikniętej karty) - + &Play all nonlands onto the stack (not the battlefield) by default Karty nie będące lądami zagrywaj domyślnie na stos (zamiast na &pole bitwy) - + Do not delete &arrows inside of subphases - + Close card view window when last card is removed - + Auto focus search bar when card view window is opened - + Annotate card text on tokens Dodaj tekst karty na tokenach - - Show selection counter during drag selection + + Show selection count during drag selection - - Show total selection counter + + Show total selection count - + + Show subtype breakdown in selection tally + + + + Use tear-off menus, allowing right click menus to persist on screen Użycie odczepianych menu, pozwalające na zatrzymanie menu kontekstowego na ekranie - + + Keep game chat focused when clicking in game (Note: disables card view search bar) + + + + Notifications settings Ustawienia powiadomień - + Enable notifications in taskbar Włącz &powiadomienia na pasku zadań - + Notify in the taskbar for game events while you are spectating Powiadomienia na pasku zadań dla gier, które &obserwujesz - + Notify in the taskbar when users in your buddy list connect Powiadamiaj na pasku zadań kiedy użytkownik z twojej listy znajomych zaloguje się. - + Animation settings Ustawienia animacji - + &Tap/untap animation Animacja &tapowania/odtapowania - + Deck editor/storage settings - + Open deck in new tab by default Domyślnie otwórz talię w nowej karcie - + Use visual deck storage in game lobby - + Use selection animation for Visual Deck Storage - + When adding a tag in the visual deck storage to a .txt deck: - + do nothing - + ask to convert to .cod - + always convert to .cod - + Default deck editor type - + Classic Deck Editor - + Visual Deck Editor - + Replay settings Ustawienia powtórek - + Buffer time for backwards skip via shortcut: Buforuj czas do przeskoku w tył wywołane przez skrót: @@ -9627,22 +10407,22 @@ Unikaj tego zachowania, w przeciwnym mogą zostać pozwięte dalsze czynności. UserListWidget - + Users connected to server: %1 - + Users in this room: %1 - + Buddies online: %1 / %2 - + Ignored users online: %1 / %2 @@ -9650,35 +10430,45 @@ Unikaj tego zachowania, w przeciwnym mogą zostać pozwięte dalsze czynności. UtilityMenu - + Increment all card counters - + &Untap all permanents - + R&oll die... - + + Flip coin + + + + &Create token... - + C&reate another token - + Cr&eate predefined token + + + C&reate another %1 token + + VisualDatabaseDisplayColorFilterWidget @@ -9730,72 +10520,72 @@ Unikaj tego zachowania, w przeciwnym mogą zostać pozwięte dalsze czynności. VisualDatabaseDisplayFilterToolbarWidget - + Sort by - + Filter by - + Save and load filters - + Filter by exact card name - + Filter by card main-type - + Filter by card sub-type - + Filter by set - + Filter by format legality - + Save/Load - + Name - + Main Type - + Sub Type - + Sets - + Formats @@ -9803,22 +10593,22 @@ Unikaj tego zachowania, w przeciwnym mogą zostać pozwięte dalsze czynności. VisualDatabaseDisplayFormatLegalityFilterWidget - + Show formats with at least: - + cards - + Do not display formats with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) @@ -9836,32 +10626,32 @@ Unikaj tego zachowania, w przeciwnym mogą zostać pozwięte dalsze czynności. VisualDatabaseDisplayMainTypeFilterWidget - + Show main types with at least: - + cards - + Do not display card main-types with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) - + Mode: Exact Match - + Mode: Includes @@ -9869,27 +10659,27 @@ Unikaj tego zachowania, w przeciwnym mogą zostać pozwięte dalsze czynności. VisualDatabaseDisplayNameFilterWidget - + Filter by name... (Exact match) - + Load from Deck - + Apply all card names in currently loaded deck as exact match name filters - + Load from Clipboard - + Apply all card names in clipboard as exact match name filters @@ -9897,7 +10687,7 @@ Unikaj tego zachowania, w przeciwnym mogą zostać pozwięte dalsze czynności. VisualDatabaseDisplayRecentSetFilterSettingsWidget - + Filter to most recent sets @@ -9905,19 +10695,19 @@ Unikaj tego zachowania, w przeciwnym mogą zostać pozwięte dalsze czynności. VisualDatabaseDisplaySetFilterWidget - + Search sets... - - + + Mode: Exact Match - - + + Mode: Includes @@ -9925,37 +10715,37 @@ Unikaj tego zachowania, w przeciwnym mogą zostać pozwięte dalsze czynności. VisualDatabaseDisplaySubTypeFilterWidget - + Search subtypes... - + Show sub types with at least: - + cards - + Do not display card sub-types with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) - + Mode: Exact Match - + Mode: Includes @@ -9963,23 +10753,23 @@ Unikaj tego zachowania, w przeciwnym mogą zostać pozwięte dalsze czynności. VisualDatabaseDisplayWidget - + Search by card name (or search expressions) - + Visual - + Loading database ... - + Clear all filters @@ -10018,17 +10808,17 @@ Unikaj tego zachowania, w przeciwnym mogą zostać pozwięte dalsze czynności. - + Toggle Layout: Overlap - + Change how cards are displayed within zones (i.e. overlapped or fully visible.) - + Toggle Layout: Flat @@ -10057,17 +10847,17 @@ Unikaj tego zachowania, w przeciwnym mogą zostać pozwięte dalsze czynności. VisualDeckEditorWidget - + Type a card name here for suggestions from the database... - + Quick search and add card - + Search for closest match in the database (with auto-suggestions) and add preferred printing to the deck on pressing enter @@ -10185,43 +10975,43 @@ Unikaj tego zachowania, w przeciwnym mogą zostać pozwięte dalsze czynności. WarningDialog - + Which warning would you like to send? Jakie ostrzeżenie chciałbyś wysłać? - + Redact all messages from this user in all rooms Redaguj wiadomości od tego użytkownika we wszystkich pokojach - + &OK &OK - + &Cancel &Anuluj - + Warn user for misconduct Ostrzeż gracza za złe zachowanie - - + + Error Błąd - + User name to send a warning to can not be blank, please specify a user to warn. Nazwa gracza do wysłania ostrzeżenia nie może być pusta, prosze podać gracza. - + Warning to use can not be blank, please select a valid warning to send. Ostrzeżenie nie może być puste, zaznacz poprawne ostrzeżenie do wysłania. @@ -10229,133 +11019,133 @@ Unikaj tego zachowania, w przeciwnym mogą zostać pozwięte dalsze czynności. WndSets - + Move selected set to the top Przesuń zaznaczony set do góry - + Move selected set up Przesuć wybrany dodatek w górę - + Move selected set down Przesuń wybrany dodatek w dół - + Move selected set to the bottom Przesuń zaznaczony set na dół - + Search by set name, code, or type Szukaj po nazwie dodatku, kodzie lub typie - + Default order Domyślna kolejność - + Restore original art priority order Przywróć oryginalny priorytet obrazów kart - + Enable all sets Włącz wszystkie dodatki - + Disable all sets Wyłącz wszystkie dodatki - + Enable selected set(s) Włącz zaznaczony/e set(y) - + Disable selected set(s) Wyłącz zaznaczony/e set(y) - + Deck Editor Edycja Talii - + Use CTRL+A to select all sets in the view. Użyj CTRL+A aby wybrać wszystkie dodatki w widoku. - + Only cards in enabled sets will appear in the card list of the deck editor. Tylko karty z odblokowanych dodatków pojawią się w edytorze talii kart - + Image priority is decided in the following order: Priorytet obrazów jest ustalany w następujący sposób: - + first the CUSTOM Folder (%1), then the Enabled Sets in this dialog (Top to Bottom) %1 is a link to the wiki najpierw folder NIESTANDARDOWYCH (%1), potem Włączone Dodatki w tym oknie dialogowym (Z góry na dół) - + Include cards rebalanced for Alchemy [requires restart] - + Card Art Obraz karty - + How to use custom card art Jak ustawić niestandardowe obrazy do kart - + Hints Wskazówki - + Note Notatka - + Sorting by column allows you to find a set while not changing set priority. Sortowane kolumnami pozwala ci znaleźć dodatek nie zmieniając ich priorytetów. - + To enable ordering again, click the column header until this message disappears. Aby wyłączyć sortowanie klikaj ponownie na ten sam nagłówek kolumny aż zniknie ten komunikat. - + Use the current sorting as the set priority instead Użyj tymczasowego sortowania jako priorytet dodatków - + Sorts the set priority using the same column Sortuje priorytet dodatków korzystając z tej samej kolumny - + Manage sets Zarządzaj dodatkami @@ -10363,72 +11153,72 @@ Unikaj tego zachowania, w przeciwnym mogą zostać pozwięte dalsze czynności. ZoneViewWidget - + Search by card name (or search expressions) - + Ungrouped Niepogrupowane - + Group by Type Grupuj po typie - + Group by Mana Value Grupuj po wartości many - + Group by Color Grupuj po kolorze - + Unsorted Nieposortowane - + Sort by Name Sortuj według nazwy - + Sort by Type Sortuj według typu - + Sort by Mana Cost Sortuj według wartości many - + Sort by Colors Sortuj według koloru - + Sort by P/T Sortuj według P/T - + Sort by Set Sortuj według dodatku - + shuffle when closing przetasuj przy zamykaniu - + pile view widok sterty @@ -10436,7 +11226,7 @@ Unikaj tego zachowania, w przeciwnym mogą zostać pozwięte dalsze czynności. i18n - + English Polski (Polish) @@ -10444,12 +11234,12 @@ Unikaj tego zachowania, w przeciwnym mogą zostać pozwięte dalsze czynności. main - + Connect on startup Zaloguj po uruchowmieniu - + Debug to file Debuguj do pliku @@ -10463,7 +11253,7 @@ Unikaj tego zachowania, w przeciwnym mogą zostać pozwięte dalsze czynności. - + Deck Editor Edycja Talii @@ -10544,7 +11334,7 @@ Unikaj tego zachowania, w przeciwnym mogą zostać pozwięte dalsze czynności. - + Replays Powtórki @@ -10659,129 +11449,129 @@ Unikaj tego zachowania, w przeciwnym mogą zostać pozwięte dalsze czynności. Wczytaj talię... - - + + Load Deck from Clipboard... Wczytaj talię ze schowka… - + Edit Deck in Clipboard, Annotated - + Edit Deck in Clipboard - + New Deck Nowa talia - + Open Custom Pictures Folder Otwórz folder z niestandardowymi obrazkami - + Print Deck... Drukuj talię - + Delete Card Usuń kartę - - + + Reset Layout Zresetuj wygląd - + Save Deck Zapisz talię - + Save Deck as... Zapisz talię jako… - + Save Deck to Clipboard, Annotated Zapisz talię, anotowaną, do schowka - + Save Deck to Clipboard, Annotated (No Set Info) - + Save Deck to Clipboard Zapisz talię do schowka - + Save Deck to Clipboard (No Set Info) - + Load Local Deck... Wczytaj talię lokalną... - + Load Remote Deck... Wczytaj zdalną talię... - + Set Ready to Start Ustaw gotowość do rozpoczęcia - + Toggle Sideboard Lock Przełącz blokadę sideboard - + Add Green Counter Dodaj Zielony Znacznik - + Remove Green Counter Odejmij Zielony Znacznik - + Set Green Counters... Ustaw Zielony Znacznik... - + Add Red Counter Dodaj Czerwony Znacznik - + Remove Red Counter Odejmij Czerwony Znacznik - + Set Red Counters... Ustaw Czerwony Znacznik - + Add Life Counter Dodaj do Znacznika Życia @@ -10791,724 +11581,744 @@ Unikaj tego zachowania, w przeciwnym mogą zostać pozwięte dalsze czynności. - + + Load deck from online service... + + + + + Load from website... + + + + Unload Deck - + Force Start - + Add Card Counter (F) - + Remove Card Counter (F) - + Set Card Counters (F)... - + Add Card Counter (E) - + Remove Card Counter (E) - + Set Card Counters (E)... - + Add Card Counter(D) - + Remove Card Counter (D) - + Set Card Counters (D)... - + Add Card Counter (C) - + Remove Card Counter (C) - + Set Card Counters (C)... - + Add Card Counter (B) - + Remove Card Counter (B) - + Set Card Counters (B)... - + Add Card Counter (A) - + Remove Card Counter (A) - + Set Card Counters (A)... - + Remove Life Counter Odejmij od Znacznika Życia - + Set Life Counters... Ustaw Znacznik Życia... - + Add White Counter Dodaj Biały Znacznik - + Remove White Counter Odejmij Biały Znacznik - + Set White Counters... Ustaw Biały Znacznik... - + Add Blue Counter Dodaj Niebieski Znacznik - + Remove Blue Counter Odejmij Niebieski Znacznik - + Set Blue Counters... Ustaw Niebieski Znacznik... - + Add Black Counter Dodaj Czarny Znacznik - + Remove Black Counter Odejmij Czarny Znacznik - + Set Black Counters... Ustaw Czarny Znacznik... - + Add Colorless Counter Dodaj Bezkolorowy Znacznik - + Remove Colorless Counter Odejmij Bezkolorowy Znacznik - + Set Colorless Counters... Ustaw Bezkolorowy Znacznik... - + Add Other Counter Dodaj Inny Znacznik - + Remove Other Counter Odejmij Inny Znacznik - + Set Other Counters... Ustaw Inny Znacznik... - + Increment all card counters - + Add Power (+1/+0) Dodaj Power (+1/+0) - + Remove Power (-1/-0) Odejmij Power (-1/-0) - + Move Toughness to Power (+1/-1) Przesuń Toughness do Power (+1/-1) - + Add Toughness (+0/+1) Dodaj Toughness (+0/+1) - + Remove Toughness (-0/-1) Odejmij Toughness (-0/-1) - + Move Power to Toughness (-1/+1) Przesuń Power do Toughness (-1/+1) - + Add Power and Toughness (+1/+1) Dodaj Power i Toughness (+1/+1) - + Remove Power and Toughness (-1/-1) Odejmij Power i Toughness (-1/-1) - + Set Power and Toughness... Ustaw Power i Toughness... - + Reset Power and Toughness Resetuj Power i Toughness - + Untap Odtapuj - + Upkeep Utrzymanie - + Draw Dobranie - + First Main Phase Pierwsza Faza Główna - + Start Combat Początek Walki - + Attack Atak - + Block Blok - + Damage Obrażenia - + End Combat Koniec Walki - + Second Main Phase Druga Faza Główna - + End Koniec - + Next Phase Następna Faza - + Next Phase Action Następna Akcja Fazowa - + Next Turn Następna Tura - + Hide Card in Reveal Window - + Tap / Untap Card Zatapuj / Odtapuj kartę - + Untap All Odtapuj Wszystkie - + Toggle Skip Untapping Toggle Untap - + Turn Card Over Obróć Kartę - + Peek Card Podglądnij Kartę - + Play Card Zagraj Kartę - + Play Card, Face Down - + Attach Card... Dołącz Kartę.. - + Unattach Card Odłącz Kartę - + Clone Card Sklonuj Kartę - + Create Token... Utwórz token… - + Create All Related Tokens Stwórz Wszystkie Pokrewne Tokeny - + Create Another Token Stwórz Kolejny Token - + Set Annotation... Ustaw Adnotację… - + + Reduce Life by Power + + + + Select All Cards in Zone Zaznacz Wszystkie Karty w Strefie - + Select All Cards in Row Zaznacz Wszystkie Karty w Rzędzie - + Select All Cards in Column Zaznacz Wszystkie Karty w Kolumnie - + Reveal Selected Cards to All Players - - + + Bottom of Library Dno biblioteki - - - - + + + + Exile Wygnanie - - - - + + + + Graveyard Grób - - + + Hand Ręka - - + + Top of Library Wierzch biblioteki - - + + Battlefield, Face Down Pole Bitwy, Koszulką do Góry - + Battlefield Pole Bitwy - + Library Biblioteka - + Sideboard Biblioteka poboczna - + Top Cards of Library Karty z Wierzchu Biblioteki - + Bottom Cards of Library - + Close Recent View Zamknij Ostatni Widok - - + + Stack Stos - - + + Graveyard (Multiple) Cmentarz (Wiele) - - + + Graveyard (Multiple), Face Down - - + + Exile (Multiple) Wygnanie (Wiele) - - + + Exile (Multiple), Face Down - + Stack Until Found Stos aż do Znalezienia - + Draw Bottom Card Dobierz Kartę z Dna - + Draw Multiple Cards from Bottom... Dobierz Wiele Kart z Dna... - + Draw Arrow... Narysuj wskaźnik... - + Remove Local Arrows Usuń Lokalne Wskaźniki - + Leave Game Opuść Grę - + Concede Poddanie - + Roll Dice... Rzuć Kośćmi... - + + Flip Coin + + + + Shuffle Library Potasuj Bibliotekę - + Shuffle Top Cards of Library - + Shuffle Bottom Cards of Library - + Mulligan Mulligan - + Mulligan (Same hand size) - + Mulligan (Hand size - 1) - + Draw a Card Dobierz Kartę - + Draw Multiple Cards... Dobierz Wiele Kart... - + Undo Draw Cofnij Dobranie - + Always Reveal Top Card Zawsze Odkrywaj Górną Kartę - + Always Look At Top Card Zawsze Patrz na Górną Kartę - + Sort Hand by Name - + Sort Hand by Type - + Sort Hand by Mana Value - + Reveal Hand to All Players - + Reveal Random Card to All Players - + Rotate View Clockwise Obróć widok zgodnie z ruchem wskazówek zegara - + Rotate View Counterclockwise Obróć widok przeciwnie do ruchu wskazówek zegara - + Unfocus Text Box Nie skupiaj Tekstu - + Focus Chat Skup Czat - + Clear Chat Wyczyść czat - + Refresh Odśwież - + Skip Forward Przeskocz do Przodu - + Skip Backward Przeskocz do Tyłu - + Skip Forward by a lot Bardzo Przeskocz do Przodu - + Skip Backward by a lot Bardzo Przeskocz do Tyłu - + Play/Pause Odtwórz/Pauza - + Toggle Fast Forward Przełącz przyspieszenie - + Home - + Visual Deck Storage - + Deck Storage - + Server - + Account - + Administration - + Logs diff --git a/cockatrice/translations/cockatrice_pt.ts b/cockatrice/translations/cockatrice_pt.ts index f67c8d138..912ee09d0 100644 --- a/cockatrice/translations/cockatrice_pt.ts +++ b/cockatrice/translations/cockatrice_pt.ts @@ -2,7 +2,7 @@ AbstractCounter - + &Set counter... Definir marcador @@ -10,12 +10,12 @@ AbstractCounterDialog - + Set counter - + New value for counter '%1': @@ -36,60 +36,60 @@ AbstractTabDeckEditor - + Open in new tab - + Are you sure? - + The decklist has been modified. Do you want to save the changes? - - - - - - + + + + + + Error - + Could not open deck at %1 - + Could not save remote deck - - + + The deck could not be saved. Please check that the directory is writable and try again. - + Save deck - + The deck could not be saved. - + There are no cards in your deck to be exported @@ -105,12 +105,12 @@ Please check that the directory is writable and try again. AdminNotesDialog - + Update Notes - + Admin Notes for %1 @@ -127,172 +127,227 @@ Please check that the directory is writable and try again. Sideboard + + + Tokens + + AppearanceSettingsPage - + seconds - + Error - + Could not create themes directory at '%1'. - + Theme settings Definições do Tema - + Current theme: Tema actual: - + Open themes folder - + Home tab background source: - + Home tab background shuffle frequency: - + Disabled - - Display card name of background in bottom right: - - - - + Menu settings - + Show keyboard shortcuts in right-click menus - + Show game filter toolbar above list in room tab - + Card rendering Rendering da carta - + Display card names on cards having a picture Mostrar o nome em cartas com imagem - + Auto-Rotate cards with sideways layout - + Override all card art with personal set preference (Pre-ProviderID change behavior) - + + Light + + + + + Dark + + + + + System + + + + + Active theme palette: + + + + + Edit theme palette + + + + + Home tab settings + + + + + Display card name of background in bottom right + + + + + Styling settings + + + + + Style user list + + + + + Card printings + + + + Bump sets that the deck contains cards from to the top in the printing selector - + Scale cards on mouse over Aumentar as cartas ao passar o rato - + Use rounded card corners - + + Card layout + + + + Minimum overlap percentage of cards on the stack and in vertical hand - + Maximum initial height for card view window: - - + + rows - + Maximum expanded height for card view window: - + Card counters - + Counter %1 - + Hand layout Disposição da Mão - + Display hand horizontally (wastes space) Mostrar mão horizontalmente (desperdiça espaço) - + Enable left justification Permitir justificação de texto à esquerda - + Table grid layout Esquema da mesa - + Invert vertical coordinate Inverter coordenada vertical - + Minimum player count for multi-column layout: Número mínimo de kogadores para layout com múltiplas colunas: - + Maximum font size for information displayed on cards: @@ -300,12 +355,12 @@ Please check that the directory is writable and try again. ArchidektApiResponseDeckDisplayWidget - + Back to results - + Open Deck in Deck Editor @@ -331,112 +386,112 @@ Please check that the directory is writable and try again. BanDialog - + ban &user name Banir nome de &utilizador - + ban &IP address Banir endereço de &IP - + ban client I&D banir I&D do cliente - + Ban type Tipo de Ban - + &permanent ban Ban &permanente - + &temporary ban Ban &temporário - + &Days: &Dias: - + &Hours: &Horas: - + &Minutes: &Minutos: - + Duration of the ban Duração do ban - + Please enter the reason for the ban. This is only saved for moderators and cannot be seen by the banned person. Por favor introduza o motivo do ban. Isto apenas é guardado para os moderadores e não é visível para a pessoa banida. - + Please enter the reason for the ban that will be visible to the banned person. Por favor introduza o motivo do ban que será visível à pessoa banida. - + Redact all messages from this user in all rooms - + &OK &OK - + &Cancel &Cancelar - + Ban user from server Banir utilizador do servidor - - - - + + + + Error Erro - + You have to select a name-based, IP-based, clientId based, or some combination of the three to place a ban. Tem de seleccionar um banimento baseado no nome, no IP ou ambos. - + You must have a value in the name ban when selecting the name ban checkbox. Deve preencher o campo de nome a banir quando selecciona a caixa de verificação para banir um nome. - + You must have a value in the ip ban when selecting the ip ban checkbox. Deve preencher o campo de IP a banir quando selecciona a caixa de verificação para banir um IP. - + You must have a value in the clientid ban when selecting the clientid ban checkbox. Deve preencher o campo de identificação de cliente quando selecciona a caixa de verificação para banir uma identificação de cliente. @@ -444,59 +499,123 @@ Isto apenas é guardado para os moderadores e não é visível para a pessoa ban BetaReleaseChannel - + Beta - + No reply received from the release update server. - + Invalid reply received from the release update server. - + No reply received from the file update server. + + CardArtPreviewWidget + + + No card selected + + + + + CardArtRulesModel + + + Card + + + + + ProviderId + + + + + Mode + + + + + Reason + + + CardDatabaseModel - + Name Nome - + Sets Expansões - + Mana cost Custo de mana - + Card type Tipo de carta - + P/T P/R - + Color(s) Cor(es) + + CardDatabaseView + + + Add to Deck + + + + + Add to Sideboard + + + + + Select Printing + + + + + Show on EDHRec (Commander) + + + + + Show on EDHRec (Card) + + + + + Show Related cards + + + CardFilter @@ -625,22 +744,22 @@ Isto apenas é guardado para os moderadores e não é visível para a pessoa ban CardInfoPictureWidget - + View related cards - + Add card to deck - + Mainboard - + Sideboard @@ -663,12 +782,12 @@ Isto apenas é guardado para os moderadores e não é visível para a pessoa ban - + Related cards: - + Unknown card: @@ -676,128 +795,146 @@ Isto apenas é guardado para os moderadores e não é visível para a pessoa ban CardMenu - + Re&veal to... - + &All players - + View related cards - + Token: - + All tokens - + &Select All - + S&elect Row - + S&elect Column - + &Play - + &Hide - + Play &Face Down - + &Tap / Untap Turn sideways or back again - + Skip &untapping - + T&urn Over Turn face up/face down - + &Peek at card face - + &Clone - + Attac&h to card... - + Unattac&h - + &Draw arrow... - + &Set annotation... - + + Reduce life by power + + + + Ca&rd counters - + &Add counter (%1) - + &Remove counter (%1) - + &Set counters (%1)... + + CardPictureLoaderCacheMethod + + + Network Cache + + + + + Filesystem + + + CardSizeWidget @@ -809,133 +946,133 @@ Isto apenas é guardado para os moderadores e não é visível para a pessoa ban CardZoneLogic - + their hand nominative - + %1's hand nominative - + their library look at zone - + %1's library look at zone - + of their library top cards of zone, - + of %1's library top cards of zone - + their library reveal zone - + %1's library reveal zone - + their library shuffle - + %1's library shuffle - + their library nominative - + %1's library nominative - + their graveyard nominative - + %1's graveyard nominative - + their exile nominative - + %1's exile nominative - + their sideboard look at zone - + %1's sideboard look at zone - + their sideboard nominative - + %1's sideboard nominative - + their custom zone '%1' nominative - + %1's custom zone '%2' nominative @@ -957,16 +1094,421 @@ Isto apenas é guardado para os moderadores e não é visível para a pessoa ban + + ColorButton + + + Click to pick a color + + + + + Pick colour + + + + + ConnectionController + + + + The server has reached its maximum user capacity, please check back later. + + + + + There are too many concurrent connections from your address. + + + + + Banned by moderator + + + + + Expected end time: %1 + + + + + This ban lasts indefinitely. + + + + + Scheduled server shutdown. + + + + + + Invalid username. + + + + + You have been logged out due to logging in at another location. + + + + + Connection closed + + + + + The server has terminated your connection. +Reason: %1 + + + + + The server is going to be restarted in %n minute(s). +All running games will be lost. +Reason for shutdown: %1 + + + + + Scheduled server shutdown + + + + + Failed Login + + + + + Your client seems to be missing features this server requires for connection. + + + + + To update your client, go to 'Help -> Check for Client Updates'. + + + + + + + + + + + + + + + + + + + + + + Error + + + + + Incorrect username or password. Please check your authentication information and try again. + + + + + There is already an active session using this user name. +Please close that session first and re-login. + + + + + + You are banned until %1. + + + + + + You are banned indefinitely. + + + + + This server requires user registration. Do you want to register now? + + + + + This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. +Please close and reopen your client to try again. + + + + + An internal error has occurred, please close and reopen Cockatrice before trying again. +If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. + + + + + Account activation + + + + + Your account has not been activated yet. +You need to provide the activation token received in the activation email. + + + + + Server Full + + + + + Unknown login error: %1 + + + + + + +This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. + + + + + + + + + + Registration denied + + + + + Registration is currently disabled on this server + + + + + There is already an existing account with the same user name. + + + + + It's mandatory to specify a valid email address when registering. + + + + + It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. + + + + + Password too short. + + + + + Registration failed for a technical problem on the server. + + + + + The connection to the server has been lost. + + + + + Unknown registration error: %1 + + + + + Account activation failed + + + + + Socket error: %1 + + + + + Server timeout + + + + + You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. +Local version is %1, remote version is %2. + + + + + Your Cockatrice client is obsolete. Please update your Cockatrice version. +Local version is %1, remote version is %2. + + + + + + Success + + + + + Registration accepted. +Will now login. + + + + + Account activation accepted. +Will now login. + + + + + Information + + + + + This server supports additional features that your client doesn't have. +This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. + +To update your client, go to Help -> Check for Updates. + + + + + + + Reset Password + + + + + Your password has been reset successfully, you can now log in using the new credentials. + + + + + Failed to reset user account password, please contact the server operator to reset your password. + + + + + Activation request received, please check your email for an activation token. + + + + + Connecting to %1... + + + + + Registering to %1 as %2... + + + + + Disconnected + + + + + Connected, logging in at %1 + + + + + Requesting forgotten password to %1 as %2... + + + + + Your username must respect these rules: + + + + + is %1 - %2 characters long + + + + + can %1 contain lowercase characters + + + + + + + + NOT + + + + + can %1 contain uppercase characters + + + + + can %1 contain numeric characters + + + + + can contain the following punctuation: %1 + + + + + first character can %1 be a punctuation mark + + + + + no unacceptable language as specified by these server rules: + note that the following lines will not be translated + + + + + can not contain any of the following words: %1 + + + + + can not match any of the following expressions: %1 + + + + + You may only use A-Z, a-z, 0-9, _, ., and - in your username. + + + CustomZoneMenu - + C&ustom Zones - - + + View custom zone '%1' @@ -974,30 +1516,35 @@ Isto apenas é guardado para os moderadores e não é visível para a pessoa ban DeckAnalyticsWidget - + Add Panel - + Remove Panel - + Save Layout - + Load Layout + + + Include Sideboard + + DeckEditorCardDatabaseDockWidget - + Card Database @@ -1013,47 +1560,17 @@ Isto apenas é guardado para os moderadores e não é visível para a pessoa ban DeckEditorDatabaseDisplayWidget - + Search by card name (or search expressions) - - Add to Deck - - - - - Add to Sideboard - - - - - Select Printing - - - - - Show on EDHRec (Commander) - - - - - Show on EDHRec (Card) - - - - - Show Related cards - - - - + Add card to &maindeck - + Add card to &sideboard @@ -1086,72 +1603,72 @@ Isto apenas é guardado para os moderadores e não é visível para a pessoa ban - + Select Printing - + Deck - + Deck &name: - + Banner Card/Tags Visibility Settings - + Show banner card selection menu - + Show tags selection menu - + &Comments: - + Group by: - + Format: - + Hash: - + &Increment number - + &Decrement number - + &Remove row - + Swap card to/from sideboard @@ -1159,17 +1676,17 @@ Isto apenas é guardado para os moderadores e não é visível para a pessoa ban DeckEditorFilterDockWidget - + Filters - + &Clear all filters - + Delete selected @@ -1300,169 +1817,113 @@ Isto apenas é guardado para os moderadores e não é visível para a pessoa ban DeckEditorSettingsPage - - + + Update Spoilers - - + Success - + Download URLs have been reset. - - Downloaded card pictures have been reset. - - - - - Error - - - - - One or more downloaded card pictures could not be cleared. - - - - + Add URL - - + + URL: - - + + Edit URL - - Network Cache Size: - - - - - Redirect Cache TTL: - - - - - How long cached redirects for urls are valid for. - - - - - Picture Cache Size: - - - - + Add New URL - + Remove URL - - Day(s) - - - - + Updating... - + Choose path - + URL Download Priority - + Spoilers - + Download Spoilers Automatically - + Spoiler Location: - + Last Change - + Spoilers download automatically on launch - + Press the button to manually update without relaunching - + Do not close settings until manual update is complete - + Download card pictures on the fly - + How to add a custom URL - - Delete Downloaded Images - - - - + Reset Download URLs - - - On-disk cache for downloaded pictures - - - - - In-memory cache for pictures not currently on screen - - DeckListHistoryManagerWidget @@ -1528,12 +1989,12 @@ Isto apenas é guardado para os moderadores e não é visível para a pessoa ban DeckLoader - + Common deck formats (%1) - + All files (*.*) @@ -1755,27 +2216,27 @@ Isto apenas é guardado para os moderadores e não é visível para a pessoa ban - + Moved to %1 1 × "%2" (%3) - + Removed "%1" (all copies) - + %1 1 × "%2" (%3) - + Added - + Removed @@ -1797,74 +2258,74 @@ Isto apenas é guardado para os moderadores e não é visível para a pessoa ban DeckViewContainer - + Load deck... &Carregar deck... - + Load remote deck... Carregar deck remoto... - + Load from clipboard... - + Load from website... - + Unload deck - + Ready to start - + Force start - + Sideboard unlocked - + Sideboard locked - - + + Error Erro - + The selected file could not be loaded. O ficheiro seleccionado não pôde ser carregado. - + Deck is greater than maximum file size. - + Are you sure you want to force start? This will kick all non-ready players from the game. - + Cockatrice @@ -1963,7 +2424,7 @@ This will kick all non-ready players from the game. - + Webpage @@ -2003,37 +2464,37 @@ This will kick all non-ready players from the game. - + Server URL - + Communication Port - + Unique Server Name - + Connection Warning - + You need to name your new connection profile. - + Connect Warning Aviso de conexão - + The player name can't be empty. O nome de utilizador tem de ser inserido. @@ -2146,17 +2607,17 @@ This will kick all non-ready players from the game. Criar jogo - + Game information Informação do jogo - + Error Erro - + Server error. Erro do servidor. @@ -2164,97 +2625,97 @@ This will kick all non-ready players from the game. DlgCreateToken - + &Name: &Nome: - + Token Ficha - + C&olor: C&or: - + white branco - + blue azul - + black preto - + red vermelho - + green verde - + multicolor multicolor - + colorless incolor - + &P/T: &P/R: - + &Annotation: &Nota: - + &Destroy token when it leaves the table &Destruir ficha quando ela deixar a mesa - + Create face-down (Only hides name) - + Token data Informação de ficha - + Show &all tokens Mostrar &todas as fichas - + Show tokens from this &deck Mostrar &fichas deste deck - + Choose token from list Escolha ficha da lista - + Create token Criar ficha @@ -2288,7 +2749,7 @@ This will kick all non-ready players from the game. - + @@ -2303,12 +2764,12 @@ This will kick all non-ready players from the game. - + Duplicate Tag - + This tag already exists. @@ -2495,12 +2956,12 @@ Para remover o seu avatar actual, confirme SEM escolher uma nova imagem.Por favor introduza o nome da ficha: - + Error Erro - + The chosen name conflicts with an existing card or token. Make sure to enable the 'Token' set in the "Manage sets" dialog to display them correctly. @@ -2524,12 +2985,12 @@ Make sure to enable the 'Token' set in the "Manage sets" dia Indefinido - + Real name: Nome real: - + Edit user profile Fazer alterações no seu perfil: @@ -2567,113 +3028,113 @@ Make sure to enable the 'Token' set in the "Manage sets" dia - + Hide 'buddies only' games - + Hide full games - + Hide games that have started - + Hide password protected games - + Hide 'ignored user' games - + Hide games not created by buddies Hide games not created by buddy - + Hide games with forced open decklists - + &Newer than: - + Game &description: &Descrição de jogo: - + &Creator name: &Nome do criador: - + General - + &Game types &Tipo de jogo - + at &least: no &mínimo: - + at &most: no m&áximo: - + Maximum player count Limite máximo de jogadores - + Restrictions Restrições - + Show games only if &spectators can watch - + Show spectator password p&rotected games - + Show only if spectators can ch&at - + Show only if spectators can see &hands - + Spectators - + Filter games Filtrar jogos @@ -2976,37 +3437,37 @@ https://tappedout.net/mtg-decks/your-deck-name/ DlgMoveTopCardsUntil - + Card name (or search expressions): - + Number of hits: - + Auto play hits - + Put top cards on stack until... - + No cards matching the search expression exists in the card database. Proceed anyways? - + Cockatrice - + Invalid filter @@ -3065,40 +3526,40 @@ Your email will be used to verify your account. Indefinido - + Real name: Nome real: - + Register to server Registe-se no server - - - - + + + + Registration Warning Registo recusado - + Your password is too short. - + Your passwords do not match, please try again. As passwords que digitou não coincidem. - + Your email addresses do not match, please try again. Os emails que digitou não coincidem. - + The player name can't be empty. Tem de escolher um nome de utilizador. @@ -3106,17 +3567,17 @@ Your email will be used to verify your account. DlgRollDice - + Number of sides: - + Number of dice: - + Roll Dice @@ -3167,12 +3628,12 @@ Your email will be used to verify your account. DlgSettings - + Unknown Error loading card database Erro desconhecido ao carregar a base de dados das cartas - + Your card database is invalid. Cockatrice may not function correctly with an invalid database @@ -3189,7 +3650,7 @@ Poderá necessitar de correr o Oracle para actualizar a base de dados. Gostaria de alterar a localização da base de dados? - + Your card database version is too old. This can cause problems loading card information or images @@ -3206,7 +3667,7 @@ Pode ser reparado correndo o Oracle de novo para actualizar a base de dados das Gostaria de alterar a localização da base de dados? - + Your card database did not finish loading Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues with your cards.xml attached @@ -3215,7 +3676,7 @@ Would you like to change your database location setting? - + File Error loading your card database. Would you like to change your database location setting? @@ -3224,7 +3685,7 @@ Would you like to change your database location setting? Gostaria de alterar a localização da base de dados? - + Your card database was loaded but contains no cards. Would you like to change your database location setting? @@ -3233,7 +3694,7 @@ Would you like to change your database location setting? Gostaria de alterar a localização da base de dados? - + Unknown card database load status Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues @@ -3242,59 +3703,64 @@ Would you like to change your database location setting? - - - + + + Error Erro - + The path to your deck directory is invalid. Would you like to go back and set the correct path? O directório do seu deck é inválido. Gostaria de voltar atrás e corrigir o directório? - + The path to your card pictures directory is invalid. Would you like to go back and set the correct path? O directório das imagens das cartas é inválido. Gostaria de voltar atrás e corrigir o directório? - + Settings Definições - + General Geral - + Appearance Aparência - + User Interface Interface do utilizador - + + Storage + + + + Card Sources - + Chat Chat - + Sound Som - + Shortcuts Atalhos @@ -3386,9 +3852,9 @@ You can always change this behavior in the 'General' settings tab. - - - + + + Error Erro @@ -3440,31 +3906,31 @@ Please visit the download page to update manually. - + Update Available - + A new version of Cockatrice is available! - + New version - + Released - + Changelog @@ -3474,50 +3940,50 @@ Please visit the download page to update manually. - + Unfortunately, the automatic updater failed to find a compatible download. You may have to manually download the new version. - + Please check the <a href="%1">releases page</a> on our Github and download the build for your system. - - - + + + Update Error Erro na actualização - + An error occurred while checking for updates: - + An error occurred while downloading an update: - + Installing... A instalar... - + Cockatrice is unable to open the installer. - + Try to update manually by closing Cockatrice and running the installer. - + Download location @@ -3607,67 +4073,67 @@ You may have to manually download the new version. DrawProbabilityWidget - + Draw Probability - + Probability of drawing - + Card Name - + Type - + Subtype - + Mana Value - + At least - + Exactly - + card(s) having drawn at least - + cards - + Category - + Qty - + Odds (%) @@ -3781,7 +4247,7 @@ You may have to manually download the new version. FilterBuilder - + Type your filter here @@ -3812,22 +4278,22 @@ You may have to manually download the new version. GameEventHandler - + kicked by game host or moderator - + player left the game - + player disconnected from server - + reason unknown @@ -3835,140 +4301,140 @@ You may have to manually download the new version. GameSelector - - - - - - - - - + + + + + + + + + Error Erro - + Please join the appropriate room first. Por favor entre na sala apropriada primeiro. - + Wrong password. Password incorrecta. - + Spectators are not allowed in this game. Não são permitidos espectadores neste jogo. - + The game is already full. O jogo já se encontra cheio. - + The game does not exist any more. O jogo já não existe. - + This game is only open to registered users. Este jogo só está aberto a utilizadores registados. - + This game is only open to its creator's buddies. Este jogo só está aberto aos amigos do seu criador. - + You are being ignored by the creator of this game. Você está a ser ignorado pelo criador deste jogo. - + Join Game - + Spectate Game - + Game Information - + Join Game as Judge - + Spectate Game as Judge - + Join game Entrar no jogo - + Password: Password: - + Please join the respective room first. Por favor entre na sala respectiva primeiro. - + &Filter games &Filtrar jogos - + C&lear filter &Limpar filtros - + C&reate C&riar - + &Join &Entrar - + Join as judge - + J&oin as spectator Entrar como &espectador - + Join as judge spectator - + Games shown: %1 / %2 - + Games Jogos @@ -3976,32 +4442,32 @@ You may have to manually download the new version. GameSelectorQuickFilterToolBar - + All types - + Filter by game name... - + Filter by game type/format - + Hide games not created by buddies - + Hide full games - + Hide started games @@ -4009,105 +4475,105 @@ You may have to manually download the new version. GamesModel - + >1 day - + %1%2 hr short age in hours - + new - + %1%2 min short age in minutes - + password senha - + buddies only amigos apenas - + reg. users only utilizadores registados apenas - + open decklists - + can chat pode falar - + see hands ver mãos - + can see hands pode ver as mãos - + not allowed não permitidos - + Room Sala - + Age Idade - + Description Descrição - + Creator Criador - + Type Tipo - + Restrictions Restrições - + Players Jogadores - + Spectators Espectadores @@ -4115,143 +4581,158 @@ You may have to manually download the new version. GeneralSettingsPage - + Reset all paths - + All paths have been reset - - - - - - - + + + + + + + Choose path Escolher directório - - Personal settings - Defenições pessoais + + Language settings + - + Language: Língua: - + + Version settings + + + + + Card database + + + + + Startup settings + + + + Paths (editing disabled in portable mode) Diretórios (edição desativada em modo portátil) - + Paths Directórios - + How to help with translations - + Decks directory: Directório dos decks: - + Filters directory: - + Replays directory: Directório de replays: - + Pictures directory: Directório das imagens: - + Card database: Base de dados das cartas: - + Custom database directory: - + Token database: Base de dados das fichas: - + Update channel - + Check for client updates on startup - + Check for card database updates on startup - + Don't check - + Prompt for update - + Always update in the background - + Check for card database updates every - + days - + Notify if a feature supported by the server is missing in my client - + Automatically run Oracle when running a new version of Cockatrice - + Show tips on startup - + Last update check on %1 (%2 days ago) @@ -4259,47 +4740,47 @@ You may have to manually download the new version. GraveyardMenu - + &Graveyard - + &View graveyard - + &Move graveyard to... - + &Top of library - + &Bottom of library - + &All players - + &Hand - + &Exile - + Reveal random card to... @@ -4307,88 +4788,88 @@ You may have to manually download the new version. HandMenu - + &Hand - + &View hand - + Sort hand by... - + Name - + Type - + Mana Value - + Take &mulligan (Choose hand size) - + Take mulligan (Same hand size) - + Take mulligan (Hand size - 1) - + &Move hand to... - + &Top of library - + &Bottom of library - + &Graveyard - + &Exile - + &Reveal hand to... - - + + All players - + Reveal r&andom card to... @@ -4396,52 +4877,52 @@ You may have to manually download the new version. HomeWidget - + Create New Deck - + Browse Decks - + Browse Card Database - + Browse EDHRec - + Browse Archidekt - + View Replays - + Quit - + Connecting... - + Connect - + Play @@ -4449,213 +4930,213 @@ You may have to manually download the new version. LibraryMenu - + &Library - + &View library - + View &top cards of library... - + View bottom cards of library... - + Reveal &library to... - + Lend library to... - + Reveal &top cards to... - + &Top of library... - + &Bottom of library... - + &Always reveal top card - + &Always look at top card - + &Open deck in deck editor - + &Draw card - + D&raw cards... - + &Undo last draw - + Shuffle - + &Play top card - + Play top card &face down - + Put top card on &bottom - + Move top card to grave&yard - + Move top card to e&xile - + Move top cards to &graveyard... - + Move top cards to graveyard face down... - + Move top cards to &exile... - + Move top cards to exile face down... - + Put top cards on stack &until... - + Shuffle top cards... - + &Draw bottom card - + D&raw bottom cards... - + &Play bottom card - + Play bottom card &face down - + Move bottom card to grave&yard - + Move bottom card to e&xile - + Move bottom cards to &graveyard... - + Move bottom cards to graveyard face down... - + Move bottom cards to &exile... - + Move bottom cards to exile face down... - + Put bottom card on &top - + Shuffle bottom cards... - - + + &All players - + Reveal top cards of library - + Number of cards: (max. %1) @@ -4663,636 +5144,271 @@ You may have to manually download the new version. MainWindow - - - The server has reached its maximum user capacity, please check back later. - O servidor atingiu a máxima capacidade de utilizadores, por favor verifique de novo mais tarde. - - - - There are too many concurrent connections from your address. - Há demasiadas ligações concorrentes do seu endereço. - - - - Banned by moderator - Banido por moderador - - - - Expected end time: %1 - Tempo previsto para o final:%1 - - - - This ban lasts indefinitely. - Este banimento dura indefinidamente. - - - - Scheduled server shutdown. - Encerramento do servidor agendado. - - - - - Invalid username. - Nome de utilizador invalido. - - - - You have been logged out due to logging in at another location. - Você deslogado por ter entrado numa outra localização. - - - - Connection closed - Ligação terminada - - - - The server has terminated your connection. -Reason: %1 - O servidor terminou a sua ligação. -Motivo: %1 - - - - The server is going to be restarted in %n minute(s). -All running games will be lost. -Reason for shutdown: %1 - O servidor vai ser reiniciado em %n minuto(s). -Todos os jogos a decorrer serão perdidos. -Motivo para o encerramento: %1O servidor vai ser reiniciado em %n minuto(s). -Todos os jogos a decorrer serão perdidos. -Motivo para o encerramento: %1O servidor vai ser reiniciado em %n minuto(s). -Todos os jogos a decorrer serão perdidos. -Motivo para o encerramento: %1 - - - - Scheduled server shutdown - Encerramento do servidor agendado - - - - - Success - Sucesso - - - - Registration accepted. -Will now login. - Registo aceite.%s -Entrará agora. - - - - Account activation accepted. -Will now login. - Activação da conta aceite. %s -Entrará agora. - - - - + + Player %1 Jogador %1 - + Load replay Carregar replay - + About Cockatrice Sobre o Cockatrice - + Version - + Cockatrice Webpage Website Cockatrice - + Project Manager: Gestor do projecto: - + Past Project Managers: Anteriores gestores do projecto: - + Developers: Desenvolvedores: - + Our Developers Os nossos desenvolvedores - + Help Develop! Ajuda Desenvolvimento! - + Translators: Tradutores: - + Our Translators - + Help Translate! Ajude a traduzir! - + Support: Ajuda técnica: - + Report an Issue Reportar um Problema - + Troubleshooting Solução de problemas - + F.A.Q. Perguntas frequentes - - - - - - - - - - - - - - - - - - - - + + Error Erro - - Server timeout - Tempo do servidor esgotado - - - - Failed Login - Tentativa falhada de entrada - - - - Your client seems to be missing features this server requires for connection. - - - - - To update your client, go to 'Help -> Check for Client Updates'. - - - - - Incorrect username or password. Please check your authentication information and try again. - Nome de utilizador ou palavra-passe incorrecta. Por favor verifique as suas informações e tente de novo. - - - - There is already an active session using this user name. -Please close that session first and re-login. - Já existe uma sessão activa com este nome de utilizador. -Por favor termine essa sessão e volte a ligar-se. - - - - - You are banned until %1. - Está banido até %1. - - - - - You are banned indefinitely. - Está banido indefinidamente. - - - - This server requires user registration. Do you want to register now? - Este servidor requer registo de utilizador. Gostaria de se registar agora? - - - - This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. -Please close and reopen your client to try again. - - - - - An internal error has occurred, please close and reopen Cockatrice before trying again. -If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. - - - - - Account activation - Activação de conta - - - - Your account has not been activated yet. -You need to provide the activation token received in the activation email. - - - - - Server Full - - - - - Unknown login error: %1 - Erro de login desconhecido:%1 - - - - - -This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. - -Normalmente isto significa que a sua versão de cliente está desactualizada e que o servidor enviou uma resposta que o seu cliente não percebe. - - - - Your username must respect these rules: - O seu nome de utilizador deve respeitar as seguintes regras: - - - - is %1 - %2 characters long - tem %1 - %2 caracteres - - - - can %1 contain lowercase characters - %1 pode conter caracteres em minúsculas - - - - - - - NOT - NÃO - - - - can %1 contain uppercase characters - %1 pode conter caracteres em maiúsculas - - - - can %1 contain numeric characters - %1 pode conter caracteres numéricos - - - - can contain the following punctuation: %1 - pode conter a seguinte pontuação: %1 - - - - first character can %1 be a punctuation mark - o primeiro caracter %1 pode ser pontuação - - - - no unacceptable language as specified by these server rules: - note that the following lines will not be translated - - - - - can not contain any of the following words: %1 - - - - - can not match any of the following expressions: %1 - - - - - You may only use A-Z, a-z, 0-9, _, ., and - in your username. - Apenas A-Z, a-z, 0-9, _, ., e - são permitidos. - - - - - - - - - Registration denied - Registo recusado - - - - Registration is currently disabled on this server - Os registos encontram-se indisponívels para este servidor de momento. - - - - There is already an existing account with the same user name. - Já existe uma conta com o mesmo nome de utilizador. - - - - It's mandatory to specify a valid email address when registering. - É obrigatório especificar um endereço de e-mail válido quando se regista. - - - - It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. - - - - - Password too short. - A password que inseriu é demasiado curta. - - - - Registration failed for a technical problem on the server. - Falha no registo devido a um problema técnico com o servidor - - - - The connection to the server has been lost. - - - - - Unknown registration error: %1 - Erro de registo desconhecido: %1 - - - - Account activation failed - Falha na activação de conta - - - - Socket error: %1 - Erro de ligação:%1 - - - - You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. -Local version is %1, remote version is %2. - Está a tentar ligar-se a um servidor obsoleto. Por favor faça downgrade à sua versão do Cockatrice ou ligue-se a servidor adequado. -Versão local é %1, versão remota é %2. - - - - Your Cockatrice client is obsolete. Please update your Cockatrice version. -Local version is %1, remote version is %2. - A sua versão do Cockatrice é obsoleta. Por favor actualize-a. -Versão local é %1, versão remota é %2. - - - - Connecting to %1... - Ligando a %1... - - - - Registering to %1 as %2... - Registando em %1 como %2... - - - - Disconnected - Desligado - - - - Connected, logging in at %1 - Conectado, logando em %1 - - - - - - Requesting forgotten password to %1 as %2... - - - - + &Connect... &Ligar... - + &Disconnect &Desligar - + Start &local game... Começar &jogo local... - + &Watch replay... &Ver replay... - + &Full screen Ecrã &inteiro - + &Register to server... &A registar no servidor... - + &Restore password... - + &Settings... &Configurações... - + &Exit &Sair - + A&ctions A&cções - + &Cockatrice &Cockatrice - + C&ard Database B&ase de dados de cartas - + &Manage sets... - + Edit custom &tokens... - + Open custom image folder Abrir a pasta de imagens personalizadas - + Open custom sets folder Abrir a pasta de expansões personalizadas - + Add custom sets/cards Adicione cartas/expansões personalizadas - + Reload card database - + Tabs - + &Help &Ajuda - + &About Cockatrice S&obre o Cockatrice - + &Tip of the Day - + Check for Client Updates - + Check for Card Updates... - + Check for Card Updates (Automatic) - + Show Status Bar - + View &Debug Log - + Open Settings Folder - + Show/Hide - + New Version - + Congratulations on updating to Cockatrice %1! Oracle will now launch to update your card database. - + Cockatrice installed - + Congratulations on installing Cockatrice %1! Oracle will now launch to install the initial card database. - + Card database Base de dados de cartas - + Cockatrice is unable to load the card database. Do you want to update your card database now? If unsure or first time user, choose "Yes" @@ -5301,209 +5417,183 @@ Deseja actualizar a sua base de dados agora?%s Se não tem a certeza ou é a primeira vez que está a utilizar o Cockatrice, escolha "Sim" - - + + Yes Sim - - + + No Não - + Open settings Abrir definições - + New sets found Novas expansões foram encontradas - + %n new set(s) found in the card database Set code(s): %1 Do you want to enable it/them? - + + Yes, always enable + + + + View sets Ver expansões - + Welcome Bem-vindo/a - + Hi! It seems like you're running this version of Cockatrice for the first time. All the sets in the card database have been enabled. Read more about changing the set order or disabling specific sets and consequent effects in the "Manage Sets" dialog. - - + Information Informação - + A card database update is already running. A actualização da base de dados das cartas já está a correr. - + Unable to run the card database updater: Incapaz de correr o actualizador da base de dados das cartas: - + Card database update running. - + Failed to start. The file might be missing, or permissions might be incorrect. - + The process crashed some time after starting successfully. - + Timed out. The process took too long to respond. The last waitFor...() function timed out. - + An error occurred when attempting to write to the process. For example, the process may not be running, or it may have closed its input channel. - + An error occurred when attempting to read from the process. For example, the process may not be running. - + Unknown error occurred. - + The card database updater exited with an error: %1 - - This server supports additional features that your client doesn't have. -This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. - -To update your client, go to Help -> Check for Updates. - - - - - - - - + + + + + Load sets/cards Carregar expansões/cartas - + Selected file cannot be found. O ficheiro escolhido não foi encontrado. - + You can only import XML databases at this time. - + The new sets/cards have been added successfully. Cockatrice will now reload the card database. Actualização completa com sucesso. %s O Cockatrice irá agora recarregar a base de dados das cartas. - + Sets/cards failed to import. Falha na importação de expansões/cartas. - - - - - Reset Password - - - - - Your password has been reset successfully, you can now log in using the new credentials. - - - - - Failed to reset user account password, please contact the server operator to reset your password. - - - - - Activation request received, please check your email for an activation token. - - ManaBaseConfigDialog - + Mana Base Configuration - + Display type: - + pie - + bar - + combinedBar - + Filter Colors (optional): - + OK - + Cancel @@ -5575,27 +5665,27 @@ O Cockatrice irá agora recarregar a base de dados das cartas. ManaDevotionConfigDialog - + Display type: - + pie - + bar - + combinedBar - + Filter Colors (optional): @@ -5665,610 +5755,615 @@ O Cockatrice irá agora recarregar a base de dados das cartas. MessageLogWidget - + from play do jogo - + from their graveyard do seu cemitério - + from exile vindo do exílio - + from their hand da sua mão - + the top card of %1's library a carta do topo do grimório de %1 - + the top card of their library a carta do topo dos seus grimórios - + from the top of %1's library do topo do grimório de %1 - + from the top of their library do topo dos seus grimórios - + the bottom card of %1's library a carta do fundo do grimório de %1 - + the bottom card of their library a carta do fundo dos seus grimórios - + from the bottom of %1's library do fundo do grimório de %1 - + from the bottom of their library do fundo dos seus grimórios - + from %1's library do grimório de %1 - + from their library dos seus grimórios - + from sideboard do sideboard - + from the stack da pilha - + from custom zone '%1' - + %1 is now keeping the top card %2 revealed. %1 está agora a manter a carta do topo %2 revelada. - + %1 is not revealing the top card %2 any longer. %1 já não está a manter a carta do topo %2 revelada. - + %1 can now look at top card %2 at any time. - + %1 no longer can look at top card %2 at any time. - + %1 attaches %2 to %3's %4. %1 anexa %2 a %4 de %3. - + %1 has conceded the game. %1 concedeu o jogo. - + %1 has unconceded the game. - + %1 has restored connection to the game. %1 restabeleceu a ligação ao jogo. - + %1 has lost connection to the game. %1 perdeu a ligação ao jogo. - + %1 points from their %2 to themselves. %1 aponta do seu %2 para si próprio. - + %1 points from their %2 to %3. %1 aponta do seu %2 para %3. - + %1 points from %2's %3 to themselves. %1 aponta do %3 de %2 para si própria. - + %1 points from %2's %3 to %4. %1 aponta de %3 de %2 para %4. - + %1 points from their %2 to their %3. %1 aponta do seu %2 para %3. - + %1 points from their %2 to %3's %4. %1 aponta do seu %2 para o %4 de %3. - + %1 points from %2's %3 to their own %4. %1 aponta de %3 de %2 para o seu %4. - + %1 points from %2's %3 to %4's %5. %1 aponta de %3 de %2 para %5 de %4. - + %1 creates a face down token. - + %1 creates token: %2%3. %1 cria ficha: %2%3. - + %1 has loaded a deck (%2). %1 carregou um deck (%2). - + %1 has loaded a deck with %2 sideboard cards (%3). %1 carregou um deck com %2 cartas na sideboard (%3). - + %1 destroys %2. %1 destrói %2. - + a card uma carta - + %1 gives %2 control over %3. %1 dá controlo sobre %3 a %2. - + %1 puts %2 into play%3 face down. - + %1 puts %2 into play%3. %1 coloca %2 em jogo %3. - + %1 puts %2%3 into their graveyard face down. - + %1 puts %2%3 into their graveyard. %1 coloca %2%3 no seu grimório. - + %1 exiles %2%3 face down. - + %1 exiles %2%3. %1 exila %2%3. - + %1 moves %2%3 to their hand. %1 move %2%3 para a sua mão. - + %1 puts %2%3 into their library. %1 coloca %2%3 no seu grimório. - + %1 puts %2%3 onto the bottom of their library. - + %1 puts %2%3 on top of their library. %1 coloca %2%3 no topo do seu grimório. - + %1 puts %2%3 into their library %4 cards from the top. %1 põe %2%3 no seu grimório, %4 cartas a contar do topo. - + %1 moves %2%3 to sideboard. %1 move %2%3 para o sideboard. - + %1 plays %2%3 face down. - + %1 plays %2%3. %1 joga %2%3. - + %1 moves %2%3 to custom zone '%4' face down. - + %1 moves %2%3 to custom zone '%4'. - + %1 tries to draw from an empty library - + %1 draws %2 card(s). - + %1 is looking at %2. %1 está a olhar para %2. - + %1 is looking at the %4 %3 card(s) %2. top card for singular, top %3 cards for plural - + bottom - + top - + %1 turns %2 face-down. - + %1 turns %2 face-up. - + The game has been closed. Este jogo foi encerrado. - + The game has started. O jogo começou. - + You are flooding the game. Please wait a couple of seconds. - + %1 has joined the game. %1 entrou no jogo. - + %1 is now watching the game. %1 está agora a ver o jogo. - + You have been kicked out of the game. Você foi expulso do jogo. - + %1 has left the game (%2). %1 abandonou o jogo (%2). - + %1 is not watching the game any more (%2). %1 não está mais a observar o jogo (%2). - + %1 is not ready to start the game any more. %1 já não está pronto a começar o jogo. - + %1 shuffles their deck and draws a new hand of %2 card(s). - + %1 shuffles their deck and draws a new hand. - + You are watching a replay of game #%1. Está a ver um replay do jogo #%1. - + %1 is ready to start the game. %1 está pronto a começar o jogo. - + cards an unknown amount of cards - + %1 card(s) a card for singular, %1 cards for plural - + %1 lends %2 to %3. - + %1 reveals %2 to %3. %1 revela %2 a %3. - + %1 reveals %2. %1 revela %2. - + %1 randomly reveals %2%3 to %4. %1 revela aleatoreamente %2%3. a %4. - + %1 randomly reveals %2%3. %1 revela aleatoreamente %2%3. - + %1 peeks at face down card #%2. %1 espreita a carta virada para baixo #%2. - + %1 peeks at face down card #%2: %3. %1 espreita a carta virada para baixo #%2: %3. - + %1 reveals %2%3 to %4. %1 revela %2%3 a %4. - + %1 reveals %2%3. %1 revela %2%3. - + %1 reversed turn order, now it's %2. - + reversed - + normal - + Heads Cara - + Tails Coroa - + %1 flipped a coin. It landed as %2. %1 atirou uma moeda ao ar. Calhou %2. - + %1 rolls a %2 with a %3-sided die. %1 obteve %2 com um dado de %3 faces. - + %1 flips %2 coins. There are %3 heads and %4 tails. - + %1 rolls a %2-sided dice %3 times: %4. - + %1's turn. Turno de %1. - + %1 sets annotation of %2 to %3. %1 coloca uma nota de %2 em%3. - - %1 places %2 "%3" counter(s) on %4 (now %5). + + %1 places %2 %3%4 counter(s) on %5 (now %6). - - %1 removes %2 "%3" counter(s) from %4 (now %5). + + %1 removes %2 %3%4 counter(s) from %5 (now %6). - + + %1 failed to undo their last draw. + + + + %1 sets counter %2 to %3 (%4%5). %1 altera o número de marcadores %2 para %3(%4%5). - + %1 sets %2 to not untap normally. %1 define %2 para não desvirar normalmente. - + %1 sets %2 to untap normally. %1 define %2 para desvirar normalmente. - + %1 removes the PT of %2. - + %1 changes the PT of %2 from nothing to %4. - + %1 changes the PT of %2 from %3 to %4. - + %1 has locked their sideboard. %1 bloqueou o seu sideboard. - + %1 has unlocked their sideboard. %1 desbloqueou o seu sideboard. - + %1 taps their permanents. %1 vira as suas permanentes. - + %1 untaps their permanents. %1 desvira as suas permanentes. - + %1 taps %2. %1 vira %2. - + %1 untaps %2. %1 desvira %2. - + %1 shuffles %2. %1 baralha %2. - + %1 shuffles the bottom %3 cards of %2. - + %1 shuffles the top %3 cards of %2. - + %1 shuffles cards %3 - %4 of %2. - + %1 unattaches %2. %1 desanexa %2. - + %1 undoes their last draw. %1 desfaz a sua última compra. - + %1 undoes their last draw (%2). %1 desfaz a sua última compra (%2). @@ -6276,110 +6371,115 @@ O Cockatrice irá agora recarregar a base de dados das cartas. MessagesSettingsPage - + Word1 Word2 Word3 - + Add New Message - + Edit Message - + Remove Message - + Add message Adicionar mensagem - - + + Message: Mensagem: - + Edit message - + Chat settings Definições do Chat - + Custom alert words Palavras alerta personalizáveis - + Enable chat mentions Permitir menções no chat - + Enable mention completer Permitir complementação de menções - + In-game message macros Macros de mensagem no decorrer do jogo - + How to use in-game message macros - + Ignore chat room messages sent by unregistered users Ignorar mensagens de chat enviadas por utilizadores não registados - + Ignore private messages sent by unregistered users Ignorar mensagens privadas enviadas por utilizadores não registados - - + + Ignore private messages sent by non-buddy users + + + + + Invert text color Inverter cor do texto - + Enable desktop notifications for private messages Permitir notificações de mensagens privadas no Ambiente de Trabalho - + Enable desktop notification for mentions Permitir notificações de menções no ambiente de trabalho - + Enable room message history on join Permitir o histórico de mensagens de uma sala ao entrar - - + + (Color is hexadecimal) (A cor é hexadecimal) - + Separate words with a space, alphanumeric characters only Palavras separadas com espaço, apenas caracteres alfanuméricos @@ -6387,42 +6487,42 @@ O Cockatrice irá agora recarregar a base de dados das cartas. MoveMenu - + Move to - + &Top of library in random order - + X cards from the top of library... - + &Bottom of library in random order - + T&able - + &Hand - + &Graveyard - + &Exile @@ -6435,47 +6535,47 @@ O Cockatrice irá agora recarregar a base de dados das cartas. - + Mana Value - + Color(s) - + Loyalty - + Main Card Type - + Mana Cost - + P/T - + Side - + Layout - + Color Identity @@ -6498,6 +6598,152 @@ O Cockatrice irá agora recarregar a base de dados das cartas. + + PaletteEditorDialog + + + + Reset + + + + + + Apply + + + + + + Save && Apply + + + + + ▼ Edit Palette + + + + + + ▶ Edit Palette + + + + + Palette Editor — %1 + + + + + <b>Palette Editor</b> &nbsp;·&nbsp; %1 + + + + + This theme ships no default palette files + + + + + Replace current colours with the theme author's defaults + + + + + Switch between the light and dark palette files + + + + + Editing: + + + + + Show or hide the per-role colour grid for manual tweaks + + + + + ↺ Revert to theme default + + + + + Discard unsaved edits and restore the last saved palette + + + + + Preview this palette without saving to disk + + + + + Write palette-%1.toml and reload the theme + + + + + Cannot save: this theme has no directory on disk + + + + + Save failed + + + + + Could not write %1 to: +%2 + + + + + No default found + + + + + No default palette file found for the "%1" scheme. + + + + + PaletteGridWidget + + + Active + + + + + Disabled + + + + + Inactive + + + + + Normal interactive state + + + + + Widget is disabled / not interactive + + + + + Window is in background / unfocused + + + Phase @@ -6564,57 +6810,57 @@ O Cockatrice irá agora recarregar a base de dados das cartas. PhasesToolbar - + Untap step Etapa de Desvirar - + Upkeep step Etapa de manutenção - + Draw step Etapa de compra - + First main phase 1ª Fase Principal (pré-combate) - + Beginning of combat step Etapa de Início de Combate - + Declare attackers step Etapa de Declaração de Atacantes - + Declare blockers step Etapa de Declaração de Bloqueadores - + Combat damage step Etapa de Dano de Combate - + End of combat step Etapa de Fim de Combate - + Second main phase 2ª Fase Principal (pós-combate) - + End of turn step Fase Final @@ -6622,7 +6868,7 @@ O Cockatrice irá agora recarregar a base de dados das cartas. PictureLoader - + en code for scryfall's language property, not available for all languages pt @@ -6631,151 +6877,142 @@ O Cockatrice irá agora recarregar a base de dados das cartas. PlayerActions - - View top cards of library - - - - - - - - - - - - - Number of cards: (max. %1) - - - - - View bottom cards of library - - - - - Shuffle top cards of library - - - - - Shuffle bottom cards of library - - - - - Draw hand - - - - - 0 and lower are in comparison to current hand size - - - - - Draw cards - - - - - - - + + + + grave - - - - + + + + exile + + + PlayerDialogs - + + View top cards of library + + + + + + + + + + + + + Number of cards: (max. %1) + + + + + View bottom cards of library + + + + + Shuffle top cards of library + + + + + Shuffle bottom cards of library + + + + + Draw hand + + + + + 0 and lower are in comparison to current hand size + + + + + Draw cards + + + + Move top cards to %1 - + Move bottom cards to %1 - + Draw bottom cards - - - C&reate another %1 token - - - - + Create tokens - - + Number: - + Place card X cards from top of library - + Which position should this card be placed: - + (max. %1) - + Change power/toughness - + Change stats to: - + Set annotation - + Please enter the new annotation: - - - Set counters - - PlayerMenu - + Player "%1" - + &Counters @@ -6805,7 +7042,7 @@ This setting means you'll only see the default printing for each card, instead o - + Printing Selector @@ -6813,22 +7050,22 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelectorCardOverlayWidget - + Preference - + Pin Printing - + Unpin Printing - + Show Related cards @@ -6899,57 +7136,57 @@ This setting means you'll only see the default printing for each card, instead o PtMenu - + Power / toughness - + &Increase power - + &Decrease power - + I&ncrease toughness - + D&ecrease toughness - + In&crease power and toughness - + Dec&rease power and toughness - + Increase power and decrease toughness - + Decrease power and increase toughness - + Set &power and toughness... - + Reset p&ower and toughness @@ -6957,37 +7194,37 @@ This setting means you'll only see the default printing for each card, instead o QMenuBar - + Services Serviços - + Hide %1 Ocultar %1 - + Hide Others Ocultar outros - + Show All Mostrar tudo - + Preferences... Preferências... - + Quit %1 Sair %1 - + About %1 Cerca %1 @@ -6995,17 +7232,17 @@ This setting means you'll only see the default printing for each card, instead o QObject - + Cockatrice card database (*.xml) Base de dados de cartas Cockatrice (*.xml) - + All files (*.*) Todos os ficheiros (*.*) - + Cockatrice replays (*.cor) Replays do Cockatrice (*.cor) @@ -7065,110 +7302,177 @@ Are you sure you would like to disable this feature? QPlatformTheme - + OK - + Save - + Save All - + Open - + &Yes - + Yes to &All - + &No - + N&o to All - + Abort - + Retry - + Ignore - + Close - + Cancel - + Discard - + Help - + Apply - + Reset - + Restore Defaults + + QuickSetupPanel + + + %1% + + + + + <b>Quick Setup</b> + + + + + Generate all palette roles automatically from a single accent colour + + + + + Accent: + + + + + Primary hue. Used directly for highlights and links. +At high intensity it also tints buttons and backgrounds. + + + + + Intensity: + + + + + Subtle + + + + + Full colour + + + + + 0–30 Subtle tint — only highlights and links change hue +30–70 Accented — buttons, tooltips, and borders join in +70–100 Full colour — backgrounds, everything + + + + + 70% + + + + + Generate ↓ + + + + + Derive all palette roles from the accent colour above. +Fine-tune individual colours in the grid afterwards. + + + RemoteDeckList_TreeModel - + Name Nome - + ID ID - + Upload time Hora de upload @@ -7176,32 +7480,32 @@ Are you sure you would like to disable this feature? RemoteReplayList_TreeModel - + ID ID - + Name Nome - + Players Jogadores - + Keep Keep - + Time started Tempo de começo - + Duration (sec) Duração (seg) @@ -7209,37 +7513,37 @@ Are you sure you would like to disable this feature? RfgMenu - + &Exile - + &View exile - + &Move exile to... - + &Top of library - + &Bottom of library - + &Hand - + &Graveyard @@ -7285,7 +7589,7 @@ Are you sure you would like to disable this feature? SayMenu - + S&ay @@ -7326,27 +7630,27 @@ Are you sure you would like to disable this feature? SetsModel - + Enabled Activado - + Set type Tipo de expansão - + Set code Código da expansão - + Long name Nome longo - + Release date Data de lançamento @@ -7354,53 +7658,53 @@ Are you sure you would like to disable this feature? ShortcutSettingsPage - - + + Restore all default shortcuts - + Do you really want to restore all default shortcuts? - + Clear all default shortcuts - + Do you really want to clear all shortcuts? - + Section: - + Action: - + Shortcut: - + How to set custom shortcuts - + Clear all shortcuts - + Search by shortcut name @@ -7454,12 +7758,12 @@ Please check your shortcut settings! SideboardMenu - + &Sideboard - + &View sideboard @@ -7467,27 +7771,27 @@ Please check your shortcut settings! SoundSettingsPage - + Enable &sounds Permitir &sons - + Current sounds theme: Tema sonoro actual: - + Test system sound engine Teste motor de sistema de som - + Sound settings Definições de Som - + Master volume Volume mestre @@ -7559,16 +7863,146 @@ Please check your shortcut settings! - + No reply received from the tag update server. - + Invalid reply received from the tag update server. + + StorageSettingsPage + + + + + Success + + + + + Cached card pictures have been reset. + + + + + Downloaded card pictures have been reset. + + + + + Error + + + + + One or more downloaded card pictures could not be cleared. + + + + + In-memory (currently loaded) card pictures have been reset. + + + + + Card Picture Loader Caching Method: + + + + + The network cache is the preferred way of storing images. Downloaded images are stored here until the size of the cache exceeds the configured size. Cockatrice automatically monitors this cache and deletes the least recently seen card images to ensure the cache does not exceed the configured size. + + + + + Writing card images directly to a folder on your hard drive is another way of storing images. This does not change how Cockatrice accesses or downloads images. Cockatrice will NOT automatically monitor and clear this folder, so if you enable this option, it is up to you to ensure sufficient available space. It should also be noted that if a provider outage causes you to download the wrong picture (i.e. wrong printing) you will be stuck with it until you manually delete the file, as opposed to using the network cache, which automatically rotates and thus correct errors after a while. + + + + + This is the in-memory picture cache used by the application at runtime. It determines how much memory (RAM) Cockatrice can use before it has to fetch card images from the hard disk again. Increasing this will allow more card images to be displayed at once but shouldn't be necessary. Clearing this will make Cockatrice reload all images from the network cache or the disk. + + + + + Delete Cached Images + + + + + Delete Saved Images + + + + + Clear In-Memory Images + + + + + Card Picture Loader Cache Method + + + + + Network Cache + + + + + Filesystem + + + + + In-Memory Picture Cache + + + + + Network Cache Size: + + + + + On-disk cache for downloaded pictures + + + + + Redirect Cache TTL: + + + + + How long cached redirects for urls are valid for. + + + + + Picture Cache Size: + + + + + In-memory cache for pictures not currently on screen + + + + + Naming scheme: + + + + + Day(s) + + + TabAccount @@ -7704,117 +8138,117 @@ Please check your shortcut settings! TabArchidekt - - + + Desc. - - + + AND - - + + Require ALL selected colors - - + + Deck name... - - + + Owner... - - + + Packages - - + + Advanced Filters - + Bracket: - - + + Any - - + + Contains card... - - + + Commander... - - + + Tag... - - + + Deck Size - + Cards: - - + + Asc. - + Sort by: - + Filter by: - + Display Settings - - + + Search - - + + Formats @@ -7824,6 +8258,54 @@ Please check your shortcut settings! + + TabCardArtRules + + + Card: + + + + + ProviderId: + + + + + Mode: + + + + + Reason: + + + + + Type a card name... + + + + + Add rule + + + + + Remove rule + + + + + Refresh + + + + + Card Art Rules + + + TabDeckEditor @@ -7872,7 +8354,7 @@ Please check your shortcut settings! Repor disposição - + Deck: %1 Deck:%1 @@ -7880,55 +8362,49 @@ Please check your shortcut settings! TabDeckEditorVisual - + Visual Deck: %1 - + &Visual Deck Editor - - + + Card Info - - + + Deck - - - Filters - - - - + &View - + Printing - + Visible - + Floating - + Reset layout @@ -7936,22 +8412,22 @@ Please check your shortcut settings! TabDeckEditorVisualTabWidget - + Visual Deck View - + Visual Database Display - + Deck Analytics - + Sample Hand @@ -7959,134 +8435,134 @@ Please check your shortcut settings! TabDeckStorage - + Local file system Ficheiros locais - + Server deck storage Decks armazenados no servidor - - + + Open in deck editor Abrir no editor de decks - + Rename deck or folder - + Upload deck Upload do deck - + Download deck Download do deck - - - + + + New folder Nova pasta - + Delete Apagar - + Open decks folder - + Rename local folder - + Rename local file - + New name: - - - - + + + + Error - + Rename failed - - + + Invalid deck file - + Enter deck name Introduza o nome do deck - + This decklist does not have a name. Please enter a name: Este deck nao tem um nome Por favor introduza um nome: - + Unnamed deck Deck sem nome - + Failed to upload deck to server - + Delete local file Apagar ficheiro local - + Are you sure you want to delete the selected files? - + Delete remote decks - + Are you sure you want to delete the selected decks? - - + + Name of new folder: Nome da nova pasta: @@ -8158,191 +8634,191 @@ Por favor introduza um nome: TabGame - - - + + + Replay Repetição - - + + Game Jogo - - + + Player List Jogadores - - + + Card Info Informação da carta - - + + Messages Mensagens - - + + Replay Timeline Repetir Cronologia - + &Phases Fa&ses - + &Game &Jogo - + Next &phase Próxima &fase - + Next phase with &action - + Next &turn Próximo &turno - + Reverse turn order - + &Remove all local arrows &Remover todas as setas locais - + Rotate View Cl&ockwise Rodar a vista no se&ntido dos ponteiros - + Rotate View Co&unterclockwise rodar a vista no se&ntido contrário aos ponteiros - + Game &information &Informação do jogo - + Un&concede - - - + + + &Concede &Conceder - + &Leave game Sair do &jogo - + C&lose replay &Fechar replay - + &Focus Chat - + &Say: &Dizer: - + Selected cards - + &View &Vista - + Visible Visível - + Floating Flutuando - + Reset layout Repor disposição - + Concede Conceder - + Are you sure you want to concede this game? Tem a certeza que deseja conceder este jogo? - + Unconcede - + You have already conceded. Do you want to return to this game? - + Leave game Sair do jogo - + Are you sure you want to leave this game? Tem a certeza que deseja sair deste jogo? - + A player has joined game #%1 - + %1 has joined the game - + You have been kicked out of the game. Foi expulso do jogo. @@ -8401,115 +8877,115 @@ Por favor introduza um nome: Deve seleccionar um número válido de dias para localizar. - + Username: Nome de utilizador: - + IP Address: Morada IP: - + Game Name: Nome do Jogo: - + GameID: ID do Jogo - + Message: Mensagem: - + Main Room Sala principal - + Game Room Sala de Jogo - + Private Chat Chat Privado - + Past X Days: Últimos X Dias: - + Today Hoje - + Last Hour Última Hora - + Maximum Results: Resultados Máximos: - + At least one filter is required. The more information you put in, the more specific your results will be. É requerido pelo menos um filtro. %s Quanta mais informação der, mais específicos serão os resultados. - + Get User Logs Obter registos dos utilizadores - + Clear Filters Limpar Filtros - + Filters Filtros: - + Log Locations Localização dos Registos - + Date Range Alcance de dados - + Maximum Results Resultados máximos - - + + Message History Histórico de mensagens - + Failed to collect message history information. Falha na obtenção do histórico de mensagens - + There are no messages for the selected filters. Não há mensagens para os filtros seleccionados @@ -8527,27 +9003,27 @@ Quanta mais informação der, mais específicos serão os resultados.&Abandonar - + %1 - Private chat %1 - Chat privado - + This user is ignoring you, they cannot see your messages in main chat and you cannot join their games. - + Private message from - + %1 has left the server. %1 abandonou o servidor. - + %1 has joined the server. %1 entrou no servidor. @@ -8555,180 +9031,180 @@ Quanta mais informação der, mais específicos serão os resultados. TabReplays - + Local file system Ficheiros locais - + Server replay storage Armazenamento de replays no servidor - - + + Watch replay Ver replay - + Rename - - + + New folder - - + + Delete Apagar - + Open replays folder - + Download replay Download replay - + Toggle expiration lock Alternar bloqueio de expiração - + Get replay share code - - + + Look up replay by share code - + Rename local folder - + Rename local file - + New name: - + Error - + Rename failed - + Name of new folder: - + Delete local file Apagar ficheiro local - + Are you sure you want to delete the selected files? - + Are you sure you want to delete the selected replays? - + Failed to get code - - + + Either this server does not support replay sharing, or does not permit replay sharing for you. - - - + + + Failed - + Could not get replay code - + Replay Share Code - + Others can use this code to add the replay to their list of remote replays: %1 - + Copy to clipboard - + Replay share code - + Replay code found - + Replay was added, or you already had access to it. - + Replay code not found - + Failed to submit code - + Unexpected error - + Delete remote replay Apagar replay remoto @@ -8741,47 +9217,62 @@ Quanta mais informação der, mais específicos serão os resultados. TabRoom - + + Friends + + + + + Online + + + + + Ignored + + + + &Say: &Dizer: - + Chat Chat - + &Room &Sala - + &Leave room &Abandonar a sala - + &Clear chat &Limpar chat - + Chat Settings... Definições do chat... - + mentioned you. mencionou-o. - + Click to view Clicar para ver - + You are flooding the chat. Please wait a couple of seconds. Estás a inundar o chat .Por favor aguarde alguns segundos. @@ -8794,30 +9285,30 @@ Quanta mais informação der, mais específicos serão os resultados.Servidor - - - - + + + + Error Erro - + Failed to join the server room: it doesn't exist on the server. - + The server thinks you are in the server room but your client is unable to display it. Try restarting your client. - + You do not have the required permission to join this server room. - + Failed to join the server room due to an unknown error: %1. @@ -8825,97 +9316,97 @@ Quanta mais informação der, mais específicos serão os resultados. TabSupervisor - + Deck Editor - + Visual Deck Editor - + EDHRec - + Archidekt - + Home - + &Visual Deck Storage - + Visual Database Display - + Server - + Account - + Deck Storage - + Game Replays - + Administration - + Logs - + Are you sure? Tem a certeza? - + There are still open games. Are you sure you want to quit? Ainda estão a decorrer alguns jogos. Tem a certeza que quer sair? - + Click to view - + Your buddy %1 has signed on! - + Unknown Event - + The server has sent you a message that your client does not understand. This message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -8923,39 +9414,39 @@ To update your client, go to Help -> Check for Updates. - + Idle Timeout - + You are about to be logged out due to inactivity. - + Promotion Promoção - + You have been promoted. Please log out and back in for changes to take effect. - + Warned Avisado - + You have received a warning due to %1. Please refrain from engaging in this activity or further actions may be taken against you. If you have any questions, please private message a moderator. Recebeu um aviso devido a %1. %s Por favor evite continuar essa actividade ou acções serão tomadas contra si futuramente. Se tem alguma questão, por favor envie uma mensagem privada a um moderador. - + You have received the following message from the server. (custom messages like these could be untranslated) @@ -8964,12 +9455,12 @@ Por favor evite continuar essa actividade ou acções serão tomadas contra si f TabVisualDatabaseDisplay - + Database Display - + Visual Database Display @@ -9006,42 +9497,42 @@ Por favor evite continuar essa actividade ou acções serão tomadas contra si f TranslateCounterName - + Life - + White - + Blue - + Black - + Red - + Green - + Colorless - + Other @@ -9049,11 +9540,69 @@ Por favor evite continuar essa actividade ou acções serão tomadas contra si f UpdateDownloader - + Could not open the file for reading. Incapaz de abrir o ficheiro para leitura. + + UserCardArtSettingsDialog + + + Card Art Settings + + + + + Type a card name... + + + + + Card name: + + + + + Card ProviderId: + + + + + Left margin (%): + + + + + Right margin (%): + + + + + Vertical offset: + + + + + Zoom: + + + + + Parameters + + + + + Preview + + + + + Remove Banner Card + + + UserContextMenu @@ -9171,7 +9720,7 @@ Por favor evite continuar essa actividade ou acções serão tomadas contra si f - + Ban History Histórico de banições @@ -9186,77 +9735,87 @@ Por favor evite continuar essa actividade ou acções serão tomadas contra si f O utilizador nunca foi banido. - + Failed to collect ban information. - - - + + + Warning History Histórico de avisos - + Warning Time;Moderator;User Name;Reason Hora do aviso; Moderador; Nome do Utilizador; Razão - + User has never been warned. O utilizador nunca foi avisado. - + Failed to collect warning information. - + Failed to get admin notes. - - + + Success Sucesso - + Successfully promoted user. Utilizador promovido com sucesso. - + Successfully demoted user. Utilizador despromovido com sucesso. - - - + + Kick Player + + + + + Are you sure you want to kick this player from the game? + + + + + + Failed Falhou - + Failed to promote user. Falha na promoção do utilizador. - + Failed to demote user. Falha na despromoção do utilizador. - + Copy hash to clipboard - + Remove this user's messages @@ -9264,326 +9823,554 @@ Por favor evite continuar essa actividade ou acções serão tomadas contra si f UserInfoBox - + Location: Localização: - + Account Age: Idade da Conta - + Edit Editar - + Change password Alterar password - + Change avatar Alterar avatar - + Administrator Administrador - + Moderator Moderador - + Registered user Utilizador registado - - + + Unregistered user Utilizador não registado - + Judge - + Unknown Desconhecido - + The entered password does not match your account. - - - + + + + Information Informação - + User information updated. Informação do utilizador actualizada. - - - - - - - - - - + + + + + + + + + + + Error Erro - + User Information - + Real Name: - + User Level: + + + Edit Banner Card + + - + %n Year(s), amount of years (only shown if more than 0) - + %10%n Day(s) %20 amount of years (if more than 0), amount of days, date in local short format - + Enter Password - + Password verification is required in order to change your email address - - - + + + An error occurred while trying to update your user information. - + + The selected card is blacklisted on this server or another error occurred. + + + + + Banner card removed. + + + + + Banner card updated. + + + + This server does not permit you to update your user informations. Este servidor não permite a actualização da sua informação pessoal. - + Password changed. Password alterada. - + This server does not permit you to change your password. Este servidor não permite a actualização da sua password. - + The new password is too short. A password nova é demasiado curta. - + The old password is incorrect. A password antiga está incorrecta. - + Avatar updated. Avatar actualizado. - + This server does not permit you to update your avatar. Este servidor não permite a actualização do seu avatar. + + + An error occured while trying to update your avatar. + + - An error occured while trying to updater your avatar. - Um erro ocorreu ao tentar actualizar o seu avatar. + This server does not permit you to update your user informations. + An error occured while trying to updater your avatar. + Este servidor não permite a actualização da sua informação pessoal. + + + + UserInfoPopup + + + + Games + + + + + Chat + + + + + Open private chat + + + + + Profile + + + + + View user profile + + + + + Show this user's games + + + + + − Buddy + + + + + Remove from buddy list + + + + + + Buddy + + + + + Add to buddy list + + + + + − Ignore + + + + + Remove from ignore list + + + + + + Ignore + + + + + Add to ignore list + + + + + Ban + + + + + Ban from server + + + + + Warn + + + + + Warn user + + + + + Ban log + + + + + View ban history + + + + + Warn log + + + + + View warning history + + + + + Notes + + + + + View admin notes + + + + + − Mod + + + + + Demote from moderator + + + + + + Mod + + + + + Promote to moderator + + + + + − Judge + + + + + Demote from judge + + + + + + Judge + + + + + Promote to judge + + + + + Join game + + + + + Spectate + + + + + + Loading games… + + + + + Could not load games. + + + + + No active games. + UserInterfaceSettingsPage - + General interface settings Configurações gerais da interface - + &Double-click cards to play them (instead of single-click) Clicar &duas vezes nas cartas para as jogar (ao invés de clicar apenas uma vez) - + &Clicking plays all selected cards (instead of just the clicked card) - + &Play all nonlands onto the stack (not the battlefield) by default &Jogar todos os não-terrenos para a pilha (em vez de para o campo de batalha) por definição - + Do not delete &arrows inside of subphases - + Close card view window when last card is removed - + Auto focus search bar when card view window is opened - + Annotate card text on tokens Permitir anotações em tokens - - Show selection counter during drag selection + + Show selection count during drag selection - - Show total selection counter + + Show total selection count - + + Show subtype breakdown in selection tally + + + + Use tear-off menus, allowing right click menus to persist on screen - + + Keep game chat focused when clicking in game (Note: disables card view search bar) + + + + Notifications settings - + Enable notifications in taskbar Permitir notificações na barra de tarefas - + Notify in the taskbar for game events while you are spectating Notificar eventos de jogos na barra de tarefas - + Notify in the taskbar when users in your buddy list connect - + Animation settings Configurações de Animações - + &Tap/untap animation Animação de &virar/desvirar - + Deck editor/storage settings - + Open deck in new tab by default - + Use visual deck storage in game lobby - + Use selection animation for Visual Deck Storage - + When adding a tag in the visual deck storage to a .txt deck: - + do nothing - + ask to convert to .cod - + always convert to .cod - + Default deck editor type - + Classic Deck Editor - + Visual Deck Editor - + Replay settings - + Buffer time for backwards skip via shortcut: @@ -9591,22 +10378,22 @@ Por favor evite continuar essa actividade ou acções serão tomadas contra si f UserListWidget - + Users connected to server: %1 - + Users in this room: %1 - + Buddies online: %1 / %2 - + Ignored users online: %1 / %2 @@ -9614,35 +10401,45 @@ Por favor evite continuar essa actividade ou acções serão tomadas contra si f UtilityMenu - + Increment all card counters - + &Untap all permanents - + R&oll die... - + + Flip coin + + + + &Create token... - + C&reate another token - + Cr&eate predefined token + + + C&reate another %1 token + + VisualDatabaseDisplayColorFilterWidget @@ -9694,72 +10491,72 @@ Por favor evite continuar essa actividade ou acções serão tomadas contra si f VisualDatabaseDisplayFilterToolbarWidget - + Sort by - + Filter by - + Save and load filters - + Filter by exact card name - + Filter by card main-type - + Filter by card sub-type - + Filter by set - + Filter by format legality - + Save/Load - + Name - + Main Type - + Sub Type - + Sets - + Formats @@ -9767,22 +10564,22 @@ Por favor evite continuar essa actividade ou acções serão tomadas contra si f VisualDatabaseDisplayFormatLegalityFilterWidget - + Show formats with at least: - + cards - + Do not display formats with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) @@ -9800,32 +10597,32 @@ Por favor evite continuar essa actividade ou acções serão tomadas contra si f VisualDatabaseDisplayMainTypeFilterWidget - + Show main types with at least: - + cards - + Do not display card main-types with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) - + Mode: Exact Match - + Mode: Includes @@ -9833,27 +10630,27 @@ Por favor evite continuar essa actividade ou acções serão tomadas contra si f VisualDatabaseDisplayNameFilterWidget - + Filter by name... (Exact match) - + Load from Deck - + Apply all card names in currently loaded deck as exact match name filters - + Load from Clipboard - + Apply all card names in clipboard as exact match name filters @@ -9861,7 +10658,7 @@ Por favor evite continuar essa actividade ou acções serão tomadas contra si f VisualDatabaseDisplayRecentSetFilterSettingsWidget - + Filter to most recent sets @@ -9869,19 +10666,19 @@ Por favor evite continuar essa actividade ou acções serão tomadas contra si f VisualDatabaseDisplaySetFilterWidget - + Search sets... - - + + Mode: Exact Match - - + + Mode: Includes @@ -9889,37 +10686,37 @@ Por favor evite continuar essa actividade ou acções serão tomadas contra si f VisualDatabaseDisplaySubTypeFilterWidget - + Search subtypes... - + Show sub types with at least: - + cards - + Do not display card sub-types with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) - + Mode: Exact Match - + Mode: Includes @@ -9927,23 +10724,23 @@ Por favor evite continuar essa actividade ou acções serão tomadas contra si f VisualDatabaseDisplayWidget - + Search by card name (or search expressions) - + Visual - + Loading database ... - + Clear all filters @@ -9982,17 +10779,17 @@ Por favor evite continuar essa actividade ou acções serão tomadas contra si f - + Toggle Layout: Overlap - + Change how cards are displayed within zones (i.e. overlapped or fully visible.) - + Toggle Layout: Flat @@ -10021,17 +10818,17 @@ Por favor evite continuar essa actividade ou acções serão tomadas contra si f VisualDeckEditorWidget - + Type a card name here for suggestions from the database... - + Quick search and add card - + Search for closest match in the database (with auto-suggestions) and add preferred printing to the deck on pressing enter @@ -10149,43 +10946,43 @@ Por favor evite continuar essa actividade ou acções serão tomadas contra si f WarningDialog - + Which warning would you like to send? Que aviso gostaria de enviar? - + Redact all messages from this user in all rooms - + &OK &OK - + &Cancel &Cancelar - + Warn user for misconduct Aviso por má conduta - - + + Error Erro - + User name to send a warning to can not be blank, please specify a user to warn. O nome de utilizador não se pode encontrar vazio, por favor especifique o utilizador que deseja avisar. - + Warning to use can not be blank, please select a valid warning to send. O aviso a enviar não se pode encontrar vazio, por favor seleccione um aviso válido para enviar. @@ -10193,133 +10990,133 @@ Por favor evite continuar essa actividade ou acções serão tomadas contra si f WndSets - + Move selected set to the top Mover expansão seleccionada para o topo - + Move selected set up Mover expansão seleccionada para cima - + Move selected set down Mover expansão seleccionada para baixo - + Move selected set to the bottom Mover expansão seleccionada para o fundo - + Search by set name, code, or type - + Default order - + Restore original art priority order - + Enable all sets Permitir todas as expansões - + Disable all sets Inactivar todas as expansões - + Enable selected set(s) - + Disable selected set(s) - + Deck Editor - + Use CTRL+A to select all sets in the view. - + Only cards in enabled sets will appear in the card list of the deck editor. - + Image priority is decided in the following order: - + first the CUSTOM Folder (%1), then the Enabled Sets in this dialog (Top to Bottom) %1 is a link to the wiki - + Include cards rebalanced for Alchemy [requires restart] - + Card Art - + How to use custom card art - + Hints - + Note - + Sorting by column allows you to find a set while not changing set priority. - + To enable ordering again, click the column header until this message disappears. - + Use the current sorting as the set priority instead - + Sorts the set priority using the same column - + Manage sets @@ -10327,72 +11124,72 @@ Por favor evite continuar essa actividade ou acções serão tomadas contra si f ZoneViewWidget - + Search by card name (or search expressions) - + Ungrouped - + Group by Type - + Group by Mana Value - + Group by Color - + Unsorted - + Sort by Name - + Sort by Type - + Sort by Mana Cost - + Sort by Colors - + Sort by P/T - + Sort by Set - + shuffle when closing baralhar quando terminar - + pile view vista em pilha @@ -10400,7 +11197,7 @@ Por favor evite continuar essa actividade ou acções serão tomadas contra si f i18n - + English Português (Portuguese) @@ -10408,12 +11205,12 @@ Por favor evite continuar essa actividade ou acções serão tomadas contra si f main - + Connect on startup - + Debug to file @@ -10427,7 +11224,7 @@ Por favor evite continuar essa actividade ou acções serão tomadas contra si f - + Deck Editor Editor de Baralhos @@ -10508,7 +11305,7 @@ Por favor evite continuar essa actividade ou acções serão tomadas contra si f - + Replays @@ -10623,129 +11420,129 @@ Por favor evite continuar essa actividade ou acções serão tomadas contra si f - - + + Load Deck from Clipboard... - + Edit Deck in Clipboard, Annotated - + Edit Deck in Clipboard - + New Deck - + Open Custom Pictures Folder - + Print Deck... - + Delete Card - - + + Reset Layout - + Save Deck - + Save Deck as... - + Save Deck to Clipboard, Annotated - + Save Deck to Clipboard, Annotated (No Set Info) - + Save Deck to Clipboard - + Save Deck to Clipboard (No Set Info) - + Load Local Deck... - + Load Remote Deck... - + Set Ready to Start - + Toggle Sideboard Lock - + Add Green Counter - + Remove Green Counter - + Set Green Counters... - + Add Red Counter - + Remove Red Counter - + Set Red Counters... - + Add Life Counter @@ -10755,724 +11552,744 @@ Por favor evite continuar essa actividade ou acções serão tomadas contra si f - + + Load deck from online service... + + + + + Load from website... + + + + Unload Deck - + Force Start - + Add Card Counter (F) - + Remove Card Counter (F) - + Set Card Counters (F)... - + Add Card Counter (E) - + Remove Card Counter (E) - + Set Card Counters (E)... - + Add Card Counter(D) - + Remove Card Counter (D) - + Set Card Counters (D)... - + Add Card Counter (C) - + Remove Card Counter (C) - + Set Card Counters (C)... - + Add Card Counter (B) - + Remove Card Counter (B) - + Set Card Counters (B)... - + Add Card Counter (A) - + Remove Card Counter (A) - + Set Card Counters (A)... - + Remove Life Counter - + Set Life Counters... - + Add White Counter - + Remove White Counter - + Set White Counters... - + Add Blue Counter - + Remove Blue Counter - + Set Blue Counters... - + Add Black Counter - + Remove Black Counter - + Set Black Counters... - + Add Colorless Counter - + Remove Colorless Counter - + Set Colorless Counters... - + Add Other Counter - + Remove Other Counter - + Set Other Counters... - + Increment all card counters - + Add Power (+1/+0) - + Remove Power (-1/-0) - + Move Toughness to Power (+1/-1) - + Add Toughness (+0/+1) - + Remove Toughness (-0/-1) - + Move Power to Toughness (-1/+1) - + Add Power and Toughness (+1/+1) - + Remove Power and Toughness (-1/-1) - + Set Power and Toughness... - + Reset Power and Toughness - + Untap Desvirar - + Upkeep Etapa de Upkeep - + Draw Etapa de compra de cartas - + First Main Phase - + Start Combat - + Attack Etapa de Ataque - + Block Etapa de Bloqueio - + Damage Etapa de atribuição de dano - + End Combat - + Second Main Phase - + End Fim de turno - + Next Phase - + Next Phase Action - + Next Turn - + Hide Card in Reveal Window - + Tap / Untap Card - + Untap All - + Toggle Skip Untapping Toggle Untap - + Turn Card Over - + Peek Card - + Play Card - + Play Card, Face Down - + Attach Card... - + Unattach Card - + Clone Card - + Create Token... - + Create All Related Tokens - + Create Another Token - + Set Annotation... - + + Reduce Life by Power + + + + Select All Cards in Zone - + Select All Cards in Row - + Select All Cards in Column - + Reveal Selected Cards to All Players - - + + Bottom of Library - - - - + + + + Exile Exílio - - - - + + + + Graveyard Cemitério - - + + Hand Mão - - + + Top of Library - - + + Battlefield, Face Down - + Battlefield - + Library Baralho - + Sideboard Sideboard - + Top Cards of Library - + Bottom Cards of Library - + Close Recent View - - + + Stack - - + + Graveyard (Multiple) - - + + Graveyard (Multiple), Face Down - - + + Exile (Multiple) - - + + Exile (Multiple), Face Down - + Stack Until Found - + Draw Bottom Card - + Draw Multiple Cards from Bottom... - + Draw Arrow... - + Remove Local Arrows - + Leave Game - + Concede Desistir - + Roll Dice... - + + Flip Coin + + + + Shuffle Library - + Shuffle Top Cards of Library - + Shuffle Bottom Cards of Library - + Mulligan Mulligan - + Mulligan (Same hand size) - + Mulligan (Hand size - 1) - + Draw a Card - + Draw Multiple Cards... - + Undo Draw - + Always Reveal Top Card - + Always Look At Top Card - + Sort Hand by Name - + Sort Hand by Type - + Sort Hand by Mana Value - + Reveal Hand to All Players - + Reveal Random Card to All Players - + Rotate View Clockwise - + Rotate View Counterclockwise - + Unfocus Text Box - + Focus Chat - + Clear Chat - + Refresh - + Skip Forward - + Skip Backward - + Skip Forward by a lot - + Skip Backward by a lot - + Play/Pause - + Toggle Fast Forward - + Home - + Visual Deck Storage - + Deck Storage - + Server - + Account - + Administration - + Logs diff --git a/cockatrice/translations/cockatrice_pt_BR.ts b/cockatrice/translations/cockatrice_pt_BR.ts index 9956c1793..422ed5a4b 100644 --- a/cockatrice/translations/cockatrice_pt_BR.ts +++ b/cockatrice/translations/cockatrice_pt_BR.ts @@ -2,7 +2,7 @@ AbstractCounter - + &Set counter... &Definir marcador... @@ -10,12 +10,12 @@ AbstractCounterDialog - + Set counter Definir marcador - + New value for counter '%1': Novo valor para marcador '%1': @@ -36,62 +36,62 @@ AbstractTabDeckEditor - + Open in new tab Abrir em uma nova aba - + Are you sure? Você tem certeza? - + The decklist has been modified. Do you want to save the changes? A lista foi modificada. Gostaria de salvar as alterações? - - - - - - + + + + + + Error Erro - + Could not open deck at %1 Não foi possível abrir o deck em %1 - + Could not save remote deck O deck não pode ser salvo remotamente. - - + + The deck could not be saved. Please check that the directory is writable and try again. O deck não pôde ser salvo. Por favor, verifique se a pasta não é somente leitura e tente novamente. - + Save deck Salvar deck - + The deck could not be saved. O deck não pôde ser salvo. - + There are no cards in your deck to be exported Não há cartas em seu deck a serem exportadas @@ -101,18 +101,18 @@ Por favor, verifique se a pasta não é somente leitura e tente novamente. Add Analytics Panel - + Adicionar ao Painel de Métricas AdminNotesDialog - + Update Notes Notas de Atualização - + Admin Notes for %1 Notas do Administrador para %1 @@ -127,174 +127,229 @@ Por favor, verifique se a pasta não é somente leitura e tente novamente. Sideboard - Side + Reserva + + + + Tokens + AppearanceSettingsPage - + seconds segundos - + Error Erro - + Could not create themes directory at '%1'. Não foi possível criar o diretório de temas em '%1'. - + Theme settings Configurações de Tema - + Current theme: Tema atual: - + Open themes folder Abra a pasta de temas - + Home tab background source: Fonte do background da tab Home: - + Home tab background shuffle frequency: Frequência de escolha aleatória de background da tab Home: - + Disabled Desabilitado - - Display card name of background in bottom right: - - - - + Menu settings Configurações do Menu - + Show keyboard shortcuts in right-click menus Mostrar atalhos de teclado nos menus de clique com o botão direito - + Show game filter toolbar above list in room tab Mostrar o filtro de jogos acima da lista da tab de sala - + Card rendering Renderização das cartas - + Display card names on cards having a picture Mostrar nome nas cartas que possuem imagem - + Auto-Rotate cards with sideways layout Rotacionar automaticamente cartas com layout horizontal - + Override all card art with personal set preference (Pre-ProviderID change behavior) - + + Light + + + + + Dark + + + + + System + + + + + Active theme palette: + + + + + Edit theme palette + + + + + Home tab settings + + + + + Display card name of background in bottom right + + + + + Styling settings + + + + + Style user list + + + + + Card printings + + + + Bump sets that the deck contains cards from to the top in the printing selector Priorizar conjuntos que o deck contém no topo do seletor de impressão - + Scale cards on mouse over Redimensionar cartas ao passar o mouse - + Use rounded card corners Usar cantos de cartas arredondados - + + Card layout + + + + Minimum overlap percentage of cards on the stack and in vertical hand Porcentagem mínima de sobreposição de cartas na pilha e na mão vertical - + Maximum initial height for card view window: Altura inicial máxima para a janela de visualização de cartas: - - + + rows linhas - + Maximum expanded height for card view window: Altura expandida máxima para a janela de visualização de cartas: - + Card counters Marcadores de carta - + Counter %1 Marcador %1 - + Hand layout Organização da mão - + Display hand horizontally (wastes space) Mostrar a mão na horizontal (desperdiça espaço) - + Enable left justification Habilitada justificação à esquerda - + Table grid layout Organização do campo de batalha - + Invert vertical coordinate Inverter a coordenada vertical - + Minimum player count for multi-column layout: Número mínimo de jogadores para o layout de multi-colunas - + Maximum font size for information displayed on cards: Tamanho máximo da fonte para informações nas cartas: @@ -302,12 +357,12 @@ Por favor, verifique se a pasta não é somente leitura e tente novamente. ArchidektApiResponseDeckDisplayWidget - + Back to results - + Open Deck in Deck Editor Abrir no Editor de Decks @@ -333,112 +388,112 @@ Por favor, verifique se a pasta não é somente leitura e tente novamente. BanDialog - + ban &user name banir &usuário pelo nome - + ban &IP address banir endereço &IP - + ban client I&D banir cliente por I&D: - + Ban type Tipo de banimento - + &permanent ban &banir permanentemente - + &temporary ban &banir temporariamente - + &Days: &Dias: - + &Hours: &Horas: - + &Minutes: &Minutos: - + Duration of the ban Duração do banimento - + Please enter the reason for the ban. This is only saved for moderators and cannot be seen by the banned person. Por favor, entre com o motivo do banimento. Isto será visto somente por moderadores e não pode ser visto pela pessoa banida. - + Please enter the reason for the ban that will be visible to the banned person. Por favor, entre com o motivo para o banimento que será visível pela pessoa banida. - + Redact all messages from this user in all rooms Remover todas mensagens deste usuário em todas as salas - + &OK &OK - + &Cancel &Cancelar - + Ban user from server Banir usuário do servidor - - - - + + + + Error Erro - + You have to select a name-based, IP-based, clientId based, or some combination of the three to place a ban. Você deve selecionar um nome, IP, cliente ou alguma combinação desses três itens para banir. - + You must have a value in the name ban when selecting the name ban checkbox. Você têm que colocar um nome válido quando seleciona a caixa de banimento por nome. - + You must have a value in the ip ban when selecting the ip ban checkbox. Você têm que colocar um IP válido quando seleciona a caixa de banimento por IP. - + You must have a value in the clientid ban when selecting the clientid ban checkbox. Você têm que colocar um ID de cliente válido quando seleciona a caixa de banimento por ID de cliente. @@ -446,59 +501,123 @@ Isto será visto somente por moderadores e não pode ser visto pela pessoa banid BetaReleaseChannel - + Beta Beta - + No reply received from the release update server. Nenhuma resposta recebida do servidor de atualização. - + Invalid reply received from the release update server. Resposta inválida recebida do servidor de atualização. - + No reply received from the file update server. Nenhuma resposta recebida do servidor de atualização. + + CardArtPreviewWidget + + + No card selected + + + + + CardArtRulesModel + + + Card + + + + + ProviderId + + + + + Mode + + + + + Reason + + + CardDatabaseModel - + Name Nome - + Sets Expansões - + Mana cost Custo de mana - + Card type Tipo de carta - + P/T P/R - + Color(s) Cor(es) + + CardDatabaseView + + + Add to Deck + + + + + Add to Sideboard + + + + + Select Printing + + + + + Show on EDHRec (Commander) + + + + + Show on EDHRec (Card) + + + + + Show Related cards + + + CardFilter @@ -627,22 +746,22 @@ Isto será visto somente por moderadores e não pode ser visto pela pessoa banid CardInfoPictureWidget - + View related cards Visualizar cartas relacionadas - + Add card to deck Adicionar carta ao deck - + Mainboard Main - + Sideboard Side @@ -665,12 +784,12 @@ Isto será visto somente por moderadores e não pode ser visto pela pessoa banid Número de Colecionador: - + Related cards: Cartas relacionadas: - + Unknown card: Carta desconhecida: @@ -678,128 +797,146 @@ Isto será visto somente por moderadores e não pode ser visto pela pessoa banid CardMenu - + Re&veal to... Revelar para... - + &All players Todos os &jogadores - + View related cards Visualizar cartas relacionadas - + Token: Ficha: - + All tokens Todas as fichas - + &Select All &Marque todos - + S&elect Row Selecionar Linha - + S&elect Column Selecionar Coluna - + &Play &Jogar - + &Hide &Ocultar - + Play &Face Down Jogue &virada para baixo - + &Tap / Untap Turn sideways or back again &Virar / Desvirar - + Skip &untapping - + T&urn Over Turn face up/face down &Virar - + &Peek at card face &Olhar a face da carta - + &Clone &Clonar - + Attac&h to card... Ane&xar na carta... - + Unattac&h De&sanexar - + &Draw arrow... &Desenhar seta... - + &Set annotation... Colocar &nota... - + + Reduce life by power + + + + Ca&rd counters Marcadores de carta - + &Add counter (%1) &Adicionar marcador(%1) - + &Remove counter (%1) &Remover marcador (%1) - + &Set counters (%1)... &Definir marcadores (%1)... + + CardPictureLoaderCacheMethod + + + Network Cache + + + + + Filesystem + + + CardSizeWidget @@ -811,133 +948,133 @@ Isto será visto somente por moderadores e não pode ser visto pela pessoa banid CardZoneLogic - + their hand nominative sua mão - + %1's hand nominative mão de %1 - + their library look at zone seu grimório - + %1's library look at zone grimório de %1 - + of their library top cards of zone, do seu grimório - + of %1's library top cards of zone do grimório de %1 - + their library reveal zone seu grimório - + %1's library reveal zone grimório de %1 - + their library shuffle seu grimório - + %1's library shuffle grimório de %1 - + their library nominative seu grimório - + %1's library nominative grimório de %1 - + their graveyard nominative seu cemitério - + %1's graveyard nominative cemitério de %1 - + their exile nominative seu exílio - + %1's exile nominative exílio de %1 - + their sideboard look at zone seu sideboard - + %1's sideboard look at zone sideboard de %1 - + their sideboard nominative seu sideboard - + %1's sideboard nominative sideboard de %1 - + their custom zone '%1' nominative sua zona customizada '%1' - + %1's custom zone '%2' nominative zona customizada '%2' de %1 @@ -959,16 +1096,421 @@ Isto será visto somente por moderadores e não pode ser visto pela pessoa banid Erro na linha %1 coluna %2: + + ColorButton + + + Click to pick a color + + + + + Pick colour + + + + + ConnectionController + + + + The server has reached its maximum user capacity, please check back later. + + + + + There are too many concurrent connections from your address. + + + + + Banned by moderator + + + + + Expected end time: %1 + + + + + This ban lasts indefinitely. + + + + + Scheduled server shutdown. + + + + + + Invalid username. + + + + + You have been logged out due to logging in at another location. + + + + + Connection closed + + + + + The server has terminated your connection. +Reason: %1 + + + + + The server is going to be restarted in %n minute(s). +All running games will be lost. +Reason for shutdown: %1 + + + + + Scheduled server shutdown + + + + + Failed Login + + + + + Your client seems to be missing features this server requires for connection. + + + + + To update your client, go to 'Help -> Check for Client Updates'. + + + + + + + + + + + + + + + + + + + + + + Error + + + + + Incorrect username or password. Please check your authentication information and try again. + + + + + There is already an active session using this user name. +Please close that session first and re-login. + + + + + + You are banned until %1. + + + + + + You are banned indefinitely. + + + + + This server requires user registration. Do you want to register now? + + + + + This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. +Please close and reopen your client to try again. + + + + + An internal error has occurred, please close and reopen Cockatrice before trying again. +If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. + + + + + Account activation + + + + + Your account has not been activated yet. +You need to provide the activation token received in the activation email. + + + + + Server Full + + + + + Unknown login error: %1 + + + + + + +This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. + + + + + + + + + + Registration denied + + + + + Registration is currently disabled on this server + + + + + There is already an existing account with the same user name. + + + + + It's mandatory to specify a valid email address when registering. + + + + + It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. + + + + + Password too short. + + + + + Registration failed for a technical problem on the server. + + + + + The connection to the server has been lost. + + + + + Unknown registration error: %1 + + + + + Account activation failed + + + + + Socket error: %1 + + + + + Server timeout + + + + + You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. +Local version is %1, remote version is %2. + + + + + Your Cockatrice client is obsolete. Please update your Cockatrice version. +Local version is %1, remote version is %2. + + + + + + Success + + + + + Registration accepted. +Will now login. + + + + + Account activation accepted. +Will now login. + + + + + Information + + + + + This server supports additional features that your client doesn't have. +This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. + +To update your client, go to Help -> Check for Updates. + + + + + + + Reset Password + + + + + Your password has been reset successfully, you can now log in using the new credentials. + + + + + Failed to reset user account password, please contact the server operator to reset your password. + + + + + Activation request received, please check your email for an activation token. + + + + + Connecting to %1... + + + + + Registering to %1 as %2... + + + + + Disconnected + + + + + Connected, logging in at %1 + + + + + Requesting forgotten password to %1 as %2... + + + + + Your username must respect these rules: + + + + + is %1 - %2 characters long + + + + + can %1 contain lowercase characters + + + + + + + + NOT + + + + + can %1 contain uppercase characters + + + + + can %1 contain numeric characters + + + + + can contain the following punctuation: %1 + + + + + first character can %1 be a punctuation mark + + + + + no unacceptable language as specified by these server rules: + note that the following lines will not be translated + + + + + can not contain any of the following words: %1 + + + + + can not match any of the following expressions: %1 + + + + + You may only use A-Z, a-z, 0-9, _, ., and - in your username. + + + CustomZoneMenu - + C&ustom Zones Zonas Customizadas - - + + View custom zone '%1' Ver zona customizada '%1' @@ -976,30 +1518,35 @@ Isto será visto somente por moderadores e não pode ser visto pela pessoa banid DeckAnalyticsWidget - + Add Panel Adicionar Painel - + Remove Panel Remover Painel - + Save Layout Salvar Layout - + Load Layout Carregar Layout + + + Include Sideboard + + DeckEditorCardDatabaseDockWidget - + Card Database Banco de Dados de Cartas @@ -1015,47 +1562,17 @@ Isto será visto somente por moderadores e não pode ser visto pela pessoa banid DeckEditorDatabaseDisplayWidget - + Search by card name (or search expressions) Pesquisar pelo nome da carta (ou expressões de busca) - - Add to Deck - Adicionar ao deck - - - - Add to Sideboard - Adicionar ao Sideboard - - - - Select Printing - Escolher Impressão - - - - Show on EDHRec (Commander) - - - - - Show on EDHRec (Card) - - - - - Show Related cards - Mostrar cartas Relacionadas - - - + Add card to &maindeck Adicionar carta ao &baralho principal - + Add card to &sideboard Adicionar carta ao &sideboard @@ -1088,72 +1605,72 @@ Isto será visto somente por moderadores e não pode ser visto pela pessoa banid Cores - + Select Printing Escolher Impressão - + Deck Deck - + Deck &name: &Nome do Deck: - + Banner Card/Tags Visibility Settings - + Show banner card selection menu - + Show tags selection menu - + &Comments: &Comments: - + Group by: Agrupar por: - + Format: Formato: - + Hash: Hash: - + &Increment number &Aumentar número - + &Decrement number &Diminuir número - + &Remove row &Remover linha - + Swap card to/from sideboard Mover carta para/do Side @@ -1161,17 +1678,17 @@ Isto será visto somente por moderadores e não pode ser visto pela pessoa banid DeckEditorFilterDockWidget - + Filters Filtros - + &Clear all filters &Limpar todos os filtros - + Delete selected Apagar selecionados @@ -1302,169 +1819,113 @@ Isto será visto somente por moderadores e não pode ser visto pela pessoa banid DeckEditorSettingsPage - - + + Update Spoilers Atualizar Spoilers - - + Success Sucesso - + Download URLs have been reset. As URLs de download foram redefinidas. - - Downloaded card pictures have been reset. - As imagens de cartas baixadas foram redefinidas. - - - - Error - Erro - - - - One or more downloaded card pictures could not be cleared. - Uma ou mais imagens de cartas baixadas não puderam ser apagadas. - - - + Add URL Adicionar URL - - + + URL: URL: - - + + Edit URL Editar URL - - Network Cache Size: - Tamanho do Cache de Rede: - - - - Redirect Cache TTL: - TTL do Cache de Redirecionamento: - - - - How long cached redirects for urls are valid for. - Por quanto tempo os redirecionamentos em cache para URLs são válidos. - - - - Picture Cache Size: - Tamanho do Cache de Imagens: - - - + Add New URL Adicionar nova URL - + Remove URL Remover URL - - Day(s) - Dia(s) - - - + Updating... Atualizando... - + Choose path Selecione o caminho - + URL Download Priority Prioridade de download de URL - + Spoilers 'Spoilers' - + Download Spoilers Automatically Baixar 'spoilers' automaticamente - + Spoiler Location: Localização do 'spoiler': - + Last Change Última Alteração - + Spoilers download automatically on launch Baixar automaticamente 'spoilers' ao iniciar - + Press the button to manually update without relaunching Pressione o botão para atualizar manualmente sem reinicializar - + Do not close settings until manual update is complete Não feche as configurações até que a atualização manual termine - + Download card pictures on the fly Baixar imagens de cartas em tempo real - + How to add a custom URL Como adicionar uma URL customizada - - Delete Downloaded Images - Deletar imagens baixadas - - - + Reset Download URLs Resetar URLs de download - - - On-disk cache for downloaded pictures - Cache em disco para imagens baixadas - - - - In-memory cache for pictures not currently on screen - Cache em memória para imagens que não estão atualmente na tela - DeckListHistoryManagerWidget @@ -1530,12 +1991,12 @@ Isto será visto somente por moderadores e não pode ser visto pela pessoa banid DeckLoader - + Common deck formats (%1) Formatos de decks comuns (%1) - + All files (*.*) Todos arquivos (*.*) @@ -1757,27 +2218,27 @@ Isto será visto somente por moderadores e não pode ser visto pela pessoa banid Adicionado (%1): %2 (%3) %4 - + Moved to %1 1 × "%2" (%3) Movido para %1 1 × "%2" (%3) - + Removed "%1" (all copies) Removido "%1" (todas as cópias) - + %1 1 × "%2" (%3) %1 1 × "%2" (%3) - + Added Adicionado - + Removed Removido @@ -1799,75 +2260,75 @@ Isto será visto somente por moderadores e não pode ser visto pela pessoa banid DeckViewContainer - + Load deck... Carregar deck... - + Load remote deck... Carregar deck remoto... - + Load from clipboard... Carregado da área de transferência... - + Load from website... Carregado do website... - + Unload deck Descarregar deck - + Ready to start Preparadx para começar - + Force start Forçar início - + Sideboard unlocked Sideboard aberto - + Sideboard locked Sideboard fechado - - + + Error Erro - + The selected file could not be loaded. O arquivo selecionado não pode ser carregado - + Deck is greater than maximum file size. O baralho é maior que o tamanho máximo de arquivo. - + Are you sure you want to force start? This will kick all non-ready players from the game. Tem certeza que deseja forçar o início? Isso irá expulsar todos os jogadores não preparados do jogo. - + Cockatrice Cockatrice @@ -1968,7 +2429,7 @@ Deseja converter o deck para .cod? - + Webpage Página da Web @@ -2008,37 +2469,37 @@ Deseja converter o deck para .cod? Conectar ao Servidor - + Server URL URL do Servidor - + Communication Port Porta de comunicação - + Unique Server Name Nome Único de Servidor - + Connection Warning Aviso de conexão - + You need to name your new connection profile. Você deve nomear seu novo perfil de conexão - + Connect Warning Aviso de conexão - + The player name can't be empty. O nome do jogador é obrigatório. @@ -2151,17 +2612,17 @@ Deseja converter o deck para .cod? Criar jogo - + Game information Informação de jogo - + Error Erro - + Server error. Erro do servidor. @@ -2169,97 +2630,97 @@ Deseja converter o deck para .cod? DlgCreateToken - + &Name: &Nome: - + Token Ficha - + C&olor: C&or: - + white branco - + blue azul - + black preto - + red vermelho - + green verde - + multicolor multicolorido - + colorless incolor - + &P/T: &P/R: - + &Annotation: &Nota: - + &Destroy token when it leaves the table Destruir a &ficha quando ela sair do campo de batalha - + Create face-down (Only hides name) - + Token data Informação de ficha - + Show &all tokens Mostre &todas fichas - + Show tokens from this &deck Mostre fichas deste &deck - + Choose token from list Mostre fichas da lista - + Create token Criar ficha @@ -2293,7 +2754,7 @@ Deseja converter o deck para .cod? - + @@ -2308,12 +2769,12 @@ Deseja converter o deck para .cod? Nomes de tags não podem ser vazios! - + Duplicate Tag Tag Duplicada - + This tag already exists. Essa tag já existe. @@ -2500,12 +2961,12 @@ Para remover o avatar atual, confirme sem escolher uma nova imagem.Por favor, entre com o nome da ficha: - + Error Erro - + The chosen name conflicts with an existing card or token. Make sure to enable the 'Token' set in the "Manage sets" dialog to display them correctly. O nome escolhido está em conflito com uma carta ou ficha existente. @@ -2530,12 +2991,12 @@ Certifique-se de habilitar a expansão 'Fichas' em "Gerenciar exp Indefinido - + Real name: Nome real: - + Edit user profile Editar perfil @@ -2573,113 +3034,113 @@ Certifique-se de habilitar a expansão 'Fichas' em "Gerenciar exp 2 horas - + Hide 'buddies only' games Ocultar jogos 'apenas amigos' - + Hide full games Ocultar jogos cheios - + Hide games that have started Ocultar jogos já iniciados - + Hide password protected games Ocultar jogos com senha - + Hide 'ignored user' games Ocultar 'jogos de usuários ignorados' - + Hide games not created by buddies Hide games not created by buddy Esconder jogos não criados por amigos - + Hide games with forced open decklists - + &Newer than: &Mais novos que: - + Game &description: &Descrição do jogo: - + &Creator name: Nome do &criador: - + General Geral - + &Game types Tipos de &jogos - + at &least: &ao menos: - + at &most: no &máximo: - + Maximum player count Quantidade máxima de jogadores - + Restrictions Restrições - + Show games only if &spectators can watch Mostrar jogos apenas se &espectadores puderem assistir - + Show spectator password p&rotected games Mostrar jogos p&rotegidos por senha para espectador - + Show only if spectators can ch&at Mostrar apenas se espectadores puderem interagir com o chat - + Show only if spectators can see &hands Mostrar apenas se as mãos forem visíveis para os espectadores - + Spectators Espectadores - + Filter games Filtrar jogos @@ -2982,37 +3443,37 @@ https://tappedout.net/mtg-decks/your-deck-name/ DlgMoveTopCardsUntil - + Card name (or search expressions): Nome da carta (ou expressões de busca): - + Number of hits: Número de resultados: - + Auto play hits Reproduzir resultados automaticamente - + Put top cards on stack until... Colocar as cartas do topo na pilha até... - + No cards matching the search expression exists in the card database. Proceed anyways? Nenhuma carta correspondente à expressão de busca existe no banco de dados de cartas. Deseja continuar assim mesmo? - + Cockatrice Cockatrice - + Invalid filter Filtro inválido @@ -3072,40 +3533,40 @@ Seu e-mail será utilizado para verificar sua conta. Indefinido - + Real name: Nome real: - + Register to server Registrar ao servidor: - - - - + + + + Registration Warning Aviso de Registro - + Your password is too short. Sua senha é muito curta. - + Your passwords do not match, please try again. Suas senhas não coincidem, por favor tente novamente. - + Your email addresses do not match, please try again. Seus endereços de email não coincidem, por favor tente novamente. - + The player name can't be empty. O nome de jogador não pode estar vazio. @@ -3113,17 +3574,17 @@ Seu e-mail será utilizado para verificar sua conta. DlgRollDice - + Number of sides: Número de lados: - + Number of dice: Número de dados: - + Roll Dice Rolar dados @@ -3174,12 +3635,12 @@ Seu e-mail será utilizado para verificar sua conta. DlgSettings - + Unknown Error loading card database Erro desconhecido ao carregar bando de dados de cartas - + Your card database is invalid. Cockatrice may not function correctly with an invalid database @@ -3196,7 +3657,7 @@ Você pode precisar rodar o Oracle novamente para atualizar o banco de dados de Você gostaria de mudar o local da configuração de seu banco de dados? - + Your card database version is too old. This can cause problems loading card information or images @@ -3213,7 +3674,7 @@ Usualmente isto pode ser consertado rodando novamente o Oracle para atualizar se Você gostaria de alterar o local da configuração de seu banco de dados? - + Your card database did not finish loading Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues with your cards.xml attached @@ -3226,7 +3687,7 @@ Por favor, envie um ticket para https://github.com/Cockatrice/Cockatrice/issues Gostaria de mudar a definição da localização da sua base de dados? - + File Error loading your card database. Would you like to change your database location setting? @@ -3235,7 +3696,7 @@ Would you like to change your database location setting? Você gostaria de alterar seu local de configuração de seu banco de dados? - + Your card database was loaded but contains no cards. Would you like to change your database location setting? @@ -3244,7 +3705,7 @@ Would you like to change your database location setting? Você gostaria de alterar seu local de configuração de banco de dados? - + Unknown card database load status Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues @@ -3257,59 +3718,64 @@ Por favor, crie um ticket em https://github.com/Cockatrice/Cockatrice/issues Você gostaria de alterar seu local de configuração do banco de dados? - - - + + + Error Erro - + The path to your deck directory is invalid. Would you like to go back and set the correct path? O caminho para a sua pasta de decks é inválido. Você gostaria de voltar e corrigir o caminho? - + The path to your card pictures directory is invalid. Would you like to go back and set the correct path? O caminho para a sua pasta de imagens de cards é inválido. Você gostaria de voltar e corrigir o caminho? - + Settings Configurações - + General Geral - + Appearance Aparência - + User Interface Interface do usuário - + + Storage + + + + Card Sources Fonte de Cartas - + Chat Conversa - + Sound Som - + Shortcuts Atalhos @@ -3401,9 +3867,9 @@ You can always change this behavior in the 'General' settings tab. - - - + + + Error Erro @@ -3455,31 +3921,31 @@ Please visit the download page to update manually. - + Update Available Atualização disponível - + A new version of Cockatrice is available! Uma nova versão do Cockatrice está disponível! - + New version Nova versão - + Released Lançado - + Changelog Log de mudanças @@ -3489,51 +3955,51 @@ Please visit the download page to update manually. Você quer atualizar agora? - + Unfortunately, the automatic updater failed to find a compatible download. You may have to manually download the new version. Infelizmente, o atualizador automática não encontrou um download compatível. Talvez você tenha que baixar a nova versão manualmente. - + Please check the <a href="%1">releases page</a> on our Github and download the build for your system. Por favor confira a <a href="%1">página de releases</a> no nosso repositório do Github e baixe o programa para o seu sistema. - - - + + + Update Error Erro na atualização - + An error occurred while checking for updates: Ocorreu um erro enquanto procurávamos por atualizações: - + An error occurred while downloading an update: Um erro ocorreu enquanto uma atualização era baixada: - + Installing... Instalando... - + Cockatrice is unable to open the installer. Cockatrice está incapaz de abrir o instalador. - + Try to update manually by closing Cockatrice and running the installer. Tente atualizar o Cockatrice manualmente fechando-o e rodando o instalador. - + Download location Localização do download @@ -3623,67 +4089,67 @@ Talvez você tenha que baixar a nova versão manualmente. DrawProbabilityWidget - + Draw Probability Probabilidade de compra - + Probability of drawing - + Card Name Nome da Carta - + Type Tipo - + Subtype Subtipo - + Mana Value Valor de Mana - + At least Pelo menos - + Exactly Exatamente - + card(s) having drawn at least - + cards cartas - + Category Categoria - + Qty Qtd - + Odds (%) Chances (%) @@ -3797,7 +4263,7 @@ Talvez você tenha que baixar a nova versão manualmente. FilterBuilder - + Type your filter here Digite seu filtro aqui @@ -3828,22 +4294,22 @@ Talvez você tenha que baixar a nova versão manualmente. GameEventHandler - + kicked by game host or moderator - + player left the game jogador saiu do jogo - + player disconnected from server jogador desconectado do servidor - + reason unknown motivo desconhecido @@ -3851,140 +4317,140 @@ Talvez você tenha que baixar a nova versão manualmente. GameSelector - - - - - - - - - + + + + + + + + + Error Erro - + Please join the appropriate room first. Por favor, entre na sala apropriada primeiramente - + Wrong password. Senha incorreta. - + Spectators are not allowed in this game. Não são permitidos visitantes neste jogo. - + The game is already full. O jogo está cheio. - + The game does not exist any more. O jogo não existe mais. - + This game is only open to registered users. Este jogo é aberto apenas para usuários registrados. - + This game is only open to its creator's buddies. Este jogo é aberto apenas para os amigos de quem criou o jogo. - + You are being ignored by the creator of this game. Você está sendo ignorado pelo criador deste jogo. - + Join Game Entrar no jogo - + Spectate Game Assistir ao jogo - + Game Information Informações do jogo - + Join Game as Judge Entrar no Jogo como Juíz - + Spectate Game as Judge - + Join game Entrar no jogo - + Password: Senha: - + Please join the respective room first. Por favor, entre na respectiva sala primeiro. - + &Filter games &Filtrar jogos. - + C&lear filter L&impar Filtros - + C&reate C&riar - + &Join &Entrar - + Join as judge - + J&oin as spectator E&ntrar como visitante - + Join as judge spectator - + Games shown: %1 / %2 Jogos mostrados: %1 / %2 - + Games Jogos @@ -3992,32 +4458,32 @@ Talvez você tenha que baixar a nova versão manualmente. GameSelectorQuickFilterToolBar - + All types - + Filter by game name... - + Filter by game type/format - + Hide games not created by buddies - + Hide full games - + Hide started games @@ -4025,105 +4491,105 @@ Talvez você tenha que baixar a nova versão manualmente. GamesModel - + >1 day >1 dia - + %1%2 hr short age in hours %1%2 h%1%2 h%1%2 h - + new novo - + %1%2 min short age in minutes %1%2 min%1%2 min%1%2 min - + password senha - + buddies only apenas amigos - + reg. users only usuários reg. apenas - + open decklists - + can chat pode conversar - + see hands ver mãos - + can see hands pode ver mãos - + not allowed não permitidos - + Room Sala - + Age Idade - + Description Descrição - + Creator Criador - + Type Tipo - + Restrictions Restrições - + Players Jogadores - + Spectators Visitantes @@ -4131,143 +4597,158 @@ Talvez você tenha que baixar a nova versão manualmente. GeneralSettingsPage - + Reset all paths Redefinir todos os diretórios - + All paths have been reset Todos os caminhos para diretórios foram redefinidos - - - - - - - + + + + + + + Choose path Escolher caminho - - Personal settings - Configurações pessoais + + Language settings + - + Language: Idioma: - + + Version settings + + + + + Card database + + + + + Startup settings + + + + Paths (editing disabled in portable mode) Caminhos (edição desabilitada no modo portátil) - + Paths Caminhos - + How to help with translations Como ajudar com as traduções - + Decks directory: Pasta de decks: - + Filters directory: - + Replays directory: Pasta de replays: - + Pictures directory: Pasta de imagens: - + Card database: Banco de dados de cartas: - + Custom database directory: Diretório do banco de dados de cartas customizadas - + Token database: Banco de dados de fichas: - + Update channel Canal de atualização - + Check for client updates on startup Verificar atualizações do cliente ao iniciar - + Check for card database updates on startup - + Don't check - + Prompt for update - + Always update in the background - + Check for card database updates every - + days - + Notify if a feature supported by the server is missing in my client Notificar se uma função suportada pelo servidor está faltando em meu programa. - + Automatically run Oracle when running a new version of Cockatrice Rodar Oracle automaticamente quando rodar uma nova versão de Cockatrice - + Show tips on startup Exibir dicas ao iniciar - + Last update check on %1 (%2 days ago) @@ -4275,47 +4756,47 @@ Talvez você tenha que baixar a nova versão manualmente. GraveyardMenu - + &Graveyard - + &View graveyard - + &Move graveyard to... - + &Top of library - + &Bottom of library - + &All players - + &Hand - + &Exile - + Reveal random card to... @@ -4323,88 +4804,88 @@ Talvez você tenha que baixar a nova versão manualmente. HandMenu - + &Hand - + &View hand - + Sort hand by... - + Name - + Type - + Mana Value - + Take &mulligan (Choose hand size) - + Take mulligan (Same hand size) - + Take mulligan (Hand size - 1) - + &Move hand to... - + &Top of library - + &Bottom of library - + &Graveyard - + &Exile - + &Reveal hand to... - - + + All players - + Reveal r&andom card to... @@ -4412,52 +4893,52 @@ Talvez você tenha que baixar a nova versão manualmente. HomeWidget - + Create New Deck - + Browse Decks - + Browse Card Database - + Browse EDHRec - + Browse Archidekt - + View Replays - + Quit - + Connecting... - + Connect - + Play @@ -4465,213 +4946,213 @@ Talvez você tenha que baixar a nova versão manualmente. LibraryMenu - + &Library - + &View library - + View &top cards of library... - + View bottom cards of library... - + Reveal &library to... - + Lend library to... - + Reveal &top cards to... - + &Top of library... - + &Bottom of library... - + &Always reveal top card - + &Always look at top card - + &Open deck in deck editor - + &Draw card - + D&raw cards... - + &Undo last draw - + Shuffle - + &Play top card - + Play top card &face down - + Put top card on &bottom - + Move top card to grave&yard - + Move top card to e&xile - + Move top cards to &graveyard... - + Move top cards to graveyard face down... - + Move top cards to &exile... - + Move top cards to exile face down... - + Put top cards on stack &until... - + Shuffle top cards... - + &Draw bottom card - + D&raw bottom cards... - + &Play bottom card - + Play bottom card &face down - + Move bottom card to grave&yard - + Move bottom card to e&xile - + Move bottom cards to &graveyard... - + Move bottom cards to graveyard face down... - + Move bottom cards to &exile... - + Move bottom cards to exile face down... - + Put bottom card on &top - + Shuffle bottom cards... - - + + &All players - + Reveal top cards of library - + Number of cards: (max. %1) @@ -4679,641 +5160,273 @@ Talvez você tenha que baixar a nova versão manualmente. MainWindow - - - The server has reached its maximum user capacity, please check back later. - O servidor atingiu a capacidade maxima de usuarios, tente novamente mais tarde. - - - - There are too many concurrent connections from your address. - Há conexões concorrentes demais vinds do seu endereço. - - - - Banned by moderator - Banido pelo moderador - - - - Expected end time: %1 - Esperado tempo para fim: %1 - - - - This ban lasts indefinitely. - Este banimento dura indefinidamente. - - - - Scheduled server shutdown. - Servidor de eventos fora do ar. - - - - - Invalid username. - Usuário inválido - - - - You have been logged out due to logging in at another location. - Você foi desconectado devido a um acesso em outra localidade. - - - - Connection closed - Conexão fechada - - - - The server has terminated your connection. -Reason: %1 - O servidor terminou sua conexão. -Razão: %1 - - - - The server is going to be restarted in %n minute(s). -All running games will be lost. -Reason for shutdown: %1 - O servidor será reiniciado em %n minuto(s). -Todos os jogos serão perdidos. -Motivo para desligar: %1O servidor será reiniciado em %n minuto(s). -Todos os jogos serão perdidos. -Motivo para desligar: %1O servidor será reiniciado em %n minuto(s). -Todos os jogos serão perdidos. -Motivo para desligar: %1 - - - - Scheduled server shutdown - Servidor de eventos fora do ar - - - - - Success - Sucesso - - - - Registration accepted. -Will now login. - Registro aceito -Irá entrar agora - - - - Account activation accepted. -Will now login. - Ativação da conta aceita. -Irá entrar agora. - - - - + + Player %1 Jogador %1 - + Load replay Carregar replay - + About Cockatrice Sobre o Cockatrice - + Version Versão - + Cockatrice Webpage Pagina da Web do Cockatrice - + Project Manager: Diretor do projeto: - + Past Project Managers: Antigos diretores do projeto: - + Developers: Desenvolvedores: - + Our Developers Nossos desenvolvedores - + Help Develop! Ajude a desenvolver! - + Translators: Tradutores: - + Our Translators Nossos tradutores. - + Help Translate! Ajude a traduzir! - + Support: Suporte: - + Report an Issue Reporte um problema: - + Troubleshooting Solução de problemas - + F.A.Q. F.A.Q. - - - - - - - - - - - - - - - - - - - - + + Error Erro - - Server timeout - Tempo esgotado do servidor - - - - Failed Login - Login falhou - - - - Your client seems to be missing features this server requires for connection. - Seu cliente parece estar faltando recursos que este servidor requer para conexão. - - - - To update your client, go to 'Help -> Check for Client Updates'. - Para atualizar seu cliente, vá para 'Ajuda -> Procurar por atualizações do cliente'. - - - - Incorrect username or password. Please check your authentication information and try again. - Usuário ou senha incorretos. Por favor, verifique suas informações de autenticação e tente novamente. - - - - There is already an active session using this user name. -Please close that session first and re-login. - Já existe uma sessão ativa usando este nome de usuário. -Por favor, feche a sessão primeiro e logue novamente. - - - - - You are banned until %1. - Você está banido até %1. - - - - - You are banned indefinitely. - Você está banido indefinidamente. - - - - This server requires user registration. Do you want to register now? - Este servidor exige registro de usuário. Você gostaria de registrar agora? - - - - This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. -Please close and reopen your client to try again. - Este servidor requer ID de cliente. Seu cliente ou não está conseguindo gerar um ID ou você está executando um cliente modificado. -Por favor feche e abra seu cliente para tentar novamente. - - - - An internal error has occurred, please close and reopen Cockatrice before trying again. -If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. - Ocorreu um erro interno, por favor, feche e reabra o Cockatrice antes de tentar novamente. -Se o erro persistir, verifique se você está executando a versão mais recente do software e, se necessário, entre em contato com os desenvolvedores de software. - - - - Account activation - Ativação da conta - - - - Your account has not been activated yet. -You need to provide the activation token received in the activation email. - Sua conta não foi ativada ainda -Você precisa fornecer o código de ativação recebido por email. - - - - Server Full - Servidor cheio. - - - - Unknown login error: %1 - Erro de login desconhecido: %1 - - - - - -This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. - -Isso geralmente significa que seu cliente está obsoleto, e o servidor enviou uma resposta que seu cliente não entende. - - - - Your username must respect these rules: - Seu nome de usuário precesia seguir essas regras: - - - - is %1 - %2 characters long - tem entre %1 e %2 caracteres - - - - can %1 contain lowercase characters - %1 pode conter caracteres minúsculos. - - - - - - - NOT - NÃO - - - - can %1 contain uppercase characters - %1 pode conter caracteres maiúsculos. - - - - can %1 contain numeric characters - %1 pode conter caracteres numéricos. - - - - can contain the following punctuation: %1 - Pode conter a seguinte pontuação: %1 - - - - first character can %1 be a punctuation mark - Primeiro caractere pode %1 ser um sinal de pontuação - - - - no unacceptable language as specified by these server rules: - note that the following lines will not be translated - Nenhuma linguagem inaceitável, conforme especificado por estas regras do servidor: - - - - can not contain any of the following words: %1 - Não pode conter nenhuma das seguintes palavras: %1 - - - - can not match any of the following expressions: %1 - Não pode corresponder a nenhuma das seguintes expressões: %1 - - - - You may only use A-Z, a-z, 0-9, _, ., and - in your username. - Você pode usar apenas A-Z, a-z, 0-9, _, ., e - em seu nome de usuário. - - - - - - - - - Registration denied - Registro negado - - - - Registration is currently disabled on this server - Registro está atualmente desabilitado neste servidor - - - - There is already an existing account with the same user name. - Já existe uma conta com este nome de usuário. - - - - It's mandatory to specify a valid email address when registering. - É obrigatório especificar um endereço de email válido para registrar. - - - - It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. - Aparentemente você está tentando registrar uma nova conta nesse servidor, porém você já tem uma conta registrada com o e-mail fornecido. Este servidor restringe a quantidade de contas que podem ser registradas por endereço. Por favor contate o operador do servidor para assistência posterior ou para obter informações das suas credenciais. - - - - Password too short. - Senha muito curta - - - - Registration failed for a technical problem on the server. - Registro falhou devido a um problema tecnico no servidor. - - - - The connection to the server has been lost. - - - - - Unknown registration error: %1 - Erro de registro desconhecido: %1 - - - - Account activation failed - A ativação de conta falhou - - - - Socket error: %1 - Erro de ligação:%1 - - - - You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. -Local version is %1, remote version is %2. - Você está tentando conectar a um servidor obsoleto. Por favor, faça um downgrade na versão do seu Cockatrice ou conecte-se ao servidor correto. -A versão local é %1 e a versão remota é %2. - - - - Your Cockatrice client is obsolete. Please update your Cockatrice version. -Local version is %1, remote version is %2. - A versão do seu Cockatrice é obsoleta. Por favor, atualize a sua versão. -A versão local é %1 e a versão remota é %2. - - - - Connecting to %1... - Conectando a %1... - - - - Registering to %1 as %2... - Registrando em %1 como %2... - - - - Disconnected - Desconectado - - - - Connected, logging in at %1 - Conectado, logando em %1 - - - - - - Requesting forgotten password to %1 as %2... - Solicitando senha esquecida para %1 como %2... - - - + &Connect... &Conectar... - + &Disconnect &Desconectar - + Start &local game... Iniciar jogo &local... - + &Watch replay... &Assistir replay... - + &Full screen Tela &cheia - + &Register to server... &Registrar ao servidor: - + &Restore password... &Recuperar senha... - + &Settings... &Configurações... - + &Exit &Sair - + A&ctions Açõ&es - + &Cockatrice &Cockatrice - + C&ard Database Base de Cartas - + &Manage sets... &Gerenciar expansões... - + Edit custom &tokens... Editar &fichas personalizadas - + Open custom image folder Abrir pasta customizada de imagens. - + Open custom sets folder Abrir pasta de sets personalizados - + Add custom sets/cards Adicionar sets/cartas personalizadas - + Reload card database Recarregar banco de dados de cartas - + Tabs Abas - + &Help &Ajuda - + &About Cockatrice So&bre o Cockatrice - + &Tip of the Day &Dica do Dia - + Check for Client Updates Procurar por atualizações do cliente - + Check for Card Updates... Procurar por atualizações de cartas... - + Check for Card Updates (Automatic) - + Show Status Bar - + View &Debug Log Ver registro de &depuração - + Open Settings Folder Abrir Pasta de Configurações - + Show/Hide Mostrar/Ocultar - + New Version Nova Versão - + Congratulations on updating to Cockatrice %1! Oracle will now launch to update your card database. Parabéns por atualizar o Cockatrice %1! Oracle será inicializado agora para atualizar sua base de cartas - + Cockatrice installed Cockatrice instalado - + Congratulations on installing Cockatrice %1! Oracle will now launch to install the initial card database. Parabéns pela instalação do Cockatrice %1! O Oracle agora será iniciado para instalar o banco de dados inicial de cartas. - + Card database Base de cartas - + Cockatrice is unable to load the card database. Do you want to update your card database now? If unsure or first time user, choose "Yes" @@ -5322,29 +5435,29 @@ Gostaria de atualizar seu banco de dados de cartas agora? Se estiver em dúvida ou for um novo usuário selecione "Sim" - - + + Yes Sim - - + + No Não - + Open settings Abrir configurações - + New sets found Novas expansões encontradas - + %n new set(s) found in the card database Set code(s): %1 Do you want to enable it/them? @@ -5357,17 +5470,22 @@ Códigos das coleções: %1 Deseja ativá-las? - + + Yes, always enable + + + + View sets Visualizar expansões - + Welcome Bem vindo - + Hi! It seems like you're running this version of Cockatrice for the first time. All the sets in the card database have been enabled. Read more about changing the set order or disabling specific sets and consequent effects in the "Manage Sets" dialog. @@ -5376,166 +5494,132 @@ Todas as expansões do banco de dados de cartas foram habilitadas. Leia mais sobre mudar a ordem das expansões ou desabilitando expansões específicas na aba "Gerenciar Expansões". - - + Information Informação - + A card database update is already running. Uma atualização da base de dados de cartas já está em andamento. - + Unable to run the card database updater: Não foi possível executar a atualização da base de dados de cartas. - + Card database update running. - + Failed to start. The file might be missing, or permissions might be incorrect. Falha ao começar. O arquivo pode não existir ou as permissões estão incorretas. - + The process crashed some time after starting successfully. - + Timed out. The process took too long to respond. The last waitFor...() function timed out. - + An error occurred when attempting to write to the process. For example, the process may not be running, or it may have closed its input channel. - + An error occurred when attempting to read from the process. For example, the process may not be running. - + Unknown error occurred. - + The card database updater exited with an error: %1 - - This server supports additional features that your client doesn't have. -This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. - -To update your client, go to Help -> Check for Updates. - Esse servidor oferece suporte a recursos adicionais que seu cliente não possui. -Provavelmente não é um problema, mas esta mensagem pode significar que existe uma nova versão do Cockatrice disponível ou este servidor está executando uma versão personalizada ou pré-lançamento. - -Para atualizar seu cliente, vá para Ajuda -> Verificar atualizações. - - - - - - - + + + + + Load sets/cards Carregar expansões/cards - + Selected file cannot be found. Arquivo selecionado não pode ser encontrado - + You can only import XML databases at this time. Você só pode importar bancos de dados XML neste momento. - + The new sets/cards have been added successfully. Cockatrice will now reload the card database. As novas cartas/coleções foram adicionadas com sucesso. O Cockatrice reiniciará imediatamente o banco de dados de cartas. - + Sets/cards failed to import. Falha ao importar expansões/cartas. - - - - - Reset Password - Redefinir Senha - - - - Your password has been reset successfully, you can now log in using the new credentials. - Sua senha foi alterada com sucesso, você pode se conectar usando suas novas credenciais - - - - Failed to reset user account password, please contact the server operator to reset your password. - Falha ao redefinir a senha da conta do usuário, entre em contato com o operador do servidor para redefinir sua senha. - - - - Activation request received, please check your email for an activation token. - Solicitação de ativação recebida, verifique seu e-mail para um token de ativação. - ManaBaseConfigDialog - + Mana Base Configuration - + Display type: - + pie - + bar - + combinedBar - + Filter Colors (optional): - + OK - + Cancel @@ -5607,27 +5691,27 @@ O Cockatrice reiniciará imediatamente o banco de dados de cartas. ManaDevotionConfigDialog - + Display type: - + pie - + bar - + combinedBar - + Filter Colors (optional): @@ -5697,610 +5781,615 @@ O Cockatrice reiniciará imediatamente o banco de dados de cartas. MessageLogWidget - + from play do jogo - + from their graveyard do seu cemitério - + from exile vindo do exílio - + from their hand da sua mão - + the top card of %1's library a carta do topo do grimório de %1 - + the top card of their library a carta do topo do seu grimório - + from the top of %1's library do topo do grimório de %1 - + from the top of their library do topo do seu grimório - + the bottom card of %1's library a carta do fundo do grimório de %1 - + the bottom card of their library a carta do fundo do seu grimório - + from the bottom of %1's library do fundo do grimório de %1 - + from the bottom of their library do fundo do seu grimório - + from %1's library do grimório de %1 - + from their library do seu grimório - + from sideboard vindo do sideboard - + from the stack vindo da pilha - + from custom zone '%1' - + %1 is now keeping the top card %2 revealed. %1 está mantendo as carta do topo %2 reveladas. - + %1 is not revealing the top card %2 any longer. %1 não está mais revelando as cartas do topo %2. - + %1 can now look at top card %2 at any time. %1 agora pode olhar a carta do topo de %2 a qualquer momento. - + %1 no longer can look at top card %2 at any time. %1 não pode mais olhar a carta do topo de %2 a qualquer momento. - + %1 attaches %2 to %3's %4. %1 vincula %2 a %4 de %3. - + %1 has conceded the game. %1 concedeu o jogo. - + %1 has unconceded the game. %1 desfez a concessão do jogo - + %1 has restored connection to the game. %1 restaurou a conexão com o jogo. - + %1 has lost connection to the game. %1 perdeu conexão com o jogo. - + %1 points from their %2 to themselves. %1 aponta de sua %2 para si - + %1 points from their %2 to %3. %1 aponta de sua %2 para %3. - + %1 points from %2's %3 to themselves. %1 aponta da %3 de %2 para si - + %1 points from %2's %3 to %4. %1 aponta da %3 de %2 para %4 - + %1 points from their %2 to their %3. %1 aponta da sua %2 para sua %3 - + %1 points from their %2 to %3's %4. %1 aponta de sua %2 para %4 de %3 - + %1 points from %2's %3 to their own %4. %1 aponta da %3 de %2 para seu próprio %4. - + %1 points from %2's %3 to %4's %5. %1 aponta da %3 de %2 para a %5 de %4. - + %1 creates a face down token. - + %1 creates token: %2%3. %1 cria uma ficha: %2%3. - + %1 has loaded a deck (%2). %1 carregou um deck (%2). - + %1 has loaded a deck with %2 sideboard cards (%3). %1 carregou um deck com %2 cartas no sideboard (%3). - + %1 destroys %2. %1 destrói %2. - + a card um card - + %1 gives %2 control over %3. %1 dá controle para %2 sobre %3. - + %1 puts %2 into play%3 face down. %1 coloca %2 em jogo%3 com a face voltada para baixo. - + %1 puts %2 into play%3. %1 põe %2 no campo de batalha %3. - + %1 puts %2%3 into their graveyard face down. - + %1 puts %2%3 into their graveyard. %1 coloca %2%3 em seu cemitério. - + %1 exiles %2%3 face down. - + %1 exiles %2%3. %1 exila %2%3. - + %1 moves %2%3 to their hand. %1 moveu %2%3 para sua mão. - + %1 puts %2%3 into their library. %1 coloca %2%3 em seu grimório - + %1 puts %2%3 onto the bottom of their library. %1 coloca %2%3 no fundo de seu grimório. - + %1 puts %2%3 on top of their library. %1 coloca %2%3 no topo de seu grimório. - + %1 puts %2%3 into their library %4 cards from the top. %1 coloca %2%3 do seu grimório %4 cartas abaixo do topo. - + %1 moves %2%3 to sideboard. %1 move %2%3 para o sideboard. - + %1 plays %2%3 face down. - + %1 plays %2%3. %1 põe %2 na pilha%3. - + %1 moves %2%3 to custom zone '%4' face down. - + %1 moves %2%3 to custom zone '%4'. - + %1 tries to draw from an empty library %1 tenta comprar de uma biblioteca vazia - + %1 draws %2 card(s). %1 comprou %2 carta(s).%1 comprou %2 carta(s).%1 comprou %2 carta(s). - + %1 is looking at %2. %1 está olhando para %2. - + %1 is looking at the %4 %3 card(s) %2. top card for singular, top %3 cards for plural %1 está olhando para a(s) %3 carta(s) %4 %2.%1 está olhando para a(s) %3 carta(s) %4 %2.%1 está olhando para a(s) %3 carta(s) %4 %2. - + bottom de fundo - + top de topo - + %1 turns %2 face-down. %1 turnos %2 cartas viradas para baixo. - + %1 turns %2 face-up. %1 turnos %2 cartas viradas para cima. - + The game has been closed. O jogo foi fechado. - + The game has started. O jogo começou. - + You are flooding the game. Please wait a couple of seconds. - + %1 has joined the game. %1 entrou no jogo. - + %1 is now watching the game. %1 está assistindo o jogo agora. - + You have been kicked out of the game. Você foi 'chutado' do jogo. - + %1 has left the game (%2). %1 deixou o jogo (%2) - + %1 is not watching the game any more (%2). %1 não está mais assistindo o jogo (%2). - + %1 is not ready to start the game any more. %1 não está mais pronto para inciar o jogo. - + %1 shuffles their deck and draws a new hand of %2 card(s). %1 embaralhou seu grimório e comprou uma nova mão com %2 card.%1 embaralhou seu grimório e comprou uma nova mão com %2 cards.%1 embaralhou seu grimório e comprou uma nova mão com %2 cards. - + %1 shuffles their deck and draws a new hand. %1 embaralhou seu grimório e comprou uma nova mão. - + You are watching a replay of game #%1. Você está assistindo o replay do jogo #%1. - + %1 is ready to start the game. %1 está pronto para inciar o jogo. - + cards an unknown amount of cards cartas - + %1 card(s) a card for singular, %1 cards for plural %1 carta%1 carta(s)%1 carta(s) - + %1 lends %2 to %3. %1 empresta %2 para %3. - + %1 reveals %2 to %3. %1 revela %2 para %3. - + %1 reveals %2. %1 revela %2. - + %1 randomly reveals %2%3 to %4. %1 revela aleatoriamente %2%3 para %4. - + %1 randomly reveals %2%3. %1 revela aleatoriamente %2%3. - + %1 peeks at face down card #%2. %1 olha a carta virada para baixo #%2. - + %1 peeks at face down card #%2: %3. %1 espia a face para baixo da carta #%2: %3. - + %1 reveals %2%3 to %4. %1 revela %2 %3 para %4. - + %1 reveals %2%3. %1 revela %2%3. - + %1 reversed turn order, now it's %2. %1 reverteu a ordem dos turnos, agora é a vez de %2. - + reversed revertido - + normal normal - + Heads Cara - + Tails Coroa - + %1 flipped a coin. It landed as %2. %1 jogou uma moeda. Saiu %2. - + %1 rolls a %2 with a %3-sided die. %1 tirou %2 em um D%3. - + %1 flips %2 coins. There are %3 heads and %4 tails. %1 joga %2 moedas. São %3 caras e %4 coroas. - + %1 rolls a %2-sided dice %3 times: %4. %1 rola um dado de %2 lados %3 vezes: %4. - + %1's turn. Turno de %1. - + %1 sets annotation of %2 to %3. %1 altera a anotação de %2 para %3. - - %1 places %2 "%3" counter(s) on %4 (now %5). + + %1 places %2 %3%4 counter(s) on %5 (now %6). - - %1 removes %2 "%3" counter(s) from %4 (now %5). + + %1 removes %2 %3%4 counter(s) from %5 (now %6). - + + %1 failed to undo their last draw. + + + + %1 sets counter %2 to %3 (%4%5). %1 ajusta o marcador %2 para %3 (%4%5). - + %1 sets %2 to not untap normally. %1 determina que %2 não desvira normalmente. - + %1 sets %2 to untap normally. %1 determina que %2 desvira normalmente. - + %1 removes the PT of %2. %1 remove o P/R de %2 - + %1 changes the PT of %2 from nothing to %4. %1 alterar o PR de %2 de nada para %4. - + %1 changes the PT of %2 from %3 to %4. %1 alterar o P/R de %2 de %3 para %4. - + %1 has locked their sideboard. %1 bloqueou seu sideboard - + %1 has unlocked their sideboard. %1 desbloqueou seu sideboard - + %1 taps their permanents. %1 vira suas permanentes. - + %1 untaps their permanents. %1 desvira suas permanentes. - + %1 taps %2. %1 vira %2 - + %1 untaps %2. %1 desvira %2. - + %1 shuffles %2. %1 embaralha %2 - + %1 shuffles the bottom %3 cards of %2. %1 embaralha as %3 cartas do fundo de %2 - + %1 shuffles the top %3 cards of %2. %1 embaralha as %3 cartas do topo de %2 - + %1 shuffles cards %3 - %4 of %2. %1 embaralha as cartas %3 - %4 de %2 - + %1 unattaches %2. %1 desanexa %2. - + %1 undoes their last draw. %1 desfaz sua última compra. - + %1 undoes their last draw (%2). %1 desfaz sua última compra (%2). @@ -6308,110 +6397,115 @@ O Cockatrice reiniciará imediatamente o banco de dados de cartas. MessagesSettingsPage - + Word1 Word2 Word3 Palavra1 Palavra2 Palavra3 - + Add New Message Adicionar nova mensagem - + Edit Message Editar mensagem - + Remove Message Remover mensagem - + Add message Adicionar mensagem - - + + Message: Mensagem: - + Edit message Editar mensagem - + Chat settings Configurações de Conversa - + Custom alert words Palavras de alertas customizadas. - + Enable chat mentions Habilitar menções em conversa - + Enable mention completer Habilitar completador de menções. - + In-game message macros Mensagem no jogo - + How to use in-game message macros Como usar macros de mensagens no jogo - + Ignore chat room messages sent by unregistered users Ignorar mensagens na sala enviadas por usuários não registrados - + Ignore private messages sent by unregistered users Ignorar mensagens privadas enviadas por usuários não registrados - - + + Ignore private messages sent by non-buddy users + + + + + Invert text color Inverter cor do texto - + Enable desktop notifications for private messages Ativar notificações da área de trabalho para mensagens privadas. - + Enable desktop notification for mentions Habilitar notificações de desktop para menções. - + Enable room message history on join Ativar histórico de mensagens da sala ao entrar - - + + (Color is hexadecimal) (Cor em hexadecimal) - + Separate words with a space, alphanumeric characters only Separar palavras com um espaço, apenas caracteres alfanuméricos @@ -6419,42 +6513,42 @@ O Cockatrice reiniciará imediatamente o banco de dados de cartas. MoveMenu - + Move to - + &Top of library in random order - + X cards from the top of library... - + &Bottom of library in random order - + T&able - + &Hand - + &Graveyard - + &Exile @@ -6467,47 +6561,47 @@ O Cockatrice reiniciará imediatamente o banco de dados de cartas. Tipo de Carta - + Mana Value Valor de Mana - + Color(s) Cor(es) - + Loyalty Lealdade - + Main Card Type Tipo de Carta Principal - + Mana Cost Custo de Mana - + P/T P/R - + Side Side - + Layout Layout - + Color Identity Identidade de Cor @@ -6530,6 +6624,152 @@ O Cockatrice reiniciará imediatamente o banco de dados de cartas. Direção da sobreposição: + + PaletteEditorDialog + + + + Reset + + + + + + Apply + + + + + + Save && Apply + + + + + ▼ Edit Palette + + + + + + ▶ Edit Palette + + + + + Palette Editor — %1 + + + + + <b>Palette Editor</b> &nbsp;·&nbsp; %1 + + + + + This theme ships no default palette files + + + + + Replace current colours with the theme author's defaults + + + + + Switch between the light and dark palette files + + + + + Editing: + + + + + Show or hide the per-role colour grid for manual tweaks + + + + + ↺ Revert to theme default + + + + + Discard unsaved edits and restore the last saved palette + + + + + Preview this palette without saving to disk + + + + + Write palette-%1.toml and reload the theme + + + + + Cannot save: this theme has no directory on disk + + + + + Save failed + + + + + Could not write %1 to: +%2 + + + + + No default found + + + + + No default palette file found for the "%1" scheme. + + + + + PaletteGridWidget + + + Active + + + + + Disabled + + + + + Inactive + + + + + Normal interactive state + + + + + Widget is disabled / not interactive + + + + + Window is in background / unfocused + + + Phase @@ -6596,57 +6836,57 @@ O Cockatrice reiniciará imediatamente o banco de dados de cartas. PhasesToolbar - + Untap step Etapa de desvirar - + Upkeep step Etapa de manutenção - + Draw step Etapa de compra - + First main phase Primeira fase principal - + Beginning of combat step Etapa de início de combate - + Declare attackers step Etapa de declaracão de atacantes - + Declare blockers step Etapa de declaração de bloqueadores - + Combat damage step Etapa de dano de combate - + End of combat step Etapa de fim de combate - + Second main phase Segunda fase principal - + End of turn step Etapa de fim de combate @@ -6654,7 +6894,7 @@ O Cockatrice reiniciará imediatamente o banco de dados de cartas. PictureLoader - + en code for scryfall's language property, not available for all languages pt @@ -6663,151 +6903,142 @@ O Cockatrice reiniciará imediatamente o banco de dados de cartas. PlayerActions - - View top cards of library - - - - - - - - - - - - - Number of cards: (max. %1) - - - - - View bottom cards of library - - - - - Shuffle top cards of library - - - - - Shuffle bottom cards of library - - - - - Draw hand - - - - - 0 and lower are in comparison to current hand size - - - - - Draw cards - - - - - - - + + + + grave - - - - + + + + exile + + + PlayerDialogs - + + View top cards of library + + + + + + + + + + + + + Number of cards: (max. %1) + + + + + View bottom cards of library + + + + + Shuffle top cards of library + + + + + Shuffle bottom cards of library + + + + + Draw hand + + + + + 0 and lower are in comparison to current hand size + + + + + Draw cards + + + + Move top cards to %1 - + Move bottom cards to %1 - + Draw bottom cards - - - C&reate another %1 token - - - - + Create tokens - - + Number: - + Place card X cards from top of library - + Which position should this card be placed: - + (max. %1) - + Change power/toughness - + Change stats to: - + Set annotation - + Please enter the new annotation: - - - Set counters - - PlayerMenu - + Player "%1" - + &Counters @@ -6837,7 +7068,7 @@ This setting means you'll only see the default printing for each card, instead o - + Printing Selector @@ -6845,22 +7076,22 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelectorCardOverlayWidget - + Preference Preferência - + Pin Printing Fixar Impressão - + Unpin Printing Desafixar Impressão - + Show Related cards Mostrar Cartas Relacionadas @@ -6931,57 +7162,57 @@ This setting means you'll only see the default printing for each card, instead o PtMenu - + Power / toughness - + &Increase power - + &Decrease power - + I&ncrease toughness - + D&ecrease toughness - + In&crease power and toughness - + Dec&rease power and toughness - + Increase power and decrease toughness - + Decrease power and increase toughness - + Set &power and toughness... - + Reset p&ower and toughness @@ -6989,37 +7220,37 @@ This setting means you'll only see the default printing for each card, instead o QMenuBar - + Services Serviços - + Hide %1 Esconder %1 - + Hide Others Esconder outros - + Show All Mostrar tudo - + Preferences... Preferências - + Quit %1 Sair %1 - + About %1 Sobre %1 @@ -7027,17 +7258,17 @@ This setting means you'll only see the default printing for each card, instead o QObject - + Cockatrice card database (*.xml) Banco de dados de cartas do Cockatrice (*.xml) - + All files (*.*) Todos arquivos (*.*) - + Cockatrice replays (*.cor) Cockatrice replays (*.cor) @@ -7097,110 +7328,177 @@ Are you sure you would like to disable this feature? QPlatformTheme - + OK OK - + Save Salvar - + Save All Salvar Todos - + Open Abrir - + &Yes &Sim - + Yes to &All Sim para &Todos - + &No &Não - + N&o to All Não para &Todos - + Abort Abortar - + Retry Tentar Novamente - + Ignore Ignorar - + Close Fechar - + Cancel Cancelar - + Discard Descartar - + Help Ajuda - + Apply Aplicar - + Reset Resetar - + Restore Defaults Restaurar Padrão + + QuickSetupPanel + + + %1% + + + + + <b>Quick Setup</b> + + + + + Generate all palette roles automatically from a single accent colour + + + + + Accent: + + + + + Primary hue. Used directly for highlights and links. +At high intensity it also tints buttons and backgrounds. + + + + + Intensity: + + + + + Subtle + + + + + Full colour + + + + + 0–30 Subtle tint — only highlights and links change hue +30–70 Accented — buttons, tooltips, and borders join in +70–100 Full colour — backgrounds, everything + + + + + 70% + + + + + Generate ↓ + + + + + Derive all palette roles from the accent colour above. +Fine-tune individual colours in the grid afterwards. + + + RemoteDeckList_TreeModel - + Name Nome - + ID ID - + Upload time Hora do upload @@ -7208,32 +7506,32 @@ Are you sure you would like to disable this feature? RemoteReplayList_TreeModel - + ID Código - + Name Nome - + Players Jogadores - + Keep Manter - + Time started Tempo iniciado - + Duration (sec) Duração (seg) @@ -7241,37 +7539,37 @@ Are you sure you would like to disable this feature? RfgMenu - + &Exile - + &View exile - + &Move exile to... - + &Top of library - + &Bottom of library - + &Hand - + &Graveyard @@ -7317,7 +7615,7 @@ Are you sure you would like to disable this feature? SayMenu - + S&ay @@ -7358,27 +7656,27 @@ Are you sure you would like to disable this feature? SetsModel - + Enabled Ativado - + Set type Selecione tipo - + Set code Selecione código - + Long name Nome longo - + Release date Data lançamento @@ -7386,53 +7684,53 @@ Are you sure you would like to disable this feature? ShortcutSettingsPage - - + + Restore all default shortcuts Restaurar todos atalhos padrões - + Do you really want to restore all default shortcuts? Você tem certeza que deseja restaurar todos atalhos padrões? - + Clear all default shortcuts Limpar todos os atalhos padrão - + Do you really want to clear all shortcuts? Você realmente quer limpar todos os atalhos? - + Section: Seção: - + Action: Ação: - + Shortcut: Atalho: - + How to set custom shortcuts Como definir atalhos personalizados - + Clear all shortcuts Limpar todos os atalhos - + Search by shortcut name Pesquisar pelo nome do atalho @@ -7488,12 +7786,12 @@ Por favor, verifique suas configurações de atalho! SideboardMenu - + &Sideboard - + &View sideboard @@ -7501,27 +7799,27 @@ Por favor, verifique suas configurações de atalho! SoundSettingsPage - + Enable &sounds Habilitar &sons - + Current sounds theme: Tema sonoro atual: - + Test system sound engine Testar sistema de som - + Sound settings Configurações de som - + Master volume Volume principal @@ -7593,16 +7891,146 @@ Por favor, verifique suas configurações de atalho! Resposta inválida do servidor de atualização - + No reply received from the tag update server. Nenhuma resposta recebida do servidor de atualização de tags. - + Invalid reply received from the tag update server. Resposta inválida recebida do servidor de atualização de tags. + + StorageSettingsPage + + + + + Success + + + + + Cached card pictures have been reset. + + + + + Downloaded card pictures have been reset. + + + + + Error + + + + + One or more downloaded card pictures could not be cleared. + + + + + In-memory (currently loaded) card pictures have been reset. + + + + + Card Picture Loader Caching Method: + + + + + The network cache is the preferred way of storing images. Downloaded images are stored here until the size of the cache exceeds the configured size. Cockatrice automatically monitors this cache and deletes the least recently seen card images to ensure the cache does not exceed the configured size. + + + + + Writing card images directly to a folder on your hard drive is another way of storing images. This does not change how Cockatrice accesses or downloads images. Cockatrice will NOT automatically monitor and clear this folder, so if you enable this option, it is up to you to ensure sufficient available space. It should also be noted that if a provider outage causes you to download the wrong picture (i.e. wrong printing) you will be stuck with it until you manually delete the file, as opposed to using the network cache, which automatically rotates and thus correct errors after a while. + + + + + This is the in-memory picture cache used by the application at runtime. It determines how much memory (RAM) Cockatrice can use before it has to fetch card images from the hard disk again. Increasing this will allow more card images to be displayed at once but shouldn't be necessary. Clearing this will make Cockatrice reload all images from the network cache or the disk. + + + + + Delete Cached Images + + + + + Delete Saved Images + + + + + Clear In-Memory Images + + + + + Card Picture Loader Cache Method + + + + + Network Cache + + + + + Filesystem + + + + + In-Memory Picture Cache + + + + + Network Cache Size: + + + + + On-disk cache for downloaded pictures + + + + + Redirect Cache TTL: + + + + + How long cached redirects for urls are valid for. + + + + + Picture Cache Size: + + + + + In-memory cache for pictures not currently on screen + + + + + Naming scheme: + + + + + Day(s) + + + TabAccount @@ -7738,117 +8166,117 @@ Por favor, verifique suas configurações de atalho! TabArchidekt - - + + Desc. - - + + AND - - + + Require ALL selected colors - - + + Deck name... - - + + Owner... - - + + Packages - - + + Advanced Filters - + Bracket: - - + + Any - - + + Contains card... - - + + Commander... - - + + Tag... - - + + Deck Size - + Cards: - - + + Asc. - + Sort by: - + Filter by: - + Display Settings - - + + Search - - + + Formats @@ -7858,6 +8286,54 @@ Por favor, verifique suas configurações de atalho! + + TabCardArtRules + + + Card: + + + + + ProviderId: + + + + + Mode: + + + + + Reason: + + + + + Type a card name... + + + + + Add rule + + + + + Remove rule + + + + + Refresh + + + + + Card Art Rules + + + TabDeckEditor @@ -7906,7 +8382,7 @@ Por favor, verifique suas configurações de atalho! Reiniciar layout - + Deck: %1 Deck: %1 @@ -7914,55 +8390,49 @@ Por favor, verifique suas configurações de atalho! TabDeckEditorVisual - + Visual Deck: %1 - + &Visual Deck Editor - - + + Card Info - - + + Deck - - - Filters - - - - + &View - + Printing - + Visible - + Floating - + Reset layout @@ -7970,22 +8440,22 @@ Por favor, verifique suas configurações de atalho! TabDeckEditorVisualTabWidget - + Visual Deck View - + Visual Database Display - + Deck Analytics - + Sample Hand @@ -7993,134 +8463,134 @@ Por favor, verifique suas configurações de atalho! TabDeckStorage - + Local file system Sistema de arquivos local - + Server deck storage Armazenamento de decks no servidor - - + + Open in deck editor Abrir no editor de decks - + Rename deck or folder - + Upload deck Upload do deck - + Download deck Download do deck - - - + + + New folder Nova pasta - + Delete Apagar - + Open decks folder Abrir pasta de decks - + Rename local folder - + Rename local file - + New name: - - - - + + + + Error Error - + Rename failed - - + + Invalid deck file Arquivo de deck inválido - + Enter deck name Entre o nome do deck - + This decklist does not have a name. Please enter a name: Este deck não tem um nome. Por favor, entre um nome: - + Unnamed deck Deck sem nome - + Failed to upload deck to server Falha ao enviar deck ao servidor - + Delete local file Excluir arquivo local - + Are you sure you want to delete the selected files? Tem certeza de que deseja excluir os arquivos selecionados? - + Delete remote decks Excluir decks remotos - + Are you sure you want to delete the selected decks? Tem certeza de que deseja excluir os decks selecionados? - - + + Name of new folder: Nome da nova pasta: @@ -8192,191 +8662,191 @@ Por favor, entre um nome: TabGame - - - + + + Replay Replay - - + + Game Jogo - - + + Player List Lista de jogadores. - - + + Card Info Informação da carta - - + + Messages Mensagens - - + + Replay Timeline Linha do tempo do Replay - + &Phases &Etapas - + &Game &Jogo - + Next &phase Próxima &etapa - + Next phase with &action Próxima etapa com &acao - + Next &turn Próximo &turno - + Reverse turn order Ordem dos turnos revertida - + &Remove all local arrows &Apagar todas as setas locais - + Rotate View Cl&ockwise Girar visualização em sentido h&orário - + Rotate View Co&unterclockwise Girar visualização em sentido a&nti-horário - + Game &information &Informação de jogo - + Un&concede Cancelar Rendição - - - + + + &Concede &Conceder - + &Leave game &Sair do jogo - + C&lose replay &Fechar replay - + &Focus Chat &Focar no chat - + &Say: &Falar: - + Selected cards - + &View &Visualizar - + Visible Visível - + Floating Flutuante - + Reset layout Reiniciar layout - + Concede Conceder - + Are you sure you want to concede this game? Você tem certeza que deseja conceder este jogo? - + Unconcede Defazer concessão - + You have already conceded. Do you want to return to this game? Você já concedeu. Quer retornar a esse jogo? - + Leave game Sair do jogo - + Are you sure you want to leave this game? Você tem certeza que deseja sair deste jogo? - + A player has joined game #%1 Um jogador entrou no jogo #%1 - + %1 has joined the game %1 entrou no jogo - + You have been kicked out of the game. Você foi 'chutado' do jogo. @@ -8435,115 +8905,115 @@ Por favor, entre um nome: Você precisa selecionar um número de dias válido para localizar - + Username: Nome de usuário. - + IP Address: Endereço de IP: - + Game Name: Nome do jogo: - + GameID: ID do Jogo: - + Message: Mensagem: - + Main Room Sala Principal - + Game Room Sala de Jogo - + Private Chat Chat Privado. - + Past X Days: X dias anteriores: - + Today Hoje - + Last Hour Última hora - + Maximum Results: Máximo de resultados: - + At least one filter is required. The more information you put in, the more specific your results will be. Ao menos um filtro deve ser selecionado. Quanto mais informação você inserir, mais específicos seus resultados serão. - + Get User Logs Obter logs de usuário - + Clear Filters Limpar Filtros - + Filters Filtros - + Log Locations Logs de localização - + Date Range Intervalo de data - + Maximum Results Máximo de resultados - - + + Message History Histórico de Mensagens - + Failed to collect message history information. Falha ao coletar informação do histórico de mensagens. - + There are no messages for the selected filters. Não existe mensagem que corresponda aos filtros selecionados. @@ -8561,27 +9031,27 @@ Quanto mais informação você inserir, mais específicos seus resultados serão &Sair - + %1 - Private chat %1 - Conversa privada - + This user is ignoring you, they cannot see your messages in main chat and you cannot join their games. Este usuário esta ignorando você, ele não pode ver suas mensagens no chat principal e você não pode entrar nos jogos dele. - + Private message from Mensagem privada de - + %1 has left the server. %1 saiu do servidor. - + %1 has joined the server. %1 entrou no servidor. @@ -8589,180 +9059,180 @@ Quanto mais informação você inserir, mais específicos seus resultados serão TabReplays - + Local file system Sistema de arquivos local - + Server replay storage Servidor de armazenamento de replays - - + + Watch replay Assistir replay - + Rename Renomear - - + + New folder Nova pasta - - + + Delete Excluir - + Open replays folder Abrir pasta de repetições - + Download replay Baixar replay - + Toggle expiration lock Trocar expiração da trava - + Get replay share code - - + + Look up replay by share code - + Rename local folder Renomear pasta local - + Rename local file Renomear arquivo local - + New name: Novo nome: - + Error Erro - + Rename failed Falha ao renomear - + Name of new folder: Nome da nova pasta: - + Delete local file Excluir arquivo local - + Are you sure you want to delete the selected files? Tem certeza de que deseja excluir os arquivos selecionados? - + Are you sure you want to delete the selected replays? Tem certeza de que deseja excluir as repetições selecionadas? - + Failed to get code - - + + Either this server does not support replay sharing, or does not permit replay sharing for you. - - - + + + Failed - + Could not get replay code - + Replay Share Code - + Others can use this code to add the replay to their list of remote replays: %1 - + Copy to clipboard - + Replay share code - + Replay code found - + Replay was added, or you already had access to it. - + Replay code not found - + Failed to submit code - + Unexpected error - + Delete remote replay Excluir replay remoto @@ -8775,47 +9245,62 @@ Quanto mais informação você inserir, mais específicos seus resultados serão TabRoom - + + Friends + + + + + Online + + + + + Ignored + + + + &Say: &Falar: - + Chat Chat - + &Room &Sala - + &Leave room S&air da sala - + &Clear chat Limpar conversa - + Chat Settings... Configurações de Conversa - + mentioned you. mencionou você. - + Click to view Clique para ver - + You are flooding the chat. Please wait a couple of seconds. Você está flodando o chat. Por favor, espere alguns segundos. @@ -8828,30 +9313,30 @@ Quanto mais informação você inserir, mais específicos seus resultados serão Servidor - - - - + + + + Error Erro - + Failed to join the server room: it doesn't exist on the server. Falha ao entrar na sala: ela não existe no servidor. - + The server thinks you are in the server room but your client is unable to display it. Try restarting your client. O servidor pensa que você está na sala, mas seu cliente não consegue exibi-la. Tente reiniciar o seu cliente. - + You do not have the required permission to join this server room. Você não tem permissão para entrar nesta sala. - + Failed to join the server room due to an unknown error: %1. Falha ao entrar na sala devido a um erro desconhecido: %1. @@ -8859,97 +9344,97 @@ Quanto mais informação você inserir, mais específicos seus resultados serão TabSupervisor - + Deck Editor Editor de Deck - + Visual Deck Editor - + EDHRec - + Archidekt - + Home - + &Visual Deck Storage &Decks Visuais - + Visual Database Display - + Server Servidor - + Account Conta - + Deck Storage Armazenamento de Deck - + Game Replays Repetições de jogo - + Administration Administração - + Logs Registros - + Are you sure? Você tem certeza? - + There are still open games. Are you sure you want to quit? Ainda existem jogos abertos. Você tem certeza que deseja sair? - + Click to view Clique para visualizar - + Your buddy %1 has signed on! Seu amigo %1 entrou no servidor! - + Unknown Event Evento desconhecido. - + The server has sent you a message that your client does not understand. This message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -8960,39 +9445,39 @@ Esta mensagem pode significar que existe uma nova versão do Cockatrice disponí Para atualizar seu cliente, vá para Ajuda -> Verificar atualizações. - + Idle Timeout Tempo esgotado de inatividade - + You are about to be logged out due to inactivity. Você irá ser desconectado por inatividade - + Promotion Promoção - + You have been promoted. Please log out and back in for changes to take effect. Você foi promovido a moderador(a). Por favor faça logout e depois acesse novamente para que as mudanças sejam efetivadas. - + Warned Advertido - + You have received a warning due to %1. Please refrain from engaging in this activity or further actions may be taken against you. If you have any questions, please private message a moderator. Você recebeu uma advertência por %1. Por favor evite tais ações ou outras medidas poderão ser tomadas contra você. Se você tem qualquer dúvida, por favor envie uma mensagem privada um dos moderadores. - + You have received the following message from the server. (custom messages like these could be untranslated) Você recebeu a seguinte mensagem do servidor. @@ -9002,12 +9487,12 @@ Por favor evite tais ações ou outras medidas poderão ser tomadas contra você TabVisualDatabaseDisplay - + Database Display - + Visual Database Display @@ -9046,42 +9531,42 @@ Por favor evite tais ações ou outras medidas poderão ser tomadas contra você TranslateCounterName - + Life Vida - + White Branco - + Blue Azul - + Black Preto - + Red Vermelho - + Green Verde - + Colorless Incolor - + Other Outro @@ -9089,11 +9574,69 @@ Por favor evite tais ações ou outras medidas poderão ser tomadas contra você UpdateDownloader - + Could not open the file for reading. Não foi possível abrir o arquivo para leitura. + + UserCardArtSettingsDialog + + + Card Art Settings + + + + + Type a card name... + + + + + Card name: + + + + + Card ProviderId: + + + + + Left margin (%): + + + + + Right margin (%): + + + + + Vertical offset: + + + + + Zoom: + + + + + Parameters + + + + + Preview + + + + + Remove Banner Card + + + UserContextMenu @@ -9211,7 +9754,7 @@ Por favor evite tais ações ou outras medidas poderão ser tomadas contra você - + Ban History Histórico de banimento @@ -9226,77 +9769,87 @@ Por favor evite tais ações ou outras medidas poderão ser tomadas contra você O usuário nunca foi banido. - + Failed to collect ban information. Falha ao coletar informações de banidas. - - - + + + Warning History Histórico de advertências - + Warning Time;Moderator;User Name;Reason Tempo de advertência;Moderador;Nome de Usuário;Razão - + User has never been warned. Usuário nunca foi advertido. - + Failed to collect warning information. Falha ao coletar informações de aviso. - + Failed to get admin notes. Falha ao obter as notas do administrador. - - + + Success Sucesso - + Successfully promoted user. Usuário promovido com sucesso. - + Successfully demoted user. Permissões de moderação revogadas com sucesso. - - - + + Kick Player + + + + + Are you sure you want to kick this player from the game? + + + + + + Failed Falha - + Failed to promote user. Falha ao promover usuário. - + Failed to demote user. Falha ao revogar permissões de moderação do usuário. - + Copy hash to clipboard Copiar hash para área de transferência - + Remove this user's messages Remover mensagens deste usuário @@ -9304,326 +9857,554 @@ Por favor evite tais ações ou outras medidas poderão ser tomadas contra você UserInfoBox - + Location: Localização: - + Account Age: Idade conta: - + Edit Editar - + Change password Alterar senha - + Change avatar Alterar avatar - + Administrator Administrador - + Moderator Moderador - + Registered user Usuário registrado - - + + Unregistered user Usuário não registrado - + Judge Juíz - + Unknown Desconhecido - + The entered password does not match your account. A senha inserida não coincide com de sua conta. - - - + + + + Information Informação - + User information updated. Informações do usuário atualizadas - - - - - - - - - - + + + + + + + + + + + Error Erro - + User Information Informação do usuário - + Real Name: Nome real: - + User Level: Nível do usuário: + + + Edit Banner Card + + - + %n Year(s), amount of years (only shown if more than 0) %n ano(s),%n ano(s),%n ano(s), - + %10%n Day(s) %20 amount of years (if more than 0), amount of days, date in local short format %10%n dia(s) %20%10%n dia(s) %20%10%n dia(s) %20 - + Enter Password Insira a senha - + Password verification is required in order to change your email address Uma verificação de senha é obrigatória para alterar seu endereço de e-mail - - - + + + An error occurred while trying to update your user information. Ocorreu um erro ao tentar atualizar suas informações de usuário. - + + The selected card is blacklisted on this server or another error occurred. + + + + + Banner card removed. + + + + + Banner card updated. + + + + This server does not permit you to update your user informations. Este servidor não permite que você atualize suas informações de usuário. - + Password changed. Senha alterada. - + This server does not permit you to change your password. Este servidor não permite que você altere sua senha. - + The new password is too short. A nova senha é muito curta. - + The old password is incorrect. A senha antiga está incorreta. - + Avatar updated. Avatar alterado. - + This server does not permit you to update your avatar. Este servidor não permite que você altere seu avatar. + + + An error occured while trying to update your avatar. + + - An error occured while trying to updater your avatar. - Um erro ocorreu enquanto seu avatar era atualizado. + This server does not permit you to update your user informations. + An error occured while trying to updater your avatar. + Este servidor não permite que você atualize suas informações de usuário. + + + + UserInfoPopup + + + + Games + + + + + Chat + + + + + Open private chat + + + + + Profile + + + + + View user profile + + + + + Show this user's games + + + + + − Buddy + + + + + Remove from buddy list + + + + + + Buddy + + + + + Add to buddy list + + + + + − Ignore + + + + + Remove from ignore list + + + + + + Ignore + + + + + Add to ignore list + + + + + Ban + + + + + Ban from server + + + + + Warn + + + + + Warn user + + + + + Ban log + + + + + View ban history + + + + + Warn log + + + + + View warning history + + + + + Notes + + + + + View admin notes + + + + + − Mod + + + + + Demote from moderator + + + + + + Mod + + + + + Promote to moderator + + + + + − Judge + + + + + Demote from judge + + + + + + Judge + + + + + Promote to judge + + + + + Join game + + + + + Spectate + + + + + + Loading games… + + + + + Could not load games. + + + + + No active games. + UserInterfaceSettingsPage - + General interface settings Configurações gerais de interface - + &Double-click cards to play them (instead of single-click) &Duplo clique nos cards para jogá-los (ao invés de clique simples) - + &Clicking plays all selected cards (instead of just the clicked card) Clicar reproduz todas as cartas selecionadas (em vez de apenas a carta clicada) - + &Play all nonlands onto the stack (not the battlefield) by default &Jogar todos não terrenos na pilha (não campo de batalha) por padrão - + Do not delete &arrows inside of subphases - + Close card view window when last card is removed Fechar janela de visualização ao remover a última carta - + Auto focus search bar when card view window is opened - + Annotate card text on tokens Anotar texto de card nas fichas - - Show selection counter during drag selection + + Show selection count during drag selection - - Show total selection counter + + Show total selection count - + + Show subtype breakdown in selection tally + + + + Use tear-off menus, allowing right click menus to persist on screen Usar menus tear-off, permitindo que menus de clique com botão direito persistam na tela - + + Keep game chat focused when clicking in game (Note: disables card view search bar) + + + + Notifications settings Configurações de Notificações - + Enable notifications in taskbar Habilitar notificações na barra de tarefas - + Notify in the taskbar for game events while you are spectating Notificar na barra de tarefas para eventos do jogo enquanto você está como espectador - + Notify in the taskbar when users in your buddy list connect Notificar na barra de tarefas quando usuários na sua lista de amigos se conectar - + Animation settings Configurações de animação - + &Tap/untap animation Animação de &virar/desvirar - + Deck editor/storage settings Configurações do editor/armazenamento de decks - + Open deck in new tab by default Abrir deck em uma nova aba por padrão - + Use visual deck storage in game lobby Usar decks visuais no lobby do jogo - + Use selection animation for Visual Deck Storage - + When adding a tag in the visual deck storage to a .txt deck: - + do nothing - + ask to convert to .cod - + always convert to .cod - + Default deck editor type - + Classic Deck Editor - + Visual Deck Editor - + Replay settings Configurações de repetição - + Buffer time for backwards skip via shortcut: Tempo de buffer para retroceder usando atalho: @@ -9631,22 +10412,22 @@ Por favor evite tais ações ou outras medidas poderão ser tomadas contra você UserListWidget - + Users connected to server: %1 Usuários conectados ao servidor: %1 - + Users in this room: %1 Usuários nesta sala: %1 - + Buddies online: %1 / %2 Amigos online: %1 / %2 - + Ignored users online: %1 / %2 Usuários ignorados online: %1 / %2 @@ -9654,35 +10435,45 @@ Por favor evite tais ações ou outras medidas poderão ser tomadas contra você UtilityMenu - + Increment all card counters - + &Untap all permanents - + R&oll die... - + + Flip coin + + + + &Create token... - + C&reate another token - + Cr&eate predefined token + + + C&reate another %1 token + + VisualDatabaseDisplayColorFilterWidget @@ -9734,72 +10525,72 @@ Por favor evite tais ações ou outras medidas poderão ser tomadas contra você VisualDatabaseDisplayFilterToolbarWidget - + Sort by - + Filter by - + Save and load filters - + Filter by exact card name - + Filter by card main-type - + Filter by card sub-type - + Filter by set - + Filter by format legality - + Save/Load - + Name - + Main Type - + Sub Type - + Sets - + Formats @@ -9807,22 +10598,22 @@ Por favor evite tais ações ou outras medidas poderão ser tomadas contra você VisualDatabaseDisplayFormatLegalityFilterWidget - + Show formats with at least: - + cards - + Do not display formats with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) @@ -9840,32 +10631,32 @@ Por favor evite tais ações ou outras medidas poderão ser tomadas contra você VisualDatabaseDisplayMainTypeFilterWidget - + Show main types with at least: - + cards - + Do not display card main-types with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) - + Mode: Exact Match - + Mode: Includes @@ -9873,27 +10664,27 @@ Por favor evite tais ações ou outras medidas poderão ser tomadas contra você VisualDatabaseDisplayNameFilterWidget - + Filter by name... (Exact match) - + Load from Deck - + Apply all card names in currently loaded deck as exact match name filters - + Load from Clipboard - + Apply all card names in clipboard as exact match name filters @@ -9901,7 +10692,7 @@ Por favor evite tais ações ou outras medidas poderão ser tomadas contra você VisualDatabaseDisplayRecentSetFilterSettingsWidget - + Filter to most recent sets @@ -9909,19 +10700,19 @@ Por favor evite tais ações ou outras medidas poderão ser tomadas contra você VisualDatabaseDisplaySetFilterWidget - + Search sets... - - + + Mode: Exact Match - - + + Mode: Includes @@ -9929,37 +10720,37 @@ Por favor evite tais ações ou outras medidas poderão ser tomadas contra você VisualDatabaseDisplaySubTypeFilterWidget - + Search subtypes... - + Show sub types with at least: - + cards - + Do not display card sub-types with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) - + Mode: Exact Match - + Mode: Includes @@ -9967,23 +10758,23 @@ Por favor evite tais ações ou outras medidas poderão ser tomadas contra você VisualDatabaseDisplayWidget - + Search by card name (or search expressions) - + Visual - + Loading database ... - + Clear all filters @@ -10022,17 +10813,17 @@ Por favor evite tais ações ou outras medidas poderão ser tomadas contra você - + Toggle Layout: Overlap - + Change how cards are displayed within zones (i.e. overlapped or fully visible.) - + Toggle Layout: Flat @@ -10061,17 +10852,17 @@ Por favor evite tais ações ou outras medidas poderão ser tomadas contra você VisualDeckEditorWidget - + Type a card name here for suggestions from the database... - + Quick search and add card - + Search for closest match in the database (with auto-suggestions) and add preferred printing to the deck on pressing enter @@ -10189,43 +10980,43 @@ Por favor evite tais ações ou outras medidas poderão ser tomadas contra você WarningDialog - + Which warning would you like to send? Qual advertência você gostaria de enviar? - + Redact all messages from this user in all rooms Remover todas mensagens deste usuário em todas salas - + &OK %OK - + &Cancel &Cancelar - + Warn user for misconduct Advertir usuario por mau comportamento. - - + + Error Erro - + User name to send a warning to can not be blank, please specify a user to warn. O campo "nome de usuário" ao qual se deseja enviar uma advertência não pode permanecer em branco, por favor especifique um usuário para ser advertido. - + Warning to use can not be blank, please select a valid warning to send. O campo "advertência" não pode permanecer em branco, por favor selecione uma advertência para ser enviada. @@ -10233,133 +11024,133 @@ Por favor evite tais ações ou outras medidas poderão ser tomadas contra você WndSets - + Move selected set to the top Mover o selecionado para o topo - + Move selected set up Mover o selecionado para cima - + Move selected set down Mover o selecionado para baixo - + Move selected set to the bottom Mover o selecionado para o fundo - + Search by set name, code, or type Procurar por expansão, código ou tipo - + Default order Ordem padrão - + Restore original art priority order Restaurar prioridade da ordem de arte original - + Enable all sets Habilitar todas expansões - + Disable all sets Desabilitar todas expansões - + Enable selected set(s) Habilitar expansão/expansões selecionada(s) - + Disable selected set(s) Desabilitar expansão/expansões selecionada(s) - + Deck Editor Editor de Deck - + Use CTRL+A to select all sets in the view. Use CTRL+A para selecionar todas expansões visíveis. - + Only cards in enabled sets will appear in the card list of the deck editor. Apenas cartas em expansões ativadas irão aparecer na lista de cartas do editor de deck. - + Image priority is decided in the following order: Prioridade da imagem é decidida na seguinte ordem: - + first the CUSTOM Folder (%1), then the Enabled Sets in this dialog (Top to Bottom) %1 is a link to the wiki primeiro a pasta de PERSONALIDADAS (%1), depois as expansões habilitadas neste diálogo (de cima para baixo) - + Include cards rebalanced for Alchemy [requires restart] - + Card Art Arte da Carta - + How to use custom card art Como usar arte de carta personalizada - + Hints Dicas - + Note Nota - + Sorting by column allows you to find a set while not changing set priority. Classificar por coluna te permite encontrar um set sem mudar a prioridade de sets - + To enable ordering again, click the column header until this message disappears. Para reabilitar a ordenação, clique no cabeçalho da coluna até que esta mensagem desapareça - + Use the current sorting as the set priority instead Usar a classificação atual como a prioridade de sets - + Sorts the set priority using the same column Classificar a prioridade de sets usando a mesma coluna - + Manage sets Gerenciar expansões @@ -10367,72 +11158,72 @@ Por favor evite tais ações ou outras medidas poderão ser tomadas contra você ZoneViewWidget - + Search by card name (or search expressions) - + Ungrouped Não agrupado - + Group by Type Agrupar por Tipo - + Group by Mana Value Agrupar por Valor de Mana - + Group by Color Agrupar por Cor - + Unsorted Não ordenado - + Sort by Name Ordenar por Nome - + Sort by Type Ordenar por Tipo - + Sort by Mana Cost Ordenar por Custo de Mana - + Sort by Colors Ordenar por Cores - + Sort by P/T Ordenar por P/T - + Sort by Set Ordenar por Conjunto - + shuffle when closing embaralhar quando fechar - + pile view visualização da pilha @@ -10440,7 +11231,7 @@ Por favor evite tais ações ou outras medidas poderão ser tomadas contra você i18n - + English Português do Brasil (Brazilian Portuguese) @@ -10448,12 +11239,12 @@ Por favor evite tais ações ou outras medidas poderão ser tomadas contra você main - + Connect on startup Conectar ao iniciar - + Debug to file Depurar para um arquivo @@ -10467,7 +11258,7 @@ Por favor evite tais ações ou outras medidas poderão ser tomadas contra você - + Deck Editor Editor de Deck @@ -10548,7 +11339,7 @@ Por favor evite tais ações ou outras medidas poderão ser tomadas contra você - + Replays Repetições @@ -10663,129 +11454,129 @@ Por favor evite tais ações ou outras medidas poderão ser tomadas contra você Carregar Deck... - - + + Load Deck from Clipboard... Carregar deck da área de transferência... - + Edit Deck in Clipboard, Annotated - + Edit Deck in Clipboard - + New Deck Novo Deck - + Open Custom Pictures Folder Abrir pasta de imagens customizadas - + Print Deck... Imprimir Deck... - + Delete Card Deletar carta - - + + Reset Layout Redefinir layout - + Save Deck Salvar Deck - + Save Deck as... Salvar Deck Como... - + Save Deck to Clipboard, Annotated Salvar deck na área de transferência, anotado - + Save Deck to Clipboard, Annotated (No Set Info) - + Save Deck to Clipboard Salvar deck na área de transferência - + Save Deck to Clipboard (No Set Info) - + Load Local Deck... Carregar deck local... - + Load Remote Deck... Carregar deck remoto... - + Set Ready to Start Preparade para começar - + Toggle Sideboard Lock Alternar trava de sideboard - + Add Green Counter Adicionar marcador verde - + Remove Green Counter Remover marcador verde - + Set Green Counters... Definir marcadores verde... - + Add Red Counter Adicionar marcador vermelho - + Remove Red Counter Remover marcador vermelho - + Set Red Counters... Definir marcadores vermelhos - + Add Life Counter Adicionar ponto de vida @@ -10795,724 +11586,744 @@ Por favor evite tais ações ou outras medidas poderão ser tomadas contra você - + + Load deck from online service... + + + + + Load from website... + + + + Unload Deck - + Force Start - + Add Card Counter (F) - + Remove Card Counter (F) - + Set Card Counters (F)... - + Add Card Counter (E) - + Remove Card Counter (E) - + Set Card Counters (E)... - + Add Card Counter(D) - + Remove Card Counter (D) - + Set Card Counters (D)... - + Add Card Counter (C) - + Remove Card Counter (C) - + Set Card Counters (C)... - + Add Card Counter (B) - + Remove Card Counter (B) - + Set Card Counters (B)... - + Add Card Counter (A) - + Remove Card Counter (A) - + Set Card Counters (A)... - + Remove Life Counter Remover ponto de vida - + Set Life Counters... Definir pontos de vida... - + Add White Counter Adicionar marcador branco - + Remove White Counter Remover marcador branco - + Set White Counters... Definir marcadores brancos... - + Add Blue Counter Adicionar marcador azul - + Remove Blue Counter Remover marcador azul - + Set Blue Counters... Definir marcadores azul - + Add Black Counter Adicionar marcador preto - + Remove Black Counter Remover marcador preto - + Set Black Counters... Definir marcadores pretos... - + Add Colorless Counter Adicionar marcador incolor - + Remove Colorless Counter Remover marcador incolor - + Set Colorless Counters... Definir marcadores incolor... - + Add Other Counter Adicionar outro marcador - + Remove Other Counter Remover outro marcador - + Set Other Counters... Definir outros marcadores... - + Increment all card counters - + Add Power (+1/+0) adicionar Poder (+1/+0) - + Remove Power (-1/-0) Remover Poder (-1/-0) - + Move Toughness to Power (+1/-1) Mover Resistência Para Poder (+1/-1) - + Add Toughness (+0/+1) Adicionar Resistência (+0/+1) - + Remove Toughness (-0/-1) Remover Resistência (-0/-1) - + Move Power to Toughness (-1/+1) Mover Poder Para Resistência (-1/+1) - + Add Power and Toughness (+1/+1) Adicionar Poder e Resistência (+1/+1) - + Remove Power and Toughness (-1/-1) Remover Poder e Resistência (-1/-1) - + Set Power and Toughness... Definir poder e resistência... - + Reset Power and Toughness Redefinir poder e resistência - + Untap Desvirar - + Upkeep Manutenção - + Draw Compra - + First Main Phase Primeira Fase Principal - + Start Combat Iniciar Combate - + Attack Atacar - + Block Bloquear - + Damage Dano - + End Combat Terminar Combate - + Second Main Phase Segunda Fase Principal - + End Fim - + Next Phase Próxima Fase - + Next Phase Action Ação de Próxima Etapa - + Next Turn Próximo Turno - + Hide Card in Reveal Window Ocultar Carta na Janela de Revelação - + Tap / Untap Card Virar / Desvirar Carta - + Untap All Desvirar Tudo - + Toggle Skip Untapping Toggle Untap - + Turn Card Over Virar carta para cima - + Peek Card Olhar carta - + Play Card Jogar Carta - + Play Card, Face Down - + Attach Card... Anexar carta... - + Unattach Card Desanexar carta... - + Clone Card Duplicar Carta - + Create Token... Criar Ficha - + Create All Related Tokens Criar todas as fichas relacionadas - + Create Another Token Criar Outra Ficha - + Set Annotation... Definir anotação - + + Reduce Life by Power + + + + Select All Cards in Zone Selecionar Todas as Cartas na Zona - + Select All Cards in Row Selecionar Todas as Cartas na Linha - + Select All Cards in Column Selecionar Todas as Cartas na Coluna - + Reveal Selected Cards to All Players - - + + Bottom of Library Fundo do Grimório - - - - + + + + Exile Exilar - - - - + + + + Graveyard Cemitério - - + + Hand Mão - - + + Top of Library Topo do Grimório - - + + Battlefield, Face Down Campo de batalha, Voltada para baixo - + Battlefield Campo de Batalha - + Library Grimório - + Sideboard Sideboard - + Top Cards of Library Cartas do topo do grimório - + Bottom Cards of Library Cartas do Fundo da Biblioteca - + Close Recent View Fechar visualização recente - - + + Stack Pilha - - + + Graveyard (Multiple) Cemitério (Vários) - - + + Graveyard (Multiple), Face Down - - + + Exile (Multiple) Exílio (Vários) - - + + Exile (Multiple), Face Down - + Stack Until Found Empilhar Até Encontrar - + Draw Bottom Card Comprar carta do fundo - + Draw Multiple Cards from Bottom... Comprar várias cartas do fundo... - + Draw Arrow... Desenhar seta... - + Remove Local Arrows Remover setas locais - + Leave Game Sair do jogo - + Concede Conceder - + Roll Dice... Rolar dados... - + + Flip Coin + + + + Shuffle Library Embaralhar grimório - + Shuffle Top Cards of Library - + Shuffle Bottom Cards of Library - + Mulligan Mulligan - + Mulligan (Same hand size) - + Mulligan (Hand size - 1) - + Draw a Card Comprar uma carta - + Draw Multiple Cards... Comprar várias cartas - + Undo Draw Desfazer compra - + Always Reveal Top Card Sempre revelar a carta do topo do grimório - + Always Look At Top Card Sempre olhar para a carta do topo do grimório - + Sort Hand by Name - + Sort Hand by Type - + Sort Hand by Mana Value - + Reveal Hand to All Players - + Reveal Random Card to All Players - + Rotate View Clockwise Rotacionar visão no sentido horário - + Rotate View Counterclockwise Rotacionar visão no sentido anti-horário - + Unfocus Text Box Desfocar caixa de texto - + Focus Chat Focar no chat - + Clear Chat Limpar chat - + Refresh Atualizar - + Skip Forward Avançar - + Skip Backward Retroceder - + Skip Forward by a lot Avançar Bastante - + Skip Backward by a lot Retroceder Bastante - + Play/Pause Reproduzir/Pausar - + Toggle Fast Forward Ativar/Desativar Avanço Rápido - + Home - + Visual Deck Storage Decks Visuais - + Deck Storage Armazenamento de Deck - + Server Servidor - + Account Servidor - + Administration Administração - + Logs Registros diff --git a/cockatrice/translations/cockatrice_ru.ts b/cockatrice/translations/cockatrice_ru.ts index e666bf0de..26d95fe1c 100644 --- a/cockatrice/translations/cockatrice_ru.ts +++ b/cockatrice/translations/cockatrice_ru.ts @@ -2,7 +2,7 @@ AbstractCounter - + &Set counter... &Установить жетоны... @@ -10,12 +10,12 @@ AbstractCounterDialog - + Set counter Установить жетоны - + New value for counter '%1': Новое значение жетонов '%1': @@ -36,62 +36,62 @@ AbstractTabDeckEditor - + Open in new tab Открыть в новой вкладке - + Are you sure? Вы уверены? - + The decklist has been modified. Do you want to save the changes? Деклист был изменен. Вы хотите сохранить изменения? - - - - - - + + + + + + Error Ошибка - + Could not open deck at %1 Не удалось открыть колоду по пути «%1» - + Could not save remote deck Не удалось сохранить сетевую колоду - - + + The deck could not be saved. Please check that the directory is writable and try again. Колода не может быть сохранена. Проверьте что директория доступна для записи и попробуйте снова. - + Save deck Сохранить колоду - + The deck could not be saved. Не удалось сохранить колоду. - + There are no cards in your deck to be exported В колоде нет карт для экспорта @@ -107,12 +107,12 @@ Please check that the directory is writable and try again. AdminNotesDialog - + Update Notes Список изменений - + Admin Notes for %1 Примечания администратора для %1 @@ -129,172 +129,227 @@ Please check that the directory is writable and try again. Sideboard Сайдборд + + + Tokens + + AppearanceSettingsPage - + seconds секунд - + Error Ошибка - + Could not create themes directory at '%1'. Не удалость создать папку для тем в '%1'. - + Theme settings Настройки темы - + Current theme: Текущая тема: - + Open themes folder Открыть папку с темами - + Home tab background source: Путь до обоев на домашней вкладке: - + Home tab background shuffle frequency: Частота смены обоев: - + Disabled Отключено - - Display card name of background in bottom right: - - - - + Menu settings Настройки меню - + Show keyboard shortcuts in right-click menus Отображать сочетания клавиш в контекстном меню - + Show game filter toolbar above list in room tab - + Card rendering Отрисовка карт - + Display card names on cards having a picture Отображать название карты поверх изображения - + Auto-Rotate cards with sideways layout Автоматически поворачивать карты альбомной ориентации - + Override all card art with personal set preference (Pre-ProviderID change behavior) Перезаписать все арты в соответствии с выбранными изданиями (Pre-ProviderID меняет это поведение) - + + Light + + + + + Dark + + + + + System + + + + + Active theme palette: + + + + + Edit theme palette + + + + + Home tab settings + + + + + Display card name of background in bottom right + + + + + Styling settings + + + + + Style user list + + + + + Card printings + + + + Bump sets that the deck contains cards from to the top in the printing selector Перемещать в начало списка выбора издания те выпуски, из которых в колоде есть карты. - + Scale cards on mouse over Увеличивать карты при наведении мыши - + Use rounded card corners Использовать скругленные углы карт - + + Card layout + + + + Minimum overlap percentage of cards on the stack and in vertical hand Минимальный процент наложения карт в стеке и вертикальной руке - + Maximum initial height for card view window: Максимальная начальная высота для окна просмотра карты - - + + rows строки - + Maximum expanded height for card view window: Макс. высота окна для просмотра карт - + Card counters Жетоны на картах - + Counter %1 Жетон %1 - + Hand layout Раскладка руки - + Display hand horizontally (wastes space) Отображать руку горизонтально (требует места) - + Enable left justification Выравнивание по левому краю - + Table grid layout Сетка - + Invert vertical coordinate Инвертировать вертикальные координаты - + Minimum player count for multi-column layout: Минимальное количество игроков для столбчатого расположения: - + Maximum font size for information displayed on cards: Максимальный размер шрифта для текста карт: @@ -302,12 +357,12 @@ Please check that the directory is writable and try again. ArchidektApiResponseDeckDisplayWidget - + Back to results - + Open Deck in Deck Editor @@ -333,112 +388,112 @@ Please check that the directory is writable and try again. BanDialog - + ban &user name Забанить по &Имени игрока - + ban &IP address Забанить по &адресу IP - + ban client I&D Забанить по I&D клиента - + Ban type Тип бана - + &permanent ban &постоянный бан - + &temporary ban &временный бан - + &Days: &Дни - + &Hours: &Часы - + &Minutes: &Минуты - + Duration of the ban Длительность бана - + Please enter the reason for the ban. This is only saved for moderators and cannot be seen by the banned person. Пожалуйста, назовите причину бана. Эта информация сохраняется для модераторов и не будет видна забаненному игроку - + Please enter the reason for the ban that will be visible to the banned person. Пожалуйста, назовите причину бана, которая будет видна забаненному игроку. - + Redact all messages from this user in all rooms Отредактировать все сообщения от этого пользователя во всех комнатах - + &OK &ОК - + &Cancel &Отмена - + Ban user from server Забанить игрока на этом сервере - - - - + + + + Error Ошибка - + You have to select a name-based, IP-based, clientId based, or some combination of the three to place a ban. Необходимо выбрать тип бана: по имени, по адресу IP, по ID клиента или комбинацию этих трех типов. - + You must have a value in the name ban when selecting the name ban checkbox. Необходимо ввести имя игрока при выборе опции бана по имени. - + You must have a value in the ip ban when selecting the ip ban checkbox. Необходимо ввести адрес IP при выборе опции бана по IP. - + You must have a value in the clientid ban when selecting the clientid ban checkbox. Необходимо ввести ID клиента при выборе опции бана по ID. @@ -446,59 +501,123 @@ This is only saved for moderators and cannot be seen by the banned person. BetaReleaseChannel - + Beta Бета - + No reply received from the release update server. Нет ответа от сервера обновления версий. - + Invalid reply received from the release update server. Неправильный ответ от сервера обновления версий. - + No reply received from the file update server. Нет ответа от сервера обновления файлов. + + CardArtPreviewWidget + + + No card selected + + + + + CardArtRulesModel + + + Card + + + + + ProviderId + + + + + Mode + + + + + Reason + + + CardDatabaseModel - + Name Название - + Sets Издания - + Mana cost Мана-стоимость - + Card type Тип - + P/T Сила/Выносливость: - + Color(s) Цвет(а) + + CardDatabaseView + + + Add to Deck + + + + + Add to Sideboard + + + + + Select Printing + + + + + Show on EDHRec (Commander) + + + + + Show on EDHRec (Card) + + + + + Show Related cards + + + CardFilter @@ -627,22 +746,22 @@ This is only saved for moderators and cannot be seen by the banned person. CardInfoPictureWidget - + View related cards Посмотреть связанные карты - + Add card to deck Добавить карту в колоду - + Mainboard Колода - + Sideboard Сайд @@ -665,12 +784,12 @@ This is only saved for moderators and cannot be seen by the banned person. - + Related cards: Связанные карты: - + Unknown card: Неизвестная карта: @@ -678,128 +797,146 @@ This is only saved for moderators and cannot be seen by the banned person. CardMenu - + Re&veal to... Показать... - + &All players Всем игрокам - + View related cards Посмотреть связанные карты - + Token: Фишка: - + All tokens Все фишки: - + &Select All Выбрать все - + S&elect Row Выбрать строку - + S&elect Column Выбрать колонку - + &Play Разыграть - + &Hide Спрятать - + Play &Face Down Разыграть лицом вниз - + &Tap / Untap Turn sideways or back again Повернуть / Развернуть - + Skip &untapping - + T&urn Over Turn face up/face down Перевернуть - + &Peek at card face Посмотреть лицевую сторону - + &Clone Клонировать - + Attac&h to card... Прикрепить к карте... - + Unattac&h Открепить - + &Draw arrow... Нарисовать стрелку - + &Set annotation... Добавить заметку - + + Reduce life by power + + + + Ca&rd counters Жетоны на карте - + &Add counter (%1) &Добавить жетон (%1) - + &Remove counter (%1) &Убрать жетон (%1) - + &Set counters (%1)... &Установить жетоны (%1)... + + CardPictureLoaderCacheMethod + + + Network Cache + + + + + Filesystem + + + CardSizeWidget @@ -811,133 +948,133 @@ This is only saved for moderators and cannot be seen by the banned person. CardZoneLogic - + their hand nominative своей руки - + %1's hand nominative %1 руки - + their library look at zone своей библиотеки - + %1's library look at zone библиотеки %1 - + of their library top cards of zone, своей библиотеки - + of %1's library top cards of zone библиотеки %1 - + their library reveal zone своей библиотеки - + %1's library reveal zone библиотеки %1 - + their library shuffle своей библиотеки - + %1's library shuffle библиотеки %1 - + their library nominative своей библиотеки - + %1's library nominative библиотеки %1 - + their graveyard nominative своего кладбища - + %1's graveyard nominative кладбища %1 - + their exile nominative своего изгнания - + %1's exile nominative изгнание %1 - + their sideboard look at zone своего сайда - + %1's sideboard look at zone сайда %1 - + their sideboard nominative своего сайда - + %1's sideboard nominative сайда %1 - + their custom zone '%1' nominative кастомной зоны '%1' - + %1's custom zone '%2' nominative кастомной зоны '%2' игрока '%1' @@ -959,16 +1096,421 @@ This is only saved for moderators and cannot be seen by the banned person.Ошибка распознавания, строка %1 колонка %2: + + ColorButton + + + Click to pick a color + + + + + Pick colour + + + + + ConnectionController + + + + The server has reached its maximum user capacity, please check back later. + + + + + There are too many concurrent connections from your address. + + + + + Banned by moderator + + + + + Expected end time: %1 + + + + + This ban lasts indefinitely. + + + + + Scheduled server shutdown. + + + + + + Invalid username. + + + + + You have been logged out due to logging in at another location. + + + + + Connection closed + + + + + The server has terminated your connection. +Reason: %1 + + + + + The server is going to be restarted in %n minute(s). +All running games will be lost. +Reason for shutdown: %1 + + + + + Scheduled server shutdown + Плановый перерыв в работе сервера + + + + Failed Login + Не удалось подключиться + + + + Your client seems to be missing features this server requires for connection. + + + + + To update your client, go to 'Help -> Check for Client Updates'. + + + + + + + + + + + + + + + + + + + + + + Error + Ошибка + + + + Incorrect username or password. Please check your authentication information and try again. + + + + + There is already an active session using this user name. +Please close that session first and re-login. + + + + + + You are banned until %1. + + + + + + You are banned indefinitely. + + + + + This server requires user registration. Do you want to register now? + + + + + This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. +Please close and reopen your client to try again. + + + + + An internal error has occurred, please close and reopen Cockatrice before trying again. +If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. + + + + + Account activation + + + + + Your account has not been activated yet. +You need to provide the activation token received in the activation email. + + + + + Server Full + + + + + Unknown login error: %1 + + + + + + +This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. + + + + + + + + + + Registration denied + + + + + Registration is currently disabled on this server + + + + + There is already an existing account with the same user name. + + + + + It's mandatory to specify a valid email address when registering. + + + + + It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. + + + + + Password too short. + + + + + Registration failed for a technical problem on the server. + + + + + The connection to the server has been lost. + + + + + Unknown registration error: %1 + + + + + Account activation failed + + + + + Socket error: %1 + + + + + Server timeout + + + + + You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. +Local version is %1, remote version is %2. + + + + + Your Cockatrice client is obsolete. Please update your Cockatrice version. +Local version is %1, remote version is %2. + + + + + + Success + + + + + Registration accepted. +Will now login. + + + + + Account activation accepted. +Will now login. + + + + + Information + + + + + This server supports additional features that your client doesn't have. +This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. + +To update your client, go to Help -> Check for Updates. + + + + + + + Reset Password + + + + + Your password has been reset successfully, you can now log in using the new credentials. + + + + + Failed to reset user account password, please contact the server operator to reset your password. + + + + + Activation request received, please check your email for an activation token. + + + + + Connecting to %1... + + + + + Registering to %1 as %2... + + + + + Disconnected + + + + + Connected, logging in at %1 + + + + + Requesting forgotten password to %1 as %2... + + + + + Your username must respect these rules: + + + + + is %1 - %2 characters long + + + + + can %1 contain lowercase characters + + + + + + + + NOT + + + + + can %1 contain uppercase characters + + + + + can %1 contain numeric characters + + + + + can contain the following punctuation: %1 + + + + + first character can %1 be a punctuation mark + + + + + no unacceptable language as specified by these server rules: + note that the following lines will not be translated + + + + + can not contain any of the following words: %1 + + + + + can not match any of the following expressions: %1 + + + + + You may only use A-Z, a-z, 0-9, _, ., and - in your username. + + + CustomZoneMenu - + C&ustom Zones Кастомная зона - - + + View custom zone '%1' Посмотреть кастомную зону '%1' @@ -976,30 +1518,35 @@ This is only saved for moderators and cannot be seen by the banned person. DeckAnalyticsWidget - + Add Panel - + Remove Panel - + Save Layout - + Load Layout + + + Include Sideboard + + DeckEditorCardDatabaseDockWidget - + Card Database @@ -1015,47 +1562,17 @@ This is only saved for moderators and cannot be seen by the banned person. DeckEditorDatabaseDisplayWidget - + Search by card name (or search expressions) Искать по названию карты (или по поисковому запросу) - - Add to Deck - Добавить в колоду - - - - Add to Sideboard - Добавить в сайд - - - - Select Printing - Выбрать издание - - - - Show on EDHRec (Commander) - Посмотреть на EDHRec (Командир) - - - - Show on EDHRec (Card) - Посмотреть на EDHRec (карта) - - - - Show Related cards - Посмотреть связанные карты - - - + Add card to &maindeck Добавить карту в колоду - + Add card to &sideboard Добавить карту в сайд @@ -1088,72 +1605,72 @@ This is only saved for moderators and cannot be seen by the banned person.Цвет - + Select Printing Выбрать издание - + Deck Колода - + Deck &name: Название колоды: - + Banner Card/Tags Visibility Settings Настройки отоборажение обложки и тегов - + Show banner card selection menu Показать меню выбора обложки - + Show tags selection menu Показать меню выбора тегов - + &Comments: Комментарий: - + Group by: Группировать по: - + Format: - + Hash: Хэш-сумма - + &Increment number Увеличить количество - + &Decrement number Уменьшить количество - + &Remove row Удалить строку - + Swap card to/from sideboard Переместить карту в/из сайда @@ -1161,17 +1678,17 @@ This is only saved for moderators and cannot be seen by the banned person. DeckEditorFilterDockWidget - + Filters Фильтры - + &Clear all filters Убрать все фильтры - + Delete selected Удалить выбранное @@ -1302,169 +1819,113 @@ This is only saved for moderators and cannot be seen by the banned person. DeckEditorSettingsPage - - + + Update Spoilers Обновить спойлеры - - + Success Успешно - + Download URLs have been reset. Адреса URL загрузки были сброшены. - - Downloaded card pictures have been reset. - Загруженные изображения карт были сброшены. - - - - Error - Ошибка - - - - One or more downloaded card pictures could not be cleared. - Одно или более загруженных изображений карт не может быть удалено. - - - + Add URL Добавить URL - - + + URL: URL: - - + + Edit URL Изменить URL: - - Network Cache Size: - Размер сетевого кеша: - - - - Redirect Cache TTL: - TTL кеша перенаправлений: - - - - How long cached redirects for urls are valid for. - Время жизни кеша редиректов - - - - Picture Cache Size: - Размер кеша изображений: - - - + Add New URL Добавить новый URL - + Remove URL Удалить URL - - Day(s) - Дней - - - + Updating... Обновление... - + Choose path Выбрать путь - + URL Download Priority Приоритет загрузки URL - + Spoilers Спойлеры - + Download Spoilers Automatically Скачивать спойлеры автоматически - + Spoiler Location: Расположение спойлеров: - + Last Change Последние изменения - + Spoilers download automatically on launch Спойлеры скачиваются автоматически при запуске - + Press the button to manually update without relaunching Нажмите, чтобы обновить вручную без перезапуска - + Do not close settings until manual update is complete Не закрывать настройки до завершения ручного обновления - + Download card pictures on the fly Синхронная загрузка изображений карт - + How to add a custom URL Как добавить пользовательский URL - - Delete Downloaded Images - Удалить загруженные изображения - - - + Reset Download URLs Обнулить загруженные URL - - - On-disk cache for downloaded pictures - Размер кеша для загруженных артов - - - - In-memory cache for pictures not currently on screen - Размер кеша в памяти для загруженных артов - DeckListHistoryManagerWidget @@ -1530,12 +1991,12 @@ This is only saved for moderators and cannot be seen by the banned person. DeckLoader - + Common deck formats (%1) Формат колоды (%1) - + All files (*.*) Все файлы (*.*) @@ -1757,29 +2218,29 @@ This is only saved for moderators and cannot be seen by the banned person. - + Moved to %1 1 × "%2" (%3) - + Removed "%1" (all copies) - + %1 1 × "%2" (%3) - + Added - + Добавлено - + Removed - + Удалено @@ -1799,75 +2260,75 @@ This is only saved for moderators and cannot be seen by the banned person. DeckViewContainer - + Load deck... Загрузить колоду... - + Load remote deck... Загрузить колоду удаленно... - + Load from clipboard... Загрузить из буфера обмена... - + Load from website... Загрузить из интернета... - + Unload deck Выгрузить колоду - + Ready to start &Готов - + Force start Принудительный старт - + Sideboard unlocked Сайдборд разблокирован - + Sideboard locked Сайдборд заблокирован - - + + Error Ошибка - + The selected file could not be loaded. Выбранный файл не может быть загружен - + Deck is greater than maximum file size. Колода больше максимально возможного размера файла - + Are you sure you want to force start? This will kick all non-ready players from the game. Уверены что хотите принудительно начать? Это кикнет всех неготовых игроков из игры - + Cockatrice Cockatrice @@ -1968,7 +2429,7 @@ This will kick all non-ready players from the game. - + Webpage Веб-страница @@ -2008,37 +2469,37 @@ This will kick all non-ready players from the game. Подключиться к серверу - + Server URL URL сервера - + Communication Port Порт связи - + Unique Server Name Уникальное имя сервера - + Connection Warning Предупреждение при соединении - + You need to name your new connection profile. Назовите ваш профиль для соединения. - + Connect Warning Ошибка при соединении - + The player name can't be empty. Необходимо указать имя пользователя. @@ -2151,17 +2612,17 @@ This will kick all non-ready players from the game. Создать игру - + Game information Информация об игре - + Error Ошибка - + Server error. Ошибка сервера. @@ -2169,97 +2630,97 @@ This will kick all non-ready players from the game. DlgCreateToken - + &Name: &Название: - + Token Фишка - + C&olor: &Цвет: - + white белый - + blue синий - + black черный - + red красный - + green зеленый - + multicolor многоцветный - + colorless бесцветный - + &P/T: &Сила/Выносливость: - + &Annotation: &Примечание: - + &Destroy token when it leaves the table &Уничтожить фишку, когда она покинет поле битвы - + Create face-down (Only hides name) Создать лицом вниз (скроет только название) - + Token data Описание фишки - + Show &all tokens Показать &все фишки - + Show tokens from this &deck Показать фишки из указанной &колоды - + Choose token from list Выбрать фишку из списка - + Create token Создать фишку @@ -2293,7 +2754,7 @@ This will kick all non-ready players from the game. - + @@ -2308,12 +2769,12 @@ This will kick all non-ready players from the game. Название тега не может быть пустым! - + Duplicate Tag Копировать тег - + This tag already exists. Этот тег уже существует @@ -2500,12 +2961,12 @@ To remove your current avatar, confirm without choosing a new image. Введите имя фишки: - + Error Ошибка - + The chosen name conflicts with an existing card or token. Make sure to enable the 'Token' set in the "Manage sets" dialog to display them correctly. Выбранное имя конфликтует с существующей картой или токеном. @@ -2530,12 +2991,12 @@ Make sure to enable the 'Token' set in the "Manage sets" dia Не указано - + Real name: Настоящее имя: - + Edit user profile Редактировать профиль @@ -2573,113 +3034,113 @@ Make sure to enable the 'Token' set in the "Manage sets" dia 2 часа - + Hide 'buddies only' games Скрывать игры "только для друзей" - + Hide full games Скрыть заполненные игры - + Hide games that have started Скрыть уже начавшиеся игры - + Hide password protected games Скрыть игры, защищенные паролем - + Hide 'ignored user' games Скрыть игры от игнорируемых пользователей - + Hide games not created by buddies Hide games not created by buddy - + Hide games with forced open decklists Скрыть игры "только с открытыми деклистами" - + &Newer than: &Новее чем: - + Game &description: &Описание игры - + &Creator name: Имя &создателя - + General Основные - + &Game types Форматы &игры - + at &least: не &менее - + at &most: не &более - + Maximum player count Максимальное количество игроков - + Restrictions Ограничения - + Show games only if &spectators can watch Показывать игры со &зрителями - + Show spectator password p&rotected games Показывать &игры с паролем для зрителей - + Show only if spectators can ch&at Показывать игры где зрители могут &писать в чат - + Show only if spectators can see &hands Показывать игры где зрители могут видеть &руки игроков - + Spectators Зрители - + Filter games Фильтровать игры @@ -2959,7 +3420,7 @@ https://tappedout.net/mtg-decks/your-deck-name/ Players: - + Игроки @@ -2990,37 +3451,37 @@ https://tappedout.net/mtg-decks/your-deck-name/ DlgMoveTopCardsUntil - + Card name (or search expressions): Название карты (или поисковый запрос) - + Number of hits: Количество: - + Auto play hits Автоматически разыгрывать найденные карты - + Put top cards on stack until... Поместить вернюю карту в стек, пока... - + No cards matching the search expression exists in the card database. Proceed anyways? В базе данных карт ничего не найдено. Все равно продолжить? - + Cockatrice Cockatrice - + Invalid filter Неправильный фильтр @@ -3080,40 +3541,40 @@ Your email will be used to verify your account. Не определено - + Real name: Настоящее имя: - + Register to server Зарегистрироваться на сервере - - - - + + + + Registration Warning Предкпреждение - + Your password is too short. Пароль слишком короткий - + Your passwords do not match, please try again. Введенные пароли не совпадают, повторите снова. - + Your email addresses do not match, please try again. Введенные адреса email не совпадают, повторите снова. - + The player name can't be empty. Необходимо указать имя пользователя. @@ -3121,17 +3582,17 @@ Your email will be used to verify your account. DlgRollDice - + Number of sides: Количество граней: - + Number of dice: Количество кубов: - + Roll Dice Бросить кубик @@ -3182,12 +3643,12 @@ Your email will be used to verify your account. DlgSettings - + Unknown Error loading card database Неизвестная ошибка при загрузке базы карт. - + Your card database is invalid. Cockatrice may not function correctly with an invalid database @@ -3201,7 +3662,7 @@ Cockatrice не может корректно работать с испорче Вы хотите сменить расположение файла базы карт? - + Your card database version is too old. This can cause problems loading card information or images @@ -3215,7 +3676,7 @@ Would you like to change your database location setting? Вы хотите сменить расположение файла базы карт? - + Your card database did not finish loading Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues with your cards.xml attached @@ -3228,7 +3689,7 @@ Would you like to change your database location setting? Вы хотите изменить путь до локальной базы карт? - + File Error loading your card database. Would you like to change your database location setting? @@ -3236,7 +3697,7 @@ Would you like to change your database location setting? Вы хотите сменить расположение файла базы карт? - + Your card database was loaded but contains no cards. Would you like to change your database location setting? @@ -3244,7 +3705,7 @@ Would you like to change your database location setting? Вы хотите сменить расположение файла базы карт? - + Unknown card database load status Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues @@ -3257,59 +3718,64 @@ Would you like to change your database location setting? Вы хотите изменить путь до локальной базы карт? - - - + + + Error Ошибка - + The path to your deck directory is invalid. Would you like to go back and set the correct path? Ваши колоды отсутствуют в указанной папке. Вернуться и задать правильный путь? - + The path to your card pictures directory is invalid. Would you like to go back and set the correct path? Изображения карт не найдены. Вернуться и задать правильный путь? - + Settings Настройки - + General Основные - + Appearance Вид - + User Interface Интерфейс - + + Storage + + + + Card Sources Источник карт - + Chat Чат - + Sound Звук - + Shortcuts Горячие клавиши @@ -3403,9 +3869,9 @@ You can always change this behavior in the 'General' settings tab. - - - + + + Error Ошибка @@ -3459,31 +3925,31 @@ Please visit the download page to update manually. - + Update Available Обновление доступно - + A new version of Cockatrice is available! Доступна новая версия Cockatrice! - + New version Новая версия - + Released Выпущена - + Changelog Список изменений @@ -3493,50 +3959,50 @@ Please visit the download page to update manually. Вы хотите обновить сейчас? - + Unfortunately, the automatic updater failed to find a compatible download. You may have to manually download the new version. Автообновление не удалось - подходящая версия не найдена. Скачайте новую версию вручную. - + Please check the <a href="%1">releases page</a> on our Github and download the build for your system. Проверьте <a href="%1">страницу релиза</a> в нашем репозитории на Github и скачайте сборку для своей системы. - - - + + + Update Error Ошибка при оновлении - + An error occurred while checking for updates: В процессе проверки обновлений произошла ошибка: - + An error occurred while downloading an update: В процессе загрузки обновления произошла ошибка: - + Installing... Установка... - + Cockatrice is unable to open the installer. Невозможно открыть установщик. - + Try to update manually by closing Cockatrice and running the installer. Попробуйте обновить вручную, закрыв Cockatrice и запустив инсталлятор. - + Download location @@ -3626,67 +4092,67 @@ You may have to manually download the new version. DrawProbabilityWidget - + Draw Probability - + Probability of drawing - + Card Name - + Type - + Subtype - + Mana Value - + At least - + Exactly - + card(s) having drawn at least - + cards - + Category - + Qty - + Odds (%) @@ -3800,7 +4266,7 @@ You may have to manually download the new version. FilterBuilder - + Type your filter here Укажите свой фильтр @@ -3831,22 +4297,22 @@ You may have to manually download the new version. GameEventHandler - + kicked by game host or moderator кикнут из игры хостом или модератором - + player left the game игрок покинул игру - + player disconnected from server игрок отсоединился от сервера - + reason unknown причина неизвестна @@ -3854,140 +4320,140 @@ You may have to manually download the new version. GameSelector - - - - - - - - - + + + + + + + + + Error Ошибка - + Please join the appropriate room first. Пожалуйста, сперва войдите в соответствующую комнату. - + Wrong password. Неверный пароль. - + Spectators are not allowed in this game. Зрители не допускаются в эту игру. - + The game is already full. Все места уже заняты! - + The game does not exist any more. Эта игра больше не существует. - + This game is only open to registered users. Доступно только для зарегистрированных. - + This game is only open to its creator's buddies. Доступно только для друзей. - + You are being ignored by the creator of this game. Вы добавлены в игнор-лист данного игрока. - + Join Game Присоединиться - + Spectate Game Наблюдать - + Game Information Информация об игре - + Join Game as Judge - + Spectate Game as Judge - + Join game Присоединиться - + Password: Пароль: - + Please join the respective room first. Пожалуйста, сначала присоединитесь к соответствующей комнате. - + &Filter games &Фильтр игр - + C&lear filter О&чистить фильтр - + C&reate &Создать - + &Join &Присоединиться - + Join as judge - + J&oin as spectator Присоединиться как &зритель - + Join as judge spectator - + Games shown: %1 / %2 Показано игр: %1 / %2 - + Games Игры @@ -3995,32 +4461,32 @@ You may have to manually download the new version. GameSelectorQuickFilterToolBar - + All types - + Filter by game name... - + Filter by game type/format - + Hide games not created by buddies - + Hide full games - + Hide started games @@ -4028,105 +4494,105 @@ You may have to manually download the new version. GamesModel - + >1 day > 1 дня - + %1%2 hr short age in hours %1%2 час%1%2 часа%1%2 часов%1%2 часов - + new создать - + %1%2 min short age in minutes %1%2 минута%1%2 минут%1%2 минут%1%2 минут - + password пароль - + buddies only только друзья - + reg. users only только для зарегистрированных пользователей - + open decklists открыть деклист - + can chat зрители могут комментировать в чате - + see hands видеть руки игроков - + can see hands могут видеть руки - + not allowed не допускаются - + Room Комната - + Age Возраст - + Description Описание - + Creator Создатель - + Type Тип - + Restrictions Ограничения - + Players Количество игроков - + Spectators Зрители @@ -4134,143 +4600,158 @@ You may have to manually download the new version. GeneralSettingsPage - + Reset all paths Сбросить - + All paths have been reset Все пути были сброшены - - - - - - - + + + + + + + Choose path Путь - - Personal settings - Настройки пользователя + + Language settings + Выбор языка - + Language: Язык: - + + Version settings + + + + + Card database + + + + + Startup settings + + + + Paths (editing disabled in portable mode) Путь (в портативном режиме редактирование недоступно) - + Paths Пути расположения - + How to help with translations Как помочь с переводом - + Decks directory: Колоды: - + Filters directory: Фильтровать директории: - + Replays directory: Повторы: - + Pictures directory: Изображения карт: - + Card database: База данных карт: - + Custom database directory: Пользовательские сеты: - + Token database: База данных токенов: - + Update channel Обновить канал - + Check for client updates on startup Проверять обновления при запуске - + Check for card database updates on startup Проверять обновления базы карт при запуске - + Don't check Не проверять - + Prompt for update Поиск обновлений - + Always update in the background Всегда обновлять на фоне - + Check for card database updates every Проверять обновления каждые - + days дней - + Notify if a feature supported by the server is missing in my client Предупреждать, если функции, используемые сервером, отсутствуют в этом клиенте. - + Automatically run Oracle when running a new version of Cockatrice Автоматически запускать Oracle в новой версии Cockatrice - + Show tips on startup Показывать советы при запуске клиента - + Last update check on %1 (%2 days ago) Последнее проверка обновлений была %1 (%2 дней назад) @@ -4278,47 +4759,47 @@ You may have to manually download the new version. GraveyardMenu - + &Graveyard Кладбище - + &View graveyard Посмотреть кладбище - + &Move graveyard to... Переместить кладбище в... - + &Top of library Верх библиотеки - + &Bottom of library Низ библиотеки - + &All players Всем игрокам - + &Hand Рука - + &Exile Изгнание - + Reveal random card to... Показать случайно карту... @@ -4326,88 +4807,88 @@ You may have to manually download the new version. HandMenu - + &Hand Рука - + &View hand Посмотреть руку - + Sort hand by... - + Name - + Type - + Mana Value - + Take &mulligan (Choose hand size) - + Take mulligan (Same hand size) - + Take mulligan (Hand size - 1) - + &Move hand to... Переместить руку... - + &Top of library Верх библиотеки - + &Bottom of library Низ библиотеки - + &Graveyard Кладбище - + &Exile Изгнание - + &Reveal hand to... Показать руку... - - + + All players - + Reveal r&andom card to... Показать случайную карту... @@ -4415,52 +4896,52 @@ You may have to manually download the new version. HomeWidget - + Create New Deck Создать новую колоду - + Browse Decks Посмотреть колоды - + Browse Card Database Посмотреть базу карт - + Browse EDHRec Посмотреть EDHRec - + Browse Archidekt - + View Replays Посмотреть повтор - + Quit Выход - + Connecting... Подключение... - + Connect Подключиться - + Play Играть @@ -4468,213 +4949,213 @@ You may have to manually download the new version. LibraryMenu - + &Library Библиотека - + &View library Посмотреть библиотеку - + View &top cards of library... Посмотреть топдек - + View bottom cards of library... Посмотреть нижнюю карту библиотеки - + Reveal &library to... Показать библиотеку... - + Lend library to... Предоставить доступ к библиотеке... - + Reveal &top cards to... Показать топдек... - + &Top of library... Топдек - + &Bottom of library... Низ библиотеки - + &Always reveal top card &Всегда показывать верхюю карту - + &Always look at top card &Всегда смотреть верхнюю карту - + &Open deck in deck editor Открыть колоду в редакторе - + &Draw card Взять карту - + D&raw cards... Взять карты - + &Undo last draw Отменить последнее взятие карты - + Shuffle Перемешать - + &Play top card Сыграть топдек - + Play top card &face down Сыграть топдек лицом вниз - + Put top card on &bottom Положить топдек на низ библиотеки - + Move top card to grave&yard Положить топдек в кладбище - + Move top card to e&xile Положить топдек в изгнание - + Move top cards to &graveyard... Положить верхние карты в кладбище - + Move top cards to graveyard face down... - + Move top cards to &exile... Положить верхние карты в кладбище - + Move top cards to exile face down... - + Put top cards on stack &until... Поместить вернюю карту в стек, пока... - + Shuffle top cards... Перемешать верхние карты - + &Draw bottom card &Взять нижнюю карту - + D&raw bottom cards... Взять нижнюю карту библиотеки - + &Play bottom card Разыграть нижнюю карту библиотеки - + Play bottom card &face down Разыграть нижнюю карту библиотеки лицом вниз - + Move bottom card to grave&yard Положить нижнюю карту библиотеки в кладбище - + Move bottom card to e&xile Положить нижнюю карту библиотеки в кладбище - + Move bottom cards to &graveyard... Положить нижние карты библиотеки в кладбище - + Move bottom cards to graveyard face down... - + Move bottom cards to &exile... Положить нижние карты в изгнание... - + Move bottom cards to exile face down... - + Put bottom card on &top Переместить нижнюю карту на топдек - + Shuffle bottom cards... Перемешать нижние карты... - - + + &All players Все игроки - + Reveal top cards of library Показать верхние карты библиотеки - + Number of cards: (max. %1) Количество карт: (макс. %1) @@ -4682,850 +5163,454 @@ You may have to manually download the new version. MainWindow - - - The server has reached its maximum user capacity, please check back later. - Этот сервер достиг максимального количества пользователей, попробуйте позже. - - - - There are too many concurrent connections from your address. - Слишком много одновременных подключений с Вашего адреса. - - - - Banned by moderator - Забанен модератором - - - - Expected end time: %1 - Ожидаемое время: %1 - - - - This ban lasts indefinitely. - Этот бан не ограничен по времени. - - - - Scheduled server shutdown. - Плановый перерыв в работе сервера. - - - - - Invalid username. - Неверное имя пользователя. - - - - You have been logged out due to logging in at another location. - Вы вышли из аккаунта, так как был выполнен вход из другого расположения. - - - - Connection closed - Соединение прервано - - - - The server has terminated your connection. -Reason: %1 - Ваше подключение было прервано сервером. -Причина: %1 - - - - The server is going to be restarted in %n minute(s). -All running games will be lost. -Reason for shutdown: %1 - Сервер будет перезагружен через %n минут(ы). -Все незавершенные игры будут потеряны. -Причина перезагрузки: %1Сервер будет перезагружен через %n минут(ы). -Все незавершенные игры будут потеряны. -Причина перезагрузки: %1Сервер будет перезагружен через %n минут(ы). -Все незавершенные игры будут потеряны. -Причина перезагрузки: %1Сервер будет перезагружен через %n минут(ы). -Все незавершенные игры будут потеряны. -Причина перезагрузки: %1 - - - - Scheduled server shutdown - Плановый перерыв в работе сервера - - - - - Success - Успешно - - - - Registration accepted. -Will now login. - Регистрация прошла успешно. -Идет соединение. - - - - Account activation accepted. -Will now login. - Активация аккаунта прошла успешно. -Идет соединение. - - - - + + Player %1 Игрок %1 - + Load replay Загрузить повтор - + About Cockatrice О программе - + Version Версия - + Cockatrice Webpage Страница Cockatrice - + Project Manager: Руководитель проекта: - + Past Project Managers: Предыдущие руководители проекта: - + Developers: Разработчики: - + Our Developers Наши разработчики - + Help Develop! Помочь в разработке! - + Translators: Переводчики: - + Our Translators Наши переводчики - + Help Translate! Помочь с переводом! - + Support: Поддержать проект: - + Report an Issue Сообщить о проблеме - + Troubleshooting Устранение неполадок - + F.A.Q. ЧАВо - - - - - - - - - - - - - - - - - - - - + + Error Ошибка - - Server timeout - Нет связи с сервером - - - - Failed Login - Не могу подключиться - - - - Your client seems to be missing features this server requires for connection. - Ваш клиент не поддерживает некоторые функции, необходимые для соединения с сервером. - - - - To update your client, go to 'Help -> Check for Client Updates'. - - - - - Incorrect username or password. Please check your authentication information and try again. - Неверное имя пользователя или пароль. Пожалуйста, уточните регистрационные данные и попробуйте снова. - - - - There is already an active session using this user name. -Please close that session first and re-login. - Пользователь с таким именем уже подключен. -Пожалуйста, закройте это подключение и войдите заново. - - - - - You are banned until %1. - Вы забанены до %1. - - - - - You are banned indefinitely. - Вы забанены бессрочно. - - - - This server requires user registration. Do you want to register now? - На этом сервере требуется регистрация. Вы хотите зарегистрироваться сейчас? - - - - This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. -Please close and reopen your client to try again. - - - - - An internal error has occurred, please close and reopen Cockatrice before trying again. -If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. - - - - - Account activation - Активация аккаунта - - - - Your account has not been activated yet. -You need to provide the activation token received in the activation email. - Ваш аккаунт ещё не активирован. Вам нужно предоставить жетон/код активации, полученный по электронной почте. - - - - Server Full - Сервер заполнен - - - - Unknown login error: %1 - Неизвестная ошибка входа: %1 - - - - - -This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. - -Обычно это означает, что Вы используете устаревшую версию клиента, и тот не отвечает запросам сервера. - - - - Your username must respect these rules: - Имя пользователя должно удовлетворять следующим условиям: - - - - is %1 - %2 characters long - длиной в %1 - %2 символов - - - - can %1 contain lowercase characters - %1 может содержать строчные буквы - - - - - - - NOT - НЕ - - - - can %1 contain uppercase characters - %1 может содержать заглавные буквы - - - - can %1 contain numeric characters - %1 может содержать цифры - - - - can contain the following punctuation: %1 - Может содержать следующие символы: %1 - - - - first character can %1 be a punctuation mark - Первый знак %1 может быть знаком пунктуации - - - - no unacceptable language as specified by these server rules: - note that the following lines will not be translated - - - - - can not contain any of the following words: %1 - не может содержать следующие слова: %1 - - - - can not match any of the following expressions: %1 - не может соответствовать следующим выражениям: %1 - - - - You may only use A-Z, a-z, 0-9, _, ., and - in your username. - Имя пользователя может включать только следующие символы: A-Z, a-z, 0-9, "_", "." и "-" - - - - - - - - - Registration denied - Запрос на регистрацию отклонен - - - - Registration is currently disabled on this server - Регистрация на данном сервере в настоящий момент недоступна - - - - There is already an existing account with the same user name. - Аккаунт с таким именем уже существует. - - - - It's mandatory to specify a valid email address when registering. - При регистрации необходимо указать действующий e-mail. - - - - It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. - - - - - Password too short. - Слишком короткий пароль. - - - - Registration failed for a technical problem on the server. - Регистрация не удалась из-за технических неполадок на сервере. - - - - The connection to the server has been lost. - - - - - Unknown registration error: %1 - Неизвестная ошибка при регистрации: %1 - - - - Account activation failed - Не удалось активировать аккаунт - - - - Socket error: %1 - Ошибка сокета: %1 - - - - You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. -Local version is %1, remote version is %2. - Вы пытаетесь подключиться к несуществующему серверу. Пожалуйста, обновите Cockatrice или выберите другой сервер. -Локальная версия %1, удаленная версия %2. - - - - Your Cockatrice client is obsolete. Please update your Cockatrice version. -Local version is %1, remote version is %2. - Ваш клиент Cockatrice устарел. Пожалуйста, обновите Cockatrice. -Локальная версия %1, удаленная версия %2. - - - - Connecting to %1... - Подключение к %1... - - - - Registering to %1 as %2... - Регистрация на %1 как %2... - - - - Disconnected - Подключение прервано - - - - Connected, logging in at %1 - Соединение установлено, выполняется вход на %1 - - - - - - Requesting forgotten password to %1 as %2... - - - - + &Connect... Подключение... &С - + &Disconnect П&рервать подключение - + Start &local game... &Начать локальную игру... - + &Watch replay... &Смотреть повтор... - + &Full screen Полный экран &F - + &Register to server... &Регистрация на сервере... - + &Restore password... - + &Settings... &Настройки - + &Exit &Выход - + A&ctions &Действия - + &Cockatrice &Cockatrice - + C&ard Database &База карт - + &Manage sets... - + Edit custom &tokens... - + Open custom image folder Открыть папку с изображениями - + Open custom sets folder Открыть папку с изданиями - + Add custom sets/cards Добавить издания/карты - + Reload card database - + Tabs - + Быстрый доступ - + &Help &Справка - + &About Cockatrice О про&грамме - + &Tip of the Day - + Check for Client Updates - + Check for Card Updates... - + Check for Card Updates (Automatic) - + Show Status Bar - + View &Debug Log - + Open Settings Folder - + Show/Hide - + New Version - + Congratulations on updating to Cockatrice %1! Oracle will now launch to update your card database. - + Cockatrice installed - + Congratulations on installing Cockatrice %1! Oracle will now launch to install the initial card database. - + Card database База карт - + Cockatrice is unable to load the card database. Do you want to update your card database now? If unsure or first time user, choose "Yes" Cockatrice не может загрузить базу карт. Обновить вашу базу карт сейчас? Если Вы не уверены, или впервые запустили программу, нажмите "Да" - - + + Yes Да - - + + No Нет - + Open settings Открыть настройки - + New sets found Найдены новые издания - + %n new set(s) found in the card database Set code(s): %1 Do you want to enable it/them? - + + Yes, always enable + + + + View sets Просмотреть издания - + Welcome Добро пожаловать - + Hi! It seems like you're running this version of Cockatrice for the first time. All the sets in the card database have been enabled. Read more about changing the set order or disabling specific sets and consequent effects in the "Manage Sets" dialog. - - + Information Информация - + A card database update is already running. Обновление базы карт уже идет. - + Unable to run the card database updater: Не удалось запустить обновление базы карт: - + Card database update running. - + Failed to start. The file might be missing, or permissions might be incorrect. - + The process crashed some time after starting successfully. - + Timed out. The process took too long to respond. The last waitFor...() function timed out. - + An error occurred when attempting to write to the process. For example, the process may not be running, or it may have closed its input channel. - + An error occurred when attempting to read from the process. For example, the process may not be running. - + Unknown error occurred. - + The card database updater exited with an error: %1 - - This server supports additional features that your client doesn't have. -This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. - -To update your client, go to Help -> Check for Updates. - Этот сервер использует функции, отсутствующие в вашем клиенте. -Вероятно, это не вызовет затруднений, а это сообщение означает, что доступна новая версия Cockatrice или сервер использует неофициальную пре-релизную версию. - -Чтобы обновить клиент, перейдите в Справка-> Проверка обновлений. - - - - - - - + + + + + Load sets/cards Загрузить издания/карты - + Selected file cannot be found. Выбранный файл не найден. - + You can only import XML databases at this time. На данный момент вы только можете предостовлять базы данных в формате XML. - + The new sets/cards have been added successfully. Cockatrice will now reload the card database. Новые издания/карты успешно добавлены. Сейчас Cockatrice перезагрузит базу карт. - + Sets/cards failed to import. Не удалось импортировать издания/карты. - - - - - Reset Password - - - - - Your password has been reset successfully, you can now log in using the new credentials. - - - - - Failed to reset user account password, please contact the server operator to reset your password. - Невозможно сбросить пароль. Пожалуйста, свяжитесь с оператором сервера. - - - - Activation request received, please check your email for an activation token. - Запрос активации получен. Вам отправлен код активации. Пожалуйста, проверьте свой e-mail. - ManaBaseConfigDialog - + Mana Base Configuration - + Display type: - + pie - + bar - + combinedBar - + Filter Colors (optional): - + OK - + Cancel @@ -5597,27 +5682,27 @@ Cockatrice will now reload the card database. ManaDevotionConfigDialog - + Display type: - + pie - + bar - + combinedBar - + Filter Colors (optional): @@ -5687,610 +5772,615 @@ Cockatrice will now reload the card database. MessageLogWidget - + from play из игры - + from their graveyard из их кладбищ - + from exile из изгнания - + from their hand из их рук - + the top card of %1's library верхняя карта библиотеки %1 - + the top card of their library верхнюю карту своей библиотеки - + from the top of %1's library с верха библиотеки %1 - + from the top of their library с верха своей библиотеки - + the bottom card of %1's library нижняя карта библиотеки %1 - + the bottom card of their library нижнюю карту своей библиотеки - + from the bottom of %1's library Со дна библиотеки %1 - + from the bottom of their library с низа своей библиотеки - + from %1's library из библиотеки %1 - + from their library из своей библиотеки - + from sideboard из сайдборда - + from the stack из стека - + from custom zone '%1' - + %1 is now keeping the top card %2 revealed. %1 оставляет верхнюю карту %2 открытой. - + %1 is not revealing the top card %2 any longer. Верхняя карта %2 %1 больше не остается открытой. - + %1 can now look at top card %2 at any time. - + %1 no longer can look at top card %2 at any time. - + %1 attaches %2 to %3's %4. %1 прикрепляет %2 к %4 под контролем %3. - + %1 has conceded the game. %1 сдался. - + %1 has unconceded the game. - + %1 has restored connection to the game. %1 восстановил связь с игрой. - + %1 has lost connection to the game. %1 потерял связь с игрой. - + %1 points from their %2 to themselves. %1 указывает с их %2 на себя. - + %1 points from their %2 to %3. %1 указывает с их %2 на %3. - + %1 points from %2's %3 to themselves. %1 указывает с %3 под контролем %2 на себя. - + %1 points from %2's %3 to %4. %1 указывает с %3, контролируемого %2, на %4. - + %1 points from their %2 to their %3. %1 указывает с их %2 на их %3. - + %1 points from their %2 to %3's %4. %1 указывает с их %2 на %4 под контролем %3. - + %1 points from %2's %3 to their own %4. %1 указывает с %3 под контролем %2 на %4, владельцем которого они являются. - + %1 points from %2's %3 to %4's %5. %1 указывает с %3 контролируемого %2 на %5 под контролем %4. - + %1 creates a face down token. - + %1 creates token: %2%3. %1 создает фишку: %2%3. - + %1 has loaded a deck (%2). %1 загрузил колоду (%2). - + %1 has loaded a deck with %2 sideboard cards (%3). %1 загрузил колоду с %2 картами из сайдборда (%3). - + %1 destroys %2. %1 уничтожает %2. - + a card карту - + %1 gives %2 control over %3. %1 передает %2 контроль над %3. - + %1 puts %2 into play%3 face down. - + %1 puts %2 into play%3. %1 поместил %2 на поле битвы %3. - + %1 puts %2%3 into their graveyard face down. - + %1 puts %2%3 into their graveyard. %1 кладет %2%3 на своё кладбище. - + %1 exiles %2%3 face down. - + %1 exiles %2%3. %1 изгоняет %2%3. - + %1 moves %2%3 to their hand. %1 кладет %2%3 в свою руку. - + %1 puts %2%3 into their library. %1 кладет %2%3 в свою библиотеку. - + %1 puts %2%3 onto the bottom of their library. - + %1 puts %2%3 on top of their library. %1 кладет %2%3 на верх своей библиотеки. - + %1 puts %2%3 into their library %4 cards from the top. - + %1 moves %2%3 to sideboard. %1 поместил %2%3 в сайд. - + %1 plays %2%3 face down. - + %1 plays %2%3. %1 разыгрывает %2%3. - + %1 moves %2%3 to custom zone '%4' face down. - + %1 moves %2%3 to custom zone '%4'. - + %1 tries to draw from an empty library - + %1 draws %2 card(s). %1 берет %2 карту.%1 берет %2 карты.%1 берет %2 карт.%1 берет %2 карты. - + %1 is looking at %2. %1 просматривает %2. - + %1 is looking at the %4 %3 card(s) %2. top card for singular, top %3 cards for plural - + bottom - + top - + %1 turns %2 face-down. - + %1 turns %2 face-up. - + The game has been closed. Игра закрыта. - + The game has started. Игра началась. - + You are flooding the game. Please wait a couple of seconds. - + %1 has joined the game. %1 присоединился к игре. - + %1 is now watching the game. %1 вошел как зритель. - + You have been kicked out of the game. Вы были отключены от игры - + %1 has left the game (%2). - + %1 is not watching the game any more (%2). - + %1 is not ready to start the game any more. %1 пока не готов начать игру. - + %1 shuffles their deck and draws a new hand of %2 card(s). %1 тасует колоду и берет новую руку из %2 карты%1 тасует колоду и берет новую руку из %2 карт%1 тасует колоду и берет новую руку из %2 карт%1 тасует колоду и берет новую руку из %2 карт - + %1 shuffles their deck and draws a new hand. - + You are watching a replay of game #%1. Вы просматриваете запись игры #%1. - + %1 is ready to start the game. %1 готов начать игру. - + cards an unknown amount of cards - + %1 card(s) a card for singular, %1 cards for plural - + %1 lends %2 to %3. - + %1 reveals %2 to %3. %1 открывает %2 для %3. - + %1 reveals %2. %1 открывает %2. - + %1 randomly reveals %2%3 to %4. %1 случайным образом открывает %2%3 для %4. - + %1 randomly reveals %2%3. %1 случайным образом открывает %2%3. - + %1 peeks at face down card #%2. %1 указывает на перевернутую рубашкой вверх карту #%2. - + %1 peeks at face down card #%2: %3. %1 указывает на перевернутую рубашкой вверх карту #%2: %3. - + %1 reveals %2%3 to %4. %1 открывает %2%3 для %4. - + %1 reveals %2%3. %1 открывает %2%3. - + %1 reversed turn order, now it's %2. - + reversed - + normal - + Heads Орел - + Tails Решка - + %1 flipped a coin. It landed as %2. %1 подбросил монету. Выпал(а) %2. - + %1 rolls a %2 with a %3-sided die. %1 выбросил %2 на %3-гранном кубике. - + %1 flips %2 coins. There are %3 heads and %4 tails. - + %1 rolls a %2-sided dice %3 times: %4. - + %1's turn. - + %1 sets annotation of %2 to %3. %1 помечает %2 "%3". - - %1 places %2 "%3" counter(s) on %4 (now %5). - %1 разместил %2 "%3" жетон на %4 (теперь %5).%1 разместил %2 "%3" жетонов на %4 (теперь %5).%1 разместил %2 "%3" жетонов на %4 (теперь %5).%1 разместил %2 "%3" жетонов на %4 (теперь %5). + + %1 places %2 %3%4 counter(s) on %5 (now %6). + - - %1 removes %2 "%3" counter(s) from %4 (now %5). - %1 удалил %2 "%3" жетон на %4 (теперь %5).%1 удалил %2 "%3" жетона на %4 (теперь %5).%1 удалил %2 "%3" жетонов на %4 (теперь %5).%1 удалил %2 "%3" жетона на %4 (теперь %5). + + %1 removes %2 %3%4 counter(s) from %5 (now %6). + - + + %1 failed to undo their last draw. + + + + %1 sets counter %2 to %3 (%4%5). %1 устанавливает жетон %2 на %3 (%4%5). - + %1 sets %2 to not untap normally. %2 теперь не разворачивается как обычно (%1). - + %1 sets %2 to untap normally. %2 теперь разворачивается как обычно (%1). - + %1 removes the PT of %2. - + %1 changes the PT of %2 from nothing to %4. - + %1 changes the PT of %2 from %3 to %4. - + %1 has locked their sideboard. %1 заблокировал(а) свой сайдборд. - + %1 has unlocked their sideboard. %1 разблокировал(а) свой сайдборд. - + %1 taps their permanents. %1 поворачивает свои перманенты. - + %1 untaps their permanents. %1 разворачивает свои перманенты. - + %1 taps %2. %1 поворачивает %2. - + %1 untaps %2. %1 разворачивает %2. - + %1 shuffles %2. %1 размешал %2. - + %1 shuffles the bottom %3 cards of %2. - + %1 shuffles the top %3 cards of %2. - + %1 shuffles cards %3 - %4 of %2. - + %1 unattaches %2. %1 открепляет %2. - + %1 undoes their last draw. %1 отменяет последние взятия. - + %1 undoes their last draw (%2). %1 отменяет последние взятия (%2). @@ -6298,110 +6388,115 @@ Cockatrice will now reload the card database. MessagesSettingsPage - + Word1 Word2 Word3 - + Add New Message - + Edit Message - + Remove Message - + Add message Добавить сообщение - - + + Message: Сообщение: - + Edit message - + Chat settings Настройки чата - + Custom alert words Пользовательские предупреждения - + Enable chat mentions Включить упоминания в чате - + Enable mention completer Включить автозавершение упоминаний - + In-game message macros Макросы сообщений игрового чата - + How to use in-game message macros Как использовать внутриигровые макросы для сообщений - + Ignore chat room messages sent by unregistered users Не уведомлять о сообщениях в чате от незарегистрированных пользователей - + Ignore private messages sent by unregistered users Не уведомлять о личных сообщениях от незарегистрированных пользователей - - + + Ignore private messages sent by non-buddy users + + + + + Invert text color Инвертировать цвет текста - + Enable desktop notifications for private messages Включить оповещения о личных сообщениях - + Enable desktop notification for mentions Включить оповещения об упоминаниях - + Enable room message history on join Отображать историю сообщений при присоединении к комнате. - - + + (Color is hexadecimal) (Шестнадцатеричный цвет) - + Separate words with a space, alphanumeric characters only Разделять слова пробелом, только для букв и цифр @@ -6409,42 +6504,42 @@ Cockatrice will now reload the card database. MoveMenu - + Move to - + &Top of library in random order - + X cards from the top of library... - + &Bottom of library in random order - + T&able - + &Hand - + &Graveyard - + &Exile @@ -6457,47 +6552,47 @@ Cockatrice will now reload the card database. - + Mana Value - + Color(s) - + Loyalty - + Main Card Type - + Mana Cost - + P/T - + Side - + Layout - + Color Identity @@ -6520,6 +6615,152 @@ Cockatrice will now reload the card database. + + PaletteEditorDialog + + + + Reset + + + + + + Apply + + + + + + Save && Apply + + + + + ▼ Edit Palette + + + + + + ▶ Edit Palette + + + + + Palette Editor — %1 + + + + + <b>Palette Editor</b> &nbsp;·&nbsp; %1 + + + + + This theme ships no default palette files + + + + + Replace current colours with the theme author's defaults + + + + + Switch between the light and dark palette files + + + + + Editing: + + + + + Show or hide the per-role colour grid for manual tweaks + + + + + ↺ Revert to theme default + + + + + Discard unsaved edits and restore the last saved palette + + + + + Preview this palette without saving to disk + + + + + Write palette-%1.toml and reload the theme + + + + + Cannot save: this theme has no directory on disk + + + + + Save failed + + + + + Could not write %1 to: +%2 + + + + + No default found + + + + + No default palette file found for the "%1" scheme. + + + + + PaletteGridWidget + + + Active + + + + + Disabled + + + + + Inactive + + + + + Normal interactive state + + + + + Widget is disabled / not interactive + + + + + Window is in background / unfocused + + + Phase @@ -6586,57 +6827,57 @@ Cockatrice will now reload the card database. PhasesToolbar - + Untap step Шаг разворота - + Upkeep step Шаг поддержки - + Draw step Шаг взятия карты - + First main phase Первая главная фаза - + Beginning of combat step Шаг начала боя - + Declare attackers step Шаг назначения атакующих - + Declare blockers step Шаг назначения блокирующих - + Combat damage step Шаг нанесения повреждений - + End of combat step Шаг завершения боя - + Second main phase Вторая главная фаза - + End of turn step Заключительный шаг @@ -6644,7 +6885,7 @@ Cockatrice will now reload the card database. PictureLoader - + en code for scryfall's language property, not available for all languages ru @@ -6653,151 +6894,142 @@ Cockatrice will now reload the card database. PlayerActions - - View top cards of library - - - - - - - - - - - - - Number of cards: (max. %1) - - - - - View bottom cards of library - - - - - Shuffle top cards of library - - - - - Shuffle bottom cards of library - - - - - Draw hand - - - - - 0 and lower are in comparison to current hand size - - - - - Draw cards - Взять карты - - - - - - + + + + grave - - - - + + + + exile + + + PlayerDialogs - + + View top cards of library + + + + + + + + + + + + + Number of cards: (max. %1) + + + + + View bottom cards of library + + + + + Shuffle top cards of library + + + + + Shuffle bottom cards of library + + + + + Draw hand + + + + + 0 and lower are in comparison to current hand size + + + + + Draw cards + + + + Move top cards to %1 - + Move bottom cards to %1 - + Draw bottom cards - Взять карты с низа библиотеки - - - - - C&reate another %1 token - + Create tokens - - + Number: - + Place card X cards from top of library - + Which position should this card be placed: - + (max. %1) - + Change power/toughness - + Change stats to: - + Set annotation - + Please enter the new annotation: - - - Set counters - Установить жетоны - PlayerMenu - + Player "%1" - + &Counters &Жетоны @@ -6827,7 +7059,7 @@ This setting means you'll only see the default printing for each card, instead o - + Printing Selector @@ -6835,22 +7067,22 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelectorCardOverlayWidget - + Preference - + Pin Printing - + Unpin Printing - + Show Related cards @@ -6921,57 +7153,57 @@ This setting means you'll only see the default printing for each card, instead o PtMenu - + Power / toughness - + &Increase power - + &Decrease power - + I&ncrease toughness - + D&ecrease toughness - + In&crease power and toughness - + Dec&rease power and toughness - + Increase power and decrease toughness - + Decrease power and increase toughness - + Set &power and toughness... - + Reset p&ower and toughness @@ -6979,37 +7211,37 @@ This setting means you'll only see the default printing for each card, instead o QMenuBar - + Services Сервисы - + Hide %1 Скрыть %1 - + Hide Others Скрыть остальные - + Show All Показать все - + Preferences... Параметры... - + Quit %1 Выйти %1 - + About %1 О %1 @@ -7017,17 +7249,17 @@ This setting means you'll only see the default printing for each card, instead o QObject - + Cockatrice card database (*.xml) База карт Cockatrice (*.xml) - + All files (*.*) Все файлы (*.*) - + Cockatrice replays (*.cor) Записи игр Cockatrice (*.cor) @@ -7087,110 +7319,177 @@ Are you sure you would like to disable this feature? QPlatformTheme - + OK - + Save - + Save All - + Open - + &Yes - + Yes to &All - + &No - + N&o to All - + Abort - + Retry - + Ignore - + Close - + Cancel - + Discard - + Help - + Apply - + Reset - + Restore Defaults + + QuickSetupPanel + + + %1% + + + + + <b>Quick Setup</b> + + + + + Generate all palette roles automatically from a single accent colour + + + + + Accent: + + + + + Primary hue. Used directly for highlights and links. +At high intensity it also tints buttons and backgrounds. + + + + + Intensity: + + + + + Subtle + + + + + Full colour + + + + + 0–30 Subtle tint — only highlights and links change hue +30–70 Accented — buttons, tooltips, and borders join in +70–100 Full colour — backgrounds, everything + + + + + 70% + + + + + Generate ↓ + + + + + Derive all palette roles from the accent colour above. +Fine-tune individual colours in the grid afterwards. + + + RemoteDeckList_TreeModel - + Name Название - + ID ID - + Upload time Дата загрузки @@ -7198,32 +7497,32 @@ Are you sure you would like to disable this feature? RemoteReplayList_TreeModel - + ID ID - + Name Имя - + Players Игроки - + Keep Хранение - + Time started Время начала - + Duration (sec) Длительность (сек) @@ -7231,37 +7530,37 @@ Are you sure you would like to disable this feature? RfgMenu - + &Exile - + &View exile - + &Move exile to... - + &Top of library - + &Bottom of library - + &Hand - + &Graveyard @@ -7307,7 +7606,7 @@ Are you sure you would like to disable this feature? SayMenu - + S&ay @@ -7348,27 +7647,27 @@ Are you sure you would like to disable this feature? SetsModel - + Enabled Включено - + Set type Установить тип - + Set code Установить код - + Long name Полное название - + Release date Дата релиза @@ -7376,53 +7675,53 @@ Are you sure you would like to disable this feature? ShortcutSettingsPage - - + + Restore all default shortcuts - + Do you really want to restore all default shortcuts? - + Clear all default shortcuts - + Do you really want to clear all shortcuts? - + Section: - + Action: - + Shortcut: - + How to set custom shortcuts - + Clear all shortcuts - + Search by shortcut name @@ -7476,12 +7775,12 @@ Please check your shortcut settings! SideboardMenu - + &Sideboard - + &View sideboard @@ -7489,27 +7788,27 @@ Please check your shortcut settings! SoundSettingsPage - + Enable &sounds Включить &звуки - + Current sounds theme: Звуковая тема: - + Test system sound engine Протестировать системный звук - + Sound settings Настройки звука - + Master volume Громкости @@ -7535,7 +7834,7 @@ Please check your shortcut settings! No internet connection - + Нет подключения к Интернету @@ -7581,16 +7880,146 @@ Please check your shortcut settings! Ошибка отклика сервера обновлений версии. - + No reply received from the tag update server. Сервер обновлений меток не отвечает. - + Invalid reply received from the tag update server. Ошибка отклика сервера обновлений меток. + + StorageSettingsPage + + + + + Success + + + + + Cached card pictures have been reset. + + + + + Downloaded card pictures have been reset. + + + + + Error + + + + + One or more downloaded card pictures could not be cleared. + + + + + In-memory (currently loaded) card pictures have been reset. + + + + + Card Picture Loader Caching Method: + + + + + The network cache is the preferred way of storing images. Downloaded images are stored here until the size of the cache exceeds the configured size. Cockatrice automatically monitors this cache and deletes the least recently seen card images to ensure the cache does not exceed the configured size. + + + + + Writing card images directly to a folder on your hard drive is another way of storing images. This does not change how Cockatrice accesses or downloads images. Cockatrice will NOT automatically monitor and clear this folder, so if you enable this option, it is up to you to ensure sufficient available space. It should also be noted that if a provider outage causes you to download the wrong picture (i.e. wrong printing) you will be stuck with it until you manually delete the file, as opposed to using the network cache, which automatically rotates and thus correct errors after a while. + + + + + This is the in-memory picture cache used by the application at runtime. It determines how much memory (RAM) Cockatrice can use before it has to fetch card images from the hard disk again. Increasing this will allow more card images to be displayed at once but shouldn't be necessary. Clearing this will make Cockatrice reload all images from the network cache or the disk. + + + + + Delete Cached Images + + + + + Delete Saved Images + + + + + Clear In-Memory Images + + + + + Card Picture Loader Cache Method + + + + + Network Cache + + + + + Filesystem + + + + + In-Memory Picture Cache + + + + + Network Cache Size: + + + + + On-disk cache for downloaded pictures + + + + + Redirect Cache TTL: + + + + + How long cached redirects for urls are valid for. + + + + + Picture Cache Size: + + + + + In-memory cache for pictures not currently on screen + + + + + Naming scheme: + + + + + Day(s) + + + TabAccount @@ -7726,117 +8155,117 @@ Please check your shortcut settings! TabArchidekt - - + + Desc. - - + + AND - - + + Require ALL selected colors - - + + Deck name... - - + + Owner... - - + + Packages - - + + Advanced Filters - + Bracket: - - + + Any - - + + Contains card... - - + + Commander... - - + + Tag... - - + + Deck Size - + Cards: - - + + Asc. - + Sort by: - + Filter by: - + Display Settings - - + + Search - - + + Formats @@ -7846,6 +8275,54 @@ Please check your shortcut settings! + + TabCardArtRules + + + Card: + + + + + ProviderId: + + + + + Mode: + + + + + Reason: + + + + + Type a card name... + + + + + Add rule + + + + + Remove rule + + + + + Refresh + + + + + Card Art Rules + + + TabDeckEditor @@ -7894,7 +8371,7 @@ Please check your shortcut settings! Сбросить слой - + Deck: %1 Колода: %1 @@ -7902,55 +8379,49 @@ Please check your shortcut settings! TabDeckEditorVisual - + Visual Deck: %1 - + &Visual Deck Editor - - + + Card Info - - + + Deck - - - Filters - - - - + &View - + Printing - + Visible - + Floating - + Reset layout @@ -7958,22 +8429,22 @@ Please check your shortcut settings! TabDeckEditorVisualTabWidget - + Visual Deck View - + Visual Database Display - + Deck Analytics - + Sample Hand @@ -7981,134 +8452,134 @@ Please check your shortcut settings! TabDeckStorage - + Local file system Локальная файловая система - + Server deck storage Хранилище колод на сервере - - + + Open in deck editor Открыть колоду в редакторе - + Rename deck or folder - + Upload deck Загрузить колоду в хранилище - + Download deck Загрузить колоду из хранилища - - - + + + New folder Новая папка - + Delete Удалить - + Open decks folder - + Rename local folder - + Rename local file - + New name: - - - - + + + + Error - + Rename failed - - + + Invalid deck file - + Enter deck name Введите название колоды - + This decklist does not have a name. Please enter a name: У этой колоды нет названия. Пожалуйста, введите имя: - + Unnamed deck Безымянная колода - + Failed to upload deck to server - + Delete local file Удалить файл - + Are you sure you want to delete the selected files? - + Delete remote decks - + Are you sure you want to delete the selected decks? - - + + Name of new folder: Название новой папки: @@ -8180,191 +8651,191 @@ Please enter a name: TabGame - - - + + + Replay Запись игры - - + + Game Игра - - + + Player List Список игроков - - + + Card Info Информация о карте - - + + Messages Сообщения - - + + Replay Timeline Полоса прокрутки записи - + &Phases &Фазы - + &Game &Игра - + Next &phase Следующая &фаза - + Next phase with &action - + Next &turn Следующий &ход - + Reverse turn order - + &Remove all local arrows &Удалить все указатели - + Rotate View Cl&ockwise Повернуть вид по часовой стрелке - + Rotate View Co&unterclockwise Повернуть вид против часовой стрелки - + Game &information Информация &об игре - + Un&concede - - - + + + &Concede &Сдаться... - + &Leave game Покинуть и&гру - + C&lose replay З&акрыть повтор - + &Focus Chat &Установить фокус на чат - + &Say: Ска&зать: - + Selected cards - + &View Вид - + Visible Видимый - + Floating Плавающий - + Reset layout Сбросить слой - + Concede Сдаться - + Are you sure you want to concede this game? Вы точно хотите сдаться? - + Unconcede - + You have already conceded. Do you want to return to this game? - + Leave game Покинуть игру - + Are you sure you want to leave this game? Вы уверены, что хотите уйти? - + A player has joined game #%1 - + %1 has joined the game - + You have been kicked out of the game. Вы были отключены от игры @@ -8423,115 +8894,115 @@ Please enter a name: Вы должны указать верное количество дней для размещения. - + Username: Имя пользователя: - + IP Address: IP адрес - + Game Name: Имя игры: - + GameID: ID Игры: - + Message: Сообщение: - + Main Room Основная комната - + Game Room Игровая комната - + Private Chat Приватный чат - + Past X Days: Последние X Дней: - + Today Сегодня - + Last Hour За последний час - + Maximum Results: Максимальный результат: - + At least one filter is required. The more information you put in, the more specific your results will be. Должен быть как минимум один фильтр. Чем больше информации Вы укажете, тем точнее будет результат. - + Get User Logs Получить логи пользователя - + Clear Filters Очистить фильтры - + Filters Фильтры - + Log Locations Размещение логов - + Date Range Интервал дат - + Maximum Results Максимальный результат - - + + Message History История сообщений - + Failed to collect message history information. Не удалось собрать историю сообщений. - + There are no messages for the selected filters. Нет сообщений для указанных фильтров. @@ -8549,27 +9020,27 @@ The more information you put in, the more specific your results will be.&Покинуть - + %1 - Private chat %1 - Приватный чат - + This user is ignoring you, they cannot see your messages in main chat and you cannot join their games. Это пользователь добавил Вас в игнор-лист и не видит ваши сообщения в главном чате, а Вы не можете присоединяться к созданным им (ею) играм. - + Private message from Личное сообщение от - + %1 has left the server. %1 отключился. - + %1 has joined the server. %1 зашел на сервер. @@ -8577,180 +9048,180 @@ The more information you put in, the more specific your results will be. TabReplays - + Local file system Локальная файловая система - + Server replay storage Хранилище записей игр на сервере - - + + Watch replay Посмотреть повтор - + Rename - - + + New folder - - + + Delete Удалить - + Open replays folder - + Download replay Скачать запись игры - + Toggle expiration lock Переключить блокировку срока - + Get replay share code - - + + Look up replay by share code - + Rename local folder - + Rename local file - + New name: - + Error - + Rename failed - + Name of new folder: - + Delete local file Удалить локальный файл - + Are you sure you want to delete the selected files? - + Are you sure you want to delete the selected replays? - + Failed to get code - - + + Either this server does not support replay sharing, or does not permit replay sharing for you. - - - + + + Failed - + Could not get replay code - + Replay Share Code - + Others can use this code to add the replay to their list of remote replays: %1 - + Copy to clipboard - + Replay share code - + Replay code found - + Replay was added, or you already had access to it. - + Replay code not found - + Failed to submit code - + Unexpected error - + Delete remote replay Удалить запись игры @@ -8763,47 +9234,62 @@ The more information you put in, the more specific your results will be. TabRoom - + + Friends + + + + + Online + + + + + Ignored + + + + &Say: &Сказать: - + Chat Чат - + &Room &Комната - + &Leave room &Покинуть комнату - + &Clear chat & Обновить чат - + Chat Settings... Установки чата - + mentioned you. упомянул(а) Вас. - + Click to view Нажмите для просмотра - + You are flooding the chat. Please wait a couple of seconds. Кажется, Вы нафлудили. Пожалуйста, подождите пару секунд. @@ -8816,30 +9302,30 @@ The more information you put in, the more specific your results will be.Сервер - - - - + + + + Error Ошибка - + Failed to join the server room: it doesn't exist on the server. - + The server thinks you are in the server room but your client is unable to display it. Try restarting your client. - + You do not have the required permission to join this server room. - + Failed to join the server room due to an unknown error: %1. @@ -8847,97 +9333,97 @@ The more information you put in, the more specific your results will be. TabSupervisor - + Deck Editor - + Visual Deck Editor - + EDHRec - + Archidekt - + Home - + &Visual Deck Storage - + Visual Database Display - + Server - + Account - + Deck Storage - + Game Replays - + Administration - + Logs - + Are you sure? Вы уверены? - + There are still open games. Are you sure you want to quit? У вас все еще есть активные игры. Уверены, что хотите выйти? - + Click to view - + Your buddy %1 has signed on! - + Unknown Event Неопознанное событие - + The server has sent you a message that your client does not understand. This message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -8948,38 +9434,38 @@ To update your client, go to Help -> Check for Updates. Чтобы обновить клиент, перейдите в Справка-> Проверка обновлений. - + Idle Timeout Исчерпано время бездействия - + You are about to be logged out due to inactivity. Вы были отключены из-за отсутствия активности. - + Promotion Повышение - + You have been promoted. Please log out and back in for changes to take effect. - + Warned Предупрежден - + You have received a warning due to %1. Please refrain from engaging in this activity or further actions may be taken against you. If you have any questions, please private message a moderator. Вы получили предупреждение по причине %1. Пожалуйста, воздержитесь от подобных действий, иначе в ваш адрес будут применены дальнейшие меры. При возникновении каких-либо вопросов напишите личное сообщение любому модератору. - + You have received the following message from the server. (custom messages like these could be untranslated) Вы получили сообщение от сервера. @@ -8989,12 +9475,12 @@ Please refrain from engaging in this activity or further actions may be taken ag TabVisualDatabaseDisplay - + Database Display - + Visual Database Display @@ -9031,42 +9517,42 @@ Please refrain from engaging in this activity or further actions may be taken ag TranslateCounterName - + Life - + White - + Blue - + Black - + Red - + Green - + Colorless - + Other @@ -9074,11 +9560,69 @@ Please refrain from engaging in this activity or further actions may be taken ag UpdateDownloader - + Could not open the file for reading. Невозможно открыть файл для чтения. + + UserCardArtSettingsDialog + + + Card Art Settings + + + + + Type a card name... + + + + + Card name: + + + + + Card ProviderId: + + + + + Left margin (%): + + + + + Right margin (%): + + + + + Vertical offset: + + + + + Zoom: + + + + + Parameters + + + + + Preview + + + + + Remove Banner Card + + + UserContextMenu @@ -9196,7 +9740,7 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Ban History История банов @@ -9211,77 +9755,87 @@ Please refrain from engaging in this activity or further actions may be taken ag Пользователя никогда не банили. - + Failed to collect ban information. - - - + + + Warning History История предупреждений - + Warning Time;Moderator;User Name;Reason ВремяПредупреждения;Модератор;ИмяПольозователя;Причина - + User has never been warned. Пользователя никогда не предупреждали. - + Failed to collect warning information. - + Failed to get admin notes. - - + + Success Успешно - + Successfully promoted user. Пользователь успешно повышен. - + Successfully demoted user. Пользователь успешно понижен. - - - + + Kick Player + + + + + Are you sure you want to kick this player from the game? + + + + + + Failed Не удалось - + Failed to promote user. Не удалось повысить пользователя. - + Failed to demote user. Не удалось понизить пользователя. - + Copy hash to clipboard - + Remove this user's messages @@ -9289,326 +9843,554 @@ Please refrain from engaging in this activity or further actions may be taken ag UserInfoBox - + Location: Местонахождение: - + Account Age: Возраст учетной записи: - + Edit Редактировать - + Change password Сменить пароль - + Change avatar Сменить аватар - + Administrator Администратор - + Moderator Модератор - + Registered user Зарегистрированный пользователь - - + + Unregistered user Рандом - + Judge - + Unknown Неизвестный - + The entered password does not match your account. - - - + + + + Information Информация - + User information updated. Информация о пользователе обновлена. - - - - - - - - - - + + + + + + + + + + + Error Ошибка - + User Information - + Real Name: - + User Level: + + + Edit Banner Card + + - + %n Year(s), amount of years (only shown if more than 0) - + %10%n Day(s) %20 amount of years (if more than 0), amount of days, date in local short format - + Enter Password - + Password verification is required in order to change your email address - - - + + + An error occurred while trying to update your user information. - + + The selected card is blacklisted on this server or another error occurred. + + + + + Banner card removed. + + + + + Banner card updated. + + + + This server does not permit you to update your user informations. Этот сервер не позволяет обновить пользовательскую информацию. - + Password changed. Пароль изменен. - + This server does not permit you to change your password. Этот сервер не позволяет Вам сменить пароль. - + The new password is too short. Новый пароль слишком короткий. - + The old password is incorrect. Предыдущий пароль неверен. - + Avatar updated. Аватар изменен. - + This server does not permit you to update your avatar. Этот сервер не позволяет Вам изменить аватар. + + + An error occured while trying to update your avatar. + + - An error occured while trying to updater your avatar. - При изменении аватара произошла ошибка. + This server does not permit you to update your user informations. + An error occured while trying to updater your avatar. + Этот сервер не позволяет обновить пользовательскую информацию. + + + + UserInfoPopup + + + + Games + + + + + Chat + + + + + Open private chat + + + + + Profile + + + + + View user profile + + + + + Show this user's games + + + + + − Buddy + + + + + Remove from buddy list + + + + + + Buddy + + + + + Add to buddy list + + + + + − Ignore + + + + + Remove from ignore list + + + + + + Ignore + + + + + Add to ignore list + + + + + Ban + + + + + Ban from server + + + + + Warn + + + + + Warn user + + + + + Ban log + + + + + View ban history + + + + + Warn log + + + + + View warning history + + + + + Notes + + + + + View admin notes + + + + + − Mod + + + + + Demote from moderator + + + + + + Mod + + + + + Promote to moderator + + + + + − Judge + + + + + Demote from judge + + + + + + Judge + + + + + Promote to judge + + + + + Join game + + + + + Spectate + + + + + + Loading games… + + + + + Could not load games. + + + + + No active games. + UserInterfaceSettingsPage - + General interface settings Основные настройки интерфейса - + &Double-click cards to play them (instead of single-click) &Двойной клик, чтобы разыграть карту (вместо одинарного) - + &Clicking plays all selected cards (instead of just the clicked card) &Клик для разыгрывания всех выбранных карт (вместо только выбранной) - + &Play all nonlands onto the stack (not the battlefield) by default &Помещать все заклинания в стек при разыгрывании (вместо поля боя) - + Do not delete &arrows inside of subphases - + Close card view window when last card is removed Закрыть окно просмотра карт когда последняя карта удалена из него - + Auto focus search bar when card view window is opened - + Annotate card text on tokens Изменить текст карты на токенах - - Show selection counter during drag selection + + Show selection count during drag selection - - Show total selection counter + + Show total selection count - + + Show subtype breakdown in selection tally + + + + Use tear-off menus, allowing right click menus to persist on screen Разрешить закрепление контекстных меню на экране - + + Keep game chat focused when clicking in game (Note: disables card view search bar) + + + + Notifications settings - + Enable notifications in taskbar Включить оповещения в панели задач. - + Notify in the taskbar for game events while you are spectating Уведомлять об игровых событиях в панели задач, когда вы наблюдаете за игрой - + Notify in the taskbar when users in your buddy list connect Уведомлять при заходе в игру друга - + Animation settings Настройки анимации - + &Tap/untap animation &Анимировать поворот/разворот карты - + Deck editor/storage settings Редактор колод/Настройки хранилища - + Open deck in new tab by default Открывать колоду в новой вкладке - + Use visual deck storage in game lobby Использовать графическое представление карт в лобби игры - + Use selection animation for Visual Deck Storage Использовать анимацию при выборе карты в графическом хранилище - + When adding a tag in the visual deck storage to a .txt deck: - + do nothing - + ask to convert to .cod - + always convert to .cod всегда конвертировать в .cod - + Default deck editor type - + Classic Deck Editor - + Visual Deck Editor - + Replay settings - + Buffer time for backwards skip via shortcut: Время отката назад при нажатии шортката @@ -9616,22 +10398,22 @@ Please refrain from engaging in this activity or further actions may be taken ag UserListWidget - + Users connected to server: %1 - + Users in this room: %1 - + Buddies online: %1 / %2 - + Ignored users online: %1 / %2 @@ -9639,35 +10421,45 @@ Please refrain from engaging in this activity or further actions may be taken ag UtilityMenu - + Increment all card counters Добавить по одному каждого жетона - + &Untap all permanents - + R&oll die... - + + Flip coin + + + + &Create token... - + C&reate another token - + Cr&eate predefined token + + + C&reate another %1 token + + VisualDatabaseDisplayColorFilterWidget @@ -9719,72 +10511,72 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayFilterToolbarWidget - + Sort by - + Filter by - + Save and load filters - + Filter by exact card name - + Filter by card main-type - + Filter by card sub-type - + Filter by set - + Filter by format legality - + Save/Load - + Name - + Main Type - + Sub Type - + Sets - + Formats @@ -9792,22 +10584,22 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayFormatLegalityFilterWidget - + Show formats with at least: - + cards - + Do not display formats with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) @@ -9825,32 +10617,32 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayMainTypeFilterWidget - + Show main types with at least: - + cards - + Do not display card main-types with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) - + Mode: Exact Match - + Mode: Includes @@ -9858,27 +10650,27 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayNameFilterWidget - + Filter by name... (Exact match) - + Load from Deck - + Apply all card names in currently loaded deck as exact match name filters - + Load from Clipboard - + Apply all card names in clipboard as exact match name filters @@ -9886,7 +10678,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayRecentSetFilterSettingsWidget - + Filter to most recent sets @@ -9894,19 +10686,19 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplaySetFilterWidget - + Search sets... - - + + Mode: Exact Match - - + + Mode: Includes @@ -9914,37 +10706,37 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplaySubTypeFilterWidget - + Search subtypes... - + Show sub types with at least: - + cards - + Do not display card sub-types with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) - + Mode: Exact Match - + Mode: Includes @@ -9952,23 +10744,23 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayWidget - + Search by card name (or search expressions) - + Visual - + Loading database ... - + Clear all filters @@ -10007,17 +10799,17 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Toggle Layout: Overlap - + Change how cards are displayed within zones (i.e. overlapped or fully visible.) - + Toggle Layout: Flat @@ -10046,17 +10838,17 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckEditorWidget - + Type a card name here for suggestions from the database... - + Quick search and add card - + Search for closest match in the database (with auto-suggestions) and add preferred printing to the deck on pressing enter @@ -10174,43 +10966,43 @@ Please refrain from engaging in this activity or further actions may be taken ag WarningDialog - + Which warning would you like to send? Какое предупреждение Вы бы хотели отправить? - + Redact all messages from this user in all rooms - + &OK &ОК - + &Cancel &Отменить - + Warn user for misconduct Дать предупреждение пользователю за проступок - - + + Error Ошибка - + User name to send a warning to can not be blank, please specify a user to warn. Поле не может быть пустым. Пожалуйста, уточните имя пользователя для выдачи предупреждения. - + Warning to use can not be blank, please select a valid warning to send. Поле предупреждения не может быть пустым. Пожалуйста, выберите корректную причину. @@ -10218,133 +11010,133 @@ Please refrain from engaging in this activity or further actions may be taken ag WndSets - + Move selected set to the top Переместить выбранное издание наверх - + Move selected set up Переместить выбранное издание вверх - + Move selected set down Переместить выбранное издание вниз - + Move selected set to the bottom Переместить выбранное издание в конец. - + Search by set name, code, or type - + Default order - + Restore original art priority order - + Enable all sets Включить все издания - + Disable all sets Отключить все издания - + Enable selected set(s) Включить выбранные издания - + Disable selected set(s) Отключить выбранные издания - + Deck Editor Редактор колод - + Use CTRL+A to select all sets in the view. - + Only cards in enabled sets will appear in the card list of the deck editor. - + Image priority is decided in the following order: - + first the CUSTOM Folder (%1), then the Enabled Sets in this dialog (Top to Bottom) %1 is a link to the wiki - + Include cards rebalanced for Alchemy [requires restart] - + Card Art - + How to use custom card art Как использовать кастомный арт для карты - + Hints - + Note - + Sorting by column allows you to find a set while not changing set priority. - + To enable ordering again, click the column header until this message disappears. - + Use the current sorting as the set priority instead - + Sorts the set priority using the same column - + Manage sets @@ -10352,72 +11144,72 @@ Please refrain from engaging in this activity or further actions may be taken ag ZoneViewWidget - + Search by card name (or search expressions) - + Ungrouped - + Group by Type - + Group by Mana Value - + Group by Color - + Unsorted - + Sort by Name - + Sort by Type - + Sort by Mana Cost - + Sort by Colors - + Sort by P/T - + Sort by Set - + shuffle when closing Перемешать после просмотра - + pile view просмотр вразнобой @@ -10425,7 +11217,7 @@ Please refrain from engaging in this activity or further actions may be taken ag i18n - + English Русский (Russian) @@ -10433,12 +11225,12 @@ Please refrain from engaging in this activity or further actions may be taken ag main - + Connect on startup - + Debug to file @@ -10452,7 +11244,7 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Deck Editor Редактор колоды @@ -10533,7 +11325,7 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Replays @@ -10648,129 +11440,129 @@ Please refrain from engaging in this activity or further actions may be taken ag - - + + Load Deck from Clipboard... - + Edit Deck in Clipboard, Annotated - + Edit Deck in Clipboard - + New Deck - + Open Custom Pictures Folder - + Print Deck... - + Delete Card - - + + Reset Layout - + Save Deck - + Save Deck as... - + Save Deck to Clipboard, Annotated - + Save Deck to Clipboard, Annotated (No Set Info) - + Save Deck to Clipboard - + Save Deck to Clipboard (No Set Info) - + Load Local Deck... - + Load Remote Deck... - + Set Ready to Start - + Toggle Sideboard Lock - + Add Green Counter Добавить зелёный жетон - + Remove Green Counter Удалить зелёный жетон - + Set Green Counters... Установить зелёные жетоны - + Add Red Counter Добавить красный жетон - + Remove Red Counter Убрать красный жетон - + Set Red Counters... Установить кол-во красных жетонов... - + Add Life Counter Добавить 1 ед. жизни @@ -10780,724 +11572,744 @@ Please refrain from engaging in this activity or further actions may be taken ag - + + Load deck from online service... + + + + + Load from website... + + + + Unload Deck - + Force Start - + Add Card Counter (F) Добавить жетон (F) - + Remove Card Counter (F) Удалить жетон (F) - + Set Card Counters (F)... Установить жетоны (F) - + Add Card Counter (E) Добавить жетон (E) - + Remove Card Counter (E) Удалить жетон (E) - + Set Card Counters (E)... Установить жетоны (E)... - + Add Card Counter(D) Добавить жетон(D) - + Remove Card Counter (D) Удалить жетон (D) - + Set Card Counters (D)... Установить жетоны (D)... - + Add Card Counter (C) Добавить жетон (C) - + Remove Card Counter (C) Удалить жетон (C) - + Set Card Counters (C)... Установить жетоны (C) - + Add Card Counter (B) Добавить жетон (B) - + Remove Card Counter (B) Удалить жетон (B) - + Set Card Counters (B)... Установить жетоны (B)... - + Add Card Counter (A) Добавить жетон (A) - + Remove Card Counter (A) Удалить жетон (A) - + Set Card Counters (A)... Установить жетоны (A)... - + Remove Life Counter Убрать 1 ед. жизни - + Set Life Counters... Установить кол-во жизней - + Add White Counter Добавить белый жетон - + Remove White Counter Убрать белый жетон - + Set White Counters... Установить белые жетоны... - + Add Blue Counter Добавить синий жетон - + Remove Blue Counter Убрать синие жетоны - + Set Blue Counters... Установить синие жетоны... - + Add Black Counter Добавить черные жетон - + Remove Black Counter Удалить черные жетон - + Set Black Counters... Установить черные жетоны... - + Add Colorless Counter Добавить бесцветные жетон - + Remove Colorless Counter Удалить бесцветные жетон - + Set Colorless Counters... Установить бесцветные жетоны - + Add Other Counter Добавить другие жетоны - + Remove Other Counter Удалить другие жетоны - + Set Other Counters... Установить другие жетоны... - + Increment all card counters Добавить по одному каждого жетона - + Add Power (+1/+0) - + Remove Power (-1/-0) - + Move Toughness to Power (+1/-1) - + Add Toughness (+0/+1) - + Remove Toughness (-0/-1) - + Move Power to Toughness (-1/+1) - + Add Power and Toughness (+1/+1) - + Remove Power and Toughness (-1/-1) - + Set Power and Toughness... - + Reset Power and Toughness - + Untap Шаг разворота - + Upkeep Шаг поддержки - + Draw Шаг взятия карты - + First Main Phase - + Start Combat - + Attack Атака - + Block Блок - + Damage Повреждения - + End Combat - + Second Main Phase - + End Конец хода - + Next Phase - + Next Phase Action - + Next Turn - + Hide Card in Reveal Window - + Tap / Untap Card Повернуть / развернуть карту - + Untap All - + Toggle Skip Untapping Toggle Untap - + Turn Card Over - + Peek Card - + Play Card - + Play Card, Face Down - + Attach Card... - + Unattach Card - + Clone Card - + Create Token... - + Create All Related Tokens - + Create Another Token - + Set Annotation... - + + Reduce Life by Power + + + + Select All Cards in Zone - + Select All Cards in Row - + Select All Cards in Column - + Reveal Selected Cards to All Players - - + + Bottom of Library - - - - + + + + Exile Изгнание - - - - + + + + Graveyard Кладбище - - + + Hand Рука - - + + Top of Library - - + + Battlefield, Face Down - + Battlefield - + Library Библиотека - + Sideboard Сайдборд - + Top Cards of Library - + Bottom Cards of Library - + Close Recent View - - + + Stack - - + + Graveyard (Multiple) - - + + Graveyard (Multiple), Face Down - - + + Exile (Multiple) - - + + Exile (Multiple), Face Down - + Stack Until Found - + Draw Bottom Card Взять карту с низа библиотеки - + Draw Multiple Cards from Bottom... Взять несколько карт с низа библиотеки... - + Draw Arrow... - + Remove Local Arrows - + Leave Game - + Concede Сдаться - + Roll Dice... - + + Flip Coin + + + + Shuffle Library - + Shuffle Top Cards of Library - + Shuffle Bottom Cards of Library - + Mulligan Муллиган - + Mulligan (Same hand size) - + Mulligan (Hand size - 1) - + Draw a Card Взять карту - + Draw Multiple Cards... Взять несколько карт - + Undo Draw - + Always Reveal Top Card Всегда показывать верхнюю карту - + Always Look At Top Card Всегда смотреть верхнюю карту - + Sort Hand by Name - + Sort Hand by Type - + Sort Hand by Mana Value - + Reveal Hand to All Players - + Reveal Random Card to All Players - + Rotate View Clockwise - + Rotate View Counterclockwise - + Unfocus Text Box Убрать фокус с окна ввода - + Focus Chat Установить фокус на чат - + Clear Chat Очистить чат - + Refresh Обновить - + Skip Forward - + Skip Backward - + Skip Forward by a lot - + Skip Backward by a lot - + Play/Pause - + Toggle Fast Forward - + Home - + Visual Deck Storage - + Deck Storage - + Server - + Account - + Administration - + Logs Логи diff --git a/cockatrice/translations/cockatrice_sv.ts b/cockatrice/translations/cockatrice_sv.ts index 376b9fe5f..2f9eb911b 100644 --- a/cockatrice/translations/cockatrice_sv.ts +++ b/cockatrice/translations/cockatrice_sv.ts @@ -2,7 +2,7 @@ AbstractCounter - + &Set counter... &Placera polett... @@ -10,12 +10,12 @@ AbstractCounterDialog - + Set counter - + New value for counter '%1': @@ -36,60 +36,60 @@ AbstractTabDeckEditor - + Open in new tab - + Are you sure? - + The decklist has been modified. Do you want to save the changes? - - - - - - + + + + + + Error - + Could not open deck at %1 - + Could not save remote deck - - + + The deck could not be saved. Please check that the directory is writable and try again. - + Save deck - + The deck could not be saved. - + There are no cards in your deck to be exported @@ -105,12 +105,12 @@ Please check that the directory is writable and try again. AdminNotesDialog - + Update Notes - + Admin Notes for %1 @@ -127,172 +127,227 @@ Please check that the directory is writable and try again. Sideboard + + + Tokens + + AppearanceSettingsPage - + seconds - + Error - + Could not create themes directory at '%1'. - + Theme settings Temainställningar - + Current theme: Aktuellt tema: - + Open themes folder - + Home tab background source: - + Home tab background shuffle frequency: - + Disabled - - Display card name of background in bottom right: - - - - + Menu settings - + Show keyboard shortcuts in right-click menus - + Show game filter toolbar above list in room tab - + Card rendering Kortrendering - + Display card names on cards having a picture Visa kortnamn på kort som har bilder - + Auto-Rotate cards with sideways layout - + Override all card art with personal set preference (Pre-ProviderID change behavior) - + + Light + + + + + Dark + + + + + System + + + + + Active theme palette: + + + + + Edit theme palette + + + + + Home tab settings + + + + + Display card name of background in bottom right + + + + + Styling settings + + + + + Style user list + + + + + Card printings + + + + Bump sets that the deck contains cards from to the top in the printing selector - + Scale cards on mouse over Skala kort på musen över - + Use rounded card corners - + + Card layout + + + + Minimum overlap percentage of cards on the stack and in vertical hand - + Maximum initial height for card view window: - - + + rows - + Maximum expanded height for card view window: - + Card counters - + Counter %1 - + Hand layout Handlayout - + Display hand horizontally (wastes space) Visa hand horisontellt (slösar plats) - + Enable left justification Aktivera vänsterjustering - + Table grid layout Bordets rutnätlayout - + Invert vertical coordinate Invertera vertikal koordinat - + Minimum player count for multi-column layout: Minst antal spelare för multi-kolumnlayout: - + Maximum font size for information displayed on cards: Maximal teckenstorlek för information som visas på kort: @@ -300,12 +355,12 @@ Please check that the directory is writable and try again. ArchidektApiResponseDeckDisplayWidget - + Back to results - + Open Deck in Deck Editor @@ -331,112 +386,112 @@ Please check that the directory is writable and try again. BanDialog - + ban &user name bannlys användar&namn - + ban &IP address bannlys &IP address - + ban client I&D förbjuda klienten I & D - + Ban type Typ av bannlysning - + &permanent ban &permanent bannlysning - + &temporary ban &temporär bannlysning - + &Days: &Dagar: - + &Hours: &Timmar: - + &Minutes: &Minuter: - + Duration of the ban Bannlysningens längd - + Please enter the reason for the ban. This is only saved for moderators and cannot be seen by the banned person. Vänligen ange anledningen till bannlysningen. Detta sparas endast för moderatorer och kan inte ses av den bannlysta personen. - + Please enter the reason for the ban that will be visible to the banned person. Vänligen ange en anledning för bannlysningen som kommer att visas för den bannlysta personen. - + Redact all messages from this user in all rooms - + &OK &OK - + &Cancel &Avbryt - + Ban user from server Bannlys användaren från servern - - - - + + + + Error Fel - + You have to select a name-based, IP-based, clientId based, or some combination of the three to place a ban. Du måste välja en namnbaserad, IP-baserad, clientId-baserad eller en kombination av de tre för att lägga ett förbud. - + You must have a value in the name ban when selecting the name ban checkbox. Du måste ha ett värde i namnförbudet när du markerar kryssrutan för namnförbud. - + You must have a value in the ip ban when selecting the ip ban checkbox. Du måste ha ett värde i ip-förbudet när du markerar kryssrutan ip-banan. - + You must have a value in the clientid ban when selecting the clientid ban checkbox. Du måste ha ett värde i clientidförbudet när du markerar kryssrutan för klientidförbud. @@ -444,59 +499,123 @@ Detta sparas endast för moderatorer och kan inte ses av den bannlysta personen. BetaReleaseChannel - + Beta - + No reply received from the release update server. Inget svar mottaget från uppdateringsservern. - + Invalid reply received from the release update server. Ogiltigt svar mottaget från uppdateringsserveren. - + No reply received from the file update server. Inget svar mottaget från filuppdateringsservern. + + CardArtPreviewWidget + + + No card selected + + + + + CardArtRulesModel + + + Card + + + + + ProviderId + + + + + Mode + + + + + Reason + + + CardDatabaseModel - + Name Namn - + Sets Utgåvor - + Mana cost Manakostnad - + Card type Korttyp - + P/T P/T - + Color(s) Färg(er) + + CardDatabaseView + + + Add to Deck + + + + + Add to Sideboard + + + + + Select Printing + + + + + Show on EDHRec (Commander) + + + + + Show on EDHRec (Card) + + + + + Show Related cards + + + CardFilter @@ -625,22 +744,22 @@ Detta sparas endast för moderatorer och kan inte ses av den bannlysta personen. CardInfoPictureWidget - + View related cards - + Add card to deck - + Mainboard - + Sideboard @@ -663,12 +782,12 @@ Detta sparas endast för moderatorer och kan inte ses av den bannlysta personen. - + Related cards: - + Unknown card: @@ -676,128 +795,146 @@ Detta sparas endast för moderatorer och kan inte ses av den bannlysta personen. CardMenu - + Re&veal to... - + &All players - + View related cards - + Token: - + All tokens - + &Select All - + S&elect Row - + S&elect Column - + &Play - + &Hide - + Play &Face Down - + &Tap / Untap Turn sideways or back again - + Skip &untapping - + T&urn Over Turn face up/face down - + &Peek at card face - + &Clone - + Attac&h to card... - + Unattac&h - + &Draw arrow... - + &Set annotation... - + + Reduce life by power + + + + Ca&rd counters - + &Add counter (%1) - + &Remove counter (%1) - + &Set counters (%1)... + + CardPictureLoaderCacheMethod + + + Network Cache + + + + + Filesystem + + + CardSizeWidget @@ -809,133 +946,133 @@ Detta sparas endast för moderatorer och kan inte ses av den bannlysta personen. CardZoneLogic - + their hand nominative - + %1's hand nominative - + their library look at zone - + %1's library look at zone - + of their library top cards of zone, - + of %1's library top cards of zone - + their library reveal zone - + %1's library reveal zone - + their library shuffle - + %1's library shuffle - + their library nominative - + %1's library nominative - + their graveyard nominative - + %1's graveyard nominative - + their exile nominative - + %1's exile nominative - + their sideboard look at zone - + %1's sideboard look at zone - + their sideboard nominative - + %1's sideboard nominative - + their custom zone '%1' nominative - + %1's custom zone '%2' nominative @@ -957,16 +1094,421 @@ Detta sparas endast för moderatorer och kan inte ses av den bannlysta personen. + + ColorButton + + + Click to pick a color + + + + + Pick colour + + + + + ConnectionController + + + + The server has reached its maximum user capacity, please check back later. + + + + + There are too many concurrent connections from your address. + + + + + Banned by moderator + + + + + Expected end time: %1 + + + + + This ban lasts indefinitely. + + + + + Scheduled server shutdown. + + + + + + Invalid username. + + + + + You have been logged out due to logging in at another location. + + + + + Connection closed + + + + + The server has terminated your connection. +Reason: %1 + + + + + The server is going to be restarted in %n minute(s). +All running games will be lost. +Reason for shutdown: %1 + + + + + Scheduled server shutdown + + + + + Failed Login + + + + + Your client seems to be missing features this server requires for connection. + + + + + To update your client, go to 'Help -> Check for Client Updates'. + + + + + + + + + + + + + + + + + + + + + + Error + + + + + Incorrect username or password. Please check your authentication information and try again. + + + + + There is already an active session using this user name. +Please close that session first and re-login. + + + + + + You are banned until %1. + + + + + + You are banned indefinitely. + + + + + This server requires user registration. Do you want to register now? + + + + + This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. +Please close and reopen your client to try again. + + + + + An internal error has occurred, please close and reopen Cockatrice before trying again. +If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. + + + + + Account activation + + + + + Your account has not been activated yet. +You need to provide the activation token received in the activation email. + + + + + Server Full + + + + + Unknown login error: %1 + + + + + + +This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. + + + + + + + + + + Registration denied + + + + + Registration is currently disabled on this server + + + + + There is already an existing account with the same user name. + + + + + It's mandatory to specify a valid email address when registering. + + + + + It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. + + + + + Password too short. + + + + + Registration failed for a technical problem on the server. + + + + + The connection to the server has been lost. + + + + + Unknown registration error: %1 + + + + + Account activation failed + + + + + Socket error: %1 + + + + + Server timeout + + + + + You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. +Local version is %1, remote version is %2. + + + + + Your Cockatrice client is obsolete. Please update your Cockatrice version. +Local version is %1, remote version is %2. + + + + + + Success + + + + + Registration accepted. +Will now login. + + + + + Account activation accepted. +Will now login. + + + + + Information + + + + + This server supports additional features that your client doesn't have. +This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. + +To update your client, go to Help -> Check for Updates. + + + + + + + Reset Password + + + + + Your password has been reset successfully, you can now log in using the new credentials. + + + + + Failed to reset user account password, please contact the server operator to reset your password. + + + + + Activation request received, please check your email for an activation token. + + + + + Connecting to %1... + + + + + Registering to %1 as %2... + + + + + Disconnected + + + + + Connected, logging in at %1 + + + + + Requesting forgotten password to %1 as %2... + + + + + Your username must respect these rules: + + + + + is %1 - %2 characters long + + + + + can %1 contain lowercase characters + + + + + + + + NOT + + + + + can %1 contain uppercase characters + + + + + can %1 contain numeric characters + + + + + can contain the following punctuation: %1 + + + + + first character can %1 be a punctuation mark + + + + + no unacceptable language as specified by these server rules: + note that the following lines will not be translated + + + + + can not contain any of the following words: %1 + + + + + can not match any of the following expressions: %1 + + + + + You may only use A-Z, a-z, 0-9, _, ., and - in your username. + + + CustomZoneMenu - + C&ustom Zones - - + + View custom zone '%1' @@ -974,30 +1516,35 @@ Detta sparas endast för moderatorer och kan inte ses av den bannlysta personen. DeckAnalyticsWidget - + Add Panel - + Remove Panel - + Save Layout - + Load Layout + + + Include Sideboard + + DeckEditorCardDatabaseDockWidget - + Card Database @@ -1013,47 +1560,17 @@ Detta sparas endast för moderatorer och kan inte ses av den bannlysta personen. DeckEditorDatabaseDisplayWidget - + Search by card name (or search expressions) - - Add to Deck - - - - - Add to Sideboard - - - - - Select Printing - - - - - Show on EDHRec (Commander) - - - - - Show on EDHRec (Card) - - - - - Show Related cards - - - - + Add card to &maindeck - + Add card to &sideboard @@ -1086,72 +1603,72 @@ Detta sparas endast för moderatorer och kan inte ses av den bannlysta personen. - + Select Printing - + Deck - + Deck &name: - + Banner Card/Tags Visibility Settings - + Show banner card selection menu - + Show tags selection menu - + &Comments: - + Group by: - + Format: - + Hash: - + &Increment number - + &Decrement number - + &Remove row - + Swap card to/from sideboard @@ -1159,17 +1676,17 @@ Detta sparas endast för moderatorer och kan inte ses av den bannlysta personen. DeckEditorFilterDockWidget - + Filters - + &Clear all filters - + Delete selected @@ -1300,169 +1817,113 @@ Detta sparas endast för moderatorer och kan inte ses av den bannlysta personen. DeckEditorSettingsPage - - + + Update Spoilers Uppdatera Spoilers - - + Success - + Download URLs have been reset. - - Downloaded card pictures have been reset. - - - - - Error - Fel - - - - One or more downloaded card pictures could not be cleared. - - - - + Add URL Lägg till webbadress - - + + URL: - - + + Edit URL - - Network Cache Size: - - - - - Redirect Cache TTL: - - - - - How long cached redirects for urls are valid for. - - - - - Picture Cache Size: - - - - + Add New URL - + Remove URL - - Day(s) - - - - + Updating... Uppdaterar ... - + Choose path Välj sökväg - + URL Download Priority - + Spoilers Spoilers - + Download Spoilers Automatically Ladda ner Spoilers automatiskt - + Spoiler Location: Spoiler Plats: - + Last Change Senaste ändring - + Spoilers download automatically on launch Spoilers laddas ner automatiskt vid lanseringen - + Press the button to manually update without relaunching Tryck på knappen för att manuellt uppdatera utan omstart - + Do not close settings until manual update is complete - + Download card pictures on the fly Ladda enkelt ned bilder på kort - + How to add a custom URL Hur man lägger till en valfri webbadress - - Delete Downloaded Images - Ta bort nedladdade bilder - - - + Reset Download URLs Återställ nedladdnings-webbadresser - - - On-disk cache for downloaded pictures - - - - - In-memory cache for pictures not currently on screen - - DeckListHistoryManagerWidget @@ -1528,12 +1989,12 @@ Detta sparas endast för moderatorer och kan inte ses av den bannlysta personen. DeckLoader - + Common deck formats (%1) - + All files (*.*) @@ -1755,27 +2216,27 @@ Detta sparas endast för moderatorer och kan inte ses av den bannlysta personen. - + Moved to %1 1 × "%2" (%3) - + Removed "%1" (all copies) - + %1 1 × "%2" (%3) - + Added - + Removed @@ -1797,74 +2258,74 @@ Detta sparas endast för moderatorer och kan inte ses av den bannlysta personen. DeckViewContainer - + Load deck... Ladda lek... - + Load remote deck... Ladda remote lek... - + Load from clipboard... - + Load from website... - + Unload deck - + Ready to start - + Force start - + Sideboard unlocked - + Sideboard locked - - + + Error Fel - + The selected file could not be loaded. Den valda filen kunde ej laddas. - + Deck is greater than maximum file size. - + Are you sure you want to force start? This will kick all non-ready players from the game. - + Cockatrice @@ -1963,7 +2424,7 @@ This will kick all non-ready players from the game. - + Webpage Webbsida @@ -2003,37 +2464,37 @@ This will kick all non-ready players from the game. Anslut till servern - + Server URL Server webbadress - + Communication Port - + Unique Server Name Unikt servernamn - + Connection Warning Anslutningsvarning - + You need to name your new connection profile. Du måste ange din nya anslutningsprofil. - + Connect Warning Anslutningsvarning - + The player name can't be empty. Fältet för spelarnamn kan inte lämnas tomt. @@ -2146,17 +2607,17 @@ This will kick all non-ready players from the game. Skapa spel - + Game information Spelinformation - + Error Fel - + Server error. Serverfel. @@ -2164,97 +2625,97 @@ This will kick all non-ready players from the game. DlgCreateToken - + &Name: &Namn: - + Token Jetong - + C&olor: &Färg: - + white vit - + blue blå - + black svart - + red röd - + green grön - + multicolor multifärgad - + colorless färglös - + &P/T: &P/T: - + &Annotation: &Annotering: - + &Destroy token when it leaves the table &Förstör jetong när den lämnar bordet - + Create face-down (Only hides name) - + Token data Jetongdata - + Show &all tokens Visa alla &jetonger - + Show tokens from this &deck Visa jetonger från denna &lek - + Choose token from list Välj jetong från lista - + Create token Skapa jetong @@ -2288,7 +2749,7 @@ This will kick all non-ready players from the game. - + @@ -2303,12 +2764,12 @@ This will kick all non-ready players from the game. - + Duplicate Tag - + This tag already exists. @@ -2495,12 +2956,12 @@ För att radera din nuvarande avatar, klicka "bekräfta" utan att väl Vänligen ange namnet på jetongen: - + Error Fel - + The chosen name conflicts with an existing card or token. Make sure to enable the 'Token' set in the "Manage sets" dialog to display them correctly. Det valda namnet står i konflikt med ett befintligt kort eller token. @@ -2525,12 +2986,12 @@ Se till att du aktiverar "Token" i dialogrutan "Hantera uppsättn Odefinierat - + Real name: Riktiga namn: - + Edit user profile Justera användarprofil @@ -2568,113 +3029,113 @@ Se till att du aktiverar "Token" i dialogrutan "Hantera uppsättn - + Hide 'buddies only' games - + Hide full games - + Hide games that have started - + Hide password protected games - + Hide 'ignored user' games - + Hide games not created by buddies Hide games not created by buddy - + Hide games with forced open decklists - + &Newer than: - + Game &description: Spel&beskrivning: - + &Creator name: S&kaparens namn: - + General - + &Game types &Speltyper - + at &least: &Minst: - + at &most: M&ax: - + Maximum player count Maximalt antal spelare - + Restrictions begränsningar - + Show games only if &spectators can watch - + Show spectator password p&rotected games - + Show only if spectators can ch&at - + Show only if spectators can see &hands - + Spectators - + Filter games Filtrera spel @@ -2977,37 +3438,37 @@ https://tappedout.net/mtg-decks/your-deck-name/ DlgMoveTopCardsUntil - + Card name (or search expressions): - + Number of hits: - + Auto play hits - + Put top cards on stack until... - + No cards matching the search expression exists in the card database. Proceed anyways? - + Cockatrice - + Invalid filter @@ -3066,40 +3527,40 @@ Your email will be used to verify your account. Odefinierad - + Real name: Riktiga namn: - + Register to server Registrera på server - - - - + + + + Registration Warning Registreringsvarning - + Your password is too short. - + Your passwords do not match, please try again. Dina lösenord matchar inte, var god försök igen. - + Your email addresses do not match, please try again. Dina e-postadresser matchar inte, var god försök igen. - + The player name can't be empty. Fältet för spelarnamn kan inte lämnas tomt. @@ -3107,17 +3568,17 @@ Your email will be used to verify your account. DlgRollDice - + Number of sides: - + Number of dice: - + Roll Dice @@ -3168,12 +3629,12 @@ Your email will be used to verify your account. DlgSettings - + Unknown Error loading card database Ett okänt fel har inträffat vid laddande av kortdatabasen - + Your card database is invalid. Cockatrice may not function correctly with an invalid database @@ -3190,7 +3651,7 @@ Möjligen kan du behöva starta om oracle för att uppdatera din kortdatabas. Vill du ändra dina databaslokaliseringsinställningar? - + Your card database version is too old. This can cause problems loading card information or images @@ -3207,7 +3668,7 @@ Detta kan oftast fixas genom att starta om oracle för att uppdatera din kortdat Vill du ändra dina kortdatabaslokaliseringsinställningar? - + Your card database did not finish loading Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues with your cards.xml attached @@ -3216,7 +3677,7 @@ Would you like to change your database location setting? - + File Error loading your card database. Would you like to change your database location setting? @@ -3225,7 +3686,7 @@ Would you like to change your database location setting? Vill du ändra dina kortdatabaslokaliseringsinställningar? - + Your card database was loaded but contains no cards. Would you like to change your database location setting? @@ -3234,7 +3695,7 @@ Would you like to change your database location setting? Vill du ändra dina kortdatabaslokaliseringsinställningar? - + Unknown card database load status Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues @@ -3243,59 +3704,64 @@ Would you like to change your database location setting? - - - + + + Error Fel - + The path to your deck directory is invalid. Would you like to go back and set the correct path? Sökvägen till din lekkatalog är ogiltig. Vill du gå tillbaka och ange den korrekta sökvägen? - + The path to your card pictures directory is invalid. Would you like to go back and set the correct path? Sökvägen till din kortbildsdatabas är ogiltig. Vill du gå tillbaka och ange den korrekta sökvägen? - + Settings Inställningar - + General Allmänt - + Appearance Utseende - + User Interface Gränssnitt - + + Storage + + + + Card Sources - + Chat Chatt - + Sound Ljud - + Shortcuts Genvägar @@ -3387,9 +3853,9 @@ You can always change this behavior in the 'General' settings tab. - - - + + + Error Fel @@ -3442,31 +3908,31 @@ Besök sidan för nedladdning för att uppdatera manuellt. - + Update Available Uppdatering tillgänglig - + A new version of Cockatrice is available! En ny version av Cockatrice är tillgänglig! - + New version Ny version - + Released Släppte - + Changelog Ändringslogg @@ -3476,50 +3942,50 @@ Besök sidan för nedladdning för att uppdatera manuellt. Vill du uppdatera nu? - + Unfortunately, the automatic updater failed to find a compatible download. You may have to manually download the new version. - + Please check the <a href="%1">releases page</a> on our Github and download the build for your system. - - - + + + Update Error Uppdateringsfel - + An error occurred while checking for updates: Ett fel uppstod när du letade efter uppdateringar: - + An error occurred while downloading an update: Ett fel uppstod när du hämtade en uppdatering: - + Installing... Installerar... - + Cockatrice is unable to open the installer. Cockatrice kan inte öppna installatören. - + Try to update manually by closing Cockatrice and running the installer. Försök att uppdatera manuellt genom att stänga Cockatrice och köra installationsprogrammet. - + Download location Hämta plats @@ -3609,67 +4075,67 @@ You may have to manually download the new version. DrawProbabilityWidget - + Draw Probability - + Probability of drawing - + Card Name - + Type - + Subtype - + Mana Value - + At least - + Exactly - + card(s) having drawn at least - + cards - + Category - + Qty - + Odds (%) @@ -3783,7 +4249,7 @@ You may have to manually download the new version. FilterBuilder - + Type your filter here Skriv ditt filter här @@ -3814,22 +4280,22 @@ You may have to manually download the new version. GameEventHandler - + kicked by game host or moderator - + player left the game - + player disconnected from server - + reason unknown @@ -3837,140 +4303,140 @@ You may have to manually download the new version. GameSelector - - - - - - - - - + + + + + + + + + Error Fel - + Please join the appropriate room first. Vänligen anslut till det lämpliga rummet först. - + Wrong password. Fel lösenord. - + Spectators are not allowed in this game. Åskådare är ej tillåtna i detta spelet. - + The game is already full. Spelet är redan fullt. - + The game does not exist any more. Det här spelet finns inte längre. - + This game is only open to registered users. Det här spelet är bara öppet för registrerade användare. - + This game is only open to its creator's buddies. Det här spelet är bara öppet för skaparens vänner. - + You are being ignored by the creator of this game. Spelets skapare ignorerar dig. - + Join Game - + Spectate Game - + Game Information - + Join Game as Judge - + Spectate Game as Judge - + Join game Anslut till spel - + Password: Lösenord: - + Please join the respective room first. Vänligen anslut till respektive rum först. - + &Filter games Specificera speltyp - + C&lear filter Rensa filter - + C&reate Skapa - + &Join Anslut - + Join as judge - + J&oin as spectator Anslut som &åskådare - + Join as judge spectator - + Games shown: %1 / %2 - + Games Spel @@ -3978,32 +4444,32 @@ You may have to manually download the new version. GameSelectorQuickFilterToolBar - + All types - + Filter by game name... - + Filter by game type/format - + Hide games not created by buddies - + Hide full games - + Hide started games @@ -4011,105 +4477,105 @@ You may have to manually download the new version. GamesModel - + >1 day - + %1%2 hr short age in hours - + new - + %1%2 min short age in minutes - + password lösenord - + buddies only endast vänner - + reg. users only endast reg. användare - + open decklists - + can chat kan chatta - + see hands visa händer - + can see hands Tillåts se händer - + not allowed ej tillåtna - + Room Rum - + Age Ålder - + Description Beskrivning - + Creator Skapare - + Type Typ - + Restrictions Begränsningar - + Players Spelare - + Spectators Åskådare @@ -4117,143 +4583,158 @@ You may have to manually download the new version. GeneralSettingsPage - + Reset all paths - + All paths have been reset - - - - - - - + + + + + + + Choose path Välj sökväg - - Personal settings - Personliga inställningar + + Language settings + - + Language: Språk: - + + Version settings + + + + + Card database + + + + + Startup settings + + + + Paths (editing disabled in portable mode) Sökvägar (redigering inaktiverad i bärbart läge) - + Paths Sökvägar - + How to help with translations - + Decks directory: Lekkatalog: - + Filters directory: - + Replays directory: Repriskatalog: - + Pictures directory: Bildkatalog: - + Card database: Kortdatabas: - + Custom database directory: - + Token database: Token-databas: - + Update channel Uppdatera kanal - + Check for client updates on startup - + Check for card database updates on startup - + Don't check - + Prompt for update - + Always update in the background - + Check for card database updates every - + days - + Notify if a feature supported by the server is missing in my client Meddela om en funktion som stöds av servern saknas i min klient - + Automatically run Oracle when running a new version of Cockatrice - + Show tips on startup Visa tips vid uppstart - + Last update check on %1 (%2 days ago) @@ -4261,47 +4742,47 @@ You may have to manually download the new version. GraveyardMenu - + &Graveyard - + &View graveyard - + &Move graveyard to... - + &Top of library - + &Bottom of library - + &All players - + &Hand - + &Exile - + Reveal random card to... @@ -4309,88 +4790,88 @@ You may have to manually download the new version. HandMenu - + &Hand - + &View hand - + Sort hand by... - + Name - + Type - + Mana Value - + Take &mulligan (Choose hand size) - + Take mulligan (Same hand size) - + Take mulligan (Hand size - 1) - + &Move hand to... - + &Top of library - + &Bottom of library - + &Graveyard - + &Exile - + &Reveal hand to... - - + + All players - + Reveal r&andom card to... @@ -4398,52 +4879,52 @@ You may have to manually download the new version. HomeWidget - + Create New Deck - + Browse Decks - + Browse Card Database - + Browse EDHRec - + Browse Archidekt - + View Replays - + Quit - + Connecting... - + Connect - + Play @@ -4451,213 +4932,213 @@ You may have to manually download the new version. LibraryMenu - + &Library - + &View library - + View &top cards of library... - + View bottom cards of library... - + Reveal &library to... - + Lend library to... - + Reveal &top cards to... - + &Top of library... - + &Bottom of library... - + &Always reveal top card - + &Always look at top card - + &Open deck in deck editor - + &Draw card - + D&raw cards... - + &Undo last draw - + Shuffle - + &Play top card - + Play top card &face down - + Put top card on &bottom - + Move top card to grave&yard - + Move top card to e&xile - + Move top cards to &graveyard... - + Move top cards to graveyard face down... - + Move top cards to &exile... - + Move top cards to exile face down... - + Put top cards on stack &until... - + Shuffle top cards... - + &Draw bottom card - + D&raw bottom cards... - + &Play bottom card - + Play bottom card &face down - + Move bottom card to grave&yard - + Move bottom card to e&xile - + Move bottom cards to &graveyard... - + Move bottom cards to graveyard face down... - + Move bottom cards to &exile... - + Move bottom cards to exile face down... - + Put bottom card on &top - + Shuffle bottom cards... - - + + &All players - + Reveal top cards of library - + Number of cards: (max. %1) @@ -4665,844 +5146,453 @@ You may have to manually download the new version. MainWindow - - - The server has reached its maximum user capacity, please check back later. - Servern har nått sin maximala kapacitet för användare, var god försök igen vid ett senare tillfälle. - - - - There are too many concurrent connections from your address. - Din adress har för många uppkopplingar samtidigt. - - - - Banned by moderator - Bannlyst av moderator - - - - Expected end time: %1 - Förväntad sluttid: %1 - - - - This ban lasts indefinitely. - Denna bannlysning varar för evigt. - - - - Scheduled server shutdown. - Schemalagd serverstängning. - - - - - Invalid username. - Ogiltigt användarnamn. - - - - You have been logged out due to logging in at another location. - Du loggades ut på grund av att du loggade in via en annan plats. - - - - Connection closed - Uppkoppling avslutad - - - - The server has terminated your connection. -Reason: %1 - Servern har avslutat din uppkoppling. -Anledning: %1 - - - - The server is going to be restarted in %n minute(s). -All running games will be lost. -Reason for shutdown: %1 - Servern kommer att starts om om %n minut. -Alla pågående spel kommer att gå förlorade. -Anledning till nedstängning: %1Servern kommer att starts om om %n minuter. -Alla pågående spel kommer att gå förlorade. -Anledning till nedstängning: %1 - - - - Scheduled server shutdown - Schemalagd serverstängning - - - - - Success - Framgång - - - - Registration accepted. -Will now login. - Registrering lyckad. -Inloggning påbörjad. - - - - Account activation accepted. -Will now login. - Aktivering tillåten. -Inloggning påbörjad. - - - - + + Player %1 Spelare %1 - + Load replay Ladda repris - + About Cockatrice Om Cockatrice - + Version - + Cockatrice Webpage - + Project Manager: Projektledare: - + Past Project Managers: Tidigare projektledare: - + Developers: utvecklare: - + Our Developers Våra utvecklare - + Help Develop! - + Translators: Översättare: - + Our Translators - + Help Translate! - + Support: - + Report an Issue - + Troubleshooting - + F.A.Q. - - - - - - - - - - - - - - - - - - - - + + Error Fel - - Server timeout - Server timeout - - - - Failed Login - - - - - Your client seems to be missing features this server requires for connection. - - - - - To update your client, go to 'Help -> Check for Client Updates'. - - - - - Incorrect username or password. Please check your authentication information and try again. - - - - - There is already an active session using this user name. -Please close that session first and re-login. - Det finns redan en aktiv session med det användarnamnet. -Vänligen stäng den sessionen först och försök igen. - - - - - You are banned until %1. - Du är bannlyst till %1. - - - - - You are banned indefinitely. - Du är bannlyst för evigt. - - - - This server requires user registration. Do you want to register now? - - - - - This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. -Please close and reopen your client to try again. - - - - - An internal error has occurred, please close and reopen Cockatrice before trying again. -If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. - - - - - Account activation - - - - - Your account has not been activated yet. -You need to provide the activation token received in the activation email. - - - - - Server Full - - - - - Unknown login error: %1 - Okänt inloggningsfel: %1 - - - - - -This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. - - - - - Your username must respect these rules: - - - - - is %1 - %2 characters long - - - - - can %1 contain lowercase characters - - - - - - - - NOT - - - - - can %1 contain uppercase characters - - - - - can %1 contain numeric characters - - - - - can contain the following punctuation: %1 - - - - - first character can %1 be a punctuation mark - - - - - no unacceptable language as specified by these server rules: - note that the following lines will not be translated - - - - - can not contain any of the following words: %1 - - - - - can not match any of the following expressions: %1 - - - - - You may only use A-Z, a-z, 0-9, _, ., and - in your username. - - - - - - - - - - Registration denied - - - - - Registration is currently disabled on this server - - - - - There is already an existing account with the same user name. - - - - - It's mandatory to specify a valid email address when registering. - - - - - It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. - - - - - Password too short. - - - - - Registration failed for a technical problem on the server. - - - - - The connection to the server has been lost. - - - - - Unknown registration error: %1 - - - - - Account activation failed - - - - - Socket error: %1 - Socketfel: %1 - - - - You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. -Local version is %1, remote version is %2. - Du försöker koppla upp dig till en föråldrad server. Vänligen nergradera din version av Cockatrice eller koppla upp dig till en lämplig server. -Lokal version är %1, avlägsen version är %2. - - - - Your Cockatrice client is obsolete. Please update your Cockatrice version. -Local version is %1, remote version is %2. - Din version av Cockatrice är föråldrad. Vänligen uppdatera din version av Cockatrice. -Lokal version är %1, avlägsen version är %2. - - - - Connecting to %1... - Ansluter till %1... - - - - Registering to %1 as %2... - - - - - Disconnected - Frånkopplad - - - - Connected, logging in at %1 - Uppkopplad, loggar in hos %1 - - - - - - Requesting forgotten password to %1 as %2... - Begär att få glömt lösenord skickat till %1 som %2 - - - + &Connect... &Anslut... - + &Disconnect &Frånkoppla - + Start &local game... Starta &lokalt spel... - + &Watch replay... &Titta på repris... - + &Full screen &Fullskärmsläge - + &Register to server... - + &Restore password... - + &Settings... &Inställningar... - + &Exit A&vsluta - + A&ctions - + &Cockatrice &Cockatrice - + C&ard Database - + &Manage sets... &Hantera utgåvor... - + Edit custom &tokens... Redigera egna &tokens... - + Open custom image folder - + Open custom sets folder - + Add custom sets/cards - + Reload card database - + Tabs - + &Help &Hjälp - + &About Cockatrice &Om Cockatrice - + &Tip of the Day - + Check for Client Updates - + Check for Card Updates... - + Check for Card Updates (Automatic) - + Show Status Bar - + View &Debug Log - + Open Settings Folder - + Show/Hide - + New Version - + Congratulations on updating to Cockatrice %1! Oracle will now launch to update your card database. - + Cockatrice installed - + Congratulations on installing Cockatrice %1! Oracle will now launch to install the initial card database. - + Card database - + Cockatrice is unable to load the card database. Do you want to update your card database now? If unsure or first time user, choose "Yes" - - + + Yes - - + + No - + Open settings - + New sets found - + %n new set(s) found in the card database Set code(s): %1 Do you want to enable it/them? - + + Yes, always enable + + + + View sets - + Welcome - + Hi! It seems like you're running this version of Cockatrice for the first time. All the sets in the card database have been enabled. Read more about changing the set order or disabling specific sets and consequent effects in the "Manage Sets" dialog. - - + Information - + A card database update is already running. - + Unable to run the card database updater: - + Card database update running. - + Failed to start. The file might be missing, or permissions might be incorrect. - + The process crashed some time after starting successfully. - + Timed out. The process took too long to respond. The last waitFor...() function timed out. - + An error occurred when attempting to write to the process. For example, the process may not be running, or it may have closed its input channel. - + An error occurred when attempting to read from the process. For example, the process may not be running. - + Unknown error occurred. - + The card database updater exited with an error: %1 - - This server supports additional features that your client doesn't have. -This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. - -To update your client, go to Help -> Check for Updates. - Den här servern stöder funktioner din version av Cockatrice inte har. -Det behöver inte innebära problem men det kan vara ett tecken på att det finns en ny version av Cockatrice tillgänglig eller att servern kör en anpassad eller förhandsutgiven version. - -För att uppdatera din klient, gå till Hjälp -> Leta efter uppdateringar. - - - - - - - + + + + + Load sets/cards Ladda utgåvor/kort - + Selected file cannot be found. Den valda filen kunde inte laddas. - + You can only import XML databases at this time. För tillfället går det bara att importera XML-databaser. - + The new sets/cards have been added successfully. Cockatrice will now reload the card database. - + Sets/cards failed to import. Det gick inte att importera utgåvor/kort. - - - - - Reset Password - - - - - Your password has been reset successfully, you can now log in using the new credentials. - - - - - Failed to reset user account password, please contact the server operator to reset your password. - Det gick inte att återställa kontots lösenord. Kontakta serveroperatören för att få hjälp att återställa lösenordet. - - - - Activation request received, please check your email for an activation token. - - ManaBaseConfigDialog - + Mana Base Configuration - + Display type: - + pie - + bar - + combinedBar - + Filter Colors (optional): - + OK - + Cancel @@ -5574,27 +5664,27 @@ Cockatrice will now reload the card database. ManaDevotionConfigDialog - + Display type: - + pie - + bar - + combinedBar - + Filter Colors (optional): @@ -5664,610 +5754,615 @@ Cockatrice will now reload the card database. MessageLogWidget - + from play - + from their graveyard - + from exile från exil - + from their hand - + the top card of %1's library det översta kortet av %1's lek - + the top card of their library - + from the top of %1's library från toppen av %1's lek - + from the top of their library - + the bottom card of %1's library det sista kortet i %1's lek - + the bottom card of their library - + from the bottom of %1's library från botten av %1's lek - + from the bottom of their library - + from %1's library från %1'a lek - + from their library - + from sideboard från sidbrädan - + from the stack från stapeln - + from custom zone '%1' - + %1 is now keeping the top card %2 revealed. %1 håller nu det översta kortet %2 avslöjat. - + %1 is not revealing the top card %2 any longer. %1 håller inte längre det översta kortet %2 avslöjat. - + %1 can now look at top card %2 at any time. - + %1 no longer can look at top card %2 at any time. - + %1 attaches %2 to %3's %4. %1 fäster %2 på %3s %4. - + %1 has conceded the game. - + %1 has unconceded the game. %1 har ångrat att ge upp spelet. - + %1 has restored connection to the game. - + %1 has lost connection to the game. - + %1 points from their %2 to themselves. - + %1 points from their %2 to %3. - + %1 points from %2's %3 to themselves. - + %1 points from %2's %3 to %4. - + %1 points from their %2 to their %3. - + %1 points from their %2 to %3's %4. - + %1 points from %2's %3 to their own %4. - + %1 points from %2's %3 to %4's %5. - + %1 creates a face down token. - + %1 creates token: %2%3. - + %1 has loaded a deck (%2). - + %1 has loaded a deck with %2 sideboard cards (%3). - + %1 destroys %2. - + a card ett kort - + %1 gives %2 control over %3. %1 ger kontroll över %3 till %2. - + %1 puts %2 into play%3 face down. - + %1 puts %2 into play%3. %1 sätter %2 i spel%3. - + %1 puts %2%3 into their graveyard face down. - + %1 puts %2%3 into their graveyard. - + %1 exiles %2%3 face down. - + %1 exiles %2%3. %1 sätter %2%3 i exil. - + %1 moves %2%3 to their hand. - + %1 puts %2%3 into their library. - + %1 puts %2%3 onto the bottom of their library. - + %1 puts %2%3 on top of their library. - + %1 puts %2%3 into their library %4 cards from the top. %1 lägger %2%3 i sitt bibliotek %4 kort från toppen. - + %1 moves %2%3 to sideboard. %1 flyttar %2%3 till sidbrädan. - + %1 plays %2%3 face down. - + %1 plays %2%3. %1 spelar %2%3. - + %1 moves %2%3 to custom zone '%4' face down. - + %1 moves %2%3 to custom zone '%4'. - + %1 tries to draw from an empty library - + %1 draws %2 card(s). - + %1 is looking at %2. - + %1 is looking at the %4 %3 card(s) %2. top card for singular, top %3 cards for plural - + bottom - + top - + %1 turns %2 face-down. %1 vänder %2 nedåt. - + %1 turns %2 face-up. %1 vänder %2 uppåt. - + The game has been closed. Spelet har stängts. - + The game has started. Spelet har börjat. - + You are flooding the game. Please wait a couple of seconds. - + %1 has joined the game. - + %1 is now watching the game. %1 kollar nu på spelet. - + You have been kicked out of the game. Du har blivit utsparkad från spelet. - + %1 has left the game (%2). %1 har lämnat spelet (%2) - + %1 is not watching the game any more (%2). %1 tittar inte längre på spelet (%2). - + %1 is not ready to start the game any more. - + %1 shuffles their deck and draws a new hand of %2 card(s). - + %1 shuffles their deck and draws a new hand. %1 blandar sin lek och drar en ny hand. - + You are watching a replay of game #%1. - + %1 is ready to start the game. - + cards an unknown amount of cards kort - + %1 card(s) a card for singular, %1 cards for plural - + %1 lends %2 to %3. - + %1 reveals %2 to %3. - + %1 reveals %2. - + %1 randomly reveals %2%3 to %4. - + %1 randomly reveals %2%3. - + %1 peeks at face down card #%2. - + %1 peeks at face down card #%2: %3. - + %1 reveals %2%3 to %4. - + %1 reveals %2%3. - + %1 reversed turn order, now it's %2. - + reversed - + normal - + Heads - + Tails - + %1 flipped a coin. It landed as %2. - + %1 rolls a %2 with a %3-sided die. - + %1 flips %2 coins. There are %3 heads and %4 tails. - + %1 rolls a %2-sided dice %3 times: %4. - + %1's turn. %1's tur - + %1 sets annotation of %2 to %3. - - %1 places %2 "%3" counter(s) on %4 (now %5). + + %1 places %2 %3%4 counter(s) on %5 (now %6). - - %1 removes %2 "%3" counter(s) from %4 (now %5). + + %1 removes %2 %3%4 counter(s) from %5 (now %6). - + + %1 failed to undo their last draw. + + + + %1 sets counter %2 to %3 (%4%5). - + %1 sets %2 to not untap normally. - + %1 sets %2 to untap normally. - + %1 removes the PT of %2. - + %1 changes the PT of %2 from nothing to %4. - + %1 changes the PT of %2 from %3 to %4. - + %1 has locked their sideboard. - + %1 has unlocked their sideboard. - + %1 taps their permanents. - + %1 untaps their permanents. - + %1 taps %2. - + %1 untaps %2. - + %1 shuffles %2. - + %1 shuffles the bottom %3 cards of %2. - + %1 shuffles the top %3 cards of %2. - + %1 shuffles cards %3 - %4 of %2. - + %1 unattaches %2. - + %1 undoes their last draw. - + %1 undoes their last draw (%2). @@ -6275,110 +6370,115 @@ Cockatrice will now reload the card database. MessagesSettingsPage - + Word1 Word2 Word3 - + Add New Message - + Edit Message - + Remove Message - + Add message Lägg till meddelande - - + + Message: Meddelande: - + Edit message - + Chat settings - + Custom alert words - + Enable chat mentions - + Enable mention completer - + In-game message macros - + How to use in-game message macros - + Ignore chat room messages sent by unregistered users - + Ignore private messages sent by unregistered users - - + + Ignore private messages sent by non-buddy users + + + + + Invert text color - + Enable desktop notifications for private messages - + Enable desktop notification for mentions - + Enable room message history on join - - + + (Color is hexadecimal) - + Separate words with a space, alphanumeric characters only @@ -6386,42 +6486,42 @@ Cockatrice will now reload the card database. MoveMenu - + Move to - + &Top of library in random order - + X cards from the top of library... - + &Bottom of library in random order - + T&able - + &Hand - + &Graveyard - + &Exile @@ -6434,47 +6534,47 @@ Cockatrice will now reload the card database. - + Mana Value - + Color(s) - + Loyalty - + Main Card Type - + Mana Cost - + P/T - + Side - + Layout - + Color Identity @@ -6497,6 +6597,152 @@ Cockatrice will now reload the card database. + + PaletteEditorDialog + + + + Reset + + + + + + Apply + + + + + + Save && Apply + + + + + ▼ Edit Palette + + + + + + ▶ Edit Palette + + + + + Palette Editor — %1 + + + + + <b>Palette Editor</b> &nbsp;·&nbsp; %1 + + + + + This theme ships no default palette files + + + + + Replace current colours with the theme author's defaults + + + + + Switch between the light and dark palette files + + + + + Editing: + + + + + Show or hide the per-role colour grid for manual tweaks + + + + + ↺ Revert to theme default + + + + + Discard unsaved edits and restore the last saved palette + + + + + Preview this palette without saving to disk + + + + + Write palette-%1.toml and reload the theme + + + + + Cannot save: this theme has no directory on disk + + + + + Save failed + + + + + Could not write %1 to: +%2 + + + + + No default found + + + + + No default palette file found for the "%1" scheme. + + + + + PaletteGridWidget + + + Active + + + + + Disabled + + + + + Inactive + + + + + Normal interactive state + + + + + Widget is disabled / not interactive + + + + + Window is in background / unfocused + + + Phase @@ -6563,57 +6809,57 @@ Cockatrice will now reload the card database. PhasesToolbar - + Untap step Uptappningssteget - + Upkeep step Underhållssteget - + Draw step Dragsteget - + First main phase Första huvudfasen - + Beginning of combat step Början av stridssteget - + Declare attackers step Attacksteget - + Declare blockers step Blockeringssteget - + Combat damage step Skadesteget - + End of combat step Slutet av stridssteget - + Second main phase Andra huvudfasen - + End of turn step Slutsteget @@ -6621,7 +6867,7 @@ Cockatrice will now reload the card database. PictureLoader - + en code for scryfall's language property, not available for all languages @@ -6630,151 +6876,142 @@ Cockatrice will now reload the card database. PlayerActions - - View top cards of library - - - - - - - - - - - - - Number of cards: (max. %1) - - - - - View bottom cards of library - - - - - Shuffle top cards of library - - - - - Shuffle bottom cards of library - - - - - Draw hand - - - - - 0 and lower are in comparison to current hand size - - - - - Draw cards - - - - - - - + + + + grave - - - - + + + + exile + + + PlayerDialogs - + + View top cards of library + + + + + + + + + + + + + Number of cards: (max. %1) + + + + + View bottom cards of library + + + + + Shuffle top cards of library + + + + + Shuffle bottom cards of library + + + + + Draw hand + + + + + 0 and lower are in comparison to current hand size + + + + + Draw cards + + + + Move top cards to %1 - + Move bottom cards to %1 - + Draw bottom cards - - - C&reate another %1 token - - - - + Create tokens - - + Number: - + Place card X cards from top of library - + Which position should this card be placed: - + (max. %1) - + Change power/toughness - + Change stats to: - + Set annotation - + Please enter the new annotation: - - - Set counters - - PlayerMenu - + Player "%1" - + &Counters @@ -6804,7 +7041,7 @@ This setting means you'll only see the default printing for each card, instead o - + Printing Selector @@ -6812,22 +7049,22 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelectorCardOverlayWidget - + Preference - + Pin Printing - + Unpin Printing - + Show Related cards @@ -6898,57 +7135,57 @@ This setting means you'll only see the default printing for each card, instead o PtMenu - + Power / toughness - + &Increase power - + &Decrease power - + I&ncrease toughness - + D&ecrease toughness - + In&crease power and toughness - + Dec&rease power and toughness - + Increase power and decrease toughness - + Decrease power and increase toughness - + Set &power and toughness... - + Reset p&ower and toughness @@ -6956,37 +7193,37 @@ This setting means you'll only see the default printing for each card, instead o QMenuBar - + Services - + Hide %1 - + Hide Others - + Show All - + Preferences... - + Quit %1 - + About %1 @@ -6994,17 +7231,17 @@ This setting means you'll only see the default printing for each card, instead o QObject - + Cockatrice card database (*.xml) - + All files (*.*) Alla filer (*.*) - + Cockatrice replays (*.cor) Cockatricerepriser (*.cor) @@ -7064,110 +7301,177 @@ Are you sure you would like to disable this feature? QPlatformTheme - + OK - + Save - + Save All - + Open - + &Yes - + Yes to &All - + &No - + N&o to All - + Abort - + Retry - + Ignore - + Close - + Cancel - + Discard - + Help - + Apply - + Reset - + Restore Defaults + + QuickSetupPanel + + + %1% + + + + + <b>Quick Setup</b> + + + + + Generate all palette roles automatically from a single accent colour + + + + + Accent: + + + + + Primary hue. Used directly for highlights and links. +At high intensity it also tints buttons and backgrounds. + + + + + Intensity: + + + + + Subtle + + + + + Full colour + + + + + 0–30 Subtle tint — only highlights and links change hue +30–70 Accented — buttons, tooltips, and borders join in +70–100 Full colour — backgrounds, everything + + + + + 70% + + + + + Generate ↓ + + + + + Derive all palette roles from the accent colour above. +Fine-tune individual colours in the grid afterwards. + + + RemoteDeckList_TreeModel - + Name Namn - + ID ID - + Upload time Uppladdningstidpunkt @@ -7175,32 +7479,32 @@ Are you sure you would like to disable this feature? RemoteReplayList_TreeModel - + ID ID - + Name Namn - + Players Spelare - + Keep Behåll - + Time started Tid startad - + Duration (sec) Varaktighet (sek) @@ -7208,37 +7512,37 @@ Are you sure you would like to disable this feature? RfgMenu - + &Exile - + &View exile - + &Move exile to... - + &Top of library - + &Bottom of library - + &Hand - + &Graveyard @@ -7284,7 +7588,7 @@ Are you sure you would like to disable this feature? SayMenu - + S&ay @@ -7325,27 +7629,27 @@ Are you sure you would like to disable this feature? SetsModel - + Enabled - + Set type - + Set code - + Long name Långt namna - + Release date @@ -7353,53 +7657,53 @@ Are you sure you would like to disable this feature? ShortcutSettingsPage - - + + Restore all default shortcuts - + Do you really want to restore all default shortcuts? - + Clear all default shortcuts - + Do you really want to clear all shortcuts? - + Section: - + Action: - + Shortcut: - + How to set custom shortcuts - + Clear all shortcuts - + Search by shortcut name @@ -7453,12 +7757,12 @@ Please check your shortcut settings! SideboardMenu - + &Sideboard - + &View sideboard @@ -7466,27 +7770,27 @@ Please check your shortcut settings! SoundSettingsPage - + Enable &sounds - + Current sounds theme: - + Test system sound engine - + Sound settings - + Master volume @@ -7558,16 +7862,146 @@ Please check your shortcut settings! - + No reply received from the tag update server. - + Invalid reply received from the tag update server. + + StorageSettingsPage + + + + + Success + + + + + Cached card pictures have been reset. + + + + + Downloaded card pictures have been reset. + + + + + Error + + + + + One or more downloaded card pictures could not be cleared. + + + + + In-memory (currently loaded) card pictures have been reset. + + + + + Card Picture Loader Caching Method: + + + + + The network cache is the preferred way of storing images. Downloaded images are stored here until the size of the cache exceeds the configured size. Cockatrice automatically monitors this cache and deletes the least recently seen card images to ensure the cache does not exceed the configured size. + + + + + Writing card images directly to a folder on your hard drive is another way of storing images. This does not change how Cockatrice accesses or downloads images. Cockatrice will NOT automatically monitor and clear this folder, so if you enable this option, it is up to you to ensure sufficient available space. It should also be noted that if a provider outage causes you to download the wrong picture (i.e. wrong printing) you will be stuck with it until you manually delete the file, as opposed to using the network cache, which automatically rotates and thus correct errors after a while. + + + + + This is the in-memory picture cache used by the application at runtime. It determines how much memory (RAM) Cockatrice can use before it has to fetch card images from the hard disk again. Increasing this will allow more card images to be displayed at once but shouldn't be necessary. Clearing this will make Cockatrice reload all images from the network cache or the disk. + + + + + Delete Cached Images + + + + + Delete Saved Images + + + + + Clear In-Memory Images + + + + + Card Picture Loader Cache Method + + + + + Network Cache + + + + + Filesystem + + + + + In-Memory Picture Cache + + + + + Network Cache Size: + + + + + On-disk cache for downloaded pictures + + + + + Redirect Cache TTL: + + + + + How long cached redirects for urls are valid for. + + + + + Picture Cache Size: + + + + + In-memory cache for pictures not currently on screen + + + + + Naming scheme: + + + + + Day(s) + + + TabAccount @@ -7703,117 +8137,117 @@ Please check your shortcut settings! TabArchidekt - - + + Desc. - - + + AND - - + + Require ALL selected colors - - + + Deck name... - - + + Owner... - - + + Packages - - + + Advanced Filters - + Bracket: - - + + Any - - + + Contains card... - - + + Commander... - - + + Tag... - - + + Deck Size - + Cards: - - + + Asc. - + Sort by: - + Filter by: - + Display Settings - - + + Search - - + + Formats @@ -7823,6 +8257,54 @@ Please check your shortcut settings! + + TabCardArtRules + + + Card: + + + + + ProviderId: + + + + + Mode: + + + + + Reason: + + + + + Type a card name... + + + + + Add rule + + + + + Remove rule + + + + + Refresh + + + + + Card Art Rules + + + TabDeckEditor @@ -7871,7 +8353,7 @@ Please check your shortcut settings! - + Deck: %1 Lek: %1 @@ -7879,55 +8361,49 @@ Please check your shortcut settings! TabDeckEditorVisual - + Visual Deck: %1 - + &Visual Deck Editor - - + + Card Info - - + + Deck - - - Filters - - - - + &View - + Printing - + Visible - + Floating - + Reset layout @@ -7935,22 +8411,22 @@ Please check your shortcut settings! TabDeckEditorVisualTabWidget - + Visual Deck View - + Visual Database Display - + Deck Analytics - + Sample Hand @@ -7958,133 +8434,133 @@ Please check your shortcut settings! TabDeckStorage - + Local file system Lokalt filsystem - + Server deck storage Serverns leklagring - - + + Open in deck editor Öppna i lekredigeraren - + Rename deck or folder - + Upload deck Ladda upp lek - + Download deck Ladda ner lek - - - + + + New folder Ny mapp - + Delete Radera - + Open decks folder - + Rename local folder - + Rename local file - + New name: - - - - + + + + Error - + Rename failed - - + + Invalid deck file - + Enter deck name Ange leknamn - + This decklist does not have a name. Please enter a name: Denna leklista har inget namn. Vänligen ange ett namn: - + Unnamed deck Namnlös lek - + Failed to upload deck to server - + Delete local file Radera lokal fil - + Are you sure you want to delete the selected files? - + Delete remote decks - + Are you sure you want to delete the selected decks? - - + + Name of new folder: Namn på den nya mappen: @@ -8156,191 +8632,191 @@ Please enter a name: TabGame - - - + + + Replay - - + + Game - - + + Player List - - + + Card Info - - + + Messages - - + + Replay Timeline - + &Phases &Faser - + &Game &Spel - + Next &phase Nästa &fas - + Next phase with &action - + Next &turn Nästa &tur - + Reverse turn order - + &Remove all local arrows Ta &bort alla lokala pilar - + Rotate View Cl&ockwise - + Rotate View Co&unterclockwise - + Game &information Spel&information - + Un&concede - - - + + + &Concede &Ge upp - + &Leave game &Lämna spel - + C&lose replay S&täng repris - + &Focus Chat - + &Say: S&äg: - + Selected cards - + &View - + Visible - + Floating - + Reset layout - + Concede Ge upp - + Are you sure you want to concede this game? Är du säker på att du vill ge upp detta spel? - + Unconcede - + You have already conceded. Do you want to return to this game? - + Leave game Lämna spel - + Are you sure you want to leave this game? Är du säker på att du vill lämna detta spel? - + A player has joined game #%1 - + %1 has joined the game - + You have been kicked out of the game. @@ -8399,114 +8875,114 @@ Please enter a name: - + Username: - + IP Address: - + Game Name: - + GameID: - + Message: - + Main Room - + Game Room - + Private Chat - + Past X Days: - + Today - + Last Hour - + Maximum Results: - + At least one filter is required. The more information you put in, the more specific your results will be. - + Get User Logs - + Clear Filters - + Filters - + Log Locations - + Date Range - + Maximum Results - - + + Message History - + Failed to collect message history information. - + There are no messages for the selected filters. @@ -8524,27 +9000,27 @@ The more information you put in, the more specific your results will be.&Lämna - + %1 - Private chat - + This user is ignoring you, they cannot see your messages in main chat and you cannot join their games. - + Private message from - + %1 has left the server. %1 har lämnat servern. - + %1 has joined the server. %1 har anslutit sig till servern. @@ -8552,180 +9028,180 @@ The more information you put in, the more specific your results will be. TabReplays - + Local file system Lokalt filsystem - + Server replay storage Serverns reprislagring - - + + Watch replay Titta på repris - + Rename - - + + New folder - - + + Delete Radera - + Open replays folder - + Download replay Ladda ner repris - + Toggle expiration lock Växla utgångslås - + Get replay share code - - + + Look up replay by share code - + Rename local folder - + Rename local file - + New name: - + Error - + Rename failed - + Name of new folder: - + Delete local file Radera lokal fil - + Are you sure you want to delete the selected files? - + Are you sure you want to delete the selected replays? - + Failed to get code - - + + Either this server does not support replay sharing, or does not permit replay sharing for you. - - - + + + Failed - + Could not get replay code - + Replay Share Code - + Others can use this code to add the replay to their list of remote replays: %1 - + Copy to clipboard - + Replay share code - + Replay code found - + Replay was added, or you already had access to it. - + Replay code not found - + Failed to submit code - + Unexpected error - + Delete remote replay Radera avlägsen repris @@ -8738,47 +9214,62 @@ The more information you put in, the more specific your results will be. TabRoom - + + Friends + + + + + Online + + + + + Ignored + + + + &Say: &Säg: - + Chat Chatt - + &Room &Rum - + &Leave room &Lämna rum - + &Clear chat - + Chat Settings... - + mentioned you. - + Click to view - + You are flooding the chat. Please wait a couple of seconds. Du översvämmar chatten. Var vänlig vänta ett par sekunder. @@ -8791,30 +9282,30 @@ The more information you put in, the more specific your results will be. - - - - + + + + Error - + Failed to join the server room: it doesn't exist on the server. - + The server thinks you are in the server room but your client is unable to display it. Try restarting your client. - + You do not have the required permission to join this server room. - + Failed to join the server room due to an unknown error: %1. @@ -8822,97 +9313,97 @@ The more information you put in, the more specific your results will be. TabSupervisor - + Deck Editor - + Visual Deck Editor - + EDHRec - + Archidekt - + Home - + &Visual Deck Storage - + Visual Database Display - + Server - + Account - + Deck Storage - + Game Replays - + Administration - + Logs - + Are you sure? - + There are still open games. Are you sure you want to quit? - + Click to view - + Your buddy %1 has signed on! - + Unknown Event - + The server has sent you a message that your client does not understand. This message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -8920,38 +9411,38 @@ To update your client, go to Help -> Check for Updates. Servern har skickat dig ett meddelande som din klient inte förstår. Det här meddelandet kan betyda att det finns en ny version av Cockatrice tillgänglig eller så kör den här servern en användarinställd eller förhandsutgiven version. För att uppdatera din klient, gå till Hjälp -> Leta efter uppdateringar. - + Idle Timeout - + You are about to be logged out due to inactivity. Du kommer snart loggas ut på grund av inaktivitet. - + Promotion - + You have been promoted. Please log out and back in for changes to take effect. - + Warned Varnad - + You have received a warning due to %1. Please refrain from engaging in this activity or further actions may be taken against you. If you have any questions, please private message a moderator. - + You have received the following message from the server. (custom messages like these could be untranslated) @@ -8960,12 +9451,12 @@ Please refrain from engaging in this activity or further actions may be taken ag TabVisualDatabaseDisplay - + Database Display - + Visual Database Display @@ -9002,42 +9493,42 @@ Please refrain from engaging in this activity or further actions may be taken ag TranslateCounterName - + Life - + White - + Blue - + Black - + Red - + Green - + Colorless - + Other @@ -9045,11 +9536,69 @@ Please refrain from engaging in this activity or further actions may be taken ag UpdateDownloader - + Could not open the file for reading. + + UserCardArtSettingsDialog + + + Card Art Settings + + + + + Type a card name... + + + + + Card name: + + + + + Card ProviderId: + + + + + Left margin (%): + + + + + Right margin (%): + + + + + Vertical offset: + + + + + Zoom: + + + + + Parameters + + + + + Preview + + + + + Remove Banner Card + + + UserContextMenu @@ -9167,7 +9716,7 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Ban History @@ -9182,77 +9731,87 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Failed to collect ban information. - - - + + + Warning History - + Warning Time;Moderator;User Name;Reason - + User has never been warned. - + Failed to collect warning information. - + Failed to get admin notes. - - + + Success - + Successfully promoted user. - + Successfully demoted user. - - - + + Kick Player + + + + + Are you sure you want to kick this player from the game? + + + + + + Failed - + Failed to promote user. - + Failed to demote user. - + Copy hash to clipboard - + Remove this user's messages @@ -9260,326 +9819,554 @@ Please refrain from engaging in this activity or further actions may be taken ag UserInfoBox - + Location: Plats: - + Account Age: - + Edit - + Change password - + Change avatar - + Administrator Administratör - + Moderator Moderator - + Registered user Registrerad användare - - + + Unregistered user Oregistrerad användare - + Judge - + Unknown - + The entered password does not match your account. - - - + + + + Information - + User information updated. - - - - - - - - - - + + + + + + + + + + + Error - + User Information - + Real Name: - + User Level: + + + Edit Banner Card + + - + %n Year(s), amount of years (only shown if more than 0) - + %10%n Day(s) %20 amount of years (if more than 0), amount of days, date in local short format - + Enter Password - + Password verification is required in order to change your email address - - - + + + An error occurred while trying to update your user information. - - This server does not permit you to update your user informations. + + The selected card is blacklisted on this server or another error occurred. - - Password changed. + + Banner card removed. - - This server does not permit you to change your password. - - - - - The new password is too short. - - - - - The old password is incorrect. - - - - - Avatar updated. - - - - - This server does not permit you to update your avatar. + + Banner card updated. - An error occured while trying to updater your avatar. + This server does not permit you to update your user informations. + + + + + Password changed. + + + + + This server does not permit you to change your password. + + + + + The new password is too short. + + + + + The old password is incorrect. + + + + + Avatar updated. + + + + + This server does not permit you to update your avatar. + + + + + An error occured while trying to update your avatar. + + + + + This server does not permit you to update your user informations. + An error occured while trying to updater your avatar. + + + + + UserInfoPopup + + + + Games + + + + + Chat + + + + + Open private chat + + + + + Profile + + + + + View user profile + + + + + Show this user's games + + + + + − Buddy + + + + + Remove from buddy list + + + + + + Buddy + + + + + Add to buddy list + + + + + − Ignore + + + + + Remove from ignore list + + + + + + Ignore + + + + + Add to ignore list + + + + + Ban + + + + + Ban from server + + + + + Warn + + + + + Warn user + + + + + Ban log + + + + + View ban history + + + + + Warn log + + + + + View warning history + + + + + Notes + + + + + View admin notes + + + + + − Mod + + + + + Demote from moderator + + + + + + Mod + + + + + Promote to moderator + + + + + − Judge + + + + + Demote from judge + + + + + + Judge + + + + + Promote to judge + + + + + Join game + + + + + Spectate + + + + + + Loading games… + + + + + Could not load games. + + + + + No active games. UserInterfaceSettingsPage - + General interface settings Allmänna gränssnittsinställningar - + &Double-click cards to play them (instead of single-click) &Dubbelklicka på kort för att spela dem (istället för enkelklick) - + &Clicking plays all selected cards (instead of just the clicked card) - + &Play all nonlands onto the stack (not the battlefield) by default - + Do not delete &arrows inside of subphases - + Close card view window when last card is removed - + Auto focus search bar when card view window is opened - + Annotate card text on tokens - - Show selection counter during drag selection + + Show selection count during drag selection - - Show total selection counter + + Show total selection count - + + Show subtype breakdown in selection tally + + + + Use tear-off menus, allowing right click menus to persist on screen - + + Keep game chat focused when clicking in game (Note: disables card view search bar) + + + + Notifications settings - + Enable notifications in taskbar - + Notify in the taskbar for game events while you are spectating - + Notify in the taskbar when users in your buddy list connect - + Animation settings Animationsinställningar - + &Tap/untap animation &Tappnings/Upptappningsanimation - + Deck editor/storage settings - + Open deck in new tab by default - + Use visual deck storage in game lobby - + Use selection animation for Visual Deck Storage - + When adding a tag in the visual deck storage to a .txt deck: - + do nothing - + ask to convert to .cod - + always convert to .cod - + Default deck editor type - + Classic Deck Editor - + Visual Deck Editor - + Replay settings - + Buffer time for backwards skip via shortcut: @@ -9587,22 +10374,22 @@ Please refrain from engaging in this activity or further actions may be taken ag UserListWidget - + Users connected to server: %1 - + Users in this room: %1 - + Buddies online: %1 / %2 - + Ignored users online: %1 / %2 @@ -9610,35 +10397,45 @@ Please refrain from engaging in this activity or further actions may be taken ag UtilityMenu - + Increment all card counters - + &Untap all permanents - + R&oll die... - + + Flip coin + + + + &Create token... - + C&reate another token - + Cr&eate predefined token + + + C&reate another %1 token + + VisualDatabaseDisplayColorFilterWidget @@ -9690,72 +10487,72 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayFilterToolbarWidget - + Sort by - + Filter by - + Save and load filters - + Filter by exact card name - + Filter by card main-type - + Filter by card sub-type - + Filter by set - + Filter by format legality - + Save/Load - + Name - + Main Type - + Sub Type - + Sets - + Formats @@ -9763,22 +10560,22 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayFormatLegalityFilterWidget - + Show formats with at least: - + cards - + Do not display formats with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) @@ -9796,32 +10593,32 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayMainTypeFilterWidget - + Show main types with at least: - + cards - + Do not display card main-types with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) - + Mode: Exact Match - + Mode: Includes @@ -9829,27 +10626,27 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayNameFilterWidget - + Filter by name... (Exact match) - + Load from Deck - + Apply all card names in currently loaded deck as exact match name filters - + Load from Clipboard - + Apply all card names in clipboard as exact match name filters @@ -9857,7 +10654,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayRecentSetFilterSettingsWidget - + Filter to most recent sets @@ -9865,19 +10662,19 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplaySetFilterWidget - + Search sets... - - + + Mode: Exact Match - - + + Mode: Includes @@ -9885,37 +10682,37 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplaySubTypeFilterWidget - + Search subtypes... - + Show sub types with at least: - + cards - + Do not display card sub-types with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) - + Mode: Exact Match - + Mode: Includes @@ -9923,23 +10720,23 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayWidget - + Search by card name (or search expressions) - + Visual - + Loading database ... - + Clear all filters @@ -9978,17 +10775,17 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Toggle Layout: Overlap - + Change how cards are displayed within zones (i.e. overlapped or fully visible.) - + Toggle Layout: Flat @@ -10017,17 +10814,17 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckEditorWidget - + Type a card name here for suggestions from the database... - + Quick search and add card - + Search for closest match in the database (with auto-suggestions) and add preferred printing to the deck on pressing enter @@ -10145,43 +10942,43 @@ Please refrain from engaging in this activity or further actions may be taken ag WarningDialog - + Which warning would you like to send? - + Redact all messages from this user in all rooms - + &OK - + &Cancel - + Warn user for misconduct - - + + Error - + User name to send a warning to can not be blank, please specify a user to warn. - + Warning to use can not be blank, please select a valid warning to send. @@ -10189,133 +10986,133 @@ Please refrain from engaging in this activity or further actions may be taken ag WndSets - + Move selected set to the top - + Move selected set up - + Move selected set down - + Move selected set to the bottom - + Search by set name, code, or type - + Default order - + Restore original art priority order - + Enable all sets - + Disable all sets - + Enable selected set(s) - + Disable selected set(s) - + Deck Editor - + Use CTRL+A to select all sets in the view. - + Only cards in enabled sets will appear in the card list of the deck editor. - + Image priority is decided in the following order: - + first the CUSTOM Folder (%1), then the Enabled Sets in this dialog (Top to Bottom) %1 is a link to the wiki - + Include cards rebalanced for Alchemy [requires restart] - + Card Art - + How to use custom card art - + Hints - + Note - + Sorting by column allows you to find a set while not changing set priority. - + To enable ordering again, click the column header until this message disappears. - + Use the current sorting as the set priority instead - + Sorts the set priority using the same column - + Manage sets @@ -10323,72 +11120,72 @@ Please refrain from engaging in this activity or further actions may be taken ag ZoneViewWidget - + Search by card name (or search expressions) - + Ungrouped - + Group by Type - + Group by Mana Value - + Group by Color - + Unsorted - + Sort by Name - + Sort by Type - + Sort by Mana Cost - + Sort by Colors - + Sort by P/T - + Sort by Set - + shuffle when closing blanda när du stänger - + pile view @@ -10396,7 +11193,7 @@ Please refrain from engaging in this activity or further actions may be taken ag i18n - + English Svenska (Swedish) @@ -10404,12 +11201,12 @@ Please refrain from engaging in this activity or further actions may be taken ag main - + Connect on startup - + Debug to file @@ -10423,7 +11220,7 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Deck Editor @@ -10504,7 +11301,7 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Replays @@ -10619,129 +11416,129 @@ Please refrain from engaging in this activity or further actions may be taken ag - - + + Load Deck from Clipboard... - + Edit Deck in Clipboard, Annotated - + Edit Deck in Clipboard - + New Deck - + Open Custom Pictures Folder - + Print Deck... - + Delete Card - - + + Reset Layout - + Save Deck - + Save Deck as... - + Save Deck to Clipboard, Annotated - + Save Deck to Clipboard, Annotated (No Set Info) - + Save Deck to Clipboard - + Save Deck to Clipboard (No Set Info) - + Load Local Deck... - + Load Remote Deck... - + Set Ready to Start - + Toggle Sideboard Lock - + Add Green Counter - + Remove Green Counter - + Set Green Counters... - + Add Red Counter - + Remove Red Counter - + Set Red Counters... - + Add Life Counter @@ -10751,724 +11548,744 @@ Please refrain from engaging in this activity or further actions may be taken ag - + + Load deck from online service... + + + + + Load from website... + + + + Unload Deck - + Force Start - + Add Card Counter (F) - + Remove Card Counter (F) - + Set Card Counters (F)... - + Add Card Counter (E) - + Remove Card Counter (E) - + Set Card Counters (E)... - + Add Card Counter(D) - + Remove Card Counter (D) - + Set Card Counters (D)... - + Add Card Counter (C) - + Remove Card Counter (C) - + Set Card Counters (C)... - + Add Card Counter (B) - + Remove Card Counter (B) - + Set Card Counters (B)... - + Add Card Counter (A) - + Remove Card Counter (A) - + Set Card Counters (A)... - + Remove Life Counter - + Set Life Counters... - + Add White Counter - + Remove White Counter - + Set White Counters... - + Add Blue Counter - + Remove Blue Counter - + Set Blue Counters... - + Add Black Counter - + Remove Black Counter - + Set Black Counters... - + Add Colorless Counter - + Remove Colorless Counter - + Set Colorless Counters... - + Add Other Counter - + Remove Other Counter - + Set Other Counters... - + Increment all card counters - + Add Power (+1/+0) - + Remove Power (-1/-0) - + Move Toughness to Power (+1/-1) - + Add Toughness (+0/+1) - + Remove Toughness (-0/-1) - + Move Power to Toughness (-1/+1) - + Add Power and Toughness (+1/+1) - + Remove Power and Toughness (-1/-1) - + Set Power and Toughness... - + Reset Power and Toughness - + Untap - + Upkeep - + Draw - + First Main Phase - + Start Combat - + Attack - + Block - + Damage - + End Combat - + Second Main Phase - + End - + Next Phase - + Next Phase Action - + Next Turn - + Hide Card in Reveal Window - + Tap / Untap Card - + Untap All - + Toggle Skip Untapping Toggle Untap - + Turn Card Over - + Peek Card - + Play Card - + Play Card, Face Down - + Attach Card... - + Unattach Card - + Clone Card - + Create Token... - + Create All Related Tokens - + Create Another Token - + Set Annotation... - + + Reduce Life by Power + + + + Select All Cards in Zone - + Select All Cards in Row - + Select All Cards in Column - + Reveal Selected Cards to All Players - - + + Bottom of Library - - - - + + + + Exile Exil - - - - + + + + Graveyard Kyrkogård - - + + Hand - - + + Top of Library - - + + Battlefield, Face Down - + Battlefield - + Library Bibliotek - + Sideboard Skänk - + Top Cards of Library - + Bottom Cards of Library - + Close Recent View - - + + Stack - - + + Graveyard (Multiple) - - + + Graveyard (Multiple), Face Down - - + + Exile (Multiple) - - + + Exile (Multiple), Face Down - + Stack Until Found - + Draw Bottom Card - + Draw Multiple Cards from Bottom... - + Draw Arrow... - + Remove Local Arrows - + Leave Game Lämna spelet - + Concede Medge - + Roll Dice... Kasta tärning - + + Flip Coin + + + + Shuffle Library - + Shuffle Top Cards of Library - + Shuffle Bottom Cards of Library - + Mulligan - + Mulligan (Same hand size) - + Mulligan (Hand size - 1) - + Draw a Card Dra ett kort - + Draw Multiple Cards... - + Undo Draw - + Always Reveal Top Card - + Always Look At Top Card - + Sort Hand by Name - + Sort Hand by Type - + Sort Hand by Mana Value - + Reveal Hand to All Players - + Reveal Random Card to All Players - + Rotate View Clockwise - + Rotate View Counterclockwise - + Unfocus Text Box - + Focus Chat - + Clear Chat - + Refresh - + Skip Forward - + Skip Backward - + Skip Forward by a lot - + Skip Backward by a lot - + Play/Pause - + Toggle Fast Forward - + Home - + Visual Deck Storage Visuell Leklagring - + Deck Storage Leklagring - + Server Server - + Account Konto - + Administration Administratör - + Logs Loggar diff --git a/cockatrice/translations/cockatrice_yue.ts b/cockatrice/translations/cockatrice_yue.ts index e3c73b94a..f20b9ea1d 100644 --- a/cockatrice/translations/cockatrice_yue.ts +++ b/cockatrice/translations/cockatrice_yue.ts @@ -2,7 +2,7 @@ AbstractCounter - + &Set counter... &設置指示物 @@ -10,12 +10,12 @@ AbstractCounterDialog - + Set counter 設置指示物 - + New value for counter '%1': 數值物新價值為 '%1' @@ -36,60 +36,60 @@ AbstractTabDeckEditor - + Open in new tab - + Are you sure? 你確定嗎? - + The decklist has been modified. Do you want to save the changes? - - - - - - + + + + + + Error 出錯 - + Could not open deck at %1 - + Could not save remote deck - - + + The deck could not be saved. Please check that the directory is writable and try again. - + Save deck - + The deck could not be saved. - + There are no cards in your deck to be exported @@ -105,12 +105,12 @@ Please check that the directory is writable and try again. AdminNotesDialog - + Update Notes 更新後筆記 - + Admin Notes for %1 %1 的管理筆記 @@ -127,172 +127,227 @@ Please check that the directory is writable and try again. Sideboard 副牌庫 + + + Tokens + + AppearanceSettingsPage - + seconds - + Error 出現錯誤 - + Could not create themes directory at '%1'. 不能在'%1'創造主題目錄 - + Theme settings 主題設定 - + Current theme: 現在主題 - + Open themes folder 打開主題文件夾 - + Home tab background source: - + Home tab background shuffle frequency: - + Disabled - - Display card name of background in bottom right: - - - - + Menu settings 選單設定 - + Show keyboard shortcuts in right-click menus 在右擊選單中顯示鍵盤快速鍵 - + Show game filter toolbar above list in room tab - + Card rendering 卡面算繪 - + Display card names on cards having a picture 最有圖片的牌上顯示牌名 - + Auto-Rotate cards with sideways layout 將打橫佈局卡片自動向上旋轉 - + Override all card art with personal set preference (Pre-ProviderID change behavior) - + + Light + + + + + Dark + + + + + System + + + + + Active theme palette: + + + + + Edit theme palette + + + + + Home tab settings + + + + + Display card name of background in bottom right + + + + + Styling settings + + + + + Style user list + + + + + Card printings + + + + Bump sets that the deck contains cards from to the top in the printing selector 將牌庫中包含的牌組推到藝術選擇視窗之頂 - + Scale cards on mouse over 卡牌隨遊標縮放 - + Use rounded card corners - + + Card layout + + + + Minimum overlap percentage of cards on the stack and in vertical hand 在疊區中和直行手牌中,盡量減少交重疊百分點 - + Maximum initial height for card view window: 卡望框最高開頭高度 - - + + rows 橫列 - + Maximum expanded height for card view window: - + Card counters 牌指示物 - + Counter %1 指示物 %1 - + Hand layout 手牌佈局 - + Display hand horizontally (wastes space) 打棟顯示手牌(浪費空間) - + Enable left justification 啟用向左對齊 - + Table grid layout 表格布局 - + Invert vertical coordinate 反轉垂直坐標 - + Minimum player count for multi-column layout: 界面佈局之內能夠容納的最少玩家數量: - + Maximum font size for information displayed on cards: 卡牌上顯示的最大字體大小 @@ -300,12 +355,12 @@ Please check that the directory is writable and try again. ArchidektApiResponseDeckDisplayWidget - + Back to results 返去結果 - + Open Deck in Deck Editor 在套牌編輯&打開牌庫檔案 @@ -331,112 +386,112 @@ Please check that the directory is writable and try again. BanDialog - + ban &user name 禁用 &用家名字 - + ban &IP address 禁用&IP地址 - + ban client I&D 禁用客户ID - + Ban type 禁用類型 - + &permanent ban &永久禁用 - + &temporary ban &暫時禁用 - + &Days: &日 - + &Hours: &小時 - + &Minutes: &分鐘 - + Duration of the ban 禁用時間 - + Please enter the reason for the ban. This is only saved for moderators and cannot be seen by the banned person. 請記載禁用原因 這項只保留為版主, 被禁用戶不能看到. - + Please enter the reason for the ban that will be visible to the banned person. 請記載被禁用戶能看到的被禁原因. - + Redact all messages from this user in all rooms 將這用戶所有室中打出的訊息完全移除 - + &OK &OK - + &Cancel &取消 - + Ban user from server 把用戶從本伺服器禁用 - - - - + + + + Error 出現錯誤 - + You have to select a name-based, IP-based, clientId based, or some combination of the three to place a ban. 若要對用戶實施禁用,請你選擇以下三種的拼合:名字,IP,或用戶id. - + You must have a value in the name ban when selecting the name ban checkbox. 若用用戶名字施行禁用,必須要填寫姓名. - + You must have a value in the ip ban when selecting the ip ban checkbox. 若用ip地址施行禁用,必須要寫下IP地址. - + You must have a value in the clientid ban when selecting the clientid ban checkbox. 若用用戶ID施行禁用,必須要寫下用戶ID. @@ -444,59 +499,123 @@ This is only saved for moderators and cannot be seen by the banned person. BetaReleaseChannel - + Beta 測試版 - + No reply received from the release update server. 從發佈更新伺服器收不到回覆. - + Invalid reply received from the release update server. 從發佈更新伺服器收不到有效的回覆. - + No reply received from the file update server. 從檔案更新伺服器收不到有效的回覆. + + CardArtPreviewWidget + + + No card selected + + + + + CardArtRulesModel + + + Card + + + + + ProviderId + + + + + Mode + + + + + Reason + + + CardDatabaseModel - + Name 名字 - + Sets 牌組 - + Mana cost 法力費用 - + Card type 卡牌類別 - + P/T 力量/防御力 - + Color(s) 顏色 + + CardDatabaseView + + + Add to Deck + + + + + Add to Sideboard + + + + + Select Printing + + + + + Show on EDHRec (Commander) + + + + + Show on EDHRec (Card) + + + + + Show Related cards + + + CardFilter @@ -625,22 +744,22 @@ This is only saved for moderators and cannot be seen by the banned person. CardInfoPictureWidget - + View related cards 查看關聯卡牌 - + Add card to deck 添加卡牌到牌庫 - + Mainboard 主牌庫 - + Sideboard 副牌庫 @@ -663,12 +782,12 @@ This is only saved for moderators and cannot be seen by the banned person.收藏號碼: - + Related cards: 有關牌名: - + Unknown card: 不知名的牌 @@ -676,128 +795,146 @@ This is only saved for moderators and cannot be seen by the banned person. CardMenu - + Re&veal to... &展示给... - + &All players &所有玩家 - + View related cards 查看關聯卡牌 - + Token: 令牌: - + All tokens 所有令牌 - + &Select All &全選 - + S&elect Row &選擇橫行 - + S&elect Column &選擇直行 - + &Play &使出 - + &Hide &隱藏 - + Play &Face Down &面朝下使出 - + &Tap / Untap Turn sideways or back again &横置/重置 - + Skip &untapping - + T&urn Over Turn face up/face down - + &Peek at card face - + &Clone - + Attac&h to card... - + Unattac&h - + &Draw arrow... - + &Set annotation... - + + Reduce life by power + + + + Ca&rd counters - + &Add counter (%1) - + &Remove counter (%1) - + &Set counters (%1)... + + CardPictureLoaderCacheMethod + + + Network Cache + + + + + Filesystem + + + CardSizeWidget @@ -809,133 +946,133 @@ This is only saved for moderators and cannot be seen by the banned person. CardZoneLogic - + their hand nominative 它們的手牌 - + %1's hand nominative %1的手牌 - + their library look at zone 它們的牌庫 - + %1's library look at zone %1的牌庫 - + of their library top cards of zone, 它們的牌庫中 - + of %1's library top cards of zone %1的牌庫中 - + their library reveal zone 它們的牌庫 - + %1's library reveal zone %1的牌庫 - + their library shuffle 它們的牌庫 - + %1's library shuffle %1的牌庫 - + their library nominative 它們的牌庫 - + %1's library nominative %1的牌庫 - + their graveyard nominative 它們的墳場 - + %1's graveyard nominative %1的的墳場 - + their exile nominative 它們的放逐區 - + %1's exile nominative %1的放逐區 - + their sideboard look at zone 它們的備牌 - + %1's sideboard look at zone %1的備牌 - + their sideboard nominative 它們的備牌 - + %1's sideboard nominative %1的備牌 - + their custom zone '%1' nominative 它們的自定區域 '%1' - + %1's custom zone '%2' nominative %1的自定區域'%2' @@ -957,16 +1094,421 @@ This is only saved for moderators and cannot be seen by the banned person. + + ColorButton + + + Click to pick a color + + + + + Pick colour + + + + + ConnectionController + + + + The server has reached its maximum user capacity, please check back later. + + + + + There are too many concurrent connections from your address. + + + + + Banned by moderator + + + + + Expected end time: %1 + + + + + This ban lasts indefinitely. + + + + + Scheduled server shutdown. + + + + + + Invalid username. + + + + + You have been logged out due to logging in at another location. + + + + + Connection closed + + + + + The server has terminated your connection. +Reason: %1 + + + + + The server is going to be restarted in %n minute(s). +All running games will be lost. +Reason for shutdown: %1 + + + + + Scheduled server shutdown + + + + + Failed Login + + + + + Your client seems to be missing features this server requires for connection. + + + + + To update your client, go to 'Help -> Check for Client Updates'. + + + + + + + + + + + + + + + + + + + + + + Error + + + + + Incorrect username or password. Please check your authentication information and try again. + + + + + There is already an active session using this user name. +Please close that session first and re-login. + + + + + + You are banned until %1. + + + + + + You are banned indefinitely. + + + + + This server requires user registration. Do you want to register now? + + + + + This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. +Please close and reopen your client to try again. + + + + + An internal error has occurred, please close and reopen Cockatrice before trying again. +If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. + + + + + Account activation + + + + + Your account has not been activated yet. +You need to provide the activation token received in the activation email. + + + + + Server Full + + + + + Unknown login error: %1 + + + + + + +This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. + + + + + + + + + + Registration denied + + + + + Registration is currently disabled on this server + + + + + There is already an existing account with the same user name. + + + + + It's mandatory to specify a valid email address when registering. + + + + + It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. + + + + + Password too short. + + + + + Registration failed for a technical problem on the server. + + + + + The connection to the server has been lost. + + + + + Unknown registration error: %1 + + + + + Account activation failed + + + + + Socket error: %1 + + + + + Server timeout + + + + + You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. +Local version is %1, remote version is %2. + + + + + Your Cockatrice client is obsolete. Please update your Cockatrice version. +Local version is %1, remote version is %2. + + + + + + Success + + + + + Registration accepted. +Will now login. + + + + + Account activation accepted. +Will now login. + + + + + Information + + + + + This server supports additional features that your client doesn't have. +This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. + +To update your client, go to Help -> Check for Updates. + + + + + + + Reset Password + + + + + Your password has been reset successfully, you can now log in using the new credentials. + + + + + Failed to reset user account password, please contact the server operator to reset your password. + + + + + Activation request received, please check your email for an activation token. + + + + + Connecting to %1... + + + + + Registering to %1 as %2... + + + + + Disconnected + + + + + Connected, logging in at %1 + + + + + Requesting forgotten password to %1 as %2... + + + + + Your username must respect these rules: + + + + + is %1 - %2 characters long + + + + + can %1 contain lowercase characters + + + + + + + + NOT + + + + + can %1 contain uppercase characters + + + + + can %1 contain numeric characters + + + + + can contain the following punctuation: %1 + + + + + first character can %1 be a punctuation mark + + + + + no unacceptable language as specified by these server rules: + note that the following lines will not be translated + + + + + can not contain any of the following words: %1 + + + + + can not match any of the following expressions: %1 + + + + + You may only use A-Z, a-z, 0-9, _, ., and - in your username. + + + CustomZoneMenu - + C&ustom Zones - - + + View custom zone '%1' @@ -974,30 +1516,35 @@ This is only saved for moderators and cannot be seen by the banned person. DeckAnalyticsWidget - + Add Panel - + Remove Panel - + Save Layout - + Load Layout + + + Include Sideboard + + DeckEditorCardDatabaseDockWidget - + Card Database @@ -1013,47 +1560,17 @@ This is only saved for moderators and cannot be seen by the banned person. DeckEditorDatabaseDisplayWidget - + Search by card name (or search expressions) - - Add to Deck - - - - - Add to Sideboard - - - - - Select Printing - - - - - Show on EDHRec (Commander) - - - - - Show on EDHRec (Card) - - - - - Show Related cards - - - - + Add card to &maindeck - + Add card to &sideboard @@ -1086,72 +1603,72 @@ This is only saved for moderators and cannot be seen by the banned person. - + Select Printing - + Deck - + Deck &name: - + Banner Card/Tags Visibility Settings - + Show banner card selection menu - + Show tags selection menu - + &Comments: - + Group by: - + Format: - + Hash: - + &Increment number - + &Decrement number - + &Remove row - + Swap card to/from sideboard @@ -1159,17 +1676,17 @@ This is only saved for moderators and cannot be seen by the banned person. DeckEditorFilterDockWidget - + Filters - + &Clear all filters - + Delete selected @@ -1300,169 +1817,113 @@ This is only saved for moderators and cannot be seen by the banned person. DeckEditorSettingsPage - - + + Update Spoilers 更新預覽卡牌 - - + Success 成功 - + Download URLs have been reset. 下載URL已重置。 - - Downloaded card pictures have been reset. - 下載的卡牌圖片已被重置。 - - - - Error - 出現錯誤 - - - - One or more downloaded card pictures could not be cleared. - 一幅或以上的卡牌圖片未能被清除。 - - - + Add URL 添加URL - - + + URL: URL: - - + + Edit URL 更改URL - - Network Cache Size: - 圖片網路緩存大小: - - - - Redirect Cache TTL: - 網路緩處再定向生存時間 - - - - How long cached redirects for urls are valid for. - 再定向緩處有效時間 - - - - Picture Cache Size: - 圖片緩存大小: - - - + Add New URL 添置新 URL - + Remove URL 移除 URL. - - Day(s) - - - - + Updating... 更新中... - + Choose path 選擇路徑 - + URL Download Priority URL下載優先次序 - + Spoilers 預覽 - + Download Spoilers Automatically 自動下載預覽 - + Spoiler Location: 預覽位置: - + Last Change 最後變更 - + Spoilers download automatically on launch 開啟時自動下載預覽 - + Press the button to manually update without relaunching 免重新啟動人手按制更新 - + Do not close settings until manual update is complete 人手更新完成前,不能關掉设置 - + Download card pictures on the fly 即時下載卡牌圖片 - + How to add a custom URL 如何添加自定URL - - Delete Downloaded Images - 刪除已下載的圖片 - - - + Reset Download URLs 重置下載URL - - - On-disk cache for downloaded pictures - 下載圖片專用硬盤緩儲 - - - - In-memory cache for pictures not currently on screen - 現不在螢光幕上記憶中圖片緩儲 - DeckListHistoryManagerWidget @@ -1528,12 +1989,12 @@ This is only saved for moderators and cannot be seen by the banned person. DeckLoader - + Common deck formats (%1) - + All files (*.*) @@ -1755,27 +2216,27 @@ This is only saved for moderators and cannot be seen by the banned person. - + Moved to %1 1 × "%2" (%3) - + Removed "%1" (all copies) - + %1 1 × "%2" (%3) - + Added - + Removed @@ -1797,74 +2258,74 @@ This is only saved for moderators and cannot be seen by the banned person. DeckViewContainer - + Load deck... 載入牌庫... - + Load remote deck... 載入伺服器上的牌庫... - + Load from clipboard... - + Load from website... - + Unload deck - + Ready to start 準備好 - + Force start 強迫開始 - + Sideboard unlocked 備牌解鎖 - + Sideboard locked 解鎖鎖上 - - + + Error 出現錯誤 - + The selected file could not be loaded. 選擇的檔案不能再入 - + Deck is greater than maximum file size. - + Are you sure you want to force start? This will kick all non-ready players from the game. - + Cockatrice @@ -1963,7 +2424,7 @@ This will kick all non-ready players from the game. - + Webpage 網頁 @@ -2003,37 +2464,37 @@ This will kick all non-ready players from the game. 連接伺服器 - + Server URL 伺服器URL - + Communication Port 通信端口 - + Unique Server Name 獨特伺服器名字 - + Connection Warning 連接警告 - + You need to name your new connection profile. 你必須將你的連接簡介命名 - + Connect Warning 連接警告 - + The player name can't be empty. 玩家名稱不能空置 @@ -2146,17 +2607,17 @@ This will kick all non-ready players from the game. 創建遊戲 - + Game information 遊戲資訊 - + Error 出現錯誤 - + Server error. 伺服器出現錯誤. @@ -2164,97 +2625,97 @@ This will kick all non-ready players from the game. DlgCreateToken - + &Name: &名稱: - + Token 令牌 - + C&olor: &顏色: - + white 白色 - + blue 藍色 - + black 黑色 - + red 紅色 - + green 綠色 - + multicolor 多色 - + colorless 無色 - + &P/T: &力量/防御力: - + &Annotation: &註釋: - + &Destroy token when it leaves the table &此令牌離場時會被摧毀 - + Create face-down (Only hides name) - + Token data 令牌資料 - + Show &all tokens 顯示&所有令牌 - + Show tokens from this &deck 顯示本牌庫所有令牌 - + Choose token from list 從清單中選擇令牌 - + Create token 派出令牌 @@ -2288,7 +2749,7 @@ This will kick all non-ready players from the game. - + @@ -2303,12 +2764,12 @@ This will kick all non-ready players from the game. - + Duplicate Tag - + This tag already exists. @@ -2495,12 +2956,12 @@ To remove your current avatar, confirm without choosing a new image. 請輸入令牌名稱: - + Error 出現錯誤 - + The chosen name conflicts with an existing card or token. Make sure to enable the 'Token' set in the "Manage sets" dialog to display them correctly. 現時選擇的名字跟現有的卡牌或令牌出現衝突.請從"牌組管理"中啟動'令牌牌組',保持正確顯示. @@ -2524,12 +2985,12 @@ Make sure to enable the 'Token' set in the "Manage sets" dia 未定義 - + Real name: 實名: - + Edit user profile 更改用戶資料 @@ -2567,113 +3028,113 @@ Make sure to enable the 'Token' set in the "Manage sets" dia 2小時 - + Hide 'buddies only' games - + Hide full games - + Hide games that have started - + Hide password protected games - + Hide 'ignored user' games - + Hide games not created by buddies Hide games not created by buddy - + Hide games with forced open decklists - + &Newer than: &新過: - + Game &description: 遊戲 &描述: - + &Creator name: &創建者名稱: - + General 一般 - + &Game types &遊戲類型 - + at &least: &至少: - + at &most: &最多: - + Maximum player count 可容納玩家最高數量 - + Restrictions 限制 - + Show games only if &spectators can watch 只顯示容許&觀看者 觀看的遊戲 - + Show spectator password p&rotected games 顯示有觀看者&密碼保護的遊戲 - + Show only if spectators can ch&at 只顯示容許觀看者&聊天的遊戲 - + Show only if spectators can see &hands 只顯示容許觀看者看&手牌 的遊戲 - + Spectators 觀看者 - + Filter games 篩選遊戲 @@ -2976,37 +3437,37 @@ https://tappedout.net/mtg-decks/your-deck-name/ DlgMoveTopCardsUntil - + Card name (or search expressions): 卡牌名稱(或搜尋表達式) - + Number of hits: 因為cockatrice原生沒有ssl支援, 所以你不能自動下載更新. 請瀏覽到我們的下載網頁,進行人手更新. - + Auto play hits 開關副牌上鎖 - + Put top cards on stack until... 把頂牌放在重疊區, 直到... - + No cards matching the search expression exists in the card database. Proceed anyways? 在卡牌資料庫中找不到符合搜尋表達式的卡牌.你想繼續嗎? - + Cockatrice Cockatrice - + Invalid filter 篩選無效 @@ -3067,40 +3528,40 @@ Your email will be used to verify your account. 未定義 - + Real name: 實名: - + Register to server 註冊加入伺服器 - - - - + + + + Registration Warning 注册警告 - + Your password is too short. 你的密碼太短。 - + Your passwords do not match, please try again. 兩次輸入的密碼不一致,請重新輸入, - + Your email addresses do not match, please try again. 兩次輸入的電子郵箱不一致,請重新輸入。 - + The player name can't be empty. 玩家名稱不能空置 @@ -3108,17 +3569,17 @@ Your email will be used to verify your account. DlgRollDice - + Number of sides: 骰子面數: - + Number of dice: 骰子數目: - + Roll Dice 擲骰 @@ -3169,12 +3630,12 @@ Your email will be used to verify your account. DlgSettings - + Unknown Error loading card database 讀取卡牌資料庫時出現不知名錯誤 - + Your card database is invalid. Cockatrice may not function correctly with an invalid database @@ -3191,7 +3652,7 @@ Would you like to change your database location setting? 你想要重新設置卡牌資料庫路徑嗎? - + Your card database version is too old. This can cause problems loading card information or images @@ -3208,7 +3669,7 @@ Would you like to change your database location setting? 你想要重新設置卡牌資料庫路徑嗎? - + Your card database did not finish loading Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues with your cards.xml attached @@ -3221,7 +3682,7 @@ Would you like to change your database location setting? 你想要重新設置卡牌資料庫路徑嗎? - + File Error loading your card database. Would you like to change your database location setting? @@ -3229,7 +3690,7 @@ Would you like to change your database location setting? 你想要重新設置卡牌資料庫路徑嗎? - + Your card database was loaded but contains no cards. Would you like to change your database location setting? @@ -3238,7 +3699,7 @@ Would you like to change your database location setting? 你想要重新設置卡牌資料庫路徑嗎? - + Unknown card database load status Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues @@ -3254,59 +3715,64 @@ https://github.com/Cockatrice/Cockatrice/issues 你想不想更改你的資料庫位置設定? - - - + + + Error 出現錯誤 - + The path to your deck directory is invalid. Would you like to go back and set the correct path? 你的牌庫目錄路徑現在無效. 你想要重新設置套牌路徑嗎? - + The path to your card pictures directory is invalid. Would you like to go back and set the correct path? 你的卡牌圖片目錄路徑現在無效. 你想要重新設置卡牌圖片資料庫路徑嗎? - + Settings 設定 - + General 一般 - + Appearance 外觀 - + User Interface 用戶界面 - + + Storage + + + + Card Sources 卡牌來源 - + Chat 聊天 - + Sound 聲音 - + Shortcuts 快捷键 @@ -3398,9 +3864,9 @@ You can always change this behavior in the 'General' settings tab. - - - + + + Error 出現錯誤 @@ -3453,31 +3919,31 @@ Please visit the download page to update manually. - + Update Available 現有更新版本 - + A new version of Cockatrice is available! Cockatrice有新版本可用! - + New version 新版本 - + Released 已發行 - + Changelog 變更記載 @@ -3487,50 +3953,50 @@ Please visit the download page to update manually. 你打算現在更新嗎? - + Unfortunately, the automatic updater failed to find a compatible download. You may have to manually download the new version. - + Please check the <a href="%1">releases page</a> on our Github and download the build for your system. - - - + + + Update Error 更新出現錯誤 - + An error occurred while checking for updates: 檢測更新時出現錯誤: - + An error occurred while downloading an update: 下載更新時出現錯誤 - + Installing... 安裝中 - + Cockatrice is unable to open the installer. Cockatrice 不能啟動安裝程式 - + Try to update manually by closing Cockatrice and running the installer. 請你嘗試人手更新, 關掉cockatrice, 然後重開安裝程式. - + Download location 下載位置 @@ -3620,67 +4086,67 @@ You may have to manually download the new version. DrawProbabilityWidget - + Draw Probability - + Probability of drawing - + Card Name - + Type - + Subtype - + Mana Value - + At least - + Exactly - + card(s) having drawn at least - + cards - + Category - + Qty - + Odds (%) @@ -3794,7 +4260,7 @@ You may have to manually download the new version. FilterBuilder - + Type your filter here 輸入你的篩選類型 @@ -3825,22 +4291,22 @@ You may have to manually download the new version. GameEventHandler - + kicked by game host or moderator - + player left the game - + player disconnected from server - + reason unknown @@ -3848,140 +4314,140 @@ You may have to manually download the new version. GameSelector - - - - - - - - - + + + + + + + + + Error 出現錯誤 - + Please join the appropriate room first. 請先參加合適房間 - + Wrong password. 密碼錯誤. - + Spectators are not allowed in this game. 這個遊戲不允許觀眾加入. - + The game is already full. 這個遊戲已經滿了. - + The game does not exist any more. 這個遊戲已經不存在了。 - + This game is only open to registered users. 這個遊戲只開放給註冊用戶. - + This game is only open to its creator's buddies. 這個遊戲只開放給房間主辦的好友。 - + You are being ignored by the creator of this game. 你被這個房間的主辦屏蔽了. - + Join Game - + Spectate Game - + Game Information - + Join Game as Judge - + Spectate Game as Judge - + Join game 加入遊戲 - + Password: 密碼: - + Please join the respective room first. 請先參加相關的房間. - + &Filter games &篩選遊戲 - + C&lear filter &清除篩選項目 - + C&reate &創建 - + &Join &加入 - + Join as judge - + J&oin as spectator &作為觀看者加入 - + Join as judge spectator - + Games shown: %1 / %2 顯示遊戲:%1/%2 - + Games 遊戲 @@ -3989,32 +4455,32 @@ You may have to manually download the new version. GameSelectorQuickFilterToolBar - + All types - + Filter by game name... - + Filter by game type/format - + Hide games not created by buddies - + Hide full games - + Hide started games @@ -4022,105 +4488,105 @@ You may have to manually download the new version. GamesModel - + >1 day >1日 - + %1%2 hr short age in hours %1%2 小時 - + new 新建 - + %1%2 min short age in minutes %1%2 分鐘 - + password 密碼 - + buddies only 僅限好友 - + reg. users only 僅限註冊用户 - + open decklists - + can chat 允許聊天 - + see hands 查看手牌 - + can see hands 允許查看手牌 - + not allowed 不允許 - + Room 房間 - + Age 時長 - + Description 描述: - + Creator 創建者 - + Type 類型 - + Restrictions 限制 - + Players 玩家 - + Spectators 觀看者 @@ -4128,143 +4594,158 @@ You may have to manually download the new version. GeneralSettingsPage - + Reset all paths 重置所有路徑 - + All paths have been reset 重置所有路徑 - - - - - - - + + + + + + + Choose path 選擇路徑 - - Personal settings - 個人設定 + + Language settings + - + Language: 語言: - + + Version settings + + + + + Card database + + + + + Startup settings + + + + Paths (editing disabled in portable mode) 路徑(在便攜模式下不能編輯) - + Paths 路徑 - + How to help with translations - + Decks directory: 牌庫路徑: - + Filters directory: - + Replays directory: 遊戲錄像目錄: - + Pictures directory: 圖片目錄: - + Card database: 卡牌資料庫: - + Custom database directory: 自主資料庫目錄 - + Token database: 令牌資料庫: - + Update channel 更新渠道 - + Check for client updates on startup 啟動時檢查客戶更新 - + Check for card database updates on startup - + Don't check - + Prompt for update - + Always update in the background - + Check for card database updates every - + days - + Notify if a feature supported by the server is missing in my client 如果客戶端缺少伺服器支持的更功能時,請提示我 - + Automatically run Oracle when running a new version of Cockatrice 啟動新版本Cockatrice時,自動運行Oracle. - + Show tips on startup 顯示啓動時提示 - + Last update check on %1 (%2 days ago) @@ -4272,47 +4753,47 @@ You may have to manually download the new version. GraveyardMenu - + &Graveyard - + &View graveyard - + &Move graveyard to... - + &Top of library - + &Bottom of library - + &All players - + &Hand - + &Exile - + Reveal random card to... @@ -4320,88 +4801,88 @@ You may have to manually download the new version. HandMenu - + &Hand - + &View hand - + Sort hand by... - + Name - + Type - + Mana Value - + Take &mulligan (Choose hand size) - + Take mulligan (Same hand size) - + Take mulligan (Hand size - 1) - + &Move hand to... - + &Top of library - + &Bottom of library - + &Graveyard - + &Exile - + &Reveal hand to... - - + + All players - + Reveal r&andom card to... @@ -4409,52 +4890,52 @@ You may have to manually download the new version. HomeWidget - + Create New Deck - + Browse Decks - + Browse Card Database - + Browse EDHRec - + Browse Archidekt - + View Replays - + Quit - + Connecting... - + Connect - + Play @@ -4462,213 +4943,213 @@ You may have to manually download the new version. LibraryMenu - + &Library - + &View library - + View &top cards of library... - + View bottom cards of library... - + Reveal &library to... - + Lend library to... - + Reveal &top cards to... - + &Top of library... - + &Bottom of library... - + &Always reveal top card - + &Always look at top card - + &Open deck in deck editor - + &Draw card - + D&raw cards... - + &Undo last draw - + Shuffle - + &Play top card - + Play top card &face down - + Put top card on &bottom - + Move top card to grave&yard - + Move top card to e&xile - + Move top cards to &graveyard... - + Move top cards to graveyard face down... - + Move top cards to &exile... - + Move top cards to exile face down... - + Put top cards on stack &until... - + Shuffle top cards... - + &Draw bottom card - + D&raw bottom cards... - + &Play bottom card - + Play bottom card &face down - + Move bottom card to grave&yard - + Move bottom card to e&xile - + Move bottom cards to &graveyard... - + Move bottom cards to graveyard face down... - + Move bottom cards to &exile... - + Move bottom cards to exile face down... - + Put bottom card on &top - + Shuffle bottom cards... - - + + &All players - + Reveal top cards of library - + Number of cards: (max. %1) @@ -4676,633 +5157,273 @@ You may have to manually download the new version. MainWindow - - - The server has reached its maximum user capacity, please check back later. - 伺服器已達到最最高用戶限額,請另外選擇吉時再試. - - - - There are too many concurrent connections from your address. - 你的網路地址擁有太多同時連繫. - - - - Banned by moderator - 你已被版主禁用 - - - - Expected end time: %1 - 預計結束時間: %1 - - - - This ban lasts indefinitely. - 這是無限期禁用 - - - - Scheduled server shutdown. - 排期伺服器關機 - - - - - Invalid username. - 不可用的用戶名。 - - - - You have been logged out due to logging in at another location. - 因為你已在另外的位置登入, 所以你現有的聯繫已被登出. - - - - Connection closed - 連繫已被關閉 - - - - The server has terminated your connection. -Reason: %1 - 伺服器已經中斷你的聯繫/ -原因: %1 - - - - The server is going to be restarted in %n minute(s). -All running games will be lost. -Reason for shutdown: %1 - 伺服器將會在 %n 分鐘內重新啟動. 所有進行中的遊戲都會報銷.伺服器關閉原因: %1 - - - - Scheduled server shutdown - 排期伺服器關機 - - - - - Success - 成功 - - - - Registration accepted. -Will now login. - 註冊已成功。 -現在登入。 - - - - Account activation accepted. -Will now login. - 戶口啟動已經成功 -現在登入. - - - - + + Player %1 玩家 %1 - + Load replay 載入遊戲錄像 - + About Cockatrice 關於Cockatrice - + Version 版本 - + Cockatrice Webpage Cockatrice 網頁 - + Project Manager: 項目經理: - + Past Project Managers: 前項目經理: - + Developers: 開發者: - + Our Developers 我們的開發者: - + Help Develop! 協助開發! - + Translators: 翻譯者: - + Our Translators 翻譯者名單 - + Help Translate! 協助翻譯! - + Support: 支援: - + Report an Issue 報告問題 - + Troubleshooting 排除故障 - + F.A.Q. 問答 - - - - - - - - - - - - - - - - - - - - + + Error 出現錯誤 - - Server timeout - 伺服器超時 - - - - Failed Login - 登入失敗 - - - - Your client seems to be missing features this server requires for connection. - 你的客戶端似乎脫小本伺服器需要用來達成連繫的功能. - - - - To update your client, go to 'Help -> Check for Client Updates'. - 請去"求助->檢查客戶端更新" 內更新你的客戶. - - - - Incorrect username or password. Please check your authentication information and try again. - 用户名或密碼錯誤。請檢查賬號信息,然後重試。 - - - - There is already an active session using this user name. -Please close that session first and re-login. - 已經有在線用户正在使用這用户名.請先關閉連線,然後重新登入. - - - - - You are banned until %1. - 你被禁止直到: %1. - - - - - You are banned indefinitely. - 你已被永久封禁. - - - - This server requires user registration. Do you want to register now? - 本伺服器要求用戶註冊. 你想不想現在登記? - - - - This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. -Please close and reopen your client to try again. - 本伺服器要求用戶擁有客戶id. 你的客戶端現在不能造成客戶id,或是你正在使用修改過的客戶端. -請你關掉現有的客戶端,然後重新啟動, 擇吉日再試。 - - - - An internal error has occurred, please close and reopen Cockatrice before trying again. -If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. - 因為內部出現故障, 請關閉,重開Cockatrice 然後再次嘗試. -如果故障繼續出現,請將客戶端軟件升級為最新版本, 有需要時請聯絡軟件開發人士. - - - - Account activation - 戶口啟動 - - - - Your account has not been activated yet. -You need to provide the activation token received in the activation email. - 你的戶口還未嘗啟動. -你需要提供從啟動電郵得來的驗證碼. - - - - Server Full - 伺服器載滿 - - - - Unknown login error: %1 - 不知名登入錯誤: %1 - - - - - -This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. - -這通常意味住你的客戶端版本已經過期,引起你的客戶端不能明曉伺服器的回覆. - - - - Your username must respect these rules: - 您的用戶名字必須遵守以下規則: - - - - is %1 - %2 characters long - 在%1 - %2个字符之间。 - - - - can %1 contain lowercase characters - 可以 $1 包含細楷/小寫字母 - - - - - - - NOT - 並否 - - - - can %1 contain uppercase characters - 可以 $1 包含大楷/大寫字母 - - - - can %1 contain numeric characters - 可以 $1 包含數字 - - - - can contain the following punctuation: %1 - 可以包含以下符號:%1 - - - - first character can %1 be a punctuation mark - 第一個字符可以 %1 是標點符號 - - - - no unacceptable language as specified by these server rules: - note that the following lines will not be translated - 在以下伺服器規矩中,道明不能接受的語言: - - - - can not contain any of the following words: %1 - 不能含有以下字眼: %1 - - - - can not match any of the following expressions: %1 - 不能含有以下語句: %1 - - - - You may only use A-Z, a-z, 0-9, _, ., and - in your username. - 你的用戶名字只可以用以下的字符: A-Z, a-z, 0-9, _, ., 和 - . - - - - - - - - - Registration denied - 註冊被拒絕 - - - - Registration is currently disabled on this server - 這伺服器暫時謝絕登記 - - - - There is already an existing account with the same user name. - 這個用戶名字已經被使用. - - - - It's mandatory to specify a valid email address when registering. - 登記時必要指明合能用的電郵地址. - - - - It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. - 你看來好像在本伺服器嘗試申請新戶口, 但是你已經有跟你電郵掛鉤的戶口名字.我們的伺服器限制每用戶在每個地址可以有多少個戶口. 如果需要更多援助或是得取你的登入資料,請聯絡伺服器辦事人. - - - - Password too short. - 密碼太短。 - - - - Registration failed for a technical problem on the server. - 由於伺服器出現技術問題, 你的登記暫時失敗. - - - - The connection to the server has been lost. - - - - - Unknown registration error: %1 - 不知名登記錯誤: %1 - - - - Account activation failed - 戶口啟動失敗 - - - - Socket error: %1 - 接口錯誤: %1 - - - - You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. -Local version is %1, remote version is %2. - 你正在嘗試跟過時的伺服器連繫. 請把你的 Cockatrice 版本降級,或是連繫於合適的伺服器. 當地/離線版本維 %1, 遙距/上線版本維 %2. - - - - Your Cockatrice client is obsolete. Please update your Cockatrice version. -Local version is %1, remote version is %2. - 你的 Cockatrice 版本現已過時.請把你的版本更新. -當地/離線版本維 %1, 遙距/上線版本維 %2. - - - - Connecting to %1... - 向 %1 連接中... - - - - Registering to %1 as %2... - 正在用%2的身份登記到%1伺服器... - - - - Disconnected - 已經斷線了 - - - - Connected, logging in at %1 - 連線成功, 在%1登入 - - - - - - Requesting forgotten password to %1 as %2... - 正在用%2的身份對%1要求遺忘密碼. - - - + &Connect... &連線... - + &Disconnect &斷線 - + Start &local game... 開始&離線遊戲 - + &Watch replay... &觀看遊戲錄像 - + &Full screen &全屏 - + &Register to server... &註冊加入伺服器 - + &Restore password... &弄回密碼 - + &Settings... 設置⋯ - + &Exit &退出 - + A&ctions &動作 - + &Cockatrice &Cockatrice - + C&ard Database &卡牌資料庫 - + &Manage sets... &管理牌組 - + Edit custom &tokens... 編輯自定 &令牌... - + Open custom image folder 打開自定圖片文件夾 - + Open custom sets folder 打開自定牌组文件夾 - + Add custom sets/cards 添加自定卡组/卡牌 - + Reload card database 刷新卡牌資料庫 - + Tabs - + &Help &求助 - + &About Cockatrice &關於Cockatrice - + &Tip of the Day &每日提示 - + Check for Client Updates 檢查客戶端更新 - + Check for Card Updates... 檢查卡牌更新 - + Check for Card Updates (Automatic) - + Show Status Bar - + View &Debug Log 查看&偵錯紀錄 - + Open Settings Folder 打開設置文件夾 - + Show/Hide 顯示/隱藏 - + New Version 新版本 - + Congratulations on updating to Cockatrice %1! Oracle will now launch to update your card database. 恭喜你將Cockatrice升級為%1. 現在會幫你啟動oracle, 更新你的卡牌資料庫. - + Cockatrice installed Cockatrice 已被安裝 - + Congratulations on installing Cockatrice %1! Oracle will now launch to install the initial card database. 恭喜你成功安裝Cockatrice%1. 現在會幫你啟動oracle, 安裝你的卡牌資料庫. - + Card database 卡牌資料庫 - + Cockatrice is unable to load the card database. Do you want to update your card database now? If unsure or first time user, choose "Yes" @@ -5311,29 +5432,29 @@ If unsure or first time user, choose "Yes" 如果不確定, 或者是第一次使用,請選擇“是/Yes” - - + + Yes 是/Yes - - + + No 否/No - + Open settings 打開設置 - + New sets found 發現新卡组 - + %n new set(s) found in the card database Set code(s): %1 Do you want to enable it/them? @@ -5342,17 +5463,22 @@ Do you want to enable it/them? 要啟用它們嗎? - + + Yes, always enable + + + + View sets 查看卡组 - + Welcome 歡迎 - + Hi! It seems like you're running this version of Cockatrice for the first time. All the sets in the card database have been enabled. Read more about changing the set order or disabling specific sets and consequent effects in the "Manage Sets" dialog. @@ -5361,166 +5487,131 @@ Read more about changing the set order or disabling specific sets and consequent 請閱讀,了解更多在“卡组设置”對話框裡.更改卡组顺序或者禁用某些卡组的功能. - - + Information 資訊 - + A card database update is already running. 資料庫更新現已進行. - + Unable to run the card database updater: 不能運行卡牌資料附更新程式: - + Card database update running. - + Failed to start. The file might be missing, or permissions might be incorrect. - + The process crashed some time after starting successfully. - + Timed out. The process took too long to respond. The last waitFor...() function timed out. - + An error occurred when attempting to write to the process. For example, the process may not be running, or it may have closed its input channel. - + An error occurred when attempting to read from the process. For example, the process may not be running. - + Unknown error occurred. - + The card database updater exited with an error: %1 - - This server supports additional features that your client doesn't have. -This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. - -To update your client, go to Help -> Check for Updates. - 本伺服器支持你現在客戶端軟件欠缺的額外功能. - -這信息不是大問題, 但此信息可能意會現在已有新版本的 Cockatrice, 或是本伺服器運用自設或預先釋放版本. - -請到"援助->檢查更新"去更新你的客戶端. - - - - - - - + + + + + Load sets/cards 载入卡组/卡牌 - + Selected file cannot be found. 找不到選擇的文件。 - + You can only import XML databases at this time. 你暫時只能導入xml資料庫. - + The new sets/cards have been added successfully. Cockatrice will now reload the card database. 已經成功添加新的卡組/卡牌. Cockatrice會重新載入卡牌資料庫. - + Sets/cards failed to import. 卡組/卡牌導入失敗. - - - - - Reset Password - 重設密碼 - - - - Your password has been reset successfully, you can now log in using the new credentials. - 你的密碼已經成功重置, 你可以用新密碼重新登入。 - - - - Failed to reset user account password, please contact the server operator to reset your password. - 重置用戶密碼失敗, 請聯絡伺服器管責人重置密碼. - - - - Activation request received, please check your email for an activation token. - 已經收到啟動要求, 請從你的電郵獲取啟動驗證碼. - ManaBaseConfigDialog - + Mana Base Configuration - + Display type: - + pie - + bar - + combinedBar - + Filter Colors (optional): - + OK - + Cancel @@ -5592,27 +5683,27 @@ Cockatrice will now reload the card database. ManaDevotionConfigDialog - + Display type: - + pie - + bar - + combinedBar - + Filter Colors (optional): @@ -5682,610 +5773,615 @@ Cockatrice will now reload the card database. MessageLogWidget - + from play 從戰場上 - + from their graveyard 從它們的墳場 - + from exile 從放逐區 - + from their hand 從它們的手牌 - + the top card of %1's library %1牌庫的頂牌 - + the top card of their library 它們的頂牌 - + from the top of %1's library 從%1牌庫的頂 - + from the top of their library 從它們1牌庫頂 - + the bottom card of %1's library %1牌庫的底牌 - + the bottom card of their library 它們牌庫的底牌 - + from the bottom of %1's library 從%1的牌庫底 - + from the bottom of their library 從它們的牌庫底 - + from %1's library 從%1的牌庫 - + from their library 從它們的牌庫 - + from sideboard 從備牌中 - + from the stack 從疊區中 - + from custom zone '%1' - + %1 is now keeping the top card %2 revealed. %1現在保持%2頂牌顯現. - + %1 is not revealing the top card %2 any longer. %1不再展示牌庫頂牌%2。 - + %1 can now look at top card %2 at any time. %1可以隨時查看牌庫頂牌 %2. - + %1 no longer can look at top card %2 at any time. %1再不可以隨時查看牌庫頂牌 %2. - + %1 attaches %2 to %3's %4. %1 結附 %2 指向 %3的 %4. - + %1 has conceded the game. %1 在此遊戲宣佈降服. - + %1 has unconceded the game. %1 收回在此遊戲降服. - + %1 has restored connection to the game. %1已恢復連接。 - + %1 has lost connection to the game. %1 已失去連接。 - + %1 points from their %2 to themselves. %1 從它們的%2 指向自己。 - + %1 points from their %2 to %3. %1 從它們的%2指向%3。 - + %1 points from %2's %3 to themselves. %1 從%2的%3 指向自己。 - + %1 points from %2's %3 to %4. %1 從%2的%3 指向%4。 - + %1 points from their %2 to their %3. %1 從它們的%2指向它們的%3。 - + %1 points from their %2 to %3's %4. %1 從它們的%2指向%3的%4。 - + %1 points from %2's %3 to their own %4. %1 從%2的%3指向自己的%4。 - + %1 points from %2's %3 to %4's %5. %1 從%2的%3指向%4的%5。 - + %1 creates a face down token. - + %1 creates token: %2%3. %1 派出令牌: %2%3 - + %1 has loaded a deck (%2). %1 已载入牌庫(%2) - + %1 has loaded a deck with %2 sideboard cards (%3). %1已載入含有 %2張備牌的牌庫(%3). - + %1 destroys %2. %1销毁了%2。 - + a card 一張牌 - + %1 gives %2 control over %3. %1將%3的控制轉移給%2. - + %1 puts %2 into play%3 face down. %1將%2%3牌面朝下地放進戰場。 - + %1 puts %2 into play%3. %1將%2放進戰場%3。 - + %1 puts %2%3 into their graveyard face down. - + %1 puts %2%3 into their graveyard. %1將%2%3置入它們的的墳場。 - + %1 exiles %2%3 face down. - + %1 exiles %2%3. %1 放逐%2%3. - + %1 moves %2%3 to their hand. %1將%2%3移到它們的的手牌中。 - + %1 puts %2%3 into their library. %1將%2%3放入它們的牌庫。 - + %1 puts %2%3 onto the bottom of their library. %1將%2%3放在它們的牌庫底部。 - + %1 puts %2%3 on top of their library. %1將%2%3放在它們的牌庫頂部。 - + %1 puts %2%3 into their library %4 cards from the top. %1將%2%3放在它們的牌庫頂部%4張牌以下。 - + %1 moves %2%3 to sideboard. %1 將%2%3移到副牌庫. - + %1 plays %2%3 face down. - + %1 plays %2%3. %1使出 %2%3。 - + %1 moves %2%3 to custom zone '%4' face down. - + %1 moves %2%3 to custom zone '%4'. - + %1 tries to draw from an empty library %1嘗試從空牌庫抽牌 - + %1 draws %2 card(s). %1抽%2張牌。 - + %1 is looking at %2. %1 正在查看%2。 - + %1 is looking at the %4 %3 card(s) %2. top card for singular, top %3 cards for plural %1 正在查看%2的 %4 %3張卡牌. - + bottom 底部 - + top 頂部 - + %1 turns %2 face-down. %1把%2翻為面朝下。 - + %1 turns %2 face-up. %1把%2翻為面朝上。 - + The game has been closed. 遊戲已經關閉。 - + The game has started. 遊戲已開始。 - + You are flooding the game. Please wait a couple of seconds. - + %1 has joined the game. %1 已經加入遊戲。 - + %1 is now watching the game. %1 正在旁觀遊戲。 - + You have been kicked out of the game. 你已被踢出遊戲。 - + %1 has left the game (%2). %1 已經離開遊戲(%2)。 - + %1 is not watching the game any more (%2). %1 已經不再旁觀遊戲(%2)。 - + %1 is not ready to start the game any more. %1 還未準備好開始遊戲。 - + %1 shuffles their deck and draws a new hand of %2 card(s). %1將其牌庫洗牌,然後抽%2張牌作為新的起手。 - + %1 shuffles their deck and draws a new hand. %1將其牌庫洗牌,然後抽起新的手牌。 - + You are watching a replay of game #%1. 你正在觀看遊戲#%1的錄像。 - + %1 is ready to start the game. %1已準備好開始遊戲。 - + cards an unknown amount of cards 卡牌(眾)  - + %1 card(s) a card for singular, %1 cards for plural %1 卡牌(眾)  - + %1 lends %2 to %3. %1把&2借給%3. - + %1 reveals %2 to %3. %1把%2展示給%3. - + %1 reveals %2. %1 展示 %2。 - + %1 randomly reveals %2%3 to %4. %1隨機展示%2%3給%4。 - + %1 randomly reveals %2%3. %1隨機展示%2%3。 - + %1 peeks at face down card #%2. %1查看面朝下的卡牌#%2。 - + %1 peeks at face down card #%2: %3. %1查看面朝下的卡牌#%2:%3。 - + %1 reveals %2%3 to %4. %1展示%2%3給%4。 - + %1 reveals %2%3. %1展示%2%3。 - + %1 reversed turn order, now it's %2. %1 逆轉回合次序,現在輪到%2。 - + reversed 逆轉 - + normal 正常 - + Heads 正面 - + Tails 翻面 - + %1 flipped a coin. It landed as %2. %1擲了硬幣。結果為%2。 - + %1 rolls a %2 with a %3-sided die. %1擲%3面骰子,結果為%2。 - + %1 flips %2 coins. There are %3 heads and %4 tails. %1擲了%2個硬幣。結果為%3個正面和%4個反面。 - + %1 rolls a %2-sided dice %3 times: %4. %1把%2面骰子擲了%3次:%4 - + %1's turn. 輪到 %1. - + %1 sets annotation of %2 to %3. %1給%2添加了註釋成%3。 - - %1 places %2 "%3" counter(s) on %4 (now %5). + + %1 places %2 %3%4 counter(s) on %5 (now %6). - - %1 removes %2 "%3" counter(s) from %4 (now %5). + + %1 removes %2 %3%4 counter(s) from %5 (now %6). - + + %1 failed to undo their last draw. + + + + %1 sets counter %2 to %3 (%4%5). %1 將%2指示物設置為 %3 (%4%5)。 - + %1 sets %2 to not untap normally. %1 將 %2 設置為不被重置。 - + %1 sets %2 to untap normally. %1 將 %2 設置為照常重置。 - + %1 removes the PT of %2. %1 移除%2的力量/防禦. - + %1 changes the PT of %2 from nothing to %4. %1把%2的力量/防禦從無數值轉為%4. - + %1 changes the PT of %2 from %3 to %4. %1把%2的力量/防禦從%3轉為%4. - + %1 has locked their sideboard. %1已經鎖定它們的的副牌庫。 - + %1 has unlocked their sideboard. %1已經把它們的的副牌庫解鎖。 - + %1 taps their permanents. %1横置它們的永久卡牌 - + %1 untaps their permanents. %1重置它們的永久卡牌. - + %1 taps %2. %1 横置 %2. - + %1 untaps %2. %1 重置 %2。 - + %1 shuffles %2. % 1 對 %2 洗牌 - + %1 shuffles the bottom %3 cards of %2. %1 洗 %2 底的 %3 張卡牌. - + %1 shuffles the top %3 cards of %2. %1 洗 %2 頂的 %3 張卡牌. - + %1 shuffles cards %3 - %4 of %2. %1到%2.洗%3 - %4 - + %1 unattaches %2. %1 取消了%2的結附。 - + %1 undoes their last draw. %1 放回它們最後一張抽到的卡牌. - + %1 undoes their last draw (%2). %1 放回它們最後一張抽到的卡牌(%2). @@ -6293,110 +6389,115 @@ Cockatrice will now reload the card database. MessagesSettingsPage - + Word1 Word2 Word3 詞1 詞2 詞3 - + Add New Message 添加新信息 - + Edit Message 更改信息 - + Remove Message 移除信息 - + Add message 添加信息 - - + + Message: 信息: - + Edit message 更改信息 - + Chat settings 聊天設定 - + Custom alert words 自定通知觸發詞語 - + Enable chat mentions 允許聊天中提到某人 - + Enable mention completer 允許自動完成提名 - + In-game message macros 遊戲內短信速鍵 - + How to use in-game message macros 如何使用遊戲內短信速鍵 - + Ignore chat room messages sent by unregistered users 不要顯示未註冊用户的聊天消息。 - + Ignore private messages sent by unregistered users 不要顯示未註冊用户的私人消息. - - + + Ignore private messages sent by non-buddy users + + + + + Invert text color 反轉文本顏色 - + Enable desktop notifications for private messages 容許桌面提醒提及私人消息 - + Enable desktop notification for mentions 容許桌面提醒提及聊天提名 - + Enable room message history on join 加入聊天室時開啓消息歷史 - - + + (Color is hexadecimal) (顏色為16進制) - + Separate words with a space, alphanumeric characters only 將單詞以空格區分,僅支持字母. @@ -6404,42 +6505,42 @@ Cockatrice will now reload the card database. MoveMenu - + Move to - + &Top of library in random order - + X cards from the top of library... - + &Bottom of library in random order - + T&able - + &Hand - + &Graveyard - + &Exile @@ -6452,47 +6553,47 @@ Cockatrice will now reload the card database. 卡牌類型 - + Mana Value 法力數值 - + Color(s) 颜色(眾)  - + Loyalty 忠誠值 - + Main Card Type 卡牌主類型 - + Mana Cost 法力費用 - + P/T 力量/防御力 - + Side 正面/底面 - + Layout 佈局 - + Color Identity 顏色身份 @@ -6515,6 +6616,152 @@ Cockatrice will now reload the card database. 重疊方向 + + PaletteEditorDialog + + + + Reset + + + + + + Apply + + + + + + Save && Apply + + + + + ▼ Edit Palette + + + + + + ▶ Edit Palette + + + + + Palette Editor — %1 + + + + + <b>Palette Editor</b> &nbsp;·&nbsp; %1 + + + + + This theme ships no default palette files + + + + + Replace current colours with the theme author's defaults + + + + + Switch between the light and dark palette files + + + + + Editing: + + + + + Show or hide the per-role colour grid for manual tweaks + + + + + ↺ Revert to theme default + + + + + Discard unsaved edits and restore the last saved palette + + + + + Preview this palette without saving to disk + + + + + Write palette-%1.toml and reload the theme + + + + + Cannot save: this theme has no directory on disk + + + + + Save failed + + + + + Could not write %1 to: +%2 + + + + + No default found + + + + + No default palette file found for the "%1" scheme. + + + + + PaletteGridWidget + + + Active + + + + + Disabled + + + + + Inactive + + + + + Normal interactive state + + + + + Widget is disabled / not interactive + + + + + Window is in background / unfocused + + + Phase @@ -6581,57 +6828,57 @@ Cockatrice will now reload the card database. PhasesToolbar - + Untap step 重置步 - + Upkeep step 維持步 - + Draw step 抓牌步 - + First main phase 第一主階段 - + Beginning of combat step 戰鬥開始步 - + Declare attackers step 宣告阻擋步 - + Declare blockers step 宣告阻擋步 - + Combat damage step 戰鬥損害步 - + End of combat step 戰鬥結束步 - + Second main phase 第二主階段 - + End of turn step 回合完結部 @@ -6639,7 +6886,7 @@ Cockatrice will now reload the card database. PictureLoader - + en code for scryfall's language property, not available for all languages @@ -6648,151 +6895,142 @@ Cockatrice will now reload the card database. PlayerActions - - View top cards of library - - - - - - - - - - - - - Number of cards: (max. %1) - - - - - View bottom cards of library - - - - - Shuffle top cards of library - - - - - Shuffle bottom cards of library - - - - - Draw hand - - - - - 0 and lower are in comparison to current hand size - - - - - Draw cards - - - - - - - + + + + grave - - - - + + + + exile + + + PlayerDialogs - + + View top cards of library + + + + + + + + + + + + + Number of cards: (max. %1) + + + + + View bottom cards of library + + + + + Shuffle top cards of library + + + + + Shuffle bottom cards of library + + + + + Draw hand + + + + + 0 and lower are in comparison to current hand size + + + + + Draw cards + + + + Move top cards to %1 - + Move bottom cards to %1 - + Draw bottom cards - - - C&reate another %1 token - - - - + Create tokens - - + Number: - + Place card X cards from top of library - + Which position should this card be placed: - + (max. %1) - + Change power/toughness - + Change stats to: - + Set annotation - + Please enter the new annotation: - - - Set counters - - PlayerMenu - + Player "%1" - + &Counters @@ -6822,7 +7060,7 @@ This setting means you'll only see the default printing for each card, instead o - + Printing Selector @@ -6830,22 +7068,22 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelectorCardOverlayWidget - + Preference 喜好 - + Pin Printing 版本上別針 - + Unpin Printing 版本脫別針 - + Show Related cards 顯示關聯卡牌 @@ -6916,57 +7154,57 @@ This setting means you'll only see the default printing for each card, instead o PtMenu - + Power / toughness - + &Increase power - + &Decrease power - + I&ncrease toughness - + D&ecrease toughness - + In&crease power and toughness - + Dec&rease power and toughness - + Increase power and decrease toughness - + Decrease power and increase toughness - + Set &power and toughness... - + Reset p&ower and toughness @@ -6974,37 +7212,37 @@ This setting means you'll only see the default printing for each card, instead o QMenuBar - + Services 服務 - + Hide %1 隐藏 %1 - + Hide Others 隱藏其他 - + Show All 全部展示 - + Preferences... 喜好... - + Quit %1 退出%1 - + About %1 關於 %1 @@ -7012,17 +7250,17 @@ This setting means you'll only see the default printing for each card, instead o QObject - + Cockatrice card database (*.xml) Cockatrice卡牌資料庫 (*.xml) - + All files (*.*) 所有檔案格式 (*.*) - + Cockatrice replays (*.cor) Cockatrice錄像文件 (*.cor) @@ -7082,110 +7320,177 @@ Are you sure you would like to disable this feature? QPlatformTheme - + OK OK/確認 - + Save 保存 - + Save All 全部保存 - + Open 打開 - + &Yes &是/Yes - + Yes to &All &全選是/Yes - + &No &否/No - + N&o to All &全選否/No - + Abort 退出 - + Retry 重試 - + Ignore 不理 - + Close 關閉 - + Cancel 取消 - + Discard 棄牌 - + Help 協助 - + Apply 應用 - + Reset 重設 - + Restore Defaults 恢復常用認值 + + QuickSetupPanel + + + %1% + + + + + <b>Quick Setup</b> + + + + + Generate all palette roles automatically from a single accent colour + + + + + Accent: + + + + + Primary hue. Used directly for highlights and links. +At high intensity it also tints buttons and backgrounds. + + + + + Intensity: + + + + + Subtle + + + + + Full colour + + + + + 0–30 Subtle tint — only highlights and links change hue +30–70 Accented — buttons, tooltips, and borders join in +70–100 Full colour — backgrounds, everything + + + + + 70% + + + + + Generate ↓ + + + + + Derive all palette roles from the accent colour above. +Fine-tune individual colours in the grid afterwards. + + + RemoteDeckList_TreeModel - + Name 名字 - + ID ID - + Upload time 上載時間 @@ -7193,32 +7498,32 @@ Are you sure you would like to disable this feature? RemoteReplayList_TreeModel - + ID ID - + Name 名字 - + Players 玩家(眾) - + Keep 選用 - + Time started 開始時間 - + Duration (sec) 維持(秒) @@ -7226,37 +7531,37 @@ Are you sure you would like to disable this feature? RfgMenu - + &Exile - + &View exile - + &Move exile to... - + &Top of library - + &Bottom of library - + &Hand - + &Graveyard @@ -7302,7 +7607,7 @@ Are you sure you would like to disable this feature? SayMenu - + S&ay @@ -7343,27 +7648,27 @@ Are you sure you would like to disable this feature? SetsModel - + Enabled 已啓用 - + Set type 設置類型 - + Set code 設置代碼 - + Long name 長名 - + Release date 發行日期 @@ -7371,53 +7676,53 @@ Are you sure you would like to disable this feature? ShortcutSettingsPage - - + + Restore all default shortcuts 恢復所有常用快鍵 - + Do you really want to restore all default shortcuts? 確定要恢復所有常用快鍵嗎? - + Clear all default shortcuts 清除所有常用快鍵 - + Do you really want to clear all shortcuts? 確認清除所有快鍵嗎? - + Section: 部分: - + Action: 行動: - + Shortcut: 快鍵: - + How to set custom shortcuts 如何設置自定快鍵 - + Clear all shortcuts 清除所有快键 - + Search by shortcut name 按快鍵名稱搜尋 @@ -7473,12 +7778,12 @@ Please check your shortcut settings! SideboardMenu - + &Sideboard - + &View sideboard @@ -7486,27 +7791,27 @@ Please check your shortcut settings! SoundSettingsPage - + Enable &sounds 啓用&聲效 - + Current sounds theme: 現前聲效主題: - + Test system sound engine 測試系統聲效引擎 - + Sound settings 聲效設置 - + Master volume 主音量 @@ -7578,16 +7883,146 @@ Please check your shortcut settings! 從發佈更新伺服器收不到有效的回覆. - + No reply received from the tag update server. 未收到標籤更新伺服器的回應. - + Invalid reply received from the tag update server. 從標籤更新伺服器收不到有效的回覆. + + StorageSettingsPage + + + + + Success + + + + + Cached card pictures have been reset. + + + + + Downloaded card pictures have been reset. + + + + + Error + + + + + One or more downloaded card pictures could not be cleared. + + + + + In-memory (currently loaded) card pictures have been reset. + + + + + Card Picture Loader Caching Method: + + + + + The network cache is the preferred way of storing images. Downloaded images are stored here until the size of the cache exceeds the configured size. Cockatrice automatically monitors this cache and deletes the least recently seen card images to ensure the cache does not exceed the configured size. + + + + + Writing card images directly to a folder on your hard drive is another way of storing images. This does not change how Cockatrice accesses or downloads images. Cockatrice will NOT automatically monitor and clear this folder, so if you enable this option, it is up to you to ensure sufficient available space. It should also be noted that if a provider outage causes you to download the wrong picture (i.e. wrong printing) you will be stuck with it until you manually delete the file, as opposed to using the network cache, which automatically rotates and thus correct errors after a while. + + + + + This is the in-memory picture cache used by the application at runtime. It determines how much memory (RAM) Cockatrice can use before it has to fetch card images from the hard disk again. Increasing this will allow more card images to be displayed at once but shouldn't be necessary. Clearing this will make Cockatrice reload all images from the network cache or the disk. + + + + + Delete Cached Images + + + + + Delete Saved Images + + + + + Clear In-Memory Images + + + + + Card Picture Loader Cache Method + + + + + Network Cache + + + + + Filesystem + + + + + In-Memory Picture Cache + + + + + Network Cache Size: + + + + + On-disk cache for downloaded pictures + + + + + Redirect Cache TTL: + + + + + How long cached redirects for urls are valid for. + + + + + Picture Cache Size: + + + + + In-memory cache for pictures not currently on screen + + + + + Naming scheme: + + + + + Day(s) + + + TabAccount @@ -7723,117 +8158,117 @@ Please check your shortcut settings! TabArchidekt - - + + Desc. - - + + AND - - + + Require ALL selected colors - - + + Deck name... - - + + Owner... - - + + Packages - - + + Advanced Filters - + Bracket: - - + + Any - - + + Contains card... - - + + Commander... - - + + Tag... - - + + Deck Size - + Cards: - - + + Asc. - + Sort by: - + Filter by: - + Display Settings - - + + Search - - + + Formats @@ -7843,6 +8278,54 @@ Please check your shortcut settings! + + TabCardArtRules + + + Card: + + + + + ProviderId: + + + + + Mode: + + + + + Reason: + + + + + Type a card name... + + + + + Add rule + + + + + Remove rule + + + + + Refresh + + + + + Card Art Rules + + + TabDeckEditor @@ -7891,7 +8374,7 @@ Please check your shortcut settings! 重置布局 - + Deck: %1 牌庫:%1 @@ -7899,55 +8382,49 @@ Please check your shortcut settings! TabDeckEditorVisual - + Visual Deck: %1 - + &Visual Deck Editor - - + + Card Info - - + + Deck - - - Filters - - - - + &View - + Printing - + Visible - + Floating - + Reset layout @@ -7955,22 +8432,22 @@ Please check your shortcut settings! TabDeckEditorVisualTabWidget - + Visual Deck View - + Visual Database Display - + Deck Analytics - + Sample Hand @@ -7978,133 +8455,133 @@ Please check your shortcut settings! TabDeckStorage - + Local file system 本地檔案系統 - + Server deck storage 伺服器牌庫儲藏 - - + + Open in deck editor 在套牌編輯打開 - + Rename deck or folder - + Upload deck 上載牌庫 - + Download deck 下载套牌 - - - + + + New folder 新文件夾 - + Delete 删除 - + Open decks folder 打開牌庫文件夾 - + Rename local folder - + Rename local file - + New name: - - - - + + + + Error 出現錯誤 - + Rename failed - - + + Invalid deck file 無效牌庫檔案 - + Enter deck name 輸入牌庫名稱 - + This decklist does not have a name. Please enter a name: 本牌庫列表未有名稱.請輸入名稱: - + Unnamed deck 未命名牌庫 - + Failed to upload deck to server 牌庫上載於伺服器時失敗 - + Delete local file 删除本地檔案 - + Are you sure you want to delete the selected files? 你確定要刪除被選檔案(眾)嗎 ? - + Delete remote decks 刪除伺服器上的牌庫 - + Are you sure you want to delete the selected decks? 你確定要剷除所有被選的牌庫嗎? - - + + Name of new folder: 新建文件夾名稱: @@ -8176,191 +8653,191 @@ Please enter a name: TabGame - - - + + + Replay 遊戲錄像重溫 - - + + Game 遊戲 - - + + Player List 玩家列表 - - + + Card Info 卡牌資訊 - - + + Messages 信息 - - + + Replay Timeline 錄像時間線 - + &Phases 階段 - + &Game 遊戲 - + Next &phase 下個&階段 - + Next phase with &action 下個階段&行動 - + Next &turn 下個&回合 - + Reverse turn order 逆轉回合次序 - + &Remove all local arrows &移除所有自設箭頭 - + Rotate View Cl&ockwise &順時針旋轉視角 - + Rotate View Co&unterclockwise &逆時針旋轉視角 - + Game &information 遊戲&資訊 - + Un&concede 撤銷&投降 - - - + + + &Concede &投降 - + &Leave game &離開遊戲 - + C&lose replay &關閉遊戲錄像 - + &Focus Chat &聚焦聊天 - + &Say: &説: - + Selected cards - + &View &視圖 - + Visible 可見 - + Floating 浮動 - + Reset layout 重置布局 - + Concede 投降 - + Are you sure you want to concede this game? 你確定要宣佈投降嗎? - + Unconcede 撤銷投降 - + You have already conceded. Do you want to return to this game? 你已經宣佈投降,你想回到這場遊戲嗎? - + Leave game 離開遊戲 - + Are you sure you want to leave this game? 你確定要離開這個遊戲嗎? - + A player has joined game #%1 有玩家加入遊戲 #%1 - + %1 has joined the game %1 已經加入遊戲。 - + You have been kicked out of the game. 你已被踢出遊戲。 @@ -8419,115 +8896,115 @@ Please enter a name: 您必須選擇一個可用的日數來作為定位. - + Username: 用戶名稱: - + IP Address: IP地址 - + Game Name: 遊戲名稱: - + GameID: 遊戲ID: - + Message: 信息: - + Main Room 主房 - + Game Room 遊戲房間 - + Private Chat 私人聊天 - + Past X Days: 過去 X 天: - + Today 今天 - + Last Hour 1小時前 - + Maximum Results: 最多結果: - + At least one filter is required. The more information you put in, the more specific your results will be. 至少需要1個篩選項。 您輸入的信息越多,找到結果越準確。 - + Get User Logs 獲取用戶記錄 - + Clear Filters 清除篩選項目 - + Filters 篩選項目 - + Log Locations 記錄位置 - + Date Range 日期範圍 - + Maximum Results 最多結果 - - + + Message History 信息歷史 - + Failed to collect message history information. 不能獲取信息歷史消息 - + There are no messages for the selected filters. 沒有符合篩選條件的信息。 @@ -8545,27 +9022,27 @@ The more information you put in, the more specific your results will be.&離開 - + %1 - Private chat %1 - 私人聊天 - + This user is ignoring you, they cannot see your messages in main chat and you cannot join their games. 你已被這用戶屏蔽, 它們不能再主聊天室看見你的信息,你亦不能參加它們的遊戲. - + Private message from 私人信息信到, 来自 - + %1 has left the server. %1 已離開伺服器. - + %1 has joined the server. %1 已加入伺服器. @@ -8573,180 +9050,180 @@ The more information you put in, the more specific your results will be. TabReplays - + Local file system 本地檔案系統 - + Server replay storage 伺服器錄像倉庫 - - + + Watch replay 觀看遊戲錄像 - + Rename 改名 - - + + New folder 新文件夾 - - + + Delete 刪除 - + Open replays folder 打開錄像文件夾 - + Download replay 下載遊戲錄像 - + Toggle expiration lock 過期鎖定 - + Get replay share code - - + + Look up replay by share code - + Rename local folder 本地檔案夾改名 - + Rename local file 本地檔案改名 - + New name: 新名稱 - + Error 出錯 - + Rename failed 改名不成功 - + Name of new folder: 新建文件夾名稱: - + Delete local file 刪除本地檔案 - + Are you sure you want to delete the selected files? 你確定要剷除所有被選的檔案嗎? - + Are you sure you want to delete the selected replays? 你確定要刪除被選錄像檔案嗎 ? - + Failed to get code - - + + Either this server does not support replay sharing, or does not permit replay sharing for you. - - - + + + Failed - + Could not get replay code - + Replay Share Code - + Others can use this code to add the replay to their list of remote replays: %1 - + Copy to clipboard - + Replay share code - + Replay code found - + Replay was added, or you already had access to it. - + Replay code not found - + Failed to submit code - + Unexpected error - + Delete remote replay 刪除伺服器錄像 @@ -8759,47 +9236,62 @@ The more information you put in, the more specific your results will be. TabRoom - + + Friends + + + + + Online + + + + + Ignored + + + + &Say: &説: - + Chat 聊天 - + &Room &房間 - + &Leave room &離開房間 - + &Clear chat &清除聊天記錄 - + Chat Settings... 聊天設置... - + mentioned you. 提到了你。 - + Click to view 點擊查看 - + You are flooding the chat. Please wait a couple of seconds. 你正在刷屏。請稍等幾秒鐘。 @@ -8812,30 +9304,30 @@ The more information you put in, the more specific your results will be.伺服器 - - - - + + + + Error 出現錯誤 - + Failed to join the server room: it doesn't exist on the server. 加入伺服器房間失敗:在本伺服器上這房間並不存在。 - + The server thinks you are in the server room but your client is unable to display it. Try restarting your client. 伺服器認為你在房間內, 但Cockatrice無法顯示。請重新啓動Cockatrice。 - + You do not have the required permission to join this server room. 你未被允許進入該房間。 - + Failed to join the server room due to an unknown error: %1. 因為以下不知名的錯誤,所以不能參入伺服器房間: %1. @@ -8843,97 +9335,97 @@ The more information you put in, the more specific your results will be. TabSupervisor - + Deck Editor - + Visual Deck Editor - + EDHRec - + Archidekt - + Home - + &Visual Deck Storage - + Visual Database Display - + Server - + Account - + Deck Storage - + Game Replays - + Administration - + Logs - + Are you sure? 你確定嗎? - + There are still open games. Are you sure you want to quit? 遊戲還在繼續, 你確定要退出嗎? - + Click to view 點擊查看 - + Your buddy %1 has signed on! 你的好友%1已登錄! - + Unknown Event 不知名事件 - + The server has sent you a message that your client does not understand. This message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -8944,38 +9436,38 @@ To update your client, go to Help -> Check for Updates. 要升級你的客户端,請點擊“幫助”->“檢查更新” - + Idle Timeout 閒置超時 - + You are about to be logged out due to inactivity. 你即將因為長時間沒有活動而被退出。 - + Promotion 升級 - + You have been promoted. Please log out and back in for changes to take effect. 你已被升級.請登出然後再登入讓本更改生效. - + Warned 被警告 - + You have received a warning due to %1. Please refrain from engaging in this activity or further actions may be taken against you. If you have any questions, please private message a moderator. 你由於$1 原因,現在接受警告. 請避免繼續本行為, 否則你將有可能會接受進一步的警誡或懲罰. 若有任何疑問, 請私信我們門下的版主. - + You have received the following message from the server. (custom messages like these could be untranslated) 你收到松本伺服器發出的以下訊息(類似本信息的自定訊息可能未經翻譯) @@ -8984,12 +9476,12 @@ Please refrain from engaging in this activity or further actions may be taken ag TabVisualDatabaseDisplay - + Database Display - + Visual Database Display @@ -9028,42 +9520,42 @@ Please refrain from engaging in this activity or further actions may be taken ag TranslateCounterName - + Life 生命值 - + White 白色 - + Blue 藍色 - + Black 黑色 - + Red 紅色 - + Green 綠色 - + Colorless 無色 - + Other 其他 @@ -9071,11 +9563,69 @@ Please refrain from engaging in this activity or further actions may be taken ag UpdateDownloader - + Could not open the file for reading. 無法打開文件。 + + UserCardArtSettingsDialog + + + Card Art Settings + + + + + Type a card name... + + + + + Card name: + + + + + Card ProviderId: + + + + + Left margin (%): + + + + + Right margin (%): + + + + + Vertical offset: + + + + + Zoom: + + + + + Parameters + + + + + Preview + + + + + Remove Banner Card + + + UserContextMenu @@ -9193,7 +9743,7 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Ban History 禁用歷史 @@ -9208,77 +9758,87 @@ Please refrain from engaging in this activity or further actions may be taken ag 用戶從未被禁 - + Failed to collect ban information. 不能獲取禁用訊息 - - - + + + Warning History 警告歷史 - + Warning Time;Moderator;User Name;Reason 警告時間;版主;用戶名稱;理由 - + User has never been warned. 用戶從未接收警告 - + Failed to collect warning information. 不能獲取警告信息 - + Failed to get admin notes. 不能獲取管理筆記 - - + + Success 成功 - + Successfully promoted user. 用戶升級成功 - + Successfully demoted user. 用戶降級成功 - - - + + Kick Player + + + + + Are you sure you want to kick this player from the game? + + + + + + Failed 失敗 - + Failed to promote user. 用戶升級失敗 - + Failed to demote user. 用戶降級失敗 - + Copy hash to clipboard 把哈希覆印到用戶剪貼 - + Remove this user's messages 移除這用戶訊息 @@ -9286,326 +9846,554 @@ Please refrain from engaging in this activity or further actions may be taken ag UserInfoBox - + Location: 位置: - + Account Age: 帳戶年齡 - + Edit 更改 - + Change password 更改密碼 - + Change avatar 更改頭像 - + Administrator 管理員 - + Moderator 版主 - + Registered user 登記用戶 - - + + Unregistered user 未登記用戶 - + Judge 裁判 - + Unknown 不知名 - + The entered password does not match your account. 輸入密碼跟你戶口不匹配 - - - + + + + Information 資訊 - + User information updated. 用戶資訊已更新。 - - - - - - - - - - + + + + + + + + + + + Error 出現錯誤 - + User Information 用戶資訊 - + Real Name: 實名: - + User Level: 用戶等級: + + + Edit Banner Card + + - + %n Year(s), amount of years (only shown if more than 0) %n年(眾) , - + %10%n Day(s) %20 amount of years (if more than 0), amount of days, date in local short format %10%n日(眾) %20 - + Enter Password 輸入密碼 - + Password verification is required in order to change your email address 要核實密碼才能更改你的電郵地址 - - - + + + An error occurred while trying to update your user information. 在嘗試更新你的用戶信息時出現錯誤. - + + The selected card is blacklisted on this server or another error occurred. + + + + + Banner card removed. + + + + + Banner card updated. + + + + This server does not permit you to update your user informations. 本伺服器不容許你更新你的用戶資訊. - + Password changed. 密碼已更改 - + This server does not permit you to change your password. 本伺服器不容許你更改密碼 - + The new password is too short. 新的密碼太短 - + The old password is incorrect. 舊的密碼不正確 - + Avatar updated. 頭像已更新。 - + This server does not permit you to update your avatar. 本伺服器不容許你更新你的頭像. + + + An error occured while trying to update your avatar. + + - An error occured while trying to updater your avatar. - 在更新你頭像時出現錯誤 + This server does not permit you to update your user informations. + An error occured while trying to updater your avatar. + 本伺服器不容許你更新你的用戶資訊. + + + + UserInfoPopup + + + + Games + + + + + Chat + + + + + Open private chat + + + + + Profile + + + + + View user profile + + + + + Show this user's games + + + + + − Buddy + + + + + Remove from buddy list + + + + + + Buddy + + + + + Add to buddy list + + + + + − Ignore + + + + + Remove from ignore list + + + + + + Ignore + + + + + Add to ignore list + + + + + Ban + + + + + Ban from server + + + + + Warn + + + + + Warn user + + + + + Ban log + + + + + View ban history + + + + + Warn log + + + + + View warning history + + + + + Notes + + + + + View admin notes + + + + + − Mod + + + + + Demote from moderator + + + + + + Mod + + + + + Promote to moderator + + + + + − Judge + + + + + Demote from judge + + + + + + Judge + + + + + Promote to judge + + + + + Join game + + + + + Spectate + + + + + + Loading games… + + + + + Could not load games. + + + + + No active games. + UserInterfaceSettingsPage - + General interface settings 一般通用接口設置 - + &Double-click cards to play them (instead of single-click) 雙點擊使出卡牌 (而不是單點擊使出) - + &Clicking plays all selected cards (instead of just the clicked card) &單一點擊把所有選擇的卡牌使出(不只是被點擊的單張卡牌) - + &Play all nonlands onto the stack (not the battlefield) by default &把所有使出的非地牌放入堆疊區(不是戰場) 作為常用設置 - + Do not delete &arrows inside of subphases - + Close card view window when last card is removed - + Auto focus search bar when card view window is opened - + Annotate card text on tokens 在令牌上標註牌中文字 - - Show selection counter during drag selection + + Show selection count during drag selection - - Show total selection counter + + Show total selection count - + + Show subtype breakdown in selection tally + + + + Use tear-off menus, allowing right click menus to persist on screen 使用浮動菜單,允許右鍵單擊, 把菜單保留在屏幕上 - + + Keep game chat focused when clicking in game (Note: disables card view search bar) + + + + Notifications settings 通告設置 - + Enable notifications in taskbar 開啓任務欄通告 - + Notify in the taskbar for game events while you are spectating 觀看時在任務欄提示遊戲信息 - + Notify in the taskbar when users in your buddy list connect 在任務欄中提示好友連到伺服器 - + Animation settings 動畫設定 - + &Tap/untap animation &橫置/重置 動畫 - + Deck editor/storage settings - + Open deck in new tab by default 把用新頁開啟牌庫作為常用設置 - + Use visual deck storage in game lobby - + Use selection animation for Visual Deck Storage - + When adding a tag in the visual deck storage to a .txt deck: - + do nothing - + ask to convert to .cod - + always convert to .cod - + Default deck editor type - + Classic Deck Editor - + Visual Deck Editor - + Replay settings 重播設置 - + Buffer time for backwards skip via shortcut: 用速鍵啟動向後跳的緩衝時間: @@ -9613,22 +10401,22 @@ Please refrain from engaging in this activity or further actions may be taken ag UserListWidget - + Users connected to server: %1 - + Users in this room: %1 - + Buddies online: %1 / %2 - + Ignored users online: %1 / %2 @@ -9636,35 +10424,45 @@ Please refrain from engaging in this activity or further actions may be taken ag UtilityMenu - + Increment all card counters - + &Untap all permanents - + R&oll die... - + + Flip coin + + + + &Create token... - + C&reate another token - + Cr&eate predefined token + + + C&reate another %1 token + + VisualDatabaseDisplayColorFilterWidget @@ -9716,72 +10514,72 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayFilterToolbarWidget - + Sort by - + Filter by - + Save and load filters - + Filter by exact card name - + Filter by card main-type - + Filter by card sub-type - + Filter by set - + Filter by format legality - + Save/Load - + Name - + Main Type - + Sub Type - + Sets - + Formats @@ -9789,22 +10587,22 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayFormatLegalityFilterWidget - + Show formats with at least: - + cards - + Do not display formats with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) @@ -9822,32 +10620,32 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayMainTypeFilterWidget - + Show main types with at least: - + cards - + Do not display card main-types with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) - + Mode: Exact Match - + Mode: Includes @@ -9855,27 +10653,27 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayNameFilterWidget - + Filter by name... (Exact match) - + Load from Deck - + Apply all card names in currently loaded deck as exact match name filters - + Load from Clipboard - + Apply all card names in clipboard as exact match name filters @@ -9883,7 +10681,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayRecentSetFilterSettingsWidget - + Filter to most recent sets @@ -9891,19 +10689,19 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplaySetFilterWidget - + Search sets... - - + + Mode: Exact Match - - + + Mode: Includes @@ -9911,37 +10709,37 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplaySubTypeFilterWidget - + Search subtypes... - + Show sub types with at least: - + cards - + Do not display card sub-types with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) - + Mode: Exact Match - + Mode: Includes @@ -9949,23 +10747,23 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayWidget - + Search by card name (or search expressions) - + Visual - + Loading database ... - + Clear all filters @@ -10004,17 +10802,17 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Toggle Layout: Overlap - + Change how cards are displayed within zones (i.e. overlapped or fully visible.) - + Toggle Layout: Flat @@ -10043,17 +10841,17 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckEditorWidget - + Type a card name here for suggestions from the database... - + Quick search and add card - + Search for closest match in the database (with auto-suggestions) and add preferred printing to the deck on pressing enter @@ -10171,43 +10969,43 @@ Please refrain from engaging in this activity or further actions may be taken ag WarningDialog - + Which warning would you like to send? 你想發出那種警告? - + Redact all messages from this user in all rooms 把這用戶在所有房間留下的訊息刪除 - + &OK &確認 - + &Cancel &取消 - + Warn user for misconduct 對行為不當的用戶發出警告 - - + + Error 出現錯誤 - + User name to send a warning to can not be blank, please specify a user to warn. 接收警告的用戶名稱不能空白.請定好要接收警告的用戶名字. - + Warning to use can not be blank, please select a valid warning to send. 警告名稱不能留空. 請選擇有效的警告. @@ -10215,133 +11013,133 @@ Please refrain from engaging in this activity or further actions may be taken ag WndSets - + Move selected set to the top 將選擇的牌組送到最頂 - + Move selected set up 將選擇的牌組往上一格 - + Move selected set down 將選擇的牌組往下一格 - + Move selected set to the bottom 將選擇的牌組送到最底 - + Search by set name, code, or type 用排組名稱,代碼,或類型進行搜尋 - + Default order 常用次序 - + Restore original art priority order 恢復常用藝術先後次序 - + Enable all sets 啟用所有牌組 - + Disable all sets 禁用所有卡组 - + Enable selected set(s) 啟用選擇的牌組(眾)  - + Disable selected set(s) 停用選擇的牌組(眾) - + Deck Editor 牌庫編輯 - + Use CTRL+A to select all sets in the view. 請按 CTRL + A, 以此選擇所有看到的牌組 - + Only cards in enabled sets will appear in the card list of the deck editor. 只有已經被啟用的卡牌組中的卡牌,才會在牌庫編輯中的卡牌清單出現 - + Image priority is decided in the following order: 卡牌圖片先後次序由以下次序決定: - + first the CUSTOM Folder (%1), then the Enabled Sets in this dialog (Top to Bottom) %1 is a link to the wiki 自訂檔案夾(%`1)優先, 此對話窗中已被啟用的牌組為後(由頂到底) - + Include cards rebalanced for Alchemy [requires restart] - + Card Art 卡牌藝術 - + How to use custom card art 如何使用自定卡牌圖片 - + Hints 提示 - + Note 筆記 - + Sorting by column allows you to find a set while not changing set priority. 按列排序允許您在不更改系列優先級別的情況下查找牌組。 - + To enable ordering again, click the column header until this message disappears. 要再次啓用排序,請單擊列標題,直到此消息消失。 - + Use the current sorting as the set priority instead 用現在排序代替牌組先後 - + Sorts the set priority using the same column 用同一列分牌組先後 - + Manage sets 管理牌組 @@ -10349,72 +11147,72 @@ Please refrain from engaging in this activity or further actions may be taken ag ZoneViewWidget - + Search by card name (or search expressions) - + Ungrouped 未分組 - + Group by Type 按類型分組 - + Group by Mana Value 按法力數值分組 - + Group by Color 按顏色排序 - + Unsorted 未排序 - + Sort by Name 按名稱排次序 - + Sort by Type 按類型排序 - + Sort by Mana Cost 按法力費用排序 - + Sort by Colors 按顏色(眾) 排序 - + Sort by P/T 按力量/防禦力排序 - + Sort by Set 按排組排序 - + shuffle when closing 關閉界面時洗牌 - + pile view 棟疊觀看 @@ -10422,7 +11220,7 @@ Please refrain from engaging in this activity or further actions may be taken ag i18n - + English @@ -10430,12 +11228,12 @@ Please refrain from engaging in this activity or further actions may be taken ag main - + Connect on startup 啟動時自動連接 - + Debug to file 偵錯紀錄寫進檔案 @@ -10449,7 +11247,7 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Deck Editor 牌庫編輯 @@ -10530,7 +11328,7 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Replays 遊戲錄像 @@ -10645,129 +11443,129 @@ Please refrain from engaging in this activity or further actions may be taken ag 載入牌庫... - - + + Load Deck from Clipboard... 從剪貼板載入牌庫 - + Edit Deck in Clipboard, Annotated - + Edit Deck in Clipboard - + New Deck 創建新牌庫 - + Open Custom Pictures Folder 打開自定圖片文件夾 - + Print Deck... 印刷牌庫 - + Delete Card 刪除卡牌 - - + + Reset Layout 重置佈局 - + Save Deck 保存牌庫 - + Save Deck as... 牌庫另存為 - + Save Deck to Clipboard, Annotated 從剪貼板儲藏牌庫, 帶注釋 - + Save Deck to Clipboard, Annotated (No Set Info) - + Save Deck to Clipboard 從剪貼板儲藏牌庫 - + Save Deck to Clipboard (No Set Info) - + Load Local Deck... 從自身檔案載入牌庫 - + Load Remote Deck... 載入伺服器上的牌庫... - + Set Ready to Start 設定準備開始 - + Toggle Sideboard Lock 切換副牌庫鎖 - + Add Green Counter 增加綠色指示物 - + Remove Green Counter 移除綠色指示物 - + Set Green Counters... 設定綠色指示物 - + Add Red Counter 增加红色指示物 - + Remove Red Counter 移除紅色指示物 - + Set Red Counters... 設定紅色指示物 - + Add Life Counter 增加生命指示物 @@ -10777,724 +11575,744 @@ Please refrain from engaging in this activity or further actions may be taken ag - + + Load deck from online service... + + + + + Load from website... + + + + Unload Deck - + Force Start - + Add Card Counter (F) - + Remove Card Counter (F) - + Set Card Counters (F)... - + Add Card Counter (E) - + Remove Card Counter (E) - + Set Card Counters (E)... - + Add Card Counter(D) - + Remove Card Counter (D) - + Set Card Counters (D)... - + Add Card Counter (C) - + Remove Card Counter (C) - + Set Card Counters (C)... - + Add Card Counter (B) - + Remove Card Counter (B) - + Set Card Counters (B)... - + Add Card Counter (A) - + Remove Card Counter (A) - + Set Card Counters (A)... - + Remove Life Counter 移除生命指示物 - + Set Life Counters... 設定生命指示物 - + Add White Counter 增加白色指示物 - + Remove White Counter 移除白色指示物 - + Set White Counters... 設定白色指示物 - + Add Blue Counter 增加藍色指示物 - + Remove Blue Counter 移除藍色指示物 - + Set Blue Counters... 設定藍色指示物 - + Add Black Counter 增加黑色指示物 - + Remove Black Counter 移除黑色指示物 - + Set Black Counters... 設定黑色指示物 - + Add Colorless Counter 增加無色指示物 - + Remove Colorless Counter 移除無色指示物 - + Set Colorless Counters... 設定無色指示物 - + Add Other Counter 增加其他指示物 - + Remove Other Counter 移除其他指示物 - + Set Other Counters... 設定其他指示物 - + Increment all card counters - + Add Power (+1/+0) 增加力量(+1/+0) - + Remove Power (-1/-0) 削減力量(-1/-0) - + Move Toughness to Power (+1/-1) 防禦移到力量(+1/-1) - + Add Toughness (+0/+1) 增加防御(+0/+1) - + Remove Toughness (-0/-1) 削減防御(-0/-1) - + Move Power to Toughness (-1/+1) 力量移到防禦(-1/+1) - + Add Power and Toughness (+1/+1) 增加力量和防御(+1/+1) - + Remove Power and Toughness (-1/-1) 削減力量和防御(-1/-1) - + Set Power and Toughness... 設置力量和防禦 - + Reset Power and Toughness 重置力量和防御 - + Untap 重置 - + Upkeep 維持 - + Draw 抽牌 - + First Main Phase 第一主階段 - + Start Combat 開始戰鬥 - + Attack 攻擊 - + Block 擋格 - + Damage 計算損害 - + End Combat 戰鬥结束 - + Second Main Phase 第二主階段 - + End 完結 - + Next Phase 下個階段 - + Next Phase Action 下個行動階段 - + Next Turn 下一回合 - + Hide Card in Reveal Window 從揭露視窗中隱藏這牌 - + Tap / Untap Card 横置/重置這牌 - + Untap All 重置全牌 - + Toggle Skip Untapping Toggle Untap - + Turn Card Over 反轉本牌 - + Peek Card 查看卡牌 - + Play Card 使出本牌 - + Play Card, Face Down - + Attach Card... 结附牌 - + Unattach Card 解除牌结附 - + Clone Card 複製卡牌 - + Create Token... 派出令牌 - + Create All Related Tokens 派出所有相關令牌 - + Create Another Token 再做另外令牌 - + Set Annotation... 記下註解 - + + Reduce Life by Power + + + + Select All Cards in Zone 選擇所有在區域的牌 - + Select All Cards in Row 選擇所有橫行牌 - + Select All Cards in Column 選擇所有直行牌 - + Reveal Selected Cards to All Players - - + + Bottom of Library 牌庫底 - - - - + + + + Exile 放逐 - - - - + + + + Graveyard 墳場 - - + + Hand 手牌 - - + + Top of Library 牌庫頂 - - + + Battlefield, Face Down 戰場,面朝下 - + Battlefield 戰場 - + Library 牌庫 - + Sideboard 副牌庫 - + Top Cards of Library 排副多張頂牌 - + Bottom Cards of Library 排副多張底牌 - + Close Recent View 關閉最近查看 - - + + Stack 疊區 - - + + Graveyard (Multiple) 多張送墳場 - - + + Graveyard (Multiple), Face Down - - + + Exile (Multiple) 多張放逐 - - + + Exile (Multiple), Face Down - + Stack Until Found 找到以下牌前全送疊區 - + Draw Bottom Card 從牌庫底抽一張牌 - + Draw Multiple Cards from Bottom... 從牌庫底抽超過一張牌 - + Draw Arrow... 畫箭咀 - + Remove Local Arrows 清除自設箭咀 - + Leave Game 離開遊戲 - + Concede 投降 - + Roll Dice... 擲骰 - + + Flip Coin + + + + Shuffle Library 洗牌 - + Shuffle Top Cards of Library - + Shuffle Bottom Cards of Library - + Mulligan 再調度 - + Mulligan (Same hand size) - + Mulligan (Hand size - 1) - + Draw a Card 抽一張牌 - + Draw Multiple Cards... 抽超過一張牌 - + Undo Draw 撤銷抽牌 - + Always Reveal Top Card 永遠顯示頂牌 - + Always Look At Top Card 永遠看到頂牌 - + Sort Hand by Name - + Sort Hand by Type - + Sort Hand by Mana Value - + Reveal Hand to All Players - + Reveal Random Card to All Players - + Rotate View Clockwise 視角順時針轉 - + Rotate View Counterclockwise 視角逆時針轉 - + Unfocus Text Box 取消聚焦文本框 - + Focus Chat 聚焦聊天 - + Clear Chat 掃清聊天 - + Refresh 刷新 - + Skip Forward 跳前 - + Skip Backward 跳後 - + Skip Forward by a lot 向前大跳 - + Skip Backward by a lot 向後大跳 - + Play/Pause 播放/暫停 - + Toggle Fast Forward 切換快進 - + Home - + Visual Deck Storage - + Deck Storage - + Server - + Account - + Administration - + Logs diff --git a/cockatrice/translations/cockatrice_zh-Hans.ts b/cockatrice/translations/cockatrice_zh-Hans.ts index 3e219b572..a2df2035e 100644 --- a/cockatrice/translations/cockatrice_zh-Hans.ts +++ b/cockatrice/translations/cockatrice_zh-Hans.ts @@ -2,7 +2,7 @@ AbstractCounter - + &Set counter... &设置指示物 @@ -10,12 +10,12 @@ AbstractCounterDialog - + Set counter 设置指示物 - + New value for counter '%1': 数值物新价值为 '%1' @@ -36,60 +36,60 @@ AbstractTabDeckEditor - + Open in new tab - + Are you sure? - + The decklist has been modified. Do you want to save the changes? - - - - - - + + + + + + Error - + Could not open deck at %1 - + Could not save remote deck - - + + The deck could not be saved. Please check that the directory is writable and try again. - + Save deck - + The deck could not be saved. - + There are no cards in your deck to be exported @@ -105,12 +105,12 @@ Please check that the directory is writable and try again. AdminNotesDialog - + Update Notes 更新后笔记 - + Admin Notes for %1 %1 的管理笔记 @@ -127,172 +127,227 @@ Please check that the directory is writable and try again. Sideboard 副牌库 + + + Tokens + + AppearanceSettingsPage - + seconds - + Error 出现错误 - + Could not create themes directory at '%1'. 不能在'%1'创造主题目录 - + Theme settings 主题设定 - + Current theme: 现在主题 - + Open themes folder 打开主题文件夹 - + Home tab background source: - + Home tab background shuffle frequency: - + Disabled - - Display card name of background in bottom right: - - - - + Menu settings 选单设定 - + Show keyboard shortcuts in right-click menus 在右击选单中显示键盘快速键 - + Show game filter toolbar above list in room tab - + Card rendering 卡面算绘 - + Display card names on cards having a picture 最有图片的牌上显示牌名 - + Auto-Rotate cards with sideways layout 将打横佈局卡片自动向上旋转 - + Override all card art with personal set preference (Pre-ProviderID change behavior) - + + Light + + + + + Dark + + + + + System + + + + + Active theme palette: + + + + + Edit theme palette + + + + + Home tab settings + + + + + Display card name of background in bottom right + + + + + Styling settings + + + + + Style user list + + + + + Card printings + + + + Bump sets that the deck contains cards from to the top in the printing selector 将牌库中包含的牌组推到艺术选择视窗之顶 - + Scale cards on mouse over 卡牌随游标缩放 - + Use rounded card corners - + + Card layout + + + + Minimum overlap percentage of cards on the stack and in vertical hand 在迭区中和直行手牌中,尽量减少交重迭百分点 - + Maximum initial height for card view window: 卡望框最高开头高度 - - + + rows 横列 - + Maximum expanded height for card view window: - + Card counters - + Counter %1 - + Hand layout 手牌佈局 - + Display hand horizontally (wastes space) 打栋显示手牌(浪费空间) - + Enable left justification 啟用向左对齐 - + Table grid layout 表格布局 - + Invert vertical coordinate 反转垂直坐标 - + Minimum player count for multi-column layout: 界面佈局之内能够容纳的最少玩家数量: - + Maximum font size for information displayed on cards: 卡牌上显示的最大字体大小 @@ -300,12 +355,12 @@ Please check that the directory is writable and try again. ArchidektApiResponseDeckDisplayWidget - + Back to results - + Open Deck in Deck Editor @@ -331,112 +386,112 @@ Please check that the directory is writable and try again. BanDialog - + ban &user name 禁用 &用家名字 - + ban &IP address 禁用&IP地址 - + ban client I&D 禁用客户ID - + Ban type 禁用类型 - + &permanent ban &永久禁用 - + &temporary ban &暂时禁用 - + &Days: &日 - + &Hours: &小时 - + &Minutes: &分鐘 - + Duration of the ban 禁用时间 - + Please enter the reason for the ban. This is only saved for moderators and cannot be seen by the banned person. 请记载禁用原因 这项只保留为版主, 被禁用户不能看到. - + Please enter the reason for the ban that will be visible to the banned person. 请记载被禁用户能看到的被禁原因. - + Redact all messages from this user in all rooms 将这用户所有室中打出的讯息完全移除 - + &OK &OK - + &Cancel &取消 - + Ban user from server 把用户从本伺服器禁用 - - - - + + + + Error 出现错误 - + You have to select a name-based, IP-based, clientId based, or some combination of the three to place a ban. 若要对用户实施禁用,请你选择以下叁种的拼合:名字,IP,或用户id. - + You must have a value in the name ban when selecting the name ban checkbox. 若用用户名字施行禁用,必须要填写姓名. - + You must have a value in the ip ban when selecting the ip ban checkbox. 若用ip地址施行禁用,必须要写下IP地址. - + You must have a value in the clientid ban when selecting the clientid ban checkbox. 若用用户ID施行禁用,必须要写下用户ID. @@ -444,59 +499,123 @@ This is only saved for moderators and cannot be seen by the banned person. BetaReleaseChannel - + Beta 测试版 - + No reply received from the release update server. 从发佈更新伺服器收不到回覆. - + Invalid reply received from the release update server. 从发佈更新伺服器收不到有效的回覆. - + No reply received from the file update server. 从档案更新伺服器收不到有效的回覆. + + CardArtPreviewWidget + + + No card selected + + + + + CardArtRulesModel + + + Card + + + + + ProviderId + + + + + Mode + + + + + Reason + + + CardDatabaseModel - + Name 名字 - + Sets 牌组 - + Mana cost 法力费用 - + Card type 卡牌类别 - + P/T 力量/防御力 - + Color(s) 顏色 + + CardDatabaseView + + + Add to Deck + + + + + Add to Sideboard + + + + + Select Printing + + + + + Show on EDHRec (Commander) + + + + + Show on EDHRec (Card) + + + + + Show Related cards + + + CardFilter @@ -625,22 +744,22 @@ This is only saved for moderators and cannot be seen by the banned person. CardInfoPictureWidget - + View related cards 查看关联卡牌 - + Add card to deck - + Mainboard - + Sideboard @@ -663,12 +782,12 @@ This is only saved for moderators and cannot be seen by the banned person. - + Related cards: 有关牌名: - + Unknown card: 不知名的牌 @@ -676,128 +795,146 @@ This is only saved for moderators and cannot be seen by the banned person. CardMenu - + Re&veal to... - + &All players - + View related cards - + Token: - + All tokens - + &Select All - + S&elect Row - + S&elect Column - + &Play - + &Hide - + Play &Face Down - + &Tap / Untap Turn sideways or back again - + Skip &untapping - + T&urn Over Turn face up/face down - + &Peek at card face - + &Clone - + Attac&h to card... - + Unattac&h - + &Draw arrow... - + &Set annotation... - + + Reduce life by power + + + + Ca&rd counters - + &Add counter (%1) - + &Remove counter (%1) - + &Set counters (%1)... + + CardPictureLoaderCacheMethod + + + Network Cache + + + + + Filesystem + + + CardSizeWidget @@ -809,133 +946,133 @@ This is only saved for moderators and cannot be seen by the banned person. CardZoneLogic - + their hand nominative - + %1's hand nominative - + their library look at zone - + %1's library look at zone - + of their library top cards of zone, - + of %1's library top cards of zone - + their library reveal zone - + %1's library reveal zone - + their library shuffle - + %1's library shuffle - + their library nominative - + %1's library nominative - + their graveyard nominative - + %1's graveyard nominative - + their exile nominative - + %1's exile nominative - + their sideboard look at zone - + %1's sideboard look at zone - + their sideboard nominative - + %1's sideboard nominative - + their custom zone '%1' nominative - + %1's custom zone '%2' nominative @@ -957,16 +1094,421 @@ This is only saved for moderators and cannot be seen by the banned person. + + ColorButton + + + Click to pick a color + + + + + Pick colour + + + + + ConnectionController + + + + The server has reached its maximum user capacity, please check back later. + + + + + There are too many concurrent connections from your address. + + + + + Banned by moderator + + + + + Expected end time: %1 + + + + + This ban lasts indefinitely. + + + + + Scheduled server shutdown. + + + + + + Invalid username. + + + + + You have been logged out due to logging in at another location. + + + + + Connection closed + + + + + The server has terminated your connection. +Reason: %1 + + + + + The server is going to be restarted in %n minute(s). +All running games will be lost. +Reason for shutdown: %1 + + + + + Scheduled server shutdown + + + + + Failed Login + + + + + Your client seems to be missing features this server requires for connection. + + + + + To update your client, go to 'Help -> Check for Client Updates'. + + + + + + + + + + + + + + + + + + + + + + Error + + + + + Incorrect username or password. Please check your authentication information and try again. + + + + + There is already an active session using this user name. +Please close that session first and re-login. + + + + + + You are banned until %1. + + + + + + You are banned indefinitely. + + + + + This server requires user registration. Do you want to register now? + + + + + This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. +Please close and reopen your client to try again. + + + + + An internal error has occurred, please close and reopen Cockatrice before trying again. +If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. + + + + + Account activation + + + + + Your account has not been activated yet. +You need to provide the activation token received in the activation email. + + + + + Server Full + + + + + Unknown login error: %1 + + + + + + +This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. + + + + + + + + + + Registration denied + + + + + Registration is currently disabled on this server + + + + + There is already an existing account with the same user name. + + + + + It's mandatory to specify a valid email address when registering. + + + + + It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. + + + + + Password too short. + + + + + Registration failed for a technical problem on the server. + + + + + The connection to the server has been lost. + + + + + Unknown registration error: %1 + + + + + Account activation failed + + + + + Socket error: %1 + + + + + Server timeout + + + + + You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. +Local version is %1, remote version is %2. + + + + + Your Cockatrice client is obsolete. Please update your Cockatrice version. +Local version is %1, remote version is %2. + + + + + + Success + + + + + Registration accepted. +Will now login. + + + + + Account activation accepted. +Will now login. + + + + + Information + + + + + This server supports additional features that your client doesn't have. +This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. + +To update your client, go to Help -> Check for Updates. + + + + + + + Reset Password + + + + + Your password has been reset successfully, you can now log in using the new credentials. + + + + + Failed to reset user account password, please contact the server operator to reset your password. + + + + + Activation request received, please check your email for an activation token. + + + + + Connecting to %1... + + + + + Registering to %1 as %2... + + + + + Disconnected + + + + + Connected, logging in at %1 + + + + + Requesting forgotten password to %1 as %2... + + + + + Your username must respect these rules: + + + + + is %1 - %2 characters long + + + + + can %1 contain lowercase characters + + + + + + + + NOT + + + + + can %1 contain uppercase characters + + + + + can %1 contain numeric characters + + + + + can contain the following punctuation: %1 + + + + + first character can %1 be a punctuation mark + + + + + no unacceptable language as specified by these server rules: + note that the following lines will not be translated + + + + + can not contain any of the following words: %1 + + + + + can not match any of the following expressions: %1 + + + + + You may only use A-Z, a-z, 0-9, _, ., and - in your username. + + + CustomZoneMenu - + C&ustom Zones - - + + View custom zone '%1' @@ -974,30 +1516,35 @@ This is only saved for moderators and cannot be seen by the banned person. DeckAnalyticsWidget - + Add Panel - + Remove Panel - + Save Layout - + Load Layout + + + Include Sideboard + + DeckEditorCardDatabaseDockWidget - + Card Database @@ -1013,47 +1560,17 @@ This is only saved for moderators and cannot be seen by the banned person. DeckEditorDatabaseDisplayWidget - + Search by card name (or search expressions) - - Add to Deck - - - - - Add to Sideboard - - - - - Select Printing - - - - - Show on EDHRec (Commander) - - - - - Show on EDHRec (Card) - - - - - Show Related cards - - - - + Add card to &maindeck - + Add card to &sideboard @@ -1086,72 +1603,72 @@ This is only saved for moderators and cannot be seen by the banned person. - + Select Printing - + Deck - + Deck &name: - + Banner Card/Tags Visibility Settings - + Show banner card selection menu - + Show tags selection menu - + &Comments: - + Group by: - + Format: - + Hash: - + &Increment number - + &Decrement number - + &Remove row - + Swap card to/from sideboard @@ -1159,17 +1676,17 @@ This is only saved for moderators and cannot be seen by the banned person. DeckEditorFilterDockWidget - + Filters - + &Clear all filters - + Delete selected @@ -1300,169 +1817,113 @@ This is only saved for moderators and cannot be seen by the banned person. DeckEditorSettingsPage - - + + Update Spoilers 更新预览卡牌 - - + Success 成功 - + Download URLs have been reset. 下载URL已重置。 - - Downloaded card pictures have been reset. - 下载的卡牌图片已被重置。 - - - - Error - 出现错误 - - - - One or more downloaded card pictures could not be cleared. - 一幅或以上的卡牌图片未能被清除。 - - - + Add URL 添加URL - - + + URL: URL: - - + + Edit URL 更改URL - - Network Cache Size: - 图片网路缓存大小: - - - - Redirect Cache TTL: - 网路缓处再定向生存时间 - - - - How long cached redirects for urls are valid for. - 再定向缓处有效时间 - - - - Picture Cache Size: - 图片缓存大小: - - - + Add New URL 添置新 URL - + Remove URL 移除 URL. - - Day(s) - - - - + Updating... 更新中... - + Choose path 选择路径 - + URL Download Priority URL下载优先次序 - + Spoilers 预览 - + Download Spoilers Automatically 自动下载预览 - + Spoiler Location: 预览位置: - + Last Change 最后变更 - + Spoilers download automatically on launch 开啟时自动下载预览 - + Press the button to manually update without relaunching 免重新啟动人手按制更新 - + Do not close settings until manual update is complete 人手更新完成前,不能关掉设置 - + Download card pictures on the fly 即时下载卡牌图片 - + How to add a custom URL 如何添加自定URL - - Delete Downloaded Images - 删除已下载的图片 - - - + Reset Download URLs 重置下载URL - - - On-disk cache for downloaded pictures - 下载图片专用硬盘缓储 - - - - In-memory cache for pictures not currently on screen - 现不在萤光幕上记忆中图片缓储 - DeckListHistoryManagerWidget @@ -1528,12 +1989,12 @@ This is only saved for moderators and cannot be seen by the banned person. DeckLoader - + Common deck formats (%1) - + All files (*.*) @@ -1755,27 +2216,27 @@ This is only saved for moderators and cannot be seen by the banned person. - + Moved to %1 1 × "%2" (%3) - + Removed "%1" (all copies) - + %1 1 × "%2" (%3) - + Added - + Removed @@ -1797,74 +2258,74 @@ This is only saved for moderators and cannot be seen by the banned person. DeckViewContainer - + Load deck... 载入牌库... - + Load remote deck... 载入伺服器上的牌库... - + Load from clipboard... - + Load from website... - + Unload deck - + Ready to start 準备好 - + Force start 强迫开始 - + Sideboard unlocked 备牌解锁 - + Sideboard locked 解锁锁上 - - + + Error 出现错误 - + The selected file could not be loaded. 选择的档案不能再入 - + Deck is greater than maximum file size. - + Are you sure you want to force start? This will kick all non-ready players from the game. - + Cockatrice @@ -1963,7 +2424,7 @@ This will kick all non-ready players from the game. - + Webpage 网页 @@ -2003,37 +2464,37 @@ This will kick all non-ready players from the game. 连接伺服器 - + Server URL 伺服器URL - + Communication Port 通信端口 - + Unique Server Name 独特伺服器名字 - + Connection Warning 连接警告 - + You need to name your new connection profile. 你必须将你的连接简介命名 - + Connect Warning 连接警告 - + The player name can't be empty. 玩家名称不能空置 @@ -2146,17 +2607,17 @@ This will kick all non-ready players from the game. 创建游戏 - + Game information 游戏资讯 - + Error 出现错误 - + Server error. 伺服器出现错误. @@ -2164,97 +2625,97 @@ This will kick all non-ready players from the game. DlgCreateToken - + &Name: &名称: - + Token 令牌 - + C&olor: &顏色: - + white 白色 - + blue 蓝色 - + black 黑色 - + red 红色 - + green 绿色 - + multicolor 多色 - + colorless 无色 - + &P/T: &力量/防御力: - + &Annotation: &註释: - + &Destroy token when it leaves the table &此令牌离场时会被摧毁 - + Create face-down (Only hides name) - + Token data 令牌资料 - + Show &all tokens 显示&所有令牌 - + Show tokens from this &deck 显示本牌库所有令牌 - + Choose token from list 从清单中选择令牌 - + Create token 派出令牌 @@ -2288,7 +2749,7 @@ This will kick all non-ready players from the game. - + @@ -2303,12 +2764,12 @@ This will kick all non-ready players from the game. - + Duplicate Tag - + This tag already exists. @@ -2495,12 +2956,12 @@ To remove your current avatar, confirm without choosing a new image. 请输入令牌名称: - + Error 出现错误 - + The chosen name conflicts with an existing card or token. Make sure to enable the 'Token' set in the "Manage sets" dialog to display them correctly. 现时选择的名字跟现有的卡牌或令牌出现衝突.请从"牌组管理"中啟动'令牌牌组',保持正确显示. @@ -2524,12 +2985,12 @@ Make sure to enable the 'Token' set in the "Manage sets" dia 未定义 - + Real name: 实名: - + Edit user profile 更改用户资料 @@ -2567,113 +3028,113 @@ Make sure to enable the 'Token' set in the "Manage sets" dia 2小时 - + Hide 'buddies only' games - + Hide full games - + Hide games that have started - + Hide password protected games - + Hide 'ignored user' games - + Hide games not created by buddies Hide games not created by buddy - + Hide games with forced open decklists - + &Newer than: &新过: - + Game &description: 游戏 &描述: - + &Creator name: &创建者名称: - + General 一般 - + &Game types &游戏类型 - + at &least: &至少: - + at &most: &最多: - + Maximum player count 可容纳玩家最高数量 - + Restrictions 限制 - + Show games only if &spectators can watch 只显示容许&观看者 观看的游戏 - + Show spectator password p&rotected games 显示有观看者&密码保护的游戏 - + Show only if spectators can ch&at 只显示容许观看者&聊天的游戏 - + Show only if spectators can see &hands 只显示容许观看者看&手牌 的游戏 - + Spectators 观看者 - + Filter games 筛选游戏 @@ -2976,37 +3437,37 @@ https://tappedout.net/mtg-decks/your-deck-name/ DlgMoveTopCardsUntil - + Card name (or search expressions): 卡牌名称(或搜寻表达式) - + Number of hits: 因为cockatrice原生没有ssl支援, 所以你不能自动下载更新. 请瀏览到我们的下载网页,进行人手更新. - + Auto play hits 开关副牌上锁 - + Put top cards on stack until... 把顶牌放在重迭区, 直到... - + No cards matching the search expression exists in the card database. Proceed anyways? 在卡牌资料库中找不到符合搜寻表达式的卡牌.你想继续吗? - + Cockatrice Cockatrice - + Invalid filter 筛选无效 @@ -3067,40 +3528,40 @@ Your email will be used to verify your account. 未定义 - + Real name: 实名: - + Register to server 註册加入伺服器 - - - - + + + + Registration Warning 注册警告 - + Your password is too short. 你的密码太短。 - + Your passwords do not match, please try again. 两次输入的密码不一致,请重新输入, - + Your email addresses do not match, please try again. 两次输入的电子邮箱不一致,请重新输入。 - + The player name can't be empty. 玩家名称不能空置 @@ -3108,17 +3569,17 @@ Your email will be used to verify your account. DlgRollDice - + Number of sides: 骰子面数: - + Number of dice: 骰子数目: - + Roll Dice 掷骰 @@ -3169,12 +3630,12 @@ Your email will be used to verify your account. DlgSettings - + Unknown Error loading card database 读取卡牌资料库时出现不知名错误 - + Your card database is invalid. Cockatrice may not function correctly with an invalid database @@ -3191,7 +3652,7 @@ Would you like to change your database location setting? 你想要重新设置卡牌资料库路径吗? - + Your card database version is too old. This can cause problems loading card information or images @@ -3208,7 +3669,7 @@ Would you like to change your database location setting? 你想要重新设置卡牌资料库路径吗? - + Your card database did not finish loading Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues with your cards.xml attached @@ -3221,7 +3682,7 @@ Would you like to change your database location setting? 你想要重新设置卡牌资料库路径吗? - + File Error loading your card database. Would you like to change your database location setting? @@ -3229,7 +3690,7 @@ Would you like to change your database location setting? 你想要重新设置卡牌资料库路径吗? - + Your card database was loaded but contains no cards. Would you like to change your database location setting? @@ -3238,7 +3699,7 @@ Would you like to change your database location setting? 你想要重新设置卡牌资料库路径吗? - + Unknown card database load status Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues @@ -3254,59 +3715,64 @@ https://github.com/Cockatrice/Cockatrice/issues 你想不想更改你的资料库位置设定? - - - + + + Error 出现错误 - + The path to your deck directory is invalid. Would you like to go back and set the correct path? 你的牌库目录路径现在无效. 你想要重新设置套牌路径吗? - + The path to your card pictures directory is invalid. Would you like to go back and set the correct path? 你的卡牌图片目录路径现在无效. 你想要重新设置卡牌图片资料库路径吗? - + Settings 设定 - + General 一般 - + Appearance 外观 - + User Interface 用户界面 - + + Storage + + + + Card Sources 卡牌来源 - + Chat 聊天 - + Sound 声音 - + Shortcuts 快捷键 @@ -3398,9 +3864,9 @@ You can always change this behavior in the 'General' settings tab. - - - + + + Error 出现错误 @@ -3453,31 +3919,31 @@ Please visit the download page to update manually. - + Update Available 现有更新版本 - + A new version of Cockatrice is available! Cockatrice有新版本可用! - + New version 新版本 - + Released 已发行 - + Changelog 变更记载 @@ -3487,50 +3953,50 @@ Please visit the download page to update manually. 你打算现在更新吗? - + Unfortunately, the automatic updater failed to find a compatible download. You may have to manually download the new version. - + Please check the <a href="%1">releases page</a> on our Github and download the build for your system. - - - + + + Update Error 更新出现错误 - + An error occurred while checking for updates: 检测更新时出现错误: - + An error occurred while downloading an update: 下载更新时出现错误 - + Installing... 安装中 - + Cockatrice is unable to open the installer. Cockatrice 不能啟动安装程式 - + Try to update manually by closing Cockatrice and running the installer. 请你尝试人手更新, 关掉cockatrice, 然后重开安装程式. - + Download location 下载位置 @@ -3620,67 +4086,67 @@ You may have to manually download the new version. DrawProbabilityWidget - + Draw Probability - + Probability of drawing - + Card Name - + Type - + Subtype - + Mana Value - + At least - + Exactly - + card(s) having drawn at least - + cards - + Category - + Qty - + Odds (%) @@ -3794,7 +4260,7 @@ You may have to manually download the new version. FilterBuilder - + Type your filter here 输入你的筛选类型 @@ -3825,22 +4291,22 @@ You may have to manually download the new version. GameEventHandler - + kicked by game host or moderator - + player left the game - + player disconnected from server - + reason unknown @@ -3848,140 +4314,140 @@ You may have to manually download the new version. GameSelector - - - - - - - - - + + + + + + + + + Error 出现错误 - + Please join the appropriate room first. 请先参加合适房间 - + Wrong password. 密码错误. - + Spectators are not allowed in this game. 这个游戏不允许观眾加入. - + The game is already full. 这个游戏已经满了. - + The game does not exist any more. 这个游戏已经不存在了。 - + This game is only open to registered users. 这个游戏只开放给註册用户. - + This game is only open to its creator's buddies. 这个游戏只开放给房间主办的好友。 - + You are being ignored by the creator of this game. 你被这个房间的主办屏蔽了. - + Join Game - + Spectate Game - + Game Information - + Join Game as Judge - + Spectate Game as Judge - + Join game 加入游戏 - + Password: 密码: - + Please join the respective room first. 请先参加相关的房间. - + &Filter games &筛选游戏 - + C&lear filter &清除筛选项目 - + C&reate &创建 - + &Join &加入 - + Join as judge - + J&oin as spectator &作为观看者加入 - + Join as judge spectator - + Games shown: %1 / %2 显示游戏:%1/%2 - + Games 游戏 @@ -3989,32 +4455,32 @@ You may have to manually download the new version. GameSelectorQuickFilterToolBar - + All types - + Filter by game name... - + Filter by game type/format - + Hide games not created by buddies - + Hide full games - + Hide started games @@ -4022,105 +4488,105 @@ You may have to manually download the new version. GamesModel - + >1 day >1日 - + %1%2 hr short age in hours %1%2 小时 - + new 新建 - + %1%2 min short age in minutes %1%2 分鐘 - + password 密码 - + buddies only 仅限好友 - + reg. users only 仅限註册用户 - + open decklists - + can chat 允许聊天 - + see hands 查看手牌 - + can see hands 允许查看手牌 - + not allowed 不允许 - + Room 房间 - + Age 时长 - + Description 描述: - + Creator 创建者 - + Type 类型 - + Restrictions 限制 - + Players 玩家 - + Spectators 观看者 @@ -4128,143 +4594,158 @@ You may have to manually download the new version. GeneralSettingsPage - + Reset all paths 重置所有路径 - + All paths have been reset 重置所有路径 - - - - - - - + + + + + + + Choose path 选择路径 - - Personal settings - 个人设定 + + Language settings + - + Language: 语言: - + + Version settings + + + + + Card database + + + + + Startup settings + + + + Paths (editing disabled in portable mode) 路径(在便携模式下不能编辑) - + Paths 路径 - + How to help with translations - + Decks directory: 牌库路径: - + Filters directory: - + Replays directory: 游戏录像目录: - + Pictures directory: 图片目录: - + Card database: 卡牌资料库: - + Custom database directory: 自主资料库目录 - + Token database: 令牌资料库: - + Update channel 更新渠道 - + Check for client updates on startup 啟动时检查客户更新 - + Check for card database updates on startup - + Don't check - + Prompt for update - + Always update in the background - + Check for card database updates every - + days - + Notify if a feature supported by the server is missing in my client 如果客户端缺少伺服器支持的更功能时,请提示我 - + Automatically run Oracle when running a new version of Cockatrice 啟动新版本Cockatrice时,自动运行Oracle. - + Show tips on startup 显示启动时提示 - + Last update check on %1 (%2 days ago) @@ -4272,47 +4753,47 @@ You may have to manually download the new version. GraveyardMenu - + &Graveyard - + &View graveyard - + &Move graveyard to... - + &Top of library - + &Bottom of library - + &All players - + &Hand - + &Exile - + Reveal random card to... @@ -4320,88 +4801,88 @@ You may have to manually download the new version. HandMenu - + &Hand - + &View hand - + Sort hand by... - + Name - + Type - + Mana Value - + Take &mulligan (Choose hand size) - + Take mulligan (Same hand size) - + Take mulligan (Hand size - 1) - + &Move hand to... - + &Top of library - + &Bottom of library - + &Graveyard - + &Exile - + &Reveal hand to... - - + + All players - + Reveal r&andom card to... @@ -4409,52 +4890,52 @@ You may have to manually download the new version. HomeWidget - + Create New Deck - + Browse Decks - + Browse Card Database - + Browse EDHRec - + Browse Archidekt - + View Replays - + Quit - + Connecting... - + Connect - + Play @@ -4462,213 +4943,213 @@ You may have to manually download the new version. LibraryMenu - + &Library - + &View library - + View &top cards of library... - + View bottom cards of library... - + Reveal &library to... - + Lend library to... - + Reveal &top cards to... - + &Top of library... - + &Bottom of library... - + &Always reveal top card - + &Always look at top card - + &Open deck in deck editor - + &Draw card - + D&raw cards... - + &Undo last draw - + Shuffle - + &Play top card - + Play top card &face down - + Put top card on &bottom - + Move top card to grave&yard - + Move top card to e&xile - + Move top cards to &graveyard... - + Move top cards to graveyard face down... - + Move top cards to &exile... - + Move top cards to exile face down... - + Put top cards on stack &until... - + Shuffle top cards... - + &Draw bottom card - + D&raw bottom cards... - + &Play bottom card - + Play bottom card &face down - + Move bottom card to grave&yard - + Move bottom card to e&xile - + Move bottom cards to &graveyard... - + Move bottom cards to graveyard face down... - + Move bottom cards to &exile... - + Move bottom cards to exile face down... - + Put bottom card on &top - + Shuffle bottom cards... - - + + &All players - + Reveal top cards of library - + Number of cards: (max. %1) @@ -4676,633 +5157,273 @@ You may have to manually download the new version. MainWindow - - - The server has reached its maximum user capacity, please check back later. - 伺服器已达到最最高用户限额,请另外选择吉时再试. - - - - There are too many concurrent connections from your address. - 你的网路地址拥有太多同时连繫. - - - - Banned by moderator - 你已被版主禁用 - - - - Expected end time: %1 - 预计结束时间: %1 - - - - This ban lasts indefinitely. - 这是无限期禁用 - - - - Scheduled server shutdown. - 排期伺服器关机 - - - - - Invalid username. - 不可用的用户名。 - - - - You have been logged out due to logging in at another location. - 因为你已在另外的位置登入, 所以你现有的联繫已被登出. - - - - Connection closed - 连繫已被关闭 - - - - The server has terminated your connection. -Reason: %1 - 伺服器已经中断你的联繫/ -原因: %1 - - - - The server is going to be restarted in %n minute(s). -All running games will be lost. -Reason for shutdown: %1 - 伺服器将会在 %n 分鐘内重新啟动. 所有进行中的游戏都会报销.伺服器关闭原因: %1 - - - - Scheduled server shutdown - 排期伺服器关机 - - - - - Success - 成功 - - - - Registration accepted. -Will now login. - 註册已成功。 -现在登入。 - - - - Account activation accepted. -Will now login. - 户口啟动已经成功 -现在登入. - - - - + + Player %1 玩家 %1 - + Load replay 载入游戏录像 - + About Cockatrice 关於Cockatrice - + Version 版本 - + Cockatrice Webpage Cockatrice 网页 - + Project Manager: 项目经理: - + Past Project Managers: 前项目经理: - + Developers: 开发者: - + Our Developers 我们的开发者: - + Help Develop! 协助开发! - + Translators: 翻译者: - + Our Translators 翻译者名单 - + Help Translate! 协助翻译! - + Support: 支援: - + Report an Issue 报告问题 - + Troubleshooting 排除故障 - + F.A.Q. 问答 - - - - - - - - - - - - - - - - - - - - + + Error 出现错误 - - Server timeout - 伺服器超时 - - - - Failed Login - 登入失败 - - - - Your client seems to be missing features this server requires for connection. - 你的客户端似乎脱小本伺服器需要用来达成连繫的功能. - - - - To update your client, go to 'Help -> Check for Client Updates'. - 请去"求助->检查客户端更新" 内更新你的客户. - - - - Incorrect username or password. Please check your authentication information and try again. - 用户名或密码错误。请检查账号信息,然后重试。 - - - - There is already an active session using this user name. -Please close that session first and re-login. - 已经有在线用户正在使用这用户名.请先关闭连线,然后重新登入. - - - - - You are banned until %1. - 你被禁止直到: %1. - - - - - You are banned indefinitely. - 你已被永久封禁. - - - - This server requires user registration. Do you want to register now? - 本伺服器要求用户註册. 你想不想现在登记? - - - - This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. -Please close and reopen your client to try again. - 本伺服器要求用户拥有客户id. 你的客户端现在不能造成客户id,或是你正在使用修改过的客户端. -请你关掉现有的客户端,然后重新啟动, 择吉日再试。 - - - - An internal error has occurred, please close and reopen Cockatrice before trying again. -If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. - 因为内部出现故障, 请关闭,重开Cockatrice 然后再次尝试. -如果故障继续出现,请将客户端软件升级为最新版本, 有需要时请联络软件开发人士. - - - - Account activation - 户口啟动 - - - - Your account has not been activated yet. -You need to provide the activation token received in the activation email. - 你的户口还未尝啟动. -你需要提供从啟动电邮得来的验证码. - - - - Server Full - 伺服器载满 - - - - Unknown login error: %1 - 不知名登入错误: %1 - - - - - -This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. - -这通常意味住你的客户端版本已经过期,引起你的客户端不能明晓伺服器的回覆. - - - - Your username must respect these rules: - 您的用户名字必须遵守以下规则: - - - - is %1 - %2 characters long - 在%1 - %2个字符之间。 - - - - can %1 contain lowercase characters - 可以 $1 包含细楷/小写字母 - - - - - - - NOT - 并否 - - - - can %1 contain uppercase characters - 可以 $1 包含大楷/大写字母 - - - - can %1 contain numeric characters - 可以 $1 包含数字 - - - - can contain the following punctuation: %1 - 可以包含以下符号:%1 - - - - first character can %1 be a punctuation mark - 第一个字符可以 %1 是标点符号 - - - - no unacceptable language as specified by these server rules: - note that the following lines will not be translated - 在以下伺服器规矩中,道明不能接受的语言: - - - - can not contain any of the following words: %1 - 不能含有以下字眼: %1 - - - - can not match any of the following expressions: %1 - 不能含有以下语句: %1 - - - - You may only use A-Z, a-z, 0-9, _, ., and - in your username. - 你的用户名字只可以用以下的字符: A-Z, a-z, 0-9, _, ., 和 - . - - - - - - - - - Registration denied - 註册被拒绝 - - - - Registration is currently disabled on this server - 这伺服器暂时谢绝登记 - - - - There is already an existing account with the same user name. - 这个用户名字已经被使用. - - - - It's mandatory to specify a valid email address when registering. - 登记时必要指明合能用的电邮地址. - - - - It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. - 你看来好像在本伺服器尝试申请新户口, 但是你已经有跟你电邮掛鉤的户口名字.我们的伺服器限制每用户在每个地址可以有多少个户口. 如果需要更多援助或是得取你的登入资料,请联络伺服器办事人. - - - - Password too short. - 密码太短。 - - - - Registration failed for a technical problem on the server. - 由於伺服器出现技术问题, 你的登记暂时失败. - - - - The connection to the server has been lost. - - - - - Unknown registration error: %1 - 不知名登记错误: %1 - - - - Account activation failed - 户口啟动失败 - - - - Socket error: %1 - 接口错误: %1 - - - - You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. -Local version is %1, remote version is %2. - 你正在尝试跟过时的伺服器连繫. 请把你的 Cockatrice 版本降级,或是连繫於合适的伺服器. 当地/离线版本维 %1, 遥距/上线版本维 %2. - - - - Your Cockatrice client is obsolete. Please update your Cockatrice version. -Local version is %1, remote version is %2. - 你的 Cockatrice 版本现已过时.请把你的版本更新. -当地/离线版本维 %1, 遥距/上线版本维 %2. - - - - Connecting to %1... - 向 %1 连接中... - - - - Registering to %1 as %2... - 正在用%2的身份登记到%1伺服器... - - - - Disconnected - 已经断线了 - - - - Connected, logging in at %1 - 连线成功, 在%1登入 - - - - - - Requesting forgotten password to %1 as %2... - 正在用%2的身份对%1要求遗忘密码. - - - + &Connect... &连线... - + &Disconnect &断线 - + Start &local game... 开始&离线游戏 - + &Watch replay... &观看游戏录像 - + &Full screen &全屏 - + &Register to server... &註册加入伺服器 - + &Restore password... &弄回密码 - + &Settings... 设置⋯ - + &Exit &退出 - + A&ctions &动作 - + &Cockatrice &Cockatrice - + C&ard Database &卡牌资料库 - + &Manage sets... &管理牌组 - + Edit custom &tokens... 编辑自定 &令牌... - + Open custom image folder 打开自定图片文件夹 - + Open custom sets folder 打开自定牌组文件夹 - + Add custom sets/cards 添加自定卡组/卡牌 - + Reload card database 刷新卡牌资料库 - + Tabs - + &Help &求助 - + &About Cockatrice &关於Cockatrice - + &Tip of the Day &每日提示 - + Check for Client Updates 检查客户端更新 - + Check for Card Updates... 检查卡牌更新 - + Check for Card Updates (Automatic) - + Show Status Bar - + View &Debug Log 查看&侦错纪录 - + Open Settings Folder 打开设置文件夹 - + Show/Hide 显示/隐藏 - + New Version 新版本 - + Congratulations on updating to Cockatrice %1! Oracle will now launch to update your card database. 恭喜你将Cockatrice升级为%1. 现在会帮你啟动oracle, 更新你的卡牌资料库. - + Cockatrice installed Cockatrice 已被安装 - + Congratulations on installing Cockatrice %1! Oracle will now launch to install the initial card database. 恭喜你成功安装Cockatrice%1. 现在会帮你啟动oracle, 安装你的卡牌资料库. - + Card database 卡牌资料库 - + Cockatrice is unable to load the card database. Do you want to update your card database now? If unsure or first time user, choose "Yes" @@ -5311,29 +5432,29 @@ If unsure or first time user, choose "Yes" 如果不确定, 或者是第一次使用,请选择“是/Yes” - - + + Yes 是/Yes - - + + No 否/No - + Open settings 打开设置 - + New sets found 发现新卡组 - + %n new set(s) found in the card database Set code(s): %1 Do you want to enable it/them? @@ -5342,17 +5463,22 @@ Do you want to enable it/them? 要啟用它们吗? - + + Yes, always enable + + + + View sets 查看卡组 - + Welcome 欢迎 - + Hi! It seems like you're running this version of Cockatrice for the first time. All the sets in the card database have been enabled. Read more about changing the set order or disabling specific sets and consequent effects in the "Manage Sets" dialog. @@ -5361,166 +5487,131 @@ Read more about changing the set order or disabling specific sets and consequent 请阅读,了解更多在“卡组设置”对话框裡.更改卡组顺序或者禁用某些卡组的功能. - - + Information 资讯 - + A card database update is already running. 资料库更新现已进行. - + Unable to run the card database updater: 不能运行卡牌资料附更新程式: - + Card database update running. - + Failed to start. The file might be missing, or permissions might be incorrect. - + The process crashed some time after starting successfully. - + Timed out. The process took too long to respond. The last waitFor...() function timed out. - + An error occurred when attempting to write to the process. For example, the process may not be running, or it may have closed its input channel. - + An error occurred when attempting to read from the process. For example, the process may not be running. - + Unknown error occurred. - + The card database updater exited with an error: %1 - - This server supports additional features that your client doesn't have. -This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. - -To update your client, go to Help -> Check for Updates. - 本伺服器支持你现在客户端软件欠缺的额外功能. - -这信息不是大问题, 但此信息可能意会现在已有新版本的 Cockatrice, 或是本伺服器运用自设或预先释放版本. - -请到"援助->检查更新"去更新你的客户端. - - - - - - - + + + + + Load sets/cards 载入卡组/卡牌 - + Selected file cannot be found. 找不到选择的文件。 - + You can only import XML databases at this time. 你暂时只能导入xml资料库. - + The new sets/cards have been added successfully. Cockatrice will now reload the card database. 已经成功添加新的卡组/卡牌. Cockatrice会重新载入卡牌资料库. - + Sets/cards failed to import. 卡组/卡牌导入失败. - - - - - Reset Password - 重设密码 - - - - Your password has been reset successfully, you can now log in using the new credentials. - 你的密码已经成功重置, 你可以用新密码重新登入。 - - - - Failed to reset user account password, please contact the server operator to reset your password. - 重置用户密码失败, 请联络伺服器管责人重置密码. - - - - Activation request received, please check your email for an activation token. - 已经收到啟动要求, 请从你的电邮获取啟动验证码. - ManaBaseConfigDialog - + Mana Base Configuration - + Display type: - + pie - + bar - + combinedBar - + Filter Colors (optional): - + OK - + Cancel @@ -5592,27 +5683,27 @@ Cockatrice will now reload the card database. ManaDevotionConfigDialog - + Display type: - + pie - + bar - + combinedBar - + Filter Colors (optional): @@ -5682,610 +5773,615 @@ Cockatrice will now reload the card database. MessageLogWidget - + from play 从战场上 - + from their graveyard 从它们的坟场 - + from exile 从放逐区 - + from their hand 从它们的手牌 - + the top card of %1's library %1牌库的顶牌 - + the top card of their library 它们的顶牌 - + from the top of %1's library 从%1牌库的顶 - + from the top of their library 从它们1牌库顶 - + the bottom card of %1's library %1牌库的底牌 - + the bottom card of their library 它们牌库的底牌 - + from the bottom of %1's library 从%1的牌库底 - + from the bottom of their library 从它们的牌库底 - + from %1's library 从%1的牌库 - + from their library 从它们的牌库 - + from sideboard 从备牌中 - + from the stack 从迭区中 - + from custom zone '%1' - + %1 is now keeping the top card %2 revealed. %1现在保持%2顶牌显现. - + %1 is not revealing the top card %2 any longer. %1不再展示牌库顶牌%2。 - + %1 can now look at top card %2 at any time. %1可以随时查看牌库顶牌 %2. - + %1 no longer can look at top card %2 at any time. %1再不可以随时查看牌库顶牌 %2. - + %1 attaches %2 to %3's %4. %1 结附 %2 指向 %3的 %4. - + %1 has conceded the game. %1 在此游戏宣佈降服. - + %1 has unconceded the game. %1 收回在此游戏降服. - + %1 has restored connection to the game. %1已恢復连接。 - + %1 has lost connection to the game. %1 已失去连接。 - + %1 points from their %2 to themselves. %1 从它们的%2 指向自己。 - + %1 points from their %2 to %3. %1 从它们的%2指向%3。 - + %1 points from %2's %3 to themselves. %1 从%2的%3 指向自己。 - + %1 points from %2's %3 to %4. %1 从%2的%3 指向%4。 - + %1 points from their %2 to their %3. %1 从它们的%2指向它们的%3。 - + %1 points from their %2 to %3's %4. %1 从它们的%2指向%3的%4。 - + %1 points from %2's %3 to their own %4. %1 从%2的%3指向自己的%4。 - + %1 points from %2's %3 to %4's %5. %1 从%2的%3指向%4的%5。 - + %1 creates a face down token. - + %1 creates token: %2%3. %1 派出令牌: %2%3 - + %1 has loaded a deck (%2). %1 已载入牌库(%2) - + %1 has loaded a deck with %2 sideboard cards (%3). %1已载入含有 %2张备牌的牌库(%3). - + %1 destroys %2. %1销毁了%2。 - + a card 一张牌 - + %1 gives %2 control over %3. %1将%3的控制转移给%2. - + %1 puts %2 into play%3 face down. %1将%2%3牌面朝下地放进战场。 - + %1 puts %2 into play%3. %1将%2放进战场%3。 - + %1 puts %2%3 into their graveyard face down. - + %1 puts %2%3 into their graveyard. %1将%2%3置入它们的的坟场。 - + %1 exiles %2%3 face down. - + %1 exiles %2%3. %1 放逐%2%3. - + %1 moves %2%3 to their hand. %1将%2%3移到它们的的手牌中。 - + %1 puts %2%3 into their library. %1将%2%3放入它们的牌库。 - + %1 puts %2%3 onto the bottom of their library. %1将%2%3放在它们的牌库底部。 - + %1 puts %2%3 on top of their library. %1将%2%3放在它们的牌库顶部。 - + %1 puts %2%3 into their library %4 cards from the top. %1将%2%3放在它们的牌库顶部%4张牌以下。 - + %1 moves %2%3 to sideboard. %1 将%2%3移到副牌库. - + %1 plays %2%3 face down. - + %1 plays %2%3. %1使出 %2%3。 - + %1 moves %2%3 to custom zone '%4' face down. - + %1 moves %2%3 to custom zone '%4'. - + %1 tries to draw from an empty library %1尝试从空牌库抽牌 - + %1 draws %2 card(s). %1抽%2张牌。 - + %1 is looking at %2. %1 正在查看%2。 - + %1 is looking at the %4 %3 card(s) %2. top card for singular, top %3 cards for plural %1 正在查看%2的 %4 %3张卡牌. - + bottom 底部 - + top 顶部 - + %1 turns %2 face-down. %1把%2翻为面朝下。 - + %1 turns %2 face-up. %1把%2翻为面朝上。 - + The game has been closed. 游戏已经关闭。 - + The game has started. 游戏已开始。 - + You are flooding the game. Please wait a couple of seconds. - + %1 has joined the game. %1 已经加入游戏。 - + %1 is now watching the game. %1 正在旁观游戏。 - + You have been kicked out of the game. 你已被踢出游戏。 - + %1 has left the game (%2). %1 已经离开游戏(%2)。 - + %1 is not watching the game any more (%2). %1 已经不再旁观游戏(%2)。 - + %1 is not ready to start the game any more. %1 还未準备好开始游戏。 - + %1 shuffles their deck and draws a new hand of %2 card(s). %1将其牌库洗牌,然后抽%2张牌作为新的起手。 - + %1 shuffles their deck and draws a new hand. %1将其牌库洗牌,然后抽起新的手牌。 - + You are watching a replay of game #%1. 你正在观看游戏#%1的录像。 - + %1 is ready to start the game. %1已準备好开始游戏。 - + cards an unknown amount of cards 卡牌(眾)  - + %1 card(s) a card for singular, %1 cards for plural %1 卡牌(眾)  - + %1 lends %2 to %3. %1把&2借给%3. - + %1 reveals %2 to %3. %1把%2展示给%3. - + %1 reveals %2. %1 展示 %2。 - + %1 randomly reveals %2%3 to %4. %1随机展示%2%3给%4。 - + %1 randomly reveals %2%3. %1随机展示%2%3。 - + %1 peeks at face down card #%2. %1查看面朝下的卡牌#%2。 - + %1 peeks at face down card #%2: %3. %1查看面朝下的卡牌#%2:%3。 - + %1 reveals %2%3 to %4. %1展示%2%3给%4。 - + %1 reveals %2%3. %1展示%2%3。 - + %1 reversed turn order, now it's %2. %1 逆转回合次序,现在轮到%2。 - + reversed 逆转 - + normal 正常 - + Heads 正面 - + Tails 翻面 - + %1 flipped a coin. It landed as %2. %1掷了硬币。结果为%2。 - + %1 rolls a %2 with a %3-sided die. %1掷%3面骰子,结果为%2。 - + %1 flips %2 coins. There are %3 heads and %4 tails. %1掷了%2个硬币。结果为%3个正面和%4个反面。 - + %1 rolls a %2-sided dice %3 times: %4. %1把%2面骰子掷了%3次:%4 - + %1's turn. 轮到 %1. - + %1 sets annotation of %2 to %3. %1给%2添加了註释成%3。 - - %1 places %2 "%3" counter(s) on %4 (now %5). + + %1 places %2 %3%4 counter(s) on %5 (now %6). - - %1 removes %2 "%3" counter(s) from %4 (now %5). + + %1 removes %2 %3%4 counter(s) from %5 (now %6). - + + %1 failed to undo their last draw. + + + + %1 sets counter %2 to %3 (%4%5). %1 将%2指示物设置为 %3 (%4%5)。 - + %1 sets %2 to not untap normally. %1 将 %2 设置为不被重置。 - + %1 sets %2 to untap normally. %1 将 %2 设置为照常重置。 - + %1 removes the PT of %2. %1 移除%2的力量/防御. - + %1 changes the PT of %2 from nothing to %4. %1把%2的力量/防御从无数值转为%4. - + %1 changes the PT of %2 from %3 to %4. %1把%2的力量/防御从%3转为%4. - + %1 has locked their sideboard. %1已经锁定它们的的副牌库。 - + %1 has unlocked their sideboard. %1已经把它们的的副牌库解锁。 - + %1 taps their permanents. %1横置它们的永久卡牌 - + %1 untaps their permanents. %1重置它们的永久卡牌. - + %1 taps %2. %1 横置 %2. - + %1 untaps %2. %1 重置 %2。 - + %1 shuffles %2. % 1 对 %2 洗牌 - + %1 shuffles the bottom %3 cards of %2. %1 洗 %2 底的 %3 张卡牌. - + %1 shuffles the top %3 cards of %2. %1 洗 %2 顶的 %3 张卡牌. - + %1 shuffles cards %3 - %4 of %2. %1到%2.洗%3 - %4 - + %1 unattaches %2. %1 取消了%2的结附。 - + %1 undoes their last draw. %1 放回它们最后一张抽到的卡牌. - + %1 undoes their last draw (%2). %1 放回它们最后一张抽到的卡牌(%2). @@ -6293,110 +6389,115 @@ Cockatrice will now reload the card database. MessagesSettingsPage - + Word1 Word2 Word3 词1 词2 词3 - + Add New Message 添加新信息 - + Edit Message 更改信息 - + Remove Message 移除信息 - + Add message 添加信息 - - + + Message: 信息: - + Edit message 更改信息 - + Chat settings 聊天设定 - + Custom alert words 自定通知触发词语 - + Enable chat mentions 允许聊天中提到某人 - + Enable mention completer 允许自动完成提名 - + In-game message macros 游戏内短信速键 - + How to use in-game message macros 如何使用游戏内短信速键 - + Ignore chat room messages sent by unregistered users 不要显示未註册用户的聊天消息。 - + Ignore private messages sent by unregistered users 不要显示未註册用户的私人消息. - - + + Ignore private messages sent by non-buddy users + + + + + Invert text color 反转文本顏色 - + Enable desktop notifications for private messages 容许桌面提醒提及私人消息 - + Enable desktop notification for mentions 容许桌面提醒提及聊天提名 - + Enable room message history on join 加入聊天室时开启消息历史 - - + + (Color is hexadecimal) (顏色为16进制) - + Separate words with a space, alphanumeric characters only 将单词以空格区分,仅支持字母. @@ -6404,42 +6505,42 @@ Cockatrice will now reload the card database. MoveMenu - + Move to - + &Top of library in random order - + X cards from the top of library... - + &Bottom of library in random order - + T&able - + &Hand - + &Graveyard - + &Exile @@ -6452,47 +6553,47 @@ Cockatrice will now reload the card database. 卡牌类型 - + Mana Value 法力数值 - + Color(s) 颜色(眾)  - + Loyalty 忠诚值 - + Main Card Type 卡牌主类型 - + Mana Cost 法力费用 - + P/T 力量/防御力 - + Side 正面/底面 - + Layout 佈局 - + Color Identity 顏色身份 @@ -6515,6 +6616,152 @@ Cockatrice will now reload the card database. 重迭方向 + + PaletteEditorDialog + + + + Reset + + + + + + Apply + + + + + + Save && Apply + + + + + ▼ Edit Palette + + + + + + ▶ Edit Palette + + + + + Palette Editor — %1 + + + + + <b>Palette Editor</b> &nbsp;·&nbsp; %1 + + + + + This theme ships no default palette files + + + + + Replace current colours with the theme author's defaults + + + + + Switch between the light and dark palette files + + + + + Editing: + + + + + Show or hide the per-role colour grid for manual tweaks + + + + + ↺ Revert to theme default + + + + + Discard unsaved edits and restore the last saved palette + + + + + Preview this palette without saving to disk + + + + + Write palette-%1.toml and reload the theme + + + + + Cannot save: this theme has no directory on disk + + + + + Save failed + + + + + Could not write %1 to: +%2 + + + + + No default found + + + + + No default palette file found for the "%1" scheme. + + + + + PaletteGridWidget + + + Active + + + + + Disabled + + + + + Inactive + + + + + Normal interactive state + + + + + Widget is disabled / not interactive + + + + + Window is in background / unfocused + + + Phase @@ -6581,57 +6828,57 @@ Cockatrice will now reload the card database. PhasesToolbar - + Untap step 重置步 - + Upkeep step 维持步 - + Draw step 抓牌步 - + First main phase 第一主阶段 - + Beginning of combat step 战斗开始步 - + Declare attackers step 宣告阻挡步 - + Declare blockers step 宣告阻挡步 - + Combat damage step 战斗损害步 - + End of combat step 战斗结束步 - + Second main phase 第二主阶段 - + End of turn step 回合完结部 @@ -6639,7 +6886,7 @@ Cockatrice will now reload the card database. PictureLoader - + en code for scryfall's language property, not available for all languages zhs @@ -6648,151 +6895,142 @@ Cockatrice will now reload the card database. PlayerActions - - View top cards of library - - - - - - - - - - - - - Number of cards: (max. %1) - - - - - View bottom cards of library - - - - - Shuffle top cards of library - - - - - Shuffle bottom cards of library - - - - - Draw hand - - - - - 0 and lower are in comparison to current hand size - - - - - Draw cards - - - - - - - + + + + grave - - - - + + + + exile + + + PlayerDialogs - + + View top cards of library + + + + + + + + + + + + + Number of cards: (max. %1) + + + + + View bottom cards of library + + + + + Shuffle top cards of library + + + + + Shuffle bottom cards of library + + + + + Draw hand + + + + + 0 and lower are in comparison to current hand size + + + + + Draw cards + + + + Move top cards to %1 - + Move bottom cards to %1 - + Draw bottom cards - - - C&reate another %1 token - - - - + Create tokens - - + Number: - + Place card X cards from top of library - + Which position should this card be placed: - + (max. %1) - + Change power/toughness - + Change stats to: - + Set annotation - + Please enter the new annotation: - - - Set counters - - PlayerMenu - + Player "%1" - + &Counters @@ -6822,7 +7060,7 @@ This setting means you'll only see the default printing for each card, instead o - + Printing Selector @@ -6830,22 +7068,22 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelectorCardOverlayWidget - + Preference 喜好 - + Pin Printing 版本上别针 - + Unpin Printing 版本脱别针 - + Show Related cards 显示关联卡牌 @@ -6916,57 +7154,57 @@ This setting means you'll only see the default printing for each card, instead o PtMenu - + Power / toughness - + &Increase power - + &Decrease power - + I&ncrease toughness - + D&ecrease toughness - + In&crease power and toughness - + Dec&rease power and toughness - + Increase power and decrease toughness - + Decrease power and increase toughness - + Set &power and toughness... - + Reset p&ower and toughness @@ -6974,37 +7212,37 @@ This setting means you'll only see the default printing for each card, instead o QMenuBar - + Services 服务 - + Hide %1 隐藏 %1 - + Hide Others 隐藏其他 - + Show All 全部展示 - + Preferences... 喜好... - + Quit %1 退出%1 - + About %1 关於 %1 @@ -7012,17 +7250,17 @@ This setting means you'll only see the default printing for each card, instead o QObject - + Cockatrice card database (*.xml) Cockatrice卡牌资料库 (*.xml) - + All files (*.*) 所有档案格式 (*.*) - + Cockatrice replays (*.cor) Cockatrice录像文件 (*.cor) @@ -7082,110 +7320,177 @@ Are you sure you would like to disable this feature? QPlatformTheme - + OK OK/确认 - + Save 保存 - + Save All 全部保存 - + Open 打开 - + &Yes &是/Yes - + Yes to &All &全选是/Yes - + &No &否/No - + N&o to All &全选否/No - + Abort 退出 - + Retry 重试 - + Ignore 不理 - + Close 关闭 - + Cancel 取消 - + Discard 弃牌 - + Help 协助 - + Apply 应用 - + Reset 重设 - + Restore Defaults 恢復常用认值 + + QuickSetupPanel + + + %1% + + + + + <b>Quick Setup</b> + + + + + Generate all palette roles automatically from a single accent colour + + + + + Accent: + + + + + Primary hue. Used directly for highlights and links. +At high intensity it also tints buttons and backgrounds. + + + + + Intensity: + + + + + Subtle + + + + + Full colour + + + + + 0–30 Subtle tint — only highlights and links change hue +30–70 Accented — buttons, tooltips, and borders join in +70–100 Full colour — backgrounds, everything + + + + + 70% + + + + + Generate ↓ + + + + + Derive all palette roles from the accent colour above. +Fine-tune individual colours in the grid afterwards. + + + RemoteDeckList_TreeModel - + Name 名字 - + ID ID - + Upload time 上载时间 @@ -7193,32 +7498,32 @@ Are you sure you would like to disable this feature? RemoteReplayList_TreeModel - + ID ID - + Name 名字 - + Players 玩家(眾) - + Keep 选用 - + Time started 开始时间 - + Duration (sec) 维持(秒) @@ -7226,37 +7531,37 @@ Are you sure you would like to disable this feature? RfgMenu - + &Exile - + &View exile - + &Move exile to... - + &Top of library - + &Bottom of library - + &Hand - + &Graveyard @@ -7302,7 +7607,7 @@ Are you sure you would like to disable this feature? SayMenu - + S&ay @@ -7343,27 +7648,27 @@ Are you sure you would like to disable this feature? SetsModel - + Enabled 已启用 - + Set type 设置类型 - + Set code 设置代码 - + Long name 长名 - + Release date 发行日期 @@ -7371,53 +7676,53 @@ Are you sure you would like to disable this feature? ShortcutSettingsPage - - + + Restore all default shortcuts 恢復所有常用快键 - + Do you really want to restore all default shortcuts? 确定要恢復所有常用快键吗? - + Clear all default shortcuts 清除所有常用快键 - + Do you really want to clear all shortcuts? 确认清除所有快键吗? - + Section: 部分: - + Action: 行动: - + Shortcut: 快键: - + How to set custom shortcuts 如何设置自定快键 - + Clear all shortcuts 清除所有快键 - + Search by shortcut name 按快键名称搜寻 @@ -7473,12 +7778,12 @@ Please check your shortcut settings! SideboardMenu - + &Sideboard - + &View sideboard @@ -7486,27 +7791,27 @@ Please check your shortcut settings! SoundSettingsPage - + Enable &sounds 启用&声效 - + Current sounds theme: 现前声效主题: - + Test system sound engine 测试系统声效引擎 - + Sound settings 声效设置 - + Master volume 主音量 @@ -7578,16 +7883,146 @@ Please check your shortcut settings! 从发佈更新伺服器收不到有效的回覆. - + No reply received from the tag update server. 未收到标籤更新伺服器的回应. - + Invalid reply received from the tag update server. 从标籤更新伺服器收不到有效的回覆. + + StorageSettingsPage + + + + + Success + + + + + Cached card pictures have been reset. + + + + + Downloaded card pictures have been reset. + + + + + Error + + + + + One or more downloaded card pictures could not be cleared. + + + + + In-memory (currently loaded) card pictures have been reset. + + + + + Card Picture Loader Caching Method: + + + + + The network cache is the preferred way of storing images. Downloaded images are stored here until the size of the cache exceeds the configured size. Cockatrice automatically monitors this cache and deletes the least recently seen card images to ensure the cache does not exceed the configured size. + + + + + Writing card images directly to a folder on your hard drive is another way of storing images. This does not change how Cockatrice accesses or downloads images. Cockatrice will NOT automatically monitor and clear this folder, so if you enable this option, it is up to you to ensure sufficient available space. It should also be noted that if a provider outage causes you to download the wrong picture (i.e. wrong printing) you will be stuck with it until you manually delete the file, as opposed to using the network cache, which automatically rotates and thus correct errors after a while. + + + + + This is the in-memory picture cache used by the application at runtime. It determines how much memory (RAM) Cockatrice can use before it has to fetch card images from the hard disk again. Increasing this will allow more card images to be displayed at once but shouldn't be necessary. Clearing this will make Cockatrice reload all images from the network cache or the disk. + + + + + Delete Cached Images + + + + + Delete Saved Images + + + + + Clear In-Memory Images + + + + + Card Picture Loader Cache Method + + + + + Network Cache + + + + + Filesystem + + + + + In-Memory Picture Cache + + + + + Network Cache Size: + + + + + On-disk cache for downloaded pictures + + + + + Redirect Cache TTL: + + + + + How long cached redirects for urls are valid for. + + + + + Picture Cache Size: + + + + + In-memory cache for pictures not currently on screen + + + + + Naming scheme: + + + + + Day(s) + + + TabAccount @@ -7723,117 +8158,117 @@ Please check your shortcut settings! TabArchidekt - - + + Desc. - - + + AND - - + + Require ALL selected colors - - + + Deck name... - - + + Owner... - - + + Packages - - + + Advanced Filters - + Bracket: - - + + Any - - + + Contains card... - - + + Commander... - - + + Tag... - - + + Deck Size - + Cards: - - + + Asc. - + Sort by: - + Filter by: - + Display Settings - - + + Search - - + + Formats @@ -7843,6 +8278,54 @@ Please check your shortcut settings! + + TabCardArtRules + + + Card: + + + + + ProviderId: + + + + + Mode: + + + + + Reason: + + + + + Type a card name... + + + + + Add rule + + + + + Remove rule + + + + + Refresh + + + + + Card Art Rules + + + TabDeckEditor @@ -7891,7 +8374,7 @@ Please check your shortcut settings! 重置布局 - + Deck: %1 牌库:%1 @@ -7899,55 +8382,49 @@ Please check your shortcut settings! TabDeckEditorVisual - + Visual Deck: %1 - + &Visual Deck Editor - - + + Card Info - - + + Deck - - - Filters - - - - + &View - + Printing - + Visible - + Floating - + Reset layout @@ -7955,22 +8432,22 @@ Please check your shortcut settings! TabDeckEditorVisualTabWidget - + Visual Deck View - + Visual Database Display - + Deck Analytics - + Sample Hand @@ -7978,133 +8455,133 @@ Please check your shortcut settings! TabDeckStorage - + Local file system 本地档案系统 - + Server deck storage 伺服器牌库储藏 - - + + Open in deck editor 在套牌编辑打开 - + Rename deck or folder - + Upload deck 上载牌库 - + Download deck 下载套牌 - - - + + + New folder 新文件夹 - + Delete 删除 - + Open decks folder 打开牌库文件夹 - + Rename local folder - + Rename local file - + New name: - - - - + + + + Error 出现错误 - + Rename failed - - + + Invalid deck file 无效牌库档案 - + Enter deck name 输入牌库名称 - + This decklist does not have a name. Please enter a name: 本牌库列表未有名称.请输入名称: - + Unnamed deck 未命名牌库 - + Failed to upload deck to server 牌库上载於伺服器时失败 - + Delete local file 删除本地档案 - + Are you sure you want to delete the selected files? 你确定要删除被选档案(眾)吗 ? - + Delete remote decks 删除伺服器上的牌库 - + Are you sure you want to delete the selected decks? 你确定要剷除所有被选的牌库吗? - - + + Name of new folder: 新建文件夹名称: @@ -8176,191 +8653,191 @@ Please enter a name: TabGame - - - + + + Replay 游戏录像重温 - - + + Game 游戏 - - + + Player List 玩家列表 - - + + Card Info 卡牌资讯 - - + + Messages 信息 - - + + Replay Timeline 录像时间线 - + &Phases 阶段 - + &Game 游戏 - + Next &phase 下个&阶段 - + Next phase with &action 下个阶段&行动 - + Next &turn 下个&回合 - + Reverse turn order 逆转回合次序 - + &Remove all local arrows &移除所有自设箭头 - + Rotate View Cl&ockwise &顺时针旋转视角 - + Rotate View Co&unterclockwise &逆时针旋转视角 - + Game &information 游戏&资讯 - + Un&concede 撤销&投降 - - - + + + &Concede &投降 - + &Leave game &离开游戏 - + C&lose replay &关闭游戏录像 - + &Focus Chat &聚焦聊天 - + &Say: &説: - + Selected cards - + &View &视图 - + Visible 可见 - + Floating 浮动 - + Reset layout 重置布局 - + Concede 投降 - + Are you sure you want to concede this game? 你确定要宣佈投降吗? - + Unconcede 撤销投降 - + You have already conceded. Do you want to return to this game? 你已经宣佈投降,你想回到这场游戏吗? - + Leave game 离开游戏 - + Are you sure you want to leave this game? 你确定要离开这个游戏吗? - + A player has joined game #%1 有玩家加入游戏 #%1 - + %1 has joined the game %1 已经加入游戏。 - + You have been kicked out of the game. 你已被踢出游戏。 @@ -8419,115 +8896,115 @@ Please enter a name: 您必须选择一个可用的日数来作为定位. - + Username: 用户名称: - + IP Address: IP地址 - + Game Name: 游戏名称: - + GameID: 游戏ID: - + Message: 信息: - + Main Room 主房 - + Game Room 游戏房间 - + Private Chat 私人聊天 - + Past X Days: 过去 X 天: - + Today 今天 - + Last Hour 1小时前 - + Maximum Results: 最多结果: - + At least one filter is required. The more information you put in, the more specific your results will be. 至少需要1个筛选项。 您输入的信息越多,找到结果越準确。 - + Get User Logs 获取用户记录 - + Clear Filters 清除筛选项目 - + Filters 筛选项目 - + Log Locations 记录位置 - + Date Range 日期范围 - + Maximum Results 最多结果 - - + + Message History 信息历史 - + Failed to collect message history information. 不能获取信息历史消息 - + There are no messages for the selected filters. 没有符合筛选条件的信息。 @@ -8545,27 +9022,27 @@ The more information you put in, the more specific your results will be.&离开 - + %1 - Private chat %1 - 私人聊天 - + This user is ignoring you, they cannot see your messages in main chat and you cannot join their games. 你已被这用户屏蔽, 它们不能再主聊天室看见你的信息,你亦不能参加它们的游戏. - + Private message from 私人信息信到, 来自 - + %1 has left the server. %1 已离开伺服器. - + %1 has joined the server. %1 已加入伺服器. @@ -8573,180 +9050,180 @@ The more information you put in, the more specific your results will be. TabReplays - + Local file system 本地档案系统 - + Server replay storage 伺服器录像仓库 - - + + Watch replay 观看游戏录像 - + Rename 改名 - - + + New folder 新文件夹 - - + + Delete 删除 - + Open replays folder 打开录像文件夹 - + Download replay 下载游戏录像 - + Toggle expiration lock 过期锁定 - + Get replay share code - - + + Look up replay by share code - + Rename local folder 本地档案夹改名 - + Rename local file 本地档案改名 - + New name: 新名称 - + Error 出错 - + Rename failed 改名不成功 - + Name of new folder: 新建文件夹名称: - + Delete local file 删除本地档案 - + Are you sure you want to delete the selected files? 你确定要剷除所有被选的档案吗? - + Are you sure you want to delete the selected replays? 你确定要删除被选录像档案吗 ? - + Failed to get code - - + + Either this server does not support replay sharing, or does not permit replay sharing for you. - - - + + + Failed - + Could not get replay code - + Replay Share Code - + Others can use this code to add the replay to their list of remote replays: %1 - + Copy to clipboard - + Replay share code - + Replay code found - + Replay was added, or you already had access to it. - + Replay code not found - + Failed to submit code - + Unexpected error - + Delete remote replay 删除伺服器录像 @@ -8759,47 +9236,62 @@ The more information you put in, the more specific your results will be. TabRoom - + + Friends + + + + + Online + + + + + Ignored + + + + &Say: &説: - + Chat 聊天 - + &Room &房间 - + &Leave room &离开房间 - + &Clear chat &清除聊天记录 - + Chat Settings... 聊天设置... - + mentioned you. 提到了你。 - + Click to view 点击查看 - + You are flooding the chat. Please wait a couple of seconds. 你正在刷屏。请稍等几秒鐘。 @@ -8812,30 +9304,30 @@ The more information you put in, the more specific your results will be.伺服器 - - - - + + + + Error 出现错误 - + Failed to join the server room: it doesn't exist on the server. 加入伺服器房间失败:在本伺服器上这房间并不存在。 - + The server thinks you are in the server room but your client is unable to display it. Try restarting your client. 伺服器认为你在房间内, 但Cockatrice无法显示。请重新启动Cockatrice。 - + You do not have the required permission to join this server room. 你未被允许进入该房间。 - + Failed to join the server room due to an unknown error: %1. 因为以下不知名的错误,所以不能参入伺服器房间: %1. @@ -8843,97 +9335,97 @@ The more information you put in, the more specific your results will be. TabSupervisor - + Deck Editor - + Visual Deck Editor - + EDHRec - + Archidekt - + Home - + &Visual Deck Storage - + Visual Database Display - + Server - + Account - + Deck Storage - + Game Replays - + Administration - + Logs - + Are you sure? 你确定吗? - + There are still open games. Are you sure you want to quit? 游戏还在继续, 你确定要退出吗? - + Click to view 点击查看 - + Your buddy %1 has signed on! 你的好友%1已登录! - + Unknown Event 不知名事件 - + The server has sent you a message that your client does not understand. This message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -8944,38 +9436,38 @@ To update your client, go to Help -> Check for Updates. 要升级你的客户端,请点击“帮助”->“检查更新” - + Idle Timeout 閒置超时 - + You are about to be logged out due to inactivity. 你即将因为长时间没有活动而被退出。 - + Promotion 升级 - + You have been promoted. Please log out and back in for changes to take effect. 你已被升级.请登出然后再登入让本更改生效. - + Warned 被警告 - + You have received a warning due to %1. Please refrain from engaging in this activity or further actions may be taken against you. If you have any questions, please private message a moderator. 你由於$1 原因,现在接受警告. 请避免继续本行为, 否则你将有可能会接受进一步的警诫或惩罚. 若有任何疑问, 请私信我们门下的版主. - + You have received the following message from the server. (custom messages like these could be untranslated) 你收到松本伺服器发出的以下讯息(类似本信息的自定讯息可能未经翻译) @@ -8984,12 +9476,12 @@ Please refrain from engaging in this activity or further actions may be taken ag TabVisualDatabaseDisplay - + Database Display - + Visual Database Display @@ -9028,42 +9520,42 @@ Please refrain from engaging in this activity or further actions may be taken ag TranslateCounterName - + Life 生命值 - + White 白色 - + Blue 蓝色 - + Black 黑色 - + Red 红色 - + Green 绿色 - + Colorless 无色 - + Other 其他 @@ -9071,11 +9563,69 @@ Please refrain from engaging in this activity or further actions may be taken ag UpdateDownloader - + Could not open the file for reading. 无法打开文件。 + + UserCardArtSettingsDialog + + + Card Art Settings + + + + + Type a card name... + + + + + Card name: + + + + + Card ProviderId: + + + + + Left margin (%): + + + + + Right margin (%): + + + + + Vertical offset: + + + + + Zoom: + + + + + Parameters + + + + + Preview + + + + + Remove Banner Card + + + UserContextMenu @@ -9193,7 +9743,7 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Ban History 禁用历史 @@ -9208,77 +9758,87 @@ Please refrain from engaging in this activity or further actions may be taken ag 用户从未被禁 - + Failed to collect ban information. 不能获取禁用讯息 - - - + + + Warning History 警告历史 - + Warning Time;Moderator;User Name;Reason 警告时间;版主;用户名称;理由 - + User has never been warned. 用户从未接收警告 - + Failed to collect warning information. 不能获取警告信息 - + Failed to get admin notes. 不能获取管理笔记 - - + + Success 成功 - + Successfully promoted user. 用户升级成功 - + Successfully demoted user. 用户降级成功 - - - + + Kick Player + + + + + Are you sure you want to kick this player from the game? + + + + + + Failed 失败 - + Failed to promote user. 用户升级失败 - + Failed to demote user. 用户降级失败 - + Copy hash to clipboard 把哈希覆印到用户剪贴 - + Remove this user's messages 移除这用户讯息 @@ -9286,326 +9846,554 @@ Please refrain from engaging in this activity or further actions may be taken ag UserInfoBox - + Location: 位置: - + Account Age: 帐户年龄 - + Edit 更改 - + Change password 更改密码 - + Change avatar 更改头像 - + Administrator 管理员 - + Moderator 版主 - + Registered user 登记用户 - - + + Unregistered user 未登记用户 - + Judge 裁判 - + Unknown 不知名 - + The entered password does not match your account. 输入密码跟你户口不匹配 - - - + + + + Information 资讯 - + User information updated. 用户资讯已更新。 - - - - - - - - - - + + + + + + + + + + + Error 出现错误 - + User Information 用户资讯 - + Real Name: 实名: - + User Level: 用户等级: + + + Edit Banner Card + + - + %n Year(s), amount of years (only shown if more than 0) %n年(眾) , - + %10%n Day(s) %20 amount of years (if more than 0), amount of days, date in local short format %10%n日(眾) %20 - + Enter Password 输入密码 - + Password verification is required in order to change your email address 要核实密码才能更改你的电邮地址 - - - + + + An error occurred while trying to update your user information. 在尝试更新你的用户信息时出现错误. - + + The selected card is blacklisted on this server or another error occurred. + + + + + Banner card removed. + + + + + Banner card updated. + + + + This server does not permit you to update your user informations. 本伺服器不容许你更新你的用户资讯. - + Password changed. 密码已更改 - + This server does not permit you to change your password. 本伺服器不容许你更改密码 - + The new password is too short. 新的密码太短 - + The old password is incorrect. 旧的密码不正确 - + Avatar updated. 头像已更新。 - + This server does not permit you to update your avatar. 本伺服器不容许你更新你的头像. + + + An error occured while trying to update your avatar. + + - An error occured while trying to updater your avatar. - 在更新你头像时出现错误 + This server does not permit you to update your user informations. + An error occured while trying to updater your avatar. + 本伺服器不容许你更新你的用户资讯. + + + + UserInfoPopup + + + + Games + + + + + Chat + + + + + Open private chat + + + + + Profile + + + + + View user profile + + + + + Show this user's games + + + + + − Buddy + + + + + Remove from buddy list + + + + + + Buddy + + + + + Add to buddy list + + + + + − Ignore + + + + + Remove from ignore list + + + + + + Ignore + + + + + Add to ignore list + + + + + Ban + + + + + Ban from server + + + + + Warn + + + + + Warn user + + + + + Ban log + + + + + View ban history + + + + + Warn log + + + + + View warning history + + + + + Notes + + + + + View admin notes + + + + + − Mod + + + + + Demote from moderator + + + + + + Mod + + + + + Promote to moderator + + + + + − Judge + + + + + Demote from judge + + + + + + Judge + + + + + Promote to judge + + + + + Join game + + + + + Spectate + + + + + + Loading games… + + + + + Could not load games. + + + + + No active games. + UserInterfaceSettingsPage - + General interface settings 一般通用接口设置 - + &Double-click cards to play them (instead of single-click) 双点击使出卡牌 (而不是单点击使出) - + &Clicking plays all selected cards (instead of just the clicked card) &单一点击把所有选择的卡牌使出(不只是被点击的单张卡牌) - + &Play all nonlands onto the stack (not the battlefield) by default &把所有使出的非地牌放入堆迭区(不是战场) 作为常用设置 - + Do not delete &arrows inside of subphases - + Close card view window when last card is removed - + Auto focus search bar when card view window is opened - + Annotate card text on tokens 在令牌上标註牌中文字 - - Show selection counter during drag selection + + Show selection count during drag selection - - Show total selection counter + + Show total selection count - + + Show subtype breakdown in selection tally + + + + Use tear-off menus, allowing right click menus to persist on screen 使用浮动菜单,允许右键单击, 把菜单保留在屏幕上 - + + Keep game chat focused when clicking in game (Note: disables card view search bar) + + + + Notifications settings 通告设置 - + Enable notifications in taskbar 开启任务栏通告 - + Notify in the taskbar for game events while you are spectating 观看时在任务栏提示游戏信息 - + Notify in the taskbar when users in your buddy list connect 在任务栏中提示好友连到伺服器 - + Animation settings 动画设定 - + &Tap/untap animation &横置/重置 动画 - + Deck editor/storage settings - + Open deck in new tab by default 把用新页开啟牌库作为常用设置 - + Use visual deck storage in game lobby - + Use selection animation for Visual Deck Storage - + When adding a tag in the visual deck storage to a .txt deck: - + do nothing - + ask to convert to .cod - + always convert to .cod - + Default deck editor type - + Classic Deck Editor - + Visual Deck Editor - + Replay settings 重播设置 - + Buffer time for backwards skip via shortcut: 用速键啟动向后跳的缓衝时间: @@ -9613,22 +10401,22 @@ Please refrain from engaging in this activity or further actions may be taken ag UserListWidget - + Users connected to server: %1 - + Users in this room: %1 - + Buddies online: %1 / %2 - + Ignored users online: %1 / %2 @@ -9636,35 +10424,45 @@ Please refrain from engaging in this activity or further actions may be taken ag UtilityMenu - + Increment all card counters - + &Untap all permanents - + R&oll die... - + + Flip coin + + + + &Create token... - + C&reate another token - + Cr&eate predefined token + + + C&reate another %1 token + + VisualDatabaseDisplayColorFilterWidget @@ -9716,72 +10514,72 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayFilterToolbarWidget - + Sort by - + Filter by - + Save and load filters - + Filter by exact card name - + Filter by card main-type - + Filter by card sub-type - + Filter by set - + Filter by format legality - + Save/Load - + Name - + Main Type - + Sub Type - + Sets - + Formats @@ -9789,22 +10587,22 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayFormatLegalityFilterWidget - + Show formats with at least: - + cards - + Do not display formats with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) @@ -9822,32 +10620,32 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayMainTypeFilterWidget - + Show main types with at least: - + cards - + Do not display card main-types with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) - + Mode: Exact Match - + Mode: Includes @@ -9855,27 +10653,27 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayNameFilterWidget - + Filter by name... (Exact match) - + Load from Deck - + Apply all card names in currently loaded deck as exact match name filters - + Load from Clipboard - + Apply all card names in clipboard as exact match name filters @@ -9883,7 +10681,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayRecentSetFilterSettingsWidget - + Filter to most recent sets @@ -9891,19 +10689,19 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplaySetFilterWidget - + Search sets... - - + + Mode: Exact Match - - + + Mode: Includes @@ -9911,37 +10709,37 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplaySubTypeFilterWidget - + Search subtypes... - + Show sub types with at least: - + cards - + Do not display card sub-types with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) - + Mode: Exact Match - + Mode: Includes @@ -9949,23 +10747,23 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayWidget - + Search by card name (or search expressions) - + Visual - + Loading database ... - + Clear all filters @@ -10004,17 +10802,17 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Toggle Layout: Overlap - + Change how cards are displayed within zones (i.e. overlapped or fully visible.) - + Toggle Layout: Flat @@ -10043,17 +10841,17 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckEditorWidget - + Type a card name here for suggestions from the database... - + Quick search and add card - + Search for closest match in the database (with auto-suggestions) and add preferred printing to the deck on pressing enter @@ -10171,43 +10969,43 @@ Please refrain from engaging in this activity or further actions may be taken ag WarningDialog - + Which warning would you like to send? 你想发出那种警告? - + Redact all messages from this user in all rooms 把这用户在所有房间留下的讯息删除 - + &OK &确认 - + &Cancel &取消 - + Warn user for misconduct 对行为不当的用户发出警告 - - + + Error 出现错误 - + User name to send a warning to can not be blank, please specify a user to warn. 接收警告的用户名称不能空白.请定好要接收警告的用户名字. - + Warning to use can not be blank, please select a valid warning to send. 警告名称不能留空. 请选择有效的警告. @@ -10215,133 +11013,133 @@ Please refrain from engaging in this activity or further actions may be taken ag WndSets - + Move selected set to the top 将选择的牌组送到最顶 - + Move selected set up 将选择的牌组往上一格 - + Move selected set down 将选择的牌组往下一格 - + Move selected set to the bottom 将选择的牌组送到最底 - + Search by set name, code, or type 用排组名称,代码,或类型进行搜寻 - + Default order 常用次序 - + Restore original art priority order 恢復常用艺术先后次序 - + Enable all sets 啟用所有牌组 - + Disable all sets 禁用所有卡组 - + Enable selected set(s) 啟用选择的牌组(眾)  - + Disable selected set(s) 停用选择的牌组(眾) - + Deck Editor 牌库编辑 - + Use CTRL+A to select all sets in the view. 请按 CTRL + A, 以此选择所有看到的牌组 - + Only cards in enabled sets will appear in the card list of the deck editor. 只有已经被啟用的卡牌组中的卡牌,才会在牌库编辑中的卡牌清单出现 - + Image priority is decided in the following order: 卡牌图片先后次序由以下次序决定: - + first the CUSTOM Folder (%1), then the Enabled Sets in this dialog (Top to Bottom) %1 is a link to the wiki 自订档案夹(%`1)优先, 此对话窗中已被啟用的牌组为后(由顶到底) - + Include cards rebalanced for Alchemy [requires restart] - + Card Art 卡牌艺术 - + How to use custom card art 如何使用自定卡牌图片 - + Hints 提示 - + Note 笔记 - + Sorting by column allows you to find a set while not changing set priority. 按列排序允许您在不更改系列优先级别的情况下查找牌组。 - + To enable ordering again, click the column header until this message disappears. 要再次启用排序,请单击列标题,直到此消息消失。 - + Use the current sorting as the set priority instead 用现在排序代替牌组先后 - + Sorts the set priority using the same column 用同一列分牌组先后 - + Manage sets 管理牌组 @@ -10349,72 +11147,72 @@ Please refrain from engaging in this activity or further actions may be taken ag ZoneViewWidget - + Search by card name (or search expressions) - + Ungrouped 未分组 - + Group by Type 按类型分组 - + Group by Mana Value 按法力数值分组 - + Group by Color 按顏色排序 - + Unsorted 未排序 - + Sort by Name 按名称排次序 - + Sort by Type 按类型排序 - + Sort by Mana Cost 按法力费用排序 - + Sort by Colors 按顏色(眾) 排序 - + Sort by P/T 按力量/防御力排序 - + Sort by Set 按排组排序 - + shuffle when closing 关闭界面时洗牌 - + pile view 栋迭观看 @@ -10422,7 +11220,7 @@ Please refrain from engaging in this activity or further actions may be taken ag i18n - + English 简体中文 (Chinese Simplified) @@ -10430,12 +11228,12 @@ Please refrain from engaging in this activity or further actions may be taken ag main - + Connect on startup 啟动时自动连接 - + Debug to file 侦错纪录写进档案 @@ -10449,7 +11247,7 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Deck Editor 牌库编辑 @@ -10530,7 +11328,7 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Replays 游戏录像 @@ -10645,129 +11443,129 @@ Please refrain from engaging in this activity or further actions may be taken ag 载入牌库... - - + + Load Deck from Clipboard... 从剪贴板载入牌库 - + Edit Deck in Clipboard, Annotated - + Edit Deck in Clipboard - + New Deck 创建新牌库 - + Open Custom Pictures Folder 打开自定图片文件夹 - + Print Deck... 印刷牌库 - + Delete Card 删除卡牌 - - + + Reset Layout 重置佈局 - + Save Deck 保存牌库 - + Save Deck as... 牌库另存为 - + Save Deck to Clipboard, Annotated 从剪贴板储藏牌库, 带注释 - + Save Deck to Clipboard, Annotated (No Set Info) - + Save Deck to Clipboard 从剪贴板储藏牌库 - + Save Deck to Clipboard (No Set Info) - + Load Local Deck... 从自身档案载入牌库 - + Load Remote Deck... 载入伺服器上的牌库... - + Set Ready to Start 设定準备开始 - + Toggle Sideboard Lock 切换副牌库锁 - + Add Green Counter 增加绿色指示物 - + Remove Green Counter 移除绿色指示物 - + Set Green Counters... 设定绿色指示物 - + Add Red Counter 增加红色指示物 - + Remove Red Counter 移除红色指示物 - + Set Red Counters... 设定红色指示物 - + Add Life Counter 增加生命指示物 @@ -10777,724 +11575,744 @@ Please refrain from engaging in this activity or further actions may be taken ag - + + Load deck from online service... + + + + + Load from website... + + + + Unload Deck - + Force Start - + Add Card Counter (F) - + Remove Card Counter (F) - + Set Card Counters (F)... - + Add Card Counter (E) - + Remove Card Counter (E) - + Set Card Counters (E)... - + Add Card Counter(D) - + Remove Card Counter (D) - + Set Card Counters (D)... - + Add Card Counter (C) - + Remove Card Counter (C) - + Set Card Counters (C)... - + Add Card Counter (B) - + Remove Card Counter (B) - + Set Card Counters (B)... - + Add Card Counter (A) - + Remove Card Counter (A) - + Set Card Counters (A)... - + Remove Life Counter 移除生命指示物 - + Set Life Counters... 设定生命指示物 - + Add White Counter 增加白色指示物 - + Remove White Counter 移除白色指示物 - + Set White Counters... 设定白色指示物 - + Add Blue Counter 增加蓝色指示物 - + Remove Blue Counter 移除蓝色指示物 - + Set Blue Counters... 设定蓝色指示物 - + Add Black Counter 增加黑色指示物 - + Remove Black Counter 移除黑色指示物 - + Set Black Counters... 设定黑色指示物 - + Add Colorless Counter 增加无色指示物 - + Remove Colorless Counter 移除无色指示物 - + Set Colorless Counters... 设定无色指示物 - + Add Other Counter 增加其他指示物 - + Remove Other Counter 移除其他指示物 - + Set Other Counters... 设定其他指示物 - + Increment all card counters - + Add Power (+1/+0) 增加力量(+1/+0) - + Remove Power (-1/-0) 削减力量(-1/-0) - + Move Toughness to Power (+1/-1) 防御移到力量(+1/-1) - + Add Toughness (+0/+1) 增加防御(+0/+1) - + Remove Toughness (-0/-1) 削减防御(-0/-1) - + Move Power to Toughness (-1/+1) 力量移到防御(-1/+1) - + Add Power and Toughness (+1/+1) 增加力量和防御(+1/+1) - + Remove Power and Toughness (-1/-1) 削减力量和防御(-1/-1) - + Set Power and Toughness... 设置力量和防御 - + Reset Power and Toughness 重置力量和防御 - + Untap 重置 - + Upkeep 维持 - + Draw 抽牌 - + First Main Phase 第一主阶段 - + Start Combat 开始战斗 - + Attack 攻击 - + Block 挡格 - + Damage 计算损害 - + End Combat 战斗结束 - + Second Main Phase 第二主阶段 - + End 完结 - + Next Phase 下个阶段 - + Next Phase Action 下个行动阶段 - + Next Turn 下一回合 - + Hide Card in Reveal Window 从揭露视窗中隐藏这牌 - + Tap / Untap Card 横置/重置这牌 - + Untap All 重置全牌 - + Toggle Skip Untapping Toggle Untap - + Turn Card Over 反转本牌 - + Peek Card 查看卡牌 - + Play Card 使出本牌 - + Play Card, Face Down - + Attach Card... 结附牌 - + Unattach Card 解除牌结附 - + Clone Card 复製卡牌 - + Create Token... 派出令牌 - + Create All Related Tokens 派出所有相关令牌 - + Create Another Token 再做另外令牌 - + Set Annotation... 记下註解 - + + Reduce Life by Power + + + + Select All Cards in Zone 选择所有在区域的牌 - + Select All Cards in Row 选择所有横行牌 - + Select All Cards in Column 选择所有直行牌 - + Reveal Selected Cards to All Players - - + + Bottom of Library 牌库底 - - - - + + + + Exile 放逐 - - - - + + + + Graveyard 坟场 - - + + Hand 手牌 - - + + Top of Library 牌库顶 - - + + Battlefield, Face Down 战场,面朝下 - + Battlefield 战场 - + Library 牌库 - + Sideboard 副牌库 - + Top Cards of Library 排副多张顶牌 - + Bottom Cards of Library 排副多张底牌 - + Close Recent View 关闭最近查看 - - + + Stack 迭区 - - + + Graveyard (Multiple) 多张送坟场 - - + + Graveyard (Multiple), Face Down - - + + Exile (Multiple) 多张放逐 - - + + Exile (Multiple), Face Down - + Stack Until Found 找到以下牌前全送迭区 - + Draw Bottom Card 从牌库底抽一张牌 - + Draw Multiple Cards from Bottom... 从牌库底抽超过一张牌 - + Draw Arrow... 画箭咀 - + Remove Local Arrows 清除自设箭咀 - + Leave Game 离开游戏 - + Concede 投降 - + Roll Dice... 掷骰 - + + Flip Coin + + + + Shuffle Library 洗牌 - + Shuffle Top Cards of Library - + Shuffle Bottom Cards of Library - + Mulligan 再调度 - + Mulligan (Same hand size) - + Mulligan (Hand size - 1) - + Draw a Card 抽一张牌 - + Draw Multiple Cards... 抽超过一张牌 - + Undo Draw 撤销抽牌 - + Always Reveal Top Card 永远显示顶牌 - + Always Look At Top Card 永远看到顶牌 - + Sort Hand by Name - + Sort Hand by Type - + Sort Hand by Mana Value - + Reveal Hand to All Players - + Reveal Random Card to All Players - + Rotate View Clockwise 视角顺时针转 - + Rotate View Counterclockwise 视角逆时针转 - + Unfocus Text Box 取消聚焦文本框 - + Focus Chat 聚焦聊天 - + Clear Chat 扫清聊天 - + Refresh 刷新 - + Skip Forward 跳前 - + Skip Backward 跳后 - + Skip Forward by a lot 向前大跳 - + Skip Backward by a lot 向后大跳 - + Play/Pause 播放/暂停 - + Toggle Fast Forward 切换快进 - + Home - + Visual Deck Storage - + Deck Storage - + Server - + Account - + Administration - + Logs diff --git a/cockatrice/translations/cockatrice_zh-Hant.ts b/cockatrice/translations/cockatrice_zh-Hant.ts index 1980960a9..2ce4cbd53 100644 --- a/cockatrice/translations/cockatrice_zh-Hant.ts +++ b/cockatrice/translations/cockatrice_zh-Hant.ts @@ -2,7 +2,7 @@ AbstractCounter - + &Set counter... 設置指示物為... @@ -10,12 +10,12 @@ AbstractCounterDialog - + Set counter 設置指示物 - + New value for counter '%1': 新的指示物數值'%1': @@ -36,60 +36,60 @@ AbstractTabDeckEditor - + Open in new tab - + Are you sure? - + The decklist has been modified. Do you want to save the changes? - - - - - - + + + + + + Error - + Could not open deck at %1 - + Could not save remote deck - - + + The deck could not be saved. Please check that the directory is writable and try again. - + Save deck - + The deck could not be saved. - + There are no cards in your deck to be exported @@ -105,12 +105,12 @@ Please check that the directory is writable and try again. AdminNotesDialog - + Update Notes - + Admin Notes for %1 @@ -127,172 +127,227 @@ Please check that the directory is writable and try again. Sideboard + + + Tokens + + AppearanceSettingsPage - + seconds - + Error 錯誤 - + Could not create themes directory at '%1'. - + Theme settings 主題設定 - + Current theme: 當前主題 - + Open themes folder 打開主題文件夾 - + Home tab background source: - + Home tab background shuffle frequency: - + Disabled - - Display card name of background in bottom right: - - - - + Menu settings - + Show keyboard shortcuts in right-click menus - + Show game filter toolbar above list in room tab - + Card rendering 牌面 - + Display card names on cards having a picture 顯示有圖卡牌的名稱 - + Auto-Rotate cards with sideways layout - + Override all card art with personal set preference (Pre-ProviderID change behavior) - + + Light + + + + + Dark + + + + + System + + + + + Active theme palette: + + + + + Edit theme palette + + + + + Home tab settings + + + + + Display card name of background in bottom right + + + + + Styling settings + + + + + Style user list + + + + + Card printings + + + + Bump sets that the deck contains cards from to the top in the printing selector - + Scale cards on mouse over 卡牌隨指針縮放 - + Use rounded card corners - + + Card layout + + + + Minimum overlap percentage of cards on the stack and in vertical hand - + Maximum initial height for card view window: - - + + rows - + Maximum expanded height for card view window: - + Card counters - + Counter %1 - + Hand layout 手牌區域佈局 - + Display hand horizontally (wastes space) 水平顯示手牌區域 (浪費空間) - + Enable left justification 開啓左對齊 - + Table grid layout 表格佈局 - + Invert vertical coordinate 反轉垂直坐標 - + Minimum player count for multi-column layout: 界面佈局之內能夠容納的玩家欄數量: - + Maximum font size for information displayed on cards: 卡牌上顯示的最大字號 @@ -300,12 +355,12 @@ Please check that the directory is writable and try again. ArchidektApiResponseDeckDisplayWidget - + Back to results - + Open Deck in Deck Editor @@ -331,112 +386,112 @@ Please check that the directory is writable and try again. BanDialog - + ban &user name 禁止該用戶 - + ban &IP address 禁止該IP地址 - + ban client I&D 禁止該客户端 - + Ban type 禁止類型 - + &permanent ban 永久禁止 - + &temporary ban 暫時禁止 - + &Days: 天: - + &Hours: 小時: - + &Minutes: 分鐘: - + Duration of the ban 持續時長 - + Please enter the reason for the ban. This is only saved for moderators and cannot be seen by the banned person. 請輸入禁止原因. 此項為管理緣由而保存並且對於這位被禁止的用戶不可見. - + Please enter the reason for the ban that will be visible to the banned person. 請輸入對於被禁止用戶可見的禁止原因 - + Redact all messages from this user in all rooms 修改該用戶在所有房間中的所有消息 - + &OK 確認 - + &Cancel 取消 - + Ban user from server 禁止此用戶 - - - - + + + + Error 錯誤 - + You have to select a name-based, IP-based, clientId based, or some combination of the three to place a ban. 你必須選擇姓名、IP、客户端或任意多選組合來執行禁止操作。 - + You must have a value in the name ban when selecting the name ban checkbox. 在禁用姓名時必須填寫一個姓名。 - + You must have a value in the ip ban when selecting the ip ban checkbox. 在禁用IP時必須填寫一個IP。 - + You must have a value in the clientid ban when selecting the clientid ban checkbox. 在禁用客戶端ID時必須填寫一個客戶端ID。 @@ -444,59 +499,123 @@ This is only saved for moderators and cannot be seen by the banned person. BetaReleaseChannel - + Beta - + No reply received from the release update server. 未收到更新伺服器的回應。 - + Invalid reply received from the release update server. 收到更新伺服器的無效回應 - + No reply received from the file update server. 未收到文件更新伺服器的回應 + + CardArtPreviewWidget + + + No card selected + + + + + CardArtRulesModel + + + Card + + + + + ProviderId + + + + + Mode + + + + + Reason + + + CardDatabaseModel - + Name 名稱 - + Sets 系列 - + Mana cost 魔法力費用 - + Card type 卡牌類別 - + P/T 力量/防禦力 - + Color(s) 顏色 + + CardDatabaseView + + + Add to Deck + + + + + Add to Sideboard + + + + + Select Printing + + + + + Show on EDHRec (Commander) + + + + + Show on EDHRec (Card) + + + + + Show Related cards + + + CardFilter @@ -625,22 +744,22 @@ This is only saved for moderators and cannot be seen by the banned person. CardInfoPictureWidget - + View related cards - + Add card to deck - + Mainboard - + Sideboard @@ -663,12 +782,12 @@ This is only saved for moderators and cannot be seen by the banned person. - + Related cards: - + Unknown card: @@ -676,128 +795,146 @@ This is only saved for moderators and cannot be seen by the banned person. CardMenu - + Re&veal to... - + &All players - + View related cards - + Token: - + All tokens - + &Select All - + S&elect Row - + S&elect Column - + &Play - + &Hide - + Play &Face Down - + &Tap / Untap Turn sideways or back again - + Skip &untapping - + T&urn Over Turn face up/face down - + &Peek at card face - + &Clone - + Attac&h to card... - + Unattac&h - + &Draw arrow... - + &Set annotation... - + + Reduce life by power + + + + Ca&rd counters - + &Add counter (%1) - + &Remove counter (%1) - + &Set counters (%1)... + + CardPictureLoaderCacheMethod + + + Network Cache + + + + + Filesystem + + + CardSizeWidget @@ -809,133 +946,133 @@ This is only saved for moderators and cannot be seen by the banned person. CardZoneLogic - + their hand nominative - + %1's hand nominative - + their library look at zone - + %1's library look at zone - + of their library top cards of zone, - + of %1's library top cards of zone - + their library reveal zone - + %1's library reveal zone - + their library shuffle - + %1's library shuffle - + their library nominative - + %1's library nominative - + their graveyard nominative - + %1's graveyard nominative - + their exile nominative - + %1's exile nominative - + their sideboard look at zone - + %1's sideboard look at zone - + their sideboard nominative - + %1's sideboard nominative - + their custom zone '%1' nominative - + %1's custom zone '%2' nominative @@ -957,16 +1094,421 @@ This is only saved for moderators and cannot be seen by the banned person. + + ColorButton + + + Click to pick a color + + + + + Pick colour + + + + + ConnectionController + + + + The server has reached its maximum user capacity, please check back later. + + + + + There are too many concurrent connections from your address. + + + + + Banned by moderator + + + + + Expected end time: %1 + + + + + This ban lasts indefinitely. + + + + + Scheduled server shutdown. + + + + + + Invalid username. + + + + + You have been logged out due to logging in at another location. + + + + + Connection closed + + + + + The server has terminated your connection. +Reason: %1 + + + + + The server is going to be restarted in %n minute(s). +All running games will be lost. +Reason for shutdown: %1 + + + + + Scheduled server shutdown + + + + + Failed Login + + + + + Your client seems to be missing features this server requires for connection. + + + + + To update your client, go to 'Help -> Check for Client Updates'. + + + + + + + + + + + + + + + + + + + + + + Error + + + + + Incorrect username or password. Please check your authentication information and try again. + + + + + There is already an active session using this user name. +Please close that session first and re-login. + + + + + + You are banned until %1. + + + + + + You are banned indefinitely. + + + + + This server requires user registration. Do you want to register now? + + + + + This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. +Please close and reopen your client to try again. + + + + + An internal error has occurred, please close and reopen Cockatrice before trying again. +If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. + + + + + Account activation + + + + + Your account has not been activated yet. +You need to provide the activation token received in the activation email. + + + + + Server Full + + + + + Unknown login error: %1 + + + + + + +This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. + + + + + + + + + + Registration denied + + + + + Registration is currently disabled on this server + + + + + There is already an existing account with the same user name. + + + + + It's mandatory to specify a valid email address when registering. + + + + + It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. + + + + + Password too short. + + + + + Registration failed for a technical problem on the server. + + + + + The connection to the server has been lost. + + + + + Unknown registration error: %1 + + + + + Account activation failed + + + + + Socket error: %1 + + + + + Server timeout + + + + + You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. +Local version is %1, remote version is %2. + + + + + Your Cockatrice client is obsolete. Please update your Cockatrice version. +Local version is %1, remote version is %2. + + + + + + Success + + + + + Registration accepted. +Will now login. + + + + + Account activation accepted. +Will now login. + + + + + Information + + + + + This server supports additional features that your client doesn't have. +This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. + +To update your client, go to Help -> Check for Updates. + + + + + + + Reset Password + + + + + Your password has been reset successfully, you can now log in using the new credentials. + + + + + Failed to reset user account password, please contact the server operator to reset your password. + + + + + Activation request received, please check your email for an activation token. + + + + + Connecting to %1... + + + + + Registering to %1 as %2... + + + + + Disconnected + + + + + Connected, logging in at %1 + + + + + Requesting forgotten password to %1 as %2... + + + + + Your username must respect these rules: + + + + + is %1 - %2 characters long + + + + + can %1 contain lowercase characters + + + + + + + + NOT + + + + + can %1 contain uppercase characters + + + + + can %1 contain numeric characters + + + + + can contain the following punctuation: %1 + + + + + first character can %1 be a punctuation mark + + + + + no unacceptable language as specified by these server rules: + note that the following lines will not be translated + + + + + can not contain any of the following words: %1 + + + + + can not match any of the following expressions: %1 + + + + + You may only use A-Z, a-z, 0-9, _, ., and - in your username. + + + CustomZoneMenu - + C&ustom Zones - - + + View custom zone '%1' @@ -974,30 +1516,35 @@ This is only saved for moderators and cannot be seen by the banned person. DeckAnalyticsWidget - + Add Panel - + Remove Panel - + Save Layout - + Load Layout + + + Include Sideboard + + DeckEditorCardDatabaseDockWidget - + Card Database @@ -1013,47 +1560,17 @@ This is only saved for moderators and cannot be seen by the banned person. DeckEditorDatabaseDisplayWidget - + Search by card name (or search expressions) - - Add to Deck - - - - - Add to Sideboard - - - - - Select Printing - - - - - Show on EDHRec (Commander) - - - - - Show on EDHRec (Card) - - - - - Show Related cards - - - - + Add card to &maindeck - + Add card to &sideboard @@ -1086,72 +1603,72 @@ This is only saved for moderators and cannot be seen by the banned person. - + Select Printing - + Deck - + Deck &name: - + Banner Card/Tags Visibility Settings - + Show banner card selection menu - + Show tags selection menu - + &Comments: - + Group by: - + Format: - + Hash: - + &Increment number - + &Decrement number - + &Remove row - + Swap card to/from sideboard @@ -1159,17 +1676,17 @@ This is only saved for moderators and cannot be seen by the banned person. DeckEditorFilterDockWidget - + Filters - + &Clear all filters - + Delete selected @@ -1300,169 +1817,113 @@ This is only saved for moderators and cannot be seen by the banned person. DeckEditorSettingsPage - - + + Update Spoilers 更新預覽 - - + Success 成功 - + Download URLs have been reset. 下載URL已重置。 - - Downloaded card pictures have been reset. - 下載的卡牌圖片已被重置。 - - - - Error - 錯誤 - - - - One or more downloaded card pictures could not be cleared. - 1個或多個卡牌圖片未能被清除。 - - - + Add URL 添加URL - - + + URL: URL: - - + + Edit URL 編輯URL - - Network Cache Size: - - - - - Redirect Cache TTL: - - - - - How long cached redirects for urls are valid for. - - - - - Picture Cache Size: - - - - + Add New URL 添加新的URL - + Remove URL 移除URL - - Day(s) - - - - + Updating... 更新中... - + Choose path 選擇路徑 - + URL Download Priority URL下載優先級 - + Spoilers 預覽 - + Download Spoilers Automatically 自動下載預覽 - + Spoiler Location: 預覽位置: - + Last Change 最近的變更 - + Spoilers download automatically on launch 自動下載預覽運行 - + Press the button to manually update without relaunching 按下按鈕手動更新而不重新啓動 - + Do not close settings until manual update is complete 手動更新完成之前,請勿關閉設置 - + Download card pictures on the fly 即時下載卡牌圖片 - + How to add a custom URL 如何添加自定義URL - - Delete Downloaded Images - 刪除已下載的圖片 - - - + Reset Download URLs 重置用於下載的URL - - - On-disk cache for downloaded pictures - - - - - In-memory cache for pictures not currently on screen - - DeckListHistoryManagerWidget @@ -1528,12 +1989,12 @@ This is only saved for moderators and cannot be seen by the banned person. DeckLoader - + Common deck formats (%1) - + All files (*.*) @@ -1755,27 +2216,27 @@ This is only saved for moderators and cannot be seen by the banned person. - + Moved to %1 1 × "%2" (%3) - + Removed "%1" (all copies) - + %1 1 × "%2" (%3) - + Added - + Removed @@ -1797,74 +2258,74 @@ This is only saved for moderators and cannot be seen by the banned person. DeckViewContainer - + Load deck... 讀取套牌... - + Load remote deck... 載入伺服器上的套牌... - + Load from clipboard... - + Load from website... - + Unload deck - + Ready to start 準備好開始 - + Force start - + Sideboard unlocked 解鎖備牌 - + Sideboard locked 鎖定備牌 - - + + Error 錯誤 - + The selected file could not be loaded. 此文件無法被載入 - + Deck is greater than maximum file size. - + Are you sure you want to force start? This will kick all non-ready players from the game. - + Cockatrice @@ -1963,7 +2424,7 @@ This will kick all non-ready players from the game. - + Webpage 網頁 @@ -2003,37 +2464,37 @@ This will kick all non-ready players from the game. 連接伺服器 - + Server URL 伺服器URL - + Communication Port 通信端口 - + Unique Server Name 唯一的伺服器名稱 - + Connection Warning 連接警告 - + You need to name your new connection profile. 你需要給連接配置文件命名。 - + Connect Warning 連接警告 - + The player name can't be empty. 玩家名稱不能為空白 @@ -2146,17 +2607,17 @@ This will kick all non-ready players from the game. 創建一個房間 - + Game information 房間信息 - + Error 錯誤 - + Server error. 伺服器錯誤. @@ -2164,97 +2625,97 @@ This will kick all non-ready players from the game. DlgCreateToken - + &Name: 名稱: - + Token 衍生物 - + C&olor: 顏色: - + white - + blue - + black - + red - + green - + multicolor 多色 - + colorless 無色 - + &P/T: 力量/防禦力: - + &Annotation: 註釋: - + &Destroy token when it leaves the table 此衍生物離場時會被摧毀 - + Create face-down (Only hides name) - + Token data 衍生物數據資料 - + Show &all tokens 顯示所有衍生物 - + Show tokens from this &deck 顯示此套牌所使用的衍生物 - + Choose token from list 從列表中選擇衍生物 - + Create token 派出一個衍生物 @@ -2288,7 +2749,7 @@ This will kick all non-ready players from the game. - + @@ -2303,12 +2764,12 @@ This will kick all non-ready players from the game. - + Duplicate Tag - + This tag already exists. @@ -2495,12 +2956,12 @@ To remove your current avatar, confirm without choosing a new image. 請輸入衍生物名稱: - + Error 錯誤 - + The chosen name conflicts with an existing card or token. Make sure to enable the 'Token' set in the "Manage sets" dialog to display them correctly. 所選擇的名字與現有的卡牌或衍生物有衝突。 @@ -2525,12 +2986,12 @@ Make sure to enable the 'Token' set in the "Manage sets" dia 未定義 - + Real name: 真實姓名: - + Edit user profile 編輯用戶資料 @@ -2568,113 +3029,113 @@ Make sure to enable the 'Token' set in the "Manage sets" dia 2小時 - + Hide 'buddies only' games - + Hide full games - + Hide games that have started - + Hide password protected games - + Hide 'ignored user' games - + Hide games not created by buddies Hide games not created by buddy - + Hide games with forced open decklists - + &Newer than: - + Game &description: 房間描述: - + &Creator name: 創建者名字: - + General 綜合 - + &Game types 房間類型 - + at &least: 至少: - + at &most: 最多: - + Maximum player count 可容納玩家數量 - + Restrictions 限制 - + Show games only if &spectators can watch - + Show spectator password p&rotected games - + Show only if spectators can ch&at - + Show only if spectators can see &hands - + Spectators 觀看者 - + Filter games 過濾房間 @@ -2977,37 +3438,37 @@ https://tappedout.net/mtg-decks/your-deck-name/ DlgMoveTopCardsUntil - + Card name (or search expressions): - + Number of hits: - + Auto play hits - + Put top cards on stack until... - + No cards matching the search expression exists in the card database. Proceed anyways? - + Cockatrice - + Invalid filter @@ -3066,40 +3527,40 @@ Your email will be used to verify your account. 未定義 - + Real name: 真實姓名: - + Register to server 註冊到伺服器 - - - - + + + + Registration Warning 註冊警告 - + Your password is too short. 你的密碼太短。 - + Your passwords do not match, please try again. 兩次輸入的密碼不一致,請重新輸入, - + Your email addresses do not match, please try again. 兩次輸入的電子郵箱不一致,請重新輸入。 - + The player name can't be empty. 玩家名稱不能為空白 @@ -3107,17 +3568,17 @@ Your email will be used to verify your account. DlgRollDice - + Number of sides: - + Number of dice: - + Roll Dice @@ -3168,12 +3629,12 @@ Your email will be used to verify your account. DlgSettings - + Unknown Error loading card database 讀取卡牌數據庫時出現未知錯誤 - + Your card database is invalid. Cockatrice may not function correctly with an invalid database @@ -3190,7 +3651,7 @@ Would you like to change your database location setting? 你想要重新設置卡牌資料庫路徑嗎? - + Your card database version is too old. This can cause problems loading card information or images @@ -3207,7 +3668,7 @@ Would you like to change your database location setting? 你想要重新設置卡牌資料庫路徑嗎? - + Your card database did not finish loading Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues with your cards.xml attached @@ -3216,7 +3677,7 @@ Would you like to change your database location setting? - + File Error loading your card database. Would you like to change your database location setting? @@ -3225,7 +3686,7 @@ Would you like to change your database location setting? 你想要重新設置卡牌數據庫路徑嗎? - + Your card database was loaded but contains no cards. Would you like to change your database location setting? @@ -3234,7 +3695,7 @@ Would you like to change your database location setting? 你想要重新設置卡牌資料庫路徑嗎? - + Unknown card database load status Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues @@ -3243,59 +3704,64 @@ Would you like to change your database location setting? - - - + + + Error 錯誤 - + The path to your deck directory is invalid. Would you like to go back and set the correct path? 你的套牌路徑無效。你想要重新設置正確的路徑嗎? - + The path to your card pictures directory is invalid. Would you like to go back and set the correct path? 你想要重新設置卡牌數據庫路徑嗎? - + Settings 設定 - + General 常規 - + Appearance 外觀 - + User Interface 用戶界面 - + + Storage + + + + Card Sources 卡牌的來源 - + Chat 聊天 - + Sound 聲音 - + Shortcuts 快捷鍵 @@ -3387,9 +3853,9 @@ You can always change this behavior in the 'General' settings tab. - - - + + + Error 錯誤 @@ -3442,31 +3908,31 @@ Please visit the download page to update manually. - + Update Available 更新可用 - + A new version of Cockatrice is available! 雞蛇有新版本可用! - + New version 新版本 - + Released 發佈 - + Changelog 更新日誌 @@ -3476,50 +3942,50 @@ Please visit the download page to update manually. 你打算現在就更新嗎? - + Unfortunately, the automatic updater failed to find a compatible download. You may have to manually download the new version. - + Please check the <a href="%1">releases page</a> on our Github and download the build for your system. - - - + + + Update Error 更新錯誤 - + An error occurred while checking for updates: 檢查更新時發生錯誤: - + An error occurred while downloading an update: 下載更新時發生錯誤: - + Installing... 正在安裝... - + Cockatrice is unable to open the installer. 雞蛇無法打開安裝程序。 - + Try to update manually by closing Cockatrice and running the installer. 嘗試關閉雞蛇運行安裝程序手動更新。 - + Download location 下載區域信息 @@ -3609,67 +4075,67 @@ You may have to manually download the new version. DrawProbabilityWidget - + Draw Probability - + Probability of drawing - + Card Name - + Type - + Subtype - + Mana Value - + At least - + Exactly - + card(s) having drawn at least - + cards - + Category - + Qty - + Odds (%) @@ -3783,7 +4249,7 @@ You may have to manually download the new version. FilterBuilder - + Type your filter here 輸入你打算篩選的類型 @@ -3814,22 +4280,22 @@ You may have to manually download the new version. GameEventHandler - + kicked by game host or moderator - + player left the game - + player disconnected from server - + reason unknown @@ -3837,140 +4303,140 @@ You may have to manually download the new version. GameSelector - - - - - - - - - + + + + + + + + + Error 錯誤 - + Please join the appropriate room first. 首先請加入一個房間. - + Wrong password. 密碼錯誤. - + Spectators are not allowed in this game. 這個遊戲不允許觀眾加入. - + The game is already full. 這個遊戲已經滿了. - + The game does not exist any more. 這個遊戲不能裝下啊更多了. - + This game is only open to registered users. 這個遊戲只開放給註冊用戶. - + This game is only open to its creator's buddies. 這個遊戲只開放給好友。 - + You are being ignored by the creator of this game. 你屏蔽了這個與遊戲的創造者. - + Join Game - + Spectate Game - + Game Information - + Join Game as Judge - + Spectate Game as Judge - + Join game 加入遊戲 - + Password: 密碼: - + Please join the respective room first. 請各自加入一個房間. - + &Filter games 過濾房間 - + C&lear filter 清除篩選項目 - + C&reate 創建 - + &Join 加入 - + Join as judge - + J&oin as spectator 觀看 - + Join as judge spectator - + Games shown: %1 / %2 顯示遊戲:%1/%2 - + Games 遊戲 @@ -3978,32 +4444,32 @@ You may have to manually download the new version. GameSelectorQuickFilterToolBar - + All types - + Filter by game name... - + Filter by game type/format - + Hide games not created by buddies - + Hide full games - + Hide started games @@ -4011,105 +4477,105 @@ You may have to manually download the new version. GamesModel - + >1 day - + %1%2 hr short age in hours - + new 新建 - + %1%2 min short age in minutes - + password 密碼 - + buddies only 僅限好友 - + reg. users only 僅限註冊用户 - + open decklists - + can chat 允許聊天 - + see hands 查看手牌 - + can see hands 允許查看手牌 - + not allowed 不允許 - + Room 房間 - + Age 時長 - + Description 描述 - + Creator 創建者 - + Type 類型 - + Restrictions 限制 - + Players 玩家 - + Spectators 觀看者 @@ -4117,143 +4583,158 @@ You may have to manually download the new version. GeneralSettingsPage - + Reset all paths - + All paths have been reset - - - - - - - + + + + + + + Choose path 選擇路徑 - - Personal settings - 個人設定 + + Language settings + - + Language: 語言: - + + Version settings + + + + + Card database + + + + + Startup settings + + + + Paths (editing disabled in portable mode) 路徑(在便攜模式下禁止編輯) - + Paths 路徑 - + How to help with translations - + Decks directory: 套牌路徑: - + Filters directory: - + Replays directory: 遊戲錄像目錄: - + Pictures directory: 圖片目錄: - + Card database: 卡牌數據庫: - + Custom database directory: - + Token database: 衍生物數據庫: - + Update channel 更新通道 - + Check for client updates on startup - + Check for card database updates on startup - + Don't check - + Prompt for update - + Always update in the background - + Check for card database updates every - + days - + Notify if a feature supported by the server is missing in my client 當客戶端缺少伺服器支持的特性時提示我 - + Automatically run Oracle when running a new version of Cockatrice 運行新版本雞蛇時自動運行Oracle - + Show tips on startup 顯示啓動時提示 - + Last update check on %1 (%2 days ago) @@ -4261,47 +4742,47 @@ You may have to manually download the new version. GraveyardMenu - + &Graveyard - + &View graveyard - + &Move graveyard to... - + &Top of library - + &Bottom of library - + &All players - + &Hand - + &Exile - + Reveal random card to... @@ -4309,88 +4790,88 @@ You may have to manually download the new version. HandMenu - + &Hand - + &View hand - + Sort hand by... - + Name - + Type - + Mana Value - + Take &mulligan (Choose hand size) - + Take mulligan (Same hand size) - + Take mulligan (Hand size - 1) - + &Move hand to... - + &Top of library - + &Bottom of library - + &Graveyard - + &Exile - + &Reveal hand to... - - + + All players - + Reveal r&andom card to... @@ -4398,52 +4879,52 @@ You may have to manually download the new version. HomeWidget - + Create New Deck - + Browse Decks - + Browse Card Database - + Browse EDHRec - + Browse Archidekt - + View Replays - + Quit - + Connecting... - + Connect - + Play @@ -4451,213 +4932,213 @@ You may have to manually download the new version. LibraryMenu - + &Library - + &View library - + View &top cards of library... - + View bottom cards of library... - + Reveal &library to... - + Lend library to... - + Reveal &top cards to... - + &Top of library... - + &Bottom of library... - + &Always reveal top card - + &Always look at top card - + &Open deck in deck editor - + &Draw card - + D&raw cards... - + &Undo last draw - + Shuffle - + &Play top card - + Play top card &face down - + Put top card on &bottom - + Move top card to grave&yard - + Move top card to e&xile - + Move top cards to &graveyard... - + Move top cards to graveyard face down... - + Move top cards to &exile... - + Move top cards to exile face down... - + Put top cards on stack &until... - + Shuffle top cards... - + &Draw bottom card - + D&raw bottom cards... - + &Play bottom card - + Play bottom card &face down - + Move bottom card to grave&yard - + Move bottom card to e&xile - + Move bottom cards to &graveyard... - + Move bottom cards to graveyard face down... - + Move bottom cards to &exile... - + Move bottom cards to exile face down... - + Put bottom card on &top - + Shuffle bottom cards... - - + + &All players - + Reveal top cards of library - + Number of cards: (max. %1) @@ -4665,634 +5146,273 @@ You may have to manually download the new version. MainWindow - - - The server has reached its maximum user capacity, please check back later. - 伺服器已達到最大用戶數,請稍微再試。 - - - - There are too many concurrent connections from your address. - 你的地址有太多連接. - - - - Banned by moderator - 已被版主禁止 - - - - Expected end time: %1 - 預計結束時間: %1 - - - - This ban lasts indefinitely. - 這個封禁是永久的. - - - - Scheduled server shutdown. - 預定伺服器關閉. - - - - - Invalid username. - 不可用的用戶名。 - - - - You have been logged out due to logging in at another location. - 由於在其他地點登陸,您已被登出。 - - - - Connection closed - 連接關閉 - - - - The server has terminated your connection. -Reason: %1 - 伺服器中斷連接. -原因: %1 - - - - The server is going to be restarted in %n minute(s). -All running games will be lost. -Reason for shutdown: %1 - - - - - Scheduled server shutdown - 預定伺服器關閉 - - - - - Success - 成功 - - - - Registration accepted. -Will now login. - 註冊已成功。 -現在登陸。 - - - - Account activation accepted. -Will now login. - 賬號已激活。 -現在登陸 - - - - + + Player %1 玩家 %1 - + Load replay 載入遊戲錄像 - + About Cockatrice 關於Cockatrice雞蛇 - + Version 版本 - + Cockatrice Webpage Cockatrice雞蛇網站 - + Project Manager: 項目經理: - + Past Project Managers: 前項目經理: - + Developers: 開發者: - + Our Developers 我們的開發者: - + Help Develop! 協助開發! - + Translators: 翻譯者: - + Our Translators 翻譯者名單 - + Help Translate! 協助翻譯! - + Support: 支持: - + Report an Issue 報告錯誤 - + Troubleshooting 排除故障 - + F.A.Q. 問答 - - - - - - - - - - - - - - - - - - - - + + Error 錯誤 - - Server timeout - 伺服器超時 - - - - Failed Login - 登錄失敗 - - - - Your client seems to be missing features this server requires for connection. - 你的客戶端缺少連接這個伺服器所需要的要素 - - - - To update your client, go to 'Help -> Check for Client Updates'. - 轉到幫助->檢查客户端更新,更新你的客户端。 - - - - Incorrect username or password. Please check your authentication information and try again. - 用户名或密碼錯誤。請檢查賬號信息並重試。 - - - - There is already an active session using this user name. -Please close that session first and re-login. - 已經有一個在線用户正在使用這個用户名. -首先請關閉這個對話框然後重新登陸. - - - - - You are banned until %1. - 你被禁止直到: %1. - - - - - You are banned indefinitely. - 你被永久封禁. - - - - This server requires user registration. Do you want to register now? - 伺服器需要註冊。現在註冊嗎? - - - - This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. -Please close and reopen your client to try again. - 伺服器需要客户端ID。你的客户端未能生成ID或你正在運行修改過的客户端。 -請關閉並重新打開客户端。 - - - - An internal error has occurred, please close and reopen Cockatrice before trying again. -If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. - 出現內部錯誤,請關閉並重啓客戶端再嘗試。如果錯誤仍然出現請將您的客戶端升級到最新版本,如有需要請聯繫您的軟件提供商。 - - - - Account activation - 賬號激活 - - - - Your account has not been activated yet. -You need to provide the activation token received in the activation email. - 你的賬號還未激活。 -請提供激活郵件中的驗證碼。 - - - - Server Full - 伺服器已滿 - - - - Unknown login error: %1 - 未知的登陸錯誤: %1 - - - - - -This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. - -這通常表示你的客户端已經過於陳舊,未能響應伺服器提出的請求。 - - - - Your username must respect these rules: - 您的用戶名須遵守以下規則: - - - - is %1 - %2 characters long - 在%1 - %2個字符之間。 - - - - can %1 contain lowercase characters - 能包含 %1 個小寫字母 - - - - - - - NOT - - - - - can %1 contain uppercase characters - 能包含%1個小寫字母 - - - - can %1 contain numeric characters - 能包含%1個數字 - - - - can contain the following punctuation: %1 - 能包含以下符號:%1 - - - - first character can %1 be a punctuation mark - 第一個字符不能為%1 標點符號 - - - - no unacceptable language as specified by these server rules: - note that the following lines will not be translated - - - - - can not contain any of the following words: %1 - 不能包含以下詞:%1 - - - - can not match any of the following expressions: %1 - 不能包含以下語句:%1 - - - - You may only use A-Z, a-z, 0-9, _, ., and - in your username. - 你可以使用A-Z,a-z,0-9,_,.以及-作為用户名。 - - - - - - - - - Registration denied - 註冊失敗 - - - - Registration is currently disabled on this server - 當前伺服器無法註冊 - - - - There is already an existing account with the same user name. - 當前用戶名已經被使用。 - - - - It's mandatory to specify a valid email address when registering. - 註冊時必須提供一個有效的電郵。 - - - - It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. - 看起來你正試圖在這個伺服器上註冊一個新賬户,但是你已經有了一個用提供的郵箱註冊的賬户。 此伺服器限制每個郵箱地址可以註冊用户帳户的數量。請聯繫伺服器運營商以獲得進一步幫助或獲取你的憑據信息。 - - - - Password too short. - 密碼太短。 - - - - Registration failed for a technical problem on the server. - 由於伺服器出現技術問題,註冊失敗。 - - - - The connection to the server has been lost. - - - - - Unknown registration error: %1 - 未知的註冊錯誤:%1 - - - - Account activation failed - 賬號激活失敗 - - - - Socket error: %1 - 接口錯誤: %1 - - - - You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. -Local version is %1, remote version is %2. - 你正在連接到一個過時的伺服器,請下調你的版本或者連接到一個匹配的伺服器. -你當前的版本 %1, 伺服器版本 %2. - - - - Your Cockatrice client is obsolete. Please update your Cockatrice version. -Local version is %1, remote version is %2. - 你使用的客户端意見過時,請使用更新你版本. -你當前的版本 %1, 伺服器版本 %2. - - - - Connecting to %1... - 連接到 %1⋯ - - - - Registering to %1 as %2... - 正在將 %2 註冊到 %1 - - - - Disconnected - 斷開連接 - - - - Connected, logging in at %1 - 連接登陸到 %1 - - - - - - Requesting forgotten password to %1 as %2... - 正在將%2向%1申請忘記密碼流程。 - - - + &Connect... 連接⋯ - + &Disconnect 斷開連接 - + Start &local game... 開始本地遊戲... - + &Watch replay... 觀看錄像... - + &Full screen 全屏 - + &Register to server... 註冊到伺服器 - + &Restore password... - + &Settings... 設置⋯ - + &Exit 退出 - + A&ctions 動作 - + &Cockatrice CockatriceCockatrice - + C&ard Database 卡牌資料庫 - + &Manage sets... 系列管理 - + Edit custom &tokens... 編輯自定義&tokens - + Open custom image folder 打開自定義圖片文件夾 - + Open custom sets folder 打開自定義系列文件夾 - + Add custom sets/cards 添加自定義系列/卡牌 - + Reload card database - + Tabs - + &Help 幫助 - + &About Cockatrice 關於Cockatrice雞蛇 - + &Tip of the Day & 每日提示 - + Check for Client Updates 檢查客戶端更新 - + Check for Card Updates... - + Check for Card Updates (Automatic) - + Show Status Bar - + View &Debug Log - + Open Settings Folder - + Show/Hide - + New Version 新版本 - + Congratulations on updating to Cockatrice %1! Oracle will now launch to update your card database. 恭喜Cockatrice更新到%1! 現在啓動Oracle更新你的卡牌資料庫。 - + Cockatrice installed 雞蛇已安裝 - + Congratulations on installing Cockatrice %1! Oracle will now launch to install the initial card database. 恭喜Cockatrice更新到%1! 現在啓動Oracle更新你的卡牌資料庫。 - + Card database 卡牌數據庫 - + Cockatrice is unable to load the card database. Do you want to update your card database now? If unsure or first time user, choose "Yes" @@ -5301,46 +5421,51 @@ If unsure or first time user, choose "Yes" 如果不確定或者你是第一次使用,請選擇“是” - - + + Yes - - + + No - + Open settings 打開設定 - + New sets found 發現新系列 - + %n new set(s) found in the card database Set code(s): %1 Do you want to enable it/them? - + + Yes, always enable + + + + View sets 查看系列 - + Welcome 歡迎 - + Hi! It seems like you're running this version of Cockatrice for the first time. All the sets in the card database have been enabled. Read more about changing the set order or disabling specific sets and consequent effects in the "Manage Sets" dialog. @@ -5349,165 +5474,132 @@ Read more about changing the set order or disabling specific sets and consequent 瞭解更多用“系列管理”窗口更改系列順序或者禁用某系列的信息。 - - + Information 信息 - + A card database update is already running. 數據庫更新已經在運行中。 - + Unable to run the card database updater: 無法運行卡組數據庫更新器: - + Card database update running. - + Failed to start. The file might be missing, or permissions might be incorrect. - + The process crashed some time after starting successfully. - + Timed out. The process took too long to respond. The last waitFor...() function timed out. - + An error occurred when attempting to write to the process. For example, the process may not be running, or it may have closed its input channel. - + An error occurred when attempting to read from the process. For example, the process may not be running. - + Unknown error occurred. - + The card database updater exited with an error: %1 - - This server supports additional features that your client doesn't have. -This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. - -To update your client, go to Help -> Check for Updates. - 你當前的客户端缺少該伺服器所需要的特性。 -通常不會有問題,但這代表有新版本的Cockatrice可供使用,或有預覽版本。 -要升級客户端,請點擊 幫助->檢查更新 - - - - - - - + + + + + Load sets/cards 載入系列/卡牌 - + Selected file cannot be found. 找不到選擇的文件。 - + You can only import XML databases at this time. 當前只能導入XML格式數據庫。 - + The new sets/cards have been added successfully. Cockatrice will now reload the card database. 新的系列/卡牌已添加成功。 CockatriceCockatrice現在會重新載入卡組數據庫。 - + Sets/cards failed to import. 系列/卡牌導入失敗。 - - - - - Reset Password - - - - - Your password has been reset successfully, you can now log in using the new credentials. - 你的密碼已重置,現在你可以使用新密碼登錄了。 - - - - Failed to reset user account password, please contact the server operator to reset your password. - 重置密碼失敗,請聯繫伺服器管理員重置密碼。 - - - - Activation request received, please check your email for an activation token. - 激活請求已收到,請查看你的電子郵箱獲取激活驗證碼。 - ManaBaseConfigDialog - + Mana Base Configuration - + Display type: - + pie - + bar - + combinedBar - + Filter Colors (optional): - + OK - + Cancel @@ -5579,27 +5671,27 @@ CockatriceCockatrice現在會重新載入卡組數據庫。 ManaDevotionConfigDialog - + Display type: - + pie - + bar - + combinedBar - + Filter Colors (optional): @@ -5669,610 +5761,615 @@ CockatriceCockatrice現在會重新載入卡組數據庫。 MessageLogWidget - + from play 從戰場上 - + from their graveyard 從他的墳墓場 - + from exile 從放逐區 - + from their hand 從他的手牌 - + the top card of %1's library %1牌庫頂的牌 - + the top card of their library 他牌庫頂的卡牌 - + from the top of %1's library 從%1的牌庫頂 - + from the top of their library 從他的牌庫頂 - + the bottom card of %1's library %1的牌庫底牌 - + the bottom card of their library 他牌庫底部的牌 - + from the bottom of %1's library 從%1的牌庫底 - + from the bottom of their library 從他的牌庫底 - + from %1's library 從%1的牌庫中 - + from their library 從他的牌庫 - + from sideboard 從備牌中 - + from the stack 從堆疊中 - + from custom zone '%1' - + %1 is now keeping the top card %2 revealed. %1已持續展示牌庫頂牌%2 - + %1 is not revealing the top card %2 any longer. %1不再%2展示牌庫頂牌。 - + %1 can now look at top card %2 at any time. - + %1 no longer can look at top card %2 at any time. - + %1 attaches %2 to %3's %4. %1 結附 %2 指向 %3的 %4. - + %1 has conceded the game. %1 已放棄遊戲。 - + %1 has unconceded the game. %1 已放棄遊戲。 - + %1 has restored connection to the game. %1已恢復連接。 - + %1 has lost connection to the game. %1 已失去連接。 - + %1 points from their %2 to themselves. %1 將%2 指向自己。 - + %1 points from their %2 to %3. %1 將%2指向%3。 - + %1 points from %2's %3 to themselves. %1將%2的%3 - + %1 points from %2's %3 to %4. %1 將%2的%3指向%4。 - + %1 points from their %2 to their %3. %1將他的%2指向他的%3。 - + %1 points from their %2 to %3's %4. %1將他的%2指向%3的%4。 - + %1 points from %2's %3 to their own %4. %1將%2的%3指向他自己的%4。 - + %1 points from %2's %3 to %4's %5. %1 將%2的%3指向%4的%5。 - + %1 creates a face down token. - + %1 creates token: %2%3. %1創建衍生物:%2%3。 - + %1 has loaded a deck (%2). %1 已載入套牌(%2) - + %1 has loaded a deck with %2 sideboard cards (%3). %1已載入含有 %2張備牌的套牌(%3) - + %1 destroys %2. %1銷燬了%2。 - + a card 一張牌 - + %1 gives %2 control over %3. %1讓%2控制%3。 - + %1 puts %2 into play%3 face down. - + %1 puts %2 into play%3. %1將%2%3放進戰場。 - + %1 puts %2%3 into their graveyard face down. - + %1 puts %2%3 into their graveyard. %1將%2%3置入他的墳墓場。 - + %1 exiles %2%3 face down. - + %1 exiles %2%3. %1 %3放逐了 %2 - + %1 moves %2%3 to their hand. %1將%2%3放回他的手牌中。 - + %1 puts %2%3 into their library. %1將%2%3放入他的牌庫。 - + %1 puts %2%3 onto the bottom of their library. - + %1 puts %2%3 on top of their library. %1將%2%3置於其牌庫頂。 - + %1 puts %2%3 into their library %4 cards from the top. %1將%2%3放入牌庫%4張牌放在牌庫頂 - + %1 moves %2%3 to sideboard. %1 將%2%3移動到備牌 - + %1 plays %2%3 face down. - + %1 plays %2%3. %1使用 %2%3。 - + %1 moves %2%3 to custom zone '%4' face down. - + %1 moves %2%3 to custom zone '%4'. - + %1 tries to draw from an empty library - + %1 draws %2 card(s). - + %1 is looking at %2. %1 正在查看%2。 - + %1 is looking at the %4 %3 card(s) %2. top card for singular, top %3 cards for plural - + bottom - + top - + %1 turns %2 face-down. %1回合%2翻為面朝下。 - + %1 turns %2 face-up. %1回合%2翻為面朝上。 - + The game has been closed. 遊戲已經關閉。 - + The game has started. 遊戲已開始。 - + You are flooding the game. Please wait a couple of seconds. - + %1 has joined the game. %1 已經加入遊戲。 - + %1 is now watching the game. %1 正在旁觀遊戲。 - + You have been kicked out of the game. 你已被踢出遊戲。 - + %1 has left the game (%2). %1 已經離開了遊戲(%2)。 - + %1 is not watching the game any more (%2). %1 不再旁觀遊戲(%2)。 - + %1 is not ready to start the game any more. %1 還未準備好開始遊戲。 - + %1 shuffles their deck and draws a new hand of %2 card(s). - + %1 shuffles their deck and draws a new hand. %1洗牌並抓了新的起手 - + You are watching a replay of game #%1. 你正在觀看遊戲#%1的錄像。 - + %1 is ready to start the game. %1已準備好開始遊戲。 - + cards an unknown amount of cards - + %1 card(s) a card for singular, %1 cards for plural - + %1 lends %2 to %3. - + %1 reveals %2 to %3. %1將%2展示給%3。 - + %1 reveals %2. %1 展示 %2。 - + %1 randomly reveals %2%3 to %4. %1隨機展示%2%3給%4。 - + %1 randomly reveals %2%3. %1隨機展示%2%3。 - + %1 peeks at face down card #%2. %1查看面朝下的卡牌#%2。 - + %1 peeks at face down card #%2: %3. %1查看面朝下的卡牌#%2:%3。 - + %1 reveals %2%3 to %4. %1展示%2%3給%4。 - + %1 reveals %2%3. %1展示%2%3。 - + %1 reversed turn order, now it's %2. %1 反轉回合順序,現在是%2的回合。 - + reversed 反轉 - + normal 正常 - + Heads 正面 - + Tails 反面 - + %1 flipped a coin. It landed as %2. %1擲硬幣。結果為%2。 - + %1 rolls a %2 with a %3-sided die. %1擲%3面骰子,結果為%2。 - + %1 flips %2 coins. There are %3 heads and %4 tails. - + %1 rolls a %2-sided dice %3 times: %4. - + %1's turn. 現在是%1的回合。 - + %1 sets annotation of %2 to %3. %1給%2添加了註釋%3。 - - %1 places %2 "%3" counter(s) on %4 (now %5). + + %1 places %2 %3%4 counter(s) on %5 (now %6). - - %1 removes %2 "%3" counter(s) from %4 (now %5). + + %1 removes %2 %3%4 counter(s) from %5 (now %6). - + + %1 failed to undo their last draw. + + + + %1 sets counter %2 to %3 (%4%5). %1 將%2指示物設置為 %3 (%4%5)。 - + %1 sets %2 to not untap normally. %1 將 %2 設置為不會被通常重置。 - + %1 sets %2 to untap normally. %1 將 %2 設置為可通常重置。 - + %1 removes the PT of %2. %1 移除%2力量/防禦 - + %1 changes the PT of %2 from nothing to %4. %1改變%2的力量/防禦至%4 - + %1 changes the PT of %2 from %3 to %4. %1將%2的力量/防禦從%3改變至%4 - + %1 has locked their sideboard. %1已鎖定他的備牌。 - + %1 has unlocked their sideboard. %1 解除鎖定他的備牌。 - + %1 taps their permanents. %1 橫置了永久物。 - + %1 untaps their permanents. %1 重置了永久物。 - + %1 taps %2. %1 橫置了 %2。 - + %1 untaps %2. %1 重置了 %2。 - + %1 shuffles %2. %1 切洗了%2。 - + %1 shuffles the bottom %3 cards of %2. %1 洗 %2 底的 %3 張卡 - + %1 shuffles the top %3 cards of %2. %1 洗 %2 頂的 %3 張卡 - + %1 shuffles cards %3 - %4 of %2. %1洗%3 - %4 到%2. - + %1 unattaches %2. %1 取消了%2的結附。 - + %1 undoes their last draw. %1撤銷了最後的抓牌。 - + %1 undoes their last draw (%2). %1撤銷了最後的抓牌(%2)。 @@ -6280,110 +6377,115 @@ CockatriceCockatrice現在會重新載入卡組數據庫。 MessagesSettingsPage - + Word1 Word2 Word3 詞1 詞2 詞3 - + Add New Message 添加新信息 - + Edit Message 編輯信息 - + Remove Message - + Add message 添加信息 - - + + Message: 信息: - + Edit message 編輯消息 - + Chat settings 聊天設定 - + Custom alert words 自定義警告語 - + Enable chat mentions 允許聊天中提到某人 - + Enable mention completer 允許自動完成提名 - + In-game message macros 遊戲內消息宏 - + How to use in-game message macros - + Ignore chat room messages sent by unregistered users 忽略未註冊用戶的聊天室消息。 - + Ignore private messages sent by unregistered users 忽略未註冊用戶發出的私人消息 - - + + Ignore private messages sent by non-buddy users + + + + + Invert text color 反轉文本顏色 - + Enable desktop notifications for private messages 開啓私人消息桌面提醒 - + Enable desktop notification for mentions 開啓聊天提名桌面提醒 - + Enable room message history on join 開啓加入聊天室時的歷史消息 - - + + (Color is hexadecimal) (顏色為16進制) - + Separate words with a space, alphanumeric characters only 將單詞以空格區分,僅支持字母 @@ -6391,42 +6493,42 @@ CockatriceCockatrice現在會重新載入卡組數據庫。 MoveMenu - + Move to - + &Top of library in random order - + X cards from the top of library... - + &Bottom of library in random order - + T&able - + &Hand - + &Graveyard - + &Exile @@ -6439,47 +6541,47 @@ CockatriceCockatrice現在會重新載入卡組數據庫。 牌類型 - + Mana Value 魔法力值 - + Color(s) 顏色 - + Loyalty 忠誠 - + Main Card Type 牌的主類型 - + Mana Cost 魔法力費用 - + P/T 力量/防禦力 - + Side 旁邊 - + Layout 佈局 - + Color Identity 標識色 @@ -6502,6 +6604,152 @@ CockatriceCockatrice現在會重新載入卡組數據庫。 + + PaletteEditorDialog + + + + Reset + + + + + + Apply + + + + + + Save && Apply + + + + + ▼ Edit Palette + + + + + + ▶ Edit Palette + + + + + Palette Editor — %1 + + + + + <b>Palette Editor</b> &nbsp;·&nbsp; %1 + + + + + This theme ships no default palette files + + + + + Replace current colours with the theme author's defaults + + + + + Switch between the light and dark palette files + + + + + Editing: + + + + + Show or hide the per-role colour grid for manual tweaks + + + + + ↺ Revert to theme default + + + + + Discard unsaved edits and restore the last saved palette + + + + + Preview this palette without saving to disk + + + + + Write palette-%1.toml and reload the theme + + + + + Cannot save: this theme has no directory on disk + + + + + Save failed + + + + + Could not write %1 to: +%2 + + + + + No default found + + + + + No default palette file found for the "%1" scheme. + + + + + PaletteGridWidget + + + Active + + + + + Disabled + + + + + Inactive + + + + + Normal interactive state + + + + + Widget is disabled / not interactive + + + + + Window is in background / unfocused + + + Phase @@ -6568,57 +6816,57 @@ CockatriceCockatrice現在會重新載入卡組數據庫。 PhasesToolbar - + Untap step 重置步驟 - + Upkeep step 維持步驟 - + Draw step 抓牌步驟 - + First main phase 主要步驟1 - + Beginning of combat step 戰鬥開始步驟 - + Declare attackers step 宣告進攻步驟 - + Declare blockers step 宣告阻擋步驟 - + Combat damage step 戰鬥傷害步驟 - + End of combat step 戰鬥結束步驟 - + Second main phase 主要步驟2 - + End of turn step 結束步驟 @@ -6626,7 +6874,7 @@ CockatriceCockatrice現在會重新載入卡組數據庫。 PictureLoader - + en code for scryfall's language property, not available for all languages @@ -6635,151 +6883,142 @@ CockatriceCockatrice現在會重新載入卡組數據庫。 PlayerActions - - View top cards of library - - - - - - - - - - - - - Number of cards: (max. %1) - - - - - View bottom cards of library - - - - - Shuffle top cards of library - - - - - Shuffle bottom cards of library - - - - - Draw hand - - - - - 0 and lower are in comparison to current hand size - - - - - Draw cards - - - - - - - + + + + grave - - - - + + + + exile + + + PlayerDialogs - + + View top cards of library + + + + + + + + + + + + + Number of cards: (max. %1) + + + + + View bottom cards of library + + + + + Shuffle top cards of library + + + + + Shuffle bottom cards of library + + + + + Draw hand + + + + + 0 and lower are in comparison to current hand size + + + + + Draw cards + + + + Move top cards to %1 - + Move bottom cards to %1 - + Draw bottom cards - - - C&reate another %1 token - - - - + Create tokens - - + Number: - + Place card X cards from top of library - + Which position should this card be placed: - + (max. %1) - + Change power/toughness - + Change stats to: - + Set annotation - + Please enter the new annotation: - - - Set counters - - PlayerMenu - + Player "%1" - + &Counters @@ -6809,7 +7048,7 @@ This setting means you'll only see the default printing for each card, instead o - + Printing Selector @@ -6817,22 +7056,22 @@ This setting means you'll only see the default printing for each card, instead o PrintingSelectorCardOverlayWidget - + Preference - + Pin Printing - + Unpin Printing - + Show Related cards @@ -6903,57 +7142,57 @@ This setting means you'll only see the default printing for each card, instead o PtMenu - + Power / toughness - + &Increase power - + &Decrease power - + I&ncrease toughness - + D&ecrease toughness - + In&crease power and toughness - + Dec&rease power and toughness - + Increase power and decrease toughness - + Decrease power and increase toughness - + Set &power and toughness... - + Reset p&ower and toughness @@ -6961,37 +7200,37 @@ This setting means you'll only see the default printing for each card, instead o QMenuBar - + Services 服務 - + Hide %1 隱藏 %1 - + Hide Others 隱藏其他 - + Show All 展示所有 - + Preferences... 參數 - + Quit %1 退出%1 - + About %1 關於 %1 @@ -6999,17 +7238,17 @@ This setting means you'll only see the default printing for each card, instead o QObject - + Cockatrice card database (*.xml) CockatriceCockatrice卡牌數據庫 (*.xml) - + All files (*.*) 全部文件 (*.*) - + Cockatrice replays (*.cor) Cockatrice錄像文件 (*.cor) @@ -7069,110 +7308,177 @@ Are you sure you would like to disable this feature? QPlatformTheme - + OK 確定 - + Save 保存 - + Save All 保存全部 - + Open 打開 - + &Yes - + Yes to &All 全選是 - + &No - + N&o to All 全選否 - + Abort 退出 - + Retry 重試 - + Ignore 忽略 - + Close 關閉 - + Cancel 取消 - + Discard 棄牌 - + Help 幫助 - + Apply 應用 - + Reset 重置 - + Restore Defaults 恢復為默認值 + + QuickSetupPanel + + + %1% + + + + + <b>Quick Setup</b> + + + + + Generate all palette roles automatically from a single accent colour + + + + + Accent: + + + + + Primary hue. Used directly for highlights and links. +At high intensity it also tints buttons and backgrounds. + + + + + Intensity: + + + + + Subtle + + + + + Full colour + + + + + 0–30 Subtle tint — only highlights and links change hue +30–70 Accented — buttons, tooltips, and borders join in +70–100 Full colour — backgrounds, everything + + + + + 70% + + + + + Generate ↓ + + + + + Derive all palette roles from the accent colour above. +Fine-tune individual colours in the grid afterwards. + + + RemoteDeckList_TreeModel - + Name 名稱 - + ID ID - + Upload time 上傳時間 @@ -7180,32 +7486,32 @@ Are you sure you would like to disable this feature? RemoteReplayList_TreeModel - + ID ID - + Name 名稱 - + Players 玩家 - + Keep 維持 - + Time started 遊戲時間 - + Duration (sec) 持續(離開) @@ -7213,37 +7519,37 @@ Are you sure you would like to disable this feature? RfgMenu - + &Exile - + &View exile - + &Move exile to... - + &Top of library - + &Bottom of library - + &Hand - + &Graveyard @@ -7289,7 +7595,7 @@ Are you sure you would like to disable this feature? SayMenu - + S&ay @@ -7330,27 +7636,27 @@ Are you sure you would like to disable this feature? SetsModel - + Enabled 已啓用 - + Set type 設置類型 - + Set code 設置代碼 - + Long name 長的名稱 - + Release date 發佈日期 @@ -7358,53 +7664,53 @@ Are you sure you would like to disable this feature? ShortcutSettingsPage - - + + Restore all default shortcuts 恢復所有默認快捷鍵 - + Do you really want to restore all default shortcuts? 確定要恢復所有默認快捷鍵嗎? - + Clear all default shortcuts 清楚所有默認快捷鍵 - + Do you really want to clear all shortcuts? 確定要清楚所有快捷鍵? - + Section: 部分: - + Action: 行動: - + Shortcut: 快捷鍵: - + How to set custom shortcuts 如何設置自定義快捷鍵 - + Clear all shortcuts - + Search by shortcut name @@ -7460,12 +7766,12 @@ Please check your shortcut settings! SideboardMenu - + &Sideboard - + &View sideboard @@ -7473,27 +7779,27 @@ Please check your shortcut settings! SoundSettingsPage - + Enable &sounds 啓用聲效 - + Current sounds theme: 當前聲效主題: - + Test system sound engine 測試系統聲效 - + Sound settings 聲效設定 - + Master volume 主音量 @@ -7565,16 +7871,146 @@ Please check your shortcut settings! 收到更新伺服器的無效回應。 - + No reply received from the tag update server. 未收到標籤更新伺服器的回應 - + Invalid reply received from the tag update server. 收到標籤更新伺服器的無效回應 + + StorageSettingsPage + + + + + Success + + + + + Cached card pictures have been reset. + + + + + Downloaded card pictures have been reset. + + + + + Error + + + + + One or more downloaded card pictures could not be cleared. + + + + + In-memory (currently loaded) card pictures have been reset. + + + + + Card Picture Loader Caching Method: + + + + + The network cache is the preferred way of storing images. Downloaded images are stored here until the size of the cache exceeds the configured size. Cockatrice automatically monitors this cache and deletes the least recently seen card images to ensure the cache does not exceed the configured size. + + + + + Writing card images directly to a folder on your hard drive is another way of storing images. This does not change how Cockatrice accesses or downloads images. Cockatrice will NOT automatically monitor and clear this folder, so if you enable this option, it is up to you to ensure sufficient available space. It should also be noted that if a provider outage causes you to download the wrong picture (i.e. wrong printing) you will be stuck with it until you manually delete the file, as opposed to using the network cache, which automatically rotates and thus correct errors after a while. + + + + + This is the in-memory picture cache used by the application at runtime. It determines how much memory (RAM) Cockatrice can use before it has to fetch card images from the hard disk again. Increasing this will allow more card images to be displayed at once but shouldn't be necessary. Clearing this will make Cockatrice reload all images from the network cache or the disk. + + + + + Delete Cached Images + + + + + Delete Saved Images + + + + + Clear In-Memory Images + + + + + Card Picture Loader Cache Method + + + + + Network Cache + + + + + Filesystem + + + + + In-Memory Picture Cache + + + + + Network Cache Size: + + + + + On-disk cache for downloaded pictures + + + + + Redirect Cache TTL: + + + + + How long cached redirects for urls are valid for. + + + + + Picture Cache Size: + + + + + In-memory cache for pictures not currently on screen + + + + + Naming scheme: + + + + + Day(s) + + + TabAccount @@ -7710,117 +8146,117 @@ Please check your shortcut settings! TabArchidekt - - + + Desc. - - + + AND - - + + Require ALL selected colors - - + + Deck name... - - + + Owner... - - + + Packages - - + + Advanced Filters - + Bracket: - - + + Any - - + + Contains card... - - + + Commander... - - + + Tag... - - + + Deck Size - + Cards: - - + + Asc. - + Sort by: - + Filter by: - + Display Settings - - + + Search - - + + Formats @@ -7830,6 +8266,54 @@ Please check your shortcut settings! + + TabCardArtRules + + + Card: + + + + + ProviderId: + + + + + Mode: + + + + + Reason: + + + + + Type a card name... + + + + + Add rule + + + + + Remove rule + + + + + Refresh + + + + + Card Art Rules + + + TabDeckEditor @@ -7878,7 +8362,7 @@ Please check your shortcut settings! 重置界面 - + Deck: %1 套牌: %1 @@ -7886,55 +8370,49 @@ Please check your shortcut settings! TabDeckEditorVisual - + Visual Deck: %1 - + &Visual Deck Editor - - + + Card Info - - + + Deck - - - Filters - - - - + &View - + Printing - + Visible - + Floating - + Reset layout @@ -7942,22 +8420,22 @@ Please check your shortcut settings! TabDeckEditorVisualTabWidget - + Visual Deck View - + Visual Database Display - + Deck Analytics - + Sample Hand @@ -7965,134 +8443,134 @@ Please check your shortcut settings! TabDeckStorage - + Local file system 本地文件系統 - + Server deck storage 伺服器上的套牌倉庫 - - + + Open in deck editor 打開套牌編輯 - + Rename deck or folder - + Upload deck 上傳套牌 - + Download deck 下載套牌 - - - + + + New folder 新建文件夾 - + Delete 刪除 - + Open decks folder - + Rename local folder - + Rename local file - + New name: - - - - + + + + Error 錯誤 - + Rename failed - - + + Invalid deck file - + Enter deck name 輸入套牌名稱 - + This decklist does not have a name. Please enter a name: 這個套牌沒有名稱. 請輸入一個名稱: - + Unnamed deck 未命名套牌 - + Failed to upload deck to server - + Delete local file 刪除本地文件 - + Are you sure you want to delete the selected files? - + Delete remote decks - + Are you sure you want to delete the selected decks? - - + + Name of new folder: 新建文件夾的名稱: @@ -8164,191 +8642,191 @@ Please enter a name: TabGame - - - + + + Replay 錄像 - - + + Game 遊戲 - - + + Player List 玩家列表 - - + + Card Info 卡牌信息 - - + + Messages 消息 - - + + Replay Timeline 錄像時間線 - + &Phases 階段 - + &Game 遊戲 - + Next &phase 下個階段 - + Next phase with &action 下個階段&行動 - + Next &turn 下個回合 - + Reverse turn order 反轉回合順序 - + &Remove all local arrows 移除所有箭頭 - + Rotate View Cl&ockwise 順時針旋轉視角 - + Rotate View Co&unterclockwise 逆時針旋轉視角 - + Game &information 遊戲信息 - + Un&concede - - - + + + &Concede 放棄遊戲 - + &Leave game 離開遊戲 - + C&lose replay 關閉遊戲錄像 - + &Focus Chat 聚焦聊天室 - + &Say: 説: - + Selected cards - + &View 視圖 - + Visible 可見 - + Floating 移動窗口 - + Reset layout 重置界面 - + Concede 投降 - + Are you sure you want to concede this game? 你確定放棄這個遊戲? - + Unconcede 取消投降 - + You have already conceded. Do you want to return to this game? 你已經投降,你想回到這場遊戲嗎? - + Leave game 離開遊戲 - + Are you sure you want to leave this game? 你確定離開這這個遊戲? - + A player has joined game #%1 - + %1 has joined the game - + You have been kicked out of the game. 你已被踢出遊戲。 @@ -8407,115 +8885,115 @@ Please enter a name: 您必須選擇一個可用的天數用來定位 - + Username: 用户名: - + IP Address: IP地址: - + Game Name: 遊戲名: - + GameID: 遊戲ID: - + Message: 消息: - + Main Room 主房間 - + Game Room 遊戲房間 - + Private Chat 私人聊天 - + Past X Days: 過去 X 天: - + Today 今天 - + Last Hour 1小時前 - + Maximum Results: 最大結果: - + At least one filter is required. The more information you put in, the more specific your results will be. 至少需要1個篩選項。 您輸入的信息越多,找到結果越準確。 - + Get User Logs 獲取用戶記錄 - + Clear Filters 清楚篩選項目 - + Filters 過濾器 - + Log Locations 記錄位置 - + Date Range 日期範圍 - + Maximum Results 最大結果 - - + + Message History 消息歷史 - + Failed to collect message history information. 獲取消息歷史信息失敗。 - + There are no messages for the selected filters. 沒有符合篩選條件的消息。 @@ -8533,27 +9011,27 @@ The more information you put in, the more specific your results will be.離開 - + %1 - Private chat %1 - 私人聊天 - + This user is ignoring you, they cannot see your messages in main chat and you cannot join their games. 該用戶已屏蔽你,他無法在主聊天中看見你的消息,你也不能加入他的遊戲。 - + Private message from 私人信息來自 - + %1 has left the server. %1 已經離開了伺服器. - + %1 has joined the server. %1 已經加入了伺服器. @@ -8561,180 +9039,180 @@ The more information you put in, the more specific your results will be. TabReplays - + Local file system 本地文件系統 - + Server replay storage 伺服器錄像倉庫 - - + + Watch replay 觀看錄像 - + Rename - - + + New folder - - + + Delete 刪除 - + Open replays folder - + Download replay 下載遊戲錄像 - + Toggle expiration lock 過期鎖定 - + Get replay share code - - + + Look up replay by share code - + Rename local folder - + Rename local file - + New name: - + Error - + Rename failed - + Name of new folder: - + Delete local file 刪除本地文件 - + Are you sure you want to delete the selected files? - + Are you sure you want to delete the selected replays? - + Failed to get code - - + + Either this server does not support replay sharing, or does not permit replay sharing for you. - - - + + + Failed - + Could not get replay code - + Replay Share Code - + Others can use this code to add the replay to their list of remote replays: %1 - + Copy to clipboard - + Replay share code - + Replay code found - + Replay was added, or you already had access to it. - + Replay code not found - + Failed to submit code - + Unexpected error - + Delete remote replay 刪除伺服器上的錄像 @@ -8747,47 +9225,62 @@ The more information you put in, the more specific your results will be. TabRoom - + + Friends + + + + + Online + + + + + Ignored + + + + &Say: 説: - + Chat 聊天 - + &Room 房間 - + &Leave room 離開房間 - + &Clear chat 清除聊天記錄 - + Chat Settings... 聊天設定 - + mentioned you. 提到了你。 - + Click to view 點擊查看 - + You are flooding the chat. Please wait a couple of seconds. 你正在灌水,請等待幾秒再發言. @@ -8800,30 +9293,30 @@ The more information you put in, the more specific your results will be.伺服器 - - - - + + + + Error 錯誤 - + Failed to join the server room: it doesn't exist on the server. - + The server thinks you are in the server room but your client is unable to display it. Try restarting your client. - + You do not have the required permission to join this server room. - + Failed to join the server room due to an unknown error: %1. @@ -8831,97 +9324,97 @@ The more information you put in, the more specific your results will be. TabSupervisor - + Deck Editor - + Visual Deck Editor - + EDHRec - + Archidekt - + Home - + &Visual Deck Storage - + Visual Database Display - + Server - + Account - + Deck Storage - + Game Replays - + Administration - + Logs - + Are you sure? 你確定嗎? - + There are still open games. Are you sure you want to quit? 遊戲還在繼續, 你確定要退出嗎? - + Click to view 點擊查看 - + Your buddy %1 has signed on! 你的好友%1已登錄! - + Unknown Event 未知事件 - + The server has sent you a message that your client does not understand. This message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -8932,39 +9425,39 @@ To update your client, go to Help -> Check for Updates. 要升級你的客户端,請點擊“幫助”->“檢查更新” - + Idle Timeout 閒置超時 - + You are about to be logged out due to inactivity. 你即將因為長時間未操作而被退出。 - + Promotion 晉升 - + You have been promoted. Please log out and back in for changes to take effect. 你已被提升為版主。請退出再登陸以使修改生效。 - + Warned 被警告 - + You have received a warning due to %1. Please refrain from engaging in this activity or further actions may be taken against you. If you have any questions, please private message a moderator. 你因為%1被警告。 請杜絕此類行為否則會被採取進一步行動。如果有任何疑問,請私信版主。 - + You have received the following message from the server. (custom messages like these could be untranslated) 你收到來自伺服器的如下消息。 @@ -8974,12 +9467,12 @@ Please refrain from engaging in this activity or further actions may be taken ag TabVisualDatabaseDisplay - + Database Display - + Visual Database Display @@ -9018,42 +9511,42 @@ Please refrain from engaging in this activity or further actions may be taken ag TranslateCounterName - + Life 生命值 - + White 白色 - + Blue 藍色 - + Black 黑色 - + Red 紅色 - + Green 綠色 - + Colorless 無色 - + Other 其他 @@ -9061,11 +9554,69 @@ Please refrain from engaging in this activity or further actions may be taken ag UpdateDownloader - + Could not open the file for reading. 無法打開文件。 + + UserCardArtSettingsDialog + + + Card Art Settings + + + + + Type a card name... + + + + + Card name: + + + + + Card ProviderId: + + + + + Left margin (%): + + + + + Right margin (%): + + + + + Vertical offset: + + + + + Zoom: + + + + + Parameters + + + + + Preview + + + + + Remove Banner Card + + + UserContextMenu @@ -9183,7 +9734,7 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Ban History 禁止歷史 @@ -9198,77 +9749,87 @@ Please refrain from engaging in this activity or further actions may be taken ag 用戶從未被禁止 - + Failed to collect ban information. 獲取禁止信息失敗。 - - - + + + Warning History 警告歷史 - + Warning Time;Moderator;User Name;Reason 警告時間;版主;用户名;理由 - + User has never been warned. 用戶從未被警告 - + Failed to collect warning information. 獲取警告信息失敗。 - + Failed to get admin notes. - - + + Success 成功 - + Successfully promoted user. 提升用戶成功。 - + Successfully demoted user. 撤銷用戶成功。 - - - + + Kick Player + + + + + Are you sure you want to kick this player from the game? + + + + + + Failed 失敗 - + Failed to promote user. 提升用戶失敗。 - + Failed to demote user. 撤銷用戶失敗。 - + Copy hash to clipboard 複製哈希到剪貼板 - + Remove this user's messages @@ -9276,326 +9837,554 @@ Please refrain from engaging in this activity or further actions may be taken ag UserInfoBox - + Location: 位置: - + Account Age: 賬號年齡: - + Edit 編輯 - + Change password 修改密碼 - + Change avatar 修改頭像 - + Administrator 管理員 - + Moderator 版主 - + Registered user 註冊用戶 - - + + Unregistered user 未註冊用戶 - + Judge 裁判 - + Unknown 未知的 - + The entered password does not match your account. - - - + + + + Information 信息 - + User information updated. 用戶信息已更新。 - - - - - - - - - - + + + + + + + + + + + Error 錯誤 - + User Information 用戶信息 - + Real Name: 真實姓名: - + User Level: 用戶等級: + + + Edit Banner Card + + - + %n Year(s), amount of years (only shown if more than 0) - + %10%n Day(s) %20 amount of years (if more than 0), amount of days, date in local short format - + Enter Password 輸入密碼 - + Password verification is required in order to change your email address - - - + + + An error occurred while trying to update your user information. - + + The selected card is blacklisted on this server or another error occurred. + + + + + Banner card removed. + + + + + Banner card updated. + + + + This server does not permit you to update your user informations. 該伺服器不允許你更新你的用戶信息。 - + Password changed. 密碼已修改。 - + This server does not permit you to change your password. 該伺服器不允許你修改你的密碼。 - + The new password is too short. 新密碼太短。 - + The old password is incorrect. 原密碼不正確。 - + Avatar updated. 頭像已更新。 - + This server does not permit you to update your avatar. 該伺服器不允許你更新你的頭像。 + + + An error occured while trying to update your avatar. + + - An error occured while trying to updater your avatar. - 嘗試更新你的頭像時出錯。 + This server does not permit you to update your user informations. + An error occured while trying to updater your avatar. + 該伺服器不允許你更新你的用戶信息。 + + + + UserInfoPopup + + + + Games + + + + + Chat + + + + + Open private chat + + + + + Profile + + + + + View user profile + + + + + Show this user's games + + + + + − Buddy + + + + + Remove from buddy list + + + + + + Buddy + + + + + Add to buddy list + + + + + − Ignore + + + + + Remove from ignore list + + + + + + Ignore + + + + + Add to ignore list + + + + + Ban + + + + + Ban from server + + + + + Warn + + + + + Warn user + + + + + Ban log + + + + + View ban history + + + + + Warn log + + + + + View warning history + + + + + Notes + + + + + View admin notes + + + + + − Mod + + + + + Demote from moderator + + + + + + Mod + + + + + Promote to moderator + + + + + − Judge + + + + + Demote from judge + + + + + + Judge + + + + + Promote to judge + + + + + Join game + + + + + Spectate + + + + + + Loading games… + + + + + Could not load games. + + + + + No active games. + UserInterfaceSettingsPage - + General interface settings 通用接口設定 - + &Double-click cards to play them (instead of single-click) 雙擊卡牌開始 (而不是單擊開始) - + &Clicking plays all selected cards (instead of just the clicked card) - + &Play all nonlands onto the stack (not the battlefield) by default 默認將所有非地牌加入堆疊(不是戰場) - + Do not delete &arrows inside of subphases - + Close card view window when last card is removed - + Auto focus search bar when card view window is opened - + Annotate card text on tokens 用卡牌信息給衍生物標注 - - Show selection counter during drag selection + + Show selection count during drag selection - - Show total selection counter + + Show total selection count - + + Show subtype breakdown in selection tally + + + + Use tear-off menus, allowing right click menus to persist on screen 使用浮動菜單,允許右鍵單擊菜單保留在屏幕上 - + + Keep game chat focused when clicking in game (Note: disables card view search bar) + + + + Notifications settings 通知設定 - + Enable notifications in taskbar 開啓任務欄提醒 - + Notify in the taskbar for game events while you are spectating 觀看時在任務欄提示遊戲信息 - + Notify in the taskbar when users in your buddy list connect 在任務欄中提示好友連接到伺服器 - + Animation settings 動畫設定 - + &Tap/untap animation 橫置/重置 動畫 - + Deck editor/storage settings - + Open deck in new tab by default - + Use visual deck storage in game lobby - + Use selection animation for Visual Deck Storage - + When adding a tag in the visual deck storage to a .txt deck: - + do nothing - + ask to convert to .cod - + always convert to .cod - + Default deck editor type - + Classic Deck Editor - + Visual Deck Editor - + Replay settings - + Buffer time for backwards skip via shortcut: @@ -9603,22 +10392,22 @@ Please refrain from engaging in this activity or further actions may be taken ag UserListWidget - + Users connected to server: %1 - + Users in this room: %1 - + Buddies online: %1 / %2 - + Ignored users online: %1 / %2 @@ -9626,35 +10415,45 @@ Please refrain from engaging in this activity or further actions may be taken ag UtilityMenu - + Increment all card counters - + &Untap all permanents - + R&oll die... - + + Flip coin + + + + &Create token... - + C&reate another token - + Cr&eate predefined token + + + C&reate another %1 token + + VisualDatabaseDisplayColorFilterWidget @@ -9706,72 +10505,72 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayFilterToolbarWidget - + Sort by - + Filter by - + Save and load filters - + Filter by exact card name - + Filter by card main-type - + Filter by card sub-type - + Filter by set - + Filter by format legality - + Save/Load - + Name - + Main Type - + Sub Type - + Sets - + Formats @@ -9779,22 +10578,22 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayFormatLegalityFilterWidget - + Show formats with at least: - + cards - + Do not display formats with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) @@ -9812,32 +10611,32 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayMainTypeFilterWidget - + Show main types with at least: - + cards - + Do not display card main-types with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) - + Mode: Exact Match - + Mode: Includes @@ -9845,27 +10644,27 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayNameFilterWidget - + Filter by name... (Exact match) - + Load from Deck - + Apply all card names in currently loaded deck as exact match name filters - + Load from Clipboard - + Apply all card names in clipboard as exact match name filters @@ -9873,7 +10672,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayRecentSetFilterSettingsWidget - + Filter to most recent sets @@ -9881,19 +10680,19 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplaySetFilterWidget - + Search sets... - - + + Mode: Exact Match - - + + Mode: Includes @@ -9901,37 +10700,37 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplaySubTypeFilterWidget - + Search subtypes... - + Show sub types with at least: - + cards - + Do not display card sub-types with less than this amount of cards in the database - + Filter mode (AND/OR/NOT conjunctions of filters) - + Mode: Exact Match - + Mode: Includes @@ -9939,23 +10738,23 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayWidget - + Search by card name (or search expressions) - + Visual - + Loading database ... - + Clear all filters @@ -9994,17 +10793,17 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Toggle Layout: Overlap - + Change how cards are displayed within zones (i.e. overlapped or fully visible.) - + Toggle Layout: Flat @@ -10033,17 +10832,17 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckEditorWidget - + Type a card name here for suggestions from the database... - + Quick search and add card - + Search for closest match in the database (with auto-suggestions) and add preferred printing to the deck on pressing enter @@ -10161,43 +10960,43 @@ Please refrain from engaging in this activity or further actions may be taken ag WarningDialog - + Which warning would you like to send? 你想發出那種警告? - + Redact all messages from this user in all rooms - + &OK 確認 - + &Cancel 取消 - + Warn user for misconduct 警告用戶的不當行為 - - + + Error 錯誤 - + User name to send a warning to can not be blank, please specify a user to warn. 發送警告的用戶名不能為空,請提供用戶名以供警告。 - + Warning to use can not be blank, please select a valid warning to send. 警告不能為空,請選擇警告以供發送。 @@ -10205,133 +11004,133 @@ Please refrain from engaging in this activity or further actions may be taken ag WndSets - + Move selected set to the top 將選擇的系列移到頂端 - + Move selected set up 將選擇的系列上移 - + Move selected set down 將選擇的系列下移 - + Move selected set to the bottom 將選擇的系列移到底部 - + Search by set name, code, or type 通過系列名、代碼或類型搜索 - + Default order 默認順序 - + Restore original art priority order 恢復初始優先順序 - + Enable all sets 啓用所有系列 - + Disable all sets 禁用所有系列 - + Enable selected set(s) 啓用所選系列 - + Disable selected set(s) 禁用所選系列 - + Deck Editor 套牌編輯器 - + Use CTRL+A to select all sets in the view. - + Only cards in enabled sets will appear in the card list of the deck editor. - + Image priority is decided in the following order: - + first the CUSTOM Folder (%1), then the Enabled Sets in this dialog (Top to Bottom) %1 is a link to the wiki - + Include cards rebalanced for Alchemy [requires restart] - + Card Art 牌張風格 - + How to use custom card art 如何使用自定義卡牌圖片 - + Hints 提示 - + Note - + Sorting by column allows you to find a set while not changing set priority. 按列排序允許您在不更改系列優先級的情況下查找系列。 - + To enable ordering again, click the column header until this message disappears. 要再次啓用排序,請單擊列標題,直到此消息消失。 - + Use the current sorting as the set priority instead 使用當前系列的優先級排序 - + Sorts the set priority using the same column 使用同一列系列的優先級排序 - + Manage sets 系列管理 @@ -10339,72 +11138,72 @@ Please refrain from engaging in this activity or further actions may be taken ag ZoneViewWidget - + Search by card name (or search expressions) - + Ungrouped - + Group by Type - + Group by Mana Value - + Group by Color - + Unsorted - + Sort by Name - + Sort by Type - + Sort by Mana Cost - + Sort by Colors - + Sort by P/T - + Sort by Set - + shuffle when closing 當關閉界面時洗牌 - + pile view 柱形圖 @@ -10412,7 +11211,7 @@ Please refrain from engaging in this activity or further actions may be taken ag i18n - + English 繁體中文 (Chinese Traditional) @@ -10420,12 +11219,12 @@ Please refrain from engaging in this activity or further actions may be taken ag main - + Connect on startup 啓動連接 - + Debug to file 調試至文件 @@ -10439,7 +11238,7 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Deck Editor 套牌編輯 @@ -10520,7 +11319,7 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Replays @@ -10635,129 +11434,129 @@ Please refrain from engaging in this activity or further actions may be taken ag 讀取套牌 - - + + Load Deck from Clipboard... 從剪貼板載入套牌 - + Edit Deck in Clipboard, Annotated - + Edit Deck in Clipboard - + New Deck 創建新套牌 - + Open Custom Pictures Folder 打開自定義圖片文件夾 - + Print Deck... 打印套牌 - + Delete Card 刪除卡牌 - - + + Reset Layout 重置佈局 - + Save Deck 保存套牌 - + Save Deck as... 套牌另存為 - + Save Deck to Clipboard, Annotated 保存套牌到剪貼板,帶注釋 - + Save Deck to Clipboard, Annotated (No Set Info) - + Save Deck to Clipboard 保存套牌到剪貼板 - + Save Deck to Clipboard (No Set Info) - + Load Local Deck... 從本地載入套牌 - + Load Remote Deck... 載入遠程套牌 - + Set Ready to Start 系列準備好開始 - + Toggle Sideboard Lock 切換備牌鎖 - + Add Green Counter 增加綠色指示物 - + Remove Green Counter 移除綠色指示物 - + Set Green Counters... 設置綠色指示物 - + Add Red Counter 增加紅色指示物 - + Remove Red Counter 移除紅色指示物 - + Set Red Counters... 設置紅色指示物 - + Add Life Counter 增加生命指示物 @@ -10767,724 +11566,744 @@ Please refrain from engaging in this activity or further actions may be taken ag - + + Load deck from online service... + + + + + Load from website... + + + + Unload Deck - + Force Start - + Add Card Counter (F) - + Remove Card Counter (F) - + Set Card Counters (F)... - + Add Card Counter (E) - + Remove Card Counter (E) - + Set Card Counters (E)... - + Add Card Counter(D) - + Remove Card Counter (D) - + Set Card Counters (D)... - + Add Card Counter (C) - + Remove Card Counter (C) - + Set Card Counters (C)... - + Add Card Counter (B) - + Remove Card Counter (B) - + Set Card Counters (B)... - + Add Card Counter (A) - + Remove Card Counter (A) - + Set Card Counters (A)... - + Remove Life Counter 移除生命指示物 - + Set Life Counters... 設置生命指示物 - + Add White Counter 增加白色指示物 - + Remove White Counter 移除白色指示物 - + Set White Counters... 設置白色指示物 - + Add Blue Counter 增加藍色指示物 - + Remove Blue Counter 移除藍色指示物 - + Set Blue Counters... 設置藍色指示物 - + Add Black Counter 增加黑色指示物 - + Remove Black Counter 移除黑色指示物 - + Set Black Counters... 設置黑色指示物 - + Add Colorless Counter 增加無色指示物 - + Remove Colorless Counter 移除無色指示物 - + Set Colorless Counters... 設置無色指示物 - + Add Other Counter 增加其他指示物 - + Remove Other Counter 移除其他指示物 - + Set Other Counters... 設置其他指示物 - + Increment all card counters - + Add Power (+1/+0) 增加力量(+1/+0) - + Remove Power (-1/-0) 減少力量(-1/-0) - + Move Toughness to Power (+1/-1) 移動防禦到力量(+1/-1) - + Add Toughness (+0/+1) 增加防禦(+0/+1) - + Remove Toughness (-0/-1) 減少防禦(-0/-1) - + Move Power to Toughness (-1/+1) 移動力量到防禦(-1/+1) - + Add Power and Toughness (+1/+1) 增加力量和防禦(+1/+1) - + Remove Power and Toughness (-1/-1) 減少力量和防禦(-1/-1) - + Set Power and Toughness... 設置力量和防禦 - + Reset Power and Toughness 重置力量和防禦 - + Untap 重置 - + Upkeep 維持 - + Draw - + First Main Phase 主要步驟1 - + Start Combat 開始戰鬥 - + Attack 攻擊 - + Block 阻擋 - + Damage 傷害 - + End Combat 結束戰鬥 - + Second Main Phase 主要步驟2 - + End 回合結束 - + Next Phase 下個步驟 - + Next Phase Action 下個行動步驟 - + Next Turn 下個回合 - + Hide Card in Reveal Window - + Tap / Untap Card 橫置 / 重置卡牌 - + Untap All 重置所有 - + Toggle Skip Untapping Toggle Untap - + Turn Card Over 將卡翻面 - + Peek Card 查看卡牌 - + Play Card 使用卡牌 - + Play Card, Face Down - + Attach Card... 結附卡牌 - + Unattach Card 取消結附 - + Clone Card 複製卡牌 - + Create Token... 派出衍生物 - + Create All Related Tokens 派出所有相關的衍生物 - + Create Another Token 派出另一個衍生物 - + Set Annotation... 設置注釋... - + + Reduce Life by Power + + + + Select All Cards in Zone - + Select All Cards in Row - + Select All Cards in Column - + Reveal Selected Cards to All Players - - + + Bottom of Library 牌庫底 - - - - + + + + Exile 放逐區 - - - - + + + + Graveyard 墳墓場 - - + + Hand 手牌 - - + + Top of Library 牌庫頂 - - + + Battlefield, Face Down 戰場,面朝下 - + Battlefield 戰場 - + Library 牌庫 - + Sideboard 備牌 - + Top Cards of Library 牌庫頂牌 - + Bottom Cards of Library - + Close Recent View 關閉最近的查看 - - + + Stack 堆疊 - - + + Graveyard (Multiple) 墳場(多個) - - + + Graveyard (Multiple), Face Down - - + + Exile (Multiple) 放逐區(多個) - - + + Exile (Multiple), Face Down - + Stack Until Found - + Draw Bottom Card - + Draw Multiple Cards from Bottom... - + Draw Arrow... 畫箭頭... - + Remove Local Arrows 移除本地箭頭 - + Leave Game 離開遊戲 - + Concede 投降 - + Roll Dice... 擲骰子... - + + Flip Coin + + + + Shuffle Library 洗牌 - + Shuffle Top Cards of Library - + Shuffle Bottom Cards of Library - + Mulligan 再調度起手牌 - + Mulligan (Same hand size) - + Mulligan (Hand size - 1) - + Draw a Card 抓一張牌 - + Draw Multiple Cards... 抓多張牌 - + Undo Draw 撤銷抓牌 - + Always Reveal Top Card 一直展示牌庫頂牌 - + Always Look At Top Card 一直檢視牌庫頂牌 - + Sort Hand by Name - + Sort Hand by Type - + Sort Hand by Mana Value - + Reveal Hand to All Players - + Reveal Random Card to All Players - + Rotate View Clockwise 順時針旋轉視角 - + Rotate View Counterclockwise 逆時針旋轉視角 - + Unfocus Text Box 取消聚焦文本框 - + Focus Chat 聚焦聊天室 - + Clear Chat 清除聊天記錄 - + Refresh 刷新 - + Skip Forward - + Skip Backward - + Skip Forward by a lot - + Skip Backward by a lot - + Play/Pause - + Toggle Fast Forward - + Home - + Visual Deck Storage - + Deck Storage - + Server - + Account - + Administration - + Logs diff --git a/doc/carddatabase_v4/cards.xsd b/doc/carddatabase_v4/cards.xsd index 92d30b94f..82aed32ab 100644 --- a/doc/carddatabase_v4/cards.xsd +++ b/doc/carddatabase_v4/cards.xsd @@ -6,6 +6,7 @@ + @@ -34,6 +35,13 @@ + + + + + + + @@ -48,6 +56,13 @@ + + + + + + + diff --git a/doc/doxygen/extra-pages/developer_documentation/index.md b/doc/doxygen/extra-pages/developer_documentation/index.md index da2d444d8..80f4eb492 100644 --- a/doc/doxygen/extra-pages/developer_documentation/index.md +++ b/doc/doxygen/extra-pages/developer_documentation/index.md @@ -8,4 +8,6 @@ - @subpage querying_the_card_database - @subpage loading_card_pictures -- @subpage displaying_cards \ No newline at end of file +- @subpage displaying_cards + +- @subpage developer_reference_network_overview \ No newline at end of file diff --git a/doc/doxygen/extra-pages/developer_documentation/network/client/game_event_handler.md b/doc/doxygen/extra-pages/developer_documentation/network/client/game_event_handler.md new file mode 100644 index 000000000..fbb298d3a --- /dev/null +++ b/doc/doxygen/extra-pages/developer_documentation/network/client/game_event_handler.md @@ -0,0 +1,170 @@ +@page game_event_handler GameEventHandler + +## Overview + +`GameEventHandler` is the central coordinator for **game-wide commands and events**. +It acts as the bridge between the networking layer (protobuf messages received from +the server) and the local game model and UI. + +Unlike `PlayerEventHandler`, which is responsible for player-scoped state and zones, +`GameEventHandler` handles: + +- Global game flow (turns, phases, host changes) +- Player and spectator lifecycle events +- Chat and logging events +- Dispatching incoming events to the appropriate subsystem +- Sending locally initiated commands to the server + +In short, it is the **top-level event dispatcher** for an active game. + +--- + +## Responsibilities + +`GameEventHandler` has four primary responsibilities: + +### 1. Sending game-wide commands + +UI actions that affect the game as a whole (e.g. advancing the turn, changing phases, +sending chat messages) are translated into protobuf commands and sent to the server +via this class. + +Examples include: + +- Advancing or reversing the turn order +- Conceding or unconceding +- Changing the active phase +- Leaving the game +- Sending chat messages + +These commands are wrapped in `PendingCommand` instances so their lifecycle and +responses can be tracked. + +--- + +### 2. Processing incoming game events + +Incoming server messages arrive as a `GameEventContainer`. +`GameEventHandler` is responsible for: + +- Emitting lifecycle signals before and after processing +- Dispatching each event to the correct handler +- Forwarding player-scoped events to `PlayerEventHandler` instances +- Handling spectator-only and game-global events directly + +This design keeps networking concerns isolated from game logic and UI code. + +--- + +### 3. Managing global game state transitions + +Certain events affect the entire game state and require coordinated updates across +multiple subsystems. Examples include: + +- Full game state synchronization +- Active player or phase changes +- Game host changes +- Game closure +- Turn reversal + +`GameEventHandler` ensures these events are processed in a consistent order and +that all interested systems are notified via Qt signals. + +--- + +### 4. Emitting UI and logging signals + +Rather than directly manipulating UI widgets, `GameEventHandler` emits +high-level signals that are consumed by: + +- Game widgets +- Player and spectator lists +- Message and event logs +- Status indicators (ready state, deck selection, connection state) + +This keeps the handler independent of concrete UI implementations. + +--- + +## Relationship to PlayerEventHandler + +`GameEventHandler` and `PlayerEventHandler` work together but have distinct roles: + +| GameEventHandler | PlayerEventHandler | +|------------------|--------------------| +| Global game state | Per-player state | +| Turn / phase flow | Zones and cards | +| Player join/leave | Player actions | +| Spectator events | Player-specific events | +| Chat dispatch | Card and zone updates | + +When a server event is associated with a specific player, `GameEventHandler` +routes it to the corresponding `PlayerEventHandler`. Events without a player +context are handled directly. + +--- + +## Event Processing Flow + +A typical incoming event flow looks like this: + +1. `AbstractClient` receives a `GameEventContainer` +2. `GameEventHandler::processGameEventContainer()` is called +3. `containerProcessingStarted()` is emitted +4. Each event is: + - Handled directly **or** + - Forwarded to a `PlayerEventHandler` +5. Logging and UI signals are emitted as needed +6. `containerProcessingDone()` is emitted + +This structured flow makes it easy to: + +- Suppress UI updates during replays +- Handle reconnections cleanly +- Detect flood protection or error states + +--- + +## Command Lifecycle + +Outgoing commands follow a similar structured path: + +1. UI action triggers a `handle*()` method +2. A protobuf command is constructed +3. The command is wrapped in a `PendingCommand` +4. `sendGameCommand()` sends it to the server +5. `commandFinished()` receives the response +6. Errors or flood conditions are handled centrally + +This approach avoids duplicated error handling across UI code. + +--- + +## Design Goals + +`GameEventHandler` is designed to be: + +- **Centralized** – one entry point for all game-wide events +- **UI-agnostic** – communicates via signals, not widgets +- **Predictable** – well-defined event ordering and lifecycle +- **Composable** – works in concert with `PlayerEventHandler` +- **Testable** – logic is separated from rendering + +--- + +## Related Classes + +- @ref GameEventHandler +- @ref PlayerEventHandler +- `GameEventContainer` +- `PendingCommand` +- `AbstractClient` +- `AbstractGame` + +--- + +## See Also + +- @ref GameLogic +- @ref PlayerEventHandler +- @ref GameState diff --git a/doc/doxygen/extra-pages/developer_documentation/network/client/index.md b/doc/doxygen/extra-pages/developer_documentation/network/client/index.md new file mode 100644 index 000000000..f22640448 --- /dev/null +++ b/doc/doxygen/extra-pages/developer_documentation/network/client/index.md @@ -0,0 +1,11 @@ +@page developer_reference_network_client Client Networking (Overview) + +The clients response to various network protocol events and associated handling are described here. + +For information about game scoped events, see: + +- @subpage game_event_handler + +Certain game scoped events may be forwarded to player based handlers. For more information, see: + +- @subpage player_event_handler \ No newline at end of file diff --git a/doc/doxygen/extra-pages/developer_documentation/network/client/player_event_handler.md b/doc/doxygen/extra-pages/developer_documentation/network/client/player_event_handler.md new file mode 100644 index 000000000..6878758da --- /dev/null +++ b/doc/doxygen/extra-pages/developer_documentation/network/client/player_event_handler.md @@ -0,0 +1,199 @@ +@page player_event_handler PlayerEventHandler + +## Overview + +`PlayerEventHandler` is responsible for applying **player-scoped game events** +to a single `Player` instance. These events modify the player’s board state, +zones, cards, counters, arrows, and associated UI and logging output. + +Each `PlayerEventHandler` instance is bound **1:1 to a Player** and is invoked +exclusively by @ref GameEventHandler after basic routing and validation of +incoming server events. + +This class represents the lowest-level authoritative application of game state +changes on the client. + +--- + +## Scope and Authority + +`PlayerEventHandler` operates under the following guarantees: + +- All incoming events are **authoritative** and already validated by the server +- Events refer to valid players, zones, and card identifiers +- Ordering is guaranteed by the server and preserved by `GameEventHandler` + +As a result, the handler does **not** perform rule validation or permission +checks. Its responsibility is to *apply* state, not *decide* legality. + +--- + +## Responsibilities + +### 1. Applying player-specific state changes + +The primary responsibility of `PlayerEventHandler` is to mutate player-owned +state, including: + +- Cards (`CardItem`) +- Zones (`CardZoneLogic`) +- Counters (card-level and player-level) +- Attachments and arrows +- Zone configuration flags (reveal / peek behavior) + +Many handlers update both **logical state** and **visual/UI state** in tandem. + +--- + +### 2. Coordinating complex card movement + +Some events—most notably card movement—require coordinated updates across +multiple systems. For example, `eventMoveCard()`: + +- Removes the card from the source zone +- Updates ownership, visibility, and identity +- Handles attachments and arrow cleanup +- Emits undo-draw or move logs +- Inserts the card into the destination zone +- Updates menus, hover state, and graphics + +This makes `PlayerEventHandler` intentionally stateful and tightly coupled to +the board implementation. + +--- + +### 3. Emitting logging signals + +Rather than writing directly to logs or widgets, `PlayerEventHandler` emits +structured Qt signals describing *what happened*, including: + +- Chat messages +- Card movement +- Shuffles and randomization +- Reveals and peeks +- Counter and attribute changes + +These signals are consumed by logging systems and UI components, keeping +presentation concerns out of the handler. + +Logging signals may be emitted **before or after mutation**, depending on +whether later changes would invalidate log data (e.g. card identity). + +--- + +### 4. Handling cross-player interactions + +Although bound to a single player, some events necessarily affect other +players’ state, including: + +- Moving cards between players +- Attaching cards to another player’s permanents +- Creating arrows targeting other players or cards + +In these cases, `PlayerEventHandler` performs the minimal required mutation +while respecting ownership boundaries enforced elsewhere. + +--- + +## Event Dispatch Model + +`PlayerEventHandler` exposes a single public entry point: + +- `processGameEvent()` + +This method: + +1. Receives a generic `GameEvent` +2. Switches on `GameEventType` +3. Extracts the appropriate protobuf extension +4. Forwards the event to a typed handler + +This keeps the event routing centralized and makes it easy to audit coverage +when new game events are introduced. + +Unhandled events are logged as warnings. + +--- + +## Event Categories + +For clarity, event handlers are grouped conceptually into: + +- **Chat and randomization** + - Chat messages + - Shuffles + - Dice rolls + +- **Arrows and targeting** + - Create / delete arrows + +- **Card and token creation** + - Token generation + - Counter creation + +- **Card attributes and counters** + - Tapped state + - Power/toughness + - Annotations + - Card- and player-level counters + +- **Zone-level operations** + - Card movement + - Zone dumps + - Card destruction + - Attachments + +- **Draw and reveal** + - Drawing cards + - Reveals, peeks, and reveal windows + +- **Zone configuration** + - Always-reveal and always-look-at-top-card flags + +This grouping mirrors the structure of the header file and reflects the +conceptual responsibilities of the class. + +--- + +## Relationship to GameEventHandler + +`PlayerEventHandler` is never instantiated or invoked directly by UI code. +Instead: + +1. `GameEventHandler` receives a `GameEventContainer` +2. Player-scoped events are routed to the appropriate `PlayerEventHandler` +3. Global or spectator events are handled elsewhere + +This separation keeps game-wide logic decoupled from player board state and +makes reconnection and replay handling simpler. + +--- + +## Design Intent + +`PlayerEventHandler` is designed to be: + +- **Authoritative** – applies server state exactly as received +- **Stateful** – maintains consistency across cards, zones, and UI +- **Explicit** – one handler per event type +- **UI-aware** – updates views and menus as part of state mutation +- **Auditable** – easy to trace what code handles which event + +--- + +## Related Classes + +- @ref PlayerEventHandler +- @ref GameEventHandler +- `Player` +- `CardItem` +- `CardZoneLogic` +- `GameEvent` +- `GameEventContext` + +--- + +## See Also + +- @ref GameLogicPlayers +- @ref GameLogic diff --git a/doc/doxygen/extra-pages/developer_documentation/network/game_event_handler.md b/doc/doxygen/extra-pages/developer_documentation/network/game_event_handler.md new file mode 100644 index 000000000..fbb298d3a --- /dev/null +++ b/doc/doxygen/extra-pages/developer_documentation/network/game_event_handler.md @@ -0,0 +1,170 @@ +@page game_event_handler GameEventHandler + +## Overview + +`GameEventHandler` is the central coordinator for **game-wide commands and events**. +It acts as the bridge between the networking layer (protobuf messages received from +the server) and the local game model and UI. + +Unlike `PlayerEventHandler`, which is responsible for player-scoped state and zones, +`GameEventHandler` handles: + +- Global game flow (turns, phases, host changes) +- Player and spectator lifecycle events +- Chat and logging events +- Dispatching incoming events to the appropriate subsystem +- Sending locally initiated commands to the server + +In short, it is the **top-level event dispatcher** for an active game. + +--- + +## Responsibilities + +`GameEventHandler` has four primary responsibilities: + +### 1. Sending game-wide commands + +UI actions that affect the game as a whole (e.g. advancing the turn, changing phases, +sending chat messages) are translated into protobuf commands and sent to the server +via this class. + +Examples include: + +- Advancing or reversing the turn order +- Conceding or unconceding +- Changing the active phase +- Leaving the game +- Sending chat messages + +These commands are wrapped in `PendingCommand` instances so their lifecycle and +responses can be tracked. + +--- + +### 2. Processing incoming game events + +Incoming server messages arrive as a `GameEventContainer`. +`GameEventHandler` is responsible for: + +- Emitting lifecycle signals before and after processing +- Dispatching each event to the correct handler +- Forwarding player-scoped events to `PlayerEventHandler` instances +- Handling spectator-only and game-global events directly + +This design keeps networking concerns isolated from game logic and UI code. + +--- + +### 3. Managing global game state transitions + +Certain events affect the entire game state and require coordinated updates across +multiple subsystems. Examples include: + +- Full game state synchronization +- Active player or phase changes +- Game host changes +- Game closure +- Turn reversal + +`GameEventHandler` ensures these events are processed in a consistent order and +that all interested systems are notified via Qt signals. + +--- + +### 4. Emitting UI and logging signals + +Rather than directly manipulating UI widgets, `GameEventHandler` emits +high-level signals that are consumed by: + +- Game widgets +- Player and spectator lists +- Message and event logs +- Status indicators (ready state, deck selection, connection state) + +This keeps the handler independent of concrete UI implementations. + +--- + +## Relationship to PlayerEventHandler + +`GameEventHandler` and `PlayerEventHandler` work together but have distinct roles: + +| GameEventHandler | PlayerEventHandler | +|------------------|--------------------| +| Global game state | Per-player state | +| Turn / phase flow | Zones and cards | +| Player join/leave | Player actions | +| Spectator events | Player-specific events | +| Chat dispatch | Card and zone updates | + +When a server event is associated with a specific player, `GameEventHandler` +routes it to the corresponding `PlayerEventHandler`. Events without a player +context are handled directly. + +--- + +## Event Processing Flow + +A typical incoming event flow looks like this: + +1. `AbstractClient` receives a `GameEventContainer` +2. `GameEventHandler::processGameEventContainer()` is called +3. `containerProcessingStarted()` is emitted +4. Each event is: + - Handled directly **or** + - Forwarded to a `PlayerEventHandler` +5. Logging and UI signals are emitted as needed +6. `containerProcessingDone()` is emitted + +This structured flow makes it easy to: + +- Suppress UI updates during replays +- Handle reconnections cleanly +- Detect flood protection or error states + +--- + +## Command Lifecycle + +Outgoing commands follow a similar structured path: + +1. UI action triggers a `handle*()` method +2. A protobuf command is constructed +3. The command is wrapped in a `PendingCommand` +4. `sendGameCommand()` sends it to the server +5. `commandFinished()` receives the response +6. Errors or flood conditions are handled centrally + +This approach avoids duplicated error handling across UI code. + +--- + +## Design Goals + +`GameEventHandler` is designed to be: + +- **Centralized** – one entry point for all game-wide events +- **UI-agnostic** – communicates via signals, not widgets +- **Predictable** – well-defined event ordering and lifecycle +- **Composable** – works in concert with `PlayerEventHandler` +- **Testable** – logic is separated from rendering + +--- + +## Related Classes + +- @ref GameEventHandler +- @ref PlayerEventHandler +- `GameEventContainer` +- `PendingCommand` +- `AbstractClient` +- `AbstractGame` + +--- + +## See Also + +- @ref GameLogic +- @ref PlayerEventHandler +- @ref GameState diff --git a/doc/doxygen/extra-pages/developer_documentation/network/index.md b/doc/doxygen/extra-pages/developer_documentation/network/index.md new file mode 100644 index 000000000..713291d84 --- /dev/null +++ b/doc/doxygen/extra-pages/developer_documentation/network/index.md @@ -0,0 +1,7 @@ +@page developer_reference_network_overview Network (Overview) + +This page provides information about the networking performed by the client and server. + +For information about the protocol, see @subpage developer_reference_protocol + +For information about the clients response to and handling of protocol messages, see @subpage developer_reference_network_client \ No newline at end of file diff --git a/doc/doxygen/extra-pages/developer_documentation/network/player_event_handler.md b/doc/doxygen/extra-pages/developer_documentation/network/player_event_handler.md new file mode 100644 index 000000000..6878758da --- /dev/null +++ b/doc/doxygen/extra-pages/developer_documentation/network/player_event_handler.md @@ -0,0 +1,199 @@ +@page player_event_handler PlayerEventHandler + +## Overview + +`PlayerEventHandler` is responsible for applying **player-scoped game events** +to a single `Player` instance. These events modify the player’s board state, +zones, cards, counters, arrows, and associated UI and logging output. + +Each `PlayerEventHandler` instance is bound **1:1 to a Player** and is invoked +exclusively by @ref GameEventHandler after basic routing and validation of +incoming server events. + +This class represents the lowest-level authoritative application of game state +changes on the client. + +--- + +## Scope and Authority + +`PlayerEventHandler` operates under the following guarantees: + +- All incoming events are **authoritative** and already validated by the server +- Events refer to valid players, zones, and card identifiers +- Ordering is guaranteed by the server and preserved by `GameEventHandler` + +As a result, the handler does **not** perform rule validation or permission +checks. Its responsibility is to *apply* state, not *decide* legality. + +--- + +## Responsibilities + +### 1. Applying player-specific state changes + +The primary responsibility of `PlayerEventHandler` is to mutate player-owned +state, including: + +- Cards (`CardItem`) +- Zones (`CardZoneLogic`) +- Counters (card-level and player-level) +- Attachments and arrows +- Zone configuration flags (reveal / peek behavior) + +Many handlers update both **logical state** and **visual/UI state** in tandem. + +--- + +### 2. Coordinating complex card movement + +Some events—most notably card movement—require coordinated updates across +multiple systems. For example, `eventMoveCard()`: + +- Removes the card from the source zone +- Updates ownership, visibility, and identity +- Handles attachments and arrow cleanup +- Emits undo-draw or move logs +- Inserts the card into the destination zone +- Updates menus, hover state, and graphics + +This makes `PlayerEventHandler` intentionally stateful and tightly coupled to +the board implementation. + +--- + +### 3. Emitting logging signals + +Rather than writing directly to logs or widgets, `PlayerEventHandler` emits +structured Qt signals describing *what happened*, including: + +- Chat messages +- Card movement +- Shuffles and randomization +- Reveals and peeks +- Counter and attribute changes + +These signals are consumed by logging systems and UI components, keeping +presentation concerns out of the handler. + +Logging signals may be emitted **before or after mutation**, depending on +whether later changes would invalidate log data (e.g. card identity). + +--- + +### 4. Handling cross-player interactions + +Although bound to a single player, some events necessarily affect other +players’ state, including: + +- Moving cards between players +- Attaching cards to another player’s permanents +- Creating arrows targeting other players or cards + +In these cases, `PlayerEventHandler` performs the minimal required mutation +while respecting ownership boundaries enforced elsewhere. + +--- + +## Event Dispatch Model + +`PlayerEventHandler` exposes a single public entry point: + +- `processGameEvent()` + +This method: + +1. Receives a generic `GameEvent` +2. Switches on `GameEventType` +3. Extracts the appropriate protobuf extension +4. Forwards the event to a typed handler + +This keeps the event routing centralized and makes it easy to audit coverage +when new game events are introduced. + +Unhandled events are logged as warnings. + +--- + +## Event Categories + +For clarity, event handlers are grouped conceptually into: + +- **Chat and randomization** + - Chat messages + - Shuffles + - Dice rolls + +- **Arrows and targeting** + - Create / delete arrows + +- **Card and token creation** + - Token generation + - Counter creation + +- **Card attributes and counters** + - Tapped state + - Power/toughness + - Annotations + - Card- and player-level counters + +- **Zone-level operations** + - Card movement + - Zone dumps + - Card destruction + - Attachments + +- **Draw and reveal** + - Drawing cards + - Reveals, peeks, and reveal windows + +- **Zone configuration** + - Always-reveal and always-look-at-top-card flags + +This grouping mirrors the structure of the header file and reflects the +conceptual responsibilities of the class. + +--- + +## Relationship to GameEventHandler + +`PlayerEventHandler` is never instantiated or invoked directly by UI code. +Instead: + +1. `GameEventHandler` receives a `GameEventContainer` +2. Player-scoped events are routed to the appropriate `PlayerEventHandler` +3. Global or spectator events are handled elsewhere + +This separation keeps game-wide logic decoupled from player board state and +makes reconnection and replay handling simpler. + +--- + +## Design Intent + +`PlayerEventHandler` is designed to be: + +- **Authoritative** – applies server state exactly as received +- **Stateful** – maintains consistency across cards, zones, and UI +- **Explicit** – one handler per event type +- **UI-aware** – updates views and menus as part of state mutation +- **Auditable** – easy to trace what code handles which event + +--- + +## Related Classes + +- @ref PlayerEventHandler +- @ref GameEventHandler +- `Player` +- `CardItem` +- `CardZoneLogic` +- `GameEvent` +- `GameEventContext` + +--- + +## See Also + +- @ref GameLogicPlayers +- @ref GameLogic diff --git a/doc/doxygen/extra-pages/developer_documentation/network/protocol/index.md b/doc/doxygen/extra-pages/developer_documentation/network/protocol/index.md new file mode 100644 index 000000000..724b6b994 --- /dev/null +++ b/doc/doxygen/extra-pages/developer_documentation/network/protocol/index.md @@ -0,0 +1,15 @@ +@page developer_reference_protocol Protocol (Overview) + +For an overview of the protocol used by the Cockatrice client and server, see: + +- @subpage developer_reference_protocol_overview +- @subpage protocol_game_command + +For client → server communication, see: + +- @subpage protocol_command_container + +For server → client communication, see: + +- @subpage protocol_server_message +- @subpage protocol_response \ No newline at end of file diff --git a/doc/doxygen/extra-pages/developer_documentation/network/protocol/protocol_command_container.md b/doc/doxygen/extra-pages/developer_documentation/network/protocol/protocol_command_container.md new file mode 100644 index 000000000..0fb634358 --- /dev/null +++ b/doc/doxygen/extra-pages/developer_documentation/network/protocol/protocol_command_container.md @@ -0,0 +1,114 @@ +@page protocol_command_container CommandContainer (Protocol Concept) + +@section cc_overview Overview + +CommandContainer is the client-to-server message envelope used for all +client requests in the Cockatrice protocol. + +Every client-initiated action is transmitted as a CommandContainer. +The server never sends CommandContainer messages. + +This is a protocol-level abstraction and should not be confused with the +generated protobuf accessors or wire-level encoding. + +@section cc_lifecycle Lifetime and Ownership + +- CommandContainers are created by clients and consumed by the server. +- Each container represents one logical request. +- Containers are processed atomically and in order of arrival. + +A container may generate: +- Exactly one RESPONSE message, and +- Zero or more EVENT messages. + +@section cc_cmd_id Command Identification + +The @c cmd_id field is a client-assigned identifier used to correlate +responses with requests. + +Rules: +- @c cmd_id is optional but strongly recommended +- The server echoes @c cmd_id only in RESPONSE messages +- EVENT messages are never associated with a @c cmd_id + +The server does not enforce uniqueness of @c cmd_id values. + +@section cc_context Command Context + +Certain command domains require additional context: + +- Room commands require @c room_id +- Game commands require @c game_id + +Context fields are ignored for command domains that do not require them. + +If a required context field is missing or invalid, the server responds with +@c RespContextError. + +@section cc_invariants Invariants + +The following rules are enforced by the server: + +- Exactly one command domain must be populated +- Commands may be batched only within the same domain +- Context fields must match the active command domain + +Violations of these rules result in @c RespInvalidCommand. + +@section cc_domains Command Domains + +CommandContainer supports the following mutually exclusive command domains: + +- Session commands + - Authentication + - User discovery + - Private messaging + +- Room commands + - Chat + - Game creation + - Room membership management + +- Game commands + - In-game actions + - State mutations + +- Moderator commands + - User moderation + - Room moderation + +- Admin commands + - Server administration + +@section cc_batching Command Batching + +A CommandContainer may contain multiple commands of the same domain. + +Batching guarantees: +- Commands are processed in the order they appear in the container +- All commands in the batch share the same context + +Partial failure behavior is implementation-defined and may vary by domain. + +@section cc_dispatch Dispatch + +CommandContainers are dispatched by +Server_ProtocolHandler::processCommandContainer(). + +Dispatch is performed by inspecting which command domain is populated. +Only the first populated domain is considered. + +@section cc_errors Error Handling + +Common error responses include: +- @c RespLoginNeeded – client is not authenticated +- @c RespInvalidCommand – malformed container or invalid domain usage +- @c RespContextError – missing or invalid room/game context + +@section cc_related Related Concepts + +- @ref protocol_server_message "ServerMessage" +- @ref protocol_client_states "Client State Machine" + +@see Server_ProtocolHandler::processCommandContainer + diff --git a/doc/doxygen/extra-pages/developer_documentation/network/protocol/protocol_game_command.md b/doc/doxygen/extra-pages/developer_documentation/network/protocol/protocol_game_command.md new file mode 100644 index 000000000..18e31064a --- /dev/null +++ b/doc/doxygen/extra-pages/developer_documentation/network/protocol/protocol_game_command.md @@ -0,0 +1,529 @@ +@page protocol_game_command GameCommand (Protocol Concept) + +# Game Commands Reference + +This document describes game-level commands (`GameCommandType`) and how they are +handled across the server and client. + +Flow overview: + +Client +→ GameCommand +→ Server command handler (`Server_Player`, `Server_AbstractParticipant`, or `Server_Game`) +→ GameEvent(s) +→ PlayerEventHandler::event* + +## Command Mapping + +### `GAME_SAY` (1002) + +**Purpose:** Send a chat message during a game. + +**Server:** +- `Server_AbstractParticipant::cmdGameSay` +- Validates spectator chat permissions +- Applies chat flood protection +- Emits `Event_GameSay` +- Logs the message to the server database + +**Client:** +- `PlayerEventHandler::eventGameSay` +- Adds the chat message to the game log + +--- + +### `SHUFFLE` (1003) + +**Purpose:** Shuffle a card zone (usually library). + +**Server:** +- `Server_Player::cmdShuffle` +- Only allows shuffling the deck zone +- Rejects if the game has not started or the player has conceded +- Supports partial-range shuffles (`start` / `end`) +- Emits `Event_Shuffle` +- Re-reveals the top card if the deck is being tracked + +**Client:** +- `PlayerEventHandler::eventShuffle` +- Closes affected library views +- Clears any revealed top card if necessary +- Updates deck graphics +- Logs the shuffle + +--- + +### `ROLL_DIE` (1005) + +**Purpose:** Roll one or more dice. + +**Server:** +- Computes random die results +- Emits `Event_RollDie` + +**Client:** +- `PlayerEventHandler::eventRollDie` +- Displays the sorted roll results in the game log + +--- + +### `DRAW_CARDS` (1006) + +**Purpose:** Draw cards from deck. + +**Server:** +- `Server_Player::cmdDrawCards` +- Rejects if the game has not started or the player has conceded +- Moves cards from deck to hand +- Sends full card information privately to the drawing player +- Sends only the draw count to all other players +- Tracks drawn cards for undo +- Updates revealed-top-card state when necessary +- Emits `Event_DrawCards` + +**Client:** +- `PlayerEventHandler::eventDrawCards` +- Moves cards from library to hand +- Reveals card identities only when included in the event +- Updates both zones +- Logs the draw + +--- + +### `UNDO_DRAW` (1007) + +**Purpose:** Undo a previous draw. + +**Server:** +- `Server_Player::cmdUndoDraw` +- Rejects if the game has not started or the player has conceded +- Uses the tracked draw history (`lastDrawList`) +- Moves the most recently drawable card from hand back to the top of the deck +- Emits `Event_GameLogNotice` if undo is no longer possible + +**Client:** +- Processed as a normal `MOVE_CARD` +- Detects `Context_UndoDraw` +- Logs the undo draw instead of a normal move + +--- + +### `FLIP_CARD` (1008) + +**Purpose:** Flip a card face up or face down. + +**Server:** +- Updates the card's face-down state +- Reveals identity when turning face up +- Emits `Event_FlipCard` + +**Client:** +- `PlayerEventHandler::eventFlipCard` +- Updates the card identity when revealed +- Changes face-up / face-down state +- Updates card menus +- Logs the flip + +--- + +### `ATTACH_CARD` (1009) + +**Purpose:** Attach one card to another. + +**Server:** +- Updates attachment relationships +- Emits `Event_AttachCard` + +**Client:** +- `PlayerEventHandler::eventAttachCard` +- Updates parent/child attachment links +- Reorganizes affected zones +- Logs attach or detach operations +- Refreshes card actions + +--- + +### `CREATE_TOKEN` (1010) + +**Purpose:** Create a token card. + +**Server:** +- Creates a new token card +- Emits `Event_CreateToken` + +**Client:** +- `PlayerEventHandler::eventCreateToken` +- Creates the token object +- Applies token properties (PT, color, annotation, face-down state) +- Adds it to the requested zone +- Logs token creation + +--- + +### `CREATE_ARROW` (1011) + +**Purpose:** Create a visual arrow. + +**Server:** +- Creates a visual arrow +- Emits `Event_CreateArrow` + +**Client:** +- `PlayerEventHandler::eventCreateArrow` +- Creates the arrow graphics +- Resolves endpoint card names +- Logs arrow creation + +--- + +### `DELETE_ARROW` (1012) + +**Purpose:** Remove a visual arrow. + +**Server:** +- Removes an existing arrow +- Emits `Event_DeleteArrow` + +**Client:** +- `PlayerEventHandler::eventDeleteArrow` +- Removes the arrow graphics + +--- + +### `SET_CARD_ATTR` (1013) + +**Purpose:** Set a card attribute. + +**Server:** +- Updates one or more card attributes +- Emits `Event_SetCardAttr` + +**Client:** +- `PlayerEventHandler::eventSetCardAttr` +- Updates tapped state, color, annotation, power/toughness, face-down state, attacking state, and related visuals +- Logs attribute changes where appropriate + +--- + +### `SET_CARD_COUNTER` (1014) + +**Purpose:** Set a card counter. + +**Server:** +- Updates the specified card counter +- Emits `Event_SetCardCounter` + +**Client:** +- `PlayerEventHandler::eventSetCardCounter` +- Updates the displayed counter value +- Refreshes card actions +- Logs the counter change + +--- + +### `INC_CARD_COUNTER` (1015) + +**Purpose:** Increment a card counter. + +**Server:** +- Normalizes to a set-counter operation +- Emits `Event_SetCardCounter` + +**Client:** +- Processed identically to `SET_CARD_COUNTER` + +--- + +### `READY_START` (1016) + +**Purpose:** Mark player as ready. + +**Server:** +- Marks the player as ready +- Starts the game once all required players are ready + +**Client:** +- Reflected through updated game state events + +--- + +### `CONCEDE` (1017) + +**Purpose:** Concede the game. + +**Server:** +- Marks the player as conceded +- Returns borrowed cards where appropriate +- Ends the game if only one player remains + +**Client:** +- Reflected through updated game state events + +--- + +### `INC_COUNTER` (1018) + +**Purpose:** Increment a player counter. + +**Server:** +- `Server_Player::cmdIncCounter` +- Rejects if the game has not started or the player has conceded +- Updates the counter value +- Emits `Event_SetCounter` only if the value changed + +**Client:** +- `PlayerEventHandler::eventSetCounter` +- Updates the displayed player counter +- Logs the change + +--- + +### `CREATE_COUNTER` (1019) + +**Purpose:** Create a player counter. + +**Server:** +- `Server_Player::cmdCreateCounter` +- Rejects if the game has not started or the player has conceded +- Allocates a new counter ID +- Creates the counter +- Emits `Event_CreateCounter` + +**Client:** +- `PlayerEventHandler::eventCreateCounter` +- Creates the local player counter + +--- + +### `SET_COUNTER` (1020) + +**Purpose:** Set a player counter value. + +**Server:** +- `Server_Player::cmdSetCounter` +- Rejects if the game has not started or the player has conceded +- Updates the counter value +- Emits `Event_SetCounter` only if the value changed + +**Client:** +- `PlayerEventHandler::eventSetCounter` +- Updates the counter value +- Logs the change + +--- + +### `DEL_COUNTER` (1021) + +**Purpose:** Delete a player counter. + +**Server:** +- `Server_Player::cmdDelCounter` +- Rejects if the game has not started or the player has conceded +- Deletes the counter +- Emits `Event_DelCounter` + +**Client:** +- `PlayerEventHandler::eventDelCounter` +- Removes the counter + +--- + +### `NEXT_TURN` (1022) + +**Purpose:** Advance to the next turn. + +**Server:** +- `Server_Player::cmdNextTurn` +- Rejects if the game has not started +- Conceded players cannot advance turns unless acting as a judge +- Calls `Server_Game::nextTurn()` + +**Client:** +- Reflected through subsequent active-player and active-phase events + +--- + +### `SET_ACTIVE_PHASE` (1023) + +**Purpose:** Set active phase. + +**Server:** +- `Server_Player::cmdSetActivePhase` +- Rejects if the game has not started +- Judges may change the phase at any time +- Normal players may only change the phase during their own active turn +- Calls `Server_Game::setActivePhase()` + +**Client:** +- Reflected through updated phase events + +--- + +### `DUMP_ZONE` (1024) + +**Purpose:** Dump zone contents. + +**Server:** +- Generates a zone summary +- Emits `Event_DumpZone` + +**Client:** +- `PlayerEventHandler::eventDumpZone` +- Logs the zone contents summary + +--- + +### `REVEAL_CARDS` (1026) + +**Purpose:** Reveal specific cards. + +**Server:** +- Reveals cards to one or more players +- Emits `Event_RevealCards` + +**Client:** +- `PlayerEventHandler::eventRevealCards` +- Reveals cards in-place or in a reveal window +- Supports temporary peeks +- Updates revealed top cards +- Logs the reveal + +--- + +### `MOVE_CARD` (1027) + +**Purpose:** Move a card between zones. + +**Server:** +- Moves cards between zones +- Updates ownership and attachments as needed +- Emits `Event_MoveCard` + +**Client:** +- `PlayerEventHandler::eventMoveCard` +- Moves the card between zones +- Updates ownership, attachments, IDs and revealed information +- Handles undo-draw context +- Refreshes menus and zone layouts +- Logs the move + +--- + +### `SET_SIDEBOARD_PLAN` (1028) + +**Purpose:** Set sideboard configuration. + +**Server:** +- `Server_Player::cmdSetSideboardPlan` +- Allowed only before readying for the game +- Requires a loaded deck +- Requires sideboarding to be unlocked +- Stores the current sideboard plan + +**Client:** +- No game event is generated + +--- + +### `DECK_SELECT` (1029) + +**Purpose:** Select a deck. + +**Server:** +- `Server_Player::cmdDeckSelect` +- Allowed only before the game starts +- Loads a deck from either the database or serialized deck data +- Locks sideboarding +- Updates player properties +- Emits `Event_PlayerPropertiesChanged` +- Attaches `Context_DeckSelect` +- Returns the selected deck in the command response + +**Client:** +- Reflected through updated player properties and command response + +--- + +### `SET_SIDEBOARD_LOCK` (1030) + +**Purpose:** Lock or unlock sideboarding. + +**Server:** +- `Server_Player::cmdSetSideboardLock` +- Allowed only before readying +- Toggles sideboard locking +- Clears pending sideboard plans when locking +- Emits `Event_PlayerPropertiesChanged` +- Attaches `Context_SetSideboardLock` + +**Client:** +- Reflected through updated player properties + +--- + +### `CHANGE_ZONE_PROPERTIES` (1031) + +**Purpose:** Change zone properties. + +**Server:** +- `Server_Player::cmdChangeZoneProperties` +- Delegates to the abstract implementation +- Updates top-card revelation if required +- Emits `Event_ChangeZoneProperties` + +**Client:** +- `PlayerEventHandler::eventChangeZoneProperties` +- Updates always-reveal-top-card and always-look-at-top-card settings +- Logs property changes + +--- + +### `UNCONCEDE` (1032) + +**Purpose:** Undo a concede. + +**Server:** +- Restores the player's active status + +**Client:** +- Reflected through updated game state events + +--- + +### `JUDGE` (1033) + +**Purpose:** Execute commands on behalf of another player. + +**Server:** +- `Server_AbstractParticipant::cmdJudge` +- Requires judge privileges +- Executes embedded commands as the selected player +- Marks resulting events as judge-forced + +**Client:** +- Transparent; resulting events are processed normally + +--- + +### `REVERSE_TURN` (1034) + +**Purpose:** Reverse turn order. + +**Server:** +- `Server_Player::cmdReverseTurn` +- Conceded non-judge players cannot use the command +- Delegates to `Server_AbstractParticipant::cmdReverseTurn` +- Toggles turn order +- Emits `Event_ReverseTurn` + +**Client:** +- Reflected by subsequent active-player progression + +--- + +## Notes + +- Game commands are handled by `Server_Player`, `Server_AbstractParticipant`, or `Server_Game`, depending on the command. +- Only commands that emit `GameEvent`s produce corresponding `PlayerEventHandler` callbacks. +- Some commands modify server state without generating a dedicated client event. +- Judge commands are transport wrappers that execute other game commands on behalf of another player. \ No newline at end of file diff --git a/doc/doxygen/extra-pages/developer_documentation/network/protocol/protocol_overview.md b/doc/doxygen/extra-pages/developer_documentation/network/protocol/protocol_overview.md new file mode 100644 index 000000000..f91b94c5f --- /dev/null +++ b/doc/doxygen/extra-pages/developer_documentation/network/protocol/protocol_overview.md @@ -0,0 +1,76 @@ +@page developer_reference_protocol_overview Protocol (Overview) + +# Cockatrice Server Protocol – Overview + +## Purpose + +This document describes the Cockatrice client/server protocol as implemented +by the Cockatrice server. It is intended for developers implementing clients +or modifying the server. + +The protocol is **stateful**, **event-driven**, and **asynchronous**. + +--- + +## High-Level Properties + +- Encoding: Google Protocol Buffers +- Transport: TCP or WebSocket (transport-agnostic at protocol level) +- Directionality: + - Clients send `CommandContainer` + - Server sends `ServerMessage` +- Responses and events are interleaved +- Clients must handle unsolicited events at any time + +--- + +## Message Envelopes + +### Client → Server: CommandContainer + +A `CommandContainer` represents one logical client request. + +Protocol invariants: +- Exactly one command domain may be used per container +- Multiple commands of the same domain may be batched +- Context is provided via `room_id` or `game_id` when required + +Command domains: +- Session commands +- Room commands +- Game commands +- Moderator commands +- Admin commands + +--- + +### Server → Client: ServerMessage + +A `ServerMessage` represents either: +- A response to a command (`RESPONSE`) +- An unsolicited event (`*_EVENT`) + +Protocol invariants: +- Events may be delivered before or after responses +- Responses reference the original command via `cmd_id` +- Events are never correlated to a command + +--- + +## Asynchronous Model + +Clients MUST NOT assume request/response ordering. + +Example valid sequence: + +1. Client sends JOIN_ROOM +2. Server sends room chat history events +3. Server sends room join notifications +4. Server sends JOIN_ROOM response + +--- + +## Versioning + +The server reports a protocol version during connection initialization. +Clients MUST verify compatibility before sending commands. diff --git a/doc/doxygen/extra-pages/developer_documentation/network/protocol/protocol_response.md b/doc/doxygen/extra-pages/developer_documentation/network/protocol/protocol_response.md new file mode 100644 index 000000000..ce588114b --- /dev/null +++ b/doc/doxygen/extra-pages/developer_documentation/network/protocol/protocol_response.md @@ -0,0 +1,27 @@ +@page protocol_response Response (Protocol Concept) + +## Overview + +`Response` is the server-to-client message sent immediately after a command, using the same `cmd_id` to link to the originating command. +It provides a `Response::ResponseCode` and optionally a `Response::ResponseType` to guide client handling. + +## Fields + +- **cmd_id** (`uint64`, required) — Command ID this response corresponds to +- **response_code** (`Response::ResponseCode`, optional) — Outcome of the command + +## Response Codes + +All possible outcome codes are defined in Response::ResponseCode + +These describe the result of the command, e.g., success, failure, or special conditions like bans or registration requirements. + +## Response Types + +Responses are routed according to Response::ResponseType + +Each `ResponseType` corresponds to a high-level category, like `JOIN_ROOM` or `DECK_UPLOAD`. + +## Extensions + +- Protobuf extensions from 100 to max are reserved for future use diff --git a/doc/doxygen/extra-pages/developer_documentation/network/protocol/protocol_server_message.md b/doc/doxygen/extra-pages/developer_documentation/network/protocol/protocol_server_message.md new file mode 100644 index 000000000..b8dac9c43 --- /dev/null +++ b/doc/doxygen/extra-pages/developer_documentation/network/protocol/protocol_server_message.md @@ -0,0 +1,171 @@ +@page protocol_server_message ServerMessage (Protocol Concept) + +## Overview + +`ServerMessage` is the server-to-client message envelope used for all +communication originating from the Cockatrice server. + +The server never sends `CommandContainer` messages, and clients never send +`ServerMessage` messages. + +A `ServerMessage` represents either: + +- A direct response to a client command, or +- An unsolicited event emitted by the server + +This document describes `ServerMessage` as a protocol-level concept. +It should not be confused with generated protobuf accessors or wire-level +encoding details. + +--- + +## Lifetime and Delivery + +`ServerMessage` instances are emitted by the server and delivered +asynchronously to clients. + +Delivery guarantees: + +- Messages are delivered in the order sent by the server +- Messages may be delivered at any time +- Events may be delivered before or after responses + +Clients **must** be prepared to handle `ServerMessage` objects at all times, +regardless of pending requests. + +--- + +## Message Type + +Each `ServerMessage` contains **exactly one payload**, identified by the +`message_type` field. + +The following message types are supported: + +- `RESPONSE` +- `SESSION_EVENT` +- `ROOM_EVENT` +- `GAME_EVENT_CONTAINER` + +A message containing multiple payloads or an unknown type is considered +malformed. + +--- + +## RESPONSE + +`RESPONSE` messages are direct replies to client-issued `CommandContainer` +messages. + +Characteristics: + +- Contain a `Response` payload +- Echo the client-assigned `cmd_id` +- Indicate success or failure of the request + +A single `CommandContainer` results in **at most one** `RESPONSE`. + +`RESPONSE` messages may be preceded or followed by event messages. + +--- + +## Events + +Event messages are unsolicited notifications emitted by the server. + +Event message types include: + +- `SESSION_EVENT` +- `ROOM_EVENT` +- `GAME_EVENT_CONTAINER` + +Event messages: + +- Are not correlated to a specific client command +- Do not include a `cmd_id` +- May be delivered at any time + +Clients **must** process events independently of responses. + +--- + +## Event Scopes + +### Session Events + +Session events are global to the client session and may include: + +- Server notifications +- Private messages +- User status updates + +--- + +### Room Events + +Room events are scoped to a specific room and are delivered only to clients +currently present in that room. + +--- + +### Game Events + +Game events are scoped to a specific game and are delivered only to +participating clients. + +Game events are grouped within a `GameEventContainer` to allow batching. + +--- + +## Event Batching + +Some `ServerMessage` types may contain multiple logical events. + +- `GameEventContainer` may batch multiple game events +- Other event types represent a single logical event + +Clients must process all contained events **in order**. + +--- + +## Error Semantics + +Errors are communicated **exclusively** via `RESPONSE` messages. + +Event messages are never used to signal command failure. + +Common error responses include: + +- `RespInvalidCommand` +- `RespLoginNeeded` +- `RespContextError` +- `RespChatFlood` + +--- + +## Related Concepts + +- CommandContainer +- Client State Machine +- Response + +--- + +## Reference Proto Definition + +```proto +message ServerMessage { + enum MessageType { + RESPONSE = 0; + SESSION_EVENT = 1; + GAME_EVENT_CONTAINER = 2; + ROOM_EVENT = 3; + } + + optional MessageType message_type = 1; + optional Response response = 2; + optional SessionEvent session_event = 3; + optional GameEventContainer game_event_container = 4; + optional RoomEvent room_event = 5; +} +``` \ No newline at end of file diff --git a/doc/doxygen/extra-pages/user_documentation/index.md b/doc/doxygen/extra-pages/user_documentation/index.md index d7e9d529d..468a28f8d 100644 --- a/doc/doxygen/extra-pages/user_documentation/index.md +++ b/doc/doxygen/extra-pages/user_documentation/index.md @@ -11,6 +11,11 @@ - @subpage beta_release +## Troubleshooting + +- @subpage fixing_card_pictures +- @subpage enabling_debug_logs + ## Syntax Help - @subpage search_syntax_help diff --git a/doc/doxygen/extra-pages/user_documentation/troubleshooting/enabling_debug_logs.md b/doc/doxygen/extra-pages/user_documentation/troubleshooting/enabling_debug_logs.md new file mode 100644 index 000000000..7e4196a50 --- /dev/null +++ b/doc/doxygen/extra-pages/user_documentation/troubleshooting/enabling_debug_logs.md @@ -0,0 +1,140 @@ +@page enabling_debug_logs Enabling Debug Logs + +Cockatrice ships with a "diagnostics mode" that prints detailed debug messages about what the client is doing. This is +extremely useful when asking for help, because it shows exactly what happened: which URL the card picture loader tried, +whether it found or missed a file on disk, whether a download succeeded or failed, which redirects were followed, and +much more. Each of these messages belongs to a category, and you can enable or disable categories individually. + +Don't worry, this sounds more technical than it is. You only need to do two things: create one small text file, and tell +Cockatrice where it is. There are no installation steps and you can undo everything later (see [When you are +done](#when-you-are-done)). + +# Step 1: Create the file + +Open a plain text editor (Notepad on Windows, TextEdit on macOS, or any text editor on Linux) and paste the following +content: + +```ini +[Rules] +# The default log level is info +*.debug = false + +# Turn on debug level logs for the card picture loader and all its sub categories +card_picture_loader.* = true +``` + +Save the file with the exact name `qtlogging.ini` in a place you can find again, for example your Documents folder. + +\attention The file name matters: it must be `qtlogging.ini`, not `qtlogging.ini.txt`. If your text editor adds a +`.txt` extension automatically, you need to stop it from doing so (see below). On macOS, TextEdit must be switched to +plain text mode first via 'Format → Make Plain Text'. + +The file contains one rule per line. The `*.debug = false` rule turns off debug messages everywhere by default, and the +`card_picture_loader.* = true` line then re-enables them for the card picture loader. The `.*` at the end means "this +category and all of its sub categories". To enable a different category instead, just replace that line with the +category name of your choice, for example `card_database.loading = true` or `window_main.startup = true`. + +# Step 2: Tell Cockatrice where the file is + +Cockatrice does not know about the file yet. You have to point it there by setting an environment variable called +`QT_LOGGING_CONF` to the full location of your file. How to do this depends on your operating system: + +**Windows** + +1. Press the Windows key, type "environment variables", and open "Edit the system environment variables". +2. Click "Environment Variables...", then under "User variables" click "New...". +3. Set "Variable name" to `QT_LOGGING_CONF` and "Variable value" to the full path of your file, for example + `C:\Users\YourName\Documents\qtlogging.ini`. +4. Confirm all dialogs, then close and reopen Cockatrice. + +Alternatively, if Cockatrice is installed in a folder you can write to, you can simply place the `qtlogging.ini` file +directly next to the Cockatrice executable (in the same folder as `cockatrice.exe`) and skip the environment variable +altogether. Note that this copy may be replaced when you update the client. + +**macOS** + +Open the Terminal app (it is in 'Applications → Utilities') and run the following two commands, replacing the path +with the full location of your file: + +```shell +launchctl setenv QT_LOGGING_CONF /path/to/qtlogging.ini +open -a Cockatrice +``` + +You can also use `export QT_LOGGING_CONF=/path/to/qtlogging.ini` to set the variable for the current terminal session. + +The setting stays active until you log out or restart your Mac. If you have multiple users on the same Mac, be aware +that this setting only applies to your user account. + +**Linux** + +For a quick test, open a terminal and start Cockatrice with the file on the command line, replacing the path with the +full location of your file: + +```shell +QT_LOGGING_CONF=/path/to/qtlogging.ini cockatrice +``` + +If this works and you want it to apply every time you start Cockatrice, add the following line to your `~/.profile` +file and log in again: + +```shell +export QT_LOGGING_CONF="/path/to/qtlogging.ini" +``` + +# Step 3: See the logs + +Now that debug logging is enabled, open Cockatrice and trigger the behavior you are investigating, for example by +opening a deck, reloading the card database, or starting a game. + +The easiest way to see the logs is to use the built-in log viewer inside Cockatrice itself: open 'Help → View Debug +Log'. A window appears that shows the log messages live and keeps the most recent entries. It even has a 'Copy to +clipboard' button so you can paste the output into a bug report or a Discord message. This works the same on every +operating system. + +If you prefer to capture everything to a file instead, start Cockatrice with the `--debug-output` option: + +```shell +cockatrice --debug-output +``` + +Cockatrice then writes the full log to a file called `qdebug.txt` in the folder it was started from. + +# Which categories are available? + +Every message Cockatrice logs belongs to a category. The following table lists the most useful ones for troubleshooting, +grouped by area. Enable a category by adding a line like `category = true` to your `qtlogging.ini` file (or use a `.*` +suffix, e.g. `card_picture_loader.*`, to include all sub categories). + +| What you want to see | Categories | +| ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| Card picture loading (URLs, local file hits/misses, downloads, redirects) | `card_picture_loader.*` | +| Card database loading and parsing | `card_database`, `card_database.loading`, `card_database.loading.success_or_failure`, `cockatrice_xml.*` | +| Card, set, and deck information | `card_info`, `card_list`, `deck_loader` | +| Startup sequence and update checks | `window_main.startup.*`, `release_channel`, `spoiler_background_updater` | +| User interface and themes | `theme_manager`, `sound_engine`, `flow_layout`, `flow_widget.*`, `pixel_map_generator`, `card_info_picture_widget` | +| Networking and servers | `local_client`, `remote_client`, `tapped_out_interface`, `servers_settings` | +| In-game logic | `player`, `game_scene.*`, `card_zone.*`, `view_zone`, `game_event_handler` | +| Dialogs and tabs | `dlg_settings`, `dlg_update`, `dlg_tip_of_the_day`, `tab_game`, `tab_message`, `tab_supervisor` | +| Settings and shortcuts | `settings_cache`, `shortcuts_settings` | +| Deck and card filtering | `filter_string`, `deck_filter_string`, `syntax_help` | + +For example, to investigate why a card database update seems to fail, enable the card database categories: + +```ini +[Rules] +*.debug = false + +card_database = true +card_database.loading = true +card_database.loading.success_or_failure = true +cockatrice_xml.* = true +``` + +# When you are done + +To turn the diagnostics back off, just reverse what you did: remove the `QT_LOGGING_CONF` environment variable (or +unset it again via `launchctl unsetenv QT_LOGGING_CONF` on macOS) and/or delete the `qtlogging.ini` file, then restart +Cockatrice. Leaving it on is harmless, but the extra logging can make the client slightly slower. + +For the full details on how Cockatrice logging works, including the complete list of categories, see @ref logging. diff --git a/doc/doxygen/extra-pages/user_documentation/troubleshooting/fixing_card_pictures.md b/doc/doxygen/extra-pages/user_documentation/troubleshooting/fixing_card_pictures.md new file mode 100644 index 000000000..78ba5586b --- /dev/null +++ b/doc/doxygen/extra-pages/user_documentation/troubleshooting/fixing_card_pictures.md @@ -0,0 +1,109 @@ +@page fixing_card_pictures Fixing Card Pictures + +This guide collects the common causes for card pictures not showing up, showing the wrong printing, or showing the +card back instead of the artwork, and how to fix them. + +Work through the steps in order. In most cases the problem is caused by an outdated client, misconfigured download +URLs, stale local images, or a corrupted cache. + +# Update Your Client + +Picture handling bugs are fixed on a regular basis, so the first thing to try is updating your client. + +Use the update check in 'Help → Check for Updates' (or look for the update prompt shown on startup). + +If a fix has not yet made it into the latest stable release, it may already be available in the beta release. The beta +ships very frequently and usually receives a follow-up fix within a day or two if something breaks. + +See @subpage beta_release for instructions on how to switch to the beta channel. + +# Check Your Download URLs + +Card pictures are downloaded from a list of URL templates. Each template is tried in order until one produces a valid +image, so the order matters: URLs at the top of the list are tried first. + +The list can be found in 'Cockatrice → Settings' (or Ctrl + Shift + P by default), on the 'Deck Editor' tab, in the +'URL Download Priority' section. Make sure 'Download card pictures on the fly' is enabled and that the list contains +valid URLs. If you suspect the list has been modified or corrupted, press 'Reset Download URLs' to restore the +defaults. + +For information on how to add your own custom URL templates, see the 'How to add a custom URL' link in the same +settings section. + +# Check Your Local Picture Folder + +Before any network request is made, Cockatrice looks for local image files. If a matching file is found on disk it is +shown instead of anything downloaded, even if it is the wrong image. + +The pictures directory is configured on the 'General' settings tab, under 'Directories' → 'Pictures directory'. +Cockatrice checks the following locations, in order: + +- The custom pictures folder (recursively indexed by file name). +- `//` +- `/downloadedPics//` + +The following import naming schemes are recognized (using both `_` and `-` as separators): + +| Scheme | Pattern | +| --------------------------- | -------------------------- | +| Card Name + Provider ID | `{name}_{providerId}` | +| Card Name + Set + Collector | `{name}_{set}_{collector}` | +| Set + Collector + Card Name | `{set}_{collector}_{name}` | +| Card Name + Set | `{name}_{set}` | +| Card Name | `{name}` | + +If a picture you downloaded or placed manually is wrong, stale, or corrupted, delete the offending file. Pay special +attention to the `downloadedPics` subfolder: this is where the filesystem caching method writes downloaded images, and +after a provider outage it can permanently contain the wrong printing until you delete it manually. + +See @ref loading_card_pictures for details on how local images are loaded. + +# Clear Caches + +Cockatrice caches card pictures in three places. All of them can be managed on the 'Storage' settings tab: + +- **Network cache** — downloaded images stored on disk. Press 'Delete Cached Images' to clear it. +- **Filesystem / image backup** — downloaded images written directly to `downloadedPics`. Press 'Delete Saved Images' + to clear it. +- **In-memory (pixmap) cache** — images currently held in RAM. Press 'Clear In-Memory Images' to clear it. + +If a provider outage caused the wrong pictures to be downloaded and cached, clearing the network cache (and the +'Delete Saved Images' button if you use the filesystem caching method) will force Cockatrice to download the correct +images again. The redirect cache TTL (also on the Storage tab) controls how long previously seen redirects for +download URLs are remembered; lowering it can help if a URL used to redirect somewhere else. + +# Restart the Client + +After updating the client, changing the download URLs, moving or deleting local image files, or clearing caches, it is +recommended to restart Cockatrice so that all changes are fully picked up. + +# Check Logs + +Before changing any settings, check the existing logs first. Rate limit errors and most download errors are already +logged at warn level, so you may find the cause without enabling debug mode. + +Open 'Help → View Debug Log' and look for error or warning messages related to card picture loading. If you need more +detail than the default log level provides, see below. + +# Enable Picture Loader Debug Logs + +If the steps above did not solve the problem, you can turn on a "diagnostics mode" that prints what the picture loader +is actually doing: which URL it is trying, whether it found or missed a file on disk, whether the download succeeded or +failed, and which redirects it followed. This information is extremely useful when asking for help. + +See @subpage enabling_debug_logs for a step-by-step guide on how to enable the logs, including instructions +for Windows, macOS, and Linux. + +# Provider Accuracy + +Cards in Cockatrice are identified by a provider ID, which is the Scryfall UUID of a specific printing. Decks store +this ID, which is why the exact printing a card was added as can be looked up again. + +The Scryfall URL templates built into Cockatrice use this provider ID directly (`!set:uuid!`), so they always download +the exact printing that was requested. + +The Gatherer URL templates, on the other hand, do **not** use the provider ID. They resolve pictures by multiverse ID +(`!set:muid!`) or by card name (`!name!`) only. As a result they may return a different printing than the one the +provider ID refers to, or no picture at all for cards Gatherer does not know. If you need pictures to match the exact +printing of a card, make sure the Scryfall URLs are at the top of your download URL priority list and consider removing +or demoting the Gatherer URLs. diff --git a/doc/doxygen/filters/proto2cpp.py b/doc/doxygen/filters/proto2cpp.py new file mode 100644 index 000000000..360935a48 --- /dev/null +++ b/doc/doxygen/filters/proto2cpp.py @@ -0,0 +1,285 @@ +#!/usr/bin/env python +## +# Doxygen filter for Google Protocol Buffers .proto files. +# This script converts .proto files into C++ style ones +# and prints the output to standard output. +# +# version 0.8-beta OSI +# +# How to enable this filter in Doxygen: +# 1. Generate Doxygen configuration file with command 'doxygen -g ' +# e.g. doxygen -g doxyfile +# 2. In the Doxygen configuration file, find FILE_PATTERNS and add *.proto +# FILE_PATTERNS = *.proto +# 3. In the Doxygen configuration file, find EXTENSION_MAPPING and add proto=C++ +# EXTENSION_MAPPING = proto=C++ +# 4. In the Doxygen configuration file, find INPUT_FILTER and add this script +# INPUT_FILTER = "python proto2cpp.py" +# 5. Run Doxygen with the modified configuration +# doxygen doxyfile +# +# +# Version 0.8 2018 Bugfix regarding long comments, remove typo +# Version 0.7 2018 Bugfix and extensions have been made by Open Simulation Interface (OSI) Carsten Kuebler https://github.com/OpenSimulationInterface, +# Copyright (C) 2016 Regents of the University of California https://github.com/vgteam/vg +# Copyright (C) 2012-2015 Timo Marjoniemi https://sourceforge.net/p/proto2cpp/wiki/Home/ +# All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +# +## + +import fnmatch +import inspect +import os +import re +import sys + + +## Class for converting Google Protocol Buffers .proto files into C++ style output to enable Doxygen usage. +## +## The C++ style output is printed into standard output.
+## There are three different logging levels for the class: +##
  • #logNone: do not log anything
  • +##
  • #logErrors: log errors only
  • +##
  • #logAll: log everything
+## Logging level is determined by \c #logLevel.
+## Error logs are written to file determined by \c #errorLogFile.
+## Debug logs are written to file determined by \c #logFile. +# +class proto2cpp: + + ## Logging level: do not log anything. + logNone = 0 + ## Logging level: log errors only. + logErrors = 1 + ## Logging level: log everything. + logAll = 2 + + ## Constructor + # + def __init__(self): + ## Debug log file name. + self.logFile = "proto2cpp.log" + ## Error log file name. + self.errorLogFile = "proto2cpp.error.log" + ## Logging level. + self.logLevel = self.logNone + + ## Handles a file. + ## + ## If @p fileName has .proto suffix, it is processed through parseFile(). + ## Otherwise it is printed to stdout as is except for file \c proto2cpp.py without + ## path since it's the script given to python for processing. + ## + ## @param fileName Name of the file to be handled. + # + def handleFile(self, fileName): + if fnmatch.fnmatch(filename, '*.proto'): + self.log('\nXXXXXXXXXX\nXX ' + filename + '\nXXXXXXXXXX\n\n') + # Open the file. Use try to detect whether or not we have an actual file. + if (sys.version_info >= (3, 0)): + try: + with open(filename, 'r', encoding='utf8') as inputFile: + self.parseFile(inputFile) + pass + except IOError as e: + self.logError('the file ' + filename + ' could not be opened for reading') + else: + # Python 2 code in this block + try: + with open(filename, 'r') as inputFile: + self.parseFile(inputFile) + pass + except IOError as e: + self.logError('the file ' + filename + ' could not be opened for reading') + + elif not fnmatch.fnmatch(filename, os.path.basename(inspect.getfile(inspect.currentframe()))): + self.log('\nXXXXXXXXXX\nXX ' + filename + '\nXXXXXXXXXX\n\n') + if (sys.version_info > (3, 0)): + try: + with open(filename, 'r', encoding='utf8') as theFile: + output = '' + for theLine in theFile: + output += theLine + print(output) + self.log(output) + pass + except IOError as e: + self.logError('the file ' + filename + ' could not be opened for reading') + else: + # Python 2 code in this block + try: + with open(filename, 'r') as theFile: + output = '' + for theLine in theFile: + output += theLine + print(output) + self.log(output) + pass + except IOError as e: + self.logError('the file ' + filename + ' could not be opened for reading') + + else: + self.log('\nXXXXXXXXXX\nXX ' + filename + ' --skipped--\nXXXXXXXXXX\n\n') + + ## Parser function. + ## + ## The function takes a .proto file object as input + ## parameter and modifies the contents into C++ style. + ## The modified data is printed into standard output. + ## + ## @param inputFile Input file object + # + def parseFile(self, inputFile): + # Go through the input file line by line. + isEnum = False + isPackage = False + isMultilineComment = False + # This variable is here as a workaround for not getting extra line breaks (each line + # ends with a line separator and print() method will add another one). + # We will be adding lines into this var and then print the var out at the end. + theOutput = '' + for line in inputFile: + # Search for comment ("//") and add one more slash character ("/") to the comment + # block to make Doxygen detect it. + matchComment = re.search("//", line) + # Search for semicolon and if one is found before comment, add a third slash character + # ("/") and a smaller than ("<") character to the comment to make Doxygen detect it. + matchSemicolon = re.search(";", line) + if matchSemicolon is not None and (matchComment is not None and matchSemicolon.start() < matchComment.start()): + comment = "///<" + line[matchComment.end():] + # Replace '.' in nested message references with '::' + # don't work for multi-nested references and generates problems with URLs and acronyms + #comment = re.sub(r'\s(\w+)\.(\w+)\s', r' \1::\2 ', comment) + line = line[:matchComment.start()] + elif matchComment is not None: + if isMultilineComment: + comment = " * " + line[matchComment.end():] + else: + comment = "/** " + line[matchComment.end():] + isMultilineComment = True + # replace '.' in nested message references with '::' + # don't work for multi-nested references and generates problems with URLs and acronyms + #comment = re.sub(r'\s(\w+)\.(\w+)\s', r' \1::\2 ', comment) + line = line[:matchComment.start()] + else: + comment = "" + + # End multiline comment, if there is no comment or if there are some chars before the comment. + if (matchComment is None or len(line.strip())>0) and isMultilineComment: + theOutput += " */\n" + isMultilineComment = False + + # line = line.replace(".", "::") but not in quoted strings (Necessary for import statement) + line = re.sub(r'\.(?=(?:[^"]*"[^"]*")*[^"]*$)',r'::',line) + + # Search for " option ...;", remove it + line = re.sub(r'\boption\b[^;]+;', r'', line) + + # Search for " package ", make a namespace + matchPackage = re.search(r"\bpackage\b", line) + if matchPackage is not None: + isPackage = True + # Convert to C++-style separator and block instead of statement + line = "namespace" + line[:matchPackage.start()] + line[matchPackage.end():].replace(";", " {") + + # Search for " repeated " fields and make them ... + #matchRepeated = re.search(r"\brepeated\b", line) + #if matchRepeated is not None: + # # Convert + # line = re.sub(r'\brepeated\s+(\S+)', r' repeated \1', line) + + # Search for "enum", start changing all semicolons (";") to commas (","). + matchEnum = re.search(r"\benum\b", line) + if matchEnum is not None: + isEnum = True + + # Search semicolon if we have detected an enum, and replace semicolon with comma. + if isEnum is True and matchSemicolon is not None: + line = line.replace(";", ",") + + # Search for a closing brace. + matchClosingBrace = re.search("}", line) + if isEnum is True and matchClosingBrace is not None: + line = line[:matchClosingBrace.start()] + "};" + line[matchClosingBrace.end():] + isEnum = False + elif isEnum is False and matchClosingBrace is not None: + # Message (to be struct) ends => add semicolon so that it'll + # be a proper C(++) struct and Doxygen will handle it correctly. + line = line[:matchClosingBrace.start()] + "};" + line[matchClosingBrace.end():] + + # Replacements change start of comment... + matchMsg = re.search(r"\bmessage\b", line) + if matchMsg is not None: + line = line[:matchMsg.start()] + "struct" + line[matchMsg.end():] + + # Replacements change start of comment... + matchExt = re.search(r"\bextend\b", line) + if matchExt is not None: + a_extend = line[matchExt.end():] + matchName = re.search(r"\b\w[\S:]*\b", a_extend) + if matchName is not None: + name = a_extend[matchName.start():matchName.end()] + name = re.sub(r'\w+::',r'',name) + a_extend = a_extend[:matchName.start()] + name + ": public " + a_extend[matchName.start():] + else: + a_extend = "_Dummy: public " + a_extend; + line = line[:matchExt.start()] + "struct " + a_extend + + theOutput += (line.strip() + ' ' + comment.strip()).strip() + '\n' + + if isPackage: + # Close the package namespace + theOutput += "}" + isPackage = False + + # Now that we've got all lines in the string let's split the lines and print out + # one by one. + # This is a workaround to get rid of extra empty line at the end which print() method adds. + lines = theOutput.splitlines() + for line in lines: + if len(line) > 0: + print(line) # Add linebreak to generate documentation correct. + self.log(line + '\n') + + ## Writes @p string to log file. + ## + ## #logLevel must be #logAll or otherwise the logging is skipped. + ## + ## @param string String to be written to log file. + # + def log(self, string): + if self.logLevel >= self.logAll: + with open(self.logFile, 'a') as theFile: + theFile.write(string) + + ## Writes @p string to error log file. + ## + ## #logLevel must be #logError or #logAll or otherwise the logging is skipped. + ## + ## @param string String to be written to error log file. + # + def logError(self, string): + if self.logLevel >= self.logError: + with open(self.errorLogFile, 'a') as theFile: + theFile.write(string) + +converter = proto2cpp() +# Doxygen will give us the file names +for filename in sys.argv[1:]: + converter.handleFile(filename) + +# end of file \ No newline at end of file diff --git a/format.sh b/format.sh index 83dee9e28..9e3a6069b 100755 --- a/format.sh +++ b/format.sh @@ -11,17 +11,18 @@ set -o pipefail # go to the project root directory, this file should be located in the project root directory olddir="$PWD" cd "${BASH_SOURCE%/*}/" || exit 2 # could not find path, this could happen with special links etc. +phys_root="$(pwd -P)" # defaults include=("cockatrice/src" \ libcockatrice_* \ "oracle/src" \ "servatrice/src" \ +"cmake/pch" \ "tests") exclude=("libcockatrice_rng/libcockatrice/rng/sfmt/" \ "libcockatrice_utility/libcockatrice/utility/peglib.h" \ "oracle/src/lzma/" \ -"oracle/src/qt-json/" \ "oracle/src/zip/" \ "servatrice/src/smtp/") exts=("cpp" "h" "proto") @@ -168,14 +169,14 @@ EOM echo "error in parsing arguments of $0: $1 is an unrecognized option" >&2 exit 2 # input error fi - if [[ ! $1 ]] || next_dir=$(cd "$olddir" && cd -- "$1" && pwd); then + if [[ ! $1 ]] || next_dir=$(cd "$olddir" && cd -- "$1" && pwd -P); then if ! [[ $set_include ]]; then include=() # remove default includes set_include=1 fi if [[ $1 ]]; then - if [[ $next_dir != $PWD/* ]]; then - echo "error in parsing arguments of $0: $next_dir is not in $PWD" >&2 + if [[ "$next_dir" != "$phys_root" && "$next_dir" != "$phys_root"/* ]]; then + echo "error in parsing arguments of $0: $next_dir is not in $phys_root" >&2 exit 2 # input error fi include+=("$next_dir") diff --git a/libcockatrice_card/CMakeLists.txt b/libcockatrice_card/CMakeLists.txt index dd3799e33..15388ecfc 100644 --- a/libcockatrice_card/CMakeLists.txt +++ b/libcockatrice_card/CMakeLists.txt @@ -5,6 +5,8 @@ set(CMAKE_AUTORCC ON) set(HEADERS libcockatrice/card/card_info.h libcockatrice/card/card_info_comparator.h + libcockatrice/card/card_localization.h + libcockatrice/card/lazy_properties_hash.h libcockatrice/card/database/card_database.h libcockatrice/card/database/card_database_loader.h libcockatrice/card/database/card_database_manager.h @@ -19,18 +21,17 @@ set(HEADERS libcockatrice/card/relation/card_relation.h ) -if(Qt6_FOUND) - qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) -elseif(Qt5_FOUND) - qt5_wrap_cpp(MOC_SOURCES ${HEADERS}) -endif() +qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) add_library( libcockatrice_card STATIC ${MOC_SOURCES} libcockatrice/card/card_info.cpp libcockatrice/card/card_info_comparator.cpp + libcockatrice/card/card_localization.cpp + libcockatrice/card/lazy_properties_hash.cpp libcockatrice/card/database/card_database.cpp + libcockatrice/card/database/card_database_cache.cpp libcockatrice/card/database/card_database_loader.cpp libcockatrice/card/database/card_database_manager.cpp libcockatrice/card/database/card_database_querier.cpp diff --git a/libcockatrice_card/libcockatrice/card/card_info.cpp b/libcockatrice_card/libcockatrice/card/card_info.cpp index 1aaec85b8..56c737793 100644 --- a/libcockatrice_card/libcockatrice/card/card_info.cpp +++ b/libcockatrice_card/libcockatrice/card/card_info.cpp @@ -9,7 +9,6 @@ #include #include #include -#include #include #include @@ -19,22 +18,65 @@ class CardInfo; using CardInfoPtr = QSharedPointer; +const QHash &CardInfo::getPropertiesHash() const +{ + return properties.getProperties(); +} + +void CardInfo::setProperty(const QString &_name, const QString &_value) +{ + bool changed = properties.insert(_name, _value); + if (!changed) { + return; + } + + emit cardInfoChanged(smartThis); +} + CardInfo::CardInfo(const QString &_name, const QString &_text, bool _isToken, - QVariantHash _properties, + const QHash &_properties, const QList &_relatedCards, const QList &_reverseRelatedCards, SetToPrintingsMap _sets, - const UiAttributes _uiAttributes) - : name(_name), text(_text), isToken(_isToken), properties(std::move(_properties)), relatedCards(_relatedCards), - reverseRelatedCards(_reverseRelatedCards), setsToPrintings(std::move(_sets)), uiAttributes(_uiAttributes) + const UiAttributes _uiAttributes, + QMap _localizedNames, + QMap _localizedTexts) + : name(_name), text(_text), isToken(_isToken), localizedNames(std::move(_localizedNames)), + localizedTexts(std::move(_localizedTexts)), properties(LazyPropertiesHash(_properties)), + relatedCards(_relatedCards), reverseRelatedCards(_reverseRelatedCards), setsToPrintings(std::move(_sets)), + uiAttributes(_uiAttributes) { simpleName = CardInfo::simplifyName(name); refreshCachedSets(); } +CardInfo::CardInfo(const QString &_name, + const QString &_text, + bool _isToken, + const QByteArray &_propertiesBlob, + const QList &_relatedCards, + const QList &_reverseRelatedCards, + SetToPrintingsMap _sets, + const UiAttributes _uiAttributes, + QString _simpleName, + QSet _altNames, + QMap _localizedNames, + QMap _localizedTexts) + : name(_name), simpleName(std::move(_simpleName)), text(_text), isToken(_isToken), + localizedNames(std::move(_localizedNames)), localizedTexts(std::move(_localizedTexts)), + properties(LazyPropertiesHash(_propertiesBlob)), relatedCards(_relatedCards), + reverseRelatedCards(_reverseRelatedCards), setsToPrintings(std::move(_sets)), uiAttributes(_uiAttributes), + altNames(std::move(_altNames)) +{ + // propertiesBlob is materialized lazily on first query; simpleName and + // altNames are supplied by the caller (binary cache). Only the set-name + // display list still depends on the current enabled-set state. + refreshCachedSetNames(); +} + CardInfoPtr CardInfo::newInstance(const QString &_name) { return newInstance(_name, "", false, {}, {}, {}, {}, {}); @@ -43,14 +85,16 @@ CardInfoPtr CardInfo::newInstance(const QString &_name) CardInfoPtr CardInfo::newInstance(const QString &_name, const QString &_text, bool _isToken, - QVariantHash _properties, + const QHash &_properties, const QList &_relatedCards, const QList &_reverseRelatedCards, SetToPrintingsMap _sets, - const UiAttributes _uiAttributes) + const UiAttributes _uiAttributes, + QMap _localizedNames, + QMap _localizedTexts) { - CardInfoPtr ptr(new CardInfo(_name, _text, _isToken, std::move(_properties), _relatedCards, _reverseRelatedCards, - _sets, _uiAttributes)); + CardInfoPtr ptr(new CardInfo(_name, _text, _isToken, _properties, _relatedCards, _reverseRelatedCards, _sets, + _uiAttributes, std::move(_localizedNames), std::move(_localizedTexts))); ptr->setSmartPointer(ptr); for (const auto &printings : _sets) { @@ -63,6 +107,37 @@ CardInfoPtr CardInfo::newInstance(const QString &_name, return ptr; } +CardInfoPtr CardInfo::newInstance(const QString &_name, + const QString &_text, + bool _isToken, + QByteArray _propertiesBlob, + const QList &_relatedCards, + const QList &_reverseRelatedCards, + SetToPrintingsMap _sets, + const UiAttributes _uiAttributes, + QString _simpleName, + QSet _altNames, + bool _appendToSets, + QMap _localizedNames, + QMap _localizedTexts) +{ + CardInfoPtr ptr(new CardInfo(_name, _text, _isToken, std::move(_propertiesBlob), _relatedCards, + _reverseRelatedCards, _sets, _uiAttributes, std::move(_simpleName), + std::move(_altNames), std::move(_localizedNames), std::move(_localizedTexts))); + ptr->setSmartPointer(ptr); + + if (_appendToSets) { + for (const auto &printings : _sets) { + for (const PrintingInfo &printing : printings) { + printing.getSet()->append(ptr); + break; + } + } + } + + return ptr; +} + QString CardInfo::getCorrectedName() const { // remove all the characters reserved in windows file paths, @@ -102,15 +177,16 @@ void CardInfo::addToSet(const CardSetPtr &_set, const PrintingInfo &_info) refreshCachedSets(); } -void CardInfo::combineLegalities(const QVariantHash &props) +void CardInfo::combineLegalities(const QHash &props) { - QHashIterator it(props); + QHashIterator it(props); while (it.hasNext()) { it.next(); if (it.key().startsWith("format-")) { - smartThis->setProperty(it.key(), it.value().toString()); + properties.insert(it.key(), it.value()); } } + emit cardInfoChanged(smartThis); } void CardInfo::refreshCachedSets() diff --git a/libcockatrice_card/libcockatrice/card/card_info.h b/libcockatrice_card/libcockatrice/card/card_info.h index 654ac1f63..17894cec5 100644 --- a/libcockatrice_card/libcockatrice/card/card_info.h +++ b/libcockatrice_card/libcockatrice/card/card_info.h @@ -2,6 +2,7 @@ #define CARD_INFO_H #include "format/format_legality_rules.h" +#include "lazy_properties_hash.h" #include "printing/printing_info.h" #include @@ -10,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -69,12 +71,17 @@ private: * @anchor PrivateCardProperties */ ///@{ - CardInfoPtr smartThis; ///< Smart pointer to self for safe cross-references. - QString name; ///< Full name of the card. - QString simpleName; ///< Simplified name for fuzzy matching. - QString text; ///< Text description or rules text of the card. - bool isToken; ///< Whether this card is a token or not. - QVariantHash properties; ///< Key-value store of dynamic card properties. + CardInfoPtr smartThis; ///< Smart pointer to self for safe cross-references. + QString name; ///< Full name of the card. + QString simpleName; ///< Simplified name for fuzzy matching. + QString text; ///< Text description or rules text of the card. + bool isToken; ///< Whether this card is a token or not. + + QMap localizedNames; ///< Localized card names, keyed by language code. + QMap localizedTexts; ///< Localized rules text, keyed by language code. + + LazyPropertiesHash properties; ///< Key-value store of dynamic card properties. + QList relatedCards; ///< Forward references to related cards. QList reverseRelatedCards; ///< Cards that refer back to this card. QList reverseRelatedCardsToMe; ///< Cards that consider this card as related. @@ -96,15 +103,55 @@ public: * @param _reverseRelatedCards Backward references to related cards. * @param _sets Map of set names to printing information. * @param _uiAttributes Attributes that affect display and game logic + * @param _localizedNames Localized card names, keyed by language code. + * @param _localizedTexts Localized rules text, keyed by language code. */ explicit CardInfo(const QString &_name, const QString &_text, bool _isToken, - QVariantHash _properties, + const QHash &_properties, const QList &_relatedCards, const QList &_reverseRelatedCards, SetToPrintingsMap _sets, - UiAttributes _uiAttributes); + UiAttributes _uiAttributes, + QMap _localizedNames = {}, + QMap _localizedTexts = {}); + + /** + * @brief Constructs a CardInfo from a cache snapshot with precomputed derived + * state. + * + * Used by the binary cache reader to skip recomputing @p _simpleName and + * @p _altNames (which otherwise require a Unicode normalization and a full + * printing scan). Properties are supplied as a pre-serialized @p _propertiesBlob + * so the QHash is not built at load time (it is materialized on first + * query). + * + * @param _name The card name. + * @param _text Rules text or description of the card. + * @param _isToken Token flag. + * @param _propertiesBlob Pre-serialized properties blob (as written by the cache). + * @param _relatedCards Forward relationships. + * @param _reverseRelatedCards Reverse relationships. + * @param _sets Printing information per set. + * @param _uiAttributes Attributes that affect display and game logic. + * @param _simpleName Precomputed simplified name. + * @param _altNames Precomputed alternate names. + * @param _localizedNames Localized card names, keyed by language code. + * @param _localizedTexts Localized rules text, keyed by language code. + */ + explicit CardInfo(const QString &_name, + const QString &_text, + bool _isToken, + const QByteArray &_propertiesBlob, + const QList &_relatedCards, + const QList &_reverseRelatedCards, + SetToPrintingsMap _sets, + UiAttributes _uiAttributes, + QString _simpleName, + QSet _altNames, + QMap _localizedNames = {}, + QMap _localizedTexts = {}); /** * @brief Copy constructor for CardInfo. @@ -115,7 +162,8 @@ public: */ CardInfo(const CardInfo &other) : QObject(other.parent()), name(other.name), simpleName(other.simpleName), text(other.text), - isToken(other.isToken), properties(other.properties), relatedCards(other.relatedCards), + isToken(other.isToken), localizedNames(other.localizedNames), localizedTexts(other.localizedTexts), + properties(other.properties), relatedCards(other.relatedCards), reverseRelatedCards(other.reverseRelatedCards), reverseRelatedCardsToMe(other.reverseRelatedCardsToMe), setsToPrintings(other.setsToPrintings), uiAttributes(other.uiAttributes), setsNames(other.setsNames), altNames(other.altNames) @@ -143,16 +191,56 @@ public: * @param _reverseRelatedCards Reverse relationships. * @param _sets Printing information per set. * @param _uiAttributes Attributes that affect display and game logic + * @param _localizedNames Localized card names, keyed by language code. + * @param _localizedTexts Localized rules text, keyed by language code. * @return Shared pointer to the new CardInfo instance. */ static CardInfoPtr newInstance(const QString &_name, const QString &_text, bool _isToken, - QVariantHash _properties, + const QHash &_properties, const QList &_relatedCards, const QList &_reverseRelatedCards, SetToPrintingsMap _sets, - UiAttributes _uiAttributes); + UiAttributes _uiAttributes, + QMap _localizedNames = {}, + QMap _localizedTexts = {}); + + /** + * @brief Creates a new instance from a cache snapshot with precomputed + * derived state. + * + * @param _name Name of the card. + * @param _text Rules text or description. + * @param _isToken Token flag. + * @param _propertiesBlob Pre-serialized properties blob (as written by the cache). + * @param _relatedCards Forward relationships. + * @param _reverseRelatedCards Reverse relationships. + * @param _sets Printing information per set. + * @param _uiAttributes Attributes that affect display and game logic. + * @param _simpleName Precomputed simplified name. + * @param _altNames Precomputed alternate names. + * @param _appendToSets When true (default), the card is appended to each of + * its CardSets. Pass false when building cards in parallel so the + * (non-thread-safe) set membership is populated in a later + * single-threaded pass. + * @param _localizedNames Localized card names, keyed by language code. + * @param _localizedTexts Localized rules text, keyed by language code. + * @return Shared pointer to the new CardInfo instance. + */ + static CardInfoPtr newInstance(const QString &_name, + const QString &_text, + bool _isToken, + QByteArray _propertiesBlob, + const QList &_relatedCards, + const QList &_reverseRelatedCards, + SetToPrintingsMap _sets, + UiAttributes _uiAttributes, + QString _simpleName, + QSet _altNames, + bool _appendToSets = true, + QMap _localizedNames = {}, + QMap _localizedTexts = {}); /** * @brief Clones the current CardInfo instance. @@ -202,26 +290,114 @@ public: text = _text; emit cardInfoChanged(smartThis); } + + /** + * @brief Returns the card name in the given language, falling back to the + * English name when no localization is available. + * + * @param lang Language code (e.g. "de", "ja", "zhs"). + * @return The localized name, or the English name as fallback. + */ + [[nodiscard]] const QString &getLocalizedName(const QString &lang) const + { + const auto it = localizedNames.constFind(lang); + return it != localizedNames.constEnd() ? it.value() : name; + } + + /** + * @brief Returns the rules text in the given language, falling back to the + * English text when no localization is available. + * + * @param lang Language code (e.g. "de", "ja", "zhs"). + * @return The localized text, or the English text as fallback. + */ + [[nodiscard]] const QString &getLocalizedText(const QString &lang) const + { + const auto it = localizedTexts.constFind(lang); + return it != localizedTexts.constEnd() ? it.value() : text; + } + + /** + * @brief Returns the localized card names keyed by language code. + * + * Only languages that have an entry are present; there is no English + * fallback in this map. + */ + [[nodiscard]] const QMap &getLocalizedNames() const + { + return localizedNames; + } + + /** + * @brief Returns the localized rules text keyed by language code. + * + * Only languages that have an entry are present; there is no English + * fallback in this map. + */ + [[nodiscard]] const QMap &getLocalizedTexts() const + { + return localizedTexts; + } + + /** + * @brief Sets the card name for the given language. + * + * @param lang Language code. + * @param _localizedName The localized card name. + */ + void setLocalizedName(const QString &lang, const QString &_localizedName) + { + if (localizedNames.value(lang) == _localizedName) { + return; + } + localizedNames.insert(lang, _localizedName); + emit cardInfoChanged(smartThis); + } + + /** + * @brief Sets the rules text for the given language. + * + * @param lang Language code. + * @param _localizedText The localized rules text. + */ + void setLocalizedText(const QString &lang, const QString &_localizedText) + { + if (localizedTexts.value(lang) == _localizedText) { + return; + } + localizedTexts.insert(lang, _localizedText); + emit cardInfoChanged(smartThis); + } + + /** + * @brief Returns the language codes for which this card has a localized + * name or rules text. + */ + [[nodiscard]] QStringList localizationLanguages() const + { + QStringList languages = localizedNames.keys(); + languages.append(localizedTexts.keys()); + languages.removeDuplicates(); + return languages; + } [[nodiscard]] bool getIsToken() const { return isToken; } [[nodiscard]] QStringList getProperties() const { - return properties.keys(); + return getPropertiesHash().keys(); } + [[nodiscard]] const QHash &getPropertiesHash() const; + [[nodiscard]] QString getProperty(const QString &propertyName) const { - return properties.value(propertyName).toString(); - } - void setProperty(const QString &_name, const QString &_value) - { - properties.insert(_name, _value); - emit cardInfoChanged(smartThis); + return getPropertiesHash().value(propertyName); } + void setProperty(const QString &_name, const QString &_value); [[nodiscard]] bool hasProperty(const QString &propertyName) const { - return properties.contains(propertyName); + return getPropertiesHash().contains(propertyName); } [[nodiscard]] const SetToPrintingsMap &getSets() const { @@ -325,7 +501,7 @@ public: * * @param props Key-value mapping of format legalities. */ - void combineLegalities(const QVariantHash &props); + void combineLegalities(const QHash &props); /** * @brief Refreshes all cached fields that are calculated from the contained sets and printings. diff --git a/libcockatrice_card/libcockatrice/card/card_info_comparator.cpp b/libcockatrice_card/libcockatrice/card/card_info_comparator.cpp index 821cc8675..cc0314e21 100644 --- a/libcockatrice_card/libcockatrice/card/card_info_comparator.cpp +++ b/libcockatrice_card/libcockatrice/card/card_info_comparator.cpp @@ -25,22 +25,13 @@ bool CardInfoComparator::operator()(const CardInfoPtr &a, const CardInfoPtr &b) bool CardInfoComparator::compareVariants(const QVariant &a, const QVariant &b) const { - // Determine the type of QVariant based on Qt version -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) if (a.typeId() != b.typeId()) { -#else - if (a.type() != b.type()) { -#endif // If they are not the same type, compare as strings return a.toString() < b.toString(); } // Perform type-specific comparison -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) switch (static_cast(a.typeId())) { -#else - switch (static_cast(a.type())) { -#endif case static_cast(QMetaType::Int): return a.toInt() < b.toInt(); case static_cast(QMetaType::Double): @@ -66,7 +57,7 @@ QVariant CardInfoComparator::getProperty(const CardInfoPtr &card, const QString return card->getIsToken(); } - // Otherwise, check if it's a custom property in the QVariantHash + // Otherwise, check if it's a custom property in the properties hash if (card->hasProperty(property)) { return card->getProperty(property); } diff --git a/libcockatrice_card/libcockatrice/card/card_localization.cpp b/libcockatrice_card/libcockatrice/card/card_localization.cpp new file mode 100644 index 000000000..03c6c659b --- /dev/null +++ b/libcockatrice_card/libcockatrice/card/card_localization.cpp @@ -0,0 +1,38 @@ +#include "card_localization.h" + +#include +#include +#include + +namespace CardLocalization +{ +const QStringList &supportedLanguages() +{ + static const QStringList languages = {"cs", "de", "es", "fr", "it", "ja", "ko", "pt", "ru", "zhs", "zht", "he"}; + return languages; +} + +QString languageDisplayName(const QString &lang) +{ + static const QHash displayNames = { + {"cs", "Česky (Czech)"}, + {"de", "Deutsch (German)"}, + {"es", "Español (Spanish)"}, + {"fr", "Français (French)"}, + {"it", "Italiano (Italian)"}, + {"ja", "日本語 (Japanese)"}, + {"ko", "한국어 (Korean)"}, + {"pt", "Português (Portuguese)"}, + {"ru", "Русский (Russian)"}, + {"he", "עברית (Hebrew)"}, + {"zhs", "简体中文 (Chinese Simplified)"}, + {"zht", "繁體中文 (Chinese Traditional)"}, + }; + const QString displayName = displayNames.value(lang); + if (!displayName.isEmpty()) { + return displayName; + } + const QString nativeName = QLocale(lang).nativeLanguageName(); + return nativeName.isEmpty() ? lang : nativeName; +} +} // namespace CardLocalization \ No newline at end of file diff --git a/libcockatrice_card/libcockatrice/card/card_localization.h b/libcockatrice_card/libcockatrice/card/card_localization.h new file mode 100644 index 000000000..a9c8d28ea --- /dev/null +++ b/libcockatrice_card/libcockatrice/card/card_localization.h @@ -0,0 +1,43 @@ +#ifndef CARD_LOCALIZATION_H +#define CARD_LOCALIZATION_H + +#include +#include + +/** + * @namespace CardLocalization + * @ingroup Cards + * + * @brief Shared language metadata for localized card text and images. + * + * Lists the language codes Cockatrice can display localized card data for and + * provides human-readable names. The list is shared between Oracle (which + * imports the selected language's card data) and the client settings UI (which + * offers the language choice). + */ +namespace CardLocalization +{ +/** + * @brief Language codes for which localized card data can be imported/displayed. + * + * Matches the languages Scryfall can serve localized card images for. "en" is + * always available as the default/fallback and is not listed here. + * + * @return The list of supported language codes. + */ +[[nodiscard]] const QStringList &supportedLanguages(); + +/** + * @brief Human-readable name for a language code. + * + * Follows the same "native name (English name)" format the UI language list + * uses (e.g. "日本語 (Japanese)"), so the English fallback is always visible. + * + * @param lang Language code (e.g. "de", "ja", "zhs"). + * @return The language's native name with its English name in parentheses, or + * the code itself if it cannot be resolved. + */ +[[nodiscard]] QString languageDisplayName(const QString &lang); +} // namespace CardLocalization + +#endif // CARD_LOCALIZATION_H \ No newline at end of file diff --git a/libcockatrice_card/libcockatrice/card/database/card_database.cpp b/libcockatrice_card/libcockatrice/card/database/card_database.cpp index edad46174..209b9d2c2 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database.cpp +++ b/libcockatrice_card/libcockatrice/card/database/card_database.cpp @@ -20,6 +20,7 @@ CardDatabase::CardDatabase(QObject *parent, { qRegisterMetaType("CardInfoPtr"); qRegisterMetaType("CardSetPtr"); + qRegisterMetaType("CardDatabaseData"); // create loader and wire it up loader = new CardDatabaseLoader(this, this, pathProvider, prefs, setPriorityController); @@ -28,6 +29,10 @@ CardDatabase::CardDatabase(QObject *parent, connect(loader, &CardDatabaseLoader::loadingFailed, this, &CardDatabase::cardDatabaseLoadingFailed); connect(loader, &CardDatabaseLoader::newSetsFound, this, &CardDatabase::cardDatabaseNewSetsFound); connect(loader, &CardDatabaseLoader::allNewSetsEnabled, this, &CardDatabase::cardDatabaseAllNewSetsEnabled); + // swap the finished snapshot into the live database. Uses AutoConnection so + // that cross-thread loads are delivered via the event loop while same-thread/ + // synchronous callers get the swap immediately. + connect(loader, &CardDatabaseLoader::databaseDataReady, this, &CardDatabase::swapInDatabaseData); querier = new CardDatabaseQuerier(this, this, prefs); } @@ -66,7 +71,12 @@ void CardDatabase::reloadCardDatabasesAndNotify() loadCardDatabases(); if (loadStatus == Ok) { - notifyEnabledSetsChanged(); + checkUnknownSets(); + // A reload reconstructs the exact same enabled-set state, so the cached + // set-name / alt-name data computed during construction is still valid. + // Skipping the per-card refresh avoids a full 36k-card recompute that + // would otherwise duplicate the work already done while building cards. + notifyEnabledSetsChanged(false); } } @@ -77,15 +87,21 @@ bool CardDatabase::saveCustomTokensToFile() void CardDatabase::refreshCachedReverseRelatedCards() { - for (const auto &card : cards) { + refreshCachedReverseRelatedCards(cards); +} + +void CardDatabase::refreshCachedReverseRelatedCards(CardNameMap &cardMap) +{ + for (const auto &card : cardMap) { card->resetReverseRelatedCards2Me(); } - for (const auto &card : cards) { + for (const auto &card : cardMap) { for (auto *rel : card->getReverseRelatedCards()) { - if (auto target = cards.value(rel->getName())) { + if (auto target = cardMap.value(rel->getName())) { auto *newRel = new CardRelation(card->getName(), rel->getAttachType(), rel->getIsCreateAllExclusion(), - rel->getIsVariable(), rel->getDefaultCount(), rel->getIsPersistent()); + rel->getIsVariable(), rel->getDefaultCount(), rel->getIsPersistent(), + rel->getIsFaceDown()); target->addReverseRelatedCards2Me(newRel); } } @@ -204,11 +220,15 @@ void CardDatabase::markAllSetsAsKnown() _sets.markAllAsKnown(); } -void CardDatabase::notifyEnabledSetsChanged() +void CardDatabase::notifyEnabledSetsChanged(bool recomputeCachedSets) { - // refresh the list of cached set names - for (const CardInfoPtr &card : cards) { - card->refreshCachedSets(); + if (recomputeCachedSets) { + // refresh the list of cached set names / alt names for every card; this is + // only needed when the enabled-set state actually changed (e.g. the user + // toggled a set in the settings UI). + for (const CardInfoPtr &card : cards) { + card->refreshCachedSets(); + } } // inform the carddatabasemodels that they need to re-check their list of cards @@ -219,3 +239,30 @@ void CardDatabase::addFormat(const FormatRulesPtr &format) { formats.insert(format->formatName.toLower(), format); } + +void CardDatabase::swapInDatabaseData(CardDatabaseData data) +{ + for (const auto &card : cards) { + for (auto *rel : card->getRelatedCards()) { + rel->deleteLater(); + } + for (auto *rel : card->getReverseRelatedCards()) { + rel->deleteLater(); + } + for (auto *rel : card->getReverseRelatedCards2Me()) { + rel->deleteLater(); + } + } + + cards = std::move(data.cards); + simpleNameCards = std::move(data.simpleNameCards); + sets = std::move(data.sets); + formats = std::move(data.formats); + + loadStatus = cards.isEmpty() ? NotLoaded : Ok; + + // inform listeners that the whole database was replaced; they should + // rebuild from the live containers in a single batch instead of reacting + // to individual card additions. + emit cardDatabaseReset(); +} diff --git a/libcockatrice_card/libcockatrice/card/database/card_database.h b/libcockatrice_card/libcockatrice/card/database/card_database.h index 44838962d..b10e3c726 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database.h +++ b/libcockatrice_card/libcockatrice/card/database/card_database.h @@ -2,6 +2,7 @@ #define CARDDATABASE_H #include "../set/card_set_list.h" +#include "card_database_data.h" #include "card_database_loader.h" #include "card_database_querier.h" @@ -54,16 +55,17 @@ protected: CardDatabaseQuerier *querier; private: - /** - * @brief Check for sets that are unknown and emit signals if needed. - */ - void checkUnknownSets(); - /** * @brief Refreshes the cached reverse-related cards for all cards. */ void refreshCachedReverseRelatedCards(); + /** + * @brief Refreshes the cached reverse-related cards for the given card map. + * @param cardMap Map of cards to process. + */ + void refreshCachedReverseRelatedCards(CardNameMap &cardMap); + /** @brief Mutexes for thread safety. */ QBasicMutex *clearDatabaseMutex = new QBasicMutex(), *addCardMutex = new QBasicMutex(), *removeCardMutex = new QBasicMutex(); @@ -127,8 +129,25 @@ public: /** @brief Marks all sets as known. */ void markAllSetsAsKnown(); - /** @brief Notifies listeners that enabled sets changed. */ - void notifyEnabledSetsChanged(); + /** + * @brief Notifies listeners that enabled sets changed. + * @param recomputeCachedSets When true, every card's cached set-name / alt-name + * data is recomputed (needed only when enabled-set state actually + * changed). Pass false after a plain reload, where enabled-set state is + * unchanged and the cached data is still valid. + */ + void notifyEnabledSetsChanged(bool recomputeCachedSets = true); + + /** + * @brief Check for sets that are unknown and emit signals if needed. + * + * Called from MainWindow::startupConfigCheck() after signal connections are + * live (the front-loaded parse in main() runs before MainWindow exists, so + * signals emitted there would have no receivers). May also be called by + * the loader via the friend declaration. Has side effects: enables all + * sets when none are enabled (first-run), marks all sets as known. + */ + void checkUnknownSets(); ICardSetPriorityController *getPriorityController() { @@ -159,10 +178,26 @@ public slots: */ bool saveCustomTokensToFile(); + /** + * @brief Replaces the entire contents of the database with the provided + * snapshot in a single operation. Intended to be called once a + * background load has finished building a CardDatabaseData, so that + * observers never see a partially-populated database. + * @param data The fully-built snapshot to adopt. + */ + void swapInDatabaseData(CardDatabaseData data); + signals: /** @brief Emitted when the card database has finished loading successfully. */ void cardDatabaseLoadingFinished(); + /** + * @brief Emitted once after the live database contents have been replaced + * by a freshly loaded snapshot. Models should rebuild from the + * database in a single batch rather than reacting to per-card adds. + */ + void cardDatabaseReset(); + /** @brief Emitted when the card database fails to load. */ void cardDatabaseLoadingFailed(); diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_cache.cpp b/libcockatrice_card/libcockatrice/card/database/card_database_cache.cpp new file mode 100644 index 000000000..3165ff871 --- /dev/null +++ b/libcockatrice_card/libcockatrice/card/database/card_database_cache.cpp @@ -0,0 +1,525 @@ +#include "card_database_cache.h" + +#include "../card_info.h" +#include "../format/format_legality_rules.h" +#include "../printing/printing_info.h" +#include "../relation/card_relation.h" +#include "../relation/card_relation_type.h" +#include "../set/card_set.h" +#include "card_database_loader.h" + +#include +#include +#include +#include +#include + +namespace +{ +constexpr quint32 CACHE_MAGIC = 0x43445243; // "CDRC" +constexpr quint32 CACHE_VERSION = 3; + +// ---- Primitives ----------------------------------------------------------- + +void writeString(QDataStream &out, const QString &s) +{ + out << s; +} + +QString readString(QDataStream &in) +{ + QString s; + in >> s; + return s; +} + +// Stores a QHash as a single pre-serialized blob. The reader keeps the +// blob as-is and materializes the QHash lazily on first query, which is +// what removes the allocation storm from database load (see card_info.cpp / +// printing_info.cpp). +void writeHashBlob(QDataStream &out, const QHash &h) +{ + QByteArray blob; + QDataStream blobOut(&blob, QIODevice::WriteOnly); + blobOut.setVersion(QDataStream::Qt_6_4); + blobOut << h; + out << static_cast(blob.size()); + out.writeRawData(blob.constData(), blob.size()); +} + +QByteArray readHashBlob(QDataStream &in) +{ + quint32 len = 0; + in >> len; + if (in.status() != QDataStream::Ok || static_cast(len) > in.device()->bytesAvailable()) { + return {}; + } + QByteArray blob(len, Qt::Uninitialized); + in.readRawData(blob.data(), static_cast(len)); + return blob; +} + +void writeDate(QDataStream &out, const QDate &d) +{ + out << d; +} + +QDate readDate(QDataStream &in) +{ + QDate d; + in >> d; + return d; +} + +void writeStringMap(QDataStream &out, const QMap &map) +{ + out << static_cast(map.size()); + for (auto it = map.constBegin(); it != map.constEnd(); ++it) { + writeString(out, it.key()); + writeString(out, it.value()); + } +} + +QMap readStringMap(QDataStream &in) +{ + QMap map; + quint32 count = 0; + in >> count; + if (in.status() != QDataStream::Ok) { + return map; + } + for (quint32 i = 0; i < count; ++i) { + QString key = readString(in); + QString value = readString(in); + if (in.status() != QDataStream::Ok) { + map.clear(); + return map; + } + map.insert(key, value); + } + return map; +} + +// ---- CardRelation ---------------------------------------------------------- + +void writeRelation(QDataStream &out, const CardRelation *rel) +{ + writeString(out, rel->getName()); + out << static_cast(rel->getAttachType()); + out << rel->getIsCreateAllExclusion(); + out << rel->getIsVariable(); + out << rel->getDefaultCount(); + out << rel->getIsPersistent(); + out << rel->getIsFaceDown(); +} + +CardRelation *readRelation(QDataStream &in) +{ + QString name = readString(in); + quint32 attachType = 0; + bool isExclusion = false; + bool isVariable = false; + int defaultCount = 1; + bool isPersistent = false; + bool isFaceDown = false; + in >> attachType; + in >> isExclusion; + in >> isVariable; + in >> defaultCount; + in >> isPersistent; + in >> isFaceDown; + return new CardRelation(name, static_cast(attachType), isExclusion, isVariable, defaultCount, + isPersistent, isFaceDown); +} + +// ---- PrintingInfo ---------------------------------------------------------- + +// A printing references its set by short name; the CardSetPtr is resolved after +// all sets have been reconstructed on read. +void writePrinting(QDataStream &out, const PrintingInfo &p) +{ + writeString(out, p.getSet() ? p.getSet()->getShortName() : QString()); + writeHashBlob(out, p.getPropertiesHash()); +} + +PrintingInfo readPrinting(QDataStream &in, const SetNameMap &sets) +{ + QString setName = readString(in); + QByteArray propsBlob = readHashBlob(in); + auto set = sets.value(setName); + + return PrintingInfo(set, LazyPropertiesHash(propsBlob)); +} + +// ---- CardSet --------------------------------------------------------------- + +void writeSet(QDataStream &out, const CardSetPtr &set) +{ + writeString(out, set->getShortName()); + writeString(out, set->getLongName()); + writeString(out, set->getSetType()); + writeDate(out, set->getReleaseDate()); + out << static_cast(set->getPriority()); +} + +CardSetPtr readSet(QDataStream &in, ICardSetPriorityController *priorityController) +{ + QString shortName = readString(in); + QString longName = readString(in); + QString setType = readString(in); + QDate releaseDate = readDate(in); + quint32 priority = 0; + in >> priority; + return CardSet::newInstance(priorityController, shortName, longName, setType, releaseDate, + static_cast(priority)); +} + +// ---- CardInfo -------------------------------------------------------------- + +void writeCard(QDataStream &out, const CardInfoPtr &card) +{ + writeString(out, card->getName()); + writeString(out, card->getText()); + out << card->getIsToken(); + writeHashBlob(out, card->getPropertiesHash()); + + CardInfo::UiAttributes ui = card->getUiAttributes(); + out << ui.cipt; + out << ui.landscapeOrientation; + out << ui.tableRow; + out << ui.upsideDownArt; + + // Precomputed derived state, so the reader can skip simplifyName() and the + // per-printing alt-name scan entirely. + writeString(out, card->getSimpleName()); + { + const QSet altNames = card->getAltNames(); + out << static_cast(altNames.size()); + for (const QString &alt : altNames) { + writeString(out, alt); + } + } + + // setsToPrintings + const SetToPrintingsMap sets = card->getSets(); + out << static_cast(sets.size()); + for (auto it = sets.constBegin(); it != sets.constEnd(); ++it) { + writeString(out, it.key()); + out << static_cast(it.value().size()); + for (const PrintingInfo &p : it.value()) { + writePrinting(out, p); + } + } + + // related cards + const QList related = card->getRelatedCards(); + out << static_cast(related.size()); + for (const CardRelation *rel : related) { + writeRelation(out, rel); + } + + // reverse-related cards (the reverseRelatedCards list, not the computed 2Me) + const QList reverse = card->getReverseRelatedCards(); + out << static_cast(reverse.size()); + for (const CardRelation *rel : reverse) { + writeRelation(out, rel); + } + + // localized card data + writeStringMap(out, card->getLocalizedNames()); + writeStringMap(out, card->getLocalizedTexts()); +} + +CardInfoPtr readCard(QDataStream &in, const SetNameMap &sets) +{ + QString name = readString(in); + QString text = readString(in); + bool isToken = false; + in >> isToken; + QByteArray propertiesBlob = readHashBlob(in); + + CardInfo::UiAttributes ui; + in >> ui.cipt; + in >> ui.landscapeOrientation; + in >> ui.tableRow; + in >> ui.upsideDownArt; + + // Precomputed derived state, restored directly to skip simplifyName() and the + // per-printing alt-name scan. + QString simpleName = readString(in); + QSet altNames; + quint32 altNameCount = 0; + in >> altNameCount; + if (in.status() != QDataStream::Ok) { + return nullptr; + } + altNames.reserve(altNameCount); + for (quint32 i = 0; i < altNameCount; ++i) { + altNames.insert(readString(in)); + } + + SetToPrintingsMap cardSets; + quint32 setCount = 0; + in >> setCount; + if (in.status() != QDataStream::Ok) { + return nullptr; + } + for (quint32 i = 0; i < setCount; ++i) { + QString setName = readString(in); + quint32 printingCount = 0; + in >> printingCount; + if (in.status() != QDataStream::Ok) { + return nullptr; + } + QList printings; + printings.reserve(printingCount); + for (quint32 j = 0; j < printingCount; ++j) { + printings.append(readPrinting(in, sets)); + } + cardSets.insert(setName, printings); + } + + QList related; + quint32 relatedCount = 0; + in >> relatedCount; + if (in.status() != QDataStream::Ok) { + return nullptr; + } + related.reserve(relatedCount); + for (quint32 i = 0; i < relatedCount; ++i) { + related.append(readRelation(in)); + } + + QList reverse; + quint32 reverseCount = 0; + in >> reverseCount; + if (in.status() != QDataStream::Ok) { + return nullptr; + } + reverse.reserve(reverseCount); + for (quint32 i = 0; i < reverseCount; ++i) { + reverse.append(readRelation(in)); + } + + const QMap localizedNames = readStringMap(in); + if (in.status() != QDataStream::Ok) { + return nullptr; + } + const QMap localizedTexts = readStringMap(in); + if (in.status() != QDataStream::Ok) { + return nullptr; + } + + CardInfoPtr card = CardInfo::newInstance(name, text, isToken, propertiesBlob, related, reverse, cardSets, ui, + simpleName, altNames, false, localizedNames, localizedTexts); + + return card; +} + +// ---- FormatRules ----------------------------------------------------------- + +void writeFormat(QDataStream &out, const FormatRulesPtr &format) +{ + writeString(out, format->formatName); + out << format->minDeckSize; + out << format->maxDeckSize; + out << format->maxSideboardSize; + + out << static_cast(format->allowedCounts.size()); + for (const AllowedCount &ac : format->allowedCounts) { + out << ac.max; + writeString(out, ac.label); + } + + out << static_cast(format->exceptions.size()); + for (const ExceptionRule &ex : format->exceptions) { + out << static_cast(ex.conditions.size()); + for (const CardCondition &cond : ex.conditions) { + writeString(out, cond.field); + writeString(out, cond.matchType); + writeString(out, cond.value); + } + out << ex.maxCopies; + } +} + +FormatRulesPtr readFormat(QDataStream &in) +{ + FormatRulesPtr format(new FormatRules()); + format->formatName = readString(in); + in >> format->minDeckSize; + in >> format->maxDeckSize; + in >> format->maxSideboardSize; + + quint32 allowedCount = 0; + in >> allowedCount; + if (in.status() != QDataStream::Ok) { + return nullptr; + } + for (quint32 i = 0; i < allowedCount; ++i) { + AllowedCount ac; + in >> ac.max; + ac.label = readString(in); + format->allowedCounts.append(ac); + } + + quint32 exceptionCount = 0; + in >> exceptionCount; + if (in.status() != QDataStream::Ok) { + return nullptr; + } + for (quint32 i = 0; i < exceptionCount; ++i) { + ExceptionRule ex; + quint32 condCount = 0; + in >> condCount; + if (in.status() != QDataStream::Ok) { + return nullptr; + } + for (quint32 j = 0; j < condCount; ++j) { + CardCondition cond; + cond.field = readString(in); + cond.matchType = readString(in); + cond.value = readString(in); + ex.conditions.append(cond); + } + in >> ex.maxCopies; + format->exceptions.append(ex); + } + return format; +} + +} // namespace + +bool CardDatabaseCache::write(const QString &cachePath, const CardDatabaseData &data, const QByteArray &sourceHash) +{ + QSaveFile file(cachePath); + if (!file.open(QIODevice::WriteOnly)) { + return false; + } + + QDataStream out(&file); + out.setVersion(QDataStream::Qt_6_4); + + // Header + out << CACHE_MAGIC; + out << CACHE_VERSION; + out << sourceHash; + + // Sets + out << static_cast(data.sets.size()); + for (const CardSetPtr &set : data.sets) { + writeSet(out, set); + } + + // Cards + const quint32 cardCount = static_cast(data.cards.size()); + out << cardCount; + for (const CardInfoPtr &card : data.cards) { + writeCard(out, card); + } + + // Formats + out << static_cast(data.formats.size()); + for (const FormatRulesPtr &format : data.formats) { + writeFormat(out, format); + } + + return file.commit(); +} + +bool CardDatabaseCache::read(const QString &cachePath, + CardDatabaseData &data, + const QByteArray &sourceHash, + ICardSetPriorityController *priorityController) +{ + QFile file(cachePath); + if (!file.open(QIODevice::ReadOnly)) { + return false; + } + + // Read the whole cache into memory up front; deserialization then works on an + // in-memory buffer with no further disk I/O. + QByteArray raw = file.readAll(); + if (raw.isEmpty()) { + return false; + } + + QBuffer buffer(&raw); + buffer.open(QIODevice::ReadOnly); + QDataStream in(&buffer); + in.setVersion(QDataStream::Qt_6_4); + + quint32 magic = 0; + quint32 version = 0; + QByteArray storedHash; + in >> magic >> version >> storedHash; + if (magic != CACHE_MAGIC || version != CACHE_VERSION || storedHash != sourceHash) { + return false; + } + + QElapsedTimer deserializeTimer; + deserializeTimer.start(); + + // Sets + quint32 setCount = 0; + in >> setCount; + if (in.status() != QDataStream::Ok) { + return false; + } + for (quint32 i = 0; i < setCount; ++i) { + CardSetPtr set = readSet(in, priorityController); + data.sets.insert(set->getShortName(), set); + } + + // Cards + quint32 cardCount = 0; + in >> cardCount; + if (in.status() != QDataStream::Ok) { + return false; + } + if (cardCount > 0) { + data.cards.reserve(static_cast(cardCount)); + for (quint32 i = 0; i < cardCount; ++i) { + CardInfoPtr card = readCard(in, data.sets); + if (card == nullptr) { + return false; + } + data.cards.insert(card->getName(), card); + data.simpleNameCards.insert(card->getSimpleName(), card); + } + + for (const CardInfoPtr &card : data.cards) { + for (const auto &printings : card->getSets()) { + for (const PrintingInfo &printing : printings) { + if (auto set = printing.getSet()) { + set->append(card); + break; + } + } + } + } + } + + // Formats + quint32 formatCount = 0; + in >> formatCount; + if (in.status() != QDataStream::Ok) { + return false; + } + for (quint32 i = 0; i < formatCount; ++i) { + FormatRulesPtr format = readFormat(in); + if (format == nullptr) { + return false; + } + data.formats.insert(format->formatName.toLower(), format); + } + + qCInfo(CardDatabaseLoadingLog) << "[cache] read + deserialize" << deserializeTimer.elapsed() << "ms for" + << cardCount << "cards"; + + if (in.status() != QDataStream::Ok) { + return false; + } + + return file.error() == QFile::NoError; +} diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_cache.h b/libcockatrice_card/libcockatrice/card/database/card_database_cache.h new file mode 100644 index 000000000..40ff96541 --- /dev/null +++ b/libcockatrice_card/libcockatrice/card/database/card_database_cache.h @@ -0,0 +1,34 @@ +#ifndef CARDDATABASE_CACHE_H +#define CARDDATABASE_CACHE_H + +#include "card_database_data.h" + +#include +#include + +namespace CardDatabaseCache +{ +/** + * @brief Writes a fully-built database snapshot to a binary cache file. + * @param cachePath Path of the cache file to write. + * @param data The snapshot to serialize. + * @param sourceHash Hash of the source XML used to validate the cache later. + * @return True if the cache was written successfully. + */ +bool write(const QString &cachePath, const CardDatabaseData &data, const QByteArray &sourceHash); + +/** + * @brief Reads a database snapshot from a binary cache file. + * @param cachePath Path of the cache file to read. + * @param data Snapshot to populate. + * @param sourceHash Expected hash of the source XML; read fails if it differs. + * @param priorityController Controller used to reconstruct CardSet state. + * @return True if a valid, up-to-date cache was read. + */ +bool read(const QString &cachePath, + CardDatabaseData &data, + const QByteArray &sourceHash, + ICardSetPriorityController *priorityController); +} // namespace CardDatabaseCache + +#endif // CARDDATABASE_CACHE_H diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_data.h b/libcockatrice_card/libcockatrice/card/database/card_database_data.h new file mode 100644 index 000000000..2ca087b7e --- /dev/null +++ b/libcockatrice_card/libcockatrice/card/database/card_database_data.h @@ -0,0 +1,24 @@ +#ifndef CARDDATABASE_DATA_H +#define CARDDATABASE_DATA_H + +#include + +/** + * @struct CardDatabaseData + * @ingroup CardDatabase + * @brief Value type holding the full parsed state of a card database. + * + * Parsers fill a CardDatabaseData off the UI thread; the finished snapshot is + * then swapped into the live CardDatabase atomically. Keeping this separate from + * the live container avoids emitting per-card change notifications and prevents + * readers from observing a half-loaded database. + */ +struct CardDatabaseData +{ + CardNameMap cards; + CardNameMap simpleNameCards; + SetNameMap sets; + FormatRulesNameMap formats; +}; + +#endif // CARDDATABASE_DATA_H diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_loader.cpp b/libcockatrice_card/libcockatrice/card/database/card_database_loader.cpp index 76abf87ce..47f7eee6d 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database_loader.cpp +++ b/libcockatrice_card/libcockatrice/card/database/card_database_loader.cpp @@ -1,12 +1,18 @@ #include "card_database_loader.h" #include "card_database.h" +#include "card_database_cache.h" +#include "parser/card_database_parser.h" #include "parser/cockatrice_xml_3.h" #include "parser/cockatrice_xml_4.h" +#include +#include +#include #include #include #include +#include #include CardDatabaseLoader::CardDatabaseLoader(QObject *parent, @@ -14,18 +20,14 @@ CardDatabaseLoader::CardDatabaseLoader(QObject *parent, ICardDatabasePathProvider *_pathProvider, ICardPreferenceProvider *_preferenceProvider, ICardSetPriorityController *_priorityController) - : QObject(parent), database(db), pathProvider(_pathProvider) + : QObject(parent), database(db), pathProvider(_pathProvider), priorityController(_priorityController) { - // instantiate available parsers here and connect them to the database + // instantiate available parsers here availableParsers << new CockatriceXml4Parser(_preferenceProvider, _priorityController); availableParsers << new CockatriceXml3Parser(_priorityController); - for (auto *p : availableParsers) { - // connect parser outputs to the database adders - connect(p, &ICardDatabaseParser::addCard, database, &CardDatabase::addCard, Qt::DirectConnection); - connect(p, &ICardDatabaseParser::addSet, database, &CardDatabase::addSet, Qt::DirectConnection); - connect(p, &ICardDatabaseParser::addFormat, database, &CardDatabase::addFormat, Qt::DirectConnection); - } + // The load path parses into a snapshot and never emits per-card signals; + // the finished snapshot is swapped into the live database on the GUI thread. // when SettingsCache's path changes, trigger reloads connect(pathProvider, &ICardDatabasePathProvider::cardDatabasePathChanged, this, @@ -38,7 +40,7 @@ CardDatabaseLoader::~CardDatabaseLoader() availableParsers.clear(); } -LoadStatus CardDatabaseLoader::loadFromFile(const QString &fileName) +LoadStatus CardDatabaseLoader::loadFromFile(const QString &fileName, CardDatabaseData &data) { QFile file(fileName); if (!file.open(QIODevice::ReadOnly)) { @@ -49,7 +51,7 @@ LoadStatus CardDatabaseLoader::loadFromFile(const QString &fileName) file.reset(); if (parser->getCanParseFile(fileName, file)) { file.reset(); - parser->parseFile(file); + parser->parseFileInto(file, data); return Ok; } } @@ -57,24 +59,29 @@ LoadStatus CardDatabaseLoader::loadFromFile(const QString &fileName) return Invalid; } -LoadStatus CardDatabaseLoader::loadCardDatabase(const QString &path) +LoadStatus CardDatabaseLoader::loadCardDatabase(const QString &path, CardDatabaseData &data) { auto startTime = QTime::currentTime(); LoadStatus tempLoadStatus = NotLoaded; if (!path.isEmpty()) { QMutexLocker locker(loadFromFileMutex); - tempLoadStatus = loadFromFile(path); + tempLoadStatus = loadFromFile(path, data); } int msecs = startTime.msecsTo(QTime::currentTime()); qCInfo(CardDatabaseLoadingLog) << "Loaded card database: Path =" << path << "Status =" << tempLoadStatus - << "Cards =" << (database ? database->cards.size() : 0) - << "Sets =" << (database ? database->sets.size() : 0) << QString("%1ms").arg(msecs); + << "Cards =" << data.cards.size() << "Sets =" << data.sets.size() + << QString("%1ms").arg(msecs); return tempLoadStatus; } LoadStatus CardDatabaseLoader::loadCardDatabases() +{ + return doLoadCardDatabases(); +} + +LoadStatus CardDatabaseLoader::doLoadCardDatabases() { QMutexLocker locker(reloadDatabaseMutex); @@ -86,31 +93,55 @@ LoadStatus CardDatabaseLoader::loadCardDatabases() emit loadingStarted(); qCInfo(CardDatabaseLoadingLog) << "Card Database Loading Started"; - database->clear(); // remove old db + ICardDatabaseParser::clearSetlist(); - LoadStatus loadStatus = loadCardDatabase(pathProvider->getCardDatabasePath()); // load main card database - loadCardDatabase(pathProvider->getTokenDatabasePath()); // load tokens database - loadCardDatabase(pathProvider->getSpoilerCardDatabasePath()); // load spoilers database + CardDatabaseData data; + LoadStatus loadStatus = NotLoaded; - // find all custom card databases, recursively & following symlinks - // then load them alphabetically + // Try the binary cache first: a cache hit avoids re-parsing the (large) XML. const QStringList customPaths = collectCustomDatabasePaths(); - for (int i = 0; i < customPaths.size(); ++i) { - const auto &p = customPaths.at(i); - qCInfo(CardDatabaseLoadingLog) << "Loading Custom Set" << i << "(" << p << ")"; - loadCardDatabase(p); + const QByteArray sourceHash = computeSourceHash(customPaths); + if (loadFromCache(data, sourceHash)) { + qCInfo(CardDatabaseLoadingLog) << "Loaded card database from binary cache"; + loadStatus = Ok; + } else { + loadStatus = loadCardDatabase(pathProvider->getCardDatabasePath(), data); // main card database + if (loadStatus == Ok) { + loadCardDatabase(pathProvider->getTokenDatabasePath(), data); // tokens database + loadCardDatabase(pathProvider->getSpoilerCardDatabasePath(), data); // spoilers database + + // find all custom card databases, recursively & following symlinks + // then load them alphabetically + for (int i = 0; i < customPaths.size(); ++i) { + const auto &p = customPaths.at(i); + qCInfo(CardDatabaseLoadingLog) << "Loading Custom Set" << i << "(" << p << ")"; + loadCardDatabase(p, data); + } + + if (!saveToCache(data, sourceHash)) { + qCWarning(CardDatabaseLoadingLog) << "Failed to write binary cache to" << cachePath(); + } + } } - // AFTER all the cards have been loaded - - // resolve the reverse-related tags - - database->refreshCachedReverseRelatedCards(); - + // AFTER all the cards have been loaded: resolve the reverse-related tags + // against the fully-built snapshot. if (loadStatus == Ok) { - database->checkUnknownSets(); // update deck editors, etc + database->refreshCachedReverseRelatedCards(data.cards); + qCInfo(CardDatabaseLoadingSuccessOrFailureLog) << "Card Database Loading Success"; + emit databaseDataReady(std::move(data)); emit loadingFinished(); + // During the front-loaded parse in main() this runs before MainWindow + // exists, so cardDatabaseNewSetsFound / cardDatabaseAllNewSetsEnabled + // would be emitted with no receivers. Skip the check on the first load; + // MainWindow::startupConfigCheck() calls checkUnknownSets() once its + // signal connections are live. On subsequent reloads (e.g. path change) + // MainWindow exists and signals have live receivers. + if (initialLoadComplete) { + database->checkUnknownSets(); + } + initialLoadComplete = true; } else { qCInfo(CardDatabaseLoadingSuccessOrFailureLog) << "Card Database Loading Failed"; emit loadingFailed(); // bring up the settings dialog @@ -119,6 +150,55 @@ LoadStatus CardDatabaseLoader::loadCardDatabases() return loadStatus; } +QString CardDatabaseLoader::cachePath() const +{ + return pathProvider->getCardDatabasePath() + ".cache"; +} + +QByteArray CardDatabaseLoader::computeSourceHash(const QStringList &customPaths) const +{ + // Hash over the paths, sizes and modification times of every input file so + // the cache invalidates when any source changes. Cheap (no content read). + QCryptographicHash hash(QCryptographicHash::Sha256); + + // Include the application version so parser changes automatically invalidate + // old caches even when the XML files are byte-identical. + hash.addData(QCoreApplication::applicationVersion().toUtf8()); + hash.addData(QByteArray(1, '\0')); + + const QStringList inputs = QStringList() + << pathProvider->getCardDatabasePath() << pathProvider->getTokenDatabasePath() + << pathProvider->getSpoilerCardDatabasePath() << customPaths; + for (const QString &path : inputs) { + QFileInfo info(path); + if (info.exists()) { + hash.addData(path.toUtf8()); + hash.addData(QByteArray(1, '\0')); + hash.addData(QByteArray::number(info.size())); + hash.addData(QByteArray(1, '\0')); + hash.addData(QByteArray::number(info.lastModified().toSecsSinceEpoch())); + hash.addData(QByteArray(1, '\0')); + } + } + return hash.result(); +} + +bool CardDatabaseLoader::loadFromCache(CardDatabaseData &data, const QByteArray &sourceHash) +{ + if (sourceHash.isEmpty()) { + return false; + } + return CardDatabaseCache::read(cachePath(), data, sourceHash, priorityController); +} + +bool CardDatabaseLoader::saveToCache(const CardDatabaseData &data, const QByteArray &sourceHash) +{ + if (sourceHash.isEmpty()) { + return false; + } + return CardDatabaseCache::write(cachePath(), data, sourceHash); +} + QStringList CardDatabaseLoader::collectCustomDatabasePaths() const { QDirIterator it(pathProvider->getCustomCardDatabasePath(), {"*.xml"}, QDir::Files, diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_loader.h b/libcockatrice_card/libcockatrice/card/database/card_database_loader.h index 861cc95b0..6dcb06bf2 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database_loader.h +++ b/libcockatrice_card/libcockatrice/card/database/card_database_loader.h @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -62,16 +63,20 @@ public: public slots: /** * @brief Loads all configured card databases. + * + * Runs synchronously on the calling thread. The caller should ensure + * that any signal receivers are already connected before invoking this. * @return Status of the main database load. */ LoadStatus loadCardDatabases(); /** - * @brief Loads a single card database file. + * @brief Loads a single card database file into the given snapshot. * @param path Path to the database file. + * @param data Snapshot to populate. * @return LoadStatus indicating success or failure. */ - LoadStatus loadCardDatabase(const QString &path); + LoadStatus loadCardDatabase(const QString &path, CardDatabaseData &data); /** * @brief Saves custom tokens to the user-defined custom database path. @@ -89,6 +94,12 @@ signals: /** @brief Emitted when loading fails. */ void loadingFailed(); + /** + * @brief Emitted with the fully-built snapshot once background loading + * completes. The receiver swaps it into the live database. + */ + void databaseDataReady(CardDatabaseData data); + /** * @brief Emitted when new sets are discovered during loading. * @param numSets Number of new sets. @@ -101,11 +112,18 @@ signals: private: /** - * @brief Loads a database from a single file using the available parsers. + * @brief Parses a single file into the given snapshot using the available parsers. * @param fileName Path to the database file. + * @param data Snapshot to populate. * @return LoadStatus indicating success or failure. */ - LoadStatus loadFromFile(const QString &fileName); + LoadStatus loadFromFile(const QString &fileName, CardDatabaseData &data); + + /** + * @brief Performs the actual load work synchronously on the calling thread. + * @return Status of the main database load. + */ + LoadStatus doLoadCardDatabases(); /** * @brief Collects custom card database paths recursively. @@ -113,13 +131,43 @@ private: */ [[nodiscard]] QStringList collectCustomDatabasePaths() const; + /** + * @brief Computes a hash identifying the current set of input database files. + * @return Hash over the paths, sizes and modification times of all inputs. + */ + [[nodiscard]] QByteArray computeSourceHash(const QStringList &customPaths) const; + + /** + * @brief Path of the binary cache file derived from the main card database path. + * @return Cache file path. + */ + [[nodiscard]] QString cachePath() const; + + /** + * @brief Attempts to populate the snapshot from the binary cache. + * @param data Snapshot to populate. + * @param sourceHash Pre-computed hash identifying the current input files. + * @return True if a valid, up-to-date cache was read. + */ + bool loadFromCache(CardDatabaseData &data, const QByteArray &sourceHash); + + /** + * @brief Writes the snapshot to the binary cache for the current inputs. + * @param data Snapshot to serialize. + * @param sourceHash Pre-computed hash identifying the current input files. + * @return True if the cache was written successfully. + */ + bool saveToCache(const CardDatabaseData &data, const QByteArray &sourceHash); + private: - CardDatabase *database; /**< Non-owning pointer to the target CardDatabase. */ - ICardDatabasePathProvider *pathProvider; /**< Pointer to the path provider. */ - QList availableParsers; /**< List of available parsers for different formats. */ + CardDatabase *database; /**< Non-owning pointer to the target CardDatabase. */ + ICardDatabasePathProvider *pathProvider; /**< Pointer to the path provider. */ + ICardSetPriorityController *priorityController; /**< Controller for reconstructing set state. */ + QList availableParsers; /**< List of available parsers for different formats. */ QBasicMutex *loadFromFileMutex = new QBasicMutex(); /**< Mutex for single-file loading. */ QBasicMutex *reloadDatabaseMutex = new QBasicMutex(); /**< Mutex for reloading entire database. */ + bool initialLoadComplete = false; /**< Set after the first successful load. */ }; #endif // COCKATRICE_CARD_DATABASE_LOADER_H diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_querier.cpp b/libcockatrice_card/libcockatrice/card/database/card_database_querier.cpp index 174943333..5b9c5a4b5 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database_querier.cpp +++ b/libcockatrice_card/libcockatrice/card/database/card_database_querier.cpp @@ -12,6 +12,17 @@ CardDatabaseQuerier::CardDatabaseQuerier(QObject *_parent, const ICardPreferenceProvider *prefs) : QObject(_parent), db(_db), prefs(prefs) { + // Invalidate the cached count maps whenever the database contents change. + connect(db, &CardDatabase::cardAdded, this, &CardDatabaseQuerier::invalidateCaches); + connect(db, &CardDatabase::cardRemoved, this, &CardDatabaseQuerier::invalidateCaches); + connect(db, &CardDatabase::cardDatabaseReset, this, &CardDatabaseQuerier::invalidateCaches); +} + +void CardDatabaseQuerier::invalidateCaches() +{ + mainCardTypeCountsCache.clear(); + subCardTypeCountsCache.clear(); + formatsCountCache.clear(); } /** @@ -304,44 +315,43 @@ QString CardDatabaseQuerier::getPreferredPrintingProviderId(const QString &cardN QStringList CardDatabaseQuerier::getAllMainCardTypes() const { - QSet types; - for (const auto &card : db->cards.values()) { - types.insert(card->getMainCardType()); - } - return types.values(); + return getAllMainCardTypesWithCount().keys(); } QMap CardDatabaseQuerier::getAllMainCardTypesWithCount() const { - QMap typeCounts; - - for (const auto &card : db->cards.values()) { - QString type = card->getMainCardType(); - typeCounts[type]++; + // An empty cache is always recomputed correctly: a database with no cards + // produces an empty map, so the cache is only ever empty when it needs a + // (trivially cheap) rebuild. + if (mainCardTypeCountsCache.isEmpty()) { + for (const auto &card : db->cards.values()) { + QString type = card->getMainCardType(); + mainCardTypeCountsCache[type]++; + } } - return typeCounts; + return mainCardTypeCountsCache; } QMap CardDatabaseQuerier::getAllSubCardTypesWithCount() const { - QMap typeCounts; + if (subCardTypeCountsCache.isEmpty()) { + for (const auto &card : db->cards.values()) { + QString type = card->getCardType(); - for (const auto &card : db->cards.values()) { - QString type = card->getCardType(); + QStringList parts = type.split(" — "); - QStringList parts = type.split(" — "); + if (parts.size() > 1) { // Ensure there are subtypes + QStringList subtypes = parts[1].split(" ", Qt::SkipEmptyParts); - if (parts.size() > 1) { // Ensure there are subtypes - QStringList subtypes = parts[1].split(" ", Qt::SkipEmptyParts); - - for (const QString &subtype : subtypes) { - typeCounts[subtype]++; + for (const QString &subtype : subtypes) { + subCardTypeCountsCache[subtype]++; + } } } } - return typeCounts; + return subCardTypeCountsCache; } FormatRulesPtr CardDatabaseQuerier::getFormat(const QString &formatName) const @@ -351,18 +361,18 @@ FormatRulesPtr CardDatabaseQuerier::getFormat(const QString &formatName) const QMap CardDatabaseQuerier::getAllFormatsWithCount() const { - QMap formatCounts; + if (formatsCountCache.isEmpty()) { + for (const auto &card : db->cards.values()) { + QStringList allProps = card->getProperties(); - for (const auto &card : db->cards.values()) { - QStringList allProps = card->getProperties(); - - for (const QString &prop : allProps) { - if (prop.startsWith("format-")) { - QString formatName = prop.mid(QStringLiteral("format-").size()); - formatCounts[formatName]++; + for (const QString &prop : allProps) { + if (prop.startsWith("format-")) { + QString formatName = prop.mid(QStringLiteral("format-").size()); + formatsCountCache[formatName]++; + } } } } - return formatCounts; + return formatsCountCache; } diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_querier.h b/libcockatrice_card/libcockatrice/card/database/card_database_querier.h index ff8d7958b..f195a8170 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database_querier.h +++ b/libcockatrice_card/libcockatrice/card/database/card_database_querier.h @@ -220,6 +220,16 @@ public: private: const CardDatabase *db; //!< Card database used for all lookups. const ICardPreferenceProvider *prefs; //!< Preference provider for preferred printings. + + // Count maps are expensive to compute (they iterate the whole database) and are + // queried every time a filter widget is built, so cache them and invalidate on + // any database mutation. Only the main thread reads or writes these. + mutable QMap mainCardTypeCountsCache; + mutable QMap subCardTypeCountsCache; + mutable QMap formatsCountCache; + +private slots: + void invalidateCaches(); }; #endif // COCKATRICE_CARD_DATABASE_QUERIER_H diff --git a/libcockatrice_card/libcockatrice/card/database/parser/card_database_parser.cpp b/libcockatrice_card/libcockatrice/card/database/parser/card_database_parser.cpp index f7e6bbfcf..00ed9377d 100644 --- a/libcockatrice_card/libcockatrice/card/database/parser/card_database_parser.cpp +++ b/libcockatrice_card/libcockatrice/card/database/parser/card_database_parser.cpp @@ -30,6 +30,10 @@ CardSetPtr ICardDatabaseParser::internalAddSet(const QString &setName, newSet->setPriority(priority); sets.insert(setName, newSet); - emit addSet(newSet); + if (targetData) { + targetData->sets.insert(setName, newSet); + } else { + emit addSet(newSet); + } return newSet; } diff --git a/libcockatrice_card/libcockatrice/card/database/parser/card_database_parser.h b/libcockatrice_card/libcockatrice/card/database/parser/card_database_parser.h index a8eceab5a..6d9c91260 100644 --- a/libcockatrice_card/libcockatrice/card/database/parser/card_database_parser.h +++ b/libcockatrice_card/libcockatrice/card/database/parser/card_database_parser.h @@ -2,6 +2,7 @@ #define CARDDATABASE_PARSER_H #include "../../card_info.h" +#include "../card_database_data.h" #include #include @@ -37,6 +38,15 @@ public: */ virtual void parseFile(QIODevice &device) = 0; + /** + * @brief Parses a database file into the given snapshot, without emitting + * any signals. Used for background loads that swap the snapshot into + * the live database once complete. + * @param device QIODevice representing the file content. + * @param data Target snapshot to populate. + */ + virtual void parseFileInto(QIODevice &device, CardDatabaseData &data) = 0; + /** * @brief Saves card and set data to a file. * @param _formats @@ -62,6 +72,21 @@ protected: static SetNameMap sets; ICardSetPriorityController *cardSetPriorityController; + /** + * @brief Snapshot the current parse is filling, or nullptr when emitting signals. + * + * This is an implicit-inheritance-via-member-variable pattern: parseFileInto() + * sets this before delegating to parseFile(), and loadCardsFromXml() / + * loadFormats() check it to decide between direct insertion and signal + * emission. Safe under the current model because loadFromFileMutex serialises + * all parser access and parseFile() is never re-entered. If a future change + * adds parallel parsing or a second parseFileInto() call within parseFile(), + * this pointer would race -- at that point refactor to pass CardDatabaseData* + * through the call chain instead. However, we are mostly RAM, not CPU bound on + * DB startup at this point so there's not much point to parallel parsing. + */ + CardDatabaseData *targetData = nullptr; + /** * @brief Internal helper to add a set to the global set cache. * @param setName Short set name. diff --git a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp index b6c3afc57..64202ab21 100644 --- a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp +++ b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp @@ -79,6 +79,13 @@ void CockatriceXml3Parser::parseFile(QIODevice &device) } } +void CockatriceXml3Parser::parseFileInto(QIODevice &device, CardDatabaseData &data) +{ + targetData = &data; + parseFile(device); + targetData = nullptr; +} + void CockatriceXml3Parser::loadSetsFromXml(QXmlStreamReader &xml) { while (!xml.atEnd()) { @@ -165,7 +172,7 @@ void CockatriceXml3Parser::loadCardsFromXml(QXmlStreamReader &xml) if (xmlName == "card") { QString name = QString(""); QString text = QString(""); - QVariantHash properties = QVariantHash(); + QHash properties; QString colors = QString(""); QList relatedCards, reverseRelatedCards; auto _sets = SetToPrintingsMap(); @@ -221,26 +228,27 @@ void CockatriceXml3Parser::loadCardsFromXml(QXmlStreamReader &xml) // Only load printings from sets the user has enabled, matching the v4 loader's // behaviour. Without this check, disabling a set has no effect on v3 databases. if (set->getEnabled()) { - PrintingInfo setInfo(set); + QHash printingProps; if (attrs.hasAttribute("muId")) { - setInfo.setProperty("muid", attrs.value("muId").toString()); + printingProps.insert("muid", attrs.value("muId").toString()); } if (attrs.hasAttribute("uuId")) { - setInfo.setProperty("uuid", attrs.value("uuId").toString()); + printingProps.insert("uuid", attrs.value("uuId").toString()); } if (attrs.hasAttribute("picURL")) { - setInfo.setProperty("picurl", attrs.value("picURL").toString()); + printingProps.insert("picurl", attrs.value("picURL").toString()); } if (attrs.hasAttribute("num")) { - setInfo.setProperty("num", attrs.value("num").toString()); + printingProps.insert("num", attrs.value("num").toString()); } if (attrs.hasAttribute("rarity")) { - setInfo.setProperty("rarity", attrs.value("rarity").toString()); + printingProps.insert("rarity", attrs.value("rarity").toString()); } + PrintingInfo setInfo(set, LazyPropertiesHash(printingProps)); _sets[setName].append(setInfo); } // related cards @@ -299,7 +307,20 @@ void CockatriceXml3Parser::loadCardsFromXml(QXmlStreamReader &xml) .upsideDownArt = upsideDown}; CardInfoPtr newCard = CardInfo::newInstance(name, text, isToken, properties, relatedCards, reverseRelatedCards, _sets, attributes); - emit addCard(newCard); + if (targetData) { + if (auto existing = targetData->cards.value(name)) { + for (const auto &printings : newCard->getSets()) { + for (const auto &printing : printings) { + existing->addToSet(printing.getSet(), printing); + } + } + } else { + targetData->cards.insert(name, newCard); + targetData->simpleNameCards.insert(newCard->getSimpleName(), newCard); + } + } else { + emit addCard(newCard); + } } } } diff --git a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.h b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.h index a01b705aa..83b736257 100644 --- a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.h +++ b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.h @@ -43,6 +43,13 @@ public: */ void parseFile(QIODevice &device) override; + /** + * @brief Parse the XML database into the given snapshot. + * @param device Open QIODevice positioned at start of file. + * @param data Target snapshot to populate. + */ + void parseFileInto(QIODevice &device, CardDatabaseData &data) override; + /** * @brief Save sets and cards back to an XML3 file. */ diff --git a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp index 96a5ac104..19e972a7a 100644 --- a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp +++ b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp @@ -82,6 +82,13 @@ void CockatriceXml4Parser::parseFile(QIODevice &device) } } +void CockatriceXml4Parser::parseFileInto(QIODevice &device, CardDatabaseData &data) +{ + targetData = &data; + parseFile(device); + targetData = nullptr; +} + static QSharedPointer parseFormat(QXmlStreamReader &xml) { auto rulesPtr = FormatRulesPtr(new FormatRules()); @@ -187,7 +194,11 @@ void CockatriceXml4Parser::loadFormats(QXmlStreamReader &xml) if (xml.name().toString() == "format") { auto rulesPtr = parseFormat(xml); - emit addFormat(rulesPtr); + if (targetData) { + targetData->formats.insert(rulesPtr->formatName.toLower(), rulesPtr); + } else { + emit addFormat(rulesPtr); + } } } } @@ -232,9 +243,9 @@ void CockatriceXml4Parser::loadSetsFromXml(QXmlStreamReader &xml) } } -QVariantHash CockatriceXml4Parser::loadCardPropertiesFromXml(QXmlStreamReader &xml) +QHash CockatriceXml4Parser::loadCardPropertiesFromXml(QXmlStreamReader &xml) { - QVariantHash properties = QVariantHash(); + QHash properties; while (!xml.atEnd()) { if (xml.readNext() == QXmlStreamReader::EndElement) { break; @@ -261,7 +272,9 @@ void CockatriceXml4Parser::loadCardsFromXml(QXmlStreamReader &xml) if (xmlName == "card") { QString name = QString(""); QString text = QString(""); - QVariantHash properties = QVariantHash(); + QHash properties; + QMap localizedNames; + QMap localizedTexts; QList relatedCards, reverseRelatedCards; auto _sets = SetToPrintingsMap(); int tableRow = 0; @@ -287,6 +300,44 @@ void CockatriceXml4Parser::loadCardsFromXml(QXmlStreamReader &xml) // generic properties } else if (xmlName == "prop") { properties = loadCardPropertiesFromXml(xml); + // localized card data + } else if (xmlName == "localizations") { + while (!xml.atEnd()) { + if (xml.readNextStartElement()) { + const QString elementName = xml.name().toString(); + if (elementName == "localization") { + const QString lang = xml.attributes().value("lang").toString(); + QString localizedName; + QString localizedText; + while (!xml.atEnd()) { + if (xml.readNext() == QXmlStreamReader::EndElement) { + break; + } + if (xml.isStartElement()) { + const QString childName = xml.name().toString(); + QString value = xml.readElementText(QXmlStreamReader::IncludeChildElements); + if (childName == "name") { + localizedName = value; + } else if (childName == "text") { + localizedText = value; + } + } + } + if (!lang.isEmpty()) { + if (!localizedName.isEmpty()) { + localizedNames.insert(lang, localizedName); + } + if (!localizedText.isEmpty()) { + localizedTexts.insert(lang, localizedText); + } + } + } else { + xml.skipCurrentElement(); + } + } else { + break; + } + } // positioning info } else if (xmlName == "tablerow") { tableRow = xml.readElementText(QXmlStreamReader::IncludeChildElements).toInt(); @@ -303,14 +354,15 @@ void CockatriceXml4Parser::loadCardsFromXml(QXmlStreamReader &xml) QString setName = xml.readElementText(QXmlStreamReader::IncludeChildElements); auto set = internalAddSet(setName); if (set->getEnabled()) { - PrintingInfo printingInfo(set); + QHash printingProps; for (QXmlStreamAttribute attr : attrs) { QString attrName = attr.name().toString(); if (attrName == "picURL") { attrName = "picurl"; } - printingInfo.setProperty(attrName, attr.value().toString()); + printingProps.insert(attrName, attr.value().toString()); } + PrintingInfo printingInfo(set, LazyPropertiesHash(printingProps)); // This is very much a hack and not the right place to // put this check, as it requires a reload of Cockatrice @@ -329,6 +381,7 @@ void CockatriceXml4Parser::loadCardsFromXml(QXmlStreamReader &xml) bool exclude = false; bool variable = false; bool persistent = false; + bool facedown = false; int count = 1; QXmlStreamAttributes attrs = xml.attributes(); QString cardName = xml.readElementText(QXmlStreamReader::IncludeChildElements); @@ -360,7 +413,12 @@ void CockatriceXml4Parser::loadCardsFromXml(QXmlStreamReader &xml) persistent = true; } - auto *relation = new CardRelation(cardName, attachType, exclude, variable, count, persistent); + if (attrs.hasAttribute("facedown")) { + facedown = true; + } + + auto *relation = + new CardRelation(cardName, attachType, exclude, variable, count, persistent, facedown); if (xmlName == "reverse-related") { reverseRelatedCards << relation; } else { @@ -381,9 +439,25 @@ void CockatriceXml4Parser::loadCardsFromXml(QXmlStreamReader &xml) .landscapeOrientation = landscapeOrientation, .tableRow = tableRow, .upsideDownArt = upsideDown}; - CardInfoPtr newCard = CardInfo::newInstance(name, text, isToken, properties, relatedCards, - reverseRelatedCards, _sets, attributes); - emit addCard(newCard); + CardInfoPtr newCard = + CardInfo::newInstance(name, text, isToken, properties, relatedCards, reverseRelatedCards, _sets, + attributes, std::move(localizedNames), std::move(localizedTexts)); + if (targetData) { + // Mirror CardDatabase::addCard: if a card with this name already + // exists, merge the new printings into it instead of replacing. + if (auto existing = targetData->cards.value(name)) { + for (const auto &printings : newCard->getSets()) { + for (const auto &printing : printings) { + existing->addToSet(printing.getSet(), printing); + } + } + } else { + targetData->cards.insert(name, newCard); + targetData->simpleNameCards.insert(newCard->getSimpleName(), newCard); + } + } else { + emit addCard(newCard); + } } } } @@ -484,6 +558,26 @@ static QXmlStreamWriter &operator<<(QXmlStreamWriter &xml, const CardInfoPtr &in } xml.writeEndElement(); + // localized card data + const QStringList localizedLanguages = info->localizationLanguages(); + if (!localizedLanguages.isEmpty()) { + xml.writeStartElement("localizations"); + const QMap &localizedNames = info->getLocalizedNames(); + const QMap &localizedTexts = info->getLocalizedTexts(); + for (const QString &lang : localizedLanguages) { + xml.writeStartElement("localization"); + xml.writeAttribute("lang", lang); + if (localizedNames.contains(lang)) { + xml.writeTextElement("name", localizedNames.value(lang)); + } + if (localizedTexts.contains(lang)) { + xml.writeTextElement("text", localizedTexts.value(lang)); + } + xml.writeEndElement(); + } + xml.writeEndElement(); + } + // sets for (const auto &printings : info->getSets()) { for (const PrintingInfo &set : printings) { @@ -510,6 +604,9 @@ static QXmlStreamWriter &operator<<(QXmlStreamWriter &xml, const CardInfoPtr &in if (i->getIsPersistent()) { xml.writeAttribute("persistent", "persistent"); } + if (i->getIsFaceDown()) { + xml.writeAttribute("facedown", "facedown"); + } if (i->getIsVariable()) { if (1 == i->getDefaultCount()) { xml.writeAttribute("count", "x"); diff --git a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.h b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.h index 189e79535..55f7c5a3b 100644 --- a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.h +++ b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.h @@ -19,7 +19,7 @@ inline Q_LOGGING_CATEGORY(CockatriceXml4Log, "cockatrice_xml.xml_4_parser"); * making the parser more extensible and schema-compliant. * * @note Differences from v3: - * - Card properties are stored in blocks as a QVariantHash. + * - Card properties are stored in blocks as a QHash. * - Sets can include a element. * - Supports user preferences via ICardPreferenceProvider (e.g., skipping rebalanced cards). * - Related cards support persistent relations and multiple attach types (e.g., transform). @@ -47,6 +47,13 @@ public: */ void parseFile(QIODevice &device) override; + /** + * @brief Parse the XML database into the given snapshot. + * @param device Open QIODevice positioned at start of file. + * @param data Target snapshot to populate. + */ + void parseFileInto(QIODevice &device, CardDatabaseData &data) override; + /** * @brief Save sets and cards back to an XML4 file. */ @@ -63,9 +70,9 @@ private: /** * @brief Loads a generic block from a element. * @param xml The open QXmlStreamReader positioned at a element. - * @return A QVariantHash mapping property names to values. + * @return A QHash mapping property names to values. */ - QVariantHash loadCardPropertiesFromXml(QXmlStreamReader &xml); + QHash loadCardPropertiesFromXml(QXmlStreamReader &xml); /** * @brief Load all elements from the XML stream. diff --git a/libcockatrice_card/libcockatrice/card/lazy_properties_hash.cpp b/libcockatrice_card/libcockatrice/card/lazy_properties_hash.cpp new file mode 100644 index 000000000..4d903a3bd --- /dev/null +++ b/libcockatrice_card/libcockatrice/card/lazy_properties_hash.cpp @@ -0,0 +1,95 @@ +#include "lazy_properties_hash.h" + +#include + +LazyPropertiesHash::LazyPropertiesHash() : isMaterialized(true) +{ +} + +LazyPropertiesHash::LazyPropertiesHash(const QByteArray &blob) : blob(blob) +{ +} + +LazyPropertiesHash::LazyPropertiesHash(const QHash &properties) + : properties(properties), isMaterialized(true) +{ +} + +LazyPropertiesHash::LazyPropertiesHash(const LazyPropertiesHash &other) +{ + // since we do not allow dematerialization, we only need to lock if not materialized yet + if (other.isMaterialized) { + blob = other.blob; + properties = other.properties; + isMaterialized = true; + } else { + QMutexLocker lock(&other.propertiesMutex); + blob = other.blob; + properties = other.properties; + isMaterialized = false; + } +} + +LazyPropertiesHash &LazyPropertiesHash::operator=(const LazyPropertiesHash &other) +{ + if (this == &other) { + return *this; + } + + // since we do not allow dematerialization, we only need to lock if not materialized yet + if (other.isMaterialized) { + blob = other.blob; + properties = other.properties; + isMaterialized = true; + } else { + QMutexLocker lock(&other.propertiesMutex); + blob = other.blob; + properties = other.properties; + isMaterialized = false; + } + + return *this; +} + +void LazyPropertiesHash::ensureMaterialized() const +{ + QMutexLocker lock(&propertiesMutex); + + if (isMaterialized) { + return; + } + + if (!blob.isEmpty()) { + QDataStream in(blob); + in.setVersion(QDataStream::Qt_6_4); + in >> properties; + } + + blob.clear(); + + isMaterialized = true; +} + +QString LazyPropertiesHash::value(const QString &key) const +{ + ensureMaterialized(); + return properties.value(key); +} + +bool LazyPropertiesHash::insert(const QString &key, const QString &value) +{ + ensureMaterialized(); + + if (value == properties.value(key)) { + return false; + } + + properties.insert(key, value); + return true; +} + +const QHash &LazyPropertiesHash::getProperties() const +{ + ensureMaterialized(); + return properties; +} diff --git a/libcockatrice_card/libcockatrice/card/lazy_properties_hash.h b/libcockatrice_card/libcockatrice/card/lazy_properties_hash.h new file mode 100644 index 000000000..0ed8c215b --- /dev/null +++ b/libcockatrice_card/libcockatrice/card/lazy_properties_hash.h @@ -0,0 +1,74 @@ +#ifndef COCKATRICE_LAZY_PROPERTIES_HASH_H +#define COCKATRICE_LAZY_PROPERTIES_HASH_H + +#include +#include + +/** + * @brief A property map that can lazily deserialize blobs to avoid loading overhead. + * + * Properties are stored as a pre-serialized blob (cheap to load), and the QString is materialized on the first query, + * so the database load avoids constructing thousands of QString per card. + * + * Once the properties are materialized, it cannot be unmaterialized. + * If you want to reset the properties to an unmaterialized state, you should create a new LazyPropertiesHash. + */ +class LazyPropertiesHash +{ + + mutable QByteArray blob; ///< Serialized properties (load form). + mutable QHash properties; ///< Materialized properties (query form). + mutable QMutex propertiesMutex; ///< Guards lazy materialization. + mutable bool isMaterialized = false; ///< Whether propertiesCache is valid. + + /** + * @brief Materializes properties from blob if not already done. Clears blob afterward. + * Safe to call from const getters (members are mutable). + */ + void ensureMaterialized() const; + +public: + /** + * @brief Default constructor. + */ + LazyPropertiesHash(); + + /** + * @brief Creates an unmaterialized LazyPropertiesHash + * @param blob The pre-serialized blob + */ + explicit LazyPropertiesHash(const QByteArray &blob); + + /** + * @brief Creates an already-materialized LazyPropertiesHash + * @param properties The properties + */ + explicit LazyPropertiesHash(const QHash &properties); + + // Override copy constructor and copy-assignment because mutex isn't copiable + LazyPropertiesHash(const LazyPropertiesHash &other); + LazyPropertiesHash &operator=(const LazyPropertiesHash &other); + + /** + * @brief Gets the value from the materialized properties hash + * @param key The key + * @return The value, or an empty string if the key is not present + */ + QString value(const QString &key) const; + + /** + * @brief Inserts a value into the materialized properties hash + * @param key The key + * @param value The value to insert + * @return True if a new value was inserted; false if the new value is the same as the existing value + */ + bool insert(const QString &key, const QString &value); + + /** + * @brief Gets a view of the materialized properties hash. + * @return The properties hash + */ + const QHash &getProperties() const; +}; + +#endif // COCKATRICE_LAZY_PROPERTIES_HASH_H diff --git a/libcockatrice_card/libcockatrice/card/printing/printing_info.cpp b/libcockatrice_card/libcockatrice/card/printing/printing_info.cpp index 340998565..3d185583a 100644 --- a/libcockatrice_card/libcockatrice/card/printing/printing_info.cpp +++ b/libcockatrice_card/libcockatrice/card/printing/printing_info.cpp @@ -2,19 +2,28 @@ #include "../set/card_set.h" -PrintingInfo::PrintingInfo(const CardSetPtr &_set) : set(_set) +#include +#include + +PrintingInfo::PrintingInfo(const CardSetPtr &_set, const LazyPropertiesHash &_properties) + : set(_set), properties(_properties) { } +void PrintingInfo::setProperty(const QString &_name, const QString &_value) +{ + properties.insert(_name, _value); +} + /** * Gets the uuid property of the printing, or an empty string if the property isn't present */ QString PrintingInfo::getUuid() const { - return properties.value("uuid").toString(); + return getPropertiesHash().value("uuid"); } QString PrintingInfo::getFlavorName() const { - return properties.value("flavorName").toString(); -} \ No newline at end of file + return getPropertiesHash().value("flavorName"); +} diff --git a/libcockatrice_card/libcockatrice/card/printing/printing_info.h b/libcockatrice_card/libcockatrice/card/printing/printing_info.h index ad7b33654..974c7bb95 100644 --- a/libcockatrice_card/libcockatrice/card/printing/printing_info.h +++ b/libcockatrice_card/libcockatrice/card/printing/printing_info.h @@ -2,10 +2,11 @@ #define COCKATRICE_PRINTING_INFO_H #include "../set/card_set.h" +#include "libcockatrice/card/lazy_properties_hash.h" #include #include -#include +#include class PrintingInfo; @@ -30,8 +31,9 @@ public: * @brief Constructs a PrintingInfo associated with a specific set. * * @param _set The set this printing belongs to (defaults to null). + * @param _properties The printing properties (defaults to empty) */ - explicit PrintingInfo(const CardSetPtr &_set = nullptr); + explicit PrintingInfo(const CardSetPtr &_set = nullptr, const LazyPropertiesHash &_properties = {}); /** * @brief Destroys the PrintingInfo. @@ -51,7 +53,7 @@ public: */ bool operator==(const PrintingInfo &other) const { - return this->set == other.set && this->properties == other.properties; + return this->set == other.set && this->getPropertiesHash() == other.getPropertiesHash(); } /** @@ -61,12 +63,12 @@ public: */ bool isEmpty() const { - return set == nullptr && properties.isEmpty(); + return set == nullptr && getPropertiesHash().isEmpty(); } private: - CardSetPtr set; ///< The set this variation belongs to. - QVariantHash properties; ///< Key-value store for variation-specific attributes. + CardSetPtr set; ///< The set this variation belongs to. + LazyPropertiesHash properties; ///< Key-value store for variation-specific attributes. public: /** @@ -86,7 +88,12 @@ public: */ [[nodiscard]] QStringList getProperties() const { - return properties.keys(); + return getPropertiesHash().keys(); + } + + [[nodiscard]] const QHash &getPropertiesHash() const + { + return properties.getProperties(); } /** @@ -97,7 +104,7 @@ public: */ [[nodiscard]] QString getProperty(const QString &propertyName) const { - return properties.value(propertyName).toString(); + return properties.value(propertyName); } /** @@ -108,10 +115,7 @@ public: * @param _name The name of the property. * @param _value The string value to assign. */ - void setProperty(const QString &_name, const QString &_value) - { - properties.insert(_name, _value); - } + void setProperty(const QString &_name, const QString &_value); /** * @brief Returns the providerID for this printing. @@ -126,6 +130,18 @@ public: * @return The flavorName, or empty if it isn't present. */ [[nodiscard]] QString getFlavorName() const; + + /** + * @brief Returns the artist name credited for this printing's artwork. + * + * Requires a card database generated with artist data. + * + * @return The artist name, or empty if it isn't present. + */ + [[nodiscard]] QString getArtist() const + { + return getProperty("artist"); + } }; #endif // COCKATRICE_PRINTING_INFO_H diff --git a/libcockatrice_card/libcockatrice/card/relation/card_relation.cpp b/libcockatrice_card/libcockatrice/card/relation/card_relation.cpp index 90e59e439..8903c892d 100644 --- a/libcockatrice_card/libcockatrice/card/relation/card_relation.cpp +++ b/libcockatrice_card/libcockatrice/card/relation/card_relation.cpp @@ -7,8 +7,10 @@ CardRelation::CardRelation(const QString &_name, bool _isCreateAllExclusion, bool _isVariableCount, int _defaultCount, - bool _isPersistent) + bool _isPersistent, + bool _isFaceDown) : name(_name), attachType(_attachType), isCreateAllExclusion(_isCreateAllExclusion), - isVariableCount(_isVariableCount), defaultCount(_defaultCount), isPersistent(_isPersistent) + isVariableCount(_isVariableCount), defaultCount(_defaultCount), isPersistent(_isPersistent), + isFaceDown(_isFaceDown) { -} \ No newline at end of file +} diff --git a/libcockatrice_card/libcockatrice/card/relation/card_relation.h b/libcockatrice_card/libcockatrice/card/relation/card_relation.h index 9ff704097..a1864f5b2 100644 --- a/libcockatrice_card/libcockatrice/card/relation/card_relation.h +++ b/libcockatrice_card/libcockatrice/card/relation/card_relation.h @@ -31,6 +31,7 @@ private: bool isVariableCount; ///< True if the number of creations is variable. int defaultCount; ///< Default number of cards created or involved. bool isPersistent; ///< True if this relation persists (i.e. is not destroyed) on zone change. + bool isFaceDown; ///< True if this relation creates the tokens facedown public: /** @@ -42,13 +43,15 @@ public: * @param _isVariableCount Whether the count is variable. * @param _defaultCount Default number for creations or transformations. * @param _isPersistent Whether the relation persists across zone changes. + * @param _isFaceDown Whether the relation creates the token face down */ explicit CardRelation(const QString &_name = QString(), CardRelationType _attachType = CardRelationType::DoesNotAttach, bool _isCreateAllExclusion = false, bool _isVariableCount = false, int _defaultCount = 1, - bool _isPersistent = false); + bool _isPersistent = false, + bool _isFaceDown = false); /** * @brief Returns the name of the related card. @@ -151,6 +154,16 @@ public: { return isPersistent; } + + /** + * @brief Returns whether the relation creates the token facedown. + * + * @return True if facedown, false otherwise. + */ + [[nodiscard]] bool getIsFaceDown() const + { + return isFaceDown; + } }; #endif // COCKATRICE_CARD_RELATION_H diff --git a/libcockatrice_card/libcockatrice/card/set/card_set.cpp b/libcockatrice_card/libcockatrice/card/set/card_set.cpp index 6eea220bb..92060f6c4 100644 --- a/libcockatrice_card/libcockatrice/card/set/card_set.cpp +++ b/libcockatrice_card/libcockatrice/card/set/card_set.cpp @@ -42,9 +42,10 @@ QString CardSet::getCorrectedShortName() const void CardSet::loadSetOptions() { - sortKey = priorityController->getSortKey(shortName); - enabled = priorityController->isEnabled(shortName); - isknown = priorityController->isKnown(shortName); + const ICardSetPriorityController::SetOptions options = priorityController->getSetOptions(shortName); + sortKey = options.sortKey; + enabled = options.enabled; + isknown = options.isKnown; } void CardSet::setSortKey(unsigned int _sortKey) diff --git a/libcockatrice_deck_list/CMakeLists.txt b/libcockatrice_deck_list/CMakeLists.txt index 5ccdb5f66..0c487466c 100644 --- a/libcockatrice_deck_list/CMakeLists.txt +++ b/libcockatrice_deck_list/CMakeLists.txt @@ -11,14 +11,12 @@ set(HEADERS libcockatrice/deck_list/deck_list_history_manager.h libcockatrice/deck_list/deck_list_node_tree.h libcockatrice/deck_list/deck_list_memento.h + libcockatrice/deck_list/deck_list_plain_text_parser.h + libcockatrice/deck_list/playmat_resolver.h libcockatrice/deck_list/sideboard_plan.h ) -if(Qt6_FOUND) - qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) -elseif(Qt5_FOUND) - qt5_wrap_cpp(MOC_SOURCES ${HEADERS}) -endif() +qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) add_library( libcockatrice_deck_list STATIC @@ -30,6 +28,8 @@ add_library( libcockatrice/deck_list/deck_list.cpp libcockatrice/deck_list/deck_list_history_manager.cpp libcockatrice/deck_list/deck_list_node_tree.cpp + libcockatrice/deck_list/deck_list_plain_text_parser.cpp + libcockatrice/deck_list/playmat_resolver.cpp libcockatrice/deck_list/sideboard_plan.cpp ) @@ -37,4 +37,7 @@ add_dependencies(libcockatrice_deck_list libcockatrice_protocol) target_include_directories(libcockatrice_deck_list PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) -target_link_libraries(libcockatrice_deck_list PUBLIC libcockatrice_protocol libcockatrice_utility ${QT_CORE_MODULE}) +target_link_libraries( + libcockatrice_deck_list PUBLIC libcockatrice_interfaces libcockatrice_protocol libcockatrice_utility + ${QT_CORE_MODULE} +) diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp index d41713302..90f63c09d 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp @@ -1,6 +1,7 @@ #include "deck_list.h" #include "deck_list_memento.h" +#include "deck_list_plain_text_parser.h" #include "tree/abstract_deck_list_node.h" #include "tree/deck_list_card_node.h" #include "tree/inner_deck_list_node.h" @@ -8,24 +9,136 @@ #include #include #include -#include #include #include #include -#if QT_VERSION < 0x050600 -// qHash on QRegularExpression was added in 5.6, FIX IT -uint qHash(const QRegularExpression &key, uint seed) noexcept -{ - return qHash(key.pattern(), seed); // call qHash on pattern QString instead -} -#endif - static const QString CURRENT_SIDEBOARD_PLAN_KEY = ""; +/** + * @brief Parses a floating point XML attribute into a clamped playmat parameter. + * + * Falls back to @p fallback when the attribute is missing or malformed, so + * malformed deck files cannot produce degenerate art rectangles (e.g. a zoom + * of 0 dividing by zero). + * + * @param valueString Raw attribute text. + * @param fallback Value used when the text cannot be parsed. + * @param min Lower clamp bound. + * @param max Upper clamp bound. + * @return The parsed value clamped to [min, max], or @p fallback. + */ +static double parseClampedParam(const QString &valueString, double fallback, double min, double max) +{ + bool ok = false; + const double value = valueString.toDouble(&ok); + if (!ok) { + return fallback; + } + return qBound(min, value, max); +} + +/** + * @brief Reads a `bannerCard` element from the XML stream. + * + * @param xml Reader positioned at the element. + * @return The referenced card. + */ +static CardRef readBannerCard(QXmlStreamReader *xml) +{ + QString providerId = xml->attributes().value("providerId").toString(); + QString cardName = xml->readElementText(); + return {cardName, providerId}; +} + +/** + * @brief Reads a `playmatCard` element from the XML stream. + * + * Attribute values are read before readElementText consumes the element, and + * the params are clamped to the same ranges as the settings dialog and the + * remote player-properties path so malformed deck files cannot produce + * degenerate art rectangles (e.g. a zoom of 0 dividing by zero). + * + * @param xml Reader positioned at the element. + * @return The referenced card plus its clamped positioning parameters. + */ +static PlaymatInfo readPlaymatCard(QXmlStreamReader *xml) +{ + QString providerId = xml->attributes().value("providerId").toString(); + QString marginLStr = xml->attributes().value("marginPctL").toString(); + QString marginRStr = xml->attributes().value("marginPctR").toString(); + QString vOffStr = xml->attributes().value("verticalOffset").toString(); + QString zoomStr = xml->attributes().value("zoom").toString(); + QString cardName = xml->readElementText(); + + return { + .card = {cardName, providerId}, + .params = {.marginPctL = parseClampedParam(marginLStr, 0.07, 0.0, 0.95), + .marginPctR = parseClampedParam(marginRStr, 0.07, 0.0, 0.95), + .verticalOffset = parseClampedParam(vOffStr, 0.33, 0.0, 1.0), + .zoom = parseClampedParam(zoomStr, 1.0, 0.1, 4.0)}, + }; +} + bool DeckList::Metadata::isEmpty() const { - return name.isEmpty() && comments.isEmpty() && bannerCard.isEmpty() && tags.isEmpty(); + return name.isEmpty() && comments.isEmpty() && bannerCard.isEmpty() && tags.isEmpty() && playmat.card.isEmpty(); +} + +bool DeckList::Metadata::readElement(QXmlStreamReader *xml, const QString &childName) +{ + if (childName == "lastLoadedTimestamp") { + lastLoadedTimestamp = xml->readElementText(); + } else if (childName == "deckname") { + name = xml->readElementText(); + } else if (childName == "format") { + gameFormat = xml->readElementText(); + } else if (childName == "comments") { + comments = xml->readElementText(); + } else if (childName == "bannerCard") { + bannerCard = readBannerCard(xml); + } else if (childName == "playmatCard") { + playmat = readPlaymatCard(xml); + } else if (childName == "tags") { + tags.clear(); // Clear existing tags + while (xml->readNextStartElement()) { + if (xml->name().toString() == "tag") { + tags.append(xml->readElementText()); + } + } + } else { + return false; + } + return true; +} + +void DeckList::Metadata::write(QXmlStreamWriter *xml) const +{ + xml->writeTextElement("lastLoadedTimestamp", lastLoadedTimestamp); + xml->writeTextElement("deckname", name); + xml->writeTextElement("format", gameFormat); + xml->writeStartElement("bannerCard"); + xml->writeAttribute("providerId", bannerCard.providerId); + xml->writeCharacters(bannerCard.name); + xml->writeEndElement(); + if (!playmat.card.isEmpty()) { + xml->writeStartElement("playmatCard"); + xml->writeAttribute("providerId", playmat.card.providerId); + xml->writeAttribute("marginPctL", QString::number(playmat.params.marginPctL, 'f', 4)); + xml->writeAttribute("marginPctR", QString::number(playmat.params.marginPctR, 'f', 4)); + xml->writeAttribute("verticalOffset", QString::number(playmat.params.verticalOffset, 'f', 4)); + xml->writeAttribute("zoom", QString::number(playmat.params.zoom, 'f', 4)); + xml->writeCharacters(playmat.card.name); + xml->writeEndElement(); + } + xml->writeTextElement("comments", comments); + + // Write tags + xml->writeStartElement("tags"); + for (const QString &tag : tags) { + xml->writeTextElement("tag", tag); + } + xml->writeEndElement(); } DeckList::DeckList() @@ -62,26 +175,10 @@ bool DeckList::readElement(QXmlStreamReader *xml) { const QString childName = xml->name().toString(); if (xml->isStartElement()) { - if (childName == "lastLoadedTimestamp") { - metadata.lastLoadedTimestamp = xml->readElementText(); - } else if (childName == "deckname") { - metadata.name = xml->readElementText(); - } else if (childName == "format") { - metadata.gameFormat = xml->readElementText(); - } else if (childName == "comments") { - metadata.comments = xml->readElementText(); - } else if (childName == "bannerCard") { - QString providerId = xml->attributes().value("providerId").toString(); - QString cardName = xml->readElementText(); - metadata.bannerCard = {cardName, providerId}; - } else if (childName == "tags") { - metadata.tags.clear(); // Clear existing tags - while (xml->readNextStartElement()) { - if (xml->name().toString() == "tag") { - metadata.tags.append(xml->readElementText()); - } - } - } else if (childName == "zone") { + if (metadata.readElement(xml, childName)) { + return true; + } + if (childName == "zone") { tree.readZoneElement(xml); } else if (childName == "sideboard_plan") { SideboardPlan newSideboardPlan; @@ -95,31 +192,12 @@ bool DeckList::readElement(QXmlStreamReader *xml) return true; } -static void writeMetadata(QXmlStreamWriter *xml, const DeckList::Metadata &metadata) -{ - xml->writeTextElement("lastLoadedTimestamp", metadata.lastLoadedTimestamp); - xml->writeTextElement("deckname", metadata.name); - xml->writeTextElement("format", metadata.gameFormat); - xml->writeStartElement("bannerCard"); - xml->writeAttribute("providerId", metadata.bannerCard.providerId); - xml->writeCharacters(metadata.bannerCard.name); - xml->writeEndElement(); - xml->writeTextElement("comments", metadata.comments); - - // Write tags - xml->writeStartElement("tags"); - for (const QString &tag : metadata.tags) { - xml->writeTextElement("tag", tag); - } - xml->writeEndElement(); -} - void DeckList::write(QXmlStreamWriter *xml) const { xml->writeStartElement("cockatrice_deck"); xml->writeAttribute("version", "1"); - writeMetadata(xml, metadata); + metadata.write(xml); // Write zones tree.write(xml); @@ -132,6 +210,27 @@ void DeckList::write(QXmlStreamWriter *xml) const xml->writeEndElement(); // Close "cockatrice_deck" } +bool DeckList::seekToNextElement(QXmlStreamReader *xml) +{ + while (!xml->atEnd()) { + xml->readNext(); + if (xml->isStartElement()) { + return true; + } + } + return false; +} + +void DeckList::readDeckBody(QXmlStreamReader *xml) +{ + while (!xml->atEnd()) { + xml->readNext(); + if (!readElement(xml)) { + break; + } + } +} + bool DeckList::loadFromXml(QXmlStreamReader *xml) { if (xml->error()) { @@ -140,19 +239,11 @@ bool DeckList::loadFromXml(QXmlStreamReader *xml) } cleanList(); - while (!xml->atEnd()) { - xml->readNext(); - if (xml->isStartElement()) { - if (xml->name().toString() != "cockatrice_deck") { - return false; - } - while (!xml->atEnd()) { - xml->readNext(); - if (!readElement(xml)) { - break; - } - } + while (seekToNextElement(xml)) { + if (xml->name().toString() != "cockatrice_deck") { + return false; } + readDeckBody(xml); } refreshDeckHash(); if (xml->error()) { @@ -208,160 +299,12 @@ bool DeckList::loadFromStream_Plain(QTextStream &in, bool preserveMetadata, const std::function &cardNameNormalizer) { - const QRegularExpression reCardLine(R"(^\s*[\w\[\(\{].*$)", QRegularExpression::UseUnicodePropertiesOption); - const QRegularExpression reEmpty("^\\s*$"); - const QRegularExpression reComment(R"([\w\[\(\{].*$)", QRegularExpression::UseUnicodePropertiesOption); - const QRegularExpression reSBMark("^\\s*sb:\\s*(.+)", QRegularExpression::CaseInsensitiveOption); - const QRegularExpression reSBComment("^sideboard\\b.*$", QRegularExpression::CaseInsensitiveOption); - const QRegularExpression reDeckComment("^((main)?deck(list)?|mainboard)\\b", - QRegularExpression::CaseInsensitiveOption); - - // Regex for advanced card parsing - const QRegularExpression reMultiplier(R"(^[xX\(\[]*(\d+)[xX\*\)\]]* ?(.+))"); - - // Regex for extracting set code and collector number with attached symbols - const QRegularExpression reHyphenFormat(R"(\((\w{3,})\)\s+(\w{3,})-(\d+[^\w\s]*))"); - const QRegularExpression reRegularFormat(R"(\((\w{3,})\)\s+(\d+[^\w\s]*))"); - - cleanList(preserveMetadata); - - auto inputs = in.readAll().trimmed().split('\n'); - auto max_line = inputs.size(); - - // Start at the first empty line before the first card line - auto deckStart = inputs.indexOf(reCardLine); - if (deckStart == -1) { - if (inputs.indexOf(reComment) == -1) { - return false; // Input is empty - } - deckStart = max_line; - } else { - deckStart = inputs.lastIndexOf(reEmpty, deckStart); - if (deckStart == -1) { - deckStart = 0; - } + if (!preserveMetadata) { + metadata = {}; } - - // find sideboard position, if marks are used this won't be needed - int sBStart = -1; - if (inputs.indexOf(reSBMark, deckStart) == -1) { - sBStart = inputs.indexOf(reSBComment, deckStart); - if (sBStart == -1) { - sBStart = inputs.indexOf(reEmpty, deckStart + 1); - if (sBStart == -1) { - sBStart = max_line; - } - auto nextCard = inputs.indexOf(reCardLine, sBStart + 1); - if (inputs.indexOf(reEmpty, nextCard + 1) != -1) { - sBStart = max_line; - } - } - } - - int index = 0; - QRegularExpressionMatch match; - - // Parse name and comments - while (index < deckStart) { - const auto ¤t = inputs.at(index++); - if (!current.contains(reEmpty)) { - match = reComment.match(current); - metadata.name = match.captured(); - break; - } - } - while (index < deckStart) { - const auto ¤t = inputs.at(index++); - if (!current.contains(reEmpty)) { - match = reComment.match(current); - metadata.comments += match.captured() + '\n'; - } - } - metadata.comments.chop(1); - - // Discard empty lines - while (index < max_line && inputs.at(index).contains(reEmpty)) { - ++index; - } - - // Discard line if it starts with deck or mainboard, all cards until the sideboard starts are in the mainboard - if (inputs.at(index).contains(reDeckComment)) { - ++index; - } - - // Parse decklist - for (; index < max_line; ++index) { - // check if line is a card - match = reCardLine.match(inputs.at(index)); - if (!match.hasMatch()) { - continue; - } - - QString cardName = match.captured().simplified(); - bool sideboard = false; - - // Sideboard detection - if (sBStart < 0) { - match = reSBMark.match(cardName); - if (match.hasMatch()) { - sideboard = true; - cardName = match.captured(1); - } - } else { - if (index == sBStart) { - continue; - } - sideboard = index > sBStart; - } - - // Extract set code, collector number, and foil - QString setCode; - QString collectorNumber; - bool isFoil = false; - - // Check for foil status at the end of the card name - if (cardName.endsWith("*F*", Qt::CaseInsensitive)) { - isFoil = true; - cardName.chop(3); // Remove the "*F*" from the card name - } - Q_UNUSED(isFoil); - - // Attempt to match the hyphen-separated format (PLST-2094) - match = reHyphenFormat.match(cardName); - if (match.hasMatch()) { - setCode = match.captured(2).toUpper(); - collectorNumber = match.captured(3); - cardName = cardName.left(match.capturedStart()).trimmed(); - } else { - // Attempt to match the regular format (PLST) 2094 - match = reRegularFormat.match(cardName); - if (match.hasMatch()) { - setCode = match.captured(1).toUpper(); - collectorNumber = match.captured(2); - cardName = cardName.left(match.capturedStart()).trimmed(); - } - } - - // check if a specific amount is mentioned - int amount = 1; - match = reMultiplier.match(cardName); - if (match.hasMatch()) { - amount = match.captured(1).toInt(); - cardName = match.captured(2); - } - - // Normalize the card name - cardName = cardNameNormalizer(cardName); - - // Determine the zone (mainboard/sideboard) - QString zoneName = sideboard ? DECK_ZONE_SIDE : DECK_ZONE_MAIN; - - // make new entry in decklist - tree.addCard(cardName, amount, zoneName, -1, setCode, collectorNumber); - } - + bool ok = DeckListPlainText::parse(in, cardNameNormalizer, metadata, tree); refreshDeckHash(); - return true; + return ok; } bool DeckList::loadFromFile_Plain(QIODevice *device, const std::function &cardNameNormalizer) @@ -373,6 +316,10 @@ bool DeckList::loadFromFile_Plain(QIODevice *device, const std::functiongetName() == DECK_ZONE_MAYBEBOARD) { + return; + } if (prefixSideboardCards && node->getName() == DECK_ZONE_SIDE) { stream << "SB: "; } diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.h b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.h index e792c85dc..199d3a9a8 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.h +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.h @@ -18,7 +18,7 @@ #include #include #include -#include +#include class AbstractDecklistNode; class DecklistCardNode; @@ -70,11 +70,29 @@ public: CardRef bannerCard; ///< Optional representative card for the deck. QStringList tags; ///< User-defined tags for deck classification. QString lastLoadedTimestamp; ///< Timestamp string of last load. + PlaymatInfo playmat; ///< Optional playmat background for table+stack zones. /** * @brief Checks if all values (except for lastLoadedTimestamp) in the metadata is empty. */ bool isEmpty() const; + + /** + * @brief Reads a single deck metadata element from a Cockatrice deck XML stream. + * + * @param xml Reader positioned at the element. + * @param childName Name of the current element. + * @return true if a metadata element was consumed, false if @p childName is + * not a metadata element. + */ + bool readElement(QXmlStreamReader *xml, const QString &childName); + + /** + * @brief Writes the deck metadata section of a Cockatrice deck XML file. + * + * @param xml Writer to append the metadata elements to. + */ + void write(QXmlStreamWriter *xml) const; }; private: @@ -88,6 +106,22 @@ private: */ mutable QString cachedDeckHash; + /** @name XML load helpers */ + ///@{ + /** + * @brief Advances to the next element in the XML stream. + * @param xml Reader to advance past non-element tokens. + * @return true when a start element was reached, false at end of stream. + */ + bool seekToNextElement(QXmlStreamReader *xml); + + /** + * @brief Reads the contents of a `cockatrice_deck` element into this deck. + * @param xml Reader positioned at the deck element, stopped at its end. + */ + void readDeckBody(QXmlStreamReader *xml); + ///@} + public: /** @name Metadata setters */ ///@{ @@ -115,6 +149,10 @@ public: { metadata.bannerCard = _bannerCard; } + void setPlaymat(const PlaymatInfo &_playmat = {}) + { + metadata.playmat = _playmat; + } void setLastLoadedTimestamp(const QString &_lastLoadedTimestamp = QString()) { metadata.lastLoadedTimestamp = _lastLoadedTimestamp; @@ -170,6 +208,10 @@ public: { return metadata.bannerCard; } + PlaymatInfo getPlaymat() const + { + return metadata.playmat; + } QString getLastLoadedTimestamp() const { return metadata.lastLoadedTimestamp; diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_history_manager.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_history_manager.cpp index acf4707ab..b6d0687b4 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_history_manager.cpp +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_history_manager.cpp @@ -14,42 +14,32 @@ void DeckListHistoryManager::clear() emit undoRedoStateChanged(); } -void DeckListHistoryManager::undo(DeckList *deck) +void DeckListHistoryManager::restoreAndSwap(QStack &source, + QStack &target, + DeckList *deck) { - if (undoStack.isEmpty()) { + if (source.isEmpty()) { return; } - // Peek at the memento we are going to restore - const DeckListMemento &mementoToRestore = undoStack.top(); + // The reason is read before the source is popped. + const QString reason = source.top().getReason(); - // Save current state for redo - DeckListMemento currentState = deck->createMemento(mementoToRestore.getReason()); - redoStack.push(currentState); + // Save the current state so the opposite direction can return to it. + target.push(deck->createMemento(reason)); - // Pop the last state from undo stack and restore it - DeckListMemento memento = undoStack.pop(); - deck->restoreMemento(memento); + // Apply the state we are moving to. + deck->restoreMemento(source.pop()); emit undoRedoStateChanged(); } +void DeckListHistoryManager::undo(DeckList *deck) +{ + restoreAndSwap(undoStack, redoStack, deck); +} + void DeckListHistoryManager::redo(DeckList *deck) { - if (redoStack.isEmpty()) { - return; - } - - // Peek at the memento we are going to restore - const DeckListMemento &mementoToRestore = redoStack.top(); - - // Save current state for undo - DeckListMemento currentState = deck->createMemento(mementoToRestore.getReason()); - undoStack.push(currentState); - - // Pop the next state from redo stack and restore it - DeckListMemento memento = redoStack.pop(); - deck->restoreMemento(memento); - - emit undoRedoStateChanged(); + restoreAndSwap(redoStack, undoStack, deck); } diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_history_manager.h b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_history_manager.h index e6bd27e2d..6acdb199e 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_history_manager.h +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_history_manager.h @@ -47,6 +47,13 @@ public: } private: + /** + * @brief Moves one state from @p source to @p target, applying it to @p deck. + * + * Used by both undo (undoStack -> redoStack) and redo (redoStack -> undoStack). + */ + void restoreAndSwap(QStack &source, QStack &target, DeckList *deck); + QStack undoStack; QStack redoStack; }; diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.cpp index 196416cde..66f228d19 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.cpp +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.cpp @@ -5,6 +5,86 @@ #include #include +static constexpr int MAX_DECK_SIZE = 1e5; + +namespace +{ + +/** + * @brief Expands card nodes into one lowercase name entry per copy. + * + * @param nodes The card nodes to expand. + * @param prefix Optional prefix prepended to every entry (e.g. "SB:" for the sideboard). + * @return One entry per copy, in node order. + */ +QStringList cardNodesToCopies(const QList &nodes, const QString &prefix = {}) +{ + QStringList result; + for (auto node : nodes) { + for (int i = 0; i < node->getNumber(); ++i) { + result.append(prefix + node->getName().toLower()); + } + } + return result; +} + +/** + * @brief Packs the first five bytes of a SHA-1 digest into a compact base-32 number. + * + * The bytes are placed in most-significant-byte-first order (byte 0 shifted by 32, + * byte 4 unshifted) so decks that only differ in their low-order hash bytes still + * produce distinct identifiers. + * + * @return The 8-character base-32 representation of the packed number. + */ +QString encodeDeckHash(const QByteArray &digest) +{ + quint64 number = 0; + for (int i = 0; i < 5; ++i) { + number |= static_cast(static_cast(digest[i])) << (32 - 8 * i); + } + return QString::number(number, 32).rightJustified(8, '0'); +} + +/** + * @brief Collects every card node in @p node's subtree, in tree order. + * + * @return The collected card nodes. + */ +QList collectCardsRecursive(const InnerDecklistNode *node) +{ + QList result; + for (int i = 0; i < node->size(); i++) { + if (auto *card = dynamic_cast(node->at(i))) { + result.append(card); + } else if (auto *inner = dynamic_cast(node->at(i))) { + result.append(collectCardsRecursive(inner)); + } + } + return result; +} + +/** + * @brief Invokes @p func on every card in @p node's subtree. + * + * Cards nested in custom zones are reported with their top-level @p boardZone + * so that callers can classify cards by board (main/side/maybeboard/tokens). + */ +void forEachCardInNode(InnerDecklistNode *boardZone, + InnerDecklistNode *node, + const std::function &func) +{ + for (int i = 0; i < node->size(); i++) { + if (auto *card = dynamic_cast(node->at(i))) { + func(boardZone, card); + } else if (auto *inner = dynamic_cast(node->at(i))) { + forEachCardInNode(boardZone, inner, func); + } + } +} + +} // namespace + DecklistNodeTree::DecklistNodeTree() : root(new InnerDecklistNode()) { } @@ -42,12 +122,7 @@ QList DecklistNodeTree::getCardNodes(const QSet result; for (auto *zoneNode : getZoneNodes(restrictToZones)) { - for (auto *cardNode : *zoneNode) { - auto *cardCardNode = dynamic_cast(cardNode); - if (cardCardNode) { - result.append(cardCardNode); - } - } + result.append(collectCardsRecursive(zoneNode)); } return result; @@ -75,25 +150,11 @@ QString DecklistNodeTree::computeDeckHash() const auto mainDeckNodes = getCardNodes({DECK_ZONE_MAIN}); auto sideDeckNodes = getCardNodes({DECK_ZONE_SIDE}); - static auto nodesToCardList = [](const QList &nodes, const QString &prefix = {}) { - QStringList result; - for (auto node : nodes) { - for (int i = 0; i < node->getNumber(); ++i) { - result.append(prefix + node->getName().toLower()); - } - } - return result; - }; - - QStringList cardList = nodesToCardList(mainDeckNodes) + nodesToCardList(sideDeckNodes, "SB:"); + QStringList cardList = cardNodesToCopies(mainDeckNodes) + cardNodesToCopies(sideDeckNodes, "SB:"); cardList.sort(); QByteArray deckHashArray = QCryptographicHash::hash(cardList.join(";").toUtf8(), QCryptographicHash::Sha1); - quint64 number = (((quint64)(unsigned char)deckHashArray[0]) << 32) + - (((quint64)(unsigned char)deckHashArray[1]) << 24) + - (((quint64)(unsigned char)deckHashArray[2] << 16)) + - (((quint64)(unsigned char)deckHashArray[3]) << 8) + (quint64)(unsigned char)deckHashArray[4]; - return QString::number(number, 32).rightJustified(8, '0'); + return encodeDeckHash(deckHashArray); } void DecklistNodeTree::write(QXmlStreamWriter *xml) const @@ -107,7 +168,7 @@ void DecklistNodeTree::readZoneElement(QXmlStreamReader *xml) { QString zoneName = xml->attributes().value("name").toString(); InnerDecklistNode *newZone = getZoneObjFromName(zoneName); - newZone->readElement(xml); + totalCards += newZone->readElement(xml, MAX_DECK_SIZE - totalCards); } DecklistCardNode *DecklistNodeTree::addCard(const QString &cardName, @@ -119,6 +180,8 @@ DecklistCardNode *DecklistNodeTree::addCard(const QString &cardName, const QString &cardProviderId, const bool formatLegal) { + amount = qMin(amount, MAX_DECK_SIZE - totalCards); + totalCards += amount; auto *zoneNode = getZoneObjFromName(zoneName); auto *node = new DecklistCardNode(cardName, amount, zoneNode, position, cardSetName, cardSetCollectorNumber, cardProviderId, formatLegal); @@ -138,11 +201,7 @@ bool DecklistNodeTree::deleteNode(AbstractDecklistNode *node, InnerDecklistNode int index = rootNode->indexOf(node); if (index != -1) { delete rootNode->takeAt(index); - - if (rootNode->empty()) { - deleteNode(rootNode, rootNode->getParent()); - } - + pruneEmptyBoardZone(rootNode); return true; } @@ -158,30 +217,175 @@ bool DecklistNodeTree::deleteNode(AbstractDecklistNode *node, InnerDecklistNode return false; } +void DecklistNodeTree::pruneEmptyBoardZone(InnerDecklistNode *container) +{ + if (container->isEmpty() && container->getParent() == root) { + deleteNode(container, container->getParent()); + } +} + void DecklistNodeTree::forEachCard(const std::function &func) const { - // Support for this is only possible if the internal structure - // doesn't get more complicated. for (int i = 0; i < root->size(); i++) { - InnerDecklistNode *node = dynamic_cast(root->at(i)); - for (int j = 0; j < node->size(); j++) { - DecklistCardNode *card = dynamic_cast(node->at(j)); - func(node, card); + if (auto *zone = dynamic_cast(root->at(i))) { + forEachCardInNode(zone, zone, func); } } } /** * Gets the InnerDecklistNode that is the root node for the given zone, creating a new node if it doesn't exist. + * + * Top-level zones take precedence, then deck-unique custom zones nested under boards + * are resolved. Unknown names create a new top-level zone (legacy behavior). */ -InnerDecklistNode *DecklistNodeTree::getZoneObjFromName(const QString &zoneName) const +InnerDecklistNode *DecklistNodeTree::getZoneObjFromName(const QString &zoneName) { for (int i = 0; i < root->size(); i++) { auto *node = dynamic_cast(root->at(i)); - if (node->getName() == zoneName) { + if (node && node->getName() == zoneName) { return node; } } + if (auto *customZone = findCustomZoneByName(zoneName)) { + return customZone; + } + return new InnerDecklistNode(zoneName, root); } + +InnerDecklistNode *DecklistNodeTree::findBoardZone(const QString &boardZoneName) const +{ + return dynamic_cast(root->findChild(boardZoneName)); +} + +InnerDecklistNode *DecklistNodeTree::findOrCreateBoardZone(const QString &boardZoneName) +{ + auto *boardZone = findBoardZone(boardZoneName); + if (!boardZone && + (boardZoneName == DECK_ZONE_MAYBEBOARD || boardZoneName == DECK_ZONE_MAIN || boardZoneName == DECK_ZONE_SIDE)) { + // The boards are lazy zones: they only exist once cards or custom zones need them. + boardZone = new InnerDecklistNode(boardZoneName, root); + } + return boardZone; +} + +InnerDecklistNode *DecklistNodeTree::addCustomZone(const QString &boardZoneName, const QString &zoneName) +{ + if (hasZoneName(zoneName)) { + return nullptr; + } + + auto *boardZone = findOrCreateBoardZone(boardZoneName); + + if (!boardZone) { + return nullptr; + } + + return new InnerDecklistNode(zoneName, boardZone); +} + +bool DecklistNodeTree::renameCustomZone(const QString &oldZoneName, const QString &newZoneName) +{ + if (hasZoneName(newZoneName)) { + return false; + } + + auto *zone = findCustomZoneByName(oldZoneName); + if (!zone) { + return false; + } + + zone->setName(newZoneName); + return true; +} + +bool DecklistNodeTree::moveCustomZone(const QString &zoneName, const QString &newBoardZoneName) +{ + auto *zone = findCustomZoneByName(zoneName); + if (!zone) { + return false; + } + + auto *currentBoardZone = zone->getParent(); + if (currentBoardZone && currentBoardZone->getName() == newBoardZoneName) { + return true; + } + + auto *newBoardZone = findOrCreateBoardZone(newBoardZoneName); + if (!newBoardZone) { + return false; + } + + currentBoardZone->removeOne(zone); + newBoardZone->append(zone); + zone->setParent(newBoardZone); + return true; +} + +bool DecklistNodeTree::removeCustomZone(const QString &zoneName) +{ + auto *zone = findCustomZoneByName(zoneName); + if (!zone) { + return false; + } + + // Detach and delete without pruning the board zone. + auto *boardZone = zone->getParent(); + boardZone->removeOne(zone); + delete zone; + return true; +} + +QList DecklistNodeTree::getCustomZones(const QString &boardZoneName) const +{ + QList result; + + auto *boardZone = findBoardZone(boardZoneName); + if (!boardZone) { + return result; + } + + for (int i = 0; i < boardZone->size(); i++) { + if (auto *customZone = dynamic_cast(boardZone->at(i))) { + result.append(customZone); + } + } + + return result; +} + +InnerDecklistNode *DecklistNodeTree::findCustomZoneByName(const QString &zoneName) const +{ + for (int i = 0; i < root->size(); i++) { + auto *boardZone = dynamic_cast(root->at(i)); + if (!boardZone) { + continue; + } + + for (int j = 0; j < boardZone->size(); j++) { + auto *customZone = dynamic_cast(boardZone->at(j)); + if (customZone && customZone->getName() == zoneName) { + return customZone; + } + } + } + + return nullptr; +} + +bool DecklistNodeTree::hasZoneName(const QString &zoneName) const +{ + // The standard zones are reserved names even before they are created lazily. + if (zoneName == DECK_ZONE_MAIN || zoneName == DECK_ZONE_SIDE || zoneName == DECK_ZONE_MAYBEBOARD || + zoneName == DECK_ZONE_TOKENS) { + return true; + } + + if (root->findChild(zoneName)) { + return true; + } + + return findCustomZoneByName(zoneName) != nullptr; +} diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.h b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.h index 8ef0b18a5..2c66b34ff 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.h +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.h @@ -10,6 +10,7 @@ class DecklistNodeTree { InnerDecklistNode *root; ///< Root of the deck tree (zones + cards). + int totalCards = 0; public: /** @brief Constructs an empty DecklistNodeTree. */ @@ -78,15 +79,82 @@ public: bool deleteNode(AbstractDecklistNode *node, InnerDecklistNode *rootNode = nullptr); /** - * @brief Apply a function to every card in the deck tree. This can modify the cards. + * @brief Creates a new custom zone nested under a board zone. * - * @param func Function taking (zone node, card node). + * Custom zone names must be unique across the whole deck so that cards can be + * added to a custom zone without specifying its board zone. + * + * @param boardZoneName Name of the board zone (e.g. DECK_ZONE_MAIN). + * @param zoneName Name of the custom zone. + * @return The created zone node, or nullptr if the name is already in use. + */ + InnerDecklistNode *addCustomZone(const QString &boardZoneName, const QString &zoneName); + + /** + * @brief Renames a custom zone. + * @return true on success, false if the zone was not found or the new name is taken. + */ + bool renameCustomZone(const QString &oldZoneName, const QString &newZoneName); + + /** + * @brief Moves a custom zone (and all its cards) to another board zone. + * @return true on success, false if the zone or the new board zone was not found. + */ + bool moveCustomZone(const QString &zoneName, const QString &newBoardZoneName); + + /** + * @brief Removes a custom zone and all its cards. + * @return true if the zone was found and removed. + */ + bool removeCustomZone(const QString &zoneName); + + /** + * @brief Gets all custom zones nested under a board zone. + * @param boardZoneName Name of the board zone. + * @return The custom zones, in insertion order. + */ + QList getCustomZones(const QString &boardZoneName) const; + + /** + * @brief Checks whether a zone name is taken anywhere in the deck. + * + * Covers the standard board names and any top-level or nested custom zone. + * @param zoneName The checked name. + * @return true if the name is reserved or already in use. + */ + bool hasZoneName(const QString &zoneName) const; + + /** + * @brief Finds a custom zone anywhere in the deck by name. + * + * Walks the children of every top-level zone, so a zone nested under any + * board (and not just the standard ones) is found. + * @param zoneName The zone name to find. + * @return The matching zone node, or nullptr if none exists. + */ + InnerDecklistNode *findCustomZoneByName(const QString &zoneName) const; + + /** + * @brief Applies a function to every card in the deck tree. This can modify the cards. + * + * @param func Function taking (top-level board zone node, card node). Cards nested + * in custom zones are reported with their board zone. */ void forEachCard(const std::function &func) const; private: // Helpers for traversing the tree - InnerDecklistNode *getZoneObjFromName(const QString &zoneName) const; + InnerDecklistNode *getZoneObjFromName(const QString &zoneName); + InnerDecklistNode *findBoardZone(const QString &boardZoneName) const; + InnerDecklistNode *findOrCreateBoardZone(const QString &boardZoneName); + + /** + * @brief Recursively removes @p container when it is an empty board zone. + * + * Empty custom zones are kept while empty board zones get pruned, so a + * board zone disappears once its last card or custom zone goes away. + */ + void pruneEmptyBoardZone(InnerDecklistNode *container); }; #endif // COCKATRICE_DECKLIST_NODE_TREE_H diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_plain_text_parser.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_plain_text_parser.cpp new file mode 100644 index 000000000..de50d743b --- /dev/null +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_plain_text_parser.cpp @@ -0,0 +1,172 @@ +#include "deck_list_plain_text_parser.h" + +#include "deck_list_node_tree.h" +#include "tree/inner_deck_list_node.h" + +#include +#include + +namespace DeckListPlainText +{ + +bool parse(QTextStream &in, + const std::function &cardNameNormalizer, + DeckList::Metadata &metadata, + DecklistNodeTree &tree) +{ + tree.clear(); + + static const QRegularExpression reCardLine(R"(^\s*[\w\[\(\{].*$)", QRegularExpression::UseUnicodePropertiesOption); + static const QRegularExpression reEmpty("^\\s*$"); + static const QRegularExpression reComment(R"([\w\[\(\{].*$)", QRegularExpression::UseUnicodePropertiesOption); + static const QRegularExpression reSBMark("^\\s*sb:\\s*(.+)", QRegularExpression::CaseInsensitiveOption); + static const QRegularExpression reSBComment("^sideboard\\b.*$", QRegularExpression::CaseInsensitiveOption); + static const QRegularExpression reDeckComment("^((main)?deck(list)?|mainboard)\\b", + QRegularExpression::CaseInsensitiveOption); + + // Regex for advanced card parsing + static const QRegularExpression reMultiplier(R"(^[xX\(\[]*(\d+)[xX\*\)\]]* ?(.+))"); + + // Regex for extracting set code and collector number with attached symbols + static const QRegularExpression reHyphenFormat(R"(\((\w{3,})\)\s+(\w{3,})-(\d+[^\w\s]*))"); + static const QRegularExpression reRegularFormat(R"(\((\w{3,})\)\s+(\d+[^\w\s]*))"); + + auto inputs = in.readAll().trimmed().split('\n'); + auto max_line = inputs.size(); + + // Start at the first empty line before the first card line + auto deckStart = inputs.indexOf(reCardLine); + if (deckStart == -1) { + if (inputs.indexOf(reComment) == -1) { + return false; // Input is empty + } + deckStart = max_line; + } else { + deckStart = inputs.lastIndexOf(reEmpty, deckStart); + if (deckStart == -1) { + deckStart = 0; + } + } + + // find sideboard position, if marks are used this won't be needed + int sBStart = -1; + if (inputs.indexOf(reSBMark, deckStart) == -1) { + sBStart = inputs.indexOf(reSBComment, deckStart); + if (sBStart == -1) { + sBStart = inputs.indexOf(reEmpty, deckStart + 1); + if (sBStart == -1) { + sBStart = max_line; + } + auto nextCard = inputs.indexOf(reCardLine, sBStart + 1); + if (inputs.indexOf(reEmpty, nextCard + 1) != -1) { + sBStart = max_line; + } + } + } + + int index = 0; + QRegularExpressionMatch match; + + // Parse name and comments + while (index < deckStart) { + const auto ¤t = inputs.at(index++); + if (!current.contains(reEmpty)) { + match = reComment.match(current); + metadata.name = match.captured(); + break; + } + } + while (index < deckStart) { + const auto ¤t = inputs.at(index++); + if (!current.contains(reEmpty)) { + match = reComment.match(current); + metadata.comments += match.captured() + '\n'; + } + } + metadata.comments.chop(1); + + // Discard empty lines + while (index < max_line && inputs.at(index).contains(reEmpty)) { + ++index; + } + + // Discard line if it starts with deck or mainboard, all cards until the sideboard starts are in the mainboard + if (inputs.at(index).contains(reDeckComment)) { + ++index; + } + + // Parse decklist + for (; index < max_line; ++index) { + // check if line is a card + match = reCardLine.match(inputs.at(index)); + if (!match.hasMatch()) { + continue; + } + + QString cardName = match.captured().simplified(); + bool sideboard = false; + + // Sideboard detection + if (sBStart < 0) { + match = reSBMark.match(cardName); + if (match.hasMatch()) { + sideboard = true; + cardName = match.captured(1); + } + } else { + if (index == sBStart) { + continue; + } + sideboard = index > sBStart; + } + + // Extract set code, collector number, and foil + QString setCode; + QString collectorNumber; + bool isFoil = false; + + // Check for foil status at the end of the card name + if (cardName.endsWith("*F*", Qt::CaseInsensitive)) { + isFoil = true; + cardName.chop(3); // Remove the "*F*" from the card name + } + Q_UNUSED(isFoil); + + // Attempt to match the hyphen-separated format (PLST-2094) + match = reHyphenFormat.match(cardName); + if (match.hasMatch()) { + setCode = match.captured(2).toUpper(); + collectorNumber = match.captured(3); + cardName = cardName.left(match.capturedStart()).trimmed(); + } else { + // Attempt to match the regular format (PLST) 2094 + match = reRegularFormat.match(cardName); + if (match.hasMatch()) { + setCode = match.captured(1).toUpper(); + collectorNumber = match.captured(2); + cardName = cardName.left(match.capturedStart()).trimmed(); + } + } + + // check if a specific amount is mentioned + int amount = 1; + match = reMultiplier.match(cardName); + if (match.hasMatch()) { + amount = match.captured(1).toInt(); + cardName = match.captured(2); + } + + // Normalize the card name + cardName = cardNameNormalizer(cardName); + + // Determine the zone (mainboard/sideboard) + QString zoneName = sideboard ? DECK_ZONE_SIDE : DECK_ZONE_MAIN; + + // make new entry in decklist + tree.addCard(cardName, amount, zoneName, -1, setCode, collectorNumber); + } + + return true; +} + +} // namespace DeckListPlainText \ No newline at end of file diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_plain_text_parser.h b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_plain_text_parser.h new file mode 100644 index 000000000..e0f456a18 --- /dev/null +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_plain_text_parser.h @@ -0,0 +1,33 @@ +#ifndef COCKATRICE_DECK_LIST_PLAIN_TEXT_PARSER_H +#define COCKATRICE_DECK_LIST_PLAIN_TEXT_PARSER_H + +#include "deck_list.h" + +#include +#include + +class QTextStream; + +namespace DeckListPlainText +{ + +/** + * @brief Parses a plain-text deck list into a tree and its metadata. + * + * Clears the tree first, then fills both from the text. + * + * @param in The text to load + * @param cardNameNormalizer Function that takes the parsed card name string + * in the text and returns the name to store + * @param metadata Deck metadata written by the parser + * @param tree Deck tree the parser adds cards to + * @return False if the input was empty, true otherwise. + */ +bool parse(QTextStream &in, + const std::function &cardNameNormalizer, + DeckList::Metadata &metadata, + DecklistNodeTree &tree); + +} // namespace DeckListPlainText + +#endif // COCKATRICE_DECK_LIST_PLAIN_TEXT_PARSER_H \ No newline at end of file diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/playmat_resolver.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/playmat_resolver.cpp new file mode 100644 index 000000000..fd72fea0b --- /dev/null +++ b/libcockatrice_deck_list/libcockatrice/deck_list/playmat_resolver.cpp @@ -0,0 +1,54 @@ +#include "playmat_resolver.h" + +#include + +PlaymatInfo resolveEffectivePlaymat(const DeckList &deck, + const PlaymatInfo &force, + const QList &fallbackList, + PlaymatFallbackMode fallbackMode, + int rotationIndex) +{ + if (!force.card.isEmpty()) { + return force; + } + + const PlaymatInfo &deckPlaymat = deck.getPlaymat(); + if (!deckPlaymat.card.isEmpty()) { + return deckPlaymat; + } + + if (fallbackList.isEmpty()) { + return {}; + } + + switch (fallbackMode) { + case PlaymatFallbackModeFixed: + return fallbackList.first(); + case PlaymatFallbackModeRoundRobin: + return fallbackList.at(rotationIndex % fallbackList.size()); + case PlaymatFallbackModeRandom: + return fallbackList.at(QRandomGenerator::global()->bounded(fallbackList.size())); + } + + return {}; +} + +PlaymatInfo resolvePlaymatForDeck(const DeckList &deck, + const QList &fallbackList, + PlaymatMode mode, + PlaymatFallbackMode fallbackBehavior, + int rotationIndex) +{ + switch (mode) { + case PlaymatModeOverrideDeck: { + const DeckList emptyDeck; + return resolveEffectivePlaymat(emptyDeck, {}, fallbackList, fallbackBehavior, rotationIndex); + } + case PlaymatModeFallback: + return resolveEffectivePlaymat(deck, {}, fallbackList, fallbackBehavior, rotationIndex); + case PlaymatModeDeckOnly: + return deck.getPlaymat(); + } + + return {}; +} diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/playmat_resolver.h b/libcockatrice_deck_list/libcockatrice/deck_list/playmat_resolver.h new file mode 100644 index 000000000..59bc449c8 --- /dev/null +++ b/libcockatrice_deck_list/libcockatrice/deck_list/playmat_resolver.h @@ -0,0 +1,47 @@ +#ifndef COCKATRICE_PLAYMAT_RESOLVER_H +#define COCKATRICE_PLAYMAT_RESOLVER_H + +#include "deck_list.h" + +#include + +/** + * @brief Resolves the effective playmat for a deck per the resolution chain: + * force override > deck-configured playmat > fallback list > none. + * + * @param deck The deck to resolve a playmat for. + * @param force An optional user-level override; wins over everything. Pass an + * empty @ref PlaymatInfo::card to skip it. + * @param fallbackList User-level fallback playmats, consulted only when the + * deck has no configured playmat. + * @param fallbackMode How @p fallbackList is consulted (ignored when empty). + * @param rotationIndex In/out cursor for @c PlaymatFallbackModeRoundRobin; + * advanced once per call. Unused for the other modes. + * @return The effective playmat; an empty @ref PlaymatInfo::card when + * nothing in the chain resolves. + */ +PlaymatInfo resolveEffectivePlaymat(const DeckList &deck, + const PlaymatInfo &force, + const QList &fallbackList, + PlaymatFallbackMode fallbackMode, + int rotationIndex); + +/** + * @brief Resolves the playmat to display for a deck according to the user's + * collection mode (@ref PlaymatMode), combining the deck with the + * given fallback list. + * + * @param deck The deck to resolve a playmat for. + * @param fallbackList User-level fallback playmats. + * @param mode How the collection interacts with the deck-configured playmat. + * @param fallbackBehavior How @p fallbackList is picked from. + * @param rotationIndex Cursor for @c PlaymatFallbackModeRoundRobin. + * @return The effective playmat; an empty @ref PlaymatInfo::card when nothing resolves. + */ +PlaymatInfo resolvePlaymatForDeck(const DeckList &deck, + const QList &fallbackList, + PlaymatMode mode, + PlaymatFallbackMode fallbackBehavior, + int rotationIndex); + +#endif // COCKATRICE_PLAYMAT_RESOLVER_H diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/sideboard_plan.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/sideboard_plan.cpp index a76fed619..855062c18 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/sideboard_plan.cpp +++ b/libcockatrice_deck_list/libcockatrice/deck_list/sideboard_plan.cpp @@ -2,6 +2,32 @@ #include +namespace +{ + +void readMoveCardToZone(QXmlStreamReader *xml, QList &moveList) +{ + MoveCard_ToZone move; + while (!xml->atEnd()) { + xml->readNext(); + const QString childName = xml->name().toString(); + if (xml->isStartElement()) { + if (childName == "card_name") { + move.set_card_name(xml->readElementText().toStdString()); + } else if (childName == "start_zone") { + move.set_start_zone(xml->readElementText().toStdString()); + } else if (childName == "target_zone") { + move.set_target_zone(xml->readElementText().toStdString()); + } + } else if (xml->isEndElement() && (childName == "move_card_to_zone")) { + moveList.append(move); + return; + } + } +} + +} // namespace + SideboardPlan::SideboardPlan(const QString &_name, const QList &_moveList) : name(_name), moveList(_moveList) { @@ -21,23 +47,7 @@ bool SideboardPlan::readElement(QXmlStreamReader *xml) if (childName == "name") { name = xml->readElementText(); } else if (childName == "move_card_to_zone") { - MoveCard_ToZone m; - while (!xml->atEnd()) { - xml->readNext(); - const QString childName2 = xml->name().toString(); - if (xml->isStartElement()) { - if (childName2 == "card_name") { - m.set_card_name(xml->readElementText().toStdString()); - } else if (childName2 == "start_zone") { - m.set_start_zone(xml->readElementText().toStdString()); - } else if (childName2 == "target_zone") { - m.set_target_zone(xml->readElementText().toStdString()); - } - } else if (xml->isEndElement() && (childName2 == "move_card_to_zone")) { - moveList.append(m); - break; - } - } + readMoveCardToZone(xml, moveList); } } else if (xml->isEndElement() && (childName == "sideboard_plan")) { return true; diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_card_node.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_card_node.cpp index 705dfae4c..685238d53 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_card_node.cpp +++ b/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_card_node.cpp @@ -34,17 +34,6 @@ bool AbstractDecklistCardNode::compareName(AbstractDecklistNode *other) const } } -bool AbstractDecklistCardNode::readElement(QXmlStreamReader *xml) -{ - while (!xml->atEnd()) { - xml->readNext(); - if (xml->isEndElement() && xml->name().toString() == "card") { - return false; - } - } - return true; -} - void AbstractDecklistCardNode::writeElement(QXmlStreamWriter *xml) { xml->writeEmptyElement("card"); @@ -60,4 +49,4 @@ void AbstractDecklistCardNode::writeElement(QXmlStreamWriter *xml) if (!getCardProviderId().isEmpty()) { xml->writeAttribute("uuid", getCardProviderId()); } -} \ No newline at end of file +} diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_card_node.h b/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_card_node.h index df903a168..0942a4601 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_card_node.h +++ b/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_card_node.h @@ -134,15 +134,6 @@ public: */ bool compareName(AbstractDecklistNode *other) const; - /** - * @brief Deserialize this node’s properties from XML. - * @param xml QXmlStreamReader positioned at the element. - * @return true if parsing succeeded. - * - * This supports loading deck files from Cockatrice’s XML format. - */ - bool readElement(QXmlStreamReader *xml) override; - /** * @brief Serialize this node’s properties to XML. * @param xml Writer to append this node’s XML element. diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_node.h b/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_node.h index a39f0e7b2..38d1d44b8 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_node.h +++ b/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_node.h @@ -142,6 +142,12 @@ public: return parent; } + /** @param newParent Reparent this node. The new parent takes ownership. */ + void setParent(InnerDecklistNode *newParent) + { + parent = newParent; + } + /** * @brief Compute the depth of this node in the tree. * @return Distance from the root (root = 0, children = 1, etc.). @@ -173,11 +179,10 @@ public: /** * @name XML serialization - * These methods support reading and writing decks from/to + * This method supports writing this node and its children to the * Cockatrice deck XML format. * @{ */ - virtual bool readElement(QXmlStreamReader *xml) = 0; virtual void writeElement(QXmlStreamWriter *xml) = 0; /// @} }; diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.cpp index 602ea6aec..86f7f5363 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.cpp +++ b/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.cpp @@ -28,6 +28,8 @@ QString InnerDecklistNode::visibleNameFromName(const QString &_name) return QObject::tr("Sideboard"); } else if (_name == DECK_ZONE_TOKENS) { return QObject::tr("Tokens"); + } else if (_name == DECK_ZONE_MAYBEBOARD) { + return QObject::tr("Maybeboard"); } else { return _name; } @@ -41,6 +43,13 @@ void InnerDecklistNode::setSortMethod(DeckSortMethod method) } } +const QList &InnerDecklistNode::boardZoneNames() +{ + static const QList names = {QString(DECK_ZONE_MAIN), QString(DECK_ZONE_SIDE), + QString(DECK_ZONE_MAYBEBOARD)}; + return names; +} + QString InnerDecklistNode::getVisibleName() const { return visibleNameFromName(name); @@ -85,6 +94,9 @@ AbstractDecklistNode *InnerDecklistNode::findCardChildByNameProviderIdAndNumber( int InnerDecklistNode::height() const { + if (isEmpty()) { + return 1; + } return at(0)->height() + 1; } @@ -139,27 +151,35 @@ bool InnerDecklistNode::compareName(AbstractDecklistNode *other) const } } -bool InnerDecklistNode::readElement(QXmlStreamReader *xml) +int InnerDecklistNode::readCardElement(QXmlStreamReader *xml, int remainingBudget) { + const int amount = qMin(xml->attributes().value("number").toString().toInt(), remainingBudget); + new DecklistCardNode(xml->attributes().value("name").toString(), amount, this, -1, + xml->attributes().value("setShortName").toString(), + xml->attributes().value("collectorNumber").toString(), + xml->attributes().value("uuid").toString()); + return amount; +} + +int InnerDecklistNode::readElement(QXmlStreamReader *xml, int limit) +{ + int totalCards = 0; while (!xml->atEnd()) { xml->readNext(); const QString childName = xml->name().toString(); + const int remainingBudget = limit - totalCards; if (xml->isStartElement()) { if (childName == "zone") { auto *newZone = new InnerDecklistNode(xml->attributes().value("name").toString(), this); - newZone->readElement(xml); + totalCards += newZone->readElement(xml, remainingBudget); } else if (childName == "card") { - auto *newCard = new DecklistCardNode( - xml->attributes().value("name").toString(), xml->attributes().value("number").toString().toInt(), - this, -1, xml->attributes().value("setShortName").toString(), - xml->attributes().value("collectorNumber").toString(), xml->attributes().value("uuid").toString()); - newCard->readElement(xml); + totalCards += readCardElement(xml, remainingBudget); } } else if (xml->isEndElement() && (childName == "zone")) { - return false; + return totalCards; } } - return true; + return totalCards; } void InnerDecklistNode::writeElement(QXmlStreamWriter *xml) @@ -172,31 +192,35 @@ void InnerDecklistNode::writeElement(QXmlStreamWriter *xml) xml->writeEndElement(); // zone } -QVector> InnerDecklistNode::sort(Qt::SortOrder order) +QVector> InnerDecklistNode::indexedSnapshot() const +{ + QVector> snapshot(size()); + for (int i = size() - 1; i >= 0; --i) { + snapshot[i].first = i; + snapshot[i].second = at(i); + } + return snapshot; +} + +QVector> InnerDecklistNode::applySortedOrder(const QVector> &sorted) { QVector> result(size()); - - // Initialize temporary list with contents of current list - QVector> tempList(size()); for (int i = size() - 1; i >= 0; --i) { - tempList[i].first = i; - tempList[i].second = at(i); + result[i].first = sorted[i].first; + result[i].second = i; + replace(i, sorted[i].second); } + return result; +} + +QVector> InnerDecklistNode::sort(Qt::SortOrder order) +{ + auto snapshot = indexedSnapshot(); - // Sort temporary list auto cmp = [order](const auto &a, const auto &b) { return (order == Qt::AscendingOrder) ? (b.second->compare(a.second)) : (a.second->compare(b.second)); }; + std::sort(snapshot.begin(), snapshot.end(), cmp); - std::sort(tempList.begin(), tempList.end(), cmp); - - // Map old indexes to new indexes and - // copy temporary list to the current one - for (int i = size() - 1; i >= 0; --i) { - result[i].first = tempList[i].first; - result[i].second = i; - replace(i, tempList[i].second); - } - - return result; -} \ No newline at end of file + return applySortedOrder(snapshot); +} diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.h b/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.h index 81c37dffa..8404d7116 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.h +++ b/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.h @@ -18,12 +18,17 @@ #include "abstract_deck_list_node.h" +#include +#include + /** @brief Constant for the "main" deck zone name. */ #define DECK_ZONE_MAIN "main" /** @brief Constant for the "sideboard" zone name. */ #define DECK_ZONE_SIDE "side" /** @brief Constant for the "tokens" zone name. */ #define DECK_ZONE_TOKENS "tokens" +/** @brief Constant for the "maybeboard" zone name. */ +#define DECK_ZONE_MAYBEBOARD "maybeboard" /** * @class InnerDecklistNode @@ -116,6 +121,13 @@ public: */ static QString visibleNameFromName(const QString &_name); + /** + * @brief The standard board zone names, in display order. + * + * @return main, side and maybeboard. + */ + static const QList &boardZoneNames(); + /** * @brief Get this node’s display-friendly name. * @return Human-readable name (zone/group name). @@ -211,18 +223,45 @@ public: */ QVector> sort(Qt::SortOrder order = Qt::AscendingOrder); +private: + /** + * @brief Snapshots the current children as (old index, node) pairs. + */ + QVector> indexedSnapshot() const; + + /** + * @brief Replaces this node's children with @p sorted and maps old indexes to new ones. + * + * @return A list of (old index, new index) pairs for each reordered child. + */ + QVector> applySortedOrder(const QVector> &sorted); + +public: /** * @brief Deserialize this node and its children from XML. * @param xml Reader positioned at this element. - * @return true if parsing succeeded. + * @param limit The maximum amount of cards to read + * @return the amount of cards found */ - bool readElement(QXmlStreamReader *xml) override; + int readElement(QXmlStreamReader *xml, int limit); /** * @brief Serialize this node and its children to XML. * @param xml Writer to append elements to. */ void writeElement(QXmlStreamWriter *xml) override; + +private: + /** + * @brief Reads a single `card` element and appends it to this node. + * + * The card's quantity is capped at @p remainingBudget so a malicious or + * oversized deck file cannot push the total card count past the deck size + * limit. + * + * @return The amount of cards actually added. + */ + int readCardElement(QXmlStreamReader *xml, int remainingBudget); }; #endif // COCKATRICE_INNER_DECK_LIST_NODE_H diff --git a/libcockatrice_filters/CMakeLists.txt b/libcockatrice_filters/CMakeLists.txt index 74566ca05..905533df8 100644 --- a/libcockatrice_filters/CMakeLists.txt +++ b/libcockatrice_filters/CMakeLists.txt @@ -6,11 +6,7 @@ set(HEADERS libcockatrice/filters/filter_card.h libcockatrice/filters/filter_str libcockatrice/filters/filter_tree.h ) -if(Qt6_FOUND) - qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) -elseif(Qt5_FOUND) - qt5_wrap_cpp(MOC_SOURCES ${HEADERS}) -endif() +qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) add_library( libcockatrice_filters STATIC ${MOC_SOURCES} libcockatrice/filters/filter_card.cpp diff --git a/libcockatrice_filters/libcockatrice/filters/filter_string.cpp b/libcockatrice_filters/libcockatrice/filters/filter_string.cpp index 25e8e97db..aaf391c03 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_string.cpp +++ b/libcockatrice_filters/libcockatrice/filters/filter_string.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include static peg::parser search(R"( @@ -19,7 +20,7 @@ SomewhatComplexQueryPart <- [(] QueryPartList [)] / QueryPart QueryPart <- NotQuery / SetQuery / RarityQuery / CMCQuery / FormatQuery / PowerQuery / ToughnessQuery / ColorQuery / TypeQuery / OracleQuery / FieldQuery / GenericQuery NotQuery <- ('NOT' ws/'-') SomewhatComplexQueryPart -SetQuery <- ('e'/'set') [:] FlexStringValue +SetQuery <- ('e'/'set') SetExpression / ([:] FlexStringValue) OracleQuery <- 'o' [:] MatcherString @@ -64,6 +65,8 @@ RegexMatcherString <- ('\\/' / !'/' .)+ FlexStringValue <- CompactStringSet / String / [(] StringList [)] CompactStringSet <- StringListString ([,+] StringListString)+ +SetExpression <- NumericOperator ws? String + NumericExpression <- NumericOperator ws? NumericValue NumericOperator <- [=:] / <[> NumericValue <- [0-9]+ @@ -101,12 +104,25 @@ static void setupParserRules() return [=](const CardData &x) -> bool { return matcher(x->getCardType()); }; }; search["SetQuery"] = [](const peg::SemanticValues &sv) -> Filter { - auto matcher = std::any_cast(sv[0]); - return [=](const CardData &x) -> bool { - QList sets = x->getSets().keys(); + if (sv.choice() == 1) { + auto matcher = std::any_cast(sv[0]); + return [=](const CardData &x) -> bool { + QList sets = x->getSets().keys(); - auto matchesSet = [&matcher](const QString &set) { return matcher(set); }; - return std::any_of(sets.begin(), sets.end(), matchesSet); + auto matchesSet = [&matcher](const QString &set) { return matcher(set); }; + return std::any_of(sets.begin(), sets.end(), matchesSet); + }; + } + + auto matcher = std::any_cast(sv[0]); + return [=](const CardData &x) -> bool { + const auto &sets = x->getSets().values(); + auto matchesSet = [&](const PrintingInfo &printing) { + return printing.getSet()->getEnabled() && matcher(printing.getSet()->getReleaseDate().toJulianDay()); + }; + return std::any_of(sets.begin(), sets.end(), [&](const auto &printings) { + return std::any_of(printings.begin(), printings.end(), matchesSet); + }); }; }; search["Rarity"] = [](const peg::SemanticValues &sv) -> QString { @@ -247,40 +263,54 @@ static void setupParserRules() return QString::fromStdString(std::string(sv.sv())); }; - search["NumericExpression"] = [](const peg::SemanticValues &sv) -> NumberMatcher { - const auto arg = std::any_cast(sv[1]); - const auto op = std::any_cast(sv[0]); + search["NumericOperator"] = [](const peg::SemanticValues &sv) -> NumberComparer { + const auto op = QString::fromStdString(std::string(sv.sv())); if (op == ">") { - return [=](const int s) { return s > arg; }; + return [=](const int s, const int arg) { return s > arg; }; } if (op == ">=") { - return [=](const int s) { return s >= arg; }; + return [=](const int s, const int arg) { return s >= arg; }; } if (op == "<") { - return [=](const int s) { return s < arg; }; + return [=](const int s, const int arg) { return s < arg; }; } if (op == "<=") { - return [=](const int s) { return s <= arg; }; + return [=](const int s, const int arg) { return s <= arg; }; } if (op == "=") { - return [=](const int s) { return s == arg; }; + return [=](const int s, const int arg) { return s == arg; }; } if (op == ":") { - return [=](const int s) { return s == arg; }; + return [=](const int s, const int arg) { return s == arg; }; } if (op == "!=") { - return [=](const int s) { return s != arg; }; + return [=](const int s, const int arg) { return s != arg; }; } - return [](int) { return false; }; + return [](int, int) { return false; }; }; search["NumericValue"] = [](const peg::SemanticValues &sv) -> int { return QString::fromStdString(std::string(sv.sv())).toInt(); }; - search["NumericOperator"] = [](const peg::SemanticValues &sv) -> QString { - return QString::fromStdString(std::string(sv.sv())); + search["NumericExpression"] = [](const peg::SemanticValues &sv) -> NumberMatcher { + const auto comparer = std::any_cast(sv[0]); + const auto arg = std::any_cast(sv[1]); + return [=](int s) { return comparer(s, arg); }; + }; + + search["SetExpression"] = [](const peg::SemanticValues &sv) -> NumberMatcher { + const auto comparer = std::any_cast(sv[0]); + const auto setCode = std::any_cast(sv[1]); + const auto allSets = CardDatabaseManager::getInstance()->getSetList(); + for (auto &set : allSets) { + if (set->getShortName() == setCode) { + const int releaseDate = set->getReleaseDate().toJulianDay(); + return [=](int s) { return comparer(s, releaseDate); }; + } + } + return [](int) { return false; }; }; search["NormalMatcher"] = [](const peg::SemanticValues &sv) -> StringMatcher { diff --git a/libcockatrice_filters/libcockatrice/filters/filter_string.h b/libcockatrice_filters/libcockatrice/filters/filter_string.h index 71a99f7b5..a058f7d07 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_string.h +++ b/libcockatrice_filters/libcockatrice/filters/filter_string.h @@ -22,6 +22,7 @@ typedef CardInfoPtr CardData; typedef std::function Filter; typedef std::function StringMatcher; typedef std::function NumberMatcher; +typedef std::function NumberComparer; namespace peg { diff --git a/libcockatrice_interfaces/CMakeLists.txt b/libcockatrice_interfaces/CMakeLists.txt index 4f34f7985..f53c41807 100644 --- a/libcockatrice_interfaces/CMakeLists.txt +++ b/libcockatrice_interfaces/CMakeLists.txt @@ -3,23 +3,31 @@ set(CMAKE_AUTOUIC ON) set(CMAKE_AUTORCC ON) set(HEADERS + libcockatrice/interfaces/interface_cache_storage_settings_provider.h libcockatrice/interfaces/interface_card_database_path_provider.h libcockatrice/interfaces/interface_card_preference_provider.h libcockatrice/interfaces/interface_card_set_priority_controller.h + libcockatrice/interfaces/interface_cards_display_settings_provider.h + libcockatrice/interfaces/interface_chat_settings_provider.h + libcockatrice/interfaces/interface_deck_editor_settings_provider.h + libcockatrice/interfaces/interface_game_settings_provider.h + libcockatrice/interfaces/interface_interface_settings_provider.h libcockatrice/interfaces/interface_network_settings_provider.h + libcockatrice/interfaces/interface_paths_settings_provider.h + libcockatrice/interfaces/interface_personal_settings_provider.h + libcockatrice/interfaces/interface_sound_settings_provider.h + libcockatrice/interfaces/interface_tabs_settings_provider.h + libcockatrice/interfaces/interface_updates_settings_provider.h + libcockatrice/interfaces/interface_visual_deck_storage_settings_provider.h libcockatrice/interfaces/noop_card_database_path_provider.h libcockatrice/interfaces/noop_card_preference_provider.h libcockatrice/interfaces/noop_card_set_priority_controller.h ) -if(Qt6_FOUND) - qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) -elseif(Qt5_FOUND) - qt5_wrap_cpp(MOC_SOURCES ${HEADERS}) -endif() +qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) add_library(libcockatrice_interfaces STATIC ${MOC_SOURCES}) target_include_directories(libcockatrice_interfaces PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) -target_link_libraries(libcockatrice_interfaces PUBLIC ${QT_CORE_MODULE}) +target_link_libraries(libcockatrice_interfaces PUBLIC libcockatrice_utility ${QT_CORE_MODULE}) diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/interface_cache_storage_settings_provider.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_cache_storage_settings_provider.h new file mode 100644 index 000000000..2881164d6 --- /dev/null +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_cache_storage_settings_provider.h @@ -0,0 +1,14 @@ +#ifndef COCKATRICE_INTERFACE_CACHE_STORAGE_SETTINGS_PROVIDER_H +#define COCKATRICE_INTERFACE_CACHE_STORAGE_SETTINGS_PROVIDER_H + +class ICacheStorageSettingsProvider +{ +public: + virtual ~ICacheStorageSettingsProvider() = default; + + [[nodiscard]] virtual int getPixmapCacheSize() const = 0; + [[nodiscard]] virtual int getNetworkCacheSizeInMB() const = 0; + [[nodiscard]] virtual int getRedirectCacheTtl() const = 0; +}; + +#endif // COCKATRICE_INTERFACE_CACHE_STORAGE_SETTINGS_PROVIDER_H diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/interface_card_set_priority_controller.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_card_set_priority_controller.h index 9559967af..36e748006 100644 --- a/libcockatrice_interfaces/libcockatrice/interfaces/interface_card_set_priority_controller.h +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_card_set_priority_controller.h @@ -13,6 +13,22 @@ public: bool enabled; }; + /** + * @brief Bundled per-set priority options, returned in a single call. + * + * Reading these individually (getSortKey/isEnabled/isKnown) is cheap for the + * noop controller but extremely expensive when backed by QSettings, which + * re-opens and re-parses the whole .ini on every access. Callers that need + * more than one option for the same set (notably CardSet construction during + * database load) should use getSetOptions() to avoid that cost. + */ + struct SetOptions + { + unsigned int sortKey = 0; + bool enabled = false; + bool isKnown = false; + }; + virtual ~ICardSetPriorityController() = default; virtual void setSortKey(QString shortName, unsigned int sortKey) = 0; @@ -23,6 +39,13 @@ public: virtual bool isEnabled(QString shortName) const = 0; virtual bool isKnown(QString shortName) const = 0; + /** + * @brief Returns the bundled priority options for a set in a single call. + * @param shortName The set's short name. + * @return The sort key, enabled and known flags for the set. + */ + virtual SetOptions getSetOptions(QString shortName) const = 0; + virtual void saveSets(const QVector &data) = 0; }; diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/interface_cards_display_settings_provider.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_cards_display_settings_provider.h new file mode 100644 index 000000000..304ab3cb7 --- /dev/null +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_cards_display_settings_provider.h @@ -0,0 +1,34 @@ +#ifndef COCKATRICE_INTERFACE_CARDS_DISPLAY_SETTINGS_PROVIDER_H +#define COCKATRICE_INTERFACE_CARDS_DISPLAY_SETTINGS_PROVIDER_H + +#include + +class ICardsDisplaySettingsProvider +{ +public: + virtual ~ICardsDisplaySettingsProvider() = default; + + [[nodiscard]] virtual bool getDisplayCardNames() const = 0; + [[nodiscard]] virtual bool getRoundCardCorners() const = 0; + [[nodiscard]] virtual bool getOverrideAllCardArtWithPersonalPreference() const = 0; + [[nodiscard]] virtual bool getBumpSetsWithCardsInDeckToTop() const = 0; + [[nodiscard]] virtual int getPrintingSelectorSortOrder() const = 0; + [[nodiscard]] virtual int getPrintingSelectorCardSize() const = 0; + [[nodiscard]] virtual bool getIncludeRebalancedCards() const = 0; + [[nodiscard]] virtual bool getPrintingSelectorNavigationButtonsVisible() const = 0; + [[nodiscard]] virtual bool getTapAnimation() const = 0; + [[nodiscard]] virtual bool getArrowDrawAnimation() const = 0; + [[nodiscard]] virtual bool getAutoRotateSidewaysLayoutCards() const = 0; + [[nodiscard]] virtual bool getScaleCards() const = 0; + [[nodiscard]] virtual int getStackCardOverlapPercent() const = 0; + [[nodiscard]] virtual int getCardInfoViewMode() const = 0; + [[nodiscard]] virtual int getVisualDeckStorageCardSize() const = 0; + [[nodiscard]] virtual int getVisualDatabaseDisplayCardSize() const = 0; + [[nodiscard]] virtual int getVisualDeckEditorCardSize() const = 0; + [[nodiscard]] virtual int getEDHRecCardSize() const = 0; + [[nodiscard]] virtual int getArchidektPreviewSize() const = 0; + [[nodiscard]] virtual int getSampleHandSize() const = 0; + [[nodiscard]] virtual QString getCardLang() const = 0; +}; + +#endif // COCKATRICE_INTERFACE_CARDS_DISPLAY_SETTINGS_PROVIDER_H diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/interface_chat_settings_provider.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_chat_settings_provider.h new file mode 100644 index 000000000..cdf2da5eb --- /dev/null +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_chat_settings_provider.h @@ -0,0 +1,27 @@ +#ifndef COCKATRICE_INTERFACE_CHAT_SETTINGS_PROVIDER_H +#define COCKATRICE_INTERFACE_CHAT_SETTINGS_PROVIDER_H + +#include + +class IChatSettingsProvider +{ +public: + virtual ~IChatSettingsProvider() = default; + + [[nodiscard]] virtual bool getChatMention() const = 0; + [[nodiscard]] virtual bool getChatMentionCompleter() const = 0; + [[nodiscard]] virtual QString getChatMentionColor() const = 0; + [[nodiscard]] virtual QString getChatHighlightColor() const = 0; + [[nodiscard]] virtual bool getChatMentionForeground() const = 0; + [[nodiscard]] virtual bool getChatHighlightForeground() const = 0; + [[nodiscard]] virtual bool getIgnoreUnregisteredUsers() const = 0; + [[nodiscard]] virtual bool getIgnoreUnregisteredUserMessages() const = 0; + [[nodiscard]] virtual bool getIgnoreNonBuddyUserMessages() const = 0; + [[nodiscard]] virtual bool getShowMessagePopup() const = 0; + [[nodiscard]] virtual bool getShowMentionPopup() const = 0; + [[nodiscard]] virtual bool getRoomHistory() const = 0; + [[nodiscard]] virtual bool getIgnoreAllPrivateMessages() const = 0; + [[nodiscard]] virtual QString getHighlightWords() const = 0; +}; + +#endif // COCKATRICE_INTERFACE_CHAT_SETTINGS_PROVIDER_H diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/interface_deck_editor_settings_provider.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_deck_editor_settings_provider.h new file mode 100644 index 000000000..0ef39d044 --- /dev/null +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_deck_editor_settings_provider.h @@ -0,0 +1,15 @@ +#ifndef COCKATRICE_INTERFACE_DECK_EDITOR_SETTINGS_PROVIDER_H +#define COCKATRICE_INTERFACE_DECK_EDITOR_SETTINGS_PROVIDER_H + +class IDeckEditorSettingsProvider +{ +public: + virtual ~IDeckEditorSettingsProvider() = default; + + [[nodiscard]] virtual bool getOpenDeckInNewTab() const = 0; + [[nodiscard]] virtual bool getBannerCardComboBoxVisible() const = 0; + [[nodiscard]] virtual bool getTagsWidgetVisible() const = 0; + [[nodiscard]] virtual int getDefaultDeckEditorType() const = 0; +}; + +#endif // COCKATRICE_INTERFACE_DECK_EDITOR_SETTINGS_PROVIDER_H diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/interface_game_settings_provider.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_game_settings_provider.h new file mode 100644 index 000000000..2b1ddf8ec --- /dev/null +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_game_settings_provider.h @@ -0,0 +1,29 @@ +#ifndef COCKATRICE_INTERFACE_GAME_SETTINGS_PROVIDER_H +#define COCKATRICE_INTERFACE_GAME_SETTINGS_PROVIDER_H + +#include + +class IGameSettingsProvider +{ +public: + virtual ~IGameSettingsProvider() = default; + + [[nodiscard]] virtual QString getGameDescription() const = 0; + [[nodiscard]] virtual int getMaxPlayers() const = 0; + [[nodiscard]] virtual QString getGameTypes() const = 0; + [[nodiscard]] virtual bool getOnlyBuddies() const = 0; + [[nodiscard]] virtual bool getOnlyRegistered() const = 0; + [[nodiscard]] virtual bool getSpectatorsAllowed() const = 0; + [[nodiscard]] virtual bool getSpectatorsNeedPassword() const = 0; + [[nodiscard]] virtual bool getSpectatorsCanTalk() const = 0; + [[nodiscard]] virtual bool getSpectatorsCanSeeEverything() const = 0; + [[nodiscard]] virtual bool getCreateGameAsSpectator() const = 0; + [[nodiscard]] virtual int getDefaultStartingLifeTotal() const = 0; + [[nodiscard]] virtual bool getShareDecklistsOnLoad() const = 0; + [[nodiscard]] virtual bool getRememberGameSettings() const = 0; + [[nodiscard]] virtual bool getLocalGameRememberSettings() const = 0; + [[nodiscard]] virtual int getLocalGameMaxPlayers() const = 0; + [[nodiscard]] virtual int getLocalGameStartingLifeTotal() const = 0; +}; + +#endif // COCKATRICE_INTERFACE_GAME_SETTINGS_PROVIDER_H diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/interface_interface_settings_provider.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_interface_settings_provider.h new file mode 100644 index 000000000..bc2118cb3 --- /dev/null +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_interface_settings_provider.h @@ -0,0 +1,95 @@ +#ifndef COCKATRICE_INTERFACE_INTERFACE_SETTINGS_PROVIDER_H +#define COCKATRICE_INTERFACE_INTERFACE_SETTINGS_PROVIDER_H + +#include +#include +#include +#include + +/** + * @brief Whether playmats are rendered in-game, and for whom. + */ +enum PlaymatVisibility +{ + PlaymatVisibilityNone = 0, ///< Don't use playmats. + PlaymatVisibilityOwnOnly = 1, ///< Show the local player's playmat only. + PlaymatVisibilityAll = 2 ///< Show playmats for all players. +}; + +/** + * @brief How the user-level playmat collection interacts with the deck-configured playmat. + */ +enum PlaymatMode +{ + PlaymatModeOverrideDeck = 0, ///< Always use the collection, ignoring any deck-configured playmat. + PlaymatModeFallback = 1, ///< Prefer the deck-configured playmat; fall back to the collection when absent. + PlaymatModeDeckOnly = 2 ///< Use only the deck-configured playmat, ignoring the collection. +}; + +/** + * @brief How the user-level fallback playmat list is consulted when a deck has no playmat configured. + */ +enum PlaymatFallbackMode +{ + PlaymatFallbackModeFixed = 0, ///< Always use the first entry of the fallback list. + PlaymatFallbackModeRoundRobin = 1, ///< Cycle through the list, advancing one entry per resolution. + PlaymatFallbackModeRandom = 2 ///< Pick a random entry per resolution. +}; + +class IInterfaceSettingsProvider +{ +public: + virtual ~IInterfaceSettingsProvider() = default; + + [[nodiscard]] virtual bool getUseTearOffMenus() const = 0; + [[nodiscard]] virtual int getCardViewInitialRowsMax() const = 0; + [[nodiscard]] virtual int getCardViewExpandedRowsMax() const = 0; + [[nodiscard]] virtual bool getCloseEmptyCardView() const = 0; + [[nodiscard]] virtual bool getFocusCardViewSearchBar() const = 0; + [[nodiscard]] virtual bool getKeepGameChatFocus() const = 0; + [[nodiscard]] virtual bool getNotificationsEnabled() const = 0; + [[nodiscard]] virtual bool getSpectatorNotificationsEnabled() const = 0; + [[nodiscard]] virtual bool getBuddyConnectNotificationsEnabled() const = 0; + [[nodiscard]] virtual bool getDoubleClickToPlay() const = 0; + [[nodiscard]] virtual bool getClickPlaysAllSelected() const = 0; + [[nodiscard]] virtual bool getPlayToStack() const = 0; + [[nodiscard]] virtual bool getDoNotDeleteArrowsInSubPhases() const = 0; + [[nodiscard]] virtual int getStartingHandSize() const = 0; + [[nodiscard]] virtual bool getAnnotateTokens() const = 0; + [[nodiscard]] virtual bool getShowDragSelectionCount() const = 0; + [[nodiscard]] virtual bool getShowTotalSelectionCount() const = 0; + [[nodiscard]] virtual int getTallyType() const = 0; + [[nodiscard]] virtual bool getHorizontalHand() const = 0; + [[nodiscard]] virtual bool getInvertVerticalCoordinate() const = 0; + [[nodiscard]] virtual int getMinPlayersForMultiColumnLayout() const = 0; + [[nodiscard]] virtual int getRewindBufferingMs() const = 0; + [[nodiscard]] virtual qreal getFastForwardSpeed() const = 0; + [[nodiscard]] virtual bool getSkipEmptySections() const = 0; + [[nodiscard]] virtual bool getLeftJustified() const = 0; + [[nodiscard]] virtual int getZoneViewGroupByIndex() const = 0; + [[nodiscard]] virtual int getZoneViewSortByIndex() const = 0; + [[nodiscard]] virtual bool getZoneViewPileView() const = 0; + [[nodiscard]] virtual bool getShowStatusBar() const = 0; + [[nodiscard]] virtual bool getShowShortcuts() const = 0; + [[nodiscard]] virtual bool getShowGameSelectorFilterToolbar() const = 0; + [[nodiscard]] virtual bool getLifeCounterAnimationsEnabled() const = 0; + [[nodiscard]] virtual bool getBattlefieldFlashEnabled() const = 0; + [[nodiscard]] virtual QStringList getUserListExpandedSections() const = 0; + + /** @brief Who gets playmats rendered: @ref PlaymatVisibility. */ + [[nodiscard]] virtual int getPlaymatVisibility() const = 0; + + /** @brief User-level playmat collection. Used either as a forced playmat + * (mode == @ref PlaymatModeOverrideDeck) or as a fallback when a deck has none + * (mode == @ref PlaymatModeFallback). */ + [[nodiscard]] virtual QList getPlaymatFallbackList() const = 0; + + /** @brief How the fallback list is applied: @ref PlaymatMode. */ + [[nodiscard]] virtual int getPlaymatMode() const = 0; + + /** @brief How the fallback list is picked from when mode is @ref PlaymatModeFallback: + * @ref PlaymatFallbackMode. */ + [[nodiscard]] virtual int getPlaymatFallbackBehavior() const = 0; +}; + +#endif // COCKATRICE_INTERFACE_INTERFACE_SETTINGS_PROVIDER_H diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/interface_paths_settings_provider.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_paths_settings_provider.h new file mode 100644 index 000000000..d4edc8b81 --- /dev/null +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_paths_settings_provider.h @@ -0,0 +1,24 @@ +#ifndef COCKATRICE_INTERFACE_PATHS_SETTINGS_PROVIDER_H +#define COCKATRICE_INTERFACE_PATHS_SETTINGS_PROVIDER_H + +#include + +class IPathsSettingsProvider +{ +public: + virtual ~IPathsSettingsProvider() = default; + + [[nodiscard]] virtual QString getDeckPath() const = 0; + [[nodiscard]] virtual QString getFiltersPath() const = 0; + [[nodiscard]] virtual QString getReplaysPath() const = 0; + [[nodiscard]] virtual QString getPicsPath() const = 0; + [[nodiscard]] virtual QString getCustomPicsPath() const = 0; + [[nodiscard]] virtual QString getThemesPath() const = 0; + [[nodiscard]] virtual QString getCardDatabasePath() const = 0; + [[nodiscard]] virtual QString getCustomCardDatabasePath() const = 0; + [[nodiscard]] virtual QString getTokenDatabasePath() const = 0; + [[nodiscard]] virtual QString getSpoilerCardDatabasePath() const = 0; + [[nodiscard]] virtual QString getRedirectCachePath() const = 0; +}; + +#endif // COCKATRICE_INTERFACE_PATHS_SETTINGS_PROVIDER_H diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/interface_personal_settings_provider.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_personal_settings_provider.h new file mode 100644 index 000000000..f3d3b1220 --- /dev/null +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_personal_settings_provider.h @@ -0,0 +1,18 @@ +#ifndef COCKATRICE_INTERFACE_PERSONAL_SETTINGS_PROVIDER_H +#define COCKATRICE_INTERFACE_PERSONAL_SETTINGS_PROVIDER_H + +#include +#include +#include + +class IPersonalSettingsProvider +{ +public: + virtual ~IPersonalSettingsProvider() = default; + + [[nodiscard]] virtual QString getLang() const = 0; + [[nodiscard]] virtual bool getShowTipsOnStartup() const = 0; + [[nodiscard]] virtual QList getSeenTips() const = 0; +}; + +#endif // COCKATRICE_INTERFACE_PERSONAL_SETTINGS_PROVIDER_H diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/interface_sound_settings_provider.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_sound_settings_provider.h new file mode 100644 index 000000000..7bc170d0d --- /dev/null +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_sound_settings_provider.h @@ -0,0 +1,16 @@ +#ifndef COCKATRICE_INTERFACE_SOUND_SETTINGS_PROVIDER_H +#define COCKATRICE_INTERFACE_SOUND_SETTINGS_PROVIDER_H + +#include + +class ISoundSettingsProvider +{ +public: + virtual ~ISoundSettingsProvider() = default; + + [[nodiscard]] virtual bool getSoundEnabled() const = 0; + [[nodiscard]] virtual QString getSoundThemeName() const = 0; + [[nodiscard]] virtual int getMasterVolume() const = 0; +}; + +#endif // COCKATRICE_INTERFACE_SOUND_SETTINGS_PROVIDER_H diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/interface_tabs_settings_provider.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_tabs_settings_provider.h new file mode 100644 index 000000000..054c4cd72 --- /dev/null +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_tabs_settings_provider.h @@ -0,0 +1,27 @@ +#ifndef COCKATRICE_INTERFACE_TABS_SETTINGS_PROVIDER_H +#define COCKATRICE_INTERFACE_TABS_SETTINGS_PROVIDER_H + +#include + +class ITabsSettingsProvider +{ +public: + virtual ~ITabsSettingsProvider() = default; + + [[nodiscard]] virtual int getStartupTabIndex() const = 0; + [[nodiscard]] virtual QString getStartupServerHost() const = 0; + [[nodiscard]] virtual QString getStartupServerPort() const = 0; + [[nodiscard]] virtual QString getStartupRoomName() const = 0; + [[nodiscard]] virtual bool getTabVisualDeckStorageOpen() const = 0; + [[nodiscard]] virtual bool getTabServerOpen() const = 0; + [[nodiscard]] virtual bool getTabAccountOpen() const = 0; + [[nodiscard]] virtual bool getTabDeckStorageOpen() const = 0; + [[nodiscard]] virtual bool getTabReplaysOpen() const = 0; + [[nodiscard]] virtual bool getTabAdminOpen() const = 0; + [[nodiscard]] virtual bool getTabLogOpen() const = 0; + [[nodiscard]] virtual bool getTabReportOpen() const = 0; + [[nodiscard]] virtual bool getTabModerationOpen() const = 0; + [[nodiscard]] virtual bool getTabCardArtRulesOpen() const = 0; +}; + +#endif // COCKATRICE_INTERFACE_TABS_SETTINGS_PROVIDER_H diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/interface_updates_settings_provider.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_updates_settings_provider.h new file mode 100644 index 000000000..687484c2c --- /dev/null +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_updates_settings_provider.h @@ -0,0 +1,23 @@ +#ifndef COCKATRICE_INTERFACE_UPDATES_SETTINGS_PROVIDER_H +#define COCKATRICE_INTERFACE_UPDATES_SETTINGS_PROVIDER_H + +#include + +class IUpdatesSettingsProvider +{ +public: + virtual ~IUpdatesSettingsProvider() = default; + + [[nodiscard]] virtual bool getCheckUpdatesOnStartup() const = 0; + [[nodiscard]] virtual bool getStartupCardUpdateCheckPromptForUpdate() = 0; + [[nodiscard]] virtual bool getStartupCardUpdateCheckAlwaysUpdate() = 0; + [[nodiscard]] virtual int getCardUpdateCheckInterval() const = 0; + [[nodiscard]] virtual QDate getLastCardUpdateCheck() const = 0; + [[nodiscard]] virtual bool getCardUpdateCheckRequired() const = 0; + [[nodiscard]] virtual bool getAlwaysEnableNewSets() const = 0; + [[nodiscard]] virtual bool getNotifyAboutUpdates() const = 0; + [[nodiscard]] virtual bool getNotifyAboutNewVersion() const = 0; + [[nodiscard]] virtual int getUpdateReleaseChannelIndex() const = 0; +}; + +#endif // COCKATRICE_INTERFACE_UPDATES_SETTINGS_PROVIDER_H diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/interface_visual_deck_storage_settings_provider.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_visual_deck_storage_settings_provider.h new file mode 100644 index 000000000..114b053c6 --- /dev/null +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_visual_deck_storage_settings_provider.h @@ -0,0 +1,30 @@ +#ifndef COCKATRICE_INTERFACE_VISUAL_DECK_STORAGE_SETTINGS_PROVIDER_H +#define COCKATRICE_INTERFACE_VISUAL_DECK_STORAGE_SETTINGS_PROVIDER_H + +#include + +class IVisualDeckStorageSettingsProvider +{ +public: + virtual ~IVisualDeckStorageSettingsProvider() = default; + + [[nodiscard]] virtual int getVisualDeckStorageSortingOrder() const = 0; + [[nodiscard]] virtual bool getVisualDeckStorageShowFolders() const = 0; + [[nodiscard]] virtual bool getVisualDeckStorageShowTagFilter() const = 0; + [[nodiscard]] virtual QStringList getVisualDeckStorageDefaultTagsList() const = 0; + [[nodiscard]] virtual bool getVisualDeckStorageSearchFolderNames() const = 0; + [[nodiscard]] virtual bool getVisualDeckStorageShowColorIdentity() const = 0; + [[nodiscard]] virtual bool getVisualDeckStorageShowBannerCardComboBox() const = 0; + [[nodiscard]] virtual bool getVisualDeckStorageShowTagsOnDeckPreviews() const = 0; + [[nodiscard]] virtual bool getVisualDeckStorageDrawUnusedColorIdentities() const = 0; + [[nodiscard]] virtual int getVisualDeckStorageUnusedColorIdentitiesOpacity() const = 0; + [[nodiscard]] virtual int getVisualDeckStorageTooltipType() const = 0; + [[nodiscard]] virtual bool getVisualDeckStoragePromptForConversion() const = 0; + [[nodiscard]] virtual bool getVisualDeckStorageAlwaysConvert() const = 0; + [[nodiscard]] virtual bool getVisualDeckStorageInGame() const = 0; + [[nodiscard]] virtual bool getVisualDeckStorageSelectionAnimation() const = 0; + [[nodiscard]] virtual bool getVisualDatabaseDisplayFilterToMostRecentSetsEnabled() const = 0; + [[nodiscard]] virtual int getVisualDatabaseDisplayFilterToMostRecentSetsAmount() const = 0; +}; + +#endif // COCKATRICE_INTERFACE_VISUAL_DECK_STORAGE_SETTINGS_PROVIDER_H diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/noop_card_set_priority_controller.h b/libcockatrice_interfaces/libcockatrice/interfaces/noop_card_set_priority_controller.h index 188e2ced9..8cc2011bd 100644 --- a/libcockatrice_interfaces/libcockatrice/interfaces/noop_card_set_priority_controller.h +++ b/libcockatrice_interfaces/libcockatrice/interfaces/noop_card_set_priority_controller.h @@ -29,6 +29,11 @@ public: return true; } + SetOptions getSetOptions(QString /* shortName */) const override + { + return {0, true, true}; + } + void saveSets(const QVector & /* data */) override { } diff --git a/libcockatrice_models/libcockatrice/models/database/CMakeLists.txt b/libcockatrice_models/libcockatrice/models/database/CMakeLists.txt index 950d6d79f..840f14b08 100644 --- a/libcockatrice_models/libcockatrice/models/database/CMakeLists.txt +++ b/libcockatrice_models/libcockatrice/models/database/CMakeLists.txt @@ -12,11 +12,7 @@ set(HEADERS token/token_edit_model.h ) -if(Qt6_FOUND) - qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) -elseif(Qt5_FOUND) - qt5_wrap_cpp(MOC_SOURCES ${HEADERS}) -endif() +qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) add_library( libcockatrice_models_database STATIC diff --git a/libcockatrice_models/libcockatrice/models/database/card/card_completer_proxy_model.cpp b/libcockatrice_models/libcockatrice/models/database/card/card_completer_proxy_model.cpp index 387eb454f..f31463b50 100644 --- a/libcockatrice_models/libcockatrice/models/database/card/card_completer_proxy_model.cpp +++ b/libcockatrice_models/libcockatrice/models/database/card/card_completer_proxy_model.cpp @@ -6,6 +6,10 @@ CardCompleterProxyModel::CardCompleterProxyModel(QObject *parent) : QSortFilterP bool CardCompleterProxyModel::filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const { + if (!sourceModel()) { + return false; + } + if (filterRegularExpression().pattern().isEmpty()) { return true; } @@ -13,6 +17,5 @@ bool CardCompleterProxyModel::filterAcceptsRow(int sourceRow, const QModelIndex QModelIndex index = sourceModel()->index(sourceRow, 0, sourceParent); QString data = index.data(Qt::DisplayRole).toString(); - // Ensure substring matching return data.contains(filterRegularExpression()); } diff --git a/libcockatrice_models/libcockatrice/models/database/card/card_search_model.cpp b/libcockatrice_models/libcockatrice/models/database/card/card_search_model.cpp index d1fbbac2f..621f28983 100644 --- a/libcockatrice_models/libcockatrice/models/database/card/card_search_model.cpp +++ b/libcockatrice_models/libcockatrice/models/database/card/card_search_model.cpp @@ -23,8 +23,14 @@ QVariant CardSearchModel::data(const QModelIndex &index, int role) const return QVariant(); } + const SearchResult &result = searchResults.at(index.row()); + if (role == Qt::DisplayRole) { - return searchResults.at(index.row()).card->getName(); + return result.card->getName(); + } + + if (role == CardInfoRole) { + return QVariant::fromValue(result.card); } return QVariant(); @@ -36,43 +42,62 @@ void CardSearchModel::updateSearchResults(const QString &query) searchResults.clear(); if (query.isEmpty() || !sourceModel) { + endResetModel(); return; } - // Set the filter for the display model - sourceModel->setCardName(query); + CardDatabaseModel *sourceDbModel = qobject_cast(sourceModel->sourceModel()); + if (!sourceDbModel) { + endResetModel(); + return; + } - // Collect matching cards and compute Levenshtein distance - for (int i = 0; i < sourceModel->rowCount(); ++i) { - QModelIndex modelIndex = sourceModel->index(i, 0); - QModelIndex sourceIndex = sourceModel->mapToSource(modelIndex); - CardDatabaseModel *sourceDbModel = qobject_cast(sourceModel->sourceModel()); + const QString lowerQuery = query.toLower(); - if (!sourceDbModel || !sourceIndex.isValid()) { - return; - } - - CardInfoPtr card = sourceDbModel->getCard(sourceIndex.row()); + QList prefixMatches; + QList containsMatches; + // Iterate the raw database model directly so results are always complete and fresh + const int rowCount = sourceDbModel->rowCount(); + for (int i = 0; i < rowCount; ++i) { + CardInfoPtr card = sourceDbModel->getCard(i); if (!card) { continue; } - int distance = levenshteinDistance(query.toLower(), card->getName().toLower()); - searchResults.append({card, distance}); + const QString lowerName = card->getName().toLower(); + if (!lowerName.contains(lowerQuery)) { + continue; + } + + const int distance = levenshteinDistance(lowerQuery, lowerName); + + if (lowerName.startsWith(lowerQuery)) { + prefixMatches.append({card, distance}); + } else { + containsMatches.append({card, distance}); + } } - // Sort by Levenshtein distance (lower distance = better match) - std::sort(searchResults.begin(), searchResults.end(), - [](const SearchResult &a, const SearchResult &b) { return a.distance < b.distance; }); + auto sortByDistanceThenLength = [](const SearchResult &a, const SearchResult &b) { + if (a.distance != b.distance) { + return a.distance < b.distance; + } + return a.card->getName().size() < b.card->getName().size(); + }; - // Keep only the top 5 results + std::sort(prefixMatches.begin(), prefixMatches.end(), sortByDistanceThenLength); + std::sort(containsMatches.begin(), containsMatches.end(), sortByDistanceThenLength); + + // Prefix matches always come first, then contains-only matches + searchResults.reserve(prefixMatches.size() + containsMatches.size()); + searchResults.append(prefixMatches); + searchResults.append(containsMatches); + + // Keep only the top 10 results if (searchResults.size() > 10) { searchResults = searchResults.mid(0, 10); } - emit dataChanged(index(0, 0), index(rowCount() - 1, 0)); - emit layoutChanged(); - endResetModel(); } diff --git a/libcockatrice_models/libcockatrice/models/database/card/card_search_model.h b/libcockatrice_models/libcockatrice/models/database/card/card_search_model.h index bc4be7a0e..646bf7e61 100644 --- a/libcockatrice_models/libcockatrice/models/database/card/card_search_model.h +++ b/libcockatrice_models/libcockatrice/models/database/card/card_search_model.h @@ -15,6 +15,11 @@ class CardSearchModel : public QAbstractListModel { Q_OBJECT public: + enum CardSearchRoles + { + CardInfoRole = Qt::UserRole + 1, + }; + explicit CardSearchModel(CardDatabaseDisplayModel *sourceModel, QObject *parent = nullptr); [[nodiscard]] int rowCount(const QModelIndex &parent = QModelIndex()) const override; diff --git a/libcockatrice_models/libcockatrice/models/database/card_database_model.cpp b/libcockatrice_models/libcockatrice/models/database/card_database_model.cpp index 253dcd134..ecd26a9f8 100644 --- a/libcockatrice_models/libcockatrice/models/database/card_database_model.cpp +++ b/libcockatrice_models/libcockatrice/models/database/card_database_model.cpp @@ -12,6 +12,7 @@ CardDatabaseModel::CardDatabaseModel(CardDatabase *_db, bool _showOnlyCardsFromE connect(db, &CardDatabase::cardRemoved, this, &CardDatabaseModel::cardRemoved); connect(db, &CardDatabase::cardDatabaseEnabledSetsChanged, this, &CardDatabaseModel::cardDatabaseEnabledSetsChanged); + connect(db, &CardDatabase::cardDatabaseReset, this, &CardDatabaseModel::resetCardList); cardDatabaseEnabledSetsChanged(); } @@ -110,19 +111,50 @@ bool CardDatabaseModel::checkCardHasAtLeastOneEnabledSet(const CardInfoPtr &card void CardDatabaseModel::cardDatabaseEnabledSetsChanged() { - // remove all the cards no more present in at least one enabled set + // Build the new card list in a single pass. + QList newCardList; + newCardList.reserve(cardList.size()); for (const CardInfoPtr &card : cardList) { - if (!checkCardHasAtLeastOneEnabledSet(card)) { - cardRemoved(card); + if (checkCardHasAtLeastOneEnabledSet(card)) { + newCardList.append(card); + } + } + for (const CardInfoPtr &card : db->getCardList()) { + if (!cardListSet.contains(card) && checkCardHasAtLeastOneEnabledSet(card)) { + newCardList.append(card); } } - // re-check all the card currently not shown, maybe their part of a newly-enabled set - for (const CardInfoPtr &card : db->getCardList()) { - if (!cardListSet.contains(card)) { - cardAdded(card); + if (newCardList == cardList) { + return; + } + + // Rebuild the whole list inside a single model reset instead of emitting + // per-card insert/remove notifications. With tens of thousands of cards the + // per-card path is the dominant cost of constructing a CardDatabaseModel. + QSet oldCardListSet = cardListSet; + QSet newCardListSet(newCardList.begin(), newCardList.end()); + + beginResetModel(); + + // Disconnect cards that are no longer shown. + for (const CardInfoPtr &card : cardList) { + if (!newCardListSet.contains(card)) { + disconnect(card.data(), nullptr, this, nullptr); } } + + cardList = newCardList; + cardListSet = newCardListSet; + + // Connect cards that are now shown for the first time. + for (const CardInfoPtr &card : cardList) { + if (!oldCardListSet.contains(card)) { + connect(card.data(), &CardInfo::cardInfoChanged, this, &CardDatabaseModel::cardInfoChanged); + } + } + + endResetModel(); } void CardDatabaseModel::cardAdded(const CardInfoPtr &card) @@ -151,3 +183,18 @@ void CardDatabaseModel::cardRemoved(CardInfoPtr card) cardList.removeAt(row); endRemoveRows(); } + +void CardDatabaseModel::resetCardList() +{ + beginResetModel(); + cardList.clear(); + cardListSet.clear(); + for (const CardInfoPtr &card : db->getCardList()) { + if (checkCardHasAtLeastOneEnabledSet(card)) { + cardList.append(card); + cardListSet.insert(card); + connect(card.data(), &CardInfo::cardInfoChanged, this, &CardDatabaseModel::cardInfoChanged); + } + } + endResetModel(); +} diff --git a/libcockatrice_models/libcockatrice/models/database/card_database_model.h b/libcockatrice_models/libcockatrice/models/database/card_database_model.h index 8655389d7..ad84de6f4 100644 --- a/libcockatrice_models/libcockatrice/models/database/card_database_model.h +++ b/libcockatrice_models/libcockatrice/models/database/card_database_model.h @@ -57,6 +57,7 @@ private slots: void cardRemoved(CardInfoPtr card); void cardInfoChanged(const CardInfoPtr &card); void cardDatabaseEnabledSetsChanged(); + void resetCardList(); }; #endif diff --git a/libcockatrice_models/libcockatrice/models/database/card_set/card_sets_model.cpp b/libcockatrice_models/libcockatrice/models/database/card_set/card_sets_model.cpp index 5e0cc31d8..8fd5311f2 100644 --- a/libcockatrice_models/libcockatrice/models/database/card_set/card_sets_model.cpp +++ b/libcockatrice_models/libcockatrice/models/database/card_set/card_sets_model.cpp @@ -303,12 +303,14 @@ bool SetsDisplayModel::filterAcceptsRow(int sourceRow, const QModelIndex &source auto typeIndex = sourceModel()->index(sourceRow, SetsModel::SetTypeCol, sourceParent); auto nameIndex = sourceModel()->index(sourceRow, SetsModel::LongNameCol, sourceParent); auto shortNameIndex = sourceModel()->index(sourceRow, SetsModel::ShortNameCol, sourceParent); + auto dateIndex = sourceModel()->index(sourceRow, SetsModel::ReleaseDateCol, sourceParent); const auto filter = filterRegularExpression(); return (sourceModel()->data(typeIndex).toString().contains(filter) || sourceModel()->data(nameIndex).toString().contains(filter) || - sourceModel()->data(shortNameIndex).toString().contains(filter)); + sourceModel()->data(shortNameIndex).toString().contains(filter) || + sourceModel()->data(dateIndex).toString().contains(filter)); } bool SetsDisplayModel::lessThan(const QModelIndex &left, const QModelIndex &right) const diff --git a/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt b/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt index 851636a35..a6ab2a204 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt +++ b/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt @@ -4,14 +4,11 @@ set(CMAKE_AUTORCC ON) set(HEADERS deck_list_model.h deck_list_sort_filter_proxy_model.h) -if(Qt6_FOUND) - qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) -elseif(Qt5_FOUND) - qt5_wrap_cpp(MOC_SOURCES ${HEADERS}) -endif() +qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) add_library( - libcockatrice_models_deck_list STATIC ${MOC_SOURCES} deck_list_model.cpp deck_list_sort_filter_proxy_model.cpp + libcockatrice_models_deck_list STATIC ${MOC_SOURCES} deck_list_model.cpp deck_list_model_custom_zones.cpp + deck_list_sort_filter_proxy_model.cpp ) target_include_directories(libcockatrice_models_deck_list PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) diff --git a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp index 9b43281c1..a5628a844 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp @@ -28,6 +28,16 @@ DeckListModel::~DeckListModel() delete root; } +void DeckListModel::setDisplayLanguage(const QString &lang) +{ + if (displayLang == lang) { + return; + } + displayLang = lang; + emit layoutAboutToBeChanged(); + emit layoutChanged(); +} + /** * @brief Extract the value from the card that is used for the group criteria. * @param info Pointer to card information. @@ -66,7 +76,8 @@ void DeckListModel::rebuildTree() for (int j = 0; j < currentZone->size(); j++) { auto *currentCard = dynamic_cast(currentZone->at(j)); - //! \todo Better sanity checking. + // Non-card children are custom zones; they are mirrored in a single + // pass below so each is mirrored exactly once. if (currentCard == nullptr) { continue; } @@ -82,8 +93,19 @@ void DeckListModel::rebuildTree() new DecklistModelCardNode(currentCard, groupNode); } + + // Custom zones nested under the board zone are mirrored as-is, with their + // cards as direct children (no further grouping). + DeckListModelCustomZones::mirrorCustomZones(currentZone, node); } + // The shadow tree was built in deck file order. Apply the active sort while + // the reset is still open so every consumer (tree view and visual editor) + // sees the canonical order from the start. sortShadowTree emits no signals, + // which is only valid before endResetModel closes the reset. + root->setSortMethod(lastKnownColumn == 0 ? DeckSortMethod::ByNumber : DeckSortMethod::ByName); + sortShadowTree(root, lastKnownOrder); + endResetModel(); refreshCardFormatLegalities(); @@ -154,6 +176,9 @@ QVariant DeckListModel::data(const QModelIndex &index, int role) const case DeckRoles::IsLegalRole: return true; + case DeckRoles::IsCustomZoneRole: + return DeckListModelCustomZones::isCustomZone(group); + default: return {}; } @@ -166,8 +191,15 @@ QVariant DeckListModel::data(const QModelIndex &index, int role) const switch (index.column()) { case DeckListModelColumns::CARD_AMOUNT: return card->getNumber(); - case DeckListModelColumns::CARD_NAME: + case DeckListModelColumns::CARD_NAME: { + if (role == Qt::DisplayRole) { + CardInfoPtr info = CardDatabaseManager::query()->getCardInfo(card->getName()); + if (info) { + return info->getLocalizedName(displayLang); + } + } return card->getName(); + } case DeckListModelColumns::CARD_SET: return card->getCardSetShortName(); case DeckListModelColumns::CARD_COLLECTOR_NUMBER: @@ -190,6 +222,10 @@ QVariant DeckListModel::data(const QModelIndex &index, int role) const return card->getFormatLegality(); } + case DeckRoles::IsCustomZoneRole: { + return false; + } + default: { return {}; } @@ -327,6 +363,13 @@ bool DeckListModel::removeRows(int row, int count, const QModelIndex &parent) return false; } + // Custom zone rows are managed through the deck tree, never removed as model rows. + for (int i = 0; i < count; i++) { + if (DeckListModelCustomZones::isCustomZone(node->at(row + i))) { + return false; + } + } + beginRemoveRows(parent, row, row + count - 1); for (int i = 0; i < count; i++) { AbstractDecklistNode *toDelete = node->takeAt(row); @@ -337,7 +380,8 @@ bool DeckListModel::removeRows(int row, int count, const QModelIndex &parent) } endRemoveRows(); - if (node->empty() && (node != root)) { + // Empty criteria groups get pruned, but custom zones stay until explicitly deleted. + if (node->empty() && (node != root) && !DeckListModelCustomZones::isCustomZone(node)) { removeRows(parent.row(), 1, parent.parent()); } else { emitRecursiveUpdates(parent); @@ -351,7 +395,8 @@ bool DeckListModel::removeRows(int row, int count, const QModelIndex &parent) InnerDecklistNode *DeckListModel::createNodeIfNeeded(const QString &name, InnerDecklistNode *parent) { - auto *newNode = dynamic_cast(parent->findChild(name)); + // Group lookups must not resolve a mirrored custom zone that shares the name. + auto *newNode = DeckListModelCustomZones::findGroupChild(parent, name); if (!newNode) { beginInsertRows(nodeToIndex(parent), parent->size(), parent->size()); newNode = new InnerDecklistNode(name, parent); @@ -365,24 +410,44 @@ DecklistModelCardNode *DeckListModel::findCardNode(const QString &cardName, const QString &providerId, const QString &cardNumber) const { - InnerDecklistNode *zoneNode = dynamic_cast(root->findChild(zoneName)); - if (!zoneNode) { - return nullptr; - } - CardInfoPtr info = CardDatabaseManager::query()->getCardInfo(cardName); if (!info) { return nullptr; } - QString groupCriteria = extractGroupCriteriaValue(info, activeGroupCriteria); - InnerDecklistNode *groupNode = dynamic_cast(zoneNode->findChild(groupCriteria)); - if (!groupNode) { - return nullptr; + // 1. Board zone lookup: search the criteria groups, then the custom zones + // nested under the board. + if (auto *zoneNode = dynamic_cast(root->findChild(zoneName))) { + QString groupCriteria = extractGroupCriteriaValue(info, activeGroupCriteria); + if (auto *groupNode = DeckListModelCustomZones::findGroupChild(zoneNode, groupCriteria)) { + if (auto *card = dynamic_cast( + groupNode->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber))) { + return card; + } + } + + for (auto *child : *zoneNode) { + if (!DeckListModelCustomZones::isCustomZone(child)) { + continue; + } + auto *customZone = dynamic_cast(child); + if (!customZone) { + continue; + } + if (auto *card = dynamic_cast( + customZone->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber))) { + return card; + } + } } - return dynamic_cast( - groupNode->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber)); + // 2. Custom zone lookup by name (custom zone names are deck-unique). + if (auto *customZone = DeckListModelCustomZones::findSubZoneByName(root, zoneName)) { + return dynamic_cast( + customZone->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber)); + } + + return nullptr; } QModelIndex DeckListModel::findCard(const QString &cardName, @@ -423,29 +488,95 @@ QModelIndex DeckListModel::addCard(const ExactCard &card, const QString &zoneNam return {}; } - InnerDecklistNode *zoneNode = createNodeIfNeeded(zoneName, root); - CardInfoPtr cardInfo = card.getCardPtr(); PrintingInfo printingInfo = card.getPrinting(); - QString groupCriteria = extractGroupCriteriaValue(cardInfo, activeGroupCriteria); - InnerDecklistNode *groupNode = createNodeIfNeeded(groupCriteria, zoneNode); + InnerDecklistNode *cardParent = nullptr; - const QModelIndex parentIndex = nodeToIndex(groupNode); - auto *cardNode = dynamic_cast(groupNode->findCardChildByNameProviderIdAndNumber( + auto *boardNode = dynamic_cast(root->findChild(zoneName)); + auto *customZoneNode = boardNode ? nullptr : DeckListModelCustomZones::findSubZoneByName(root, zoneName); + + // Mirroring flattens nested deck sub-zones into shadow rows, so a shadow row + // index is only usable as a deck-tree position while both sides have the same + // direct-children shape. When they diverge, the card is appended to the deck + // zone instead of being written out of range. + InnerDecklistNode *deckCardParent = nullptr; + bool customZoneNeedsAppend = false; + + if (boardNode) { + // Board zone: cards are grouped by the active criteria. + QString groupCriteria = extractGroupCriteriaValue(cardInfo, activeGroupCriteria); + cardParent = createNodeIfNeeded(groupCriteria, boardNode); + } else if (customZoneNode) { + // Custom zone: cards live flat inside the zone. + cardParent = customZoneNode; + auto *listRoot = deckList->getTree()->getRoot(); + for (int i = 0; i < listRoot->size(); ++i) { + auto *boardZone = dynamic_cast(listRoot->at(i)); + if (!boardZone) { + continue; + } + deckCardParent = dynamic_cast(boardZone->findChild(zoneName)); + if (deckCardParent) { + break; + } + } + // A deck custom zone holding nested sub-zones mirrors with flattened rows, + // so a shadow row index does not map onto its direct children. + if (deckCardParent) { + for (int i = 0; i < deckCardParent->size(); ++i) { + if (dynamic_cast(deckCardParent->at(i))) { + customZoneNeedsAppend = true; + break; + } + } + } + } else { + // Not present in the shadow tree. The deck tree may still hold a custom + // zone that has not been mirrored (callers can add a zone and then a + // card without a rebuild). Check before falling back to creating a + // top-level zone the deck does not actually have. + auto *listRoot = deckList->getTree()->getRoot(); + bool hasDeckZone = false; + for (int i = 0; i < listRoot->size(); ++i) { + if (auto *boardZone = dynamic_cast(listRoot->at(i))) { + // Only real zones count: a card sitting directly under the board + // shares the name comparison but is not a zone, and treating it as + // one would recurse forever without mirroring anything. + if (dynamic_cast(boardZone->findChild(zoneName))) { + hasDeckZone = true; + break; + } + } + } + + if (hasDeckZone) { + rebuildTree(); + return addCard(card, zoneName); + } + + // Unknown zone: create a top-level zone (legacy behavior). + QString groupCriteria = extractGroupCriteriaValue(cardInfo, activeGroupCriteria); + auto *newZone = createNodeIfNeeded(zoneName, root); + cardParent = createNodeIfNeeded(groupCriteria, newZone); + } + + const QModelIndex parentIndex = nodeToIndex(cardParent); + auto *cardNode = dynamic_cast(cardParent->findCardChildByNameProviderIdAndNumber( card.getName(), printingInfo.getUuid(), printingInfo.getProperty("num"))); const auto cardSetName = printingInfo.getSet().isNull() ? "" : printingInfo.getSet()->getCorrectedShortName(); bool cardNodeAdded = false; if (!cardNode) { // Determine the correct index - int insertRow = findSortedInsertRow(groupNode, cardInfo); + int insertRow = findSortedInsertRow(cardParent, cardInfo); + int deckInsertRow = customZoneNeedsAppend ? -1 : insertRow; - auto *decklistCard = deckList->addCard(cardInfo->getName(), zoneName, insertRow, cardSetName, + auto *decklistCard = deckList->addCard(cardInfo->getName(), zoneName, deckInsertRow, cardSetName, printingInfo.getProperty("num"), printingInfo.getProperty("uuid")); beginInsertRows(parentIndex, insertRow, insertRow); - cardNode = new DecklistModelCardNode(decklistCard, groupNode, insertRow); + cardNode = new DecklistModelCardNode(decklistCard, cardParent, insertRow); endInsertRows(); cardNodeAdded = true; @@ -576,21 +707,41 @@ QModelIndex DeckListModel::nodeToIndex(AbstractDecklistNode *node) const return createIndex(node->getParent()->indexOf(node), 0, node); } +/** + * @brief Sorts a freshly built shadow subtree without emitting model signals. + * + * Used by rebuildTree while the model reset is still open (emitting layout + * changes during a reset is invalid). Reorders every node just like + * sortHelper does, but ignores the movement mapping because there are no + * persistent indices established yet. + */ +void DeckListModel::sortShadowTree(InnerDecklistNode *node, Qt::SortOrder order) +{ + // The mapping is not needed: fresh shadow nodes have no persistent indices yet. + (void)DeckListModelCustomZones::sortWithCustomZonesLast(root, node, order); + + for (int i = node->size() - 1; i >= 0; --i) { + if (auto *subNode = dynamic_cast(node->at(i))) { + sortShadowTree(subNode, order); + } + } +} + void DeckListModel::sortHelper(InnerDecklistNode *node, Qt::SortOrder order) { - // Sort children of node and save the information needed to - // update the list of persistent indexes. - QVector> sortResult = node->sort(order); + // Sort children (custom zones always sorted after groups within a board) and + // use the movement mapping to update the list of persistent indices. + const auto mapping = DeckListModelCustomZones::sortWithCustomZonesLast(root, node, order); QModelIndexList from, to; int columns = columnCount(); - for (int i = sortResult.size() - 1; i >= 0; --i) { - const int fromRow = sortResult[i].first; - const int toRow = sortResult[i].second; - AbstractDecklistNode *temp = node->at(toRow); + for (const auto &move : mapping) { + const int preSortRow = move.first; + const int finalRow = move.second; + AbstractDecklistNode *temp = node->at(finalRow); for (int j = 0; j < columns; ++j) { - from << createIndex(fromRow, j, temp); - to << createIndex(toRow, j, temp); + from << createIndex(preSortRow, j, temp); + to << createIndex(finalRow, j, temp); } } changePersistentIndexList(from, to); @@ -704,6 +855,15 @@ QList DeckListModel::getZones() const return zones; } +QStringList DeckListModel::getCustomZoneNames(const QString &boardZoneName) const +{ + QStringList zoneNames; + for (const auto *customZone : deckList->getTree()->getCustomZones(boardZoneName)) { + zoneNames.append(customZone->getName()); + } + return zoneNames; +} + static int maxAllowedForLegality(const FormatRules &format, const QString &legality) { for (const AllowedCount &c : format.allowedCounts) { diff --git a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.h b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.h index 209ec8c42..ce6d7f8cb 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.h +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.h @@ -1,6 +1,8 @@ #ifndef DECKLISTMODEL_H #define DECKLISTMODEL_H +#include "deck_list_model_custom_zones.h" + #include <../../../../libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_card_node.h> #include <../../../../libcockatrice_deck_list/libcockatrice/deck_list/tree/deck_list_card_node.h> #include @@ -30,7 +32,8 @@ enum { IsCardRole = Qt::UserRole + 1, /**< Indicates whether the item represents a card. */ DepthRole, /**< Depth level within the deck's grouping hierarchy. */ - IsLegalRole /**< Whether the card is legal in the current deck format. */ + IsLegalRole, /**< Whether the card is legal in the current deck format. */ + IsCustomZoneRole /**< Whether the item represents a custom zone nested under a board zone. */ }; } // namespace DeckRoles @@ -284,6 +287,16 @@ public: explicit DeckListModel(QObject *parent, const QSharedPointer &deckList); ~DeckListModel() override; + /** + * @brief Selects the language code for localized card names in the display role. + * + * The model never reads global settings itself; callers wire this to the card + * language setting (including reacting to its changes) rather than the model + * querying it. + * @param lang Language code; "en" shows the canonical English names. + */ + void setDisplayLanguage(const QString &lang); + /** * @brief Returns the root index of the model. * @return QModelIndex representing the root node. @@ -391,12 +404,21 @@ public: */ [[nodiscard]] QList getZones() const; + /** + * @brief Gets the names of the custom zones nested under the given board zone. + * + * @param boardZoneName The board zone to query (main/side/maybeboard) + * @return The custom zone names, in deck order + */ + [[nodiscard]] QStringList getCustomZoneNames(const QString &boardZoneName) const; + private: QSharedPointer deckList; /**< Pointer to the decklist providing the underlying data. */ InnerDecklistNode *root; /**< Root node of the model tree. */ DeckListModelGroupCriteria::Type activeGroupCriteria = DeckListModelGroupCriteria::MAIN_TYPE; int lastKnownColumn; /**< Last column used for sorting. */ Qt::SortOrder lastKnownOrder; /**< Last known sort order. */ + QString displayLang = "en"; /**< Language code for localized card names in the display role. */ InnerDecklistNode *createNodeIfNeeded(const QString &name, InnerDecklistNode *parent); QModelIndex nodeToIndex(AbstractDecklistNode *node) const; @@ -427,6 +449,7 @@ private: void emitRecursiveUpdates(const QModelIndex &index); void sortHelper(InnerDecklistNode *node, Qt::SortOrder order); + void sortShadowTree(InnerDecklistNode *node, Qt::SortOrder order); template T getNode(const QModelIndex &index) const { diff --git a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.cpp b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.cpp new file mode 100644 index 000000000..1dc745e63 --- /dev/null +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.cpp @@ -0,0 +1,152 @@ +#include "deck_list_model_custom_zones.h" + +#include "deck_list_model.h" + +#include <../../../../libcockatrice_deck_list/libcockatrice/deck_list/tree/deck_list_card_node.h> +#include +#include + +namespace DeckListModelCustomZones +{ + +bool isCustomZone(const AbstractDecklistNode *node) +{ + return dynamic_cast(node) != nullptr; +} + +namespace +{ + +/** + * @brief Flattens every card under @p zone into @p shadowZone, preserving order. + * + * Custom zones mirror as a single row level: cards nested in sub-zones of any + * depth are added as direct children of the mirrored zone so no card is left + * without a model row. + */ +void flattenCards(const InnerDecklistNode *zone, InnerDecklistNode *shadowZone) +{ + for (int k = 0; k < zone->size(); k++) { + if (auto *zoneCard = dynamic_cast(zone->at(k))) { + new DecklistModelCardNode(zoneCard, shadowZone); + } else if (auto *subZone = dynamic_cast(zone->at(k))) { + flattenCards(subZone, shadowZone); + } + } +} + +} // namespace + +void mirrorCustomZones(const InnerDecklistNode *deckBoardZone, InnerDecklistNode *shadowBoardZone) +{ + for (int j = 0; j < deckBoardZone->size(); j++) { + auto *customZone = dynamic_cast(deckBoardZone->at(j)); + if (!customZone) { + continue; + } + + auto *shadowZone = new DecklistModelSubZoneNode(customZone->getName(), shadowBoardZone); + flattenCards(customZone, shadowZone); + } +} + +InnerDecklistNode *findGroupChild(InnerDecklistNode *parent, const QString &name) +{ + for (int i = 0; i < parent->size(); i++) { + AbstractDecklistNode *child = parent->at(i); + if (isCustomZone(child)) { + continue; + } + auto *group = dynamic_cast(child); + if (group && group->getName() == name) { + return group; + } + } + return nullptr; +} + +DecklistModelSubZoneNode *findSubZoneByName(InnerDecklistNode *root, const QString &zoneName) +{ + for (int i = 0; i < root->size(); i++) { + auto *boardZone = dynamic_cast(root->at(i)); + if (!boardZone) { + continue; + } + + for (int j = 0; j < boardZone->size(); j++) { + auto *customZone = dynamic_cast(boardZone->at(j)); + if (customZone && customZone->getName() == zoneName) { + return customZone; + } + } + } + + return nullptr; +} + +namespace +{ + +/** + * @brief Sorts a node's children and returns the (preSortRow, finalRow) mapping. + */ +QList> plainSort(InnerDecklistNode *node, Qt::SortOrder order) +{ + const QVector> sortResult = node->sort(order); + + QList> mapping; + mapping.reserve(node->size()); + for (int i = 0; i < node->size(); ++i) { + mapping.append({sortResult[i].first, i}); + } + return mapping; +} + +/** + * @brief Sorts a board zone's children, then stably moves custom zones to the end. + * + * @return The (preSortRow, finalRow) mapping covering both the sort and the shift. + */ +QList> boardSort(InnerDecklistNode *node, Qt::SortOrder order) +{ + const QVector> sortResult = node->sort(order); + + QVector groups; + QVector customZones; + QHash preSortRowOf; + + groups.reserve(node->size()); + customZones.reserve(node->size()); + + for (int i = 0; i < node->size(); ++i) { + AbstractDecklistNode *child = node->at(i); + preSortRowOf.insert(child, sortResult[i].first); + if (isCustomZone(child)) { + customZones.append(child); + } else { + groups.append(child); + } + } + + QVector ordered = groups + customZones; + for (int i = 0; i < ordered.size(); ++i) { + node->replace(i, ordered[i]); + } + + QList> mapping; + mapping.reserve(ordered.size()); + for (int i = 0; i < ordered.size(); ++i) { + mapping.append({preSortRowOf.value(ordered[i]), i}); + } + return mapping; +} + +} // namespace + +QList> sortWithCustomZonesLast(InnerDecklistNode *root, InnerDecklistNode *node, Qt::SortOrder order) +{ + const bool isBoardZone = (node != root) && (node->getParent() == root); + return isBoardZone ? boardSort(node, order) : plainSort(node, order); +} + +} // namespace DeckListModelCustomZones diff --git a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.h b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.h new file mode 100644 index 000000000..518a9e1d2 --- /dev/null +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.h @@ -0,0 +1,98 @@ +#ifndef DECK_LIST_MODEL_CUSTOM_ZONES_H +#define DECK_LIST_MODEL_CUSTOM_ZONES_H + +#include <../../../../libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.h> +#include +#include +#include + +/** + * @class DecklistModelSubZoneNode + * @ingroup DeckModels + * @brief Model node representing a custom zone nested under a board zone. + * + * Custom zones group cards by user-defined names (e.g. "Removal", "Utility") + * inside a board zone. They are mirrored from the underlying deck tree so that + * they can be told apart from criteria group nodes by type. + */ +class DecklistModelSubZoneNode : public InnerDecklistNode +{ +public: + using InnerDecklistNode::InnerDecklistNode; +}; + +/** + * @namespace DeckListModelCustomZones + * @ingroup DeckModels + * @brief Tree-level helpers for the deck list model's custom-zone shadow nodes. + * + * The deck list model keeps a second "shadow" tree of InnerDecklistNode that + * mirrors the canonical deck tree for grouping and sorting. Custom zones add a + * layer of bookkeeping to that shadow tree: they must be mirrored alongside + * criteria groups, always sort after the groups within a board, and be + * resolvable by deck-unique name. + * + * This namespace centralizes every "what is / where is a custom zone" decision + * so the model itself only wires the results into Qt model signals. + */ +namespace DeckListModelCustomZones +{ + +/** + * @brief Whether the given node is a custom zone (as opposed to a criteria group). + */ +[[nodiscard]] bool isCustomZone(const AbstractDecklistNode *node); + +/** + * @brief Finds a criteria-group child of @p parent by name, skipping custom zones. + * + * The shadow tree keeps criteria groups and mirrored custom zones as siblings + * under a board zone, and `InnerDecklistNode::findChild` matches both by name. + * Group lookups must not resolve a custom zone that happens to share the group + * name (e.g. a zone called "Creature"), so this searches only non-custom + * children. + * + * @param parent The shadow node whose children are searched. + * @param name The group name to find. + * @return The matching group node, or nullptr if none exists. + */ +[[nodiscard]] InnerDecklistNode *findGroupChild(InnerDecklistNode *parent, const QString &name); + +/** + * @brief Mirrors the custom zones of a deck board zone into its shadow board node. + * + * Each custom zone becomes a DecklistModelSubZoneNode under @p shadowBoardZone + * with its cards as direct (un-grouped) children. + * + * @param deckBoardZone The board zone in the canonical deck tree. + * @param shadowBoardZone The matching board zone in the model's shadow tree. + */ +void mirrorCustomZones(const InnerDecklistNode *deckBoardZone, InnerDecklistNode *shadowBoardZone); + +/** + * @brief Finds a custom zone in the shadow tree by deck-unique name. + * @param root Root of the shadow tree. + * @param zoneName The custom zone name to find. + * @return The matching custom zone node, or nullptr if not found. + */ +[[nodiscard]] DecklistModelSubZoneNode *findSubZoneByName(InnerDecklistNode *root, const QString &zoneName); + +/** + * @brief Sorts a shadow node's children, keeping a board's custom zones last. + * + * Sorting alone would interleave custom zones with criteria groups by name, but + * custom zones must always stay after the groups within a board, regardless of + * name. This applies the sort and, for board zones, stably moves the custom + * zones to the end. + * + * @param root Root of the shadow tree (used to classify board zones). + * @param node The shadow node whose children are reordered. + * @param order Sort order to apply. + * @return A list of (preSortRow, finalRow) pairs describing how each node moved. + */ +[[nodiscard]] QList> +sortWithCustomZonesLast(InnerDecklistNode *root, InnerDecklistNode *node, Qt::SortOrder order); + +} // namespace DeckListModelCustomZones + +#endif // DECK_LIST_MODEL_CUSTOM_ZONES_H diff --git a/libcockatrice_network/libcockatrice/network/client/abstract/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/client/abstract/CMakeLists.txt index 2753246de..6fba8d629 100644 --- a/libcockatrice_network/libcockatrice/network/client/abstract/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/client/abstract/CMakeLists.txt @@ -2,15 +2,11 @@ set(CMAKE_AUTOMOC ON) set(CMAKE_AUTOUIC ON) set(CMAKE_AUTORCC ON) -set(HEADERS abstract_client.h) +set(HEADERS abstract_client.h latency_tracker.h) -set(SOURCES abstract_client.cpp) +set(SOURCES abstract_client.cpp latency_tracker.cpp) -if(Qt6_FOUND) - qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) -elseif(Qt5_FOUND) - qt5_wrap_cpp(MOC_SOURCES ${HEADERS}) -endif() +qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) add_library(libcockatrice_network_client_abstract STATIC ${MOC_SOURCES} ${SOURCES}) diff --git a/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.cpp b/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.cpp index 916f4351b..687d93666 100644 --- a/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.cpp +++ b/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.cpp @@ -1,6 +1,7 @@ #include "abstract_client.h" #include +#include #include #include #include @@ -28,6 +29,7 @@ AbstractClient::AbstractClient(QObject *parent) qRegisterMetaType("Response"); qRegisterMetaType("Response::ResponseCode"); qRegisterMetaType("ClientStatus"); + qRegisterMetaType("LatencyTracker::Stats"); qRegisterMetaType("RoomEvent"); qRegisterMetaType("GameEventContainer"); qRegisterMetaType("Event_ServerIdentification"); @@ -71,6 +73,8 @@ void AbstractClient::processProtocolItem(const ServerMessage &item) } pendingCommands.remove(cmdId); + recordLatency(*pend); + pend->processResponse(response); pend->deleteLater(); break; @@ -161,9 +165,62 @@ void AbstractClient::queuePendingCommand(PendingCommand *pend) pendingCommands.insert(cmdId, pend); + pend->startTiming(); sendCommandContainer(pend->getCommandContainer()); } +namespace +{ +constexpr int STATS_EMIT_INTERVAL_MS = 1000; +// Game actions are what players perceive as lag. Surface unusually slow ones +// without requiring debug logging to be enabled. +constexpr qint64 SLOW_GAME_COMMAND_WARN_MS = 1500; +} // namespace + +void AbstractClient::recordLatency(PendingCommand &pend) +{ + const qint64 elapsed = pend.elapsedMs(); + if (elapsed < 0) { + return; + } + + latencyTracker.addSample(elapsed); + + if (AbstractClientLog().isDebugEnabled()) { + qCDebug(AbstractClientLog).noquote() + << "command RTT:" << elapsed << "ms (cmd_id" << pend.getCommandContainer().cmd_id() << ")"; + } + + if (elapsed >= SLOW_GAME_COMMAND_WARN_MS && pend.getCommandContainer().game_command_size() > 0) { + qCWarning(AbstractClientLog).noquote() + << "slow game command round trip:" << elapsed << "ms | " << getSafeDebugString(pend.getCommandContainer()); + } + + // Emit aggregated stats at most once per StatsEmitIntervalMs so that the + // per-command hot path stays free of signal traffic. The keepalive ping + // guarantees a fresh sample roughly every second while connected. + if (!statsEmitClockStarted || statsEmitClock.elapsed() >= STATS_EMIT_INTERVAL_MS) { + statsEmitClock.start(); + statsEmitClockStarted = true; + const LatencyTracker::Stats stats = latencyTracker.stats(); + + QList samples; + samples.reserve(stats.sampleCount); + for (qint64 sample : latencyTracker.recentSamples()) { + samples.append(static_cast(sample)); + } + + emit pingStatsUpdated(stats, samples); + } +} + +void AbstractClient::clearLatencyStats() +{ + latencyTracker.clear(); + statsEmitClockStarted = false; + emit pingStatsUpdated(LatencyTracker::Stats{}, {}); +} + PendingCommand *AbstractClient::prepareSessionCommand(const ::google::protobuf::Message &cmd) { CommandContainer cont; @@ -196,3 +253,24 @@ PendingCommand *AbstractClient::prepareAdminCommand(const ::google::protobuf::Me c->GetReflection()->MutableMessage(c, cmd.GetDescriptor()->FindExtensionByName("ext"))->CopyFrom(cmd); return new PendingCommand(cont); } + +PendingCommand *AbstractClient::prepareDeveloperCommand(const ::google::protobuf::Message &cmd) +{ + CommandContainer cont; + DeveloperCommand *c = cont.add_developer_command(); + // A developer command message may also be usable through other command + // families, so select the extension scoped to DeveloperCommand rather than + // guessing by name. + const ::google::protobuf::Descriptor *cmdDescriptor = cmd.GetDescriptor(); + const ::google::protobuf::Descriptor *developerDescriptor = DeveloperCommand::descriptor(); + const ::google::protobuf::FieldDescriptor *developerExtension = nullptr; + for (int i = 0; i < cmdDescriptor->extension_count(); ++i) { + if (cmdDescriptor->extension(i)->containing_type() == developerDescriptor) { + developerExtension = cmdDescriptor->extension(i); + break; + } + } + Q_ASSERT(developerExtension != nullptr); + c->GetReflection()->MutableMessage(c, developerExtension)->CopyFrom(cmd); + return new PendingCommand(cont); +} diff --git a/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.h b/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.h index 2eb7e3356..af22a5c9d 100644 --- a/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.h +++ b/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.h @@ -7,11 +7,17 @@ #ifndef ABSTRACTCLIENT_H #define ABSTRACTCLIENT_H +#include "latency_tracker.h" + +#include +#include #include #include #include #include +inline Q_LOGGING_CATEGORY(AbstractClientLog, "abstract_client"); + class PendingCommand; class CommandContainer; class RoomEvent; @@ -54,6 +60,18 @@ signals: void statusChanged(ClientStatus _status); void maxPingTime(int seconds, int maxSeconds); + /** + * @brief Aggregated round-trip statistics and a chronological snapshot of + * the rolling window, emitted at most once per second. + * + * All values in the stats struct are in milliseconds; sampleCount is the + * number of samples currently in the rolling window. The samples list is + * ordered oldest first so graphs can redraw without polling the tracker + * across threads. Emitted from the client thread. The connection to UI + * objects is automatically queued across threads. + */ + void pingStatsUpdated(const LatencyTracker::Stats &stats, const QList &samplesMs); + // Room events void roomEventReceived(const RoomEvent &event); // Game events @@ -85,6 +103,11 @@ private: int nextCmdId; mutable QMutex clientMutex; ClientStatus status; + LatencyTracker latencyTracker; + QElapsedTimer statsEmitClock; + bool statsEmitClockStarted = false; + + void recordLatency(PendingCommand &pend); private slots: void queuePendingCommand(PendingCommand *pend); protected slots: @@ -113,6 +136,16 @@ public: void sendCommand(const CommandContainer &cont); void sendCommand(PendingCommand *pend); + /** + * @brief Drops all recorded round-trip samples and resets the stats + * emission throttle, emitting zeroed stats so that UI listeners can + * clear their display. + * + * Must be called from the client thread (as RemoteClient's disconnect + * path does). The tracker is deliberately lock-free. + */ + void clearLatencyStats(); + bool getServerSupportsPasswordHash() const { return serverSupportsPasswordHash; @@ -122,10 +155,25 @@ public: return userName; } + /** + * @brief Returns the server address configured for the current connection. + * + * May be empty for clients that have no server counterpart (e.g. local test clients). + */ + virtual QString serverName() const + { + return {}; + } + virtual quint16 serverPort() const + { + return 0; + } + static PendingCommand *prepareSessionCommand(const ::google::protobuf::Message &cmd); static PendingCommand *prepareRoomCommand(const ::google::protobuf::Message &cmd, int roomId); static PendingCommand *prepareModeratorCommand(const ::google::protobuf::Message &cmd); static PendingCommand *prepareAdminCommand(const ::google::protobuf::Message &cmd); + static PendingCommand *prepareDeveloperCommand(const ::google::protobuf::Message &cmd); QMap clientFeatures; }; diff --git a/libcockatrice_network/libcockatrice/network/client/abstract/latency_tracker.cpp b/libcockatrice_network/libcockatrice/network/client/abstract/latency_tracker.cpp new file mode 100644 index 000000000..98b353fdf --- /dev/null +++ b/libcockatrice_network/libcockatrice/network/client/abstract/latency_tracker.cpp @@ -0,0 +1,60 @@ +#include "latency_tracker.h" + +#include +#include + +void LatencyTracker::addSample(qint64 ms) +{ + samples[static_cast(head)] = ms; + head = (head + 1) % WindowSize; + if (count < WindowSize) { + ++count; + } +} + +QList LatencyTracker::recentSamples() const +{ + QList result; + result.reserve(count); + for (int i = count; i > 0; --i) { + const int index = (head + WindowSize - i) % WindowSize; + result.append(samples[static_cast(index)]); + } + return result; +} + +LatencyTracker::Stats LatencyTracker::stats() const +{ + if (count == 0) { + return {}; + } + + QList sorted(samples.cbegin(), samples.cbegin() + count); + std::sort(sorted.begin(), sorted.end()); + + Stats s; + s.sampleCount = count; + s.lastMs = samples[static_cast((head + WindowSize - 1) % WindowSize)]; + s.maxMs = sorted.last(); + + const int n = count; + if (n % 2 == 1) { + s.medianMs = sorted[n / 2]; + } else { + s.medianMs = (sorted[n / 2 - 1] + sorted[n / 2]) / 2; + } + + // Nearest-rank percentile: smallest value in the list such that at least + // 95% of the samples are <= it. + const int p95Index = qMax(0, qCeil(0.95 * static_cast(n)) - 1); + s.p95Ms = sorted[p95Index]; + + return s; +} + +void LatencyTracker::clear() +{ + samples.fill(0); + head = 0; + count = 0; +} diff --git a/libcockatrice_network/libcockatrice/network/client/abstract/latency_tracker.h b/libcockatrice_network/libcockatrice/network/client/abstract/latency_tracker.h new file mode 100644 index 000000000..75f18ac0c --- /dev/null +++ b/libcockatrice_network/libcockatrice/network/client/abstract/latency_tracker.h @@ -0,0 +1,51 @@ +/** + * @file latency_tracker.h + * @ingroup Client + */ + +#ifndef LATENCY_TRACKER_H +#define LATENCY_TRACKER_H + +#include +#include +#include + +/** + * @brief Fixed-capacity rolling window of network round-trip time samples. + * + * The hot path (addSample) is a single array store and is intentionally free of + * allocations, locks, or signal emissions so that recording one sample per + * completed command cannot affect gameplay performance. Aggregate statistics + * are only computed on demand in stats(), which callers should throttle. + */ +class LatencyTracker +{ +public: + static constexpr int WindowSize = 64; + + struct Stats + { + qint64 lastMs = 0; ///< most recently added sample + qint64 medianMs = 0; ///< median over the current window + qint64 p95Ms = 0; ///< 95th percentile over the current window + qint64 maxMs = 0; ///< maximum over the current window + int sampleCount = 0; ///< number of samples currently in the window + }; + + void addSample(qint64 ms); + Stats stats() const; + + /// Snapshot of the current window in chronological order (oldest first). + QList recentSamples() const; + + void clear(); + +private: + std::array samples{}; + int head = 0; ///< index where the next sample will be written + int count = 0; ///< number of valid samples, capped at WindowSize +}; + +Q_DECLARE_METATYPE(LatencyTracker::Stats) + +#endif diff --git a/libcockatrice_network/libcockatrice/network/client/local/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/client/local/CMakeLists.txt index d12a324dc..2ac12e1fe 100644 --- a/libcockatrice_network/libcockatrice/network/client/local/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/client/local/CMakeLists.txt @@ -6,11 +6,7 @@ set(HEADERS local_client.h) set(SOURCES local_client.cpp) -if(Qt6_FOUND) - qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) -elseif(Qt5_FOUND) - qt5_wrap_cpp(MOC_SOURCES ${HEADERS}) -endif() +qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) add_library(libcockatrice_network_client_local STATIC ${MOC_SOURCES} ${SOURCES}) diff --git a/libcockatrice_network/libcockatrice/network/client/remote/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/client/remote/CMakeLists.txt index 0548700e4..cb68d0c37 100644 --- a/libcockatrice_network/libcockatrice/network/client/remote/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/client/remote/CMakeLists.txt @@ -6,11 +6,7 @@ set(HEADERS remote_client.h) set(SOURCES remote_client.cpp) -if(Qt6_FOUND) - qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) -elseif(Qt5_FOUND) - qt5_wrap_cpp(MOC_SOURCES ${HEADERS}) -endif() +qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) add_library(libcockatrice_network_client_remote STATIC ${MOC_SOURCES} ${SOURCES}) diff --git a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp index 7e20f2722..53608db65 100644 --- a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp +++ b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp @@ -543,6 +543,7 @@ void RemoteClient::doDisconnectFromServer() delete i; } pendingCommands.clear(); + clearLatencyStats(); setStatus(StatusDisconnected); if (websocket->isValid()) { diff --git a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.h b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.h index 289fdc5d0..862dac06e 100644 --- a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.h +++ b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.h @@ -131,6 +131,22 @@ public: return socket->peerName(); } } + quint16 peerPort() const + { + if (usingWebSocket) { + return websocket->peerPort(); + } else { + return socket->peerPort(); + } + } + QString serverName() const override + { + return lastHostname; + } + quint16 serverPort() const override + { + return static_cast(lastPort); + } void connectToServer(const QString &hostname, unsigned int port, const QString &_userName, const QString &_password); void registerToServer(const QString &hostname, diff --git a/libcockatrice_network/libcockatrice/network/server/local/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/server/local/CMakeLists.txt index 80fb379a4..494ae1294 100644 --- a/libcockatrice_network/libcockatrice/network/server/local/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/server/local/CMakeLists.txt @@ -6,11 +6,7 @@ set(HEADERS local_server.h local_server_interface.h) set(SOURCES local_server.cpp local_server_interface.cpp) -if(Qt6_FOUND) - qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) -elseif(Qt5_FOUND) - qt5_wrap_cpp(MOC_SOURCES ${HEADERS}) -endif() +qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) add_library(libcockatrice_network_server_local STATIC ${MOC_SOURCES} ${SOURCES}) diff --git a/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt index e883baa0d..e11a962d1 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt @@ -10,7 +10,12 @@ set(HEADERS game/server_card.h game/server_cardzone.h game/server_counter.h + game/game_config.h + game/server_deck_validation_strategy.h game/server_game.h + game/server_game_lifecycle_strategy.h + game/server_match_result_strategy.h + game/server_match_game_factory.h game/server_player.h game/server_spectator.h server.h @@ -23,11 +28,7 @@ set(HEADERS serverinfo_user_container.h ) -if(Qt6_FOUND) - qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) -elseif(Qt5_FOUND) - qt5_wrap_cpp(MOC_SOURCES ${HEADERS}) -endif() +qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) add_library( libcockatrice_network_server_remote STATIC diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/game_config.h b/libcockatrice_network/libcockatrice/network/server/remote/game/game_config.h new file mode 100644 index 000000000..baf946632 --- /dev/null +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/game_config.h @@ -0,0 +1,26 @@ +#ifndef GAME_CONFIG_H +#define GAME_CONFIG_H + +#include +#include +#include + +struct GameConfig +{ + ServerInfo_User creatorInfo; + int gameId = -1; + QString description; + QString password; + int maxPlayers = 2; + QList gameTypes; + bool onlyBuddies = false; + bool onlyRegistered = false; + bool spectatorsAllowed = false; + bool spectatorsNeedPassword = false; + bool spectatorsCanTalk = true; + bool spectatorsSeeEverything = true; + int startingLifeTotal = 20; + bool shareDecklistsOnLoad = true; +}; + +#endif diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.cpp index 493b8e966..a6280ffa1 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.cpp @@ -37,6 +37,7 @@ #include #include #include +#include #include #include #include @@ -48,7 +49,7 @@ #include #include #include -#include +#include Server_AbstractParticipant::Server_AbstractParticipant(Server_Game *_game, int _playerId, @@ -144,6 +145,13 @@ Response::ResponseCode Server_AbstractParticipant::cmdSetSideboardLock(const Com return Response::RespFunctionNotAllowed; } +Response::ResponseCode Server_AbstractParticipant::cmdSetPlaymat(const Command_SetPlaymat & /*cmd*/, + ResponseContainer & /*rc*/, + GameEventStorage & /*ges*/) +{ + return Response::RespFunctionNotAllowed; +} + Response::ResponseCode Server_AbstractParticipant::cmdConcede(const Command_Concede & /*cmd*/, ResponseContainer & /*rc*/, GameEventStorage & /*ges*/) @@ -525,6 +533,9 @@ Server_AbstractParticipant::processGameCommand(const GameCommand &command, Respo case GameCommand::REVERSE_TURN: return cmdReverseTurn(command.GetExtension(Command_ReverseTurn::ext), rc, ges); break; + case GameCommand::SET_PLAYMAT: + return cmdSetPlaymat(command.GetExtension(Command_SetPlaymat::ext), rc, ges); + break; default: return Response::RespInvalidCommand; } diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.h index a24fa5799..c78ae78c1 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.h @@ -52,6 +52,7 @@ class Command_SetSideboardPlan; class Command_DeckSelect; class Command_SetSideboardLock; class Command_ChangeZoneProperties; +class Command_SetPlaymat; class Server_AbstractParticipant : public Server_ArrowTarget, public ServerInfo_User_Container { @@ -124,6 +125,8 @@ public: cmdSetSideboardPlan(const Command_SetSideboardPlan &cmd, ResponseContainer &rc, GameEventStorage &ges); virtual Response::ResponseCode cmdSetSideboardLock(const Command_SetSideboardLock &cmd, ResponseContainer &rc, GameEventStorage &ges); + virtual Response::ResponseCode + cmdSetPlaymat(const Command_SetPlaymat &cmd, ResponseContainer &rc, GameEventStorage &ges); virtual Response::ResponseCode cmdGameSay(const Command_GameSay &cmd, ResponseContainer &rc, GameEventStorage &ges); virtual Response::ResponseCode cmdShuffle(const Command_Shuffle &cmd, ResponseContainer &rc, GameEventStorage &ges); virtual Response::ResponseCode diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.cpp index 157fa6441..6b4101a99 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.cpp @@ -47,7 +47,8 @@ #include #include #include -#include +#include +#include #include #include #include @@ -65,6 +66,15 @@ Server_AbstractPlayer::Server_AbstractPlayer(Server_Game *_game, Server_AbstractPlayer::~Server_AbstractPlayer() = default; +int Server_AbstractPlayer::getCardCount() const +{ + int result = 0; + for (auto *zone : zones) { + result += zone->getCards().size(); + } + return result; +} + void Server_AbstractPlayer::prepareDestroy() { delete deck; @@ -1629,10 +1639,11 @@ void Server_AbstractPlayer::getInfo(ServerInfo_Player *info, bool withUserInfo) { getProperties(*info->mutable_properties(), withUserInfo); - if (recipient == this) { - if (deck) { - info->set_deck_list(deck->writeToString_Native().toStdString()); - } + + // Deck lists are only shared with other players when the game is in Open Decklists mode, + // so a player joining an open lobby can see every deck that was loaded before they joined. + if (deck && (recipient == this || game->getShareDecklistsOnLoad())) { + info->set_deck_list(deck->writeToString_Native().toStdString()); } for (Server_Arrow *arrow : arrows) { @@ -1651,5 +1662,13 @@ void Server_AbstractPlayer::getPlayerProperties(ServerInfo_PlayerProperties &res result.set_ready_start(readyStart); if (deck) { result.set_deck_hash(deck->getDeckHash().toStdString()); + const auto &playmat = deck->getPlaymat(); + auto *playmatParams = result.mutable_playmat_params(); + playmatParams->set_card_name(playmat.card.name.toStdString()); + playmatParams->set_card_provider_id(playmat.card.providerId.toStdString()); + playmatParams->set_margin_pct_l(playmat.params.marginPctL); + playmatParams->set_margin_pct_r(playmat.params.marginPctR); + playmatParams->set_vertical_offset(playmat.params.verticalOffset); + playmatParams->set_zoom(playmat.params.zoom); } } diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.h index 85fbc0557..4cc79c5fe 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.h @@ -43,6 +43,8 @@ public: Server_AbstractUserInterface *_handler); ~Server_AbstractPlayer() override; void prepareDestroy() override; + /// Total cards across all of this player's zones. The caller must hold the game's mutex. + int getCardCount() const; const DeckList *getDeckList() const { return deck; diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_card.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_card.cpp index b858314c0..8c7feadba 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_card.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_card.cpp @@ -26,7 +26,8 @@ #include #include #include -#include +#include +#include #include Server_Card::Server_Card(const CardRef &cardRef, int _id, int _coord_x, int _coord_y, Server_CardZone *_zone) @@ -114,8 +115,8 @@ QString Server_Card::setAttribute(CardAttribute attribute, const QString &avalue bool Server_Card::setCounter(int _id, int value, Event_SetCardCounter *event) { - // Clamp to valid card counter range [0, MAX_COUNTERS_ON_CARD] - value = qBound(0, value, MAX_COUNTERS_ON_CARD); + // Clamp to valid card counter range [0, MAX_COUNTER_VALUE] + value = qBound(0, value, MAX_COUNTER_VALUE); const int oldValue = counters.value(_id, 0); if (value == oldValue) { @@ -139,10 +140,8 @@ bool Server_Card::setCounter(int _id, int value, Event_SetCardCounter *event) bool Server_Card::incrementCounter(int counterId, int delta, Event_SetCardCounter *event) { const int oldValue = counters.value(counterId, 0); - const auto result = static_cast(oldValue) + static_cast(delta); - // Clamp to [0, MAX_COUNTERS_ON_CARD] for card counters - const int newValue = - static_cast(qBound(static_cast(0), result, static_cast(MAX_COUNTERS_ON_CARD))); + // Clamp to [0, MAX_COUNTER_VALUE] for card counters + const int newValue = addClamped(oldValue, delta, 0, MAX_COUNTER_VALUE); if (newValue == oldValue) { return false; diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_card.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_card.h index 3d7e649b9..a2698ad61 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_card.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_card.h @@ -156,7 +156,7 @@ public: /** * @brief Sets a card counter to an exact value with clamping. * @param _id The counter ID. - * @param value The desired value (clamped to [0, MAX_COUNTERS_ON_CARD]; 0 removes the counter). + * @param value The desired value (clamped to [0, MAX_COUNTER_VALUE]; 0 removes the counter). * @param event Optional event to populate with counter state. * @return true if the value changed, false otherwise. */ @@ -168,7 +168,7 @@ public: * @param event Optional event to populate with counter state. * @return true if the value changed, false otherwise. * @note If counter does not exist, starts from 0. Counter is removed if result is 0. - * @note Clamps result to [0, MAX_COUNTERS_ON_CARD]. + * @note Clamps result to [0, MAX_COUNTER_VALUE]. */ [[nodiscard]] bool incrementCounter(int counterId, int delta, Event_SetCardCounter *event = nullptr); void setTapped(bool _tapped) diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_counter.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_counter.cpp index e65205cbb..b18e11c2b 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_counter.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_counter.cpp @@ -1,24 +1,12 @@ #include "server_counter.h" #include -#include Server_Counter::Server_Counter(int _id, const QString &_name, const color &_counterColor, int _radius, int _count) : id(_id), name(_name), counterColor(_counterColor), radius(_radius), count(_count) { } -//! \todo Extract overflow-safe arithmetic into shared helper. -//! Duplicated in Server_Card::incrementCounter() - keep in sync if modified. -bool Server_Counter::incrementCount(int delta) -{ - const int oldCount = count; - const auto result = static_cast(count) + static_cast(delta); - count = static_cast(qBound(static_cast(std::numeric_limits::min()), result, - static_cast(std::numeric_limits::max()))); - return count != oldCount; -} - void Server_Counter::getInfo(ServerInfo_Counter *info) { info->set_id(id); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_counter.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_counter.h index 8226e663f..ca093b7cf 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_counter.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_counter.h @@ -22,6 +22,8 @@ #include #include +#include +#include class ServerInfo_Counter; @@ -92,7 +94,12 @@ public: * @return true if the value changed, false otherwise. * @note Clamps result to [INT_MIN, INT_MAX] to prevent overflow. */ - [[nodiscard]] bool incrementCount(int delta); + [[nodiscard]] bool incrementCount(int delta) + { + const int oldCount = count; + count = addClamped(count, delta, std::numeric_limits::min(), std::numeric_limits::max()); + return count != oldCount; + } /** * @brief Populates info with this counter's current state for network serialization. diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_deck_validation_strategy.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_deck_validation_strategy.h new file mode 100644 index 000000000..8298214b4 --- /dev/null +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_deck_validation_strategy.h @@ -0,0 +1,45 @@ +#ifndef SERVER_DECK_VALIDATION_STRATEGY_H +#define SERVER_DECK_VALIDATION_STRATEGY_H + +#include + +class DeckList; +class Server_Game; +class Server_Player; +class ResponseContainer; + +/** + * @brief Strategy for validating a player's deck before it is loaded into a game. + * + * Subclasses decide whether a deck may be accepted; the default implementation + * accepts every deck. + */ +class Server_DeckValidationStrategy +{ +public: + virtual ~Server_DeckValidationStrategy() = default; + + /** + * @brief Validate @p deck for @p player in @p game. + * + * @p rc is an out parameter used to attach the response details for a rejected + * deck (e.g. an error response extension via ResponseContainer::setResponseExtension). + * @return Response::RespOk when the deck is accepted, an error code otherwise. + */ + virtual Response::ResponseCode + validate(Server_Game *game, Server_Player *player, DeckList *deck, ResponseContainer &rc) = 0; +}; + +/** + * @brief Default deck validation strategy that accepts every deck. + */ +class Server_DefaultDeckValidationStrategy : public Server_DeckValidationStrategy +{ +public: + Response::ResponseCode validate(Server_Game *, Server_Player *, DeckList *, ResponseContainer &) override + { + return Response::RespOk; + } +}; + +#endif diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp index 4761199e5..131ff1077 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp @@ -32,6 +32,7 @@ #include "server_spectator.h" #include +#include #include #include #include @@ -53,34 +54,22 @@ #include #include -Server_Game::Server_Game(const ServerInfo_User &_creatorInfo, - int _gameId, - const QString &_description, - const QString &_password, - int _maxPlayers, - const QList &_gameTypes, - bool _onlyBuddies, - bool _onlyRegistered, - bool _spectatorsAllowed, - bool _spectatorsNeedPassword, - bool _spectatorsCanTalk, - bool _spectatorsSeeEverything, - int _startingLifeTotal, - bool _shareDecklistsOnLoad, - Server_Room *_room) - : QObject(), room(_room), nextPlayerId(0), hostId(0), creatorInfo(new ServerInfo_User(_creatorInfo)), - gameStarted(false), gameClosed(false), gameId(_gameId), password(_password), maxPlayers(_maxPlayers), - gameTypes(_gameTypes), activePlayer(-1), activePhase(-1), onlyBuddies(_onlyBuddies), - onlyRegistered(_onlyRegistered), spectatorsAllowed(_spectatorsAllowed), - spectatorsNeedPassword(_spectatorsNeedPassword), spectatorsCanTalk(_spectatorsCanTalk), - spectatorsSeeEverything(_spectatorsSeeEverything), startingLifeTotal(_startingLifeTotal), - shareDecklistsOnLoad(_shareDecklistsOnLoad), inactivityCounter(0), startTimeOfThisGame(0), secondsElapsed(0), - firstGameStarted(false), turnOrderReversed(false), startTime(QDateTime::currentDateTime()), pingClock(nullptr), +Server_Game::Server_Game(const GameConfig &config, Server_Room *_room) + : QObject(), room(_room), nextPlayerId(0), hostId(0), creatorInfo(new ServerInfo_User(config.creatorInfo)), + gameStarted(false), gameClosed(false), gameId(config.gameId), description(config.description.simplified()), + password(config.password), maxPlayers(config.maxPlayers), gameTypes(config.gameTypes), activePlayer(-1), + activePhase(-1), onlyBuddies(config.onlyBuddies), onlyRegistered(config.onlyRegistered), + spectatorsAllowed(config.spectatorsAllowed), spectatorsNeedPassword(config.spectatorsNeedPassword), + spectatorsCanTalk(config.spectatorsCanTalk), spectatorsSeeEverything(config.spectatorsSeeEverything), + startingLifeTotal(config.startingLifeTotal), shareDecklistsOnLoad(config.shareDecklistsOnLoad), + inactivityCounter(0), startTimeOfThisGame(0), secondsElapsed(0), firstGameStarted(false), + turnOrderReversed(false), startTime(QDateTime::currentDateTime()), pingClock(nullptr), + deckValidationStrategy(new Server_DefaultDeckValidationStrategy), + lifecycleStrategy(new Server_DefaultLifecycleStrategy), matchResultStrategy(new Server_NullMatchResultStrategy), gameMutex() { currentReplay = new GameReplay; currentReplay->set_replay_id(room->getServer()->getDatabaseInterface()->getNextReplayId()); - description = _description.simplified(); connect(this, &Server_Game::sigStartGameIfReady, this, &Server_Game::doStartGameIfReady, Qt::QueuedConnection); @@ -250,6 +239,17 @@ int Server_Game::getPlayerCount() const return participants.size() - getSpectatorCount(); } +int Server_Game::getCardsInGame() const +{ + QMutexLocker locker(&gameMutex); + + int result = 0; + for (auto *player : getPlayers()) { + result += player->getCardCount(); + } + return result; +} + int Server_Game::getSpectatorCount() const { QMutexLocker locker(&gameMutex); @@ -342,7 +342,15 @@ void Server_Game::doStartGameIfReady(bool forceStartGame) } } + // Only actual starts are timed. The early returns above are no-ops. + QElapsedTimer startupTimer; + startupTimer.start(); players = getPlayers(); // players could have been kicked, get new list of players + if (lifecycleStrategy->onGameStarting(this) == Server_GameLifecycleStrategy::StartAction::Handled) { + locker.unlock(); + return; + } + for (Server_AbstractPlayer *player : players.values()) { player->setupZones(); } @@ -380,6 +388,7 @@ void Server_Game::doStartGameIfReady(bool forceStartGame) activePlayer = -1; nextTurn(); + room->getServer()->observeGameStartDurationMs(startupTimer.nsecsElapsed() / 1000000); locker.unlock(); @@ -400,10 +409,12 @@ void Server_Game::stopGameIfFinished() QMutexLocker locker(&gameMutex); int playing = 0; + Server_AbstractPlayer *lastPlayer = nullptr; auto players = getPlayers(); for (auto *player : players.values()) { if (!player->getConceded()) { ++playing; + lastPlayer = player; } } if (playing > 1) { @@ -419,6 +430,16 @@ void Server_Game::stopGameIfFinished() sendGameStateToPlayers(); + bool matchDecided = matchResultStrategy->onGameFinished(this, playing, lastPlayer); + if (matchDecided) { + locker.unlock(); + + sendGameEventContainer(prepareGameEvent(Event_GameClosed(), -1)); + gameClosed = true; + deleteLater(); + return; + } + locker.unlock(); ServerInfo_Game gameInfo; @@ -444,7 +465,7 @@ Response::ResponseCode Server_Game::checkJoin(ServerInfo_User *user, if (asJudge && !(user->user_level() & ServerInfo_User::IsJudge)) { return Response::RespUserLevelTooLow; } - if (!(overrideRestrictions && (user->user_level() & ServerInfo_User::IsModerator))) { + if (!(overrideRestrictions && (user->user_level() & (ServerInfo_User::IsModerator | ServerInfo_User::IsJudge)))) { if ((_password != password) && !(spectator && !spectatorsNeedPassword)) { return Response::RespWrongPassword; } @@ -526,10 +547,7 @@ void Server_Game::addPlayer(Server_AbstractUserInterface *userInterface, if (broadcastUpdate) { ServerInfo_Game gameInfo; - gameInfo.set_room_id(room->getId()); - gameInfo.set_game_id(gameId); - gameInfo.set_player_count(getPlayerCount()); - gameInfo.set_spectators_count(getSpectatorCount()); + getInfo(gameInfo); emit gameInfoChanged(gameInfo); } @@ -588,10 +606,7 @@ void Server_Game::removeParticipant(Server_AbstractParticipant *participant, Eve } ServerInfo_Game gameInfo; - gameInfo.set_room_id(room->getId()); - gameInfo.set_game_id(gameId); - gameInfo.set_player_count(getPlayerCount()); - gameInfo.set_spectators_count(getSpectatorCount()); + getInfo(gameInfo); emit gameInfoChanged(gameInfo); } @@ -847,6 +862,12 @@ void Server_Game::getInfo(ServerInfo_Game &result) const result.set_player_count(getPlayerCount()); result.set_started(gameStarted); result.mutable_creator_info()->CopyFrom(*getCreatorInfo()); + const Server_AbstractParticipant *host = participants.value(hostId, nullptr); + if (host != nullptr) { + host->copyUserInfo(*result.mutable_host_info(), false); + } else { + result.mutable_host_info()->CopyFrom(*getCreatorInfo()); + } result.set_only_buddies(onlyBuddies); result.set_only_registered(onlyRegistered); result.set_spectators_allowed(getSpectatorsAllowed()); @@ -901,3 +922,8 @@ void Server_Game::returnCardsFromPlayer(GameEventStorage &ges, Server_AbstractPl } } } + +void Server_Game::setDeckValidationStrategy(Server_DeckValidationStrategy *strategy) +{ + deckValidationStrategy.reset(strategy); +} diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h index e0e7896b7..1ed4fe4ca 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h @@ -21,11 +21,16 @@ #define SERVERGAME_H #include "../server_response_containers.h" +#include "game_config.h" +#include "server_deck_validation_strategy.h" +#include "server_game_lifecycle_strategy.h" +#include "server_match_result_strategy.h" #include #include #include #include +#include #include #include #include @@ -78,6 +83,12 @@ private: QList replayList; GameReplay *currentReplay; + QScopedPointer deckValidationStrategy; + + QScopedPointer lifecycleStrategy; + + QScopedPointer matchResultStrategy; + void createGameStateChangedEvent(Event_GameStateChanged *event, Server_AbstractParticipant *recipient, bool omniscient, @@ -92,21 +103,7 @@ private slots: public: mutable QRecursiveMutex gameMutex; - Server_Game(const ServerInfo_User &_creatorInfo, - int _gameId, - const QString &_description, - const QString &_password, - int _maxPlayers, - const QList &_gameTypes, - bool _onlyBuddies, - bool _onlyRegistered, - bool _spectatorsAllowed, - bool _spectatorsNeedPassword, - bool _spectatorsCanTalk, - bool _spectatorsSeeEverything, - int _startingLifeTotal, - bool _shareDecklistsOnLoad, - Server_Room *parent); + Server_Game(const GameConfig &config, Server_Room *parent); ~Server_Game() override; Server_Room *getRoom() const { @@ -126,6 +123,8 @@ public: return gameStarted; } int getPlayerCount() const; + /// Total cards across all players' zones. Takes gameMutex itself. + int getCardsInGame() const; int getSpectatorCount() const; QMap getPlayers() const; Server_AbstractPlayer *getPlayer(int id) const; @@ -221,6 +220,20 @@ public: GameEventStorageItem::SendToOthers, int privatePlayerId = -1); void returnCardsFromPlayer(GameEventStorage &ges, Server_AbstractPlayer *player); + + /** @brief Get the current deck validation strategy (non-owning). */ + Server_DeckValidationStrategy *getDeckValidationStrategy() const + { + return deckValidationStrategy.data(); + } + /** @brief Replace the deck validation strategy; takes ownership of @p strategy. */ + void setDeckValidationStrategy(Server_DeckValidationStrategy *strategy); + + /** @brief Get the current game lifecycle strategy (non-owning). */ + Server_GameLifecycleStrategy *getLifecycleStrategy() const + { + return lifecycleStrategy.data(); + } }; #endif diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game_lifecycle_strategy.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game_lifecycle_strategy.h new file mode 100644 index 000000000..b71ce486d --- /dev/null +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game_lifecycle_strategy.h @@ -0,0 +1,43 @@ +#ifndef SERVER_GAME_LIFECYCLE_STRATEGY_H +#define SERVER_GAME_LIFECYCLE_STRATEGY_H + +class Server_Game; + +/** + * @brief Strategy hook invoked around a game's lifecycle transitions. + * + * Subclasses can intercept the game start to perform custom setup (e.g. draft or + * tournament initialization); the default implementation never intercepts. + */ +class Server_GameLifecycleStrategy +{ +public: + virtual ~Server_GameLifecycleStrategy() = default; + + /** @brief How the game start should proceed after this hook returns. */ + enum class StartAction + { + ProceedNormal, ///< Continue with the normal game start flow. + Handled, ///< The strategy handled the start; abort the normal flow. + }; + + /** + * @brief Called when a game is about to start. + * @return How the start flow should proceed. + */ + virtual StartAction onGameStarting(Server_Game *game) = 0; +}; + +/** + * @brief Default lifecycle strategy that never intercepts the game start. + */ +class Server_DefaultLifecycleStrategy : public Server_GameLifecycleStrategy +{ +public: + StartAction onGameStarting(Server_Game *) override + { + return StartAction::ProceedNormal; + } +}; + +#endif diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_match_game_factory.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_match_game_factory.h new file mode 100644 index 000000000..d5cf81cb6 --- /dev/null +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_match_game_factory.h @@ -0,0 +1,29 @@ +#ifndef SERVER_MATCH_GAME_FACTORY_H +#define SERVER_MATCH_GAME_FACTORY_H + +#include + +class GameConfig; +class Server_Game; +class Server_AbstractUserInterface; + +/** + * @brief Abstract factory for creating match games, forward-looking for tournament modes. + * + * Implementations own the rules of how a match game is constructed, how player + * interfaces are resolved, and how games are registered with a room. + */ +class Server_MatchGameFactory +{ +public: + virtual ~Server_MatchGameFactory() = default; + + /** @brief Create a match game from @p config, storing the assigned game id in @p outGameId. */ + virtual Server_Game *createMatchGame(const GameConfig &config, int &outGameId) = 0; + /** @brief Resolve the user interface for @p playerName. */ + virtual Server_AbstractUserInterface *getUserInterface(const QString &playerName) = 0; + /** @brief Register @p game with the room that owns it. */ + virtual void addGameToRoom(Server_Game *game) = 0; +}; + +#endif diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_match_result_strategy.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_match_result_strategy.h new file mode 100644 index 000000000..51c696db1 --- /dev/null +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_match_result_strategy.h @@ -0,0 +1,38 @@ +#ifndef SERVER_MATCH_RESULT_STRATEGY_H +#define SERVER_MATCH_RESULT_STRATEGY_H + +class Server_AbstractPlayer; +class Server_Game; + +/** + * @brief Strategy hook invoked when a game has finished to decide the match result. + * + * Subclasses can report the match outcome (e.g. to a tournament backend) and decide + * whether the game should be closed permanently; the default implementation never + * closes the game, preserving the normal return-to-lobby behavior. + */ +class Server_MatchResultStrategy +{ +public: + virtual ~Server_MatchResultStrategy() = default; + + /** + * @brief Called when a game has finished. + * @return Whether the game has been decided and should be closed. + */ + virtual bool onGameFinished(Server_Game *game, int playing, Server_AbstractPlayer *lastPlayer) = 0; +}; + +/** + * @brief Default match result strategy that never closes the game. + */ +class Server_NullMatchResultStrategy : public Server_MatchResultStrategy +{ +public: + bool onGameFinished(Server_Game *, int, Server_AbstractPlayer *) override + { + return false; + } +}; + +#endif diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.cpp index 56e3f9f8e..cafa33c07 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.cpp @@ -27,6 +27,7 @@ #include #include #include +#include #include #include #include @@ -47,7 +48,7 @@ #include #include #include -#include +#include #include Server_Player::Server_Player(Server_Game *_game, @@ -250,6 +251,14 @@ Server_Player::cmdDeckSelect(const Command_DeckSelect &cmd, ResponseContainer &r Event_PlayerPropertiesChanged event; event.mutable_player_properties()->set_sideboard_locked(true); event.mutable_player_properties()->set_deck_hash(deck->getDeckHash().toStdString()); + const auto &playmat = deck->getPlaymat(); + auto *playmatParams = event.mutable_player_properties()->mutable_playmat_params(); + playmatParams->set_card_name(playmat.card.name.left(MAX_NAME_LENGTH).toStdString()); + playmatParams->set_card_provider_id(playmat.card.providerId.left(MAX_NAME_LENGTH).toStdString()); + playmatParams->set_margin_pct_l(playmat.params.marginPctL); + playmatParams->set_margin_pct_r(playmat.params.marginPctR); + playmatParams->set_vertical_offset(playmat.params.verticalOffset); + playmatParams->set_zoom(playmat.params.zoom); ges.enqueueGameEvent(event, playerId); Context_DeckSelect context; @@ -594,6 +603,46 @@ Server_Player::cmdReverseTurn(const Command_ReverseTurn &cmd, ResponseContainer return Server_AbstractParticipant::cmdReverseTurn(cmd, rc, ges); } +Response::ResponseCode +Server_Player::cmdSetPlaymat(const Command_SetPlaymat &cmd, ResponseContainer &rc, GameEventStorage &ges) +{ + Q_UNUSED(rc); + + if (!deck) { + return Response::RespContextError; + } + + const auto &pp = cmd.playmat_params(); + const auto rawName = QString::fromStdString(pp.card_name()); + const auto rawProviderId = QString::fromStdString(pp.card_provider_id()); + if (rawName.length() > MAX_NAME_LENGTH || rawProviderId.length() > MAX_NAME_LENGTH) { + return Response::RespInvalidData; + } + PlaymatInfo playmat; + playmat.card.name = rawName; + playmat.card.providerId = rawProviderId; + playmat.params.marginPctL = qBound(0.0, pp.margin_pct_l(), 0.95); + playmat.params.marginPctR = qBound(0.0, pp.margin_pct_r(), 0.95); + playmat.params.verticalOffset = qBound(0.0, pp.vertical_offset(), 1.0); + playmat.params.zoom = qBound(0.1, pp.zoom(), 4.0); + deck->setPlaymat(playmat); + + Event_PlayerPropertiesChanged event; + auto *props = event.mutable_player_properties(); + props->set_sideboard_locked(sideboardLocked); + props->set_deck_hash(deck->getDeckHash().toStdString()); + auto *playmatParams = props->mutable_playmat_params(); + playmatParams->set_card_name(playmat.card.name.toStdString()); + playmatParams->set_card_provider_id(playmat.card.providerId.toStdString()); + playmatParams->set_margin_pct_l(playmat.params.marginPctL); + playmatParams->set_margin_pct_r(playmat.params.marginPctR); + playmatParams->set_vertical_offset(playmat.params.verticalOffset); + playmatParams->set_zoom(playmat.params.zoom); + ges.enqueueGameEvent(event, playerId); + + return Response::RespOk; +} + void Server_Player::getInfo(ServerInfo_Player *info, Server_AbstractParticipant *recipient, bool omniscient, diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.h index 5925ed3c2..c35428480 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.h @@ -62,6 +62,8 @@ public: cmdSetActivePhase(const Command_SetActivePhase &cmd, ResponseContainer &rc, GameEventStorage &ges) override; Response::ResponseCode cmdReverseTurn(const Command_ReverseTurn & /*cmd*/, ResponseContainer & /*rc*/, GameEventStorage &ges) override; + Response::ResponseCode + cmdSetPlaymat(const Command_SetPlaymat &cmd, ResponseContainer &rc, GameEventStorage &ges) override; Response::ResponseCode cmdChangeZoneProperties(const Command_ChangeZoneProperties &cmd, ResponseContainer &rc, GameEventStorage &ges) override; diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server.cpp index 3da9ddc73..7abccfca8 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server.cpp @@ -190,6 +190,25 @@ AuthenticationResult Server::loginUser(Server_ProtocolHandler *session, return authState; } +void Server::broadcastUserInfoUpdate(Server_ProtocolHandler *source) +{ + Event_UserJoined event; + event.mutable_user_info()->CopyFrom(source->copyUserInfo(false)); + + SessionEvent *se = Server_ProtocolHandler::prepareSessionEvent(event); + + clientsLock.lockForRead(); + for (auto &client : clients) { + if (client->getAcceptsUserListChanges()) { + client->sendProtocolItem(*se); + } + } + clientsLock.unlock(); + + sendIsl_SessionEvent(*se); + delete se; +} + void Server::addPersistentPlayer(const QString &userName, int roomId, int gameId, int playerId) { QWriteLocker locker(&persistentPlayersLock); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server.h b/libcockatrice_network/libcockatrice/network/server/remote/server.h index ab57fac4e..3d27f4210 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server.h @@ -38,7 +38,8 @@ enum AuthenticationResult UsernameInvalid, RegistrationRequired, UserIsInactive, - ClientIdRequired + ClientIdRequired, + PasswordChangeRequired }; class Server : public QObject @@ -64,6 +65,7 @@ public: QString &clientid, QString &clientVersion, QString &connectionType); + void broadcastUserInfoUpdate(Server_ProtocolHandler *source); const QMap &getRooms() { @@ -90,6 +92,10 @@ public: { return QString(); } + virtual SessionEvent *getLoginSessionEvent() const + { + return nullptr; + } virtual QString getRequiredFeatures() const { return QString(); @@ -174,6 +180,11 @@ public: { return false; } + /// Called once per actual game start with how long bringing every player's + /// zones online took, so servers can spot deck sizes that wedge threads. + virtual void observeGameStartDurationMs(qint64 /* elapsedMs */) + { + } Server_DatabaseInterface *getDatabaseInterface() const; int getNextLocalGameId() diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_abstractuserinterface.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server_abstractuserinterface.cpp index 641be1eed..31fa13d81 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_abstractuserinterface.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_abstractuserinterface.cpp @@ -69,6 +69,10 @@ void Server_AbstractUserInterface::sendResponseContainer(const ResponseContainer } } +void Server_AbstractUserInterface::onLogin(ResponseContainer &) +{ +} + void Server_AbstractUserInterface::playerRemovedFromGame(Server_Game *game) { qDebug() << "Server_AbstractUserInterface::playerRemovedFromGame(): gameId =" << game->getGameId(); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_abstractuserinterface.h b/libcockatrice_network/libcockatrice/network/server/remote/server_abstractuserinterface.h index b11260003..2da72c01d 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_abstractuserinterface.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_abstractuserinterface.h @@ -42,6 +42,7 @@ public: void playerRemovedFromGame(Server_Game *game); void playerAddedToGame(int gameId, int roomId, int playerId); void joinPersistentGames(ResponseContainer &rc); + virtual void onLogin(ResponseContainer &rc); QMap> getGames() const { diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_database_interface.h b/libcockatrice_network/libcockatrice/network/server/remote/server_database_interface.h index b43dbde42..1e4fc990b 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_database_interface.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_database_interface.h @@ -172,6 +172,9 @@ public: { return false; } + virtual void setForcePasswordChange(const QString & /* user */, bool /* force */) + { + } }; #endif diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp index 27ebaf228..8422d703d 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp @@ -1,5 +1,6 @@ #include "server_protocolhandler.h" +#include "game/game_config.h" #include "game/server_game.h" #include "game/server_player.h" #include "server_database_interface.h" @@ -26,7 +27,7 @@ #include #include #include -#include +#include Server_ProtocolHandler::Server_ProtocolHandler(Server *_server, Server_DatabaseInterface *_databaseInterface, @@ -133,6 +134,22 @@ void Server_ProtocolHandler::sendProtocolItem(const RoomEvent &item) Response::ResponseCode Server_ProtocolHandler::processSessionCommandContainer(const CommandContainer &cont, ResponseContainer &rc) { + const auto isPreAuthSessionCommand = [](SessionCommand::SessionCommandType type) { + switch (type) { + case SessionCommand::PING: + case SessionCommand::LOGIN: + case SessionCommand::REGISTER: + case SessionCommand::ACTIVATE: + case SessionCommand::FORGOT_PASSWORD_REQUEST: + case SessionCommand::FORGOT_PASSWORD_RESET: + case SessionCommand::FORGOT_PASSWORD_CHALLENGE: + case SessionCommand::REQUEST_PASSWORD_SALT: + return true; + default: + return false; + } + }; + Response::ResponseCode finalResponseCode = Response::RespOk; for (int i = cont.session_command_size() - 1; i >= 0; --i) { Response::ResponseCode resp = Response::RespInvalidCommand; @@ -141,33 +158,38 @@ Response::ResponseCode Server_ProtocolHandler::processSessionCommandContainer(co if (num != SessionCommand::PING) { // don't log ping commands logDebugMessage(getSafeDebugString(sc)); } - switch ((SessionCommand::SessionCommandType)num) { - case SessionCommand::PING: - resp = cmdPing(sc.GetExtension(Command_Ping::ext), rc); - break; - case SessionCommand::LOGIN: - resp = cmdLogin(sc.GetExtension(Command_Login::ext), rc); - break; - case SessionCommand::MESSAGE: - resp = cmdMessage(sc.GetExtension(Command_Message::ext), rc); - break; - case SessionCommand::GET_GAMES_OF_USER: - resp = cmdGetGamesOfUser(sc.GetExtension(Command_GetGamesOfUser::ext), rc); - break; - case SessionCommand::GET_USER_INFO: - resp = cmdGetUserInfo(sc.GetExtension(Command_GetUserInfo::ext), rc); - break; - case SessionCommand::LIST_ROOMS: - resp = cmdListRooms(sc.GetExtension(Command_ListRooms::ext), rc); - break; - case SessionCommand::JOIN_ROOM: - resp = cmdJoinRoom(sc.GetExtension(Command_JoinRoom::ext), rc); - break; - case SessionCommand::LIST_USERS: - resp = cmdListUsers(sc.GetExtension(Command_ListUsers::ext), rc); - break; - default: - resp = processExtendedSessionCommand(num, sc, rc); + const auto commandType = static_cast(num); + if (authState == NotLoggedIn && !isPreAuthSessionCommand(commandType)) { + resp = Response::RespLoginNeeded; + } else { + switch (commandType) { + case SessionCommand::PING: + resp = cmdPing(sc.GetExtension(Command_Ping::ext), rc); + break; + case SessionCommand::LOGIN: + resp = cmdLogin(sc.GetExtension(Command_Login::ext), rc); + break; + case SessionCommand::MESSAGE: + resp = cmdMessage(sc.GetExtension(Command_Message::ext), rc); + break; + case SessionCommand::GET_GAMES_OF_USER: + resp = cmdGetGamesOfUser(sc.GetExtension(Command_GetGamesOfUser::ext), rc); + break; + case SessionCommand::GET_USER_INFO: + resp = cmdGetUserInfo(sc.GetExtension(Command_GetUserInfo::ext), rc); + break; + case SessionCommand::LIST_ROOMS: + resp = cmdListRooms(sc.GetExtension(Command_ListRooms::ext), rc); + break; + case SessionCommand::JOIN_ROOM: + resp = cmdJoinRoom(sc.GetExtension(Command_JoinRoom::ext), rc); + break; + case SessionCommand::LIST_USERS: + resp = cmdListUsers(sc.GetExtension(Command_ListUsers::ext), rc); + break; + default: + resp = processExtendedSessionCommand(num, sc, rc); + } } if (resp != Response::RespOk) { finalResponseCode = resp; @@ -366,6 +388,33 @@ Response::ResponseCode Server_ProtocolHandler::processAdminCommandContainer(cons return finalResponseCode; } +Response::ResponseCode Server_ProtocolHandler::processDeveloperCommandContainer(const CommandContainer &cont, + ResponseContainer &rc) +{ + if (!userInfo) { + return Response::RespLoginNeeded; + } + if (!(userInfo->user_level() & ServerInfo_User::IsDeveloper)) { + return Response::RespLoginNeeded; + } + + resetIdleTimer(); + + Response::ResponseCode finalResponseCode = Response::RespOk; + for (int i = cont.developer_command_size() - 1; i >= 0; --i) { + Response::ResponseCode resp = Response::RespInvalidCommand; + const DeveloperCommand &sc = cont.developer_command(i); + const int num = getPbExtension(sc); + logDebugMessage(getSafeDebugString(sc)); + + resp = processExtendedDeveloperCommand(num, sc, rc); + if (resp != Response::RespOk) { + finalResponseCode = resp; + } + } + return finalResponseCode; +} + void Server_ProtocolHandler::processCommandContainer(const CommandContainer &cont) { // Command processing must be disabled after prepareDestroy() has been called. @@ -388,6 +437,8 @@ void Server_ProtocolHandler::processCommandContainer(const CommandContainer &con finalResponseCode = processModeratorCommandContainer(cont, responseContainer); } else if (cont.admin_command_size()) { finalResponseCode = processAdminCommandContainer(cont, responseContainer); + } else if (cont.developer_command_size()) { + finalResponseCode = processDeveloperCommandContainer(cont, responseContainer); } else { finalResponseCode = Response::RespInvalidCommand; } @@ -432,11 +483,12 @@ void Server_ProtocolHandler::pingClockTimeout() prepareDestroy(); } - // PrivLevel users, Moderators, and Admins are not subject to the server idle timeout policy + // PrivLevel users, Moderators, Admins, and Developers are not subject to the server idle timeout policy const bool hasPrivLevel = userInfo && QString::fromStdString(userInfo->privlevel()).toLower() != "none"; - const bool isModOrAdmin = - userInfo && (userInfo->user_level() & (ServerInfo_User::IsModerator | ServerInfo_User::IsAdmin)); - if (!hasPrivLevel && !isModOrAdmin) { + const bool isStaff = + userInfo && (userInfo->user_level() & + (ServerInfo_User::IsModerator | ServerInfo_User::IsAdmin | ServerInfo_User::IsDeveloper)); + if (!hasPrivLevel && !isStaff) { if ((server->getIdleClientTimeout() > 0) && (idleClientWarningSent)) { if (timeRunning - lastActionReceived > server->getIdleClientTimeout()) { prepareDestroy(); @@ -542,6 +594,8 @@ Response::ResponseCode Server_ProtocolHandler::cmdLogin(const Command_Login &cmd return Response::RespClientIdRequired; case UserIsInactive: return Response::RespAccountNotActivated; + case PasswordChangeRequired: + return Response::RespPasswordChangeRequired; default: authState = res; usingRealPassword = needsHash; @@ -562,6 +616,11 @@ Response::ResponseCode Server_ProtocolHandler::cmdLogin(const Command_Login &cmd event.set_message(server->getLoginMessage().toStdString()); rc.enqueuePostResponseItem(ServerMessage::SESSION_EVENT, prepareSessionEvent(event)); + SessionEvent *loginEvent = server->getLoginSessionEvent(); + if (loginEvent) { + rc.enqueuePostResponseItem(ServerMessage::SESSION_EVENT, loginEvent); + } + auto *re = new Response_Login; re->mutable_user_info()->CopyFrom(copyUserInfo(true)); @@ -587,6 +646,7 @@ Response::ResponseCode Server_ProtocolHandler::cmdLogin(const Command_Login &cmd joinPersistentGames(rc); databaseInterface->removeForgotPassword(userName); + onLogin(rc); rc.setResponseExtension(re); return Response::RespOk; } @@ -685,6 +745,15 @@ Response::ResponseCode Server_ProtocolHandler::cmdGetUserInfo(const Command_GetU ServerInfo_User_Container *infoSource = server->findUser(userName); if (!infoSource) { re->mutable_user_info()->CopyFrom(databaseInterface->getUserData(userName, true)); + // The user is not currently online. Mirror the redaction that + // copyUserInfo() applies to online users: the id and email address + // are only ever visible to the account owner, and the client id + // only to moderators. + re->mutable_user_info()->clear_id(); + re->mutable_user_info()->clear_email(); + if (!(userInfo->user_level() & ServerInfo_User::IsModerator)) { + re->mutable_user_info()->clear_clientid(); + } } else { re->mutable_user_info()->CopyFrom( infoSource->copyUserInfo(true, false, userInfo->user_level() & ServerInfo_User::IsModerator)); @@ -885,10 +954,22 @@ Server_ProtocolHandler::cmdCreateGame(const Command_CreateGame &cmd, Server_Room // When server doesn't permit registered users to exist, do not honor only-reg setting bool onlyRegisteredUsers = cmd.only_registered() && (server->permitUnregisteredUsers()); - auto *game = new Server_Game(copyUserInfo(false), gameId, description, QString::fromStdString(cmd.password()), - cmd.max_players(), gameTypes, cmd.only_buddies(), onlyRegisteredUsers, - cmd.spectators_allowed(), cmd.spectators_need_password(), cmd.spectators_can_talk(), - cmd.spectators_see_everything(), startingLifeTotal, shareDecklistsOnLoad, room); + GameConfig config{.creatorInfo = copyUserInfo(false), + .gameId = gameId, + .description = description, + .password = QString::fromStdString(cmd.password()), + .maxPlayers = static_cast(cmd.max_players()), + .gameTypes = gameTypes, + .onlyBuddies = cmd.only_buddies(), + .onlyRegistered = onlyRegisteredUsers, + .spectatorsAllowed = cmd.spectators_allowed(), + .spectatorsNeedPassword = cmd.spectators_need_password(), + .spectatorsCanTalk = cmd.spectators_can_talk(), + .spectatorsSeeEverything = cmd.spectators_see_everything(), + .startingLifeTotal = startingLifeTotal, + .shareDecklistsOnLoad = shareDecklistsOnLoad}; + + auto *game = new Server_Game(config, room); game->addPlayer(this, rc, asSpectator, asJudge, false); room->addGame(game); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h index 0d05b91c8..2c8efe50e 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h @@ -27,6 +27,7 @@ class CommandContainer; class SessionCommand; class ModeratorCommand; class AdminCommand; +class DeveloperCommand; class Command_Ping; class Command_Login; @@ -98,6 +99,12 @@ private: { return Response::RespFunctionNotAllowed; } + Response::ResponseCode processDeveloperCommandContainer(const CommandContainer &cont, ResponseContainer &rc); + virtual Response::ResponseCode + processExtendedDeveloperCommand(int /* cmdType */, const DeveloperCommand & /* cmd */, ResponseContainer & /* rc */) + { + return Response::RespFunctionNotAllowed; + } void resetIdleTimer(); private slots: @@ -129,7 +136,7 @@ public: return timeRunning - lastDataReceived; } bool addSaidMessageSize(int size); - void processCommandContainer(const CommandContainer &cont); + virtual void processCommandContainer(const CommandContainer &cont); void sendProtocolItem(const Response &item); void sendProtocolItem(const SessionEvent &item); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_room.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server_room.cpp index 1bd928e09..1f29e62fb 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_room.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_room.cpp @@ -15,7 +15,7 @@ #include #include #include -#include +#include Server_Room::Server_Room(int _id, int _chatHistorySize, diff --git a/libcockatrice_protocol/libcockatrice/protocol/debug_pb_message.cpp b/libcockatrice_protocol/libcockatrice/protocol/debug_pb_message.cpp index c419a68d4..4d4889323 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/debug_pb_message.cpp +++ b/libcockatrice_protocol/libcockatrice/protocol/debug_pb_message.cpp @@ -5,7 +5,7 @@ #include #include #include -#include +#include // FastFieldValuePrinter is added in protobuf 3.4, going out of our way to add the old FieldValuePrinter is not worth it #if GOOGLE_PROTOBUF_VERSION > 3004000 diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt index b4c7b6ac8..0791f8c14 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt @@ -25,6 +25,7 @@ set(PROTO_FILES command_dump_zone.proto command_flip_card.proto command_game_say.proto + command_get_server_stats.proto command_inc_card_counter.proto command_inc_counter.proto command_kick_from_game.proto @@ -35,10 +36,20 @@ set(PROTO_FILES command_ready_start.proto command_replay_delete_match.proto command_replay_download.proto + command_replay_download_by_game_id.proto command_replay_get_code.proto command_replay_list.proto command_replay_modify_match.proto command_replay_submit_code.proto + command_report.proto + command_report_add_comment.proto + command_report_assign.proto + command_report_details.proto + command_report_list.proto + command_report_my_list.proto + command_report_resolve.proto + command_report_stats.proto + command_report_user_info.proto command_reveal_cards.proto command_reverse_turn.proto command_roll_die.proto @@ -46,6 +57,7 @@ set(PROTO_FILES command_set_card_attr.proto command_set_card_counter.proto command_set_counter.proto + command_set_playmat.proto command_set_sideboard_lock.proto command_set_sideboard_plan.proto command_shuffle.proto @@ -60,6 +72,7 @@ set(PROTO_FILES context_ready_start.proto context_set_sideboard_lock.proto context_undo_draw.proto + developer_commands.proto event_add_to_list.proto event_attach_card.proto event_change_zone_properties.proto @@ -79,7 +92,6 @@ set(PROTO_FILES event_game_log_notice.proto event_game_say.proto event_game_state_changed.proto - event_game_state_changed.proto event_join.proto event_join_room.proto event_kicked.proto @@ -122,6 +134,7 @@ set(PROTO_FILES response_activate.proto response_adjust_mod.proto response_ban_history.proto + response_card_art_rule_entry.proto response_deck_download.proto response_deck_list.proto response_deck_upload.proto @@ -129,6 +142,7 @@ set(PROTO_FILES response_forgotpasswordrequest.proto response_get_admin_notes.proto response_get_games_of_user.proto + response_get_server_stats.proto response_get_user_info.proto response_join_room.proto response_list_users.proto @@ -136,8 +150,19 @@ set(PROTO_FILES response_password_salt.proto response_register.proto response_replay_download.proto + response_replay_download_by_game_id.proto response_replay_get_code.proto response_replay_list.proto + response_report_details.proto + response_report_list.proto + response_report_my_list.proto + response_report_stats.proto + response_report_user_info.proto + response_moderator_last_logins.proto + response_remove_user_avatar.proto + response_reset_user_password.proto + response_user_alts.proto + response_user_sessions.proto response_viewlog_history.proto response_warn_history.proto response_warn_list.proto @@ -153,13 +178,17 @@ set(PROTO_FILES serverinfo_deckstorage.proto serverinfo_game.proto serverinfo_gametype.proto + serverinfo_moderator_login.proto serverinfo_player.proto serverinfo_playerping.proto serverinfo_playerproperties.proto serverinfo_replay.proto serverinfo_replay_match.proto + serverinfo_report.proto serverinfo_room.proto serverinfo_user.proto + serverinfo_user_alt.proto + serverinfo_user_session.proto serverinfo_warning.proto serverinfo_zone.proto session_commands.proto diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/admin_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/admin_commands.proto index 8faaec2d2..f1f85e376 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/admin_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/admin_commands.proto @@ -5,6 +5,7 @@ message AdminCommand { SHUTDOWN_SERVER = 1001; RELOAD_CONFIG = 1002; ADJUST_MOD = 1003; + RESET_USER_PASSWORD = 1016; } extensions 100 to max; } @@ -36,4 +37,12 @@ message Command_AdjustMod { required string user_name = 1; optional bool should_be_mod = 2; optional bool should_be_judge = 3; + optional bool should_be_developer = 4; +} + +message Command_ResetUserPassword { + extend AdminCommand { + optional Command_ResetUserPassword ext = 1016; + } + optional string user_name = 1; } diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_get_server_stats.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_get_server_stats.proto new file mode 100644 index 000000000..33c56293b --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_get_server_stats.proto @@ -0,0 +1,8 @@ +syntax = "proto2"; +import "developer_commands.proto"; + +message Command_GetServerStats { + extend DeveloperCommand { + optional Command_GetServerStats ext = 1000; + } +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_replay_download_by_game_id.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_replay_download_by_game_id.proto new file mode 100644 index 000000000..8fa61a517 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_replay_download_by_game_id.proto @@ -0,0 +1,9 @@ +syntax = "proto2"; +import "moderator_commands.proto"; + +message Command_ReplayDownloadByGameId { + extend ModeratorCommand { + optional Command_ReplayDownloadByGameId ext = 1203; + } + required sint32 game_id = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_report.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report.proto new file mode 100644 index 000000000..3a8c1548e --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report.proto @@ -0,0 +1,13 @@ +syntax = "proto2"; +import "session_commands.proto"; + +message Command_Report { + extend SessionCommand { + optional Command_Report ext = 1200; + } + optional string reported_user = 1; + optional int32 game_id = 2; + optional string category = 3; + optional string description = 4; + optional string chat_log = 5; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_add_comment.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_add_comment.proto new file mode 100644 index 000000000..1fc5696e5 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_add_comment.proto @@ -0,0 +1,10 @@ +syntax = "proto2"; +import "session_commands.proto"; + +message Command_ReportAddComment { + extend SessionCommand { + optional Command_ReportAddComment ext = 1205; + } + required int32 report_id = 1; + required string comment = 2; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_assign.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_assign.proto new file mode 100644 index 000000000..2b72205f8 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_assign.proto @@ -0,0 +1,9 @@ +syntax = "proto2"; +import "moderator_commands.proto"; + +message Command_ReportAssign { + extend ModeratorCommand { + optional Command_ReportAssign ext = 1201; + } + required int32 report_id = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_details.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_details.proto new file mode 100644 index 000000000..e3ae8cfb4 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_details.proto @@ -0,0 +1,9 @@ +syntax = "proto2"; +import "session_commands.proto"; + +message Command_ReportDetails { + extend SessionCommand { + optional Command_ReportDetails ext = 1206; + } + required int32 report_id = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_list.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_list.proto new file mode 100644 index 000000000..6993de1a7 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_list.proto @@ -0,0 +1,11 @@ +syntax = "proto2"; +import "moderator_commands.proto"; + +message Command_ReportList { + extend ModeratorCommand { + optional Command_ReportList ext = 1200; + } + optional bool unresolved_only = 1; + optional uint32 offset = 2 [default = 0]; + optional uint32 limit = 3 [default = 100]; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_my_list.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_my_list.proto new file mode 100644 index 000000000..7ee18a65d --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_my_list.proto @@ -0,0 +1,8 @@ +syntax = "proto2"; +import "session_commands.proto"; + +message Command_ReportMyList { + extend SessionCommand { + optional Command_ReportMyList ext = 1204; + } +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_resolve.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_resolve.proto new file mode 100644 index 000000000..312ca0f9d --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_resolve.proto @@ -0,0 +1,11 @@ +syntax = "proto2"; +import "moderator_commands.proto"; + +message Command_ReportResolve { + extend ModeratorCommand { + optional Command_ReportResolve ext = 1202; + } + required int32 report_id = 1; + optional string resolution_note = 2; + optional bool dismissed = 3; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_stats.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_stats.proto new file mode 100644 index 000000000..b3c6eb1a9 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_stats.proto @@ -0,0 +1,8 @@ +syntax = "proto2"; +import "moderator_commands.proto"; + +message Command_ReportStats { + extend ModeratorCommand { + optional Command_ReportStats ext = 1205; + } +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_user_info.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_user_info.proto new file mode 100644 index 000000000..8f83cb5a3 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_user_info.proto @@ -0,0 +1,9 @@ +syntax = "proto2"; +import "moderator_commands.proto"; + +message Command_ReportUserInfo { + extend ModeratorCommand { + optional Command_ReportUserInfo ext = 1204; + } + optional string user_name = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_set_playmat.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_set_playmat.proto new file mode 100644 index 000000000..ad9a29a44 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_set_playmat.proto @@ -0,0 +1,9 @@ +syntax = "proto2"; +import "game_commands.proto"; +import "serverinfo_playerproperties.proto"; +message Command_SetPlaymat { + extend GameCommand { + optional Command_SetPlaymat ext = 1035; + } + optional ServerInfo_PlayerProperties.PlaymatParams playmat_params = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/commands.proto index b6eaf6733..964407819 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/commands.proto @@ -4,6 +4,7 @@ import "game_commands.proto"; import "room_commands.proto"; import "moderator_commands.proto"; import "admin_commands.proto"; +import "developer_commands.proto"; message CommandContainer { optional uint64 cmd_id = 1; @@ -16,4 +17,5 @@ message CommandContainer { repeated RoomCommand room_command = 102; repeated ModeratorCommand moderator_command = 103; repeated AdminCommand admin_command = 104; + repeated DeveloperCommand developer_command = 105; } diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/developer_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/developer_commands.proto new file mode 100644 index 000000000..bed47d44c --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/developer_commands.proto @@ -0,0 +1,8 @@ +syntax = "proto2"; +message DeveloperCommand { + enum DeveloperCommandType { + GET_SERVER_STATS = 1000; + VIEWLOG_HISTORY = 1001; + } + extensions 100 to max; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/event_notify_user.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/event_notify_user.proto index 3a90d278b..b722dfc71 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/event_notify_user.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/event_notify_user.proto @@ -9,6 +9,8 @@ message Event_NotifyUser { WARNING = 2; IDLEWARNING = 3; CUSTOM = 4; + REPORT_RESOLVED = 5; + REPORT_COMMENT = 6; } extend SessionEvent { diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/game_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/game_commands.proto index 796f4fc68..2e5b88978 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/game_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/game_commands.proto @@ -1,56 +1,202 @@ syntax = "proto2"; -// Commands that are sent during a game to change the game state +/// Commands that are sent during a game to change the game state message GameCommand { + + /// Identifies the concrete game command to execute enum GameCommandType { + + /// Kick a player from the game. + /// Server: Server_AbstractParticipant::cmdKickFromGame + /// Client: reflected via game state events KICK_FROM_GAME = 1000; + + /// Leave the current game voluntarily. + /// Server: Server_AbstractParticipant::cmdLeaveGame + /// Client: reflected via game state events LEAVE_GAME = 1001; + + /// Send an in-game chat message. + /// Server: Server_AbstractParticipant::cmdGameSay + /// Client: PlayerEventHandler::eventGameSay GAME_SAY = 1002; + + /// Shuffle a zone (typically the library). + /// Server: Server_Player::cmdShuffle + /// Client: PlayerEventHandler::eventShuffle SHUFFLE = 1003; + + /// Take a mulligan at game start. + /// Server: Server_Player::cmdMulligan + /// Client: reflected via game state events MULLIGAN = 1004; + + /// Roll one or more dice. + /// Server: Server_AbstractPlayer::cmdRollDie + /// Client: PlayerEventHandler::eventRollDie ROLL_DIE = 1005; + + /// Draw cards from the deck. + /// Server: Server_Player::cmdDrawCards + /// Client: PlayerEventHandler::eventDrawCards DRAW_CARDS = 1006; + + /// Undo a previous draw action. + /// Server: Server_Player::cmdUndoDraw + /// Client: PlayerEventHandler::eventMoveCard (Context_UndoDraw) UNDO_DRAW = 1007; + + /// Flip a card face up or face down. + /// Server: Server_AbstractPlayer::cmdFlipCard + /// Client: PlayerEventHandler::eventFlipCard FLIP_CARD = 1008; + + /// Attach one card to another. + /// Server: Server_AbstractPlayer::cmdAttachCard + /// Client: PlayerEventHandler::eventAttachCard ATTACH_CARD = 1009; + + /// Create a token card. + /// Server: Server_AbstractPlayer::cmdCreateToken + /// Client: PlayerEventHandler::eventCreateToken CREATE_TOKEN = 1010; + + /// Create a visual arrow between objects. + /// Server: Server_AbstractPlayer::cmdCreateArrow + /// Client: PlayerEventHandler::eventCreateArrow CREATE_ARROW = 1011; + + /// Delete a visual arrow. + /// Server: Server_AbstractPlayer::cmdDeleteArrow + /// Client: PlayerEventHandler::eventDeleteArrow DELETE_ARROW = 1012; + + /// Set a card attribute (e.g. tapped, flipped). + /// Server: Server_AbstractPlayer::cmdSetCardAttr + /// Client: PlayerEventHandler::eventSetCardAttr SET_CARD_ATTR = 1013; + + /// Set a counter value on a card. + /// Server: Server_AbstractPlayer::cmdSetCardCounter + /// Client: PlayerEventHandler::eventSetCardCounter SET_CARD_COUNTER = 1014; + + /// Increment a counter on a card. + /// Server: Server_AbstractPlayer::cmdIncCardCounter + /// Client: PlayerEventHandler::eventSetCardCounter INC_CARD_COUNTER = 1015; + + /// Mark the player as ready to start the game. + /// Server: Server_AbstractPlayer::cmdReadyStart + /// Client: reflected via game state events READY_START = 1016; + + /// Concede the game. + /// Server: Server_AbstractPlayer::cmdConcede + /// Client: reflected via game state events CONCEDE = 1017; + + /// Increment a global (non-card) counter. + /// Server: Server_Player::cmdIncCounter + /// Client: PlayerEventHandler::eventSetCounter INC_COUNTER = 1018; + + /// Create a new global counter. + /// Server: Server_Player::cmdCreateCounter + /// Client: PlayerEventHandler::eventCreateCounter CREATE_COUNTER = 1019; + + /// Set a global counter value. + /// Server: Server_Player::cmdSetCounter + /// Client: PlayerEventHandler::eventSetCounter SET_COUNTER = 1020; + + /// Delete a global counter. + /// Server: Server_Player::cmdDelCounter + /// Client: PlayerEventHandler::eventDelCounter DEL_COUNTER = 1021; + + /// Advance to the next turn. + /// Server: Server_Player::cmdNextTurn + /// Client: reflected via active player / phase events NEXT_TURN = 1022; + + /// Set the active phase of the turn. + /// Server: Server_Player::cmdSetActivePhase + /// Client: reflected via game state events SET_ACTIVE_PHASE = 1023; + + /// Dump the contents of a zone. + /// Server: Server_AbstractPlayer::cmdDumpZone + /// Client: PlayerEventHandler::eventDumpZone DUMP_ZONE = 1024; - // STOP_DUMP_ZONE = 1025; // obsolete + + /// Reveal specific cards to players. + /// Server: Server_AbstractPlayer::cmdRevealCards + /// Client: PlayerEventHandler::eventRevealCards REVEAL_CARDS = 1026; + + /// Move a card between zones. + /// Server: Server_AbstractPlayer::cmdMoveCard + /// Client: PlayerEventHandler::eventMoveCard MOVE_CARD = 1027; + + /// Set the sideboard plan for the game. + /// Server: Server_Player::cmdSetSideboardPlan + /// Client: not forwarded as a game event SET_SIDEBOARD_PLAN = 1028; + + /// Select a deck for the game. + /// Server: Server_Player::cmdDeckSelect + /// Client: reflected via lobby / game state DECK_SELECT = 1029; + + /// Lock or unlock sideboarding. + /// Server: Server_Player::cmdSetSideboardLock + /// Client: reflected via game state SET_SIDEBOARD_LOCK = 1030; + + /// Change zone properties (visibility, reveal-top, etc.). + /// Server: Server_Player::cmdChangeZoneProperties + /// Client: PlayerEventHandler::eventChangeZoneProperties CHANGE_ZONE_PROPERTIES = 1031; + + /// Undo a previous concede. + /// Server: Server_AbstractPlayer::cmdUnconcede + /// Client: reflected via game state UNCONCEDE = 1032; + + /// Execute a command on behalf of another player. + /// Server: Server_AbstractParticipant::cmdJudge + /// Client: transparent (wrapped commands handled normally) JUDGE = 1033; + + /// Reverse the current turn order. + /// Server: Server_Player::cmdReverseTurn + /// Client: reflected via subsequent turn events REVERSE_TURN = 1034; + + /// Set the player's playmat independently of the deck. + /// Server: Server_Player::cmdSetPlaymat + /// Client: reflected via player properties changed event + SET_PLAYMAT = 1035; } + extensions 100 to max; } -// A wrapper around a normal game command that allows a privileged user to send a command on behalf of another player +/// A wrapper around a normal game command that allows a privileged user +/// to send a command on behalf of another player. message Command_Judge { + extend GameCommand { + /// Judge command extension payload optional Command_Judge ext = 1033; } - // The player on whose behalf this command is sent + /// The player on whose behalf this command is sent. optional sint32 target_id = 1 [default = -1]; - // The wrapped game command + /// One or more wrapped game commands to execute. repeated GameCommand game_command = 2; } diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto index 9d01b51d2..4f1e80c27 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto @@ -1,4 +1,5 @@ syntax = "proto2"; +import "developer_commands.proto"; message ModeratorCommand { enum ModeratorCommandType { BAN_FROM_SERVER = 1000; @@ -11,6 +12,20 @@ message ModeratorCommand { FORCE_ACTIVATE_USER = 1007; GET_ADMIN_NOTES = 1008; UPDATE_ADMIN_NOTES = 1009; + ADD_CARD_ART_RULE = 1010; + REMOVE_CARD_ART_RULE = 1011; + LIST_CARD_ART_RULES = 1012; + GET_USER_SESSIONS = 1013; + GET_USER_ALTS = 1014; + GET_MODERATOR_LAST_LOGINS = 1015; + RESET_USER_PASSWORD = 1016; + REMOVE_USER_AVATAR = 1017; + REPORT_LIST = 1200; + REPORT_ASSIGN = 1201; + REPORT_RESOLVE = 1202; + REPLAY_DOWNLOAD_BY_GAME_ID = 1203; + REPORT_USER_INFO = 1204; + REPORT_STATS = 1205; } extensions 100 to max; } @@ -66,6 +81,9 @@ message Command_ViewLogHistory { extend ModeratorCommand { optional Command_ViewLogHistory ext = 1005; } + extend DeveloperCommand { + optional Command_ViewLogHistory dev_ext = 1001; + } optional string user_name = 1; // user that created message optional string ip_address = 2; // ip address of user that created message optional string game_name = 3; // client id of user that created the message @@ -106,3 +124,57 @@ message Command_UpdateAdminNotes { optional string user_name = 1; optional string notes = 2; } + +message Command_AddCardArtRule { + extend ModeratorCommand { + optional Command_AddCardArtRule ext = 1010; + } + + optional string card_name = 1; + optional string card_provider_id = 2; + optional string mode = 3; // "ALLOW" or "DENY" + optional string reason = 4; +} + +message Command_RemoveCardArtRule { + extend ModeratorCommand { + optional Command_RemoveCardArtRule ext = 1011; + } + + optional string card_name = 1; + optional string card_provider_id = 2; +} + +message Command_ListCardArtRules { + extend ModeratorCommand { + optional Command_ListCardArtRules ext = 1012; + } +} + +message Command_GetUserSessions { + extend ModeratorCommand { + optional Command_GetUserSessions ext = 1013; + } + optional string user_name = 1; + optional uint32 limit = 2 [default = 110]; +} + +message Command_GetUserAlts { + extend ModeratorCommand { + optional Command_GetUserAlts ext = 1014; + } + optional string user_name = 1; +} + +message Command_GetModeratorLastLogins { + extend ModeratorCommand { + optional Command_GetModeratorLastLogins ext = 1015; + } +} + +message Command_RemoveUserAvatar { + extend ModeratorCommand { + optional Command_RemoveUserAvatar ext = 1017; + } + optional string user_name = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto index dece8ae17..42a42fcc0 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto @@ -1,35 +1,44 @@ syntax = "proto2"; -// Sent immediately after a command with the same cmd_id, connecting it to the command sent to the server +/// Server response envelope linking to a command +/** + * This message is sent immediately after a client command, using the same `cmd_id` + * to connect the response to the command. + * + * Responses may contain standard outcome codes (`ResponseCode`) and indicate + * what type of response the server is sending (`ResponseType`). + */ message Response { + + // Outcome of the command sent by the client enum ResponseCode { - RespNotConnected = -1; - RespNothing = 0; - RespOk = 1; - RespNotInRoom = 2; - RespInternalError = 3; - RespInvalidCommand = 4; - RespInvalidData = 5; - RespNameNotFound = 6; - RespLoginNeeded = 7; - RespFunctionNotAllowed = 8; - RespGameNotStarted = 9; - RespGameFull = 10; - RespContextError = 11; - RespWrongPassword = 12; - RespSpectatorsNotAllowed = 13; - RespOnlyBuddies = 14; - RespUserLevelTooLow = 15; - RespInIgnoreList = 16; - RespWouldOverwriteOldSession = 17; - RespChatFlood = 18; - RespUserIsBanned = 19; - RespAccessDenied = 20; - RespUsernameInvalid = 21; - RespRegistrationRequired = 22; - RespRegistrationAccepted = 23; // Server agrees to process client's registration request - RespUserAlreadyExists = 24; // Client attempted to register a name which is already registered - RespEmailRequiredToRegister = 25; // Server requires email to register accounts but client did not provide one + RespNotConnected = -1; // Client is not connected or session expired + RespNothing = 0; // No response required + RespOk = 1; // Command succeeded + RespNotInRoom = 2; // Client is not in the room for this command + RespInternalError = 3; // Server encountered an unexpected error + RespInvalidCommand = 4; // Command was invalid or unrecognized + RespInvalidData = 5; // Command data was invalid or malformed + RespNameNotFound = 6; // Target user not found + RespLoginNeeded = 7; // Client must log in first + RespFunctionNotAllowed = 8; // Client tried to perform a restricted action + RespGameNotStarted = 9; // Game has not started yet + RespGameFull = 10; // Game is full + RespContextError = 11; // Context (room/game) mismatch + RespWrongPassword = 12; // Password for this room or game is incorrect + RespSpectatorsNotAllowed = 13; // Spectators are not allowed in this room/game + RespOnlyBuddies = 14; // Only buddies can perform this action + RespUserLevelTooLow = 15; // User’s permission level is insufficient + RespInIgnoreList = 16; // Target user has ignored the client + RespWouldOverwriteOldSession = 17; // Would overwrite an existing session + RespChatFlood = 18; // Too many messages sent in short time + RespUserIsBanned = 19; // Client is banned + RespAccessDenied = 20; // Access to this action is denied + RespUsernameInvalid = 21; // Username is invalid + RespRegistrationRequired = 22; // Registration is required + RespRegistrationAccepted = 23; // Server agrees to process client's registration request + RespUserAlreadyExists = 24; // Client attempted to register a name which is already registered + RespEmailRequiredToRegister = 25; // Server requires email to register accounts but client did not provide one RespTooManyRequests = 26; // Server refused to complete command because client has sent too many too quickly RespPasswordTooShort = 27; // Server requires a decent password RespAccountNotActivated = @@ -44,33 +53,39 @@ message Response { RespClientUpdateRequired = 35; // Client is missing features that the server is requiring RespServerFull = 36; // Server user limit reached RespEmailBlackListed = 37; // Server has blocked the email address provided for registration for some reason + RespPasswordChangeRequired = 38; // Server requires the user to change their password before proceeding } - enum ResponseType { - JOIN_ROOM = 1000; - LIST_USERS = 1001; - GET_GAMES_OF_USER = 1002; - GET_USER_INFO = 1003; - DUMP_ZONE = 1004; - LOGIN = 1005; - DECK_LIST = 1006; - DECK_DOWNLOAD = 1007; - DECK_UPLOAD = 1008; - REGISTER = 1009; - ACTIVATE = 1010; - ADJUST_MOD = 1011; - BAN_HISTORY = 1012; - WARN_HISTORY = 1013; - WARN_LIST = 1014; - VIEW_LOG = 1015; - FORGOT_PASSWORD_REQUEST = 1016; - PASSWORD_SALT = 1017; - GET_ADMIN_NOTES = 1018; - REPLAY_LIST = 1100; - REPLAY_DOWNLOAD = 1101; - REPLAY_GET_CODE = 1102; - } - required uint64 cmd_id = 1; - optional ResponseCode response_code = 2; - extensions 100 to max; + // Type of response, used to route handling on the client + enum ResponseType { + JOIN_ROOM = 1000; // Response for joining a room + LIST_USERS = 1001; // Response listing users + GET_GAMES_OF_USER = 1002; // Response with user's games + GET_USER_INFO = 1003; // Response with user info + DUMP_ZONE = 1004; // Response with zone dump + LOGIN = 1005; // Response to login attempt + DECK_LIST = 1006; // Response with deck list + DECK_DOWNLOAD = 1007; // Response for deck download + DECK_UPLOAD = 1008; // Response for deck upload + REGISTER = 1009; // Response to registration + ACTIVATE = 1010; // Response to activation + ADJUST_MOD = 1011; // Response to mod adjustments + BAN_HISTORY = 1012; // Response with ban history + WARN_HISTORY = 1013; // Response with warn history + WARN_LIST = 1014; // Response with current warnings + VIEW_LOG = 1015; // Response with logs + FORGOT_PASSWORD_REQUEST = 1016; // Response to password reset request + PASSWORD_SALT = 1017; // Response containing password salt + GET_ADMIN_NOTES = 1018; // Response with admin notes + GET_SERVER_STATS = 1019; // Response with server status statistics + REPLAY_LIST = 1100; // Response listing replays + REPLAY_DOWNLOAD = 1101; // Response for replay download + REPLAY_GET_CODE = 1102; // Response containing replay code + CARD_ART_RULE_LIST = 1200; // Response containing a list of card art rules + } + + required uint64 cmd_id = 1; // Command ID that this response corresponds to + optional ResponseCode response_code = 2; // Outcome code of the command + + extensions 100 to max; // Protobuf extensions reserved for future use } diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_card_art_rule_entry.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_card_art_rule_entry.proto new file mode 100644 index 000000000..25b76e09f --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_card_art_rule_entry.proto @@ -0,0 +1,16 @@ +syntax = "proto2"; +import "response.proto"; + +message Response_CardArtRuleEntry { + optional string card_name = 1; + optional string card_provider_id = 2; + optional string mode = 3; + optional string reason = 4; +} + +message Response_ListCardArtRules { + extend Response { + optional Response_ListCardArtRules ext = 1200; + } + repeated Response_CardArtRuleEntry entries = 1; +} \ No newline at end of file diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_get_server_stats.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_get_server_stats.proto new file mode 100644 index 000000000..bb8ff3c43 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_get_server_stats.proto @@ -0,0 +1,41 @@ +syntax = "proto2"; +import "response.proto"; + +message CommandStats { + optional uint32 kind_index = 1; // 0=session, 1=room, 2=game, 3=moderator, 4=admin, 5=developer + optional uint32 extension_number = 2; // protobuf extension number within the kind + optional string command_name = 3; // e.g. "session/Command_Ping" + optional uint64 count = 4; // number of times observed + optional uint64 total_ms = 5; // cumulative processing milliseconds +} + +message Response_GetServerStats { + extend Response { + optional Response_GetServerStats ext = 1220; + } + + optional uint64 users_count = 1; + optional uint64 mods_count = 2; + optional uint64 games_count = 3; + + // Traffic recorded during the last status update tick + optional uint64 tx_bytes = 4; + optional uint64 rx_bytes = 5; + + optional uint64 uptime_secs = 6; + optional uint64 timest = 7; // unix timestamp of the snapshot + + // Live metrics from MetricsRegistry (reset on server restart) + optional uint64 cards_in_games = 8; + optional uint64 eventloop_stalls_total = 9; + optional uint64 eventloop_last_stall_ms = 10; + optional uint64 eventloop_max_stall_ms = 11; + optional uint64 total_commands = 12; + optional uint64 total_command_time_ms = 13; + optional int32 active_command_types = 14; + optional uint64 game_start_count = 15; + optional uint64 game_start_total_ms = 16; + + // Per-command breakdown (only types with count > 0) + repeated CommandStats command_stats = 20; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_moderator_last_logins.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_moderator_last_logins.proto new file mode 100644 index 000000000..6288880c4 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_moderator_last_logins.proto @@ -0,0 +1,10 @@ +syntax = "proto2"; +import "response.proto"; +import "serverinfo_moderator_login.proto"; + +message Response_ModeratorLastLogins { + extend Response { + optional Response_ModeratorLastLogins ext = 1217; + } + repeated ServerInfo_ModeratorLogin logins = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_remove_user_avatar.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_remove_user_avatar.proto new file mode 100644 index 000000000..e5697d4e0 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_remove_user_avatar.proto @@ -0,0 +1,9 @@ +syntax = "proto2"; +import "response.proto"; + +message Response_RemoveUserAvatar { + extend Response { + optional Response_RemoveUserAvatar ext = 1219; + } + optional string user_name = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_replay_download_by_game_id.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_replay_download_by_game_id.proto new file mode 100644 index 000000000..77a5feb81 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_replay_download_by_game_id.proto @@ -0,0 +1,10 @@ +syntax = "proto2"; +import "response.proto"; + +message Response_ReplayDownloadByGameId { + extend Response { + optional Response_ReplayDownloadByGameId ext = 1203; + } + optional bytes replay_data = 1; + optional sint32 replay_id = 2; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_report_details.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_report_details.proto new file mode 100644 index 000000000..463beaefc --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_report_details.proto @@ -0,0 +1,10 @@ +syntax = "proto2"; +import "response.proto"; +import "serverinfo_report.proto"; + +message Response_ReportDetails { + extend Response { + optional Response_ReportDetails ext = 1214; + } + optional ServerInfo_Report report = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_report_list.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_report_list.proto new file mode 100644 index 000000000..73d9fbef8 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_report_list.proto @@ -0,0 +1,11 @@ +syntax = "proto2"; +import "response.proto"; +import "serverinfo_report.proto"; + +message Response_ReportList { + extend Response { + optional Response_ReportList ext = 1210; + } + repeated ServerInfo_Report reports = 1; + optional uint32 total_count = 2; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_report_my_list.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_report_my_list.proto new file mode 100644 index 000000000..fdd02a3f1 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_report_my_list.proto @@ -0,0 +1,10 @@ +syntax = "proto2"; +import "response.proto"; +import "serverinfo_report.proto"; + +message Response_ReportMyList { + extend Response { + optional Response_ReportMyList ext = 1213; + } + repeated ServerInfo_Report reports = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_report_stats.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_report_stats.proto new file mode 100644 index 000000000..8bd2cf766 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_report_stats.proto @@ -0,0 +1,36 @@ +syntax = "proto2"; +import "response.proto"; + +message Response_ReportStats { + extend Response { + optional Response_ReportStats ext = 1212; + } + + optional int32 total_reports = 1; + optional int32 total_pending = 2; + optional int32 total_assigned = 3; + optional int32 total_resolved = 4; + + optional int32 reports_last_24h = 5; + optional int32 reports_last_7d = 6; + optional int32 reports_last_30d = 7; + + optional double avg_resolution_hours = 8; + + optional int32 reports_this_week = 9; + optional int32 reports_last_week = 10; + + repeated ReportCategoryCount category_counts = 11; + repeated ReportTopUser top_reported_users = 12; + repeated ReportTopUser top_reporters = 13; +} + +message ReportCategoryCount { + optional string category = 1; + optional int32 count = 2; +} + +message ReportTopUser { + optional string user_name = 1; + optional int32 count = 2; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_report_user_info.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_report_user_info.proto new file mode 100644 index 000000000..5f9e8ec38 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_report_user_info.proto @@ -0,0 +1,21 @@ +syntax = "proto2"; +import "response.proto"; +import "serverinfo_report.proto"; + +message Response_ReportUserInfo { + extend Response { + optional Response_ReportUserInfo ext = 1211; + } + + optional string user_name = 1; + optional int32 total_reports = 2; + optional int32 total_bans = 3; + optional int32 total_warns = 4; + optional int64 registration_time = 5; + optional bool is_admin = 6; + optional bool is_active = 7; + optional string admin_notes = 8; + repeated ServerInfo_Report recent_reports = 9; + // Last known login of the user, epoch seconds; 0 = unknown. + optional int64 last_login = 10; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_reset_user_password.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_reset_user_password.proto new file mode 100644 index 000000000..aa379e573 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_reset_user_password.proto @@ -0,0 +1,12 @@ +syntax = "proto2"; +import "response.proto"; + +message Response_ResetUserPassword { + extend Response { + optional Response_ResetUserPassword ext = 1218; + } + optional string user_name = 1; + // The generated temporary password, shown to the moderator who + // requested the reset. The affected user must change it on first login. + optional string temporary_password = 2; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_user_alts.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_user_alts.proto new file mode 100644 index 000000000..6bc48d6cb --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_user_alts.proto @@ -0,0 +1,10 @@ +syntax = "proto2"; +import "response.proto"; +import "serverinfo_user_alt.proto"; + +message Response_UserAlts { + extend Response { + optional Response_UserAlts ext = 1216; + } + repeated ServerInfo_UserAlt alts = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_user_sessions.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_user_sessions.proto new file mode 100644 index 000000000..7174fd3f7 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_user_sessions.proto @@ -0,0 +1,10 @@ +syntax = "proto2"; +import "response.proto"; +import "serverinfo_user_session.proto"; + +message Response_UserSessions { + extend Response { + optional Response_UserSessions ext = 1215; + } + repeated ServerInfo_UserSession sessions = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_warn_list.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_warn_list.proto index d48352529..cddd08a51 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/response_warn_list.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_warn_list.proto @@ -8,4 +8,7 @@ message Response_WarnList { repeated string warning = 1; optional string user_name = 2; optional string user_clientid = 3; + // Recommended starting intervention level per warning category, + // aligned by index with `warning`. Absent or shorter lists default to 1. + repeated uint32 warning_il = 4; } diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_game.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_game.proto index 9a56e034c..9989ae18a 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_game.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_game.proto @@ -62,4 +62,7 @@ message ServerInfo_Game { // whether the game is closed. Closed games are finished and can't be interacted with optional bool closed = 52; + + // the current host of the game, which may differ from the creator after a host transfer + optional ServerInfo_User host_info = 53; } diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_moderator_login.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_moderator_login.proto new file mode 100644 index 000000000..21db2d3fa --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_moderator_login.proto @@ -0,0 +1,11 @@ +syntax = "proto2"; + +/** + * The last login date of a staff member (moderator/judge/admin). + * Used by the moderation "Moderator Last Logins" tool. + */ +message ServerInfo_ModeratorLogin { + optional string user_name = 1; // staff account name + optional uint64 last_login = 2; // last known login, epoch seconds; 0 = unknown + optional uint32 user_level = 3; // ServerInfo_User::UserLevelFlag mask (moderator/judge/admin) +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_playerproperties.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_playerproperties.proto index cdd0ee42c..ae19e4018 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_playerproperties.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_playerproperties.proto @@ -2,6 +2,15 @@ syntax = "proto2"; import "serverinfo_user.proto"; message ServerInfo_PlayerProperties { + message PlaymatParams { + optional string card_name = 1; + optional string card_provider_id = 2; + optional double margin_pct_l = 3 [default = 0.07]; + optional double margin_pct_r = 4 [default = 0.07]; + optional double vertical_offset = 5 [default = 0.33]; + optional double zoom = 6 [default = 1.0]; + } + optional sint32 player_id = 1; optional ServerInfo_User user_info = 2; optional bool spectator = 3; @@ -11,4 +20,5 @@ message ServerInfo_PlayerProperties { optional sint32 ping_seconds = 7; optional bool sideboard_locked = 8; optional bool judge = 9; + optional PlaymatParams playmat_params = 10; } diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_report.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_report.proto new file mode 100644 index 000000000..a145fd855 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_report.proto @@ -0,0 +1,36 @@ +syntax = "proto2"; + +message ServerInfo_ReportComment { + optional string author_name = 1; + optional string comment_text = 2; + optional int64 comment_time = 3; + optional bool is_moderator = 4; +} + +message ServerInfo_Report { + optional int32 report_id = 1; + + optional string reporter_name = 2; + optional string reported_user_name = 3; + + optional int32 game_id = 4; + optional int32 replay_id = 5; + optional int32 room_id = 6; + + optional string category = 7; + optional string status = 8; + + optional string description = 9; + + optional int64 report_time = 10; + + optional string assigned_mod_name = 11; + + optional int64 resolution_time = 12; + + repeated ServerInfo_ReportComment comments = 13; + + optional string chat_log = 14; + + optional string resolution_note = 15; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user.proto index 10add611f..ea3f56705 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user.proto @@ -1,4 +1,5 @@ syntax = "proto2"; + message ServerInfo_User { enum UserLevelFlag { IsNothing = 0; @@ -7,11 +8,20 @@ message ServerInfo_User { IsModerator = 4; IsAdmin = 8; IsJudge = 16; + IsDeveloper = 32; }; message PawnColorsOverride { optional string left_side = 1; optional string right_side = 2; }; + message CardArtParams { + optional string card_name = 1; + optional string card_provider_id = 2; + optional double margin_pct_l = 3 [default = 0.33]; + optional double margin_pct_r = 4 [default = 0.02]; + optional double vertical_offset = 5 [default = 0.35]; + optional double zoom = 6 [default = 1.0]; + }; optional string name = 1; optional uint32 user_level = 2; @@ -28,4 +38,5 @@ message ServerInfo_User { optional string clientid = 13; optional string privlevel = 14; optional PawnColorsOverride pawn_colors = 15; -} + optional CardArtParams card_art_params = 16; +} \ No newline at end of file diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user_alt.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user_alt.proto new file mode 100644 index 000000000..191dd5063 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user_alt.proto @@ -0,0 +1,16 @@ +syntax = "proto2"; + +/** + * An account that shares an IP address, client id or eMail address with + * the account being investigated. Used by the moderation "Get User Alts" tool. + */ +message ServerInfo_UserAlt { + optional string user_name = 1; // account name + optional string email = 2; // registration eMail + optional string clientid = 3; // client id + optional uint64 registration_time = 4; // account registration, epoch seconds + optional uint64 last_login = 5; // last known login, epoch seconds; 0 = unknown + optional uint32 warn_count = 6; // number of warnings on record + optional uint32 ban_count = 7; // number of bans on record + optional bool is_active = 8; // account is not deactivated/banned +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user_session.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user_session.proto new file mode 100644 index 000000000..7ce240050 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user_session.proto @@ -0,0 +1,14 @@ +syntax = "proto2"; + +/** + * A single login session of a user on the server, as stored in the + * sessions table. Used by the moderation "Get User Sessions" tool. + */ +message ServerInfo_UserSession { + optional string user_name = 1; // account that was logged in + optional string ip_address = 2; // IP address used for the session + optional string clientid = 3; // client id used for the session + optional uint64 start_time = 4; // session start, epoch seconds + optional uint64 end_time = 5; // session end, epoch seconds; 0 = still active + optional string connection_type = 6; // "tcp" or "websocket" +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto index cecf87370..fee8c36a8 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto @@ -27,12 +27,18 @@ message SessionCommand { FORGOT_PASSWORD_RESET = 1022; FORGOT_PASSWORD_CHALLENGE = 1023; REQUEST_PASSWORD_SALT = 1024; + SET_CARD_ART_PARAMS = 1025; REPLAY_LIST = 1100; REPLAY_DOWNLOAD = 1101; REPLAY_MODIFY_MATCH = 1102; REPLAY_DELETE_MATCH = 1103; REPLAY_GET_CODE = 1104; REPLAY_SUBMIT_CODE = 1105; + REPORT = 1200; + // 1201-1203 reserved: removed during squash + REPORT_MY_LIST = 1204; + REPORT_ADD_COMMENT = 1205; + REPORT_DETAILS = 1206; } extensions 100 to max; } @@ -205,3 +211,15 @@ message Command_RequestPasswordSalt { } required string user_name = 1; } + +message Command_SetCardArtParams { + extend SessionCommand { + optional Command_SetCardArtParams ext = 1025; + } + optional string card_name = 1; + optional string card_provider_id = 2; + optional double margin_pct_l = 3; + optional double margin_pct_r = 4; + optional double vertical_offset = 5; + optional double zoom = 6; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pending_command.cpp b/libcockatrice_protocol/libcockatrice/protocol/pending_command.cpp index 4a9943d33..62d35313e 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pending_command.cpp +++ b/libcockatrice_protocol/libcockatrice/protocol/pending_command.cpp @@ -29,3 +29,13 @@ int PendingCommand::tick() { return ++ticks; } + +void PendingCommand::startTiming() +{ + startTime.start(); +} + +qint64 PendingCommand::elapsedMs() const +{ + return startTime.isValid() ? startTime.nsecsElapsed() / 1000000 : -1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pending_command.h b/libcockatrice_protocol/libcockatrice/protocol/pending_command.h index dbe57e7fc..b8f861d08 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pending_command.h +++ b/libcockatrice_protocol/libcockatrice/protocol/pending_command.h @@ -7,6 +7,7 @@ #ifndef PENDING_COMMAND_H #define PENDING_COMMAND_H +#include #include #include #include @@ -21,6 +22,7 @@ private: CommandContainer commandContainer; QVariant extraData; int ticks; + QElapsedTimer startTime; public: explicit PendingCommand(const CommandContainer &_commandContainer, QVariant _extraData = QVariant()); @@ -29,6 +31,18 @@ public: QVariant getExtraData() const; void processResponse(const Response &response); int tick(); + + /** + * @brief Starts the round-trip timer. Called by the client thread right + * before the command container is handed to the transport layer. + */ + void startTiming(); + + /** + * @return Milliseconds elapsed since startTiming(), or -1 if the timer was + * never started. + */ + qint64 elapsedMs() const; }; #endif diff --git a/libcockatrice_rng/CMakeLists.txt b/libcockatrice_rng/CMakeLists.txt index 6ff2a4537..b988c9d2f 100644 --- a/libcockatrice_rng/CMakeLists.txt +++ b/libcockatrice_rng/CMakeLists.txt @@ -4,11 +4,7 @@ set(CMAKE_AUTORCC ON) set(HEADERS libcockatrice/rng/rng_abstract.h libcockatrice/rng/rng_sfmt.h libcockatrice/rng/sfmt/SFMT.h) -if(Qt6_FOUND) - qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) -elseif(Qt5_FOUND) - qt5_wrap_cpp(MOC_SOURCES ${HEADERS}) -endif() +qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) add_library( libcockatrice_rng STATIC ${MOC_SOURCES} libcockatrice/rng/rng_abstract.cpp libcockatrice/rng/rng_sfmt.cpp diff --git a/libcockatrice_rng/libcockatrice/rng/rng_sfmt.cpp b/libcockatrice_rng/libcockatrice/rng/rng_sfmt.cpp index 5b38deb3f..4c578b4e4 100644 --- a/libcockatrice_rng/libcockatrice/rng/rng_sfmt.cpp +++ b/libcockatrice_rng/libcockatrice/rng/rng_sfmt.cpp @@ -1,6 +1,5 @@ #include "rng_sfmt.h" -#include #include #include #include @@ -11,10 +10,11 @@ #define UINT64_MAX (~(uint64_t)0) #endif -RNG_SFMT::RNG_SFMT(QObject *parent) : RNG_Abstract(parent) +RNG_SFMT::RNG_SFMT(uint64_t seed, QObject *parent) : RNG_Abstract(parent) { - // initialize the random number generator with a 32bit integer seed (timestamp) - sfmt_init_gen_rand(&sfmt, QDateTime::currentDateTime().toSecsSinceEpoch()); + // initialize the random number generator with a 64bit seed, e.g. from a CSPRNG + uint32_t seedArray[2] = {static_cast(seed), static_cast(seed >> 32)}; + sfmt_init_by_array(&sfmt, seedArray, 2); } /** diff --git a/libcockatrice_rng/libcockatrice/rng/rng_sfmt.h b/libcockatrice_rng/libcockatrice/rng/rng_sfmt.h index 7e9f53df3..a180dad99 100644 --- a/libcockatrice_rng/libcockatrice/rng/rng_sfmt.h +++ b/libcockatrice_rng/libcockatrice/rng/rng_sfmt.h @@ -36,7 +36,7 @@ private: unsigned int cdf(unsigned int min, unsigned int max); public: - explicit RNG_SFMT(QObject *parent = nullptr); + explicit RNG_SFMT(uint64_t seed, QObject *parent = nullptr); unsigned int rand(int min, int max) override; }; diff --git a/libcockatrice_settings/CMakeLists.txt b/libcockatrice_settings/CMakeLists.txt index 3afe6e00a..80a465b6d 100644 --- a/libcockatrice_settings/CMakeLists.txt +++ b/libcockatrice_settings/CMakeLists.txt @@ -3,37 +3,65 @@ set(CMAKE_AUTOUIC ON) set(CMAKE_AUTORCC ON) set(HEADERS + libcockatrice/settings/appearance_settings.h + libcockatrice/settings/cache_storage_settings.h libcockatrice/settings/card_database_settings.h libcockatrice/settings/card_override_settings.h + libcockatrice/settings/cards_display_settings.h + libcockatrice/settings/chat_settings.h + libcockatrice/settings/commander_bracket_settings.h libcockatrice/settings/debug_settings.h + libcockatrice/settings/deck_editor_settings.h libcockatrice/settings/download_settings.h libcockatrice/settings/game_filters_settings.h + libcockatrice/settings/game_settings.h + libcockatrice/settings/interface_settings.h libcockatrice/settings/layouts_settings.h libcockatrice/settings/message_settings.h + libcockatrice/settings/network_settings.h + libcockatrice/settings/paths_settings.h + libcockatrice/settings/personal_settings.h libcockatrice/settings/recents_settings.h libcockatrice/settings/servers_settings.h libcockatrice/settings/settings_manager.h + libcockatrice/settings/settings_migration.h + libcockatrice/settings/sound_settings.h + libcockatrice/settings/tabs_settings.h + libcockatrice/settings/updates_settings.h + libcockatrice/settings/visual_deck_storage_settings.h ) -if(Qt6_FOUND) - qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) -elseif(Qt5_FOUND) - qt5_wrap_cpp(MOC_SOURCES ${HEADERS}) -endif() +qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) add_library( libcockatrice_settings STATIC ${MOC_SOURCES} + libcockatrice/settings/appearance_settings.cpp + libcockatrice/settings/cache_storage_settings.cpp libcockatrice/settings/card_database_settings.cpp libcockatrice/settings/card_override_settings.cpp + libcockatrice/settings/commander_bracket_settings.cpp + libcockatrice/settings/cards_display_settings.cpp + libcockatrice/settings/chat_settings.cpp libcockatrice/settings/debug_settings.cpp + libcockatrice/settings/deck_editor_settings.cpp libcockatrice/settings/download_settings.cpp libcockatrice/settings/game_filters_settings.cpp + libcockatrice/settings/game_settings.cpp + libcockatrice/settings/interface_settings.cpp libcockatrice/settings/layouts_settings.cpp libcockatrice/settings/message_settings.cpp + libcockatrice/settings/network_settings.cpp + libcockatrice/settings/paths_settings.cpp + libcockatrice/settings/personal_settings.cpp libcockatrice/settings/recents_settings.cpp libcockatrice/settings/servers_settings.cpp libcockatrice/settings/settings_manager.cpp + libcockatrice/settings/settings_migration.cpp + libcockatrice/settings/sound_settings.cpp + libcockatrice/settings/tabs_settings.cpp + libcockatrice/settings/updates_settings.cpp + libcockatrice/settings/visual_deck_storage_settings.cpp ) target_include_directories( diff --git a/libcockatrice_settings/libcockatrice/settings/appearance_settings.cpp b/libcockatrice_settings/libcockatrice/settings/appearance_settings.cpp new file mode 100644 index 000000000..839e33be4 --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/appearance_settings.cpp @@ -0,0 +1,93 @@ +#include "appearance_settings.h" + +AppearanceSettings::AppearanceSettings(const QString &settingPath, QObject *parent) + : SettingsManager(settingPath + "appearance.ini", "appearance", QString(), parent) +{ +} + +QString AppearanceSettings::getThemeName() const +{ + return getValue("themeName", QString(), QString()).toString(); +} + +void AppearanceSettings::setThemeName(const QString &_themeName) +{ + setValue(_themeName, "themeName"); + emit themeNameChanged(); +} + +bool AppearanceSettings::getStyleUserList() const +{ + return getValue("styleUserList", QString(), QString(), true).toBool(); +} + +void AppearanceSettings::setStyleUserList(bool _styleUserList) +{ + setValue(_styleUserList, "styleUserList"); + emit styleUserListChanged(); +} + +int AppearanceSettings::getMaxFontSize() const +{ + return getValue("maxFontSize", QString(), QString(), 12).toInt(); +} + +void AppearanceSettings::setMaxFontSize(int _max) +{ + setValue(_max, "maxFontSize"); +} + +QString AppearanceSettings::getHomeTabBackgroundSource() const +{ + return getValue("homeTabBackgroundSource", QString(), QString(), "themed").toString(); +} + +void AppearanceSettings::setHomeTabBackgroundSource(const QString &_backgroundSource) +{ + setValue(_backgroundSource, "homeTabBackgroundSource"); + emit homeTabBackgroundSourceChanged(); +} + +int AppearanceSettings::getHomeTabBackgroundShuffleFrequency() const +{ + return getValue("homeTabBackgroundShuffleFrequency", QString(), QString(), 0).toInt(); +} + +void AppearanceSettings::setHomeTabBackgroundShuffleFrequency(int _frequency) +{ + setValue(_frequency, "homeTabBackgroundShuffleFrequency"); + emit homeTabBackgroundShuffleFrequencyChanged(); +} + +bool AppearanceSettings::getHomeTabDisplayCardName() const +{ + return getValue("homeTabDisplayCardName", QString(), QString(), true).toBool(); +} + +void AppearanceSettings::setHomeTabDisplayCardName(bool _displayCardName) +{ + setValue(_displayCardName, "homeTabDisplayCardName"); + emit homeTabDisplayCardNameChanged(); +} + +bool AppearanceSettings::getHomeTabBackgroundDim() const +{ + return getValue("homeTabBackgroundDim", QString(), QString(), true).toBool(); +} + +void AppearanceSettings::setHomeTabBackgroundDim(bool _dimBackground) +{ + setValue(_dimBackground, "homeTabBackgroundDim"); + emit homeTabBackgroundDimChanged(); +} + +int AppearanceSettings::getHomeTabButtonColorSourceIndex() const +{ + return getValue("homeTabButtonColorSource", "", "", 0).toInt(); +} + +void AppearanceSettings::setHomeTabButtonColorSourceIndex(int index) +{ + setValue(index, "homeTabButtonColorSource"); + emit homeTabButtonColorChanged(); +} diff --git a/libcockatrice_settings/libcockatrice/settings/appearance_settings.h b/libcockatrice_settings/libcockatrice/settings/appearance_settings.h new file mode 100644 index 000000000..a4504798e --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/appearance_settings.h @@ -0,0 +1,51 @@ +/** + * @file appearance_settings.h + * @ingroup CoreSettings + */ +//! \todo Document this file. + +#ifndef APPEARANCE_SETTINGS_H +#define APPEARANCE_SETTINGS_H + +#include "settings_manager.h" + +class AppearanceSettings : public SettingsManager +{ + Q_OBJECT + friend class SettingsCache; + +public: + [[nodiscard]] QString getThemeName() const; + void setThemeName(const QString &_themeName); + [[nodiscard]] bool getStyleUserList() const; + void setStyleUserList(bool _styleUserList); + [[nodiscard]] int getMaxFontSize() const; + void setMaxFontSize(int _max); + [[nodiscard]] QString getHomeTabBackgroundSource() const; + void setHomeTabBackgroundSource(const QString &_backgroundSource); + [[nodiscard]] int getHomeTabBackgroundShuffleFrequency() const; + void setHomeTabBackgroundShuffleFrequency(int _frequency); + [[nodiscard]] bool getHomeTabDisplayCardName() const; + void setHomeTabDisplayCardName(bool _displayCardName); + [[nodiscard]] bool getHomeTabBackgroundDim() const; + void setHomeTabBackgroundDim(bool _dimBackground); + [[nodiscard]] int getHomeTabButtonColorSourceIndex() const; + void setHomeTabButtonColorSourceIndex(int index); + +signals: + void themeNameChanged(); + void styleUserListChanged(); + void homeTabBackgroundSourceChanged(); + void homeTabBackgroundShuffleFrequencyChanged(); + void homeTabDisplayCardNameChanged(); + void homeTabBackgroundDimChanged(); + void homeTabButtonColorChanged(); + +public: + explicit AppearanceSettings(const QString &settingPath, QObject *parent = nullptr); + +private: + AppearanceSettings(const AppearanceSettings & /*other*/); +}; + +#endif // APPEARANCE_SETTINGS_H diff --git a/libcockatrice_settings/libcockatrice/settings/cache_storage_settings.cpp b/libcockatrice_settings/libcockatrice/settings/cache_storage_settings.cpp new file mode 100644 index 000000000..c800cc024 --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/cache_storage_settings.cpp @@ -0,0 +1,62 @@ +#include "cache_storage_settings.h" + +CacheStorageSettings::CacheStorageSettings(const QString &settingPath, QObject *parent) + : SettingsManager(settingPath + "cache_storage.ini", "cache_storage", QString(), parent) +{ +} + +int CacheStorageSettings::getPixmapCacheSize() const +{ + return getValue("pixmapCacheSize", QString(), QString(), PIXMAPCACHE_SIZE_DEFAULT).toInt(); +} + +int CacheStorageSettings::getNetworkCacheSizeInMB() const +{ + return getValue("networkCacheSize", QString(), QString(), NETWORK_CACHE_SIZE_DEFAULT).toInt(); +} + +int CacheStorageSettings::getRedirectCacheTtl() const +{ + return getValue("redirectCacheTtl", QString(), QString(), NETWORK_REDIRECT_CACHE_TTL_DEFAULT).toInt(); +} + +int CacheStorageSettings::getCardPictureLoaderCacheMethod() const +{ + return getValue("cardPictureLoaderCacheMethod", QString(), QString(), 0).toInt(); +} + +int CacheStorageSettings::getLocalCardImageStorageNamingScheme() const +{ + return getValue("localCardImageStorageNamingScheme", QString(), QString(), LOCAL_CARD_IMAGE_NAMING_SCHEME_DEFAULT) + .toInt(); +} + +void CacheStorageSettings::setPixmapCacheSize(int _pixmapCacheSize) +{ + setValue(_pixmapCacheSize, "pixmapCacheSize"); + emit pixmapCacheSizeChanged(_pixmapCacheSize); +} + +void CacheStorageSettings::setNetworkCacheSizeInMB(int _networkCacheSize) +{ + setValue(_networkCacheSize, "networkCacheSize"); + emit networkCacheSizeChanged(_networkCacheSize); +} + +void CacheStorageSettings::setNetworkRedirectCacheTtl(int _redirectCacheTtl) +{ + setValue(_redirectCacheTtl, "redirectCacheTtl"); + emit redirectCacheTtlChanged(_redirectCacheTtl); +} + +void CacheStorageSettings::setCardImageCacheMethod(int _cardImageCachingMethod) +{ + setValue(_cardImageCachingMethod, "cardPictureLoaderCacheMethod"); + emit cardPictureLoaderCacheMethodChanged(_cardImageCachingMethod); +} + +void CacheStorageSettings::setLocalCardImageStorageNamingScheme(int _localCardImageStorageNamingScheme) +{ + setValue(_localCardImageStorageNamingScheme, "localCardImageStorageNamingScheme"); + emit localCardImageStorageNamingSchemeChanged(_localCardImageStorageNamingScheme); +} diff --git a/libcockatrice_settings/libcockatrice/settings/cache_storage_settings.h b/libcockatrice_settings/libcockatrice/settings/cache_storage_settings.h new file mode 100644 index 000000000..4a4e73461 --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/cache_storage_settings.h @@ -0,0 +1,57 @@ +#ifndef CACHE_STORAGE_SETTINGS_H +#define CACHE_STORAGE_SETTINGS_H + +#include "settings_manager.h" + +#include + +// In MB (Increments of 64) +#define PIXMAPCACHE_SIZE_DEFAULT 2048 +#define PIXMAPCACHE_SIZE_MIN 64 +#define PIXMAPCACHE_SIZE_MAX 4096 + +// In MB +constexpr int NETWORK_CACHE_SIZE_DEFAULT = 1024 * 4; // 4 GB +constexpr int NETWORK_CACHE_SIZE_MIN = 1; // 1 MB +constexpr int NETWORK_CACHE_SIZE_MAX = 1024 * 1024; // 1 TB + +#define LOCAL_CARD_IMAGE_NAMING_SCHEME_DEFAULT 6 + +// In Days +#define NETWORK_REDIRECT_CACHE_TTL_DEFAULT 30 +#define NETWORK_REDIRECT_CACHE_TTL_MIN 1 +#define NETWORK_REDIRECT_CACHE_TTL_MAX 90 + +class CacheStorageSettings : public SettingsManager, public ICacheStorageSettingsProvider +{ + Q_OBJECT + friend class SettingsCache; + +public: + [[nodiscard]] int getPixmapCacheSize() const override; + [[nodiscard]] int getNetworkCacheSizeInMB() const override; + [[nodiscard]] int getRedirectCacheTtl() const override; + [[nodiscard]] int getCardPictureLoaderCacheMethod() const; + [[nodiscard]] int getLocalCardImageStorageNamingScheme() const; + + void setPixmapCacheSize(int _pixmapCacheSize); + void setNetworkCacheSizeInMB(int _networkCacheSize); + void setNetworkRedirectCacheTtl(int _redirectCacheTtl); + void setCardImageCacheMethod(int _cardImageCachingMethod); + void setLocalCardImageStorageNamingScheme(int _scheme); + +signals: + void pixmapCacheSizeChanged(int newSizeInMBs); + void networkCacheSizeChanged(int newSizeInMBs); + void redirectCacheTtlChanged(int newTtl); + void cardPictureLoaderCacheMethodChanged(int cardPictureLoaderCacheMethod); + void localCardImageStorageNamingSchemeChanged(int localCardImageStorageNamingScheme); + +public: + explicit CacheStorageSettings(const QString &settingPath, QObject *parent = nullptr); + +private: + CacheStorageSettings(const CacheStorageSettings & /*other*/); +}; + +#endif // CACHE_STORAGE_SETTINGS_H diff --git a/libcockatrice_settings/libcockatrice/settings/card_database_settings.cpp b/libcockatrice_settings/libcockatrice/settings/card_database_settings.cpp index 4887afd2f..b55c97909 100644 --- a/libcockatrice_settings/libcockatrice/settings/card_database_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/card_database_settings.cpp @@ -1,5 +1,7 @@ #include "card_database_settings.h" +#include + CardDatabaseSettings::CardDatabaseSettings(const QString &settingPath, QObject *parent) : SettingsManager(settingPath + "cardDatabase.ini", QString(), QString(), parent) { @@ -7,32 +9,73 @@ CardDatabaseSettings::CardDatabaseSettings(const QString &settingPath, QObject * void CardDatabaseSettings::setSortKey(QString shortName, unsigned int sortKey) { - setValue(sortKey, "sortkey", "sets", std::move(shortName)); + setValue(sortKey, "sortKey", "sets", shortName); + QMutexLocker lock(&setOptionsMutex); + ensureSetOptionsLoaded(); + setOptionsCache[shortName].sortKey = sortKey; } void CardDatabaseSettings::setEnabled(QString shortName, bool enabled) { - setValue(enabled, "enabled", "sets", std::move(shortName)); + setValue(enabled, "enabled", "sets", shortName); + QMutexLocker lock(&setOptionsMutex); + ensureSetOptionsLoaded(); + setOptionsCache[shortName].enabled = enabled; } void CardDatabaseSettings::setIsKnown(QString shortName, bool isknown) { - setValue(isknown, "isknown", "sets", std::move(shortName)); + setValue(isknown, "isKnown", "sets", shortName); + QMutexLocker lock(&setOptionsMutex); + ensureSetOptionsLoaded(); + setOptionsCache[shortName].isKnown = isknown; +} + +void CardDatabaseSettings::ensureSetOptionsLoaded() const +{ + if (setOptionsLoaded) { + return; + } + QSettings settings(getSettings()); + settings.beginGroup("sets"); + const QStringList groups = settings.childGroups(); + for (const QString &group : groups) { + settings.beginGroup(group); + SetOptions &o = setOptionsCache[group]; + o.sortKey = settings.value("sortKey", 0).toUInt(); + o.enabled = settings.value("enabled", true).toBool(); + o.isKnown = settings.value("isKnown", true).toBool(); + settings.endGroup(); + } + setOptionsLoaded = true; } unsigned int CardDatabaseSettings::getSortKey(QString shortName) const { - return getValue("sortkey", "sets", std::move(shortName)).toUInt(); + QMutexLocker lock(&setOptionsMutex); + ensureSetOptionsLoaded(); + return setOptionsCache.value(shortName).sortKey; } bool CardDatabaseSettings::isEnabled(QString shortName) const { - return getValue("enabled", "sets", std::move(shortName)).toBool(); + QMutexLocker lock(&setOptionsMutex); + ensureSetOptionsLoaded(); + return setOptionsCache.value(shortName).enabled; } bool CardDatabaseSettings::isKnown(QString shortName) const { - return getValue("isknown", "sets", std::move(shortName)).toBool(); + QMutexLocker lock(&setOptionsMutex); + ensureSetOptionsLoaded(); + return setOptionsCache.value(shortName).isKnown; +} + +ICardSetPriorityController::SetOptions CardDatabaseSettings::getSetOptions(QString shortName) const +{ + QMutexLocker lock(&setOptionsMutex); + ensureSetOptionsLoaded(); + return setOptionsCache.value(shortName); } void CardDatabaseSettings::saveSets(const QVector &data) @@ -41,10 +84,18 @@ void CardDatabaseSettings::saveSets(const QVector +#include #include class CardDatabaseSettings : public SettingsManager, public ICardSetPriorityController @@ -26,10 +28,23 @@ public: bool isEnabled(QString shortName) const override; bool isKnown(QString shortName) const override; + SetOptions getSetOptions(QString shortName) const override; + void saveSets(const QVector &data) override; private: explicit CardDatabaseSettings(const QString &settingPath, QObject *parent = nullptr); + + // In-memory cache of set priority options. QSettings re-opens and re-parses + // the whole .ini on every access, which is catastrophic when CardSet + // construction calls getSortKey/isEnabled/isKnown once per set during the + // database load (thousands of file parses). The cache is populated from a + // single QSettings session and kept consistent with the writes below. + mutable QMutex setOptionsMutex; + mutable QHash setOptionsCache; + mutable bool setOptionsLoaded = false; + + void ensureSetOptionsLoaded() const; }; #endif // CARDDATABASESETTINGS_H diff --git a/libcockatrice_settings/libcockatrice/settings/cards_display_settings.cpp b/libcockatrice_settings/libcockatrice/settings/cards_display_settings.cpp new file mode 100644 index 000000000..b8eafca6c --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/cards_display_settings.cpp @@ -0,0 +1,244 @@ +#include "cards_display_settings.h" + +CardsDisplaySettings::CardsDisplaySettings(const QString &settingPath, QObject *parent) + : SettingsManager(settingPath + "cards_display.ini", "cards", QString(), parent) +{ +} + +bool CardsDisplaySettings::getDisplayCardNames() const +{ + return getValue("displayCardNames", QString(), QString(), true).toBool(); +} + +bool CardsDisplaySettings::getRoundCardCorners() const +{ + return getValue("roundCardCorners", QString(), QString(), true).toBool(); +} + +bool CardsDisplaySettings::getOverrideAllCardArtWithPersonalPreference() const +{ + return getValue("overrideAllCardArtWithPersonalPreference", QString(), QString(), false).toBool(); +} + +bool CardsDisplaySettings::getBumpSetsWithCardsInDeckToTop() const +{ + return getValue("bumpSetsWithCardsInDeckToTop", QString(), QString(), true).toBool(); +} + +int CardsDisplaySettings::getPrintingSelectorSortOrder() const +{ + return getValue("sortOrder", "cards", "printingSelector", 1).toInt(); +} + +int CardsDisplaySettings::getPrintingSelectorCardSize() const +{ + return getValue("printingSelector", "cards", "cardSize", 100).toInt(); +} + +bool CardsDisplaySettings::getIncludeRebalancedCards() const +{ + return getValue("includerebalancedcards", QString(), QString(), true).toBool(); +} + +bool CardsDisplaySettings::getPrintingSelectorNavigationButtonsVisible() const +{ + return getValue("navigationButtonsVisible", "cards", "printingSelector", true).toBool(); +} + +bool CardsDisplaySettings::getTapAnimation() const +{ + return getValue("tapAnimation", QString(), QString(), true).toBool(); +} + +bool CardsDisplaySettings::getArrowDrawAnimation() const +{ + return getValue("arrowDrawAnimation", QString(), QString(), true).toBool(); +} + +bool CardsDisplaySettings::getAutoRotateSidewaysLayoutCards() const +{ + return getValue("autoRotateSidewaysLayoutCards", QString(), QString(), true).toBool(); +} + +bool CardsDisplaySettings::getScaleCards() const +{ + return getValue("scaleCards", QString(), QString(), true).toBool(); +} + +int CardsDisplaySettings::getStackCardOverlapPercent() const +{ + return getValue("verticalCardOverlapPercent", QString(), QString(), 33).toInt(); +} + +int CardsDisplaySettings::getCardInfoViewMode() const +{ + return getValue("cardInfoViewMode", QString(), QString(), 0).toInt(); +} + +int CardsDisplaySettings::getVisualDeckStorageCardSize() const +{ + return getValue("visualDeckStorage", "cards", "cardSize", 100).toInt(); +} + +int CardsDisplaySettings::getVisualDatabaseDisplayCardSize() const +{ + return getValue("visualDatabaseDisplay", "cards", "cardSize", 100).toInt(); +} + +int CardsDisplaySettings::getVisualDeckEditorCardSize() const +{ + return getValue("visualDeckEditor", "cards", "cardSize", 100).toInt(); +} + +int CardsDisplaySettings::getEDHRecCardSize() const +{ + return getValue("edhrec", "cards", "cardSize", 100).toInt(); +} + +int CardsDisplaySettings::getArchidektPreviewSize() const +{ + return getValue("archidektPreview", "cards", "cardSize", 100).toInt(); +} + +int CardsDisplaySettings::getSampleHandSize() const +{ + return getValue("sampleHandSize", "cards", "cardSize", 7).toInt(); +} + +QString CardsDisplaySettings::getCardLang() const +{ + return getValue("cardLang", QString(), QString(), "en").toString(); +} + +void CardsDisplaySettings::setDisplayCardNames(bool _displayCardNames) +{ + setValue(_displayCardNames, "displayCardNames"); + emit displayCardNamesChanged(); +} + +void CardsDisplaySettings::setRoundCardCorners(bool _roundCardCorners) +{ + if (_roundCardCorners == getRoundCardCorners()) { + return; + } + setValue(_roundCardCorners, "roundCardCorners"); + emit roundCardCornersChanged(_roundCardCorners); +} + +void CardsDisplaySettings::setOverrideAllCardArtWithPersonalPreference(bool _overrideAllCardArt) +{ + setValue(_overrideAllCardArt, "overrideAllCardArtWithPersonalPreference"); + emit overrideAllCardArtWithPersonalPreferenceChanged(_overrideAllCardArt); +} + +void CardsDisplaySettings::setBumpSetsWithCardsInDeckToTop(bool _bumpSetsWithCardsInDeckToTop) +{ + setValue(_bumpSetsWithCardsInDeckToTop, "bumpSetsWithCardsInDeckToTop"); + emit bumpSetsWithCardsInDeckToTopChanged(); +} + +void CardsDisplaySettings::setPrintingSelectorSortOrder(int _printingSelectorSortOrder) +{ + setValue(_printingSelectorSortOrder, "sortOrder", "cards", "printingSelector"); + emit printingSelectorSortOrderChanged(); +} + +void CardsDisplaySettings::setPrintingSelectorCardSize(int _printingSelectorCardSize) +{ + setValue(_printingSelectorCardSize, "printingSelector", "cards", "cardSize"); + emit printingSelectorCardSizeChanged(); +} + +void CardsDisplaySettings::setIncludeRebalancedCards(bool _includeRebalancedCards) +{ + if (_includeRebalancedCards == getIncludeRebalancedCards()) { + return; + } + setValue(_includeRebalancedCards, "includerebalancedcards"); + emit includeRebalancedCardsChanged(_includeRebalancedCards); +} + +void CardsDisplaySettings::setPrintingSelectorNavigationButtonsVisible(bool _navigationButtonsVisible) +{ + setValue(_navigationButtonsVisible, "navigationButtonsVisible", "cards", "printingSelector"); + emit printingSelectorNavigationButtonsVisibleChanged(); +} + +void CardsDisplaySettings::setTapAnimation(bool _tapAnimation) +{ + setValue(_tapAnimation, "tapAnimation"); +} + +void CardsDisplaySettings::setArrowDrawAnimation(bool _arrowDrawAnimation) +{ + setValue(_arrowDrawAnimation, "arrowDrawAnimation"); +} + +void CardsDisplaySettings::setAutoRotateSidewaysLayoutCards(bool _autoRotateSidewaysLayoutCards) +{ + setValue(_autoRotateSidewaysLayoutCards, "autoRotateSidewaysLayoutCards"); +} + +void CardsDisplaySettings::setCardScaling(bool _scaleCards) +{ + setValue(_scaleCards, "scaleCards"); +} + +void CardsDisplaySettings::setStackCardOverlapPercent(int _verticalCardOverlapPercent) +{ + setValue(_verticalCardOverlapPercent, "verticalCardOverlapPercent"); +} + +void CardsDisplaySettings::setCardInfoViewMode(int _viewMode) +{ + setValue(_viewMode, "cardInfoViewMode"); +} + +void CardsDisplaySettings::setVisualDeckStorageCardSize(int _cardSize) +{ + setValue(_cardSize, "visualDeckStorage", "cards", "cardSize"); + emit visualDeckStorageCardSizeChanged(); +} + +void CardsDisplaySettings::setVisualDatabaseDisplayCardSize(int _cardSize) +{ + setValue(_cardSize, "visualDatabaseDisplay", "cards", "cardSize"); + emit visualDatabaseDisplayCardSizeChanged(); +} + +void CardsDisplaySettings::setVisualDeckEditorCardSize(int _cardSize) +{ + setValue(_cardSize, "visualDeckEditor", "cards", "cardSize"); + emit visualDeckEditorCardSizeChanged(); +} + +void CardsDisplaySettings::setEDHRecCardSize(int _edhrecCardSize) +{ + setValue(_edhrecCardSize, "edhrec", "cards", "cardSize"); + emit edhRecCardSizeChanged(); +} + +void CardsDisplaySettings::setArchidektPreviewCardSize(int _archidektPreviewCardSize) +{ + setValue(_archidektPreviewCardSize, "archidektPreview", "cards", "cardSize"); + emit archidektPreviewSizeChanged(); +} + +void CardsDisplaySettings::setSampleHandSize(int _sampleHandSize) +{ + setValue(_sampleHandSize, "sampleHandSize", "cards", "cardSize"); + emit sampleHandSizeChanged(_sampleHandSize); +} + +void CardsDisplaySettings::setCardLang(const QString &_cardLang) +{ + if (_cardLang == getCardLang()) { + return; + } + setValue(_cardLang, "cardLang"); + // Flush to disk immediately: the Oracle tool is a separate process that + // reads this value to decide which foreignData to import, so it must not + // observe a stale (pre-change) value. + sync(); + emit cardLangChanged(_cardLang); +} diff --git a/libcockatrice_settings/libcockatrice/settings/cards_display_settings.h b/libcockatrice_settings/libcockatrice/settings/cards_display_settings.h new file mode 100644 index 000000000..0b47ce490 --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/cards_display_settings.h @@ -0,0 +1,82 @@ +#ifndef CARDS_DISPLAY_SETTINGS_H +#define CARDS_DISPLAY_SETTINGS_H + +#include "settings_manager.h" + +#include + +class CardsDisplaySettings : public SettingsManager, public ICardsDisplaySettingsProvider +{ + Q_OBJECT + friend class SettingsCache; + +public: + [[nodiscard]] bool getDisplayCardNames() const override; + [[nodiscard]] bool getRoundCardCorners() const override; + [[nodiscard]] bool getOverrideAllCardArtWithPersonalPreference() const override; + [[nodiscard]] bool getBumpSetsWithCardsInDeckToTop() const override; + [[nodiscard]] int getPrintingSelectorSortOrder() const override; + [[nodiscard]] int getPrintingSelectorCardSize() const override; + [[nodiscard]] bool getIncludeRebalancedCards() const override; + [[nodiscard]] bool getPrintingSelectorNavigationButtonsVisible() const override; + [[nodiscard]] bool getTapAnimation() const override; + [[nodiscard]] bool getArrowDrawAnimation() const override; + [[nodiscard]] bool getAutoRotateSidewaysLayoutCards() const override; + [[nodiscard]] bool getScaleCards() const override; + [[nodiscard]] int getStackCardOverlapPercent() const override; + [[nodiscard]] int getCardInfoViewMode() const override; + [[nodiscard]] int getVisualDeckStorageCardSize() const override; + [[nodiscard]] int getVisualDatabaseDisplayCardSize() const override; + [[nodiscard]] int getVisualDeckEditorCardSize() const override; + [[nodiscard]] int getEDHRecCardSize() const override; + [[nodiscard]] int getArchidektPreviewSize() const override; + [[nodiscard]] int getSampleHandSize() const override; + [[nodiscard]] QString getCardLang() const override; + + void setDisplayCardNames(bool _displayCardNames); + void setRoundCardCorners(bool _roundCardCorners); + void setOverrideAllCardArtWithPersonalPreference(bool _overrideAllCardArt); + void setBumpSetsWithCardsInDeckToTop(bool _bumpSetsWithCardsInDeckToTop); + void setPrintingSelectorSortOrder(int _printingSelectorSortOrder); + void setPrintingSelectorCardSize(int _printingSelectorCardSize); + void setIncludeRebalancedCards(bool _includeRebalancedCards); + void setPrintingSelectorNavigationButtonsVisible(bool _navigationButtonsVisible); + void setTapAnimation(bool _tapAnimation); + void setArrowDrawAnimation(bool _arrowDrawAnimation); + void setAutoRotateSidewaysLayoutCards(bool _autoRotateSidewaysLayoutCards); + void setCardScaling(bool _scaleCards); + void setStackCardOverlapPercent(int _verticalCardOverlapPercent); + void setCardInfoViewMode(int _viewMode); + void setVisualDeckStorageCardSize(int _cardSize); + void setVisualDatabaseDisplayCardSize(int _cardSize); + void setVisualDeckEditorCardSize(int _cardSize); + void setEDHRecCardSize(int _edhrecCardSize); + void setArchidektPreviewCardSize(int _archidektPreviewCardSize); + void setSampleHandSize(int _sampleHandSize); + void setCardLang(const QString &_cardLang); + +signals: + void displayCardNamesChanged(); + void roundCardCornersChanged(bool roundCardCorners); + void overrideAllCardArtWithPersonalPreferenceChanged(bool _overrideAllCardArtWithPersonalPreference); + void bumpSetsWithCardsInDeckToTopChanged(); + void printingSelectorSortOrderChanged(); + void printingSelectorCardSizeChanged(); + void includeRebalancedCardsChanged(bool _includeRebalancedCards); + void printingSelectorNavigationButtonsVisibleChanged(); + void visualDeckStorageCardSizeChanged(); + void visualDatabaseDisplayCardSizeChanged(); + void visualDeckEditorCardSizeChanged(); + void edhRecCardSizeChanged(); + void archidektPreviewSizeChanged(); + void sampleHandSizeChanged(int amount); + void cardLangChanged(const QString &lang); + +public: + explicit CardsDisplaySettings(const QString &settingPath, QObject *parent = nullptr); + +private: + CardsDisplaySettings(const CardsDisplaySettings & /*other*/); +}; + +#endif // CARDS_DISPLAY_SETTINGS_H diff --git a/libcockatrice_settings/libcockatrice/settings/chat_settings.cpp b/libcockatrice_settings/libcockatrice/settings/chat_settings.cpp new file mode 100644 index 000000000..974173371 --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/chat_settings.cpp @@ -0,0 +1,147 @@ +#include "chat_settings.h" + +ChatSettings::ChatSettings(const QString &settingPath, QObject *parent) + : SettingsManager(settingPath + "chat.ini", "chat", QString(), parent) +{ +} + +bool ChatSettings::getChatMention() const +{ + return getValue("mention", QString(), QString(), true).toBool(); +} + +bool ChatSettings::getChatMentionCompleter() const +{ + return getValue("mentionCompleter", QString(), QString(), true).toBool(); +} + +QString ChatSettings::getChatMentionColor() const +{ + return getValue("mentionColor", QString(), QString(), "A6120D").toString(); +} + +QString ChatSettings::getChatHighlightColor() const +{ + return getValue("highlightColor", QString(), QString(), "A6120D").toString(); +} + +bool ChatSettings::getChatMentionForeground() const +{ + return getValue("mentionForeground", QString(), QString(), true).toBool(); +} + +bool ChatSettings::getChatHighlightForeground() const +{ + return getValue("highlightForeground", QString(), QString(), true).toBool(); +} + +bool ChatSettings::getIgnoreUnregisteredUsers() const +{ + return getValue("ignoreUnregistered").toBool(); +} + +bool ChatSettings::getIgnoreUnregisteredUserMessages() const +{ + return getValue("ignoreUnregisteredMessages").toBool(); +} + +bool ChatSettings::getIgnoreNonBuddyUserMessages() const +{ + return getValue("ignoreNonBuddyMessages").toBool(); +} + +bool ChatSettings::getShowMessagePopup() const +{ + return getValue("showMessagePopups", QString(), QString(), true).toBool(); +} + +bool ChatSettings::getShowMentionPopup() const +{ + return getValue("showMentionPopups", QString(), QString(), true).toBool(); +} + +bool ChatSettings::getRoomHistory() const +{ + return getValue("roomHistory", QString(), QString(), true).toBool(); +} + +bool ChatSettings::getIgnoreAllPrivateMessages() const +{ + return getValue("ignoreAllPrivateMessages", QString(), QString(), false).toBool(); +} + +QString ChatSettings::getHighlightWords() const +{ + return getValue("highlightWords").toString(); +} + +void ChatSettings::setChatMention(bool _chatMention) +{ + setValue(_chatMention, "mention"); +} + +void ChatSettings::setChatMentionCompleter(bool _chatMentionCompleter) +{ + setValue(_chatMentionCompleter, "mentionCompleter"); + emit chatMentionCompleterChanged(); +} + +void ChatSettings::setChatMentionColor(const QString &_chatMentionColor) +{ + setValue(_chatMentionColor, "mentionColor"); +} + +void ChatSettings::setChatHighlightColor(const QString &_chatHighlightColor) +{ + setValue(_chatHighlightColor, "highlightColor"); +} + +void ChatSettings::setChatMentionForeground(bool _chatMentionForeground) +{ + setValue(_chatMentionForeground, "mentionForeground"); +} + +void ChatSettings::setChatHighlightForeground(bool _chatHighlightForeground) +{ + setValue(_chatHighlightForeground, "highlightForeground"); +} + +void ChatSettings::setIgnoreUnregisteredUsers(bool _ignoreUnregisteredUsers) +{ + setValue(_ignoreUnregisteredUsers, "ignoreUnregistered"); +} + +void ChatSettings::setIgnoreUnregisteredUserMessages(bool _ignoreUnregisteredUserMessages) +{ + setValue(_ignoreUnregisteredUserMessages, "ignoreUnregisteredMessages"); +} + +void ChatSettings::setIgnoreNonBuddyUserMessages(bool _ignoreNonBuddyUserMessages) +{ + setValue(_ignoreNonBuddyUserMessages, "ignoreNonBuddyMessages"); +} + +void ChatSettings::setShowMessagePopups(bool _showMessagePopups) +{ + setValue(_showMessagePopups, "showMessagePopups"); +} + +void ChatSettings::setShowMentionPopups(bool _showMentionPopups) +{ + setValue(_showMentionPopups, "showMentionPopups"); +} + +void ChatSettings::setRoomHistory(bool _roomHistory) +{ + setValue(_roomHistory, "roomHistory"); +} + +void ChatSettings::setIgnoreAllPrivateMessages(bool _ignoreAllPrivateMessages) +{ + setValue(_ignoreAllPrivateMessages, "ignoreAllPrivateMessages"); +} + +void ChatSettings::setHighlightWords(const QString &_highlightWords) +{ + setValue(_highlightWords, "highlightWords"); +} diff --git a/libcockatrice_settings/libcockatrice/settings/chat_settings.h b/libcockatrice_settings/libcockatrice/settings/chat_settings.h new file mode 100644 index 000000000..9566671e2 --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/chat_settings.h @@ -0,0 +1,54 @@ +#ifndef CHAT_SETTINGS_H +#define CHAT_SETTINGS_H + +#include "settings_manager.h" + +#include + +class ChatSettings : public SettingsManager, public IChatSettingsProvider +{ + Q_OBJECT + friend class SettingsCache; + +public: + [[nodiscard]] bool getChatMention() const override; + [[nodiscard]] bool getChatMentionCompleter() const override; + [[nodiscard]] QString getChatMentionColor() const override; + [[nodiscard]] QString getChatHighlightColor() const override; + [[nodiscard]] bool getChatMentionForeground() const override; + [[nodiscard]] bool getChatHighlightForeground() const override; + [[nodiscard]] bool getIgnoreUnregisteredUsers() const override; + [[nodiscard]] bool getIgnoreUnregisteredUserMessages() const override; + [[nodiscard]] bool getIgnoreNonBuddyUserMessages() const override; + [[nodiscard]] bool getShowMessagePopup() const override; + [[nodiscard]] bool getShowMentionPopup() const override; + [[nodiscard]] bool getRoomHistory() const override; + [[nodiscard]] bool getIgnoreAllPrivateMessages() const override; + [[nodiscard]] QString getHighlightWords() const override; + + void setChatMention(bool _chatMention); + void setChatMentionCompleter(bool _chatMentionCompleter); + void setChatMentionColor(const QString &_chatMentionColor); + void setChatHighlightColor(const QString &_chatHighlightColor); + void setChatMentionForeground(bool _chatMentionForeground); + void setChatHighlightForeground(bool _chatHighlightForeground); + void setIgnoreUnregisteredUsers(bool _ignoreUnregisteredUsers); + void setIgnoreUnregisteredUserMessages(bool _ignoreUnregisteredUserMessages); + void setIgnoreNonBuddyUserMessages(bool _ignoreNonBuddyUserMessages); + void setShowMessagePopups(bool _showMessagePopups); + void setShowMentionPopups(bool _showMentionPopups); + void setRoomHistory(bool _roomHistory); + void setIgnoreAllPrivateMessages(bool _ignoreAllPrivateMessages); + void setHighlightWords(const QString &_highlightWords); + +signals: + void chatMentionCompleterChanged(); + +public: + explicit ChatSettings(const QString &settingPath, QObject *parent = nullptr); + +private: + ChatSettings(const ChatSettings & /*other*/); +}; + +#endif // CHAT_SETTINGS_H diff --git a/libcockatrice_settings/libcockatrice/settings/commander_bracket_settings.cpp b/libcockatrice_settings/libcockatrice/settings/commander_bracket_settings.cpp new file mode 100644 index 000000000..0c5f5521e --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/commander_bracket_settings.cpp @@ -0,0 +1,194 @@ +#include "commander_bracket_settings.h" + +#include + +QVariantList CommanderBracketSettings::defaultDefinitions() +{ + return { + QVariantMap{{"tag", "R"}, + {"officialName", "[5] cEDH"}, + {"displayName", "Ruthless"}, + {"explanation", + "Top-tier competitive decks with maximum optimization, fast combos, and minimal variance."}}, + QVariantMap{{"tag", "S"}, + {"officialName", "[4] Optimized"}, + {"displayName", "Spicy"}, + {"explanation", "Highly tuned decks with strong synergy and occasional combo finishes."}}, + QVariantMap{{"tag", "P"}, + {"officialName", "[3] Upgraded"}, + {"displayName", "Powerful"}, + {"explanation", "Focused decks with clear win conditions and solid consistency."}}, + QVariantMap{{"tag", "O"}, + {"officialName", "[2] Core"}, + {"displayName", "Oddball"}, + {"explanation", "Unconventional or thematic decks with some structure but non-standard choices."}}, + QVariantMap{{"tag", "C"}, + {"officialName", "[2] Core"}, + {"displayName", "Core"}, + {"explanation", "Preconstructed or precon-level decks with straightforward strategies."}}, + QVariantMap{{"tag", "E"}, + {"officialName", "[1] Exhibition"}, + {"displayName", "Exhibition"}, + {"explanation", "Ultra-casual, theme-focused decks with minimal optimization."}}, + QVariantMap{{"tag", "B"}, + {"officialName", "Banned"}, + {"displayName", "Banned"}, + {"explanation", "The deck contains one or more cards banned in Commander."}}, + }; +} + +CommanderBracketSettings::CommanderBracketSettings(const QString &settingPath, QObject *parent) + : SettingsManager(settingPath + "commander_brackets.ini", "commander_brackets", QString(), parent) +{ +} + +void CommanderBracketSettings::setSchemaVersion(int version) +{ + setValue(version, "schemaVersion"); +} +int CommanderBracketSettings::getSchemaVersion() const +{ + QVariant value = getValue("schemaVersion"); + return value.isValid() ? value.toInt() : 0; +} + +void CommanderBracketSettings::clearDefinitions() +{ + auto settings = getSettings(); + + settings.beginGroup("commander_brackets"); + settings.remove(""); + settings.endGroup(); + + settings.sync(); +} + +void CommanderBracketSettings::saveDefinitions(const QVariantList &definitions) +{ + auto settings = getSettings(); + + settings.beginGroup("commander_brackets"); + + settings.remove(""); + + settings.setValue("schemaVersion", CurrentSchemaVersion); + + for (const auto &entry : definitions) { + QVariantMap map = entry.toMap(); + + QString tag = map.value("tag").toString(); + + if (tag.isEmpty()) { + continue; + } + + settings.beginGroup(tag); + + settings.setValue("officialName", map.value("officialName")); + + settings.setValue("displayName", map.value("displayName")); + + settings.setValue("explanation", map.value("explanation")); + + settings.endGroup(); + } + + settings.endGroup(); + + settings.sync(); +} + +QVariantList CommanderBracketSettings::loadDefinitions() const +{ + QVariantList result; + + auto settings = getSettings(); + + settings.beginGroup("commander_brackets"); + + int version = settings.value("schemaVersion", 0).toInt(); + + if (version != CurrentSchemaVersion) { + settings.endGroup(); + return result; + } + + QStringList groups = settings.childGroups(); + + for (const QString &tag : groups) { + settings.beginGroup(tag); + + QVariantMap map; + + map["tag"] = tag; + map["officialName"] = settings.value("officialName"); + map["displayName"] = settings.value("displayName"); + map["explanation"] = settings.value("explanation"); + + result.append(map); + + settings.endGroup(); + } + + settings.endGroup(); + + return result; +} + +void CommanderBracketSettings::reloadDefinitions(const QVariantList &definitionsList) +{ + definitions.clear(); + + for (const auto &entry : definitionsList) { + const auto map = entry.toMap(); + + CommanderBracketDefinition definition; + + definition.tag = map.value("tag").toString(); + definition.officialName = map.value("officialName").toString(); + definition.displayName = map.value("displayName").toString(); + definition.explanation = map.value("explanation").toString(); + + if (!definition.tag.isEmpty()) { + definitions.insert(definition.tag, definition); + } + } +} + +QString CommanderBracketSettings::officialName(const QString &tag) const +{ + auto it = definitions.find(tag); + + if (it == definitions.end()) { + return tag; + } + + return it->officialName; +} + +QString CommanderBracketSettings::displayName(const QString &tag) const +{ + auto it = definitions.find(tag); + + if (it == definitions.end()) { + return tag; + } + + return it->displayName; +} + +QString CommanderBracketSettings::explanation(const QString &tag) const +{ + auto it = definitions.find(tag); + + if (it == definitions.end()) { + return {}; + } + + return it->explanation; +} + +bool CommanderBracketSettings::contains(const QString &tag) const +{ + return definitions.contains(tag); +} diff --git a/libcockatrice_settings/libcockatrice/settings/commander_bracket_settings.h b/libcockatrice_settings/libcockatrice/settings/commander_bracket_settings.h new file mode 100644 index 000000000..5a29f5dc7 --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/commander_bracket_settings.h @@ -0,0 +1,56 @@ +#ifndef COMMANDER_BRACKET_SETTINGS_H +#define COMMANDER_BRACKET_SETTINGS_H + +#include "settings_manager.h" + +#include +#include +#include +#include + +struct CommanderBracketDefinition +{ + QString tag; + + QString officialName; + QString displayName; + + QString explanation; +}; + +class CommanderBracketSettings : public SettingsManager +{ + Q_OBJECT + friend class SettingsCache; + +public: + static constexpr int CurrentSchemaVersion = 1; + + static QVariantList defaultDefinitions(); + + void clearDefinitions(); + + void saveDefinitions(const QVariantList &definitions); + + QVariantList loadDefinitions() const; + + void reloadDefinitions(const QVariantList &definitions); + + QString officialName(const QString &tag) const; + QString displayName(const QString &tag) const; + QString explanation(const QString &tag) const; + bool contains(const QString &tag) const; + + void setSchemaVersion(int version); + int getSchemaVersion() const; + +private: + explicit CommanderBracketSettings(const QString &settingPath, QObject *parent = nullptr); + + CommanderBracketSettings(const CommanderBracketSettings &) = delete; + CommanderBracketSettings &operator=(const CommanderBracketSettings &) = delete; + + QHash definitions; +}; + +#endif // COMMANDER_BRACKET_SETTINGS_H diff --git a/libcockatrice_settings/libcockatrice/settings/deck_editor_settings.cpp b/libcockatrice_settings/libcockatrice/settings/deck_editor_settings.cpp new file mode 100644 index 000000000..44cdcd86f --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/deck_editor_settings.cpp @@ -0,0 +1,83 @@ +#include "deck_editor_settings.h" + +DeckEditorSettings::DeckEditorSettings(const QString &settingPath, QObject *parent) + : SettingsManager(settingPath + "deck_editor.ini", "deckeditor", QString(), parent) +{ +} + +bool DeckEditorSettings::getOpenDeckInNewTab() const +{ + return getValue("openDeckInNewTab", QString(), QString(), false).toBool(); +} + +bool DeckEditorSettings::getBannerCardComboBoxVisible() const +{ + return getValue("bannerCardComboBoxVisible", QString(), QString(), true).toBool(); +} + +bool DeckEditorSettings::getTagsWidgetVisible() const +{ + return getValue("tagsWidgetVisible", QString(), QString(), true).toBool(); +} + +int DeckEditorSettings::getDefaultDeckEditorType() const +{ + return getValue("defaultDeckEditorType", QString(), QString(), 1).toInt(); +} + +int DeckEditorSettings::getVdeStartupTab() const +{ + return getValue("vdeStartupTab", QString(), QString(), VdeStartupTabContext).toInt(); +} + +void DeckEditorSettings::setOpenDeckInNewTab(bool _openDeckInNewTab) +{ + setValue(_openDeckInNewTab, "openDeckInNewTab"); +} + +void DeckEditorSettings::setBannerCardComboBoxVisible(bool _bannerCardComboBoxVisible) +{ + setValue(_bannerCardComboBoxVisible, "bannerCardComboBoxVisible"); + emit bannerCardComboBoxVisibleChanged(_bannerCardComboBoxVisible); +} + +void DeckEditorSettings::setTagsWidgetVisible(bool _tagsWidgetVisible) +{ + setValue(_tagsWidgetVisible, "tagsWidgetVisible"); + emit tagsWidgetVisibleChanged(_tagsWidgetVisible); +} + +void DeckEditorSettings::setDefaultDeckEditorType(int _defaultDeckEditorType) +{ + setValue(_defaultDeckEditorType, "defaultDeckEditorType"); +} + +void DeckEditorSettings::setVdeStartupTab(int _vdeStartupTab) +{ + setValue(_vdeStartupTab, "vdeStartupTab"); + emit vdeStartupTabChanged(_vdeStartupTab); +} + +int DeckEditorSettings::getCommanderSpellbookIntegrationEnabled() const +{ + return getValue("commanderspellbookintegrationenabled", QString(), QString(), + commanderSpellbookIntegrationEnabledIndexUnprompted) + .toInt(); +} + +bool DeckEditorSettings::getCommanderSpellbookIntegrationUseOfficialBracketNames() const +{ + return getValue("commanderspellbookintegrationuseofficialbracketnames", QString(), QString(), false).toBool(); +} + +void DeckEditorSettings::setCommanderSpellbookIntegrationEnabled(int _commanderSpellbookIntegrationEnabled) +{ + setValue(_commanderSpellbookIntegrationEnabled, "commanderspellbookintegrationenabled"); + emit commanderSpellbookIntegrationEnabledChanged(_commanderSpellbookIntegrationEnabled); +} + +void DeckEditorSettings::setCommanderSpellbookIntegrationUseOfficialBracketNames(bool _useOfficialBracketNames) +{ + setValue(_useOfficialBracketNames, "commanderspellbookintegrationuseofficialbracketnames"); + emit commanderSpellbookIntegrationUseOfficialBracketNamesChanged(_useOfficialBracketNames); +} diff --git a/libcockatrice_settings/libcockatrice/settings/deck_editor_settings.h b/libcockatrice_settings/libcockatrice/settings/deck_editor_settings.h new file mode 100644 index 000000000..0c87a270a --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/deck_editor_settings.h @@ -0,0 +1,59 @@ +#ifndef DECK_EDITOR_SETTINGS_H +#define DECK_EDITOR_SETTINGS_H + +#include "settings_manager.h" + +#include + +enum commanderSpellbookIntegrationEnabledIndex +{ + commanderSpellbookIntegrationEnabledIndexDisabled, + commanderSpellbookIntegrationEnabledIndexEnabled, + commanderSpellbookIntegrationEnabledIndexAutomatic, + commanderSpellbookIntegrationEnabledIndexUnprompted, +}; + +enum VdeStartupTab +{ + VdeStartupTabContext, ///< Match the opened deck: new decks show the database display, existing decks the deck view + VdeStartupTabDeckDisplay, ///< Always open the Visual Deck View + VdeStartupTabDatabaseDisplay, ///< Always open the Visual Database Display +}; + +class DeckEditorSettings : public SettingsManager, public IDeckEditorSettingsProvider +{ + Q_OBJECT + friend class SettingsCache; + +public: + [[nodiscard]] bool getOpenDeckInNewTab() const override; + [[nodiscard]] bool getBannerCardComboBoxVisible() const override; + [[nodiscard]] bool getTagsWidgetVisible() const override; + [[nodiscard]] int getDefaultDeckEditorType() const override; + [[nodiscard]] int getVdeStartupTab() const; + [[nodiscard]] int getCommanderSpellbookIntegrationEnabled() const; + [[nodiscard]] bool getCommanderSpellbookIntegrationUseOfficialBracketNames() const; + + void setOpenDeckInNewTab(bool _openDeckInNewTab); + void setBannerCardComboBoxVisible(bool _bannerCardComboBoxVisible); + void setTagsWidgetVisible(bool _tagsWidgetVisible); + void setDefaultDeckEditorType(int _defaultDeckEditorType); + void setVdeStartupTab(int _vdeStartupTab); + void setCommanderSpellbookIntegrationEnabled(int _commanderSpellbookIntegrationEnabled); + void setCommanderSpellbookIntegrationUseOfficialBracketNames(bool _useOfficialBracketNames); + +signals: + void bannerCardComboBoxVisibleChanged(bool visible); + void tagsWidgetVisibleChanged(bool visible); + void commanderSpellbookIntegrationEnabledChanged(int enabled); + void commanderSpellbookIntegrationUseOfficialBracketNamesChanged(bool useOfficialBracketNames); + void vdeStartupTabChanged(int vdeStartupTab); + +public: + explicit DeckEditorSettings(const QString &settingPath, QObject *parent = nullptr); + +private: + DeckEditorSettings(const DeckEditorSettings & /*other*/); +}; + +#endif // DECK_EDITOR_SETTINGS_H diff --git a/libcockatrice_settings/libcockatrice/settings/download_settings.cpp b/libcockatrice_settings/libcockatrice/settings/download_settings.cpp index 66525a598..eb73e58ee 100644 --- a/libcockatrice_settings/libcockatrice/settings/download_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/download_settings.cpp @@ -3,11 +3,15 @@ #include "settings_manager.h" const QStringList DownloadSettings::DEFAULT_DOWNLOAD_URLS = { - "https://api.scryfall.com/cards/!set:uuid!?format=image&face=!prop:side!", - "https://api.scryfall.com/cards/multiverse/!set:muid!?format=image", + "https://cards.scryfall.io/large/!prop:side!/!set:uuid_substr_0_1!/!set:uuid_substr_1_1!/!set:uuid!.jpg", + "https://api.scryfall.com/cards/!set:uuid!?format=image&face=!prop:side!&lang=!sflang!", + "https://api.scryfall.com/cards/multiverse/!set:muid!?format=image&lang=!sflang!", "https://gatherer.wizards.com/Handlers/Image.ashx?multiverseid=!set:muid!&type=card", "https://gatherer.wizards.com/Handlers/Image.ashx?name=!name!&type=card"}; +const QString DownloadSettings::SCRYFALL_NAMED_LOCALIZED_URL = + "https://api.scryfall.com/cards/named?fuzzy=!localizedName!&lang=!sflang!&format=image&face=!prop:side!"; + DownloadSettings::DownloadSettings(const QString &settingPath, QObject *parent = nullptr) : SettingsManager(settingPath + "downloads.ini", "downloads", QString(), parent) { @@ -27,3 +31,37 @@ void DownloadSettings::resetToDefaultURLs() { setValue(QVariant::fromValue(DEFAULT_DOWNLOAD_URLS), "urls"); } + +bool DownloadSettings::addLocalizedScryfallUrl() +{ + const QStringList urls = getAllURLs(); + if (urls.contains(SCRYFALL_NAMED_LOCALIZED_URL)) { + return false; + } + QStringList updated = urls; + updated.prepend(SCRYFALL_NAMED_LOCALIZED_URL); + setDownloadUrls(updated); + return true; +} + +bool DownloadSettings::getPicDownload() const +{ + return getValue("pictureDownload", QString(), QString(), true).toBool(); +} + +void DownloadSettings::setPicDownload(bool _picDownload) +{ + setValue(_picDownload, "pictureDownload"); + emit picDownloadChanged(); +} + +bool DownloadSettings::getDownloadSpoilersStatus() const +{ + return getValue("downloadSpoilers", QString(), QString(), false).toBool(); +} + +void DownloadSettings::setDownloadSpoilerStatus(bool _spoilerStatus) +{ + setValue(_spoilerStatus, "downloadSpoilers"); + emit downloadSpoilerStatusChanged(); +} diff --git a/libcockatrice_settings/libcockatrice/settings/download_settings.h b/libcockatrice_settings/libcockatrice/settings/download_settings.h index 60e59220b..ae49884f0 100644 --- a/libcockatrice_settings/libcockatrice/settings/download_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/download_settings.h @@ -15,6 +15,7 @@ class DownloadSettings : public SettingsManager friend class SettingsCache; static const QStringList DEFAULT_DOWNLOAD_URLS; + static const QString SCRYFALL_NAMED_LOCALIZED_URL; public: explicit DownloadSettings(const QString &, QObject *); @@ -22,6 +23,15 @@ public: QStringList getAllURLs() const; void setDownloadUrls(const QStringList &downloadURLs); void resetToDefaultURLs(); + [[nodiscard]] bool addLocalizedScryfallUrl(); + [[nodiscard]] bool getPicDownload() const; + void setPicDownload(bool _picDownload); + [[nodiscard]] bool getDownloadSpoilersStatus() const; + void setDownloadSpoilerStatus(bool _spoilerStatus); + +signals: + void picDownloadChanged(); + void downloadSpoilerStatusChanged(); }; #endif // COCKATRICE_DOWNLOADSETTINGS_H diff --git a/libcockatrice_settings/libcockatrice/settings/game_filters_settings.cpp b/libcockatrice_settings/libcockatrice/settings/game_filters_settings.cpp index 4f5bf52ee..0edba44dd 100644 --- a/libcockatrice_settings/libcockatrice/settings/game_filters_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/game_filters_settings.cpp @@ -19,137 +19,137 @@ static QString hashGameType(const QString &gameType) void GameFiltersSettings::setHideBuddiesOnlyGames(bool hide) { - setValue(hide, "hide_buddies_only_games"); + setValue(hide, "hideBuddiesOnlyGames"); } bool GameFiltersSettings::isHideBuddiesOnlyGames() const { - QVariant previous = getValue("hide_buddies_only_games"); + QVariant previous = getValue("hideBuddiesOnlyGames"); return previous == QVariant() ? false : previous.toBool(); } void GameFiltersSettings::setHideFullGames(bool hide) { - setValue(hide, "hide_full_games"); + setValue(hide, "hideFullGames"); } bool GameFiltersSettings::isHideFullGames() const { - QVariant previous = getValue("hide_full_games"); + QVariant previous = getValue("hideFullGames"); return previous == QVariant() ? false : previous.toBool(); } void GameFiltersSettings::setHideGamesThatStarted(bool hide) { - setValue(hide, "hide_games_that_started"); + setValue(hide, "hideGamesThatStarted"); } bool GameFiltersSettings::isHideGamesThatStarted() const { - QVariant previous = getValue("hide_games_that_started"); + QVariant previous = getValue("hideGamesThatStarted"); return previous == QVariant() ? false : previous.toBool(); } void GameFiltersSettings::setHidePasswordProtectedGames(bool hide) { - setValue(hide, "hide_password_protected_games"); + setValue(hide, "hidePasswordProtectedGames"); } bool GameFiltersSettings::isHidePasswordProtectedGames() const { - QVariant previous = getValue("hide_password_protected_games"); + QVariant previous = getValue("hidePasswordProtectedGames"); return previous == QVariant() ? false : previous.toBool(); } void GameFiltersSettings::setHideIgnoredUserGames(bool hide) { - setValue(hide, "hide_ignored_user_games"); + setValue(hide, "hideIgnoredUserGames"); } bool GameFiltersSettings::isHideIgnoredUserGames() const { - QVariant previous = getValue("hide_ignored_user_games"); + QVariant previous = getValue("hideIgnoredUserGames"); return previous == QVariant() ? true : previous.toBool(); } void GameFiltersSettings::setHideNotBuddyCreatedGames(bool hide) { - setValue(hide, "hide_not_buddy_created_games"); + setValue(hide, "hideNotBuddyCreatedGames"); } bool GameFiltersSettings::isHideNotBuddyCreatedGames() const { - QVariant previous = getValue("hide_not_buddy_created_games"); + QVariant previous = getValue("hideNotBuddyCreatedGames"); return previous == QVariant() ? false : previous.toBool(); } void GameFiltersSettings::setHideOpenDecklistGames(bool hide) { - setValue(hide, "hide_open_decklist_games"); + setValue(hide, "hideOpenDecklistGames"); } bool GameFiltersSettings::isHideOpenDecklistGames() const { - QVariant previous = getValue("hide_open_decklist_games"); + QVariant previous = getValue("hideOpenDecklistGames"); return previous == QVariant() ? false : previous.toBool(); } void GameFiltersSettings::setGameNameFilter(QString gameName) { - setValue(gameName, "game_name_filter"); + setValue(gameName, "gameNameFilter"); } QString GameFiltersSettings::getGameNameFilter() const { - return getValue("game_name_filter").toString(); + return getValue("gameNameFilter").toString(); } -void GameFiltersSettings::setCreatorNameFilters(QStringList creatorName) +void GameFiltersSettings::setHostNameFilters(QStringList hostName) { - setValue(creatorName, "creator_name_filter"); + setValue(hostName, "hostNameFilter"); } -QStringList GameFiltersSettings::getCreatorNameFilters() const +QStringList GameFiltersSettings::getHostNameFilters() const { - return getValue("creator_name_filter").toStringList(); + return getValue("hostNameFilter").toStringList(); } void GameFiltersSettings::setMinPlayers(int min) { - setValue(min, "min_players"); + setValue(min, "minPlayers"); } int GameFiltersSettings::getMinPlayers() const { - QVariant previous = getValue("min_players"); + QVariant previous = getValue("minPlayers"); return previous == QVariant() ? 1 : previous.toInt(); } void GameFiltersSettings::setMaxPlayers(int max) { - setValue(max, "max_players"); + setValue(max, "maxPlayers"); } int GameFiltersSettings::getMaxPlayers() const { - QVariant previous = getValue("max_players"); + QVariant previous = getValue("maxPlayers"); return previous == QVariant() ? 99 : previous.toInt(); } void GameFiltersSettings::setMaxGameAge(const QTime &maxGameAge) { - setValue(maxGameAge, "max_game_age_time"); + setValue(maxGameAge, "maxGameAgeTime"); } QTime GameFiltersSettings::getMaxGameAge() const { - QVariant previous = getValue("max_game_age_time"); + QVariant previous = getValue("maxGameAgeTime"); return previous.toTime(); } void GameFiltersSettings::setGameTypeEnabled(QString gametype, bool enabled) { - setValue(enabled, "game_type/" + hashGameType(gametype)); + setValue(enabled, "gameType/" + hashGameType(gametype)); } void GameFiltersSettings::setGameHashedTypeEnabled(QString gametypeHASHED, bool enabled) @@ -159,50 +159,50 @@ void GameFiltersSettings::setGameHashedTypeEnabled(QString gametypeHASHED, bool bool GameFiltersSettings::isGameTypeEnabled(QString gametype) const { - QVariant previous = getValue("game_type/" + hashGameType(gametype)); + QVariant previous = getValue("gameType/" + hashGameType(gametype)); return previous == QVariant() ? false : previous.toBool(); } void GameFiltersSettings::setShowOnlyIfSpectatorsCanWatch(bool show) { - setValue(show, "show_only_if_spectators_can_watch"); + setValue(show, "showOnlyIfSpectatorsCanWatch"); } bool GameFiltersSettings::isShowOnlyIfSpectatorsCanWatch() const { - QVariant previous = getValue("show_only_if_spectators_can_watch"); + QVariant previous = getValue("showOnlyIfSpectatorsCanWatch"); return previous == QVariant() ? false : previous.toBool(); } void GameFiltersSettings::setShowSpectatorPasswordProtected(bool show) { - setValue(show, "show_spectator_password_protected"); + setValue(show, "showSpectatorPasswordProtected"); } bool GameFiltersSettings::isShowSpectatorPasswordProtected() const { - QVariant previous = getValue("show_spectator_password_protected"); + QVariant previous = getValue("showSpectatorPasswordProtected"); return previous == QVariant() ? false : previous.toBool(); } void GameFiltersSettings::setShowOnlyIfSpectatorsCanChat(bool show) { - setValue(show, "show_only_if_spectators_can_chat"); + setValue(show, "showOnlyIfSpectatorsCanChat"); } bool GameFiltersSettings::isShowOnlyIfSpectatorsCanChat() const { - QVariant previous = getValue("show_only_if_spectators_can_chat"); + QVariant previous = getValue("showOnlyIfSpectatorsCanChat"); return previous == QVariant() ? false : previous.toBool(); } void GameFiltersSettings::setShowOnlyIfSpectatorsCanSeeHands(bool show) { - setValue(show, "show_only_if_spectators_can_see_hands"); + setValue(show, "showOnlyIfSpectatorsCanSeeHands"); } bool GameFiltersSettings::isShowOnlyIfSpectatorsCanSeeHands() const { - QVariant previous = getValue("show_only_if_spectators_can_see_hands"); + QVariant previous = getValue("showOnlyIfSpectatorsCanSeeHands"); return previous == QVariant() ? false : previous.toBool(); } \ No newline at end of file diff --git a/libcockatrice_settings/libcockatrice/settings/game_filters_settings.h b/libcockatrice_settings/libcockatrice/settings/game_filters_settings.h index 24f582007..11480d483 100644 --- a/libcockatrice_settings/libcockatrice/settings/game_filters_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/game_filters_settings.h @@ -24,7 +24,7 @@ public: bool isHideNotBuddyCreatedGames() const; bool isHideOpenDecklistGames() const; QString getGameNameFilter() const; - QStringList getCreatorNameFilters() const; + QStringList getHostNameFilters() const; int getMinPlayers() const; int getMaxPlayers() const; QTime getMaxGameAge() const; @@ -42,7 +42,7 @@ public: void setHidePasswordProtectedGames(bool hide); void setHideNotBuddyCreatedGames(bool hide); void setGameNameFilter(QString gameName); - void setCreatorNameFilters(QStringList creatorName); + void setHostNameFilters(QStringList hostName); void setMinPlayers(int min); void setMaxPlayers(int max); void setMaxGameAge(const QTime &maxGameAge); diff --git a/libcockatrice_settings/libcockatrice/settings/game_settings.cpp b/libcockatrice_settings/libcockatrice/settings/game_settings.cpp new file mode 100644 index 000000000..dfd67c98a --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/game_settings.cpp @@ -0,0 +1,166 @@ +#include "game_settings.h" + +GameSettings::GameSettings(const QString &settingPath, QObject *parent) + : SettingsManager(settingPath + "game.ini", QString(), QString(), parent) +{ +} + +QString GameSettings::getGameDescription() const +{ + return getValue("gameDescription", "game").toString(); +} + +int GameSettings::getMaxPlayers() const +{ + return getValue("maxPlayers", "game", QString(), 2).toInt(); +} + +QString GameSettings::getGameTypes() const +{ + return getValue("gameTypes", "game").toString(); +} + +bool GameSettings::getOnlyBuddies() const +{ + return getValue("onlyBuddies", "game").toBool(); +} + +bool GameSettings::getOnlyRegistered() const +{ + return getValue("onlyRegistered", "game").toBool(); +} + +bool GameSettings::getSpectatorsAllowed() const +{ + return getValue("spectatorsAllowed", "game").toBool(); +} + +bool GameSettings::getSpectatorsNeedPassword() const +{ + return getValue("spectatorsNeedPassword", "game").toBool(); +} + +bool GameSettings::getSpectatorsCanTalk() const +{ + return getValue("spectatorsCanTalk", "game").toBool(); +} + +bool GameSettings::getSpectatorsCanSeeEverything() const +{ + return getValue("spectatorsCanSeeEverything", "game").toBool(); +} + +bool GameSettings::getCreateGameAsSpectator() const +{ + return getValue("createGameAsSpectator", "game").toBool(); +} + +int GameSettings::getDefaultStartingLifeTotal() const +{ + return getValue("defaultStartingLifeTotal", "game", QString(), 20).toInt(); +} + +bool GameSettings::getShareDecklistsOnLoad() const +{ + return getValue("shareDecklistsOnLoad", "game").toBool(); +} + +bool GameSettings::getRememberGameSettings() const +{ + return getValue("rememberGameSettings", "game", QString(), true).toBool(); +} + +bool GameSettings::getLocalGameRememberSettings() const +{ + return getValue("rememberSettings", "localgameoptions").toBool(); +} + +int GameSettings::getLocalGameMaxPlayers() const +{ + return getValue("maxPlayers", "localgameoptions", QString(), 1).toInt(); +} + +int GameSettings::getLocalGameStartingLifeTotal() const +{ + return getValue("startingLifeTotal", "localgameoptions", QString(), 20).toInt(); +} + +void GameSettings::setGameDescription(const QString &_gameDescription) +{ + setValue(_gameDescription, "gameDescription", "game"); +} + +void GameSettings::setMaxPlayers(int _maxPlayers) +{ + setValue(_maxPlayers, "maxPlayers", "game"); +} + +void GameSettings::setGameTypes(const QString &_gameTypes) +{ + setValue(_gameTypes, "gameTypes", "game"); +} + +void GameSettings::setOnlyBuddies(bool _onlyBuddies) +{ + setValue(_onlyBuddies, "onlyBuddies", "game"); +} + +void GameSettings::setOnlyRegistered(bool _onlyRegistered) +{ + setValue(_onlyRegistered, "onlyRegistered", "game"); +} + +void GameSettings::setSpectatorsAllowed(bool _spectatorsAllowed) +{ + setValue(_spectatorsAllowed, "spectatorsAllowed", "game"); +} + +void GameSettings::setSpectatorsNeedPassword(bool _spectatorsNeedPassword) +{ + setValue(_spectatorsNeedPassword, "spectatorsNeedPassword", "game"); +} + +void GameSettings::setSpectatorsCanTalk(bool _spectatorsCanTalk) +{ + setValue(_spectatorsCanTalk, "spectatorsCanTalk", "game"); +} + +void GameSettings::setSpectatorsCanSeeEverything(bool _spectatorsCanSeeEverything) +{ + setValue(_spectatorsCanSeeEverything, "spectatorsCanSeeEverything", "game"); +} + +void GameSettings::setCreateGameAsSpectator(bool _createGameAsSpectator) +{ + setValue(_createGameAsSpectator, "createGameAsSpectator", "game"); +} + +void GameSettings::setDefaultStartingLifeTotal(int _defaultStartingLifeTotal) +{ + setValue(_defaultStartingLifeTotal, "defaultStartingLifeTotal", "game"); +} + +void GameSettings::setShareDecklistsOnLoad(bool _shareDecklistsOnLoad) +{ + setValue(_shareDecklistsOnLoad, "shareDecklistsOnLoad", "game"); +} + +void GameSettings::setRememberGameSettings(bool _rememberGameSettings) +{ + setValue(_rememberGameSettings, "rememberGameSettings", "game"); +} + +void GameSettings::setLocalGameRememberSettings(bool value) +{ + setValue(value, "rememberSettings", "localgameoptions"); +} + +void GameSettings::setLocalGameMaxPlayers(int value) +{ + setValue(value, "maxPlayers", "localgameoptions"); +} + +void GameSettings::setLocalGameStartingLifeTotal(int value) +{ + setValue(value, "startingLifeTotal", "localgameoptions"); +} diff --git a/libcockatrice_settings/libcockatrice/settings/game_settings.h b/libcockatrice_settings/libcockatrice/settings/game_settings.h new file mode 100644 index 000000000..e0a484bac --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/game_settings.h @@ -0,0 +1,55 @@ +#ifndef GAME_SETTINGS_H +#define GAME_SETTINGS_H + +#include "settings_manager.h" + +#include + +class GameSettings : public SettingsManager, public IGameSettingsProvider +{ + Q_OBJECT + friend class SettingsCache; + +public: + [[nodiscard]] QString getGameDescription() const override; + [[nodiscard]] int getMaxPlayers() const override; + [[nodiscard]] QString getGameTypes() const override; + [[nodiscard]] bool getOnlyBuddies() const override; + [[nodiscard]] bool getOnlyRegistered() const override; + [[nodiscard]] bool getSpectatorsAllowed() const override; + [[nodiscard]] bool getSpectatorsNeedPassword() const override; + [[nodiscard]] bool getSpectatorsCanTalk() const override; + [[nodiscard]] bool getSpectatorsCanSeeEverything() const override; + [[nodiscard]] bool getCreateGameAsSpectator() const override; + [[nodiscard]] int getDefaultStartingLifeTotal() const override; + [[nodiscard]] bool getShareDecklistsOnLoad() const override; + [[nodiscard]] bool getRememberGameSettings() const override; + [[nodiscard]] bool getLocalGameRememberSettings() const override; + [[nodiscard]] int getLocalGameMaxPlayers() const override; + [[nodiscard]] int getLocalGameStartingLifeTotal() const override; + + void setGameDescription(const QString &_gameDescription); + void setMaxPlayers(int _maxPlayers); + void setGameTypes(const QString &_gameTypes); + void setOnlyBuddies(bool _onlyBuddies); + void setOnlyRegistered(bool _onlyRegistered); + void setSpectatorsAllowed(bool _spectatorsAllowed); + void setSpectatorsNeedPassword(bool _spectatorsNeedPassword); + void setSpectatorsCanTalk(bool _spectatorsCanTalk); + void setSpectatorsCanSeeEverything(bool _spectatorsCanSeeEverything); + void setCreateGameAsSpectator(bool _createGameAsSpectator); + void setDefaultStartingLifeTotal(int _defaultStartingLifeTotal); + void setShareDecklistsOnLoad(bool _shareDecklistsOnLoad); + void setRememberGameSettings(bool _rememberGameSettings); + void setLocalGameRememberSettings(bool value); + void setLocalGameMaxPlayers(int value); + void setLocalGameStartingLifeTotal(int value); + +public: + explicit GameSettings(const QString &settingPath, QObject *parent = nullptr); + +private: + GameSettings(const GameSettings & /*other*/); +}; + +#endif // GAME_SETTINGS_H diff --git a/libcockatrice_settings/libcockatrice/settings/interface_settings.cpp b/libcockatrice_settings/libcockatrice/settings/interface_settings.cpp new file mode 100644 index 000000000..b92a5fdec --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/interface_settings.cpp @@ -0,0 +1,460 @@ +#include "interface_settings.h" + +namespace +{ +const QChar PLAYMAT_FIELD_SEP = QChar(0x1F); ///< Separator between PlaymatInfo fields. + +QString encodePlaymatInfo(const PlaymatInfo &res) +{ + return res.card.name + PLAYMAT_FIELD_SEP + res.card.providerId + PLAYMAT_FIELD_SEP + + QString::number(res.params.marginPctL, 'f', 4) + PLAYMAT_FIELD_SEP + + QString::number(res.params.marginPctR, 'f', 4) + PLAYMAT_FIELD_SEP + + QString::number(res.params.verticalOffset, 'f', 4) + PLAYMAT_FIELD_SEP + + QString::number(res.params.zoom, 'f', 4); +} + +PlaymatInfo decodePlaymatInfo(const QString &encoded) +{ + const QStringList fields = encoded.split(PLAYMAT_FIELD_SEP); + if (fields.size() != 6) { + return {}; + } + PlaymatInfo res; + res.card.name = fields.at(0); + res.card.providerId = fields.at(1); + res.params.marginPctL = fields.at(2).toDouble(); + res.params.marginPctR = fields.at(3).toDouble(); + res.params.verticalOffset = fields.at(4).toDouble(); + res.params.zoom = fields.at(5).toDouble(); + return res; +} +} // namespace + +InterfaceSettings::InterfaceSettings(const QString &settingPath, QObject *parent) + : SettingsManager(settingPath + "interface.ini", "interface", QString(), parent) +{ +} + +bool InterfaceSettings::getUseTearOffMenus() const +{ + return getValue("useTearOffMenus", QString(), QString(), true).toBool(); +} + +int InterfaceSettings::getCardViewInitialRowsMax() const +{ + return getValue("cardViewInitialRowsMax", QString(), QString(), 14).toInt(); +} + +int InterfaceSettings::getCardViewExpandedRowsMax() const +{ + return getValue("cardViewExpandedRowsMax", QString(), QString(), 20).toInt(); +} + +bool InterfaceSettings::getCloseEmptyCardView() const +{ + return getValue("closeEmptyCardView", QString(), QString(), true).toBool(); +} + +bool InterfaceSettings::getFocusCardViewSearchBar() const +{ + return getValue("focusCardViewSearchBar", QString(), QString(), true).toBool(); +} + +bool InterfaceSettings::getKeepGameChatFocus() const +{ + return getValue("keepGameChatFocus", QString(), QString(), false).toBool(); +} + +bool InterfaceSettings::getNotificationsEnabled() const +{ + return getValue("enabled", "interface", "notifications", true).toBool(); +} + +bool InterfaceSettings::getSpectatorNotificationsEnabled() const +{ + return getValue("spectatorsEnabled", "interface", "notifications", false).toBool(); +} + +bool InterfaceSettings::getBuddyConnectNotificationsEnabled() const +{ + return getValue("buddyConnectEnabled", "interface", "notifications", true).toBool(); +} + +bool InterfaceSettings::getDoubleClickToPlay() const +{ + return getValue("doubleClickToPlay", QString(), QString(), true).toBool(); +} + +bool InterfaceSettings::getClickPlaysAllSelected() const +{ + return getValue("clickPlaysAllSelected", QString(), QString(), true).toBool(); +} + +bool InterfaceSettings::getPlayToStack() const +{ + return getValue("playToStack", QString(), QString(), true).toBool(); +} + +bool InterfaceSettings::getDoNotDeleteArrowsInSubPhases() const +{ + return getValue("doNotDeleteArrowsInSubPhases", QString(), QString(), true).toBool(); +} + +int InterfaceSettings::getStartingHandSize() const +{ + return getValue("startingHandSize", QString(), QString(), 7).toInt(); +} + +bool InterfaceSettings::getAnnotateTokens() const +{ + return getValue("annotateTokens", QString(), QString(), false).toBool(); +} + +bool InterfaceSettings::getShowDragSelectionCount() const +{ + return getValue("showLassoSelectionCount", QString(), QString(), true).toBool(); +} + +bool InterfaceSettings::getShowTotalSelectionCount() const +{ + return getValue("showPersistentSelectionCount", QString(), QString(), true).toBool(); +} + +int InterfaceSettings::getTallyType() const +{ + return getValue("tallyType", QString(), QString(), 0).toInt(); +} + +bool InterfaceSettings::getHorizontalHand() const +{ + return getValue("horizontal", "hand", QString(), true).toBool(); +} + +bool InterfaceSettings::getInvertVerticalCoordinate() const +{ + return getValue("invertVertical", "table", QString(), false).toBool(); +} + +int InterfaceSettings::getMinPlayersForMultiColumnLayout() const +{ + return getValue("minPlayersMulticolumn", QString(), QString(), 4).toInt(); +} + +int InterfaceSettings::getRewindBufferingMs() const +{ + return getValue("rewindBufferingMs", "replay", QString(), 200).toInt(); +} + +qreal InterfaceSettings::getFastForwardSpeed() const +{ + return getValue("fastForwardSpeed", "replay", QString(), 10).toReal(); +} + +bool InterfaceSettings::getSkipEmptySections() const +{ + return getValue("skipEmptySections", "replay", QString(), false).toBool(); +} + +bool InterfaceSettings::getLeftJustified() const +{ + return getValue("leftJustified", QString(), QString(), false).toBool(); +} + +int InterfaceSettings::getZoneViewGroupByIndex() const +{ + return getValue("groupBy", "zoneview", QString(), 1).toInt(); +} + +int InterfaceSettings::getZoneViewSortByIndex() const +{ + return getValue("sortBy", "zoneview", QString(), 1).toInt(); +} + +bool InterfaceSettings::getZoneViewPileView() const +{ + return getValue("pileView", "zoneview", QString(), true).toBool(); +} + +bool InterfaceSettings::getShowStatusBar() const +{ + return getValue("showStatusBar", QString(), QString(), false).toBool(); +} + +bool InterfaceSettings::getShowShortcuts() const +{ + return getValue("showShortcuts", QString(), QString(), true).toBool(); +} + +bool InterfaceSettings::getShowGameSelectorFilterToolbar() const +{ + return getValue("showGameSelectorFilterToolbar", QString(), QString(), true).toBool(); +} + +int InterfaceSettings::getPlaymatVisibility() const +{ + return qBound(0, getValue("playmatvisibility", QString(), QString(), 2).toInt(), 2); +} + +QList InterfaceSettings::getPlaymatFallbackList() const +{ + const QStringList entries = getValue("playmatFallbackList", QString(), QString(), QStringList()).toStringList(); + QList result; + result.reserve(entries.size()); + for (const QString &entry : entries) { + const PlaymatInfo res = decodePlaymatInfo(entry); + if (!res.card.isEmpty()) { + result.append(res); + } + } + return result; +} + +int InterfaceSettings::getPlaymatMode() const +{ + return qBound(0, getValue("playmatMode", QString(), QString(), 1).toInt(), 2); +} + +int InterfaceSettings::getPlaymatFallbackBehavior() const +{ + return qBound(0, getValue("playmatFallbackBehavior", QString(), QString(), 0).toInt(), 2); +} + +bool InterfaceSettings::getLifeCounterAnimationsEnabled() const +{ + return getValue("lifeCounterAnimationsEnabled", QString(), QString(), true).toBool(); +} + +bool InterfaceSettings::getBattlefieldFlashEnabled() const +{ + return getValue("battlefieldFlashEnabled", QString(), QString(), true).toBool(); +} + +QStringList InterfaceSettings::getUserListExpandedSections() const +{ + return getValue("userListExpandedSections", QString(), QString(), QStringList({"buddy", "online", "ignore"})) + .toStringList(); +} + +void InterfaceSettings::setUseTearOffMenus(bool _useTearOffMenus) +{ + setValue(_useTearOffMenus, "useTearOffMenus"); + emit useTearOffMenusChanged(_useTearOffMenus); +} + +void InterfaceSettings::setCardViewInitialRowsMax(int _cardViewInitialRowsMax) +{ + setValue(_cardViewInitialRowsMax, "cardViewInitialRowsMax"); +} + +void InterfaceSettings::setCardViewExpandedRowsMax(int value) +{ + setValue(value, "cardViewExpandedRowsMax"); +} + +void InterfaceSettings::setCloseEmptyCardView(bool value) +{ + setValue(value, "closeEmptyCardView"); +} + +void InterfaceSettings::setFocusCardViewSearchBar(bool value) +{ + setValue(value, "focusCardViewSearchBar"); +} + +void InterfaceSettings::setKeepGameChatFocus(bool value) +{ + setValue(value, "keepGameChatFocus"); + emit keepGameChatFocusChanged(value); +} + +void InterfaceSettings::setNotificationsEnabled(bool _notificationsEnabled) +{ + setValue(_notificationsEnabled, "enabled", "interface", "notifications"); +} + +void InterfaceSettings::setSpectatorNotificationsEnabled(bool _spectatorNotificationsEnabled) +{ + setValue(_spectatorNotificationsEnabled, "spectatorsEnabled", "interface", "notifications"); +} + +void InterfaceSettings::setBuddyConnectNotificationsEnabled(bool _buddyConnectNotificationsEnabled) +{ + setValue(_buddyConnectNotificationsEnabled, "buddyConnectEnabled", "interface", "notifications"); +} + +void InterfaceSettings::setDoubleClickToPlay(bool _doubleClickToPlay) +{ + setValue(_doubleClickToPlay, "doubleClickToPlay"); +} + +void InterfaceSettings::setClickPlaysAllSelected(bool _clickPlaysAllSelected) +{ + setValue(_clickPlaysAllSelected, "clickPlaysAllSelected"); +} + +void InterfaceSettings::setPlayToStack(bool _playToStack) +{ + setValue(_playToStack, "playToStack"); +} + +void InterfaceSettings::setDoNotDeleteArrowsInSubPhases(bool _doNotDeleteArrowsInSubPhases) +{ + setValue(_doNotDeleteArrowsInSubPhases, "doNotDeleteArrowsInSubPhases"); +} + +void InterfaceSettings::setStartingHandSize(int _startingHandSize) +{ + setValue(_startingHandSize, "startingHandSize"); +} + +void InterfaceSettings::setAnnotateTokens(bool _annotateTokens) +{ + setValue(_annotateTokens, "annotateTokens"); +} + +void InterfaceSettings::setShowDragSelectionCount(bool _showDragSelectionCount) +{ + setValue(_showDragSelectionCount, "showLassoSelectionCount"); +} + +void InterfaceSettings::setShowTotalSelectionCount(bool _showTotalSelectionCount) +{ + setValue(_showTotalSelectionCount, "showPersistentSelectionCount"); +} + +void InterfaceSettings::setTallyType(int value) +{ + if (getTallyType() == value) { + return; + } + setValue(value, "tallyType"); + emit tallyTypeChanged(value); +} + +void InterfaceSettings::setHorizontalHand(bool _horizontalHand) +{ + setValue(_horizontalHand, "horizontal", "hand"); + emit horizontalHandChanged(); +} + +void InterfaceSettings::setInvertVerticalCoordinate(bool _invertVerticalCoordinate) +{ + setValue(_invertVerticalCoordinate, "invertVertical", "table"); + emit invertVerticalCoordinateChanged(); +} + +void InterfaceSettings::setMinPlayersForMultiColumnLayout(int _minPlayersForMultiColumnLayout) +{ + setValue(_minPlayersForMultiColumnLayout, "minPlayersMulticolumn"); + emit minPlayersForMultiColumnLayoutChanged(); +} + +void InterfaceSettings::setRewindBufferingMs(int _rewindBufferingMs) +{ + setValue(_rewindBufferingMs, "rewindBufferingMs", "replay"); +} + +void InterfaceSettings::setFastForwardSpeed(qreal _value) +{ + setValue(_value, "fastForwardSpeed", "replay"); +} + +void InterfaceSettings::setSkipEmptySections(bool _value) +{ + setValue(_value, "skipEmptySections", "replay"); +} + +void InterfaceSettings::setLeftJustified(bool _leftJustified) +{ + setValue(_leftJustified, "leftJustified"); + emit handJustificationChanged(); +} + +void InterfaceSettings::setZoneViewGroupByIndex(int _zoneViewGroupByIndex) +{ + setValue(_zoneViewGroupByIndex, "groupBy", "zoneview"); +} + +void InterfaceSettings::setZoneViewSortByIndex(int _zoneViewSortByIndex) +{ + setValue(_zoneViewSortByIndex, "sortBy", "zoneview"); +} + +void InterfaceSettings::setZoneViewPileView(bool _zoneViewPileView) +{ + setValue(_zoneViewPileView, "pileView", "zoneview"); +} + +void InterfaceSettings::setShowStatusBar(bool _showStatusBar) +{ + setValue(_showStatusBar, "showStatusBar"); + emit showStatusBarChanged(_showStatusBar); +} + +void InterfaceSettings::setShowShortcuts(bool _showShortcuts) +{ + setValue(_showShortcuts, "showShortcuts"); +} + +void InterfaceSettings::setShowGameSelectorFilterToolbar(bool _showGameSelectorFilterToolbar) +{ + setValue(_showGameSelectorFilterToolbar, "showGameSelectorFilterToolbar"); + emit showGameSelectorFilterToolbarChanged(_showGameSelectorFilterToolbar); +} + +void InterfaceSettings::setPlaymatVisibility(int _visibility) +{ + if (getPlaymatVisibility() == _visibility) { + return; + } + setValue(_visibility, "playmatvisibility"); + emit playmatVisibilityChanged(_visibility); +} + +void InterfaceSettings::setPlaymatFallbackList(const QList &_fallbackList) +{ + QStringList entries; + entries.reserve(_fallbackList.size()); + for (const PlaymatInfo &res : _fallbackList) { + entries.append(encodePlaymatInfo(res)); + } + setValue(entries, "playmatFallbackList"); + emit playmatSettingsChanged(); +} + +void InterfaceSettings::setPlaymatMode(int _mode) +{ + const int mode = qBound(0, _mode, 2); + if (getPlaymatMode() == mode) { + return; + } + setValue(mode, "playmatMode"); + emit playmatSettingsChanged(); +} + +void InterfaceSettings::setPlaymatFallbackBehavior(int _behavior) +{ + const int behavior = qBound(0, _behavior, 2); + if (getPlaymatFallbackBehavior() == behavior) { + return; + } + setValue(behavior, "playmatFallbackBehavior"); + emit playmatSettingsChanged(); +} + +void InterfaceSettings::setLifeCounterAnimationsEnabled(bool _lifeCounterAnimationsEnabled) +{ + setValue(_lifeCounterAnimationsEnabled, "lifeCounterAnimationsEnabled"); + emit lifeCounterAnimationsEnabledChanged(_lifeCounterAnimationsEnabled); +} + +void InterfaceSettings::setBattlefieldFlashEnabled(bool _battlefieldFlashEnabled) +{ + setValue(_battlefieldFlashEnabled, "battlefieldFlashEnabled"); + emit battlefieldFlashEnabledChanged(_battlefieldFlashEnabled); +} + +void InterfaceSettings::setUserListExpandedSections(const QStringList §ions) +{ + setValue(sections, "userListExpandedSections"); +} diff --git a/libcockatrice_settings/libcockatrice/settings/interface_settings.h b/libcockatrice_settings/libcockatrice/settings/interface_settings.h new file mode 100644 index 000000000..6b226f307 --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/interface_settings.h @@ -0,0 +1,114 @@ +#ifndef INTERFACE_SETTINGS_H +#define INTERFACE_SETTINGS_H + +#include "settings_manager.h" + +#include + +class InterfaceSettings : public SettingsManager, public IInterfaceSettingsProvider +{ + Q_OBJECT + friend class SettingsCache; + +public: + [[nodiscard]] bool getUseTearOffMenus() const override; + [[nodiscard]] int getCardViewInitialRowsMax() const override; + [[nodiscard]] int getCardViewExpandedRowsMax() const override; + [[nodiscard]] bool getCloseEmptyCardView() const override; + [[nodiscard]] bool getFocusCardViewSearchBar() const override; + [[nodiscard]] bool getKeepGameChatFocus() const override; + [[nodiscard]] bool getNotificationsEnabled() const override; + [[nodiscard]] bool getSpectatorNotificationsEnabled() const override; + [[nodiscard]] bool getBuddyConnectNotificationsEnabled() const override; + [[nodiscard]] bool getDoubleClickToPlay() const override; + [[nodiscard]] bool getClickPlaysAllSelected() const override; + [[nodiscard]] bool getPlayToStack() const override; + [[nodiscard]] bool getDoNotDeleteArrowsInSubPhases() const override; + [[nodiscard]] int getStartingHandSize() const override; + [[nodiscard]] bool getAnnotateTokens() const override; + [[nodiscard]] bool getShowDragSelectionCount() const override; + [[nodiscard]] bool getShowTotalSelectionCount() const override; + [[nodiscard]] int getTallyType() const override; + [[nodiscard]] bool getHorizontalHand() const override; + [[nodiscard]] bool getInvertVerticalCoordinate() const override; + [[nodiscard]] int getMinPlayersForMultiColumnLayout() const override; + [[nodiscard]] int getRewindBufferingMs() const override; + [[nodiscard]] qreal getFastForwardSpeed() const override; + [[nodiscard]] bool getSkipEmptySections() const override; + [[nodiscard]] bool getLeftJustified() const override; + [[nodiscard]] int getZoneViewGroupByIndex() const override; + [[nodiscard]] int getZoneViewSortByIndex() const override; + [[nodiscard]] bool getZoneViewPileView() const override; + [[nodiscard]] bool getShowStatusBar() const override; + [[nodiscard]] bool getShowShortcuts() const override; + [[nodiscard]] bool getShowGameSelectorFilterToolbar() const override; + [[nodiscard]] int getPlaymatVisibility() const override; + [[nodiscard]] QList getPlaymatFallbackList() const override; + [[nodiscard]] int getPlaymatMode() const override; + [[nodiscard]] int getPlaymatFallbackBehavior() const override; + [[nodiscard]] bool getLifeCounterAnimationsEnabled() const override; + [[nodiscard]] bool getBattlefieldFlashEnabled() const override; + [[nodiscard]] QStringList getUserListExpandedSections() const override; + + void setUseTearOffMenus(bool _useTearOffMenus); + void setCardViewInitialRowsMax(int _cardViewInitialRowsMax); + void setCardViewExpandedRowsMax(int value); + void setCloseEmptyCardView(bool value); + void setFocusCardViewSearchBar(bool value); + void setKeepGameChatFocus(bool value); + void setNotificationsEnabled(bool _notificationsEnabled); + void setSpectatorNotificationsEnabled(bool _spectatorNotificationsEnabled); + void setBuddyConnectNotificationsEnabled(bool _buddyConnectNotificationsEnabled); + void setDoubleClickToPlay(bool _doubleClickToPlay); + void setClickPlaysAllSelected(bool _clickPlaysAllSelected); + void setPlayToStack(bool _playToStack); + void setDoNotDeleteArrowsInSubPhases(bool _doNotDeleteArrowsInSubPhases); + void setStartingHandSize(int _startingHandSize); + void setAnnotateTokens(bool _annotateTokens); + void setShowDragSelectionCount(bool _showDragSelectionCount); + void setShowTotalSelectionCount(bool _showTotalSelectionCount); + void setTallyType(int value); + void setHorizontalHand(bool _horizontalHand); + void setInvertVerticalCoordinate(bool _invertVerticalCoordinate); + void setMinPlayersForMultiColumnLayout(int _minPlayersForMultiColumnLayout); + void setRewindBufferingMs(int _rewindBufferingMs); + void setFastForwardSpeed(qreal _value); + void setSkipEmptySections(bool _value); + void setLeftJustified(bool _leftJustified); + void setZoneViewGroupByIndex(int _zoneViewGroupByIndex); + void setZoneViewSortByIndex(int _zoneViewSortByIndex); + void setZoneViewPileView(bool _zoneViewPileView); + void setShowStatusBar(bool _showStatusBar); + void setShowShortcuts(bool _showShortcuts); + void setShowGameSelectorFilterToolbar(bool _showGameSelectorFilterToolbar); + void setPlaymatVisibility(int _visibility); + void setPlaymatFallbackList(const QList &_fallbackList); + void setPlaymatMode(int _mode); + void setPlaymatFallbackBehavior(int _behavior); + void setLifeCounterAnimationsEnabled(bool _lifeCounterAnimationsEnabled); + void setBattlefieldFlashEnabled(bool _battlefieldFlashEnabled); + void setUserListExpandedSections(const QStringList §ions); + +signals: + void useTearOffMenusChanged(bool state); + void keepGameChatFocusChanged(bool value); + void horizontalHandChanged(); + void invertVerticalCoordinateChanged(); + void minPlayersForMultiColumnLayoutChanged(); + void handJustificationChanged(); + void tallyTypeChanged(int type); + void showStatusBarChanged(bool state); + void showGameSelectorFilterToolbarChanged(bool state); + void playmatVisibilityChanged(int visibility); + void playmatSettingsChanged(); + void lifeCounterAnimationsEnabledChanged(bool state); + void battlefieldFlashEnabledChanged(bool state); + +public: + explicit InterfaceSettings(const QString &settingPath, QObject *parent = nullptr); + +private: + InterfaceSettings(const InterfaceSettings & /*other*/); +}; + +#endif // INTERFACE_SETTINGS_H diff --git a/libcockatrice_settings/libcockatrice/settings/network_settings.cpp b/libcockatrice_settings/libcockatrice/settings/network_settings.cpp new file mode 100644 index 000000000..2d34d47ad --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/network_settings.cpp @@ -0,0 +1,56 @@ +#include "network_settings.h" + +NetworkSettings::NetworkSettings(const QString &settingPath, QObject *parent) + : SettingsManager(settingPath + "network.ini", "network", QString(), parent) +{ +} + +QString NetworkSettings::getClientID() const +{ + return getValue("clientId", QString(), QString(), "notset").toString(); +} + +void NetworkSettings::setClientID(const QString &_clientID) +{ + setValue(_clientID, "clientId"); +} + +QString NetworkSettings::getClientVersion() const +{ + return getValue("clientVersion", QString(), QString(), "notset").toString(); +} + +void NetworkSettings::setClientVersion(const QString &_clientVersion) +{ + setValue(_clientVersion, "clientVersion"); +} + +int NetworkSettings::getKeepAlive() const +{ + return getValue("keepAlive", QString(), QString(), 3).toInt(); +} + +void NetworkSettings::setKeepAlive(int _keepAlive) +{ + setValue(_keepAlive, "keepAlive"); +} + +int NetworkSettings::getTimeOut() const +{ + return getValue("timeout", QString(), QString(), 5).toInt(); +} + +void NetworkSettings::setTimeOut(int _timeOut) +{ + setValue(_timeOut, "timeout"); +} + +QString NetworkSettings::getKnownMissingFeatures() const +{ + return getValue("knownMissingFeatures", QString(), QString(), "").toString(); +} + +void NetworkSettings::setKnownMissingFeatures(const QString &_knownMissingFeatures) +{ + setValue(_knownMissingFeatures, "knownMissingFeatures"); +} diff --git a/libcockatrice_settings/libcockatrice/settings/network_settings.h b/libcockatrice_settings/libcockatrice/settings/network_settings.h new file mode 100644 index 000000000..446bb51d3 --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/network_settings.h @@ -0,0 +1,36 @@ +/** + * @file network_settings.h + * @ingroup NetworkSettings + */ +//! \todo Document this file. + +#ifndef NETWORK_SETTINGS_H +#define NETWORK_SETTINGS_H + +#include "settings_manager.h" + +class NetworkSettings : public SettingsManager +{ + Q_OBJECT + friend class SettingsCache; + +public: + [[nodiscard]] QString getClientID() const; + void setClientID(const QString &_clientID); + [[nodiscard]] QString getClientVersion() const; + void setClientVersion(const QString &_clientVersion); + [[nodiscard]] int getKeepAlive() const; + void setKeepAlive(int _keepAlive); + [[nodiscard]] int getTimeOut() const; + void setTimeOut(int _timeOut); + [[nodiscard]] QString getKnownMissingFeatures() const; + void setKnownMissingFeatures(const QString &_knownMissingFeatures); + +public: + explicit NetworkSettings(const QString &settingPath, QObject *parent = nullptr); + +private: + NetworkSettings(const NetworkSettings & /*other*/); +}; + +#endif // NETWORK_SETTINGS_H diff --git a/libcockatrice_settings/libcockatrice/settings/paths_settings.cpp b/libcockatrice_settings/libcockatrice/settings/paths_settings.cpp new file mode 100644 index 000000000..67032e38f --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/paths_settings.cpp @@ -0,0 +1,120 @@ +#include "paths_settings.h" + +#include +#include + +PathsSettings::PathsSettings(const QString &settingPath, QObject *parent) + : SettingsManager(settingPath + "paths.ini", "paths", QString(), parent) +{ +} + +QString PathsSettings::getDeckPath() const +{ + return getValue("decks").toString(); +} + +QString PathsSettings::getFiltersPath() const +{ + return getValue("filters").toString(); +} + +QString PathsSettings::getReplaysPath() const +{ + return getValue("replays").toString(); +} + +QString PathsSettings::getPicsPath() const +{ + return getValue("pics").toString(); +} + +QString PathsSettings::getCustomPicsPath() const +{ + return getValue("customPics").toString(); +} + +QString PathsSettings::getThemesPath() const +{ + return getValue("themes").toString(); +} + +QString PathsSettings::getCardDatabasePath() const +{ + return getValue("cardDatabase").toString(); +} + +QString PathsSettings::getCustomCardDatabasePath() const +{ + return getValue("customSets").toString(); +} + +QString PathsSettings::getTokenDatabasePath() const +{ + return getValue("tokenDatabase").toString(); +} + +QString PathsSettings::getSpoilerCardDatabasePath() const +{ + return getValue("spoilerDatabase").toString(); +} + +QString PathsSettings::getRedirectCachePath() const +{ + return getValue("redirects").toString(); +} + +void PathsSettings::setDeckPath(const QString &_deckPath) +{ + setValue(_deckPath, "decks"); +} + +void PathsSettings::setFiltersPath(const QString &_filtersPath) +{ + setValue(_filtersPath, "filters"); +} + +void PathsSettings::setReplaysPath(const QString &_replaysPath) +{ + setValue(_replaysPath, "replays"); +} + +void PathsSettings::setPicsPath(const QString &_picsPath) +{ + setValue(_picsPath, "pics"); + emit picsPathChanged(); +} + +void PathsSettings::setCustomPicsPath(const QString &_customPicsPath) +{ + setValue(_customPicsPath, "customPics"); +} + +void PathsSettings::setThemesPath(const QString &_themesPath) +{ + setValue(_themesPath, "themes"); + emit themeChanged(); +} + +void PathsSettings::setCardDatabasePath(const QString &_cardDatabasePath) +{ + setValue(_cardDatabasePath, "cardDatabase"); + emit cardDatabasePathChanged(); +} + +void PathsSettings::setCustomCardDatabasePath(const QString &_customCardDatabasePath) +{ + setValue(_customCardDatabasePath, "customSets"); + emit cardDatabasePathChanged(); +} + +void PathsSettings::setTokenDatabasePath(const QString &_tokenDatabasePath) +{ + setValue(_tokenDatabasePath, "tokenDatabase"); + emit cardDatabasePathChanged(); +} + +void PathsSettings::setSpoilerDatabasePath(const QString &_spoilerDatabasePath) +{ + setValue(_spoilerDatabasePath, "spoilerDatabase"); + emit cardDatabasePathChanged(); +} diff --git a/libcockatrice_settings/libcockatrice/settings/paths_settings.h b/libcockatrice_settings/libcockatrice/settings/paths_settings.h new file mode 100644 index 000000000..ab1ea05c8 --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/paths_settings.h @@ -0,0 +1,47 @@ +#ifndef PATHS_SETTINGS_H +#define PATHS_SETTINGS_H + +#include "settings_manager.h" + +#include + +class PathsSettings : public SettingsManager, public IPathsSettingsProvider +{ + Q_OBJECT + friend class SettingsCache; + +public: + [[nodiscard]] QString getDeckPath() const override; + [[nodiscard]] QString getFiltersPath() const override; + [[nodiscard]] QString getReplaysPath() const override; + [[nodiscard]] QString getPicsPath() const override; + [[nodiscard]] QString getCustomPicsPath() const override; + [[nodiscard]] QString getThemesPath() const override; + [[nodiscard]] QString getCardDatabasePath() const override; + [[nodiscard]] QString getCustomCardDatabasePath() const override; + [[nodiscard]] QString getTokenDatabasePath() const override; + [[nodiscard]] QString getSpoilerCardDatabasePath() const override; + [[nodiscard]] QString getRedirectCachePath() const override; + + void setDeckPath(const QString &_deckPath); + void setFiltersPath(const QString &_filtersPath); + void setReplaysPath(const QString &_replaysPath); + void setPicsPath(const QString &_picsPath); + void setCustomPicsPath(const QString &_customPicsPath); + void setThemesPath(const QString &_themesPath); + void setCardDatabasePath(const QString &_cardDatabasePath); + void setCustomCardDatabasePath(const QString &_customCardDatabasePath); + void setTokenDatabasePath(const QString &_tokenDatabasePath); + void setSpoilerDatabasePath(const QString &_spoilerDatabasePath); + +signals: + void cardDatabasePathChanged(); + void picsPathChanged(); + void themeChanged(); + +private: + explicit PathsSettings(const QString &settingPath, QObject *parent = nullptr); + PathsSettings(const PathsSettings & /*other*/); +}; + +#endif // PATHS_SETTINGS_H diff --git a/libcockatrice_settings/libcockatrice/settings/personal_settings.cpp b/libcockatrice_settings/libcockatrice/settings/personal_settings.cpp new file mode 100644 index 000000000..d1cb74d09 --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/personal_settings.cpp @@ -0,0 +1,46 @@ +#include "personal_settings.h" + +PersonalSettings::PersonalSettings(const QString &settingPath, QObject *parent) + : SettingsManager(settingPath + "personal.ini", "personal", QString(), parent) +{ +} + +QString PersonalSettings::getLang() const +{ + return getValue("lang", QString(), QString(), QString()).toString(); +} + +bool PersonalSettings::getShowTipsOnStartup() const +{ + return getValue("showTips", "tipOfDay", QString(), true).toBool(); +} + +QList PersonalSettings::getSeenTips() const +{ + QList tips; + auto tipValues = getValue("seenTips", "tipOfDay", QString()).toList(); + for (const auto &tipNumber : tipValues) { + tips.append(tipNumber.toInt()); + } + return tips; +} + +void PersonalSettings::setLang(const QString &_lang) +{ + setValue(_lang, "lang"); + emit langChanged(); +} + +void PersonalSettings::setShowTipsOnStartup(bool _showTipsOnStartup) +{ + setValue(_showTipsOnStartup, "showTips", "tipOfDay"); +} + +void PersonalSettings::setSeenTips(const QList &_seenTips) +{ + QList storedTipList; + for (auto tipNumber : _seenTips) { + storedTipList.append(tipNumber); + } + setValue(QVariant::fromValue(storedTipList), "seenTips", "tipOfDay"); +} diff --git a/libcockatrice_settings/libcockatrice/settings/personal_settings.h b/libcockatrice_settings/libcockatrice/settings/personal_settings.h new file mode 100644 index 000000000..04eab2a8a --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/personal_settings.h @@ -0,0 +1,34 @@ +#ifndef PERSONAL_SETTINGS_H +#define PERSONAL_SETTINGS_H + +#include "settings_manager.h" + +#include +#include +#include + +class PersonalSettings : public SettingsManager, public IPersonalSettingsProvider +{ + Q_OBJECT + friend class SettingsCache; + +public: + [[nodiscard]] QString getLang() const override; + [[nodiscard]] bool getShowTipsOnStartup() const override; + [[nodiscard]] QList getSeenTips() const override; + + void setLang(const QString &_lang); + void setShowTipsOnStartup(bool _showTipsOnStartup); + void setSeenTips(const QList &_seenTips); + +signals: + void langChanged(); + +public: + explicit PersonalSettings(const QString &settingPath, QObject *parent = nullptr); + +private: + PersonalSettings(const PersonalSettings & /*other*/); +}; + +#endif // PERSONAL_SETTINGS_H diff --git a/libcockatrice_settings/libcockatrice/settings/recents_settings.cpp b/libcockatrice_settings/libcockatrice/settings/recents_settings.cpp index 76bc4069e..5f16af179 100644 --- a/libcockatrice_settings/libcockatrice/settings/recents_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/recents_settings.cpp @@ -9,16 +9,16 @@ RecentsSettings::RecentsSettings(const QString &settingPath, QObject *parent) QStringList RecentsSettings::getRecentlyOpenedDeckPaths() const { - return getValue("deckpaths").toStringList(); + return getValue("deckPaths").toStringList(); } void RecentsSettings::clearRecentlyOpenedDeckPaths() { - deleteValue("deckpaths"); + deleteValue("deckPaths"); emit recentlyOpenedDeckPathsChanged(); } void RecentsSettings::updateRecentlyOpenedDeckPaths(const QString &deckPath) { - auto deckPaths = getValue("deckpaths").toStringList(); + auto deckPaths = getValue("deckPaths").toStringList(); deckPaths.removeAll(deckPath); deckPaths.prepend(deckPath); @@ -27,7 +27,7 @@ void RecentsSettings::updateRecentlyOpenedDeckPaths(const QString &deckPath) deckPaths.removeLast(); } - setValue(deckPaths, "deckpaths"); + setValue(deckPaths, "deckPaths"); emit recentlyOpenedDeckPathsChanged(); } diff --git a/libcockatrice_settings/libcockatrice/settings/servers_settings.cpp b/libcockatrice_settings/libcockatrice/settings/servers_settings.cpp index d9b98e036..811b0c842 100644 --- a/libcockatrice_settings/libcockatrice/settings/servers_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/servers_settings.cpp @@ -10,28 +10,28 @@ ServersSettings::ServersSettings(const QString &settingPath, QObject *parent) void ServersSettings::setPreviousHostLogin(int previous) { - setValue(previous, "previoushostlogin"); + setValue(previous, "previousHostLogin"); } int ServersSettings::getPreviousHostLogin() const { - QVariant previous = getValue("previoushostlogin"); + QVariant previous = getValue("previousHostLogin"); return previous == QVariant() ? 1 : previous.toInt(); } void ServersSettings::setPreviousHostList(QStringList list) { - setValue(list, "previoushosts"); + setValue(list, "previousHosts"); } QStringList ServersSettings::getPreviousHostList() const { - return getValue("previoushosts").toStringList(); + return getValue("previousHosts").toStringList(); } void ServersSettings::setPrevioushostName(const QString &name) { - setValue(name, "previoushostName"); + setValue(name, "previousHostName"); } QString ServersSettings::getSaveName(QString defaultname) @@ -50,7 +50,7 @@ QString ServersSettings::getSite(QString defaultSite) QString ServersSettings::getPrevioushostName() const { - QVariant value = getValue("previoushostName"); + QVariant value = getValue("previousHostName"); return value == QVariant() ? "Rooster Ranges" : value.toString(); } @@ -110,56 +110,56 @@ bool ServersSettings::getSavePassword() const void ServersSettings::setAutoConnect(int autoconnect) { - setValue(autoconnect, "auto_connect"); + setValue(autoconnect, "autoConnect"); } int ServersSettings::getAutoConnect() const { - QVariant autoconnect = getValue("auto_connect"); + QVariant autoconnect = getValue("autoConnect"); return autoconnect == QVariant() ? 0 : autoconnect.toInt(); } void ServersSettings::setFPHostName(QString hostname) { - setValue(hostname, "fphostname"); + setValue(hostname, "fpHostName"); } QString ServersSettings::getFPHostname(QString defaultHost) const { - QVariant hostname = getValue("fphostname"); + QVariant hostname = getValue("fpHostName"); return hostname == QVariant() ? std::move(defaultHost) : hostname.toString(); } void ServersSettings::setFPPort(QString port) { - setValue(port, "fpport"); + setValue(port, "fpPort"); } QString ServersSettings::getFPPort(QString defaultPort) const { - QVariant port = getValue("fpport"); + QVariant port = getValue("fpPort"); return port == QVariant() ? std::move(defaultPort) : port.toString(); } void ServersSettings::setFPPlayerName(QString playerName) { - setValue(playerName, "fpplayername"); + setValue(playerName, "fpPlayerName"); } QString ServersSettings::getFPPlayerName(QString defaultName) const { - QVariant name = getValue("fpplayername"); + QVariant name = getValue("fpPlayerName"); return name == QVariant() ? std::move(defaultName) : name.toString(); } void ServersSettings::setClearDebugLogStatus(bool abIsChecked) { - setValue(abIsChecked, "save_debug_log"); + setValue(abIsChecked, "saveDebugLog"); } bool ServersSettings::getClearDebugLogStatus(bool abDefaultValue) const { - QVariant cbFlushLog = getValue("save_debug_log"); + QVariant cbFlushLog = getValue("saveDebugLog"); return cbFlushLog == QVariant() ? abDefaultValue : cbFlushLog.toBool(); } @@ -293,3 +293,48 @@ bool ServersSettings::updateExistingServer(QString saveName, } return false; } + +int ServersSettings::findServerIndex(const QString &host, const QString &port) const +{ + int size = getValue("totalServers", "server", "server_details").toInt(); + + for (int i = 0; i <= size; ++i) { + QString storedHost = getValue(QString("server%1").arg(i), "server", "server_details").toString(); + QString storedPort = getValue(QString("port%1").arg(i), "server", "server_details").toString(); + + if (storedHost == host && storedPort == port) { + return i; + } + } + + return -1; +} + +bool ServersSettings::hasUsername(const QString &host, const QString &port) const +{ + int index = findServerIndex(host, port); + if (index < 0) { + return false; + } + + QString user = getValue(QString("username%1").arg(index), "server", "server_details").toString(); + return !user.isEmpty(); +} + +bool ServersSettings::hasCredentials(const QString &host, const QString &port) const +{ + int index = findServerIndex(host, port); + if (index < 0) { + return false; + } + + bool save = getValue(QString("savePassword%1").arg(index), "server", "server_details").toBool(); + QString password = getValue(QString("password%1").arg(index), "server", "server_details").toString(); + + return save && !password.isEmpty(); +} + +bool ServersSettings::hasLoginData(const QString &host, const QString &port) const +{ + return hasUsername(host, port) && hasCredentials(host, port); +} diff --git a/libcockatrice_settings/libcockatrice/settings/servers_settings.h b/libcockatrice_settings/libcockatrice/settings/servers_settings.h index 40fa996fb..f9803a158 100644 --- a/libcockatrice_settings/libcockatrice/settings/servers_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/servers_settings.h @@ -61,6 +61,10 @@ public: QString password, bool savePassword, QString site = QString()); + int findServerIndex(const QString &host, const QString &port) const; + bool hasUsername(const QString &host, const QString &port) const; + bool hasCredentials(const QString &host, const QString &port) const; + bool hasLoginData(const QString &host, const QString &port) const; bool updateExistingServerWithoutLoss(QString saveName, QString serv = QString(), diff --git a/libcockatrice_settings/libcockatrice/settings/settings_manager.cpp b/libcockatrice_settings/libcockatrice/settings/settings_manager.cpp index b66edd630..f98778ca0 100644 --- a/libcockatrice_settings/libcockatrice/settings/settings_manager.cpp +++ b/libcockatrice_settings/libcockatrice/settings/settings_manager.cpp @@ -138,21 +138,55 @@ QVariant SettingsManager::getValue(const QString &name, const QString &group, co { auto settings = getSettings(); - if (!group.isEmpty()) { - settings.beginGroup(group); + QString effectiveGroup = group.isEmpty() ? defaultGroup : group; + QString effectiveSubGroup = subGroup.isEmpty() ? defaultSubGroup : subGroup; + + if (!effectiveGroup.isEmpty()) { + settings.beginGroup(effectiveGroup); } - if (!subGroup.isEmpty()) { - settings.beginGroup(subGroup); + if (!effectiveSubGroup.isEmpty()) { + settings.beginGroup(effectiveSubGroup); } QVariant value = settings.value(name); - if (!subGroup.isEmpty()) { + if (!effectiveSubGroup.isEmpty()) { settings.endGroup(); } - if (!group.isEmpty()) { + if (!effectiveGroup.isEmpty()) { + settings.endGroup(); + } + + return value; +} + +QVariant SettingsManager::getValue(const QString &name, + const QString &group, + const QString &subGroup, + const QVariant &defaultValue) const +{ + auto settings = getSettings(); + + QString effectiveGroup = group.isEmpty() ? defaultGroup : group; + QString effectiveSubGroup = subGroup.isEmpty() ? defaultSubGroup : subGroup; + + if (!effectiveGroup.isEmpty()) { + settings.beginGroup(effectiveGroup); + } + + if (!effectiveSubGroup.isEmpty()) { + settings.beginGroup(effectiveSubGroup); + } + + QVariant value = settings.value(name, defaultValue); + + if (!effectiveSubGroup.isEmpty()) { + settings.endGroup(); + } + + if (!effectiveGroup.isEmpty()) { settings.endGroup(); } diff --git a/libcockatrice_settings/libcockatrice/settings/settings_manager.h b/libcockatrice_settings/libcockatrice/settings/settings_manager.h index 4213cf4c1..3d3c95d3e 100644 --- a/libcockatrice_settings/libcockatrice/settings/settings_manager.h +++ b/libcockatrice_settings/libcockatrice/settings/settings_manager.h @@ -23,6 +23,8 @@ public: QVariant getValue(const QString &name) const; QVariant getValue(const QString &name, const QString &group, const QString &subGroup = QString()) const; + QVariant + getValue(const QString &name, const QString &group, const QString &subGroup, const QVariant &defaultValue) const; void batchWrite(std::function batchWriteFunction); void sync(); diff --git a/libcockatrice_settings/libcockatrice/settings/settings_migration.cpp b/libcockatrice_settings/libcockatrice/settings/settings_migration.cpp new file mode 100644 index 000000000..f6e107dad --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/settings_migration.cpp @@ -0,0 +1,702 @@ +#include "settings_migration.h" + +#include +#include +#include +#include + +static const QString MIGRATION_SENTINEL_KEY = QStringLiteral("migration/perfile_complete"); + +static void migrateTabsSettings(const QString &settingsPath, QSettings &globalIni) +{ + if (!globalIni.contains("tabs/visualDeckStorage") && !globalIni.contains("tabs/server") && + !globalIni.contains("tabs/account") && !globalIni.contains("tabs/deckStorage") && + !globalIni.contains("tabs/replays") && !globalIni.contains("tabs/admin") && !globalIni.contains("tabs/log")) { + return; + } + + QSettings tabsIni(settingsPath + "tabs.ini", QSettings::IniFormat); + tabsIni.setValue("tabs/visualDeckStorage", globalIni.value("tabs/visualDeckStorage", true)); + tabsIni.setValue("tabs/server", globalIni.value("tabs/server", true)); + tabsIni.setValue("tabs/account", globalIni.value("tabs/account", true)); + tabsIni.setValue("tabs/deckStorage", globalIni.value("tabs/deckStorage", true)); + tabsIni.setValue("tabs/replays", globalIni.value("tabs/replays", true)); + tabsIni.setValue("tabs/admin", globalIni.value("tabs/admin", true)); + tabsIni.setValue("tabs/log", globalIni.value("tabs/log", true)); +} + +static void migrateSoundSettings(const QString &settingsPath, QSettings &globalIni) +{ + if (!globalIni.contains("sound/enabled") && !globalIni.contains("sound/theme") && + !globalIni.contains("sound/mastervolume")) { + return; + } + + QSettings soundIni(settingsPath + "sound.ini", QSettings::IniFormat); + soundIni.setValue("sound/enabled", globalIni.value("sound/enabled", false)); + soundIni.setValue("sound/theme", globalIni.value("sound/theme")); + soundIni.setValue("sound/masterVolume", globalIni.value("sound/mastervolume", 100)); +} + +static void migrateGameSettings(const QString &settingsPath, QSettings &globalIni) +{ + const QMap gameKeyMap = { + {"game/maxplayers", "game/maxPlayers"}, + {"game/gamedescription", "game/gameDescription"}, + {"game/gametypes", "game/gameTypes"}, + {"game/onlybuddies", "game/onlyBuddies"}, + {"game/onlyregistered", "game/onlyRegistered"}, + {"game/spectatorsallowed", "game/spectatorsAllowed"}, + {"game/spectatorsneedpassword", "game/spectatorsNeedPassword"}, + {"game/spectatorscantalk", "game/spectatorsCanTalk"}, + {"game/spectatorscanseeeverything", "game/spectatorsCanSeeEverything"}, + {"game/creategameasspectator", "game/createGameAsSpectator"}, + {"game/defaultstartinglifetotal", "game/defaultStartingLifeTotal"}, + {"game/sharedecklistsonload", "game/shareDecklistsOnLoad"}, + {"game/remembergamesettings", "game/rememberGameSettings"}, + {"localgameoptions/maxplayers", "localgameoptions/maxPlayers"}, + {"localgameoptions/startinglifetotal", "localgameoptions/startingLifeTotal"}, + {"localgameoptions/remembersettings", "localgameoptions/rememberSettings"}, + }; + + bool hasAny = false; + for (auto it = gameKeyMap.constBegin(); it != gameKeyMap.constEnd(); ++it) { + if (globalIni.contains(it.key())) { + hasAny = true; + break; + } + } + if (!hasAny) { + return; + } + + QSettings gameIni(settingsPath + "game.ini", QSettings::IniFormat); + for (auto it = gameKeyMap.constBegin(); it != gameKeyMap.constEnd(); ++it) { + if (globalIni.contains(it.key())) { + gameIni.setValue(it.value(), globalIni.value(it.key())); + } + } +} + +static void migrateChatSettings(const QString &settingsPath, QSettings &globalIni) +{ + const QMap chatKeyMap = { + {"chat/mention", "chat/mention"}, + {"chat/mentioncompleter", "chat/mentionCompleter"}, + {"chat/mentioncolor", "chat/mentionColor"}, + {"chat/highlightcolor", "chat/highlightColor"}, + {"chat/mentionforeground", "chat/mentionForeground"}, + {"chat/highlightforeground", "chat/highlightForeground"}, + {"chat/ignore_unregistered", "chat/ignoreUnregistered"}, + {"chat/ignore_unregistered_messages", "chat/ignoreUnregisteredMessages"}, + {"chat/ignore_nonbuddy_messages", "chat/ignoreNonBuddyMessages"}, + {"chat/showmessagepopups", "chat/showMessagePopups"}, + {"chat/showmentionpopups", "chat/showMentionPopups"}, + {"chat/roomhistory", "chat/roomHistory"}, + {"chat/highlightwords", "chat/highlightWords"}, + // Legacy highlight words lived under [personal], but the chat settings + // class reads them from [chat] + {"personal/highlightWords", "chat/highlightWords"}, + }; + bool hasAny = false; + for (auto it = chatKeyMap.constBegin(); it != chatKeyMap.constEnd(); ++it) { + if (globalIni.contains(it.key())) { + hasAny = true; + break; + } + } + if (!hasAny) { + return; + } + + QSettings chatIni(settingsPath + "chat.ini", QSettings::IniFormat); + for (auto it = chatKeyMap.constBegin(); it != chatKeyMap.constEnd(); ++it) { + if (globalIni.contains(it.key())) { + chatIni.setValue(it.value(), globalIni.value(it.key())); + } + } +} + +static void migrateCacheStorageSettings(const QString &settingsPath, QSettings &globalIni) +{ + const QMap cacheStorageKeyMap = { + {"personal/pixmapCacheSize", "cache_storage/pixmapCacheSize"}, + {"personal/networkCacheSize", "cache_storage/networkCacheSize"}, + {"personal/redirectCacheTtl", "cache_storage/redirectCacheTtl"}, + {"personal/cardPictureLoaderCacheMethod", "cache_storage/cardPictureLoaderCacheMethod"}, + {"personal/localCardImageStorageNamingScheme", "cache_storage/localCardImageStorageNamingScheme"}, + }; + bool hasAny = false; + for (auto it = cacheStorageKeyMap.constBegin(); it != cacheStorageKeyMap.constEnd(); ++it) { + if (globalIni.contains(it.key())) { + hasAny = true; + break; + } + } + if (!hasAny) { + return; + } + + QSettings cacheStorageIni(settingsPath + "cache_storage.ini", QSettings::IniFormat); + for (auto it = cacheStorageKeyMap.constBegin(); it != cacheStorageKeyMap.constEnd(); ++it) { + if (globalIni.contains(it.key())) { + cacheStorageIni.setValue(it.value(), globalIni.value(it.key())); + } + } +} + +static void migrateUpdatesSettings(const QString &settingsPath, QSettings &globalIni) +{ + const QMap updateKeyMap = { + {"personal/startupUpdateCheck", "updates/startupUpdateCheck"}, + {"personal/startupCardUpdateCheckPromptForUpdate", "updates/startupCardUpdateCheckPromptForUpdate"}, + {"personal/startupCardUpdateCheckAlwaysUpdate", "updates/startupCardUpdateCheckAlwaysUpdate"}, + {"personal/cardUpdateCheckInterval", "updates/cardUpdateCheckInterval"}, + {"personal/lastCardUpdateCheck", "updates/lastCardUpdateCheck"}, + {"personal/alwaysEnableNewSets", "updates/alwaysEnableNewSets"}, + {"personal/updatenotification", "updates/updateNotification"}, + {"personal/newversionnotification", "updates/newVersionNotification"}, + {"personal/updatereleasechannel", "updates/updateReleaseChannel"}, + }; + bool hasAny = false; + for (auto it = updateKeyMap.constBegin(); it != updateKeyMap.constEnd(); ++it) { + if (globalIni.contains(it.key())) { + hasAny = true; + break; + } + } + if (!hasAny) { + return; + } + + QSettings updatesIni(settingsPath + "updates.ini", QSettings::IniFormat); + for (auto it = updateKeyMap.constBegin(); it != updateKeyMap.constEnd(); ++it) { + if (globalIni.contains(it.key())) { + updatesIni.setValue(it.value(), globalIni.value(it.key())); + } + } +} + +static void migratePersonalSettings(const QString &settingsPath, QSettings &globalIni) +{ + const QStringList personalRootKeys = {"personal/lang"}; + const QStringList tipKeys = {"tipOfDay/showTips", "tipOfDay/seenTips"}; + + bool hasAny = false; + for (const auto &key : personalRootKeys) { + if (globalIni.contains(key)) { + hasAny = true; + } + } + for (const auto &key : tipKeys) { + if (globalIni.contains(key)) { + hasAny = true; + } + } + if (!hasAny) { + return; + } + + QSettings personalIni(settingsPath + "personal.ini", QSettings::IniFormat); + for (const auto &key : personalRootKeys) { + if (globalIni.contains(key)) { + personalIni.setValue(key, globalIni.value(key)); + } + } + for (const auto &key : tipKeys) { + if (globalIni.contains(key)) { + personalIni.setValue(key, globalIni.value(key)); + } + } +} + +static void migrateCardsDisplaySettings(const QString &settingsPath, QSettings &globalIni) +{ + const QMap cardsKeyMap = { + {"cards/displaycardnames", "cards/displayCardNames"}, + {"cards/roundcardcorners", "cards/roundCardCorners"}, + {"cards/overrideallcardartwithpersonalpreference", "cards/overrideAllCardArtWithPersonalPreference"}, + {"cards/bumpsetswithcardsindecktotop", "cards/bumpSetsWithCardsInDeckToTop"}, + {"cards/includerebalancedcards", "cards/includerebalancedcards"}, + {"cards/tapanimation", "cards/tapAnimation"}, + {"cards/autorotatesidewayslayoutcards", "cards/autoRotateSidewaysLayoutCards"}, + {"cards/scaleCards", "cards/scaleCards"}, + {"cards/verticalCardOverlapPercent", "cards/verticalCardOverlapPercent"}, + {"cards/cardinfoviewmode", "cards/cardInfoViewMode"}, + {"cards/printingselectorsortorder", "cards/printingSelector/sortOrder"}, + {"cards/printingselectornavigationbuttonsvisible", "cards/printingSelector/navigationButtonsVisible"}, + {"cards/printingselectorcardsize", "cards/cardSize/printingSelector"}, + {"interface/visualdeckstoragecardsize", "cards/cardSize/visualDeckStorage"}, + {"interface/visualdatabasedisplaycardsize", "cards/cardSize/visualDatabaseDisplay"}, + {"interface/visualdeckeditorcardsize", "cards/cardSize/visualDeckEditor"}, + {"interface/edhreccardsize", "cards/cardSize/edhrec"}, + {"interface/archidektpreviewsize", "cards/cardSize/archidektPreview"}, + {"interface/visualdeckeditorsamplehandsize", "cards/cardSize/sampleHandSize"}, + }; + + bool hasAny = false; + for (auto it = cardsKeyMap.constBegin(); it != cardsKeyMap.constEnd(); ++it) { + if (globalIni.contains(it.key())) { + hasAny = true; + break; + } + } + if (!hasAny) { + return; + } + + QSettings cardsIni(settingsPath + "cards_display.ini", QSettings::IniFormat); + for (auto it = cardsKeyMap.constBegin(); it != cardsKeyMap.constEnd(); ++it) { + if (globalIni.contains(it.key())) { + cardsIni.setValue(it.value(), globalIni.value(it.key())); + } + } +} + +static void migrateCardCounterSettings(const QString &settingsPath, QSettings &globalIni) +{ + QStringList counterKeys; + const QStringList allKeys = globalIni.allKeys(); + for (const auto &key : allKeys) { + if (key.startsWith("cards/counters/")) { + counterKeys.append(key); + } + } + if (counterKeys.isEmpty()) { + return; + } + + QSettings countersIni(settingsPath + "card_counters.ini", QSettings::IniFormat); + for (const auto &key : counterKeys) { + countersIni.setValue(key, globalIni.value(key)); + } +} + +static void migrateInterfaceSettings(const QString &settingsPath, QSettings &globalIni) +{ + const QMap interfaceKeyMap = { + {"interface/usetearoffmenus", "interface/useTearOffMenus"}, + {"interface/cardViewInitialRowsMax", "interface/cardViewInitialRowsMax"}, + {"interface/cardViewExpandedRowsMax", "interface/cardViewExpandedRowsMax"}, + {"interface/closeEmptyCardView", "interface/closeEmptyCardView"}, + {"interface/focusCardViewSearchBar", "interface/focusCardViewSearchBar"}, + {"interface/keepGameChatFocus", "interface/keepGameChatFocus"}, + {"interface/doubleclicktoplay", "interface/doubleClickToPlay"}, + {"interface/clickPlaysAllSelected", "interface/clickPlaysAllSelected"}, + {"interface/playtostack", "interface/playToStack"}, + {"interface/doNotDeleteArrowsInSubPhases", "interface/doNotDeleteArrowsInSubPhases"}, + {"interface/startinghandsize", "interface/startingHandSize"}, + {"interface/annotatetokens", "interface/annotateTokens"}, + {"interface/showlassoselectioncount", "interface/showLassoSelectionCount"}, + {"interface/showpersistentselectioncount", "interface/showPersistentSelectionCount"}, + {"interface/tallyType", "interface/tallyType"}, + {"interface/leftjustified", "interface/leftJustified"}, + {"interface/min_players_multicolumn", "interface/minPlayersMulticolumn"}, + {"hand/horizontal", "hand/horizontal"}, + {"table/invert_vertical", "table/invertVertical"}, + {"replay/rewindBufferingMs", "replay/rewindBufferingMs"}, + {"replay/fastForwardSpeed", "replay/fastForwardSpeed"}, + {"zoneview/groupby", "zoneview/groupBy"}, + {"zoneview/sortby", "zoneview/sortBy"}, + {"zoneview/pileview", "zoneview/pileView"}, + {"personal/showStatusBar", "interface/showStatusBar"}, + {"menu/showshortcuts", "interface/showShortcuts"}, + {"menu/showgameselectorfiltertoolbar", "interface/showGameSelectorFilterToolbar"}, + {"interface/notificationsenabled", "interface/notifications/enabled"}, + {"interface/specnotificationsenabled", "interface/notifications/spectatorsEnabled"}, + {"interface/buddyconnectnotificationsenabled", "interface/notifications/buddyConnectEnabled"}, + }; + bool hasAny = false; + for (auto it = interfaceKeyMap.constBegin(); it != interfaceKeyMap.constEnd(); ++it) { + if (globalIni.contains(it.key())) { + hasAny = true; + break; + } + } + if (!hasAny) { + return; + } + + QSettings interfaceIni(settingsPath + "interface.ini", QSettings::IniFormat); + for (auto it = interfaceKeyMap.constBegin(); it != interfaceKeyMap.constEnd(); ++it) { + if (globalIni.contains(it.key())) { + interfaceIni.setValue(it.value(), globalIni.value(it.key())); + } + } +} + +static void migrateDownloadSettings(const QString &settingsPath, QSettings &globalIni) +{ + const QMap downloadKeyMap = { + {"personal/picturedownload", "downloads/pictureDownload"}, + {"personal/downloadspoilers", "downloads/downloadSpoilers"}, + }; + bool hasAny = false; + for (auto it = downloadKeyMap.constBegin(); it != downloadKeyMap.constEnd(); ++it) { + if (globalIni.contains(it.key())) { + hasAny = true; + break; + } + } + if (!hasAny) { + return; + } + + QSettings downloadsIni(settingsPath + "downloads.ini", QSettings::IniFormat); + for (auto it = downloadKeyMap.constBegin(); it != downloadKeyMap.constEnd(); ++it) { + if (globalIni.contains(it.key())) { + downloadsIni.setValue(it.value(), globalIni.value(it.key())); + } + } +} + +static void migrateAppearanceSettings(const QString &settingsPath, QSettings &globalIni) +{ + const QMap appearanceKeyMap = { + {"theme/name", "appearance/themeName"}, + {"game/maxfontsize", "appearance/maxFontSize"}, + {"home/background", "appearance/homeTabBackgroundSource"}, + {"home/background/shuffleTimer", "appearance/homeTabBackgroundShuffleFrequency"}, + {"home/background/displayCardName", "appearance/homeTabDisplayCardName"}, + {"appearance/styleUserList", "appearance/styleUserList"}, + }; + bool hasAny = false; + for (auto it = appearanceKeyMap.constBegin(); it != appearanceKeyMap.constEnd(); ++it) { + if (globalIni.contains(it.key())) { + hasAny = true; + break; + } + } + if (!hasAny) { + return; + } + + QSettings appearanceIni(settingsPath + "appearance.ini", QSettings::IniFormat); + for (auto it = appearanceKeyMap.constBegin(); it != appearanceKeyMap.constEnd(); ++it) { + if (globalIni.contains(it.key())) { + QVariant value = globalIni.value(it.key()); + if (it.key() == "theme/name" && value.toString() == "Default") { + value = "System"; + } + appearanceIni.setValue(it.value(), value); + } + } +} + +static void migrateNetworkSettings(const QString &settingsPath, QSettings &globalIni) +{ + const QMap networkKeyMap = { + {"personal/clientid", "network/clientId"}, + {"personal/clientversion", "network/clientVersion"}, + {"personal/keepalive", "network/keepAlive"}, + {"personal/timeout", "network/timeout"}, + {"interface/knownmissingfeatures", "network/knownMissingFeatures"}, + }; + bool hasAny = false; + for (auto it = networkKeyMap.constBegin(); it != networkKeyMap.constEnd(); ++it) { + if (globalIni.contains(it.key())) { + hasAny = true; + break; + } + } + if (!hasAny) { + return; + } + + QSettings networkIni(settingsPath + "network.ini", QSettings::IniFormat); + for (auto it = networkKeyMap.constBegin(); it != networkKeyMap.constEnd(); ++it) { + if (globalIni.contains(it.key())) { + networkIni.setValue(it.value(), globalIni.value(it.key())); + } + } +} + +static void migratePathsSettings(const QString &settingsPath, QSettings &globalIni) +{ + const QMap pathsKeyMap = { + {"paths/decks", "paths/decks"}, + {"paths/filters", "paths/filters"}, + {"paths/replays", "paths/replays"}, + {"paths/pics", "paths/pics"}, + {"paths/custompics", "paths/customPics"}, + {"paths/themes", "paths/themes"}, + {"paths/carddatabase", "paths/cardDatabase"}, + {"paths/customsets", "paths/customSets"}, + {"paths/tokendatabase", "paths/tokenDatabase"}, + {"paths/spoilerdatabase", "paths/spoilerDatabase"}, + {"paths/redirects", "paths/redirects"}, + }; + bool hasAny = false; + for (auto it = pathsKeyMap.constBegin(); it != pathsKeyMap.constEnd(); ++it) { + if (globalIni.contains(it.key())) { + hasAny = true; + break; + } + } + if (!hasAny) { + return; + } + + QSettings pathsIni(settingsPath + "paths.ini", QSettings::IniFormat); + for (auto it = pathsKeyMap.constBegin(); it != pathsKeyMap.constEnd(); ++it) { + if (globalIni.contains(it.key())) { + pathsIni.setValue(it.value(), globalIni.value(it.key())); + } + } +} + +static void migrateVisualDeckStorageSettings(const QString &settingsPath, QSettings &globalIni) +{ + const QMap vdsKeyMap = { + {"interface/visualdeckstoragesortingorder", "interface/visualDeckStorage/sortingOrder"}, + {"interface/visualdeckstorageshowfolders", "interface/visualDeckStorage/showFolders"}, + {"interface/visualdeckstorageshowtagfilter", "interface/visualDeckStorage/showTagFilter"}, + {"interface/visualdeckstoragedefaulttagslist", "interface/visualDeckStorage/defaultTagsList"}, + {"interface/visualdeckstoragesearchfoldernames", "interface/visualDeckStorage/searchFolderNames"}, + {"interface/visualdeckstorageshowcoloridentity", "interface/visualDeckStorage/showColorIdentity"}, + {"interface/visualdeckstorageshowbannercardcombobox", "interface/visualDeckStorage/showBannerCardComboBox"}, + {"interface/visualdeckstorageshowtagsondeckpreviews", "interface/visualDeckStorage/showTagsOnDeckPreviews"}, + {"interface/visualdeckstoragedrawunusedcoloridentities", + "interface/visualDeckStorage/drawUnusedColorIdentities"}, + {"interface/visualdeckstorageunusedcoloridentitiesopacity", + "interface/visualDeckStorage/unusedColorIdentitiesOpacity"}, + {"interface/visualdeckstoragetooltiptype", "interface/visualDeckStorage/tooltipType"}, + {"interface/visualdeckstoragepromptforconversion", "interface/visualDeckStorage/promptForConversion"}, + {"interface/visualdeckstoragealwaysconvert", "interface/visualDeckStorage/alwaysConvert"}, + {"interface/visualdeckstorageingame", "interface/visualDeckStorage/inGame"}, + {"interface/visualdeckstorageselectionanimation", "interface/visualDeckStorage/selectionAnimation"}, + {"interface/visualdatabasedisplayfiltertomostrecentsetsenabled", + "interface/visualDatabaseDisplay/filterToMostRecentSetsEnabled"}, + {"interface/visualdatabasedisplayfiltertomostrecentsetsamount", + "interface/visualDatabaseDisplay/filterToMostRecentSetsAmount"}, + }; + bool hasAny = false; + for (auto it = vdsKeyMap.constBegin(); it != vdsKeyMap.constEnd(); ++it) { + if (globalIni.contains(it.key())) { + hasAny = true; + break; + } + } + if (!hasAny) { + return; + } + + QSettings vdsIni(settingsPath + "visual_deck_storage.ini", QSettings::IniFormat); + for (auto it = vdsKeyMap.constBegin(); it != vdsKeyMap.constEnd(); ++it) { + if (globalIni.contains(it.key())) { + vdsIni.setValue(it.value(), globalIni.value(it.key())); + } + } +} + +static void migrateDeckEditorSettings(const QString &settingsPath, QSettings &globalIni) +{ + const QMap deckEditorKeyMap = { + {"editor/openDeckInNewTab", "deckeditor/openDeckInNewTab"}, + {"interface/deckeditorbannercardcomboboxvisible", "deckeditor/bannerCardComboBoxVisible"}, + {"interface/deckeditortagswidgetvisible", "deckeditor/tagsWidgetVisible"}, + {"interface/defaultDeckEditorType", "deckeditor/defaultDeckEditorType"}, + }; + bool hasAny = false; + for (auto it = deckEditorKeyMap.constBegin(); it != deckEditorKeyMap.constEnd(); ++it) { + if (globalIni.contains(it.key())) { + hasAny = true; + break; + } + } + if (!hasAny) { + return; + } + + QSettings deckEditorIni(settingsPath + "deck_editor.ini", QSettings::IniFormat); + for (auto it = deckEditorKeyMap.constBegin(); it != deckEditorKeyMap.constEnd(); ++it) { + if (globalIni.contains(it.key())) { + deckEditorIni.setValue(it.value(), globalIni.value(it.key())); + } + } +} + +static void migrateLegacySets(const QString &settingsPath) +{ + QSettings legacySetting; + legacySetting.beginGroup("sets"); + QStringList groups = legacySetting.childGroups(); + if (groups.isEmpty()) { + legacySetting.endGroup(); + return; + } + + QSettings cardDbIni(settingsPath + "cardDatabase.ini", QSettings::IniFormat); + for (const auto &shortName : groups) { + legacySetting.beginGroup(shortName); + cardDbIni.setValue("sets/" + shortName + "/sortKey", legacySetting.value("sortkey")); + cardDbIni.setValue("sets/" + shortName + "/enabled", legacySetting.value("enabled")); + cardDbIni.setValue("sets/" + shortName + "/isKnown", legacySetting.value("isknown")); + legacySetting.endGroup(); + } + legacySetting.endGroup(); +} + +static void migrateLegacyServers(const QString &settingsPath) +{ + QSettings legacySetting; + legacySetting.beginGroup("server"); + QStringList keys = legacySetting.allKeys(); + if (keys.isEmpty()) { + legacySetting.endGroup(); + return; + } + + const QMap serverKeyMap = { + {"previoushostlogin", "previousHostLogin"}, + {"previoushosts", "previousHosts"}, + {"previoushostName", "previousHostName"}, + {"auto_connect", "autoConnect"}, + {"fphostname", "fpHostName"}, + {"fpport", "fpPort"}, + {"fpplayername", "fpPlayerName"}, + {"save_debug_log", "saveDebugLog"}, + }; + + QSettings serversIni(settingsPath + "servers.ini", QSettings::IniFormat); + for (auto it = serverKeyMap.constBegin(); it != serverKeyMap.constEnd(); ++it) { + if (legacySetting.contains(it.key())) { + serversIni.setValue("server/" + it.value(), legacySetting.value(it.key())); + } + } + + legacySetting.beginGroup("server_details"); + const QStringList detailsKeys = legacySetting.allKeys(); + for (const auto &key : detailsKeys) { + serversIni.setValue("server/server_details/" + key, legacySetting.value(key)); + } + legacySetting.endGroup(); + legacySetting.endGroup(); +} + +static void migrateLegacyMessages(const QString &settingsPath) +{ + QSettings legacySetting; + legacySetting.beginGroup("messages"); + QStringList keys = legacySetting.allKeys(); + if (keys.isEmpty()) { + legacySetting.endGroup(); + return; + } + + QSettings messageIni(settingsPath + "messages.ini", QSettings::IniFormat); + for (const auto &key : keys) { + if (key == "count") { + messageIni.setValue("messages/count", legacySetting.value("count")); + } else { + messageIni.setValue("messages/" + key, legacySetting.value(key)); + } + } + legacySetting.endGroup(); +} + +static void migrateLegacyGameFilters(const QString &settingsPath) +{ + QSettings legacySetting; + legacySetting.beginGroup("filter_games"); + QStringList keys = legacySetting.allKeys(); + if (keys.isEmpty()) { + legacySetting.endGroup(); + return; + } + + const QMap filterKeyMap = { + {"hide_buddies_only_games", "hideBuddiesOnlyGames"}, + {"hide_full_games", "hideFullGames"}, + {"hide_games_that_started", "hideGamesThatStarted"}, + {"hide_password_protected_games", "hidePasswordProtectedGames"}, + {"hide_ignored_user_games", "hideIgnoredUserGames"}, + {"hide_not_buddy_created_games", "hideNotBuddyCreatedGames"}, + {"hide_open_decklist_games", "hideOpenDecklistGames"}, + {"game_name_filter", "gameNameFilter"}, + {"creator_name_filter", "creatorNameFilter"}, + {"min_players", "minPlayers"}, + {"max_players", "maxPlayers"}, + {"max_game_age_time", "maxGameAgeTime"}, + {"show_only_if_spectators_can_watch", "showOnlyIfSpectatorsCanWatch"}, + {"show_spectator_password_protected", "showSpectatorPasswordProtected"}, + {"show_only_if_spectators_can_chat", "showOnlyIfSpectatorsCanChat"}, + {"show_only_if_spectators_can_see_hands", "showOnlyIfSpectatorsCanSeeHands"}, + }; + + QSettings filtersIni(settingsPath + "gamefilters.ini", QSettings::IniFormat); + for (auto it = filterKeyMap.constBegin(); it != filterKeyMap.constEnd(); ++it) { + if (legacySetting.contains(it.key())) { + filtersIni.setValue("filter_games/" + it.value(), legacySetting.value(it.key())); + } + } + const QString gameTypePrefix = "game_type/"; + for (const auto &key : keys) { + if (key.startsWith(gameTypePrefix)) { + filtersIni.setValue("filter_games/gameType/" + key.mid(gameTypePrefix.size()), legacySetting.value(key)); + } + } + legacySetting.endGroup(); +} + +bool SettingsMigration::migrateLegacySettings(const QString &settingsPath) +{ + QSettings personalIni(settingsPath + "personal.ini", QSettings::IniFormat); + if (personalIni.value("migration/legacy_complete", false).toBool()) { + return false; + } + + migrateLegacySets(settingsPath); + migrateLegacyServers(settingsPath); + migrateLegacyMessages(settingsPath); + migrateLegacyGameFilters(settingsPath); + + personalIni.setValue("migration/legacy_complete", true); + personalIni.sync(); + return true; +} + +bool SettingsMigration::migrateSettingsFromGlobalIni(const QString &settingsPath) +{ + if (!QFile::exists(settingsPath + "global.ini")) { + return false; + } + + QSettings globalIni(settingsPath + "global.ini", QSettings::IniFormat); + if (globalIni.value(MIGRATION_SENTINEL_KEY, false).toBool()) { + // If a user runs an older Cockatrice build that writes to global.ini (non-sentinel keys) + // and then upgrades back, those new keys will be silently ignored. Re-migrate them. + globalIni.sync(); + auto allKeys = globalIni.allKeys(); + allKeys.removeAll(MIGRATION_SENTINEL_KEY); + if (allKeys.isEmpty()) { + return false; + } + } + + migrateTabsSettings(settingsPath, globalIni); + migrateSoundSettings(settingsPath, globalIni); + migrateGameSettings(settingsPath, globalIni); + migrateChatSettings(settingsPath, globalIni); + migrateCacheStorageSettings(settingsPath, globalIni); + migrateUpdatesSettings(settingsPath, globalIni); + migratePersonalSettings(settingsPath, globalIni); + migrateDownloadSettings(settingsPath, globalIni); + migrateCardsDisplaySettings(settingsPath, globalIni); + migrateCardCounterSettings(settingsPath, globalIni); + migrateInterfaceSettings(settingsPath, globalIni); + migrateAppearanceSettings(settingsPath, globalIni); + migrateNetworkSettings(settingsPath, globalIni); + migratePathsSettings(settingsPath, globalIni); + migrateVisualDeckStorageSettings(settingsPath, globalIni); + migrateDeckEditorSettings(settingsPath, globalIni); + + QFile::remove(settingsPath + "global.ini.old"); + QFile::rename(settingsPath + "global.ini", settingsPath + "global.ini.old"); + + QSettings newGlobalIni(settingsPath + "global.ini", QSettings::IniFormat); + newGlobalIni.setValue(MIGRATION_SENTINEL_KEY, true); + newGlobalIni.sync(); + + return true; +} diff --git a/libcockatrice_settings/libcockatrice/settings/settings_migration.h b/libcockatrice_settings/libcockatrice/settings/settings_migration.h new file mode 100644 index 000000000..f596dba75 --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/settings_migration.h @@ -0,0 +1,13 @@ +#ifndef SETTINGS_MIGRATION_H +#define SETTINGS_MIGRATION_H + +#include + +class SettingsMigration +{ +public: + static bool migrateSettingsFromGlobalIni(const QString &settingsPath); + static bool migrateLegacySettings(const QString &settingsPath); +}; + +#endif // SETTINGS_MIGRATION_H diff --git a/libcockatrice_settings/libcockatrice/settings/sound_settings.cpp b/libcockatrice_settings/libcockatrice/settings/sound_settings.cpp new file mode 100644 index 000000000..82b724a07 --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/sound_settings.cpp @@ -0,0 +1,39 @@ +#include "sound_settings.h" + +SoundSettings::SoundSettings(const QString &settingPath, QObject *parent) + : SettingsManager(settingPath + "sound.ini", "sound", QString(), parent) +{ +} + +bool SoundSettings::getSoundEnabled() const +{ + return getValue("enabled", QString(), QString(), false).toBool(); +} + +QString SoundSettings::getSoundThemeName() const +{ + return getValue("theme", QString(), QString()).toString(); +} + +int SoundSettings::getMasterVolume() const +{ + return getValue("masterVolume", QString(), QString(), 100).toInt(); +} + +void SoundSettings::setSoundEnabled(bool _soundEnabled) +{ + setValue(_soundEnabled, "enabled"); + emit soundEnabledChanged(); +} + +void SoundSettings::setSoundThemeName(const QString &_soundThemeName) +{ + setValue(_soundThemeName, "theme"); + emit soundThemeChanged(); +} + +void SoundSettings::setMasterVolume(int _masterVolume) +{ + setValue(_masterVolume, "masterVolume"); + emit masterVolumeChanged(_masterVolume); +} diff --git a/libcockatrice_settings/libcockatrice/settings/sound_settings.h b/libcockatrice_settings/libcockatrice/settings/sound_settings.h new file mode 100644 index 000000000..ffaed2a7c --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/sound_settings.h @@ -0,0 +1,34 @@ +#ifndef SOUND_SETTINGS_H +#define SOUND_SETTINGS_H + +#include "settings_manager.h" + +#include + +class SoundSettings : public SettingsManager, public ISoundSettingsProvider +{ + Q_OBJECT + friend class SettingsCache; + +public: + [[nodiscard]] bool getSoundEnabled() const override; + [[nodiscard]] QString getSoundThemeName() const override; + [[nodiscard]] int getMasterVolume() const override; + + void setSoundEnabled(bool _soundEnabled); + void setSoundThemeName(const QString &_soundThemeName); + void setMasterVolume(int _masterVolume); + +signals: + void soundEnabledChanged(); + void soundThemeChanged(); + void masterVolumeChanged(int value); + +public: + explicit SoundSettings(const QString &settingPath, QObject *parent = nullptr); + +private: + SoundSettings(const SoundSettings & /*other*/); +}; + +#endif // SOUND_SETTINGS_H diff --git a/libcockatrice_settings/libcockatrice/settings/tabs_settings.cpp b/libcockatrice_settings/libcockatrice/settings/tabs_settings.cpp new file mode 100644 index 000000000..cf5bfd81a --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/tabs_settings.cpp @@ -0,0 +1,162 @@ +#include "tabs_settings.h" + +TabsSettings::TabsSettings(const QString &settingPath, QObject *parent) + : SettingsManager(settingPath + "tabs.ini", "tabs", QString(), parent) +{ +} + +int TabsSettings::getStartupTabIndex() const +{ + return getValue("startupTab", QString(), QString(), StartupTab::StartupTabHome).toInt(); +} + +QString TabsSettings::getStartupServerHost() const +{ + return getValue("startupServerHost", QString(), QString(), QString()).toString(); +} + +QString TabsSettings::getStartupServerPort() const +{ + return getValue("startupServerPort", QString(), QString(), QString()).toString(); +} + +QString TabsSettings::getStartupRoomName() const +{ + return getValue("startupRoomName", QString(), QString(), QString()).toString(); +} + +bool TabsSettings::getTabVisualDeckStorageOpen() const +{ + return getValue("visualDeckStorage", QString(), QString(), true).toBool(); +} + +bool TabsSettings::getTabServerOpen() const +{ + return getValue("server", QString(), QString(), true).toBool(); +} + +bool TabsSettings::getTabAccountOpen() const +{ + return getValue("account", QString(), QString(), true).toBool(); +} + +bool TabsSettings::getTabDeckStorageOpen() const +{ + return getValue("deckStorage", QString(), QString(), true).toBool(); +} + +bool TabsSettings::getTabReplaysOpen() const +{ + return getValue("replays", QString(), QString(), true).toBool(); +} + +bool TabsSettings::getTabAdminOpen() const +{ + return getValue("admin", QString(), QString(), true).toBool(); +} + +bool TabsSettings::getTabLogOpen() const +{ + return getValue("log", QString(), QString(), true).toBool(); +} + +void TabsSettings::setStartupTabIndex(int value) +{ + if (getStartupTabIndex() == value) { + return; + } + setValue(value, "startupTab"); + emit startupTabIndexChanged(value); +} + +void TabsSettings::setStartupServerHost(const QString &host) +{ + if (getStartupServerHost() == host) { + return; + } + setValue(host, "startupServerHost"); + emit startupServerHostChanged(host); +} + +void TabsSettings::setStartupServerPort(const QString &port) +{ + if (getStartupServerPort() == port) { + return; + } + setValue(port, "startupServerPort"); + emit startupServerPortChanged(port); +} + +void TabsSettings::setStartupRoomName(const QString &roomName) +{ + if (getStartupRoomName() == roomName) { + return; + } + setValue(roomName, "startupRoomName"); + emit startupRoomNameChanged(roomName); +} + +bool TabsSettings::getTabReportOpen() const +{ + return getValue("report", QString(), QString(), false).toBool(); +} + +bool TabsSettings::getTabModerationOpen() const +{ + return getValue("moderation", QString(), QString(), false).toBool(); +} + +bool TabsSettings::getTabCardArtRulesOpen() const +{ + return getValue("cardArtRules", QString(), QString(), false).toBool(); +} + +void TabsSettings::setTabVisualDeckStorageOpen(bool value) +{ + setValue(value, "visualDeckStorage"); +} + +void TabsSettings::setTabServerOpen(bool value) +{ + setValue(value, "server"); +} + +void TabsSettings::setTabAccountOpen(bool value) +{ + setValue(value, "account"); +} + +void TabsSettings::setTabDeckStorageOpen(bool value) +{ + setValue(value, "deckStorage"); +} + +void TabsSettings::setTabReplaysOpen(bool value) +{ + setValue(value, "replays"); +} + +void TabsSettings::setTabAdminOpen(bool value) +{ + setValue(value, "admin"); +} + +void TabsSettings::setTabLogOpen(bool value) +{ + setValue(value, "log"); +} + +void TabsSettings::setTabReportOpen(bool value) +{ + setValue(value, "report"); +} + +void TabsSettings::setTabModerationOpen(bool value) +{ + setValue(value, "moderation"); +} + +void TabsSettings::setTabCardArtRulesOpen(bool value) +{ + setValue(value, "cardArtRules"); +} diff --git a/libcockatrice_settings/libcockatrice/settings/tabs_settings.h b/libcockatrice_settings/libcockatrice/settings/tabs_settings.h new file mode 100644 index 000000000..eb78d311b --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/tabs_settings.h @@ -0,0 +1,76 @@ +#ifndef TABS_SETTINGS_H +#define TABS_SETTINGS_H + +#include "settings_manager.h" + +#include + +/** + * @brief The tab the application selects after launch. + * + * The destination is a request: tabs that were not open before (Deck Editor, Server Room, …) + * are opened as part of the startup flow. Destinations that require a server connection use the + * intent system to satisfy their pre-conditions. + */ +enum StartupTab +{ + StartupTabHome, ///< The Home tab + StartupTabVisualDeckStorage, ///< The visual deck storage tab + StartupTabDeckStorage, ///< The deck storage tab + StartupTabReplays, ///< The game replays tab + StartupTabDeckEditor, ///< A fresh classic deck editor tab + StartupTabVisualDeckEditor, ///< A fresh visual deck editor tab + StartupTabServer, ///< The server lobby: connect and select the server tab + StartupTabServerRoom ///< A server room: connect and join the room by name +}; + +class TabsSettings : public SettingsManager, public ITabsSettingsProvider +{ + Q_OBJECT + friend class SettingsCache; + +public: + [[nodiscard]] int getStartupTabIndex() const override; + [[nodiscard]] QString getStartupServerHost() const override; + [[nodiscard]] QString getStartupServerPort() const override; + [[nodiscard]] QString getStartupRoomName() const override; + [[nodiscard]] bool getTabVisualDeckStorageOpen() const override; + [[nodiscard]] bool getTabServerOpen() const override; + [[nodiscard]] bool getTabAccountOpen() const override; + [[nodiscard]] bool getTabDeckStorageOpen() const override; + [[nodiscard]] bool getTabReplaysOpen() const override; + [[nodiscard]] bool getTabAdminOpen() const override; + [[nodiscard]] bool getTabLogOpen() const override; + [[nodiscard]] bool getTabReportOpen() const override; + [[nodiscard]] bool getTabModerationOpen() const override; + [[nodiscard]] bool getTabCardArtRulesOpen() const override; + + void setStartupTabIndex(int value); + void setStartupServerHost(const QString &host); + void setStartupServerPort(const QString &port); + void setStartupRoomName(const QString &roomName); + void setTabVisualDeckStorageOpen(bool value); + void setTabServerOpen(bool value); + void setTabAccountOpen(bool value); + void setTabDeckStorageOpen(bool value); + void setTabReplaysOpen(bool value); + void setTabAdminOpen(bool value); + void setTabLogOpen(bool value); + void setTabReportOpen(bool value); + void setTabModerationOpen(bool value); + void setTabCardArtRulesOpen(bool value); + +signals: + void startupTabIndexChanged(int index); + void startupServerHostChanged(const QString &host); + void startupServerPortChanged(const QString &port); + void startupRoomNameChanged(const QString &roomName); + +public: + explicit TabsSettings(const QString &settingPath, QObject *parent = nullptr); + +private: + TabsSettings(const TabsSettings & /*other*/); +}; + +#endif // TABS_SETTINGS_H diff --git a/libcockatrice_settings/libcockatrice/settings/updates_settings.cpp b/libcockatrice_settings/libcockatrice/settings/updates_settings.cpp new file mode 100644 index 000000000..166bc0aa8 --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/updates_settings.cpp @@ -0,0 +1,104 @@ +#include "updates_settings.h" + +#include + +UpdatesSettings::UpdatesSettings(const QString &settingPath, QObject *parent) + : SettingsManager(settingPath + "updates.ini", "updates", QString(), parent) +{ +} + +bool UpdatesSettings::getCheckUpdatesOnStartup() const +{ + return getValue("startupUpdateCheck", QString(), QString(), true).toBool(); +} + +bool UpdatesSettings::getStartupCardUpdateCheckPromptForUpdate() +{ + return getValue("startupCardUpdateCheckPromptForUpdate", QString(), QString(), true).toBool(); +} + +bool UpdatesSettings::getStartupCardUpdateCheckAlwaysUpdate() +{ + return getValue("startupCardUpdateCheckAlwaysUpdate", QString(), QString(), false).toBool(); +} + +int UpdatesSettings::getCardUpdateCheckInterval() const +{ + return getValue("cardUpdateCheckInterval", QString(), QString(), 7).toInt(); +} + +QDate UpdatesSettings::getLastCardUpdateCheck() const +{ + return getValue("lastCardUpdateCheck", QString(), QString(), QDateTime::currentDateTime().date()).toDate(); +} + +bool UpdatesSettings::getCardUpdateCheckRequired() const +{ + return getLastCardUpdateCheck().daysTo(QDateTime::currentDateTime().date()) >= getCardUpdateCheckInterval() && + getLastCardUpdateCheck() != QDateTime::currentDateTime().date(); +} + +bool UpdatesSettings::getAlwaysEnableNewSets() const +{ + return getValue("alwaysEnableNewSets", QString(), QString(), false).toBool(); +} + +bool UpdatesSettings::getNotifyAboutUpdates() const +{ + return getValue("updateNotification", QString(), QString(), true).toBool(); +} + +bool UpdatesSettings::getNotifyAboutNewVersion() const +{ + return getValue("newVersionNotification", QString(), QString(), true).toBool(); +} + +int UpdatesSettings::getUpdateReleaseChannelIndex() const +{ + return getValue("updateReleaseChannel", QString(), QString(), 0).toInt(); +} + +void UpdatesSettings::setCheckUpdatesOnStartup(bool value) +{ + setValue(value, "startupUpdateCheck"); +} + +void UpdatesSettings::setStartupCardUpdateCheckPromptForUpdate(bool value) +{ + setValue(value, "startupCardUpdateCheckPromptForUpdate"); +} + +void UpdatesSettings::setStartupCardUpdateCheckAlwaysUpdate(bool value) +{ + setValue(value, "startupCardUpdateCheckAlwaysUpdate"); +} + +void UpdatesSettings::setCardUpdateCheckInterval(int value) +{ + setValue(value, "cardUpdateCheckInterval"); +} + +void UpdatesSettings::setLastCardUpdateCheck(QDate value) +{ + setValue(value, "lastCardUpdateCheck"); +} + +void UpdatesSettings::setAlwaysEnableNewSets(bool value) +{ + setValue(value, "alwaysEnableNewSets"); +} + +void UpdatesSettings::setNotifyAboutUpdates(bool _notifyaboutupdate) +{ + setValue(_notifyaboutupdate, "updateNotification"); +} + +void UpdatesSettings::setNotifyAboutNewVersion(bool _notifyaboutnewversion) +{ + setValue(_notifyaboutnewversion, "newVersionNotification"); +} + +void UpdatesSettings::setUpdateReleaseChannelIndex(int value) +{ + setValue(value, "updateReleaseChannel"); +} diff --git a/libcockatrice_settings/libcockatrice/settings/updates_settings.h b/libcockatrice_settings/libcockatrice/settings/updates_settings.h new file mode 100644 index 000000000..6df0f8ce4 --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/updates_settings.h @@ -0,0 +1,43 @@ +#ifndef UPDATES_SETTINGS_H +#define UPDATES_SETTINGS_H + +#include "settings_manager.h" + +#include +#include + +class UpdatesSettings : public SettingsManager, public IUpdatesSettingsProvider +{ + Q_OBJECT + friend class SettingsCache; + +public: + [[nodiscard]] bool getCheckUpdatesOnStartup() const override; + [[nodiscard]] bool getStartupCardUpdateCheckPromptForUpdate() override; + [[nodiscard]] bool getStartupCardUpdateCheckAlwaysUpdate() override; + [[nodiscard]] int getCardUpdateCheckInterval() const override; + [[nodiscard]] QDate getLastCardUpdateCheck() const override; + [[nodiscard]] bool getCardUpdateCheckRequired() const override; + [[nodiscard]] bool getAlwaysEnableNewSets() const override; + [[nodiscard]] bool getNotifyAboutUpdates() const override; + [[nodiscard]] bool getNotifyAboutNewVersion() const override; + [[nodiscard]] int getUpdateReleaseChannelIndex() const override; + + void setCheckUpdatesOnStartup(bool value); + void setStartupCardUpdateCheckPromptForUpdate(bool value); + void setStartupCardUpdateCheckAlwaysUpdate(bool value); + void setCardUpdateCheckInterval(int value); + void setLastCardUpdateCheck(QDate value); + void setAlwaysEnableNewSets(bool value); + void setNotifyAboutUpdates(bool _notifyaboutupdate); + void setNotifyAboutNewVersion(bool _notifyaboutnewversion); + void setUpdateReleaseChannelIndex(int value); + +public: + explicit UpdatesSettings(const QString &settingPath, QObject *parent = nullptr); + +private: + UpdatesSettings(const UpdatesSettings & /*other*/); +}; + +#endif // UPDATES_SETTINGS_H diff --git a/libcockatrice_settings/libcockatrice/settings/visual_deck_storage_settings.cpp b/libcockatrice_settings/libcockatrice/settings/visual_deck_storage_settings.cpp new file mode 100644 index 000000000..1b21af58e --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/visual_deck_storage_settings.cpp @@ -0,0 +1,272 @@ +#include "visual_deck_storage_settings.h" + +namespace +{ +QStringList defaultTags = { + // Strategies + "🏃️ Aggro", + "🧙‍️ Control", + "⚔️ Midrange", + "🌀 Combo", + "🪓 Mill", + "🔒 Stax", + "🗺️ Landfall", + "🛡️ Pillowfort", + "🌱 Ramp", + "⚡ Storm", + "💀 Aristocrats", + "☠️ Reanimator", + "👹 Sacrifice", + "🔥 Burn", + "🌟 Lifegain", + "🔮 Spellslinger", + "👥 Tokens", + "🎭 Blink", + "⏳ Time Manipulation", + "🌍 Domain", + "💫 Proliferate", + "📜 Saga", + "🎲 Chaos", + "🪄 Auras", + "🔫 Pingers", + + // Themes + "👑 Monarch", + "🚀 Vehicles", + "💉 Infect", + "🩸 Madness", + "🌀 Morph", + + // Card Types + "⚔️ Creature", + "💎 Artifact", + "🌔 Enchantment", + "📖 Sorcery", + "⚡ Instant", + "🌌 Planeswalker", + "🌏 Land", + "🪄 Aura", + + // Kindred Types + "🐉 Kindred", + "🧙 Humans", + "⚔️ Soldiers", + "🛡️ Knights", + "🎻 Bards", + "🧝 Elves", + "🌲 Dryads", + "😇 Angels", + "🎩 Wizards", + "🧛 Vampires", + "🦴 Skeletons", + "💀 Zombies", + "👹 Demons", + "👾 Eldrazi", + "🐉 Dragons", + "🐠 Merfolk", + "🦁 Cats", + "🐺 Wolves", + "🐺 Werewolves", + "🦇 Bats", + "🐀 Rats", + "🦅 Birds", + "🦗 Insects", + "🍄 Fungus", + "🐚 Sea Creatures", + "🐗 Boars", + "🦊 Foxes", + "🦄 Unicorns", + "🐘 Elephants", + "🐻 Bears", + "🦏 Rhinos", + "🦂 Scorpions", + +}; +} + +VisualDeckStorageSettings::VisualDeckStorageSettings(const QString &settingPath, QObject *parent) + : SettingsManager(settingPath + "visual_deck_storage.ini", "interface", QString(), parent) +{ +} + +int VisualDeckStorageSettings::getVisualDeckStorageSortingOrder() const +{ + return getValue("sortingOrder", "interface", "visualDeckStorage", 0).toInt(); +} + +bool VisualDeckStorageSettings::getVisualDeckStorageShowFolders() const +{ + return getValue("showFolders", "interface", "visualDeckStorage", true).toBool(); +} + +bool VisualDeckStorageSettings::getVisualDeckStorageShowTagFilter() const +{ + return getValue("showTagFilter", "interface", "visualDeckStorage", true).toBool(); +} + +QStringList VisualDeckStorageSettings::getVisualDeckStorageDefaultTagsList() const +{ + return getValue("defaultTagsList", "interface", "visualDeckStorage", QVariant::fromValue(defaultTags)) + .toStringList(); +} + +bool VisualDeckStorageSettings::getVisualDeckStorageSearchFolderNames() const +{ + return getValue("searchFolderNames", "interface", "visualDeckStorage", true).toBool(); +} + +bool VisualDeckStorageSettings::getVisualDeckStorageShowColorIdentity() const +{ + return getValue("showColorIdentity", "interface", "visualDeckStorage", true).toBool(); +} + +bool VisualDeckStorageSettings::getVisualDeckStorageShowBannerCardComboBox() const +{ + return getValue("showBannerCardComboBox", "interface", "visualDeckStorage", true).toBool(); +} + +bool VisualDeckStorageSettings::getVisualDeckStorageShowTagsOnDeckPreviews() const +{ + return getValue("showTagsOnDeckPreviews", "interface", "visualDeckStorage", true).toBool(); +} + +bool VisualDeckStorageSettings::getVisualDeckStorageDrawUnusedColorIdentities() const +{ + return getValue("drawUnusedColorIdentities", "interface", "visualDeckStorage", true).toBool(); +} + +int VisualDeckStorageSettings::getVisualDeckStorageUnusedColorIdentitiesOpacity() const +{ + return getValue("unusedColorIdentitiesOpacity", "interface", "visualDeckStorage", 15).toInt(); +} + +int VisualDeckStorageSettings::getVisualDeckStorageTooltipType() const +{ + return getValue("tooltipType", "interface", "visualDeckStorage", 0).toInt(); +} + +bool VisualDeckStorageSettings::getVisualDeckStoragePromptForConversion() const +{ + return getValue("promptForConversion", "interface", "visualDeckStorage", true).toBool(); +} + +bool VisualDeckStorageSettings::getVisualDeckStorageAlwaysConvert() const +{ + return getValue("alwaysConvert", "interface", "visualDeckStorage", false).toBool(); +} + +bool VisualDeckStorageSettings::getVisualDeckStorageInGame() const +{ + return getValue("inGame", "interface", "visualDeckStorage", true).toBool(); +} + +bool VisualDeckStorageSettings::getVisualDeckStorageSelectionAnimation() const +{ + return getValue("selectionAnimation", "interface", "visualDeckStorage", true).toBool(); +} + +bool VisualDeckStorageSettings::getVisualDatabaseDisplayFilterToMostRecentSetsEnabled() const +{ + return getValue("filterToMostRecentSetsEnabled", "interface", "visualDatabaseDisplay", false).toBool(); +} + +int VisualDeckStorageSettings::getVisualDatabaseDisplayFilterToMostRecentSetsAmount() const +{ + return getValue("filterToMostRecentSetsAmount", "interface", "visualDatabaseDisplay", 10).toInt(); +} + +void VisualDeckStorageSettings::setVisualDeckStorageSortingOrder(int _sortingOrder) +{ + setValue(_sortingOrder, "sortingOrder", "interface", "visualDeckStorage"); +} + +void VisualDeckStorageSettings::setVisualDeckStorageShowFolders(bool value) +{ + setValue(value, "showFolders", "interface", "visualDeckStorage"); +} + +void VisualDeckStorageSettings::setVisualDeckStorageShowTagFilter(bool _showTags) +{ + setValue(_showTags, "showTagFilter", "interface", "visualDeckStorage"); + emit visualDeckStorageShowTagFilterChanged(_showTags); +} + +void VisualDeckStorageSettings::setVisualDeckStorageDefaultTagsList(QStringList _defaultTagsList) +{ + setValue(QVariant::fromValue(_defaultTagsList), "defaultTagsList", "interface", "visualDeckStorage"); + emit visualDeckStorageDefaultTagsListChanged(); +} + +void VisualDeckStorageSettings::setVisualDeckStorageSearchFolderNames(bool value) +{ + setValue(value, "searchFolderNames", "interface", "visualDeckStorage"); +} + +void VisualDeckStorageSettings::setVisualDeckStorageShowColorIdentity(bool value) +{ + setValue(value, "showColorIdentity", "interface", "visualDeckStorage"); + emit visualDeckStorageShowColorIdentityChanged(value); +} + +void VisualDeckStorageSettings::setVisualDeckStorageShowBannerCardComboBox(bool _showBannerCardComboBox) +{ + setValue(_showBannerCardComboBox, "showBannerCardComboBox", "interface", "visualDeckStorage"); + emit visualDeckStorageShowBannerCardComboBoxChanged(_showBannerCardComboBox); +} + +void VisualDeckStorageSettings::setVisualDeckStorageShowTagsOnDeckPreviews(bool _showTags) +{ + setValue(_showTags, "showTagsOnDeckPreviews", "interface", "visualDeckStorage"); + emit visualDeckStorageShowTagsOnDeckPreviewsChanged(_showTags); +} + +void VisualDeckStorageSettings::setVisualDeckStorageDrawUnusedColorIdentities(bool _draw) +{ + setValue(_draw, "drawUnusedColorIdentities", "interface", "visualDeckStorage"); + emit visualDeckStorageDrawUnusedColorIdentitiesChanged(_draw); +} + +void VisualDeckStorageSettings::setVisualDeckStorageUnusedColorIdentitiesOpacity(int _opacity) +{ + setValue(_opacity, "unusedColorIdentitiesOpacity", "interface", "visualDeckStorage"); + emit visualDeckStorageUnusedColorIdentitiesOpacityChanged(_opacity); +} + +void VisualDeckStorageSettings::setVisualDeckStorageTooltipType(int value) +{ + setValue(value, "tooltipType", "interface", "visualDeckStorage"); +} + +void VisualDeckStorageSettings::setVisualDeckStoragePromptForConversion(bool _prompt) +{ + setValue(_prompt, "promptForConversion", "interface", "visualDeckStorage"); +} + +void VisualDeckStorageSettings::setVisualDeckStorageAlwaysConvert(bool _always) +{ + setValue(_always, "alwaysConvert", "interface", "visualDeckStorage"); +} + +void VisualDeckStorageSettings::setVisualDeckStorageInGame(bool enabled) +{ + setValue(enabled, "inGame", "interface", "visualDeckStorage"); + emit visualDeckStorageInGameChanged(enabled); +} + +void VisualDeckStorageSettings::setVisualDeckStorageSelectionAnimation(bool enabled) +{ + setValue(enabled, "selectionAnimation", "interface", "visualDeckStorage"); + emit visualDeckStorageSelectionAnimationChanged(enabled); +} + +void VisualDeckStorageSettings::setVisualDatabaseDisplayFilterToMostRecentSetsEnabled(bool _enabled) +{ + setValue(_enabled, "filterToMostRecentSetsEnabled", "interface", "visualDatabaseDisplay"); + emit visualDatabaseDisplayFilterToMostRecentSetsEnabledChanged(_enabled); +} + +void VisualDeckStorageSettings::setVisualDatabaseDisplayFilterToMostRecentSetsAmount(int _amount) +{ + setValue(_amount, "filterToMostRecentSetsAmount", "interface", "visualDatabaseDisplay"); + emit visualDatabaseDisplayFilterToMostRecentSetsAmountChanged(_amount); +} diff --git a/libcockatrice_settings/libcockatrice/settings/visual_deck_storage_settings.h b/libcockatrice_settings/libcockatrice/settings/visual_deck_storage_settings.h new file mode 100644 index 000000000..fd2a76663 --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/visual_deck_storage_settings.h @@ -0,0 +1,71 @@ +#ifndef VISUAL_DECK_STORAGE_SETTINGS_H +#define VISUAL_DECK_STORAGE_SETTINGS_H + +#include "settings_manager.h" + +#include +#include + +class VisualDeckStorageSettings : public SettingsManager, public IVisualDeckStorageSettingsProvider +{ + Q_OBJECT + friend class SettingsCache; + +public: + [[nodiscard]] int getVisualDeckStorageSortingOrder() const override; + [[nodiscard]] bool getVisualDeckStorageShowFolders() const override; + [[nodiscard]] bool getVisualDeckStorageShowTagFilter() const override; + [[nodiscard]] QStringList getVisualDeckStorageDefaultTagsList() const override; + [[nodiscard]] bool getVisualDeckStorageSearchFolderNames() const override; + [[nodiscard]] bool getVisualDeckStorageShowColorIdentity() const override; + [[nodiscard]] bool getVisualDeckStorageShowBannerCardComboBox() const override; + [[nodiscard]] bool getVisualDeckStorageShowTagsOnDeckPreviews() const override; + [[nodiscard]] bool getVisualDeckStorageDrawUnusedColorIdentities() const override; + [[nodiscard]] int getVisualDeckStorageUnusedColorIdentitiesOpacity() const override; + [[nodiscard]] int getVisualDeckStorageTooltipType() const override; + [[nodiscard]] bool getVisualDeckStoragePromptForConversion() const override; + [[nodiscard]] bool getVisualDeckStorageAlwaysConvert() const override; + [[nodiscard]] bool getVisualDeckStorageInGame() const override; + [[nodiscard]] bool getVisualDeckStorageSelectionAnimation() const override; + [[nodiscard]] bool getVisualDatabaseDisplayFilterToMostRecentSetsEnabled() const override; + [[nodiscard]] int getVisualDatabaseDisplayFilterToMostRecentSetsAmount() const override; + + void setVisualDeckStorageSortingOrder(int _sortingOrder); + void setVisualDeckStorageShowFolders(bool value); + void setVisualDeckStorageShowTagFilter(bool _showTags); + void setVisualDeckStorageDefaultTagsList(QStringList _defaultTagsList); + void setVisualDeckStorageSearchFolderNames(bool value); + void setVisualDeckStorageShowColorIdentity(bool value); + void setVisualDeckStorageShowBannerCardComboBox(bool _showBannerCardComboBox); + void setVisualDeckStorageShowTagsOnDeckPreviews(bool _showTags); + void setVisualDeckStorageDrawUnusedColorIdentities(bool _draw); + void setVisualDeckStorageUnusedColorIdentitiesOpacity(int _opacity); + void setVisualDeckStorageTooltipType(int value); + void setVisualDeckStoragePromptForConversion(bool _prompt); + void setVisualDeckStorageAlwaysConvert(bool _always); + void setVisualDeckStorageInGame(bool enabled); + void setVisualDeckStorageSelectionAnimation(bool enabled); + void setVisualDatabaseDisplayFilterToMostRecentSetsEnabled(bool _enabled); + void setVisualDatabaseDisplayFilterToMostRecentSetsAmount(int _amount); + +signals: + void visualDeckStorageShowTagFilterChanged(bool _visible); + void visualDeckStorageDefaultTagsListChanged(); + void visualDeckStorageShowColorIdentityChanged(bool _visible); + void visualDeckStorageShowBannerCardComboBoxChanged(bool _visible); + void visualDeckStorageShowTagsOnDeckPreviewsChanged(bool _visible); + void visualDeckStorageDrawUnusedColorIdentitiesChanged(bool _visible); + void visualDeckStorageUnusedColorIdentitiesOpacityChanged(bool value); + void visualDeckStorageInGameChanged(bool enabled); + void visualDeckStorageSelectionAnimationChanged(bool enabled); + void visualDatabaseDisplayFilterToMostRecentSetsEnabledChanged(bool enabled); + void visualDatabaseDisplayFilterToMostRecentSetsAmountChanged(int amount); + +public: + explicit VisualDeckStorageSettings(const QString &settingPath, QObject *parent = nullptr); + +private: + VisualDeckStorageSettings(const VisualDeckStorageSettings & /*other*/); +}; + +#endif // VISUAL_DECK_STORAGE_SETTINGS_H diff --git a/libcockatrice_utility/CMakeLists.txt b/libcockatrice_utility/CMakeLists.txt index c0c7d8cc9..db23f7951 100644 --- a/libcockatrice_utility/CMakeLists.txt +++ b/libcockatrice_utility/CMakeLists.txt @@ -5,25 +5,38 @@ set(CMAKE_AUTOMOC ON) set(CMAKE_AUTOUIC ON) set(CMAKE_AUTORCC ON) -set(UTILITY_SOURCES libcockatrice/utility/expression.cpp libcockatrice/utility/levenshtein.cpp - libcockatrice/utility/passwordhasher.cpp +set(UTILITY_SOURCES + libcockatrice/utility/cryptoutil.cpp libcockatrice/utility/expression.cpp libcockatrice/utility/levenshtein.cpp + libcockatrice/utility/passwordhasher.cpp libcockatrice/utility/server_rate_limiter.cpp + libcockatrice/utility/warning_categories.cpp ) set(UTILITY_HEADERS + libcockatrice/utility/card_ref.h libcockatrice/utility/color.h + libcockatrice/utility/cryptoutil.h libcockatrice/utility/expression.h libcockatrice/utility/levenshtein.h libcockatrice/utility/macros.h libcockatrice/utility/passwordhasher.h - libcockatrice/utility/trice_limits.h + libcockatrice/utility/playmat_params.h + libcockatrice/utility/string_limits.h + libcockatrice/utility/dice_limits.h + libcockatrice/utility/counter_limits.h + libcockatrice/utility/clamped_arithmetic.h libcockatrice/utility/zone_names.h + libcockatrice/utility/days_years_between.h + libcockatrice/utility/server_rate_limiter.h + libcockatrice/utility/warning_categories.h ) add_library(libcockatrice_utility STATIC ${UTILITY_SOURCES} ${UTILITY_HEADERS}) target_include_directories(libcockatrice_utility PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) -target_link_libraries(libcockatrice_utility PUBLIC libcockatrice_rng ${QT_CORE_MODULE}) +find_package(OpenSSL REQUIRED) + +target_link_libraries(libcockatrice_utility PUBLIC libcockatrice_rng OpenSSL::Crypto ${QT_CORE_MODULE}) set(ORACLE_LIBS) diff --git a/libcockatrice_utility/libcockatrice/utility/clamped_arithmetic.h b/libcockatrice_utility/libcockatrice/utility/clamped_arithmetic.h new file mode 100644 index 000000000..1afac758c --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/clamped_arithmetic.h @@ -0,0 +1,22 @@ +#ifndef CLAMPED_ARITHMETIC_H +#define CLAMPED_ARITHMETIC_H + +#include +#include + +/** + * @brief Overflow-safe clamped addition: returns value + delta bounded to [minValue, maxValue]. + * + * Uses a 64-bit intermediate so the addition cannot overflow int. Shared by the bounded + * counter arithmetic in both the client and the server. + * + * @note Requires minValue <= maxValue. Bounds come from trusted compile-time call sites; + * qBound() asserts this internally in debug builds. + */ +inline int addClamped(int value, int delta, int minValue, int maxValue) +{ + const auto result = static_cast(value) + static_cast(delta); + return static_cast(qBound(static_cast(minValue), result, static_cast(maxValue))); +} + +#endif // CLAMPED_ARITHMETIC_H diff --git a/libcockatrice_utility/libcockatrice/utility/counter_limits.h b/libcockatrice_utility/libcockatrice/utility/counter_limits.h new file mode 100644 index 000000000..1343dbb3f --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/counter_limits.h @@ -0,0 +1,17 @@ +#ifndef COUNTER_LIMITS_H +#define COUNTER_LIMITS_H + +/** + * @brief Upper bound for a bounded counter's value: [0, MAX_COUNTER_VALUE]. + * + * Caps an individual counter's VALUE (e.g. a +1/+1 counter at 999), not how many counters + * something holds. Applies to counters that are constrained to a non-negative display range, + * such as card counters and commander tax. Unbounded counters (e.g. a player's life total) + * do not use this limit and may go negative, saturating only at the int range. + * + * The max of 999 is a display constraint (3-digit rendering) and a reasonable gameplay limit. + * The server enforces these bounds; the client may also check them for UX optimization. + */ +constexpr int MAX_COUNTER_VALUE = 999; + +#endif // COUNTER_LIMITS_H diff --git a/libcockatrice_utility/libcockatrice/utility/cryptoutil.cpp b/libcockatrice_utility/libcockatrice/utility/cryptoutil.cpp new file mode 100644 index 000000000..416ef261b --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/cryptoutil.cpp @@ -0,0 +1,25 @@ +#include "cryptoutil.h" + +#include + +namespace CryptoUtil +{ +QByteArray randomBytes(int count) +{ + QByteArray bytes(count, '\0'); + if (RAND_bytes(reinterpret_cast(bytes.data()), count) != 1) { + // Randomness failure is fatal: never fall back to a predictable source. + qFatal("CryptoUtil::randomBytes: RAND_bytes failed"); + } + return bytes; +} + +quint64 randomUInt64() +{ + quint64 value; + if (RAND_bytes(reinterpret_cast(&value), sizeof(value)) != 1) { + qFatal("CryptoUtil::randomUInt64: RAND_bytes failed"); + } + return value; +} +} // namespace CryptoUtil diff --git a/libcockatrice_utility/libcockatrice/utility/cryptoutil.h b/libcockatrice_utility/libcockatrice/utility/cryptoutil.h new file mode 100644 index 000000000..dba9dc37d --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/cryptoutil.h @@ -0,0 +1,13 @@ +#ifndef CRYPTOUTIL_H +#define CRYPTOUTIL_H + +#include +#include + +namespace CryptoUtil +{ +QByteArray randomBytes(int count); +quint64 randomUInt64(); +} // namespace CryptoUtil + +#endif diff --git a/libcockatrice_utility/libcockatrice/utility/days_years_between.h b/libcockatrice_utility/libcockatrice/utility/days_years_between.h index c0f5da23a..4b3b5bc0c 100644 --- a/libcockatrice_utility/libcockatrice/utility/days_years_between.h +++ b/libcockatrice_utility/libcockatrice/utility/days_years_between.h @@ -1,3 +1,6 @@ +#ifndef COCKATRICE_DAYS_YEARS_BETWEEN_H +#define COCKATRICE_DAYS_YEARS_BETWEEN_H + #include inline static QPair getDaysAndYearsBetween(const QDate &then, const QDate &now) @@ -6,3 +9,5 @@ inline static QPair getDaysAndYearsBetween(const QDate &then, const QD int days = then.addYears(years).daysTo(now); return {days, years}; } + +#endif // COCKATRICE_DAYS_YEARS_BETWEEN_H diff --git a/libcockatrice_utility/libcockatrice/utility/dice_limits.h b/libcockatrice_utility/libcockatrice/utility/dice_limits.h new file mode 100644 index 000000000..e1407c57e --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/dice_limits.h @@ -0,0 +1,15 @@ +#ifndef DICE_LIMITS_H +#define DICE_LIMITS_H + +#include // for uint + +/** @brief Fewest sides a rollable die may have. */ +constexpr uint MINIMUM_DIE_SIDES = 2; +/** @brief Most sides a rollable die may have. */ +constexpr uint MAXIMUM_DIE_SIDES = 1000000; +/** @brief Fewest dice that may be rolled at once. */ +constexpr uint MINIMUM_DICE_TO_ROLL = 1; +/** @brief Most dice that may be rolled at once. */ +constexpr uint MAXIMUM_DICE_TO_ROLL = 100; + +#endif // DICE_LIMITS_H diff --git a/libcockatrice_utility/libcockatrice/utility/passwordhasher.cpp b/libcockatrice_utility/libcockatrice/utility/passwordhasher.cpp index c40c5f94f..1c22fdcfa 100644 --- a/libcockatrice_utility/libcockatrice/utility/passwordhasher.cpp +++ b/libcockatrice_utility/libcockatrice/utility/passwordhasher.cpp @@ -1,7 +1,7 @@ #include "passwordhasher.h" #include -#include +#include QString PasswordHasher::computeHash(const QString &password, const QString &salt) { @@ -21,12 +21,28 @@ QString PasswordHasher::generateRandomSalt(const int len) static const char alphanum[] = "0123456789" "ABCDEFGHIJKLMNOPQRSTUVWXYZ" "abcdefghijklmnopqrstuvwxyz"; + const int size = sizeof(alphanum) - 1; + + // Two bytes per character, corrected for modulo bias via rejection sampling. + const int bucketSize = 65536 / size; + const int limit = bucketSize * size; QString ret; - int size = sizeof(alphanum) - 1; - + ret.reserve(len); + QByteArray random = CryptoUtil::randomBytes(len * 2); + int bytesUsed = 0; for (int i = 0; i < len; ++i) { - ret.append(alphanum[rng->rand(0, size)]); + unsigned int value; + do { + if (bytesUsed >= random.size()) { + random = CryptoUtil::randomBytes(len * 2); + bytesUsed = 0; + } + value = static_cast(static_cast(random.at(bytesUsed))) << 8 | + static_cast(static_cast(random.at(bytesUsed + 1))); + bytesUsed += 2; + } while (value >= limit); + ret.append(alphanum[value / bucketSize]); } return ret; @@ -34,5 +50,5 @@ QString PasswordHasher::generateRandomSalt(const int len) QString PasswordHasher::generateActivationToken() { - return QCryptographicHash::hash(generateRandomSalt().toUtf8(), QCryptographicHash::Md5).toBase64().left(16); + return QString(CryptoUtil::randomBytes(16).toBase64().left(16)); } diff --git a/libcockatrice_utility/libcockatrice/utility/peglib.h b/libcockatrice_utility/libcockatrice/utility/peglib.h index e7e558dff..a67a86549 100644 --- a/libcockatrice_utility/libcockatrice/utility/peglib.h +++ b/libcockatrice_utility/libcockatrice/utility/peglib.h @@ -1,4 +1,4 @@ -// +// // peglib.h // // Copyright (c) 2022 Yuji Hirose. All rights reserved. @@ -7,6 +7,9 @@ #pragma once +#define CPPPEGLIB_VERSION "1.16.0" +#define CPPPEGLIB_VERSION_NUM "0x011000" + /* * Configuration */ @@ -46,6 +49,8 @@ namespace peg { +struct GrammarBlob; + /*----------------------------------------------------------------------------- * scope_exit *---------------------------------------------------------------------------*/ @@ -335,6 +340,14 @@ inline std::string resolve_escape_sequence(const char *s, size_t n) { r += ']'; i++; break; + case '^': + r += '^'; + i++; + break; + case '-': + r += '-'; + i++; + break; case '\\': r += '\\'; i++; @@ -361,6 +374,46 @@ inline std::string resolve_escape_sequence(const char *s, size_t n) { return r; } +/* + * Predefined character classes (ASCII semantics) + */ +inline const std::vector> * +predefined_character_class(std::string_view name) { + static const std::map>> + table = { + {"alnum", {{'0', '9'}, {'A', 'Z'}, {'a', 'z'}}}, + {"alpha", {{'A', 'Z'}, {'a', 'z'}}}, + {"ascii", {{0x00, 0x7F}}}, + {"blank", {{'\t', '\t'}, {' ', ' '}}}, + {"cntrl", {{0x00, 0x1F}, {0x7F, 0x7F}}}, + {"digit", {{'0', '9'}}}, + {"graph", {{0x21, 0x7E}}}, + {"lower", {{'a', 'z'}}}, + {"print", {{0x20, 0x7E}}}, + {"punct", {{0x21, 0x2F}, {0x3A, 0x40}, {0x5B, 0x60}, {0x7B, 0x7E}}}, + {"space", {{'\t', '\r'}, {' ', ' '}}}, + {"upper", {{'A', 'Z'}}}, + {"word", {{'0', '9'}, {'A', 'Z'}, {'_', '_'}, {'a', 'z'}}}, + {"xdigit", {{'0', '9'}, {'A', 'F'}, {'a', 'f'}}}, + }; + auto it = table.find(name); + return it != table.end() ? &it->second : nullptr; +} + +// Ranges must be sorted and non-overlapping. +inline std::vector> complement_character_ranges( + const std::vector> &ranges) { + std::vector> r; + char32_t next = 0; + for (const auto &[lo, hi] : ranges) { + if (lo > next) { r.emplace_back(next, lo - 1); } + next = hi + 1; + } + if (next <= 0x10FFFF) { r.emplace_back(next, 0x10FFFF); } + return r; +} + /*----------------------------------------------------------------------------- * token_to_number_ - This function should be removed eventually *---------------------------------------------------------------------------*/ @@ -448,6 +501,7 @@ public: size_t items_count() const { return items_count_; } friend struct ComputeFirstSet; + friend struct GrammarBlob; private: struct Info { @@ -506,7 +560,7 @@ inline constexpr unsigned int str2tag(std::string_view sv) { namespace udl { -inline constexpr unsigned int operator"" _(const char *s, size_t l) { +inline constexpr unsigned int operator""_(const char *s, size_t l) { return str2tag_core(s, l, 0); } @@ -724,6 +778,25 @@ inline bool fail(size_t len) { return len == static_cast(-1); } using Log = std::function; +/* + * ErrorReport - structured error information passed to an ErrorReporter. + * Unlike Log, nothing is flattened into a display string, so applications + * can map errors to their own error types, localize messages, or feed + * diagnostics to IDEs. + */ +struct ErrorReport { + size_t line = 0; // 1-based + size_t col = 1; // 1-based + size_t position = 0; // byte offset in the input + std::string unexpected_token; // heuristic token at the error position + std::vector expected_literals; + std::vector expected_rules; // rules starting with '_' excluded + std::string message; // custom error_message if any (placeholders resolved) + std::string label; // rule name or recovery label the error belongs to +}; + +using ErrorReporter = std::function; + /* * ErrorInfo */ @@ -752,7 +825,11 @@ struct ErrorInfo { expected_tokens.emplace_back(error_literal, error_rule); } - void output_log(const Log &log, const char *s, size_t n); + void output_log(const Log &log, const char *s, size_t n) { + output_log(log, nullptr, s, n); + } + void output_log(const Log &log, const ErrorReporter &reporter, const char *s, + size_t n); private: int cast_char(char c) const { return static_cast(c); } @@ -808,6 +885,131 @@ using TracerLeave = std::function; +// Packrat memoization table: open-addressing hash map keyed by the fused +// (position * rule count + rule id) index. The insert-heavy access pattern +// makes node-based containers a bottleneck, so keys and lengths live in one +// flat array of 16-byte POD slots probed linearly; semantic values go into a +// parallel array that is never allocated when no cached result carries a +// value. Erased slots become tombstones (erase only happens during +// left-recursion cache invalidation). +class PackratCache { +public: + explicit PackratCache(size_t expected_entries) { + while (initial_capacity_ < expected_entries) { + initial_capacity_ *= 2; + } + } + + bool find(size_t key, size_t &len, std::any &val) const { + if (slots_.empty()) { return false; } + auto mask = slots_.size() - 1; + auto i = mix(key) & mask; + while (true) { + auto &slot = slots_[i]; + if (slot.key == key) { + len = slot.len; + if (!vals_.empty()) { + val = vals_[i]; + } else { + val.reset(); + } + return true; + } + if (slot.key == kEmpty) { return false; } + i = (i + 1) & mask; + } + } + + void insert_or_assign(size_t key, size_t len, const std::any &val) { + if (slots_.empty() || (used_ + 1) * 4 > slots_.size() * 3) { grow(); } + auto mask = slots_.size() - 1; + auto i = mix(key) & mask; + auto insert_pos = kEmpty; + while (true) { + auto &slot = slots_[i]; + if (slot.key == key) { + insert_pos = i; + break; + } + if (slot.key == kTombstone) { + if (insert_pos == kEmpty) { insert_pos = i; } + } else if (slot.key == kEmpty) { + if (insert_pos == kEmpty) { insert_pos = i; } + if (slots_[insert_pos].key == kEmpty) { used_++; } + break; + } + i = (i + 1) & mask; + } + auto &dest = slots_[insert_pos]; + dest.key = key; + dest.len = len; + if (val.has_value()) { + if (vals_.empty()) { vals_.resize(slots_.size()); } + vals_[insert_pos] = val; + } else if (!vals_.empty()) { + vals_[insert_pos].reset(); + } + } + + void erase(size_t key) { + if (slots_.empty()) { return; } + auto mask = slots_.size() - 1; + auto i = mix(key) & mask; + while (true) { + auto &slot = slots_[i]; + if (slot.key == key) { + slot.key = kTombstone; + if (!vals_.empty()) { vals_[i].reset(); } + return; + } + if (slot.key == kEmpty) { return; } + i = (i + 1) & mask; + } + } + +private: + static constexpr size_t kEmpty = static_cast(-1); + static constexpr size_t kTombstone = static_cast(-2); + + struct Slot { + size_t key = kEmpty; + size_t len = 0; + }; + + static size_t mix(size_t key) { + // Mix in 64 bits so `h >> 32` stays well-defined where size_t is 32-bit + // (wasm32); on 64-bit targets this is bit-identical to the size_t mix. + auto h = static_cast(key) * 0x9E3779B97F4A7C15ull; + return static_cast(h ^ (h >> 32)); + } + + void grow() { + auto new_cap = slots_.empty() ? initial_capacity_ : slots_.size() * 2; + std::vector old_slots = std::move(slots_); + std::vector old_vals = std::move(vals_); + slots_.assign(new_cap, Slot{}); + if (!old_vals.empty()) { vals_.assign(new_cap, std::any()); } + used_ = 0; + auto mask = new_cap - 1; + for (size_t j = 0; j < old_slots.size(); j++) { + auto &slot = old_slots[j]; + if (slot.key == kEmpty || slot.key == kTombstone) { continue; } + auto i = mix(slot.key) & mask; + while (slots_[i].key != kEmpty) { + i = (i + 1) & mask; + } + slots_[i] = slot; + if (!old_vals.empty()) { vals_[i] = std::move(old_vals[j]); } + used_++; + } + } + + size_t initial_capacity_ = 1024; + std::vector slots_; + std::vector vals_; + size_t used_ = 0; // occupied + tombstone slots +}; + class Context { public: const char *path; @@ -817,11 +1019,18 @@ public: ErrorInfo error_info; bool recovered = false; - std::vector> value_stack; + std::vector> value_stack; size_t value_stack_size = 0; std::vector rule_stack; - std::vector>> args_stack; + + // One frame per rule reference: the macro arguments in scope, and the + // instantiation they identify (0 for anything but a left-recursive macro). + struct ArgsFrame { + std::vector> args; + size_t macro_inst = 0; + }; + std::vector args_stack; size_t in_token_boundary_count = 0; @@ -836,68 +1045,115 @@ public: const size_t def_count; const bool enablePackratParsing; + const std::vector *packrat_index; // def_id -> cache slot or -1 + size_t packrat_cached_count; // number of memoized rules std::vector cache_registered; std::vector cache_success; + // Innermost active start position per rule; re-entry guard for rules that + // are not memoized (replaces the per-position bitvector for them). + std::vector active_pos; - std::map, std::tuple> - cache_values; + PackratCache cache_values; // Left recursion support struct LRMemo { size_t len = static_cast(-1); std::any val; }; - std::map, LRMemo> lr_memo; + + // A left-recursive rule instance: the definition plus, for a macro, the + // instantiation it was invoked with (0 for a plain rule). Two + // instantiations of the same macro grow independent seeds. + using LRRule = std::pair; + using LRKey = std::pair; + + std::map lr_memo; // Rules whose lr_memo was hit during the current parse scope. // Used to track LR cycle membership. - std::set lr_refs_hit; + std::set lr_refs_hit; // Rules currently in their seeding/growing phase at a given position. // Protected from having their lr_memo erased by inner growers. - std::set> lr_active_seeds; + std::set lr_active_seeds; + + // Interned macro instantiations: (definition, resolved arguments) -> id. + std::map, size_t> macro_inst_ids; + size_t next_macro_inst_ = 1; + + // Map a def_id to its slot in the cache tables, or -1 for guard-only + // rules (not memoized). + int32_t cache_slot(size_t def_id) const { + if (!packrat_index) { return static_cast(def_id); } + return def_id < packrat_index->size() ? (*packrat_index)[def_id] : -1; + } void clear_packrat_cache(const char *pos, size_t def_id) { if (!enablePackratParsing) { return; } + auto slot = cache_slot(def_id); + if (slot < 0) { return; } auto col = static_cast(pos - s); - auto idx = def_count * col + def_id; + auto idx = packrat_cached_count * col + static_cast(slot); if (idx < cache_registered.size()) { cache_registered[idx] = false; cache_success[idx] = false; } - cache_values.erase(std::make_pair(col, def_id)); + cache_values.erase(idx); } void write_packrat_cache(const char *pos, size_t def_id, size_t len, const std::any &val) { if (!enablePackratParsing) { return; } + auto slot = cache_slot(def_id); + if (slot < 0) { return; } auto col = pos - s; - auto idx = def_count * static_cast(col) + def_id; + auto idx = packrat_cached_count * static_cast(col) + + static_cast(slot); if (idx >= cache_registered.size()) { return; } cache_registered[idx] = true; cache_success[idx] = true; - auto key = std::pair(col, def_id); - cache_values[key] = std::pair(len, val); + cache_values.insert_or_assign(idx, len, val); } TracerEnter tracer_enter; TracerLeave tracer_leave; + const bool has_tracer; std::any trace_data; const bool verbose_trace; + // Byte-wise tolower frozen at parse start, so case-insensitive matching + // avoids a locale-sensitive libc call per input byte. + unsigned char tolower_table[256]; + Log log; + ErrorReporter error_reporter; Context(const char *path, const char *s, size_t l, size_t def_count, std::shared_ptr whitespaceOpe, std::shared_ptr wordOpe, bool enablePackratParsing, TracerEnter tracer_enter, TracerLeave tracer_leave, std::any trace_data, bool verbose_trace, - Log log) + Log log, ErrorReporter error_reporter = nullptr, + const std::vector *packrat_index = nullptr, + size_t packrat_cached_count = 0) : path(path), s(s), l(l), whitespaceOpe(whitespaceOpe), wordOpe(wordOpe), def_count(def_count), enablePackratParsing(enablePackratParsing), - cache_registered(enablePackratParsing ? def_count * (l + 1) : 0), - cache_success(enablePackratParsing ? def_count * (l + 1) : 0), + packrat_index(packrat_index), + packrat_cached_count(packrat_index ? packrat_cached_count : def_count), + cache_registered( + enablePackratParsing ? this->packrat_cached_count * (l + 1) : 0), + cache_success( + enablePackratParsing ? this->packrat_cached_count * (l + 1) : 0), + active_pos(enablePackratParsing ? def_count : 0, nullptr), + cache_values(enablePackratParsing ? (packrat_index ? l / 8 + 16 : l / 2) + : 0), tracer_enter(tracer_enter), tracer_leave(tracer_leave), - trace_data(trace_data), verbose_trace(verbose_trace), log(log) { + has_tracer(tracer_enter && tracer_leave), trace_data(trace_data), + verbose_trace(verbose_trace), log(log), error_reporter(error_reporter) { + + for (size_t i = 0; i < 256; i++) { + tolower_table[i] = + static_cast(std::tolower(static_cast(i))); + } push_args({}); } @@ -918,11 +1174,6 @@ public: }; std::vector *packrat_stats = nullptr; - // Per-rule packrat filter: if set, only rules with filter[def_id]=true - // use full memoization (cache_values map). Others use bitvector-only - // re-entry guard. - const std::vector *packrat_rule_filter = nullptr; - template void packrat(const char *a_s, size_t def_id, size_t &len, std::any &val, T fn) { @@ -931,23 +1182,47 @@ public: return; } + auto slot = cache_slot(def_id); + if (slot < 0) { + // Guard-only rule: no memoization. Recursion at the same position is + // caught by the per-rule active-position guard. + if (active_pos[def_id] == a_s) { + if (packrat_stats && def_id < packrat_stats->size()) { + (*packrat_stats)[def_id].hits++; + } + len = static_cast(-1); + return; + } + if (packrat_stats && def_id < packrat_stats->size()) { + (*packrat_stats)[def_id].misses++; + } + auto save = active_pos[def_id]; + active_pos[def_id] = a_s; + fn(val); + active_pos[def_id] = save; + return; + } + auto col = a_s - s; - auto idx = def_count * static_cast(col) + def_id; + auto idx = packrat_cached_count * static_cast(col) + + static_cast(slot); if (cache_registered[idx]) { if (packrat_stats && def_id < packrat_stats->size()) { (*packrat_stats)[def_id].hits++; } if (cache_success[idx]) { - auto key = std::pair(col, def_id); - std::tie(len, val) = cache_values[key]; + if (!cache_values.find(idx, len, val)) { + len = 0; + val.reset(); + } return; } else { len = static_cast(-1); return; } } else { - // Pre-register as failure (re-entry guard for all rules) + // Pre-register as failure (re-entry guard + failure memoization) cache_registered[idx] = true; cache_success[idx] = false; @@ -957,15 +1232,7 @@ public: fn(val); - bool full_memo = - !packrat_rule_filter || (def_id < packrat_rule_filter->size() && - (*packrat_rule_filter)[def_id]); - if (full_memo) { - if (success(len)) { write_packrat_cache(a_s, def_id, len, val); } - } else { - // Guard-only: undo registration so future calls re-parse - cache_registered[idx] = false; - } + if (success(len)) { write_packrat_cache(a_s, def_id, len, val); } return; } } @@ -974,7 +1241,7 @@ public: SemanticValues &push_semantic_values_scope() { assert(value_stack_size <= value_stack.size()); if (value_stack_size == value_stack.size()) { - value_stack.emplace_back(std::make_shared(this)); + value_stack.emplace_back(std::make_unique(this)); } else { auto &vs = *value_stack[value_stack_size]; if (!vs.empty()) { @@ -996,14 +1263,31 @@ public: void pop_semantic_values_scope() { value_stack_size--; } // Arguments - void push_args(std::vector> &&args) { - args_stack.emplace_back(std::move(args)); + void push_args(std::vector> &&args, + size_t macro_inst = 0) { + args_stack.push_back({std::move(args), macro_inst}); } void pop_args() { args_stack.pop_back(); } const std::vector> &top_args() const { - return args_stack[args_stack.size() - 1]; + return args_stack[args_stack.size() - 1].args; + } + + size_t top_macro_inst() const { + return args_stack[args_stack.size() - 1].macro_inst; + } + + // Identify a macro invocation by what its resolved arguments denote (see + // macro_inst_key). `Sum(A)` inside `Sum(N)`'s own body resolves A back to + // the argument the outer call was given, so both invocations intern to the + // same id and the inner one finds the outer's seed — which is what makes + // growing terminate. + size_t intern_macro_inst(std::vector &&key) { + auto [it, inserted] = + macro_inst_ids.emplace(std::move(key), next_macro_inst_); + if (inserted) { next_macro_inst_++; } + return it->second; } // Snapshot/Rollback @@ -1169,8 +1453,8 @@ private: lower_heap.reset(new char[id_len]); lower = lower_heap.get(); } - std::transform(s, s + id_len, lower, [](unsigned char ch) { - return static_cast(std::tolower(ch)); + std::transform(s, s + id_len, lower, [&c](unsigned char ch) { + return static_cast(c.tolower_table[ch]); }); std::string_view lower_sv(lower, id_len); @@ -1241,7 +1525,8 @@ public: const auto &fs = first_sets_[id]; if (!fs.any_char && !fs.can_be_empty && !fs.chars.test(static_cast(*s))) { - if (c.log && (fs.first_literal || fs.first_rule)) { + if ((c.log || c.error_reporter) && + (fs.first_literal || fs.first_rule)) { if (c.error_info.error_pos <= s) { if (c.error_info.error_pos < s || !(id > 0)) { c.error_info.error_pos = s; @@ -1517,6 +1802,8 @@ public: void accept(Visitor &v) override; friend struct ComputeFirstSet; + friend struct GrammarBlob; + friend struct OpeSignature; bool is_ascii_only() const { return is_ascii_only_; } const std::bitset<256> &ascii_bitset() const { return ascii_bitset_; } @@ -1799,6 +2086,10 @@ public: std::shared_ptr atom_; std::shared_ptr binop_; BinOpeInfo info_; + // Owned backing storage for info_ keys when this node is built by + // GrammarBlob::deserialize. Grammars parsed from source leave this empty and + // point info_ keys into the retained grammar text instead. + std::vector info_keys_; const Definition &rule_; private: @@ -2179,11 +2470,35 @@ struct DetectLeftRecursion : public TraversalVisitor { const char *error_s = nullptr; - std::shared_ptr resolve_macro_arg(size_t iarg) const; + // What a bare parameter reference denotes, plus the frame it was found at + // -- see visit_in_defining_scope. + struct ResolvedArg { + std::shared_ptr ope; + size_t depth = 0; + }; + ResolvedArg resolve_macro_arg(size_t iarg) const; + void visit_in_defining_scope(const ResolvedArg &arg); + + // A macro's body depends on its arguments, so "already visited" has to be + // per instantiation, not per name: in `A <- W('z') / W(A)`, visiting W with + // 'z' says nothing about W with A. Instantiations are identified by their + // resolved arguments, the same way as at parse time. + size_t intern_macro_inst(const Reference &ope); + + // A macro that instantiates itself with a growing argument + // (`M(s) <- M(s / 'x')`) has no finite set of instantiations. Stop + // descending instead of looping forever; the rule is then reported as + // non-left-recursive, which is what this analysis did for every macro + // before it became instantiation-aware. The bound is on nesting depth in + // general, not self-recursion specifically, so it also caps any other + // chain of nested macro calls -- generously, for real grammars. + static const size_t max_macro_inst_depth = 32; private: std::string name_; - std::unordered_set refs_; + std::set> refs_; + std::map, size_t> macro_inst_ids_; + size_t next_macro_inst_ = 1; bool done_ = false; std::vector> *> macro_args_stack_; }; @@ -2221,6 +2536,104 @@ struct ComputeCanBeEmpty : public TraversalVisitor { void visit(Cut &) override { result = false; } }; +// Structural signature of an Ope. Two alternatives whose first k elements +// have equal signatures consume the same text, so their (k+1)-th elements +// start at the same position. Opes whose state cannot be serialized get +// their address instead: that only ever reads as "these differ", which +// costs an optimization rather than adding one. +struct OpeSignature : public Ope::Visitor { + using Ope::Visitor::visit; + std::string s; + + void visit(Sequence &ope) override { group("seq", ope.opes_); } + void visit(PrioritizedChoice &ope) override { group("cho", ope.opes_); } + void visit(Repetition &ope) override { + s += "(rep " + std::to_string(ope.min_) + " " + + (ope.max_ == std::numeric_limits::max() + ? std::string("inf") + : std::to_string(ope.max_)); + wrap(*ope.ope_); + } + void visit(AndPredicate &ope) override { unary("and", *ope.ope_); } + void visit(NotPredicate &ope) override { unary("not", *ope.ope_); } + void visit(CaptureScope &ope) override { unary("cps", *ope.ope_); } + void visit(Capture &ope) override { unary("cap", *ope.ope_); } + void visit(TokenBoundary &ope) override { unary("tok", *ope.ope_); } + void visit(Ignore &ope) override { unary("ign", *ope.ope_); } + void visit(Whitespace &ope) override { unary("wsp", *ope.ope_); } + void visit(Recovery &ope) override { unary("rec", *ope.ope_); } + // A rule is named, never expanded — that is what keeps a recursive + // grammar's signature finite. WeakHolder only ever wraps a Holder, so + // descending through it lands on a name too. + void visit(Holder &ope) override { s += "(hld " + ope.name() + ")"; } + void visit(WeakHolder &ope) override { + if (auto p = ope.weak_.lock()) { + unary("wek", *p); + } else { + opaque(&ope); + } + } + void visit(Reference &ope) override { + s += "(ref " + ope.name_; + for (auto &arg : ope.args_) { + s += ' '; + arg->accept(*this); + } + s += ')'; + } + void visit(LiteralString &ope) override { + s += "(lit " + std::to_string(ope.ignore_case_) + " " + ope.lit_ + ")"; + } + void visit(CharacterClass &ope) override { + s += "(cls " + std::to_string(ope.negated_) + " " + + std::to_string(ope.ignore_case_); + for (const auto &[lo, hi] : ope.ranges_) { + s += " " + std::to_string(static_cast(lo)) + "-" + + std::to_string(static_cast(hi)); + } + s += ')'; + } + void visit(Character &ope) override { + s += "(chr " + std::to_string(static_cast(ope.ch_)) + ")"; + } + void visit(AnyCharacter &) override { s += "(any)"; } + void visit(Dictionary &ope) override { opaque(&ope); } + void visit(User &ope) override { opaque(&ope); } + void visit(BackReference &ope) override { opaque(&ope); } + void visit(PrecedenceClimbing &ope) override { opaque(&ope); } + void visit(Cut &ope) override { opaque(&ope); } + + static std::string get(Ope &ope) { + OpeSignature vis; + ope.accept(vis); + return std::move(vis.s); + } + +private: + void group(const char *tag, const std::vector> &v) { + s += '('; + s += tag; + for (const auto &op : v) { + s += ' '; + op->accept(*this); + } + s += ')'; + } + void unary(const char *tag, Ope &inner) { + s += '('; + s += tag; + wrap(inner); + } + void wrap(Ope &inner) { + s += ' '; + inner.accept(*this); + s += ')'; + } + void opaque(const void *p) { + s += "(opq " + std::to_string(reinterpret_cast(p)) + ")"; + } +}; + struct HasEmptyElement : public TraversalVisitor { using TraversalVisitor::visit; @@ -2571,6 +2984,7 @@ struct SetupFirstSets : public TraversalVisitor { if (cc && cc->is_ascii_only()) { ope.span_bitset_ = &cc->ascii_bitset(); } } void visit(Reference &ope) override; + void visit(Holder &ope) override; private: ComputeFirstSet::FirstSetCache first_set_cache_; @@ -2617,37 +3031,40 @@ public: } Result parse(const char *s, size_t n, const char *path = nullptr, - Log log = nullptr) const { + Log log = nullptr, + ErrorReporter error_reporter = nullptr) const { SemanticValues vs; std::any dt; - return parse_core(s, n, vs, dt, path, log); + return parse_core(s, n, vs, dt, path, log, error_reporter); } - Result parse(const char *s, const char *path = nullptr, - Log log = nullptr) const { + Result parse(const char *s, const char *path = nullptr, Log log = nullptr, + ErrorReporter error_reporter = nullptr) const { auto n = strlen(s); - return parse(s, n, path, log); + return parse(s, n, path, log, error_reporter); } Result parse(const char *s, size_t n, std::any &dt, - const char *path = nullptr, Log log = nullptr) const { + const char *path = nullptr, Log log = nullptr, + ErrorReporter error_reporter = nullptr) const { SemanticValues vs; - return parse_core(s, n, vs, dt, path, log); + return parse_core(s, n, vs, dt, path, log, error_reporter); } Result parse(const char *s, std::any &dt, const char *path = nullptr, - Log log = nullptr) const { + Log log = nullptr, + ErrorReporter error_reporter = nullptr) const { auto n = strlen(s); - return parse(s, n, dt, path, log); + return parse(s, n, dt, path, log, error_reporter); } template Result parse_and_get_value(const char *s, size_t n, T &val, - const char *path = nullptr, - Log log = nullptr) const { + const char *path = nullptr, Log log = nullptr, + ErrorReporter error_reporter = nullptr) const { SemanticValues vs; std::any dt; - auto r = parse_core(s, n, vs, dt, path, log); + auto r = parse_core(s, n, vs, dt, path, log, error_reporter); if (r.ret && !vs.empty() && vs.front().has_value()) { val = std::any_cast(vs[0]); } @@ -2656,17 +3073,18 @@ public: template Result parse_and_get_value(const char *s, T &val, const char *path = nullptr, - Log log = nullptr) const { + Log log = nullptr, + ErrorReporter error_reporter = nullptr) const { auto n = strlen(s); - return parse_and_get_value(s, n, val, path, log); + return parse_and_get_value(s, n, val, path, log, error_reporter); } template Result parse_and_get_value(const char *s, size_t n, std::any &dt, T &val, - const char *path = nullptr, - Log log = nullptr) const { + const char *path = nullptr, Log log = nullptr, + ErrorReporter error_reporter = nullptr) const { SemanticValues vs; - auto r = parse_core(s, n, vs, dt, path, log); + auto r = parse_core(s, n, vs, dt, path, log, error_reporter); if (r.ret && !vs.empty() && vs.front().has_value()) { val = std::any_cast(vs[0]); } @@ -2675,10 +3093,10 @@ public: template Result parse_and_get_value(const char *s, std::any &dt, T &val, - const char *path = nullptr, - Log log = nullptr) const { + const char *path = nullptr, Log log = nullptr, + ErrorReporter error_reporter = nullptr) const { auto n = strlen(s); - return parse_and_get_value(s, n, dt, val, path, log); + return parse_and_get_value(s, n, dt, val, path, log, error_reporter); } #if defined(__cpp_lib_char8_t) @@ -2789,6 +3207,10 @@ public: std::string error_message; bool no_ast_opt = false; + bool no_whitespace = false; // Disable %whitespace skipping inside this rule + // (like a token boundary, without capturing) + std::string ast_name; // When non-empty, AST nodes produced by this rule carry + // this name/tag instead of the rule's own name bool eoi_check = true; @@ -2816,7 +3238,8 @@ private: void initialize_packrat_filter() const; Result parse_core(const char *s, size_t n, SemanticValues &vs, std::any &dt, - const char *path, Log log) const { + const char *path, Log log, + ErrorReporter error_reporter = nullptr) const { initialize_definition_ids(); std::shared_ptr ope = holder_; @@ -2827,22 +3250,28 @@ private: if (tracer_end) { tracer_end(trace_data); } }); + const std::vector *packrat_index = nullptr; + size_t packrat_cached_count = 0; + if (enablePackratParsing) { + initialize_packrat_filter(); + if (!packrat_index_.empty()) { + packrat_index = &packrat_index_; + packrat_cached_count = packrat_cached_count_; + } else { + packrat_cached_count = definition_ids_.size(); + } + } + Context c(path, s, n, definition_ids_.size(), whitespaceOpe, wordOpe, enablePackratParsing, tracer_enter, tracer_leave, trace_data, - verbose_trace, log); + verbose_trace, log, error_reporter, packrat_index, + packrat_cached_count); if (collect_packrat_stats) { packrat_stats_.resize(definition_ids_.size()); c.packrat_stats = &packrat_stats_; } - if (enablePackratParsing) { - initialize_packrat_filter(); - if (!packrat_filter_.empty()) { - c.packrat_rule_filter = &packrat_filter_; - } - } - size_t i = 0; if (whitespaceOpe) { @@ -2881,7 +3310,8 @@ private: mutable std::once_flag definition_ids_init_; mutable std::unordered_map definition_ids_; mutable std::once_flag packrat_filter_init_; - mutable std::vector packrat_filter_; + mutable std::vector packrat_index_; // def_id -> cache slot or -1 + mutable size_t packrat_cached_count_ = 0; }; /* @@ -2895,9 +3325,11 @@ inline size_t parse_literal(const char *s, size_t n, SemanticValues &vs, size_t i = 0; for (; i < lit.size(); i++) { if (i >= n || - (ignore_case ? (static_cast(std::tolower( - static_cast(s[i]))) != lower_lit[i]) - : (s[i] != lit[i]))) { + (ignore_case + ? (static_cast( + c.tolower_table[static_cast(s[i])]) != + lower_lit[i]) + : (s[i] != lit[i]))) { c.set_error_pos(s, lit.data()); return static_cast(-1); } @@ -2950,14 +3382,15 @@ inline std::pair SemanticValues::line_info() const { return c_->line_info(sv_.data()); } -inline void ErrorInfo::output_log(const Log &log, const char *s, size_t n) { +inline void ErrorInfo::output_log(const Log &log, const ErrorReporter &reporter, + const char *s, size_t n) { if (message_pos) { if (message_pos > last_output_pos) { last_output_pos = message_pos; auto line = line_info(s, message_pos); std::string msg; - if (auto unexpected_token = heuristic_error_token(s, n, message_pos); - !unexpected_token.empty()) { + auto unexpected_token = heuristic_error_token(s, n, message_pos); + if (!unexpected_token.empty()) { msg = replace_all(message, "%t", unexpected_token); auto unexpected_char = unexpected_token.substr( @@ -2968,13 +3401,28 @@ inline void ErrorInfo::output_log(const Log &log, const char *s, size_t n) { } else { msg = message; } - log(line.first, line.second, msg, label); + if (reporter) { + ErrorReport report; + report.line = line.first; + report.col = line.second; + report.position = static_cast(message_pos - s); + report.unexpected_token = unexpected_token; + report.message = msg; + report.label = label; + reporter(report); + } + if (log) { log(line.first, line.second, msg, label); } } } else if (error_pos) { if (error_pos > last_output_pos) { last_output_pos = error_pos; auto line = line_info(s, error_pos); + ErrorReport report; + report.line = line.first; + report.col = line.second; + report.position = static_cast(error_pos - s); + std::string msg; if (expected_tokens.empty()) { msg = "syntax error."; @@ -2987,6 +3435,7 @@ inline void ErrorInfo::output_log(const Log &log, const char *s, size_t n) { msg += ", unexpected '"; msg += unexpected_token; msg += "'"; + report.unexpected_token = unexpected_token; } auto first_item = true; @@ -3001,9 +3450,11 @@ inline void ErrorInfo::output_log(const Log &log, const char *s, size_t n) { msg += "'"; msg += error_literal; msg += "'"; + report.expected_literals.emplace_back(error_literal); } else { msg += "<" + error_rule->name + ">"; if (label.empty()) { label = error_rule->name; } + report.expected_rules.emplace_back(error_rule->name); } first_item = false; } @@ -3012,7 +3463,11 @@ inline void ErrorInfo::output_log(const Log &log, const char *s, size_t n) { } msg += "."; } - log(line.first, line.second, msg, label); + if (reporter) { + report.label = label; + reporter(report); + } + if (log) { log(line.first, line.second, msg, label); } } } } @@ -3027,7 +3482,7 @@ inline size_t Context::skip_whitespace(const char *a_s, size_t n, } inline void Context::set_error_pos(const char *a_s, const char *literal) { - if (log) { + if (log || error_reporter) { if (error_info.error_pos <= a_s) { if (error_info.error_pos < a_s || !error_info.keep_previous_token) { error_info.error_pos = a_s; @@ -3074,7 +3529,7 @@ inline void Context::trace_leave(const Ope &ope, const char *a_s, size_t n, } inline bool Context::is_traceable(const Ope &ope) const { - if (tracer_enter && tracer_leave) { + if (has_tracer) { if (ignore_trace_state) { return false; } return !dynamic_cast(&ope); } @@ -3169,14 +3624,56 @@ inline size_t TokenBoundary::parse_core(const char *s, size_t n, return len; } +// Resolve `%{name}` placeholders in a custom error message against the +// named captures recorded so far ($name<...>). Unknown names resolve to an +// empty string. `%t` / `%c` are resolved later, at log-output time. +inline std::string resolve_capture_placeholders(const std::string &msg, + const Context &c) { + auto pos = msg.find("%{"); + if (pos == std::string::npos) { return msg; } + + std::string r; + size_t i = 0; + while (pos != std::string::npos) { + auto end = msg.find('}', pos + 2); + if (end == std::string::npos) { break; } + r.append(msg, i, pos - i); + auto name = std::string_view(msg).substr(pos + 2, end - (pos + 2)); + for (auto it = c.capture_entries.rbegin(); it != c.capture_entries.rend(); + ++it) { + if (it->first == name) { + // The captured span can include whitespace skipped after a token + // boundary; trim it for display. + auto v = std::string_view(it->second); + while (!v.empty() && + std::isspace(static_cast(v.back()))) { + v.remove_suffix(1); + } + while (!v.empty() && + std::isspace(static_cast(v.front()))) { + v.remove_prefix(1); + } + r += v; + break; + } + } + i = end + 1; + pos = msg.find("%{", i); + } + r.append(msg, i, msg.size() - i); + return r; +} + inline size_t Holder::parse_core(const char *s, size_t n, SemanticValues &vs, Context &c, std::any &dt) const { if (!ope_) { throw std::logic_error("Uninitialized definition ope was used..."); } - // Macro reference - if (outer_->is_macro) { + // Macro reference. A left-recursive macro cannot take this path: it needs + // the seed-growing below, which in turn needs its own semantic value scope + // to memoise. Such a macro forms a scope like a plain rule does. + if (outer_->is_macro && !outer_->is_left_recursive) { c.rule_stack.push_back(outer_); auto len = ope_->parse(s, n, vs, c, dt); c.rule_stack.pop_back(); @@ -3201,7 +3698,23 @@ inline size_t Holder::parse_core(const char *s, size_t n, SemanticValues &vs, }); c.rule_stack.push_back(outer_); - parse_len = ope_->parse(s, n, chvs, c, dt); + if (outer_->no_whitespace) { + { + c.in_token_boundary_count++; + auto se2 = scope_exit([&]() { c.in_token_boundary_count--; }); + parse_len = ope_->parse(s, n, chvs, c, dt); + } + if (success(parse_len)) { + auto wl = c.skip_whitespace(s + parse_len, n - parse_len, chvs, dt); + if (fail(wl)) { + parse_len = wl; + } else { + parse_len += wl; + } + } + } else { + parse_len = ope_->parse(s, n, chvs, c, dt); + } c.rule_stack.pop_back(); if (success(parse_len)) { @@ -3221,7 +3734,8 @@ inline size_t Holder::parse_core(const char *s, size_t n, SemanticValues &vs, std::any predicate_data; if (outer_->predicate) { if (!outer_->predicate(chvs, dt, msg, predicate_data)) { - if (c.log && !msg.empty() && c.error_info.message_pos < s) { + if ((c.log || c.error_reporter) && !msg.empty() && + c.error_info.message_pos < s) { c.error_info.message_pos = s; c.error_info.message = msg; c.error_info.label = outer_->name; @@ -3233,17 +3747,19 @@ inline size_t Holder::parse_core(const char *s, size_t n, SemanticValues &vs, if (success(parse_len)) { if (!c.recovered) { parse_val = reduce(chvs, dt, predicate_data); } } else { - if (c.log && !msg.empty() && c.error_info.message_pos < s) { + if ((c.log || c.error_reporter) && !msg.empty() && + c.error_info.message_pos < s) { c.error_info.message_pos = s; c.error_info.message = msg; c.error_info.label = outer_->name; } } } else { - if (c.log && !outer_->error_message.empty() && + if ((c.log || c.error_reporter) && !outer_->error_message.empty() && c.error_info.message_pos < s) { c.error_info.message_pos = s; - c.error_info.message = outer_->error_message; + c.error_info.message = + resolve_capture_placeholders(outer_->error_message, c); c.error_info.label = outer_->name; } } @@ -3252,7 +3768,10 @@ inline size_t Holder::parse_core(const char *s, size_t n, SemanticValues &vs, }; if (outer_->is_left_recursive) { - auto lr_key = std::make_pair(outer_, s); + // A macro grows one seed per instantiation: Sum(D) and Sum(L) are + // different rules as far as the memo is concerned. + auto lr_rule = Context::LRRule(outer_, c.top_macro_inst()); + auto lr_key = Context::LRKey(lr_rule, s); // Check LR memo first auto it = c.lr_memo.find(lr_key); @@ -3265,7 +3784,7 @@ inline size_t Holder::parse_core(const char *s, size_t n, SemanticValues &vs, } // Record that this rule's lr_memo was accessed. // Any LR rule currently seeding will know we're in its cycle. - c.lr_refs_hit.insert(outer_); + c.lr_refs_hit.insert(lr_rule); } else { // Seed with FAIL c.lr_memo[lr_key] = {static_cast(-1), {}}; @@ -3287,7 +3806,7 @@ inline size_t Holder::parse_core(const char *s, size_t n, SemanticValues &vs, // the cycle, so add self — this lets parent seeders see us as // a transitive cycle member. auto cycle_rules = c.lr_refs_hit; - if (!cycle_rules.empty()) { cycle_rules.insert(outer_); } + if (!cycle_rules.empty()) { cycle_rules.insert(lr_rule); } // Restore parent's refs and propagate cycle info upward c.lr_refs_hit = std::move(saved_refs); @@ -3303,15 +3822,17 @@ inline size_t Holder::parse_core(const char *s, size_t n, SemanticValues &vs, c.lr_memo[lr_key] = {len, val}; while (true) { - // Clear this rule's packrat cache - c.clear_packrat_cache(s, outer_->id); + // Clear this rule's packrat cache. A macro is never written there + // (that cache is keyed by rule id alone, which cannot tell two + // instantiations apart), so there is nothing to clear for one. + if (!outer_->is_macro) { c.clear_packrat_cache(s, outer_->id); } // Clear lr_memo for cycle-dependent rules at this position, // but NOT for rules currently in their own seeding phase // (lr_active_seeds) — those are outer growers we must not // interfere with. for (auto memo_it = c.lr_memo.begin(); memo_it != c.lr_memo.end();) { - if (memo_it->first.second == s && memo_it->first.first != outer_ && + if (memo_it->first.second == s && memo_it->first.first != lr_rule && cycle_rules.count(memo_it->first.first) && !c.lr_active_seeds.count(memo_it->first)) { memo_it = c.lr_memo.erase(memo_it); @@ -3334,7 +3855,9 @@ inline size_t Holder::parse_core(const char *s, size_t n, SemanticValues &vs, // Write final result to packrat cache (lr_memo entry is kept as // the primary lookup for LR rules at this position) - if (success(len)) { c.write_packrat_cache(s, outer_->id, len, val); } + if (success(len) && !outer_->is_macro) { + c.write_packrat_cache(s, outer_->id, len, val); + } } } else { if (c.enablePackratParsing) { @@ -3348,7 +3871,7 @@ inline size_t Holder::parse_core(const char *s, size_t n, SemanticValues &vs, } else { // Without packrat, use lr_memo as re-entry guard to prevent // stack overflow from undetected left recursion. - auto guard_key = std::make_pair(outer_, s); + auto guard_key = Context::LRKey({outer_, c.top_macro_inst()}, s); if (c.lr_memo.count(guard_key)) { len = static_cast(-1); } else { @@ -3390,6 +3913,23 @@ inline const std::string &Holder::trace_name() const { return trace_name_; } +// Key a macro instantiation by what each argument denotes rather than by the +// node that spells it: `M(N)` written at two call sites builds two Reference +// nodes for the same rule N, and those are the same instantiation. +inline std::vector +macro_inst_key(const Definition *def, + const std::vector> &args) { + std::vector key; + key.reserve(args.size() + 1); + key.push_back(def); + for (const auto &arg : args) { + auto ref = dynamic_cast(arg.get()); + key.push_back(ref && ref->rule_ ? static_cast(ref->rule_) + : static_cast(arg.get())); + } + return key; +} + inline size_t Reference::parse_core(const char *s, size_t n, SemanticValues &vs, Context &c, std::any &dt) const { auto save_ignore_trace_state = c.ignore_trace_state; @@ -3412,7 +3952,10 @@ inline size_t Reference::parse_core(const char *s, size_t n, SemanticValues &vs, args.emplace_back(std::move(vis.found_ope)); } - c.push_args(std::move(args)); + auto inst = rule_->is_left_recursive + ? c.intern_macro_inst(macro_inst_key(rule_, args)) + : 0; + c.push_args(std::move(args), inst); auto se = scope_exit([&]() { c.pop_args(); }); return rule_->holder_->parse(s, n, vs, c, dt); } else { @@ -3548,11 +4091,12 @@ inline size_t Recovery::parse_core(const char *s, size_t n, const auto &rule = dynamic_cast(*ope_); // Custom error message - if (c.log) { + if (c.log || c.error_reporter) { auto label = dynamic_cast(rule.args_[0].get()); if (label && !label->rule_->error_message.empty()) { c.error_info.message_pos = s; - c.error_info.message = label->rule_->error_message; + c.error_info.message = + resolve_capture_placeholders(label->rule_->error_message, c); c.error_info.label = label->rule_->name; } } @@ -3561,8 +4105,13 @@ inline size_t Recovery::parse_core(const char *s, size_t n, auto len = static_cast(-1); { auto save_log = c.log; + auto save_reporter = c.error_reporter; c.log = nullptr; - auto se = scope_exit([&]() { c.log = save_log; }); + c.error_reporter = nullptr; + auto se = scope_exit([&]() { + c.log = save_log; + c.error_reporter = save_reporter; + }); SemanticValues dummy_vs; std::any dummy_dt; @@ -3573,8 +4122,8 @@ inline size_t Recovery::parse_core(const char *s, size_t n, if (success(len)) { c.recovered = true; - if (c.log) { - c.error_info.output_log(c.log, c.s, c.l); + if (c.log || c.error_reporter) { + c.error_info.output_log(c.log, c.error_reporter, c.s, c.l); c.error_info.clear(); } } @@ -3662,32 +4211,37 @@ inline void ComputeCanBeEmpty::visit(Reference &ope) { } inline void DetectLeftRecursion::visit(Reference &ope) { + // Macro parameter reference: what it denotes lives in an enclosing + // instantiation (e.g. B(X) <- C(X) where X is itself a param ref). + auto param = !ope.rule_ && !macro_args_stack_.empty() + ? resolve_macro_arg(ope.iarg_) + : ResolvedArg{}; + if (ope.name_ == name_) { error_s = ope.s_; - } else if (!ope.rule_ && !macro_args_stack_.empty()) { - // Macro parameter reference: resolve through nested macro arg - // stacks (e.g. B(X) <- C(X) where X is itself a param ref). - auto resolved = resolve_macro_arg(ope.iarg_); - if (resolved) { - resolved->accept(*this); - if (done_ == false) { return; } - } - } else if (!refs_.count(ope.name_)) { - refs_.insert(ope.name_); - if (ope.rule_) { - if (ope.is_macro_) { macro_args_stack_.push_back(&ope.args_); } - ope.rule_->accept(*this); - if (ope.is_macro_) { macro_args_stack_.pop_back(); } - if (done_ == false) { return; } - } + } else if (param.ope) { + visit_in_defining_scope(param); + if (done_ == false) { return; } + } else if (ope.is_macro_ && + macro_args_stack_.size() >= max_macro_inst_depth) { + // Unbounded instantiation chain; stop descending. + } else if (ope.rule_ && + refs_ + .emplace(ope.rule_, ope.is_macro_ ? intern_macro_inst(ope) : 0) + .second) { + if (ope.is_macro_) { macro_args_stack_.push_back(&ope.args_); } + ope.rule_->accept(*this); + if (ope.is_macro_) { macro_args_stack_.pop_back(); } + if (done_ == false) { return; } } // If the referenced rule can match empty, don't mark as done — // the sequence may continue past this element to find LR. if (!ope.rule_ && !macro_args_stack_.empty()) { - auto resolved = resolve_macro_arg(ope.iarg_); - if (resolved) { + if (param.ope) { + // ComputeCanBeEmpty never consults the frame stack, so the scope it + // runs in cannot matter. ComputeCanBeEmpty cbe; - resolved->accept(cbe); + param.ope->accept(cbe); done_ = !cbe.result; } else { done_ = true; @@ -3697,20 +4251,51 @@ inline void DetectLeftRecursion::visit(Reference &ope) { } } -inline std::shared_ptr +inline size_t DetectLeftRecursion::intern_macro_inst(const Reference &ope) { + // Resolve bare parameter references to what the enclosing instantiation was + // given, so a macro passing its own parameter through interns to the same + // instantiation instead of a fresh one at every nesting level. + std::vector> args; + args.reserve(ope.args_.size()); + for (const auto &arg : ope.args_) { + auto ref = dynamic_cast(arg.get()); + auto resolved = ref && !ref->rule_ && !macro_args_stack_.empty() + ? resolve_macro_arg(ref->iarg_).ope + : nullptr; + args.push_back(resolved ? resolved : arg); + } + auto [it, inserted] = macro_inst_ids_.emplace(macro_inst_key(ope.rule_, args), + next_macro_inst_); + if (inserted) { next_macro_inst_++; } + return it->second; +} + +inline void +DetectLeftRecursion::visit_in_defining_scope(const ResolvedArg &arg) { + // The frames below the one holding it are the scope it was written in. + // `W(X) <- Y(X / 'x')` passes Y an argument whose own `X` means W's + // parameter, not Y's -- leaving Y's frame visible would resolve that `X` + // right back to `X / 'x'`, forever. + auto saved = macro_args_stack_; + auto se = scope_exit([&]() { macro_args_stack_ = std::move(saved); }); + macro_args_stack_.resize(arg.depth); + arg.ope->accept(*this); +} + +inline DetectLeftRecursion::ResolvedArg DetectLeftRecursion::resolve_macro_arg(size_t iarg) const { for (int i = static_cast(macro_args_stack_.size()) - 1; i >= 0; i--) { auto &args = *macro_args_stack_[i]; - if (iarg >= args.size()) { return nullptr; } + if (iarg >= args.size()) { return {}; } auto ref = dynamic_cast(args[iarg].get()); if (ref && !ref->rule_) { // Another param ref — resolve using parent level's args iarg = ref->iarg_; continue; } - return args[iarg]; + return {args[iarg], static_cast(i)}; } - return nullptr; + return {}; } inline void HasEmptyElement::visit(Sequence &ope) { @@ -3863,8 +4448,16 @@ inline void ComputeFirstSet::visit(Reference &ope) { inline void SetupFirstSets::visit(Reference &ope) { if (!ope.rule_) { return; } - if (!visited_rules_.insert(ope.rule_).second) { return; } - ope.rule_->accept(*this); + ope.rule_->accept(*this); // re-entry is guarded at the rule's Holder +} + +// Guard rule setup by Definition so a SetupFirstSets shared across all rules +// visits each rule's body at most once for the whole grammar. Without this the +// per-rule setup re-walks every reachable rule once per referencing rule, which +// is O(N^2) for grammars with dense cross-references. +inline void SetupFirstSets::visit(Holder &ope) { + if (!visited_rules_.insert(ope.outer_).second) { return; } + ope.ope_->accept(*this); } inline void SetupFirstSets::visit(Sequence &ope) { @@ -3982,17 +4575,47 @@ inline void Definition::initialize_packrat_filter() const { auto def_count = definition_ids_.size(); if (def_count == 0) { return; } - // Collect rule IDs reachable from an Ope subtree (bitvector indexed by - // def_id) - struct CollectReachableRules : public TraversalVisitor { + // Collect rule IDs that can be invoked at the *same start position* as + // the given Ope subtree (leftmost reachability). A packrat cache hit + // requires the same rule to be queried twice at the same position, and + // in a PEG that only happens when alternatives of a choice share a + // leftmost prefix — rules reachable only past a consuming element can + // never be re-queried by a sibling alternative. + struct CollectLeftmostRules : public TraversalVisitor { using TraversalVisitor::visit; std::vector reachable; // indexed by def_id + std::vector + visited_rules; // indexed by def_id; guards Holder cycles - CollectReachableRules(size_t n) : reachable(n, false) {} + CollectLeftmostRules(size_t n) + : reachable(n, false), visited_rules(n, false) {} + // Collect from the position element `from` starts at: element `from` + // itself, plus what follows for as long as elements can match empty — + // only up to (and including) the first one that must consume input. + void collect(const std::vector> &opes, size_t from) { + for (auto i = from; i < opes.size(); i++) { + opes[i]->accept(*this); + ComputeCanBeEmpty empty_vis; + opes[i]->accept(empty_vis); + if (!empty_vis.result) { break; } + } + } + + void visit(Sequence &ope) override { collect(ope.opes_, 0); } void visit(Holder &ope) override { auto id = ope.outer_->id; - if (id < reachable.size()) { reachable[id] = true; } + if (id < reachable.size()) { + reachable[id] = true; + + // Grammars built directly via the combinator API embed rules through + // WeakHolder rather than Reference, so a recursive rule forms a + // Holder cycle with no Reference to break it. Guard re-entry to avoid + // infinite recursion (reachability is monotone, so revisiting a rule + // we have already traversed adds nothing). + if (visited_rules[id]) { return; } + visited_rules[id] = true; + } ope.ope_->accept(*this); } void visit(Reference &ope) override { @@ -4004,7 +4627,8 @@ inline void Definition::initialize_packrat_filter() const { } }; - // Find rules that benefit: reachable from 2+ alternatives of same choice + // Find rules that benefit: queried by 2+ alternatives of the same choice + // at the same position std::vector benefits(def_count, false); struct FindBacktrackRules : public TraversalVisitor { @@ -4016,24 +4640,60 @@ inline void Definition::initialize_packrat_filter() const { FindBacktrackRules(std::vector &b, size_t n) : benefits(b), def_count(n), visited_rules(n, false) {} - void visit(PrioritizedChoice &ope) override { - // For each alternative, collect reachable rules as bitvectors - std::vector> alt_reachable; - for (auto &op : ope.opes_) { - CollectReachableRules crr(def_count); - op->accept(crr); - alt_reachable.push_back(std::move(crr.reachable)); - } + using Elements = std::vector>; - // Mark rules reachable from 2+ alternatives + // An alternative's top-level elements, so a shared prefix can be walked + // element by element. By value: this runs once per grammar. + static Elements elements_of(const std::shared_ptr &alt) { + if (auto *seq = dynamic_cast(alt.get())) { + return seq->opes_; + } + return {alt}; + } + + // `group` holds alternatives that agree on their first `k` elements, so + // every one of them reaches element k at the same input position — that + // is exactly when a packrat cache entry can hit. k == 0 is the plain + // "alternatives of one choice" case; deeper k is what a shared prefix + // like `'(' _ PATTERN _ ',' _` hides. + void mark_aligned(const std::vector &group, size_t k) { + if (group.size() < 2) { return; } + + std::vector> reachable; + reachable.reserve(group.size()); + for (const auto &seq : group) { + CollectLeftmostRules clr(def_count); + clr.collect(seq, k); + reachable.push_back(std::move(clr.reachable)); + } for (size_t id = 0; id < def_count; id++) { size_t count = 0; - for (auto &alt : alt_reachable) { + for (const auto &alt : reachable) { if (alt[id]) { count++; } } if (count >= 2) { benefits[id] = true; } } + // Only alternatives that also agree on element k stay aligned past it. + std::map> aligned; + for (const auto &seq : group) { + if (k < seq.size()) { + aligned[OpeSignature::get(*seq[k])].push_back(seq); + } + } + for (const auto &[sig, sub] : aligned) { + mark_aligned(sub, k + 1); + } + } + + void visit(PrioritizedChoice &ope) override { + std::vector group; + group.reserve(ope.opes_.size()); + for (const auto &op : ope.opes_) { + group.push_back(elements_of(op)); + } + mark_aligned(group, 0); + // Recurse into alternatives for (auto &op : ope.opes_) { op->accept(*this); @@ -4056,7 +4716,23 @@ inline void Definition::initialize_packrat_filter() const { if (whitespaceOpe) { whitespaceOpe->accept(finder); } if (wordOpe) { wordOpe->accept(finder); } - packrat_filter_ = std::move(benefits); + // Left-recursive rules read and write the packrat cache directly during + // seed-growing, so they must stay in the cached set. Macros are the + // exception: they use lr_memo only, keyed by instantiation. + for (const auto &[ptr, id] : definition_ids_) { + auto *def = static_cast(ptr); + if (def->is_left_recursive && !def->is_macro && id < def_count) { + benefits[id] = true; + } + } + + // Compact index: def_id -> slot in the cache tables (-1 = guard only) + packrat_index_.assign(def_count, -1); + int32_t k = 0; + for (size_t id = 0; id < def_count; id++) { + if (benefits[id]) { packrat_index_[id] = k++; } + } + packrat_cached_count_ = static_cast(k); }); } @@ -4094,6 +4770,419 @@ inline void FindReference::visit(Reference &ope) { found_ope = ope.shared_from_this(); } +/*----------------------------------------------------------------------------- + * Grammar serialization + * + * Serialize a compiled Grammar (the operator tree) to a byte blob and back, + * letting an application skip the meta-parse on startup by embedding a + * prebuilt blob. Structure only: semantic callbacks (actions / enter / leave / + * predicate, attached by enable_ast() etc.) are NOT serialized and must be + * re-applied after deserialize. References resolve by name (no pointer fixup); + * first-sets and keyword guards are recomputed on load (O(N)). The + * `precedence` instruction is supported (its operator table is structural). + * Grammars using the `User` operator or a Capture with a match action are + * rejected. The blob is specific to this peglib version's layout. + *---------------------------------------------------------------------------*/ + +struct GrammarBlob { + enum Tag : uint8_t { + T_Sequence, + T_Choice, + T_Repetition, + T_And, + T_Not, + T_Dictionary, + T_Literal, + T_CharClass, + T_Char, + T_AnyChar, + T_CaptureScope, + T_Capture, + T_TokenBoundary, + T_Ignore, + T_BackRef, + T_Reference, + T_Whitespace, + T_Recovery, + T_Cut, + T_PrecedenceClimbing, + T_Null + }; + + struct Writer { + std::vector b; + void u8(uint8_t v) { b.push_back(v); } + void u32(uint32_t v) { + for (int i = 0; i < 4; i++) + b.push_back((v >> (8 * i)) & 0xff); + } + void u64(uint64_t v) { + for (int i = 0; i < 8; i++) + b.push_back((v >> (8 * i)) & 0xff); + } + void str(const std::string &s) { + u32((uint32_t)s.size()); + b.insert(b.end(), s.begin(), s.end()); + } + }; + + static void write_ope(Writer &w, const std::shared_ptr &o) { + if (!o) { + w.u8(T_Null); + return; + } + Ope *p = o.get(); + if (auto x = dynamic_cast(p)) { + w.u8(T_Sequence); + w.u32((uint32_t)x->opes_.size()); + for (auto &c : x->opes_) + write_ope(w, c); + } else if (auto x = dynamic_cast(p)) { + w.u8(T_Choice); + w.u8(x->for_label_ ? 1 : 0); + w.u32((uint32_t)x->opes_.size()); + for (auto &c : x->opes_) + write_ope(w, c); + } else if (auto x = dynamic_cast(p)) { + w.u8(T_Repetition); + w.u64(x->min_); + w.u64(x->max_); + write_ope(w, x->ope_); + } else if (auto x = dynamic_cast(p)) { + w.u8(T_And); + write_ope(w, x->ope_); + } else if (auto x = dynamic_cast(p)) { + w.u8(T_Not); + write_ope(w, x->ope_); + } else if (auto x = dynamic_cast(p)) { + w.u8(T_Dictionary); + w.u8(x->trie_.ignore_case_ ? 1 : 0); + // Recover words in their original choice-index order. The Trie stores + // each full word's id (its index in the constructor vector), which + // parse_core reports as vs.choice(). Iterating dic_ directly yields + // sorted key order and would renumber the choices, so place each word at + // its id. + std::vector words(x->trie_.items_count()); + for (auto &kv : x->trie_.dic_) + if (kv.second.match && kv.second.id < words.size()) + words[kv.second.id] = kv.first; + w.u32((uint32_t)words.size()); + for (auto &s : words) + w.str(s); + } else if (auto x = dynamic_cast(p)) { + w.u8(T_Literal); + w.u8(x->ignore_case_ ? 1 : 0); + w.str(x->lit_); + } else if (auto x = dynamic_cast(p)) { + w.u8(T_CharClass); + w.u8(x->negated_ ? 1 : 0); + w.u8(x->ignore_case_ ? 1 : 0); + w.u32((uint32_t)x->ranges_.size()); + for (auto &r : x->ranges_) { + w.u32((uint32_t)r.first); + w.u32((uint32_t)r.second); + } + } else if (auto x = dynamic_cast(p)) { + w.u8(T_Char); + w.u32((uint32_t)x->ch_); + } else if (dynamic_cast(p)) { + w.u8(T_AnyChar); + } else if (auto x = dynamic_cast(p)) { + w.u8(T_CaptureScope); + write_ope(w, x->ope_); + } else if (auto x = dynamic_cast(p)) { + if (x->match_action_) { + throw std::runtime_error( + "GrammarBlob: Capture with a match action is not serializable"); + } + w.u8(T_Capture); + write_ope(w, x->ope_); + } else if (auto x = dynamic_cast(p)) { + w.u8(T_TokenBoundary); + write_ope(w, x->ope_); + } else if (auto x = dynamic_cast(p)) { + w.u8(T_Ignore); + write_ope(w, x->ope_); + } else if (auto x = dynamic_cast(p)) { + w.u8(T_BackRef); + w.str(x->name_); + } else if (auto x = dynamic_cast(p)) { + w.u8(T_Reference); + w.u8(x->is_macro_ ? 1 : 0); + w.str(x->name_); + w.u32((uint32_t)x->args_.size()); + for (auto &a : x->args_) + write_ope(w, a); + } else if (auto x = dynamic_cast(p)) { + w.u8(T_Whitespace); + write_ope(w, x->ope_); + } else if (auto x = dynamic_cast(p)) { + w.u8(T_Recovery); + write_ope(w, x->ope_); + } else if (dynamic_cast(p)) { + w.u8(T_Cut); + } else if (auto x = dynamic_cast(p)) { + w.u8(T_PrecedenceClimbing); + write_ope(w, x->atom_); + write_ope(w, x->binop_); + w.u32((uint32_t)x->info_.size()); + for (auto &[key, pri] : x->info_) { + w.str(std::string(key)); + w.u64((uint64_t)pri.first); + w.u8((uint8_t)pri.second); + } + } else { + throw std::runtime_error( + "GrammarBlob: operator not serializable (a custom User operator or " + "a Capture with a match action)"); + } + } + + struct Reader { + const uint8_t *p, *end; + uint8_t u8() { + if (p >= end) + throw std::runtime_error("GrammarBlob: unexpected end of blob"); + return *p++; + } + uint32_t u32() { + uint32_t v = 0; + for (int i = 0; i < 4; i++) + v |= (uint32_t)u8() << (8 * i); + return v; + } + uint64_t u64() { + uint64_t v = 0; + for (int i = 0; i < 8; i++) + v |= (uint64_t)u8() << (8 * i); + return v; + } + std::string str() { + uint32_t n = u32(); + std::string s((const char *)p, (const char *)p + n); + p += n; + return s; + } + }; + + static std::shared_ptr read_ope(Reader &r, Grammar &g, + Definition *owner) { + switch (r.u8()) { + case T_Null: return nullptr; + case T_Sequence: { + uint32_t n = r.u32(); + std::vector> v; + for (uint32_t i = 0; i < n; i++) + v.push_back(read_ope(r, g, owner)); + return std::make_shared(std::move(v)); + } + case T_Choice: { + bool fl = r.u8(); + uint32_t n = r.u32(); + std::vector> v; + for (uint32_t i = 0; i < n; i++) + v.push_back(read_ope(r, g, owner)); + auto c = std::make_shared(std::move(v)); + c->for_label_ = fl; + return c; + } + case T_Repetition: { + uint64_t mn = r.u64(), mx = r.u64(); + auto o = read_ope(r, g, owner); + return std::make_shared(o, mn, mx); + } + case T_And: return std::make_shared(read_ope(r, g, owner)); + case T_Not: return std::make_shared(read_ope(r, g, owner)); + case T_Dictionary: { + bool ic = r.u8(); + uint32_t n = r.u32(); + std::vector words; + for (uint32_t i = 0; i < n; i++) + words.push_back(r.str()); + return std::make_shared(words, ic); + } + case T_Literal: { + bool ic = r.u8(); + std::string s = r.str(); + return std::make_shared(std::move(s), ic); + } + case T_CharClass: { + bool neg = r.u8(), ic = r.u8(); + uint32_t n = r.u32(); + std::vector> ranges; + for (uint32_t i = 0; i < n; i++) { + auto lo = r.u32(), hi = r.u32(); + ranges.emplace_back((char32_t)lo, (char32_t)hi); + } + return std::make_shared(ranges, neg, ic); + } + case T_Char: return std::make_shared((char32_t)r.u32()); + case T_AnyChar: return std::make_shared(); + case T_CaptureScope: + return std::make_shared(read_ope(r, g, owner)); + case T_Capture: { + auto o = read_ope(r, g, owner); + return std::make_shared(o, nullptr); + } + case T_TokenBoundary: + return std::make_shared(read_ope(r, g, owner)); + case T_Ignore: return std::make_shared(read_ope(r, g, owner)); + case T_BackRef: return std::make_shared(r.str()); + case T_Reference: { + bool im = r.u8(); + std::string nm = r.str(); + uint32_t n = r.u32(); + std::vector> args; + for (uint32_t i = 0; i < n; i++) + args.push_back(read_ope(r, g, owner)); + return std::make_shared(g, nm, nullptr, im, args); + } + case T_Whitespace: + return std::make_shared(read_ope(r, g, owner)); + case T_Recovery: return std::make_shared(read_ope(r, g, owner)); + case T_Cut: return std::make_shared(); + case T_PrecedenceClimbing: { + if (!owner) { + throw std::runtime_error( + "GrammarBlob: 'precedence' operator outside a rule body"); + } + auto atom = read_ope(r, g, owner); + auto binop = read_ope(r, g, owner); + uint32_t n = r.u32(); + auto pc = std::make_shared( + atom, binop, PrecedenceClimbing::BinOpeInfo{}, *owner); + // info_ keys are string_views; back them with owned strings whose + // addresses stay stable (reserve avoids reallocation, and the node is + // never moved once held by shared_ptr). + pc->info_keys_.reserve(n); + for (uint32_t i = 0; i < n; i++) { + std::string key = r.str(); + auto level = (size_t)r.u64(); + auto assoc = (char)r.u8(); + pc->info_keys_.push_back(std::move(key)); + pc->info_[pc->info_keys_.back()] = std::pair(level, assoc); + } + return pc; + } + default: throw std::runtime_error("GrammarBlob: bad operator tag"); + } + } + + static const uint32_t MAGIC = 0x50454732; // "PEG2" + + static std::vector serialize(const Grammar &g, + const std::string &start) { + Writer w; + w.u32(MAGIC); + w.str(start); + w.u32((uint32_t)g.size()); + // Grammar is an unordered_map, whose iteration order is implementation + // defined: walking it directly yields different bytes for the same grammar + // on different standard libraries, so a blob generated on one platform + // cannot be byte-compared on another. Emit the definitions by name. + // deserialize() rebuilds the map from the names, so the order carries no + // meaning of its own. + std::vector defs; + defs.reserve(g.size()); + for (auto &kv : g) + defs.push_back(&kv); + std::sort(defs.begin(), defs.end(), + [](const auto *a, const auto *b) { return a->first < b->first; }); + for (auto *kv : defs) { + const auto &name = kv->first; + const auto &def = kv->second; + w.str(name); + uint8_t flags = + (def.ignoreSemanticValue ? 1 : 0) | (def.is_macro ? 2 : 0) | + (def.no_ast_opt ? 4 : 0) | (def.eoi_check ? 8 : 0) | + (def.enablePackratParsing ? 16 : 0) | + (def.is_left_recursive ? 32 : 0) | (def.can_be_empty ? 64 : 0) | + (def.disable_action ? 128 : 0); + w.u8(flags); + uint8_t flags2 = (def.no_whitespace ? 1 : 0); + w.u8(flags2); + w.u32((uint32_t)def.params.size()); + for (auto &s : def.params) + w.str(s); + w.str(def.ast_name); + w.str(def.error_message); + write_ope(w, const_cast(def).get_core_operator()); + } + return std::move(w.b); + } + + static std::shared_ptr deserialize(const std::vector &blob, + std::string &start_out) { + Reader r{blob.data(), blob.data() + blob.size()}; + if (r.u32() != MAGIC) + throw std::runtime_error("GrammarBlob: bad magic / not a grammar blob"); + start_out = r.str(); + uint32_t ndef = r.u32(); + auto g = std::make_shared(); + // Create each Definition before reading its body: a PrecedenceClimbing node + // needs a stable reference to its owning rule at construction. Grammar is a + // node-based map, so references stay valid as later rules are inserted. + for (uint32_t i = 0; i < ndef; i++) { + std::string name = r.str(); + uint8_t flags = r.u8(); + uint8_t flags2 = r.u8(); + uint32_t np = r.u32(); + std::vector params; + for (uint32_t k = 0; k < np; k++) + params.push_back(r.str()); + std::string ast_name = r.str(); + std::string err = r.str(); + + auto &def = (*g)[name]; + def.name = name; + def.ignoreSemanticValue = flags & 1; + def.is_macro = flags & 2; + def.no_ast_opt = flags & 4; + def.eoi_check = flags & 8; + def.enablePackratParsing = flags & 16; + def.is_left_recursive = flags & 32; + def.can_be_empty = flags & 64; + def.disable_action = flags & 128; + def.no_whitespace = flags2 & 1; + def.params = std::move(params); + def.ast_name = std::move(ast_name); + def.error_message = std::move(err); + + auto body = read_ope(r, *g, &def); + def <= body; + } + for (auto &x : *g) { + LinkReferences vis(*g, x.second.params); + x.second.accept(vis); + // TraversalVisitor descends only into a PrecedenceClimbing's atom_. In + // the from-source path binop_ is linked while the body is still a + // Sequence, before precedence lowering; a deserialized node is built + // already lowered so its binop_ reference must be linked explicitly here. + auto core = x.second.get_core_operator(); + if (auto pc = std::dynamic_pointer_cast(core)) { + pc->binop_->accept(vis); + } + } + { + SetupFirstSets vis; // shared across rules -> O(N) + for (auto &x : *g) + x.second.accept(vis); + } + // Re-derive automatic whitespace/word skipping on the start rule from the + // %whitespace / %word definitions, exactly as ParserGenerator does. Sharing + // the (already linked and first-set) definition operators avoids leaving + // references inside the skipping ope unlinked, and keeps the blob smaller. + if (g->count(WHITESPACE_DEFINITION_NAME)) { + (*g)[start_out].whitespaceOpe = + wsp((*g)[WHITESPACE_DEFINITION_NAME].get_core_operator()); + } + if (g->count(WORD_DEFINITION_NAME)) { + (*g)[start_out].wordOpe = (*g)[WORD_DEFINITION_NAME].get_core_operator(); + } + return g; + } +}; + /*----------------------------------------------------------------------------- * PEG parser generator *---------------------------------------------------------------------------*/ @@ -4140,6 +5229,17 @@ private: ParserGenerator() { make_grammar(); setup_actions(); + // Apply First-Set filtering to the bootstrap meta-grammar itself so that + // parsing a grammar (the bulk of load_grammar) skips alternatives whose + // next byte cannot match. This is safe -- First-Set filtering only skips + // alternatives that would have failed anyway, so no semantic action that + // would have committed is skipped (unlike packrat, which is unsound here). + { + SetupFirstSets vis; + for (auto &x : g) { + x.second.accept(vis); + } + } } struct Instruction { @@ -4181,11 +5281,13 @@ private: void make_grammar() { // Setup PEG syntax parser g["Grammar"] <= seq(g["Spacing"], oom(g["Definition"]), g["EndOfFile"]); - g["Definition"] <= - cho(seq(g["Ignore"], g["IdentCont"], g["Parameters"], g["LEFTARROW"], - g["Expression"], opt(g["Instruction"])), - seq(g["Ignore"], g["Identifier"], g["LEFTARROW"], g["Expression"], - opt(g["Instruction"]))); + // Left-factored: parse the rule name (IdentCont) once, then optionally the + // macro parameter list. `opt(Parameters)` pushes a value only for a macro + // (so the value layout matches the old two-alternative form), and Spacing + // (~, no value) consumes the gap before LEFTARROW that Identifier used to. + g["Definition"] <= seq(g["Ignore"], g["IdentCont"], opt(g["Parameters"]), + g["Spacing"], g["LEFTARROW"], g["Expression"], + opt(g["Instruction"])); g["Expression"] <= seq(g["Sequence"], zom(seq(g["SLASH"], g["Sequence"]))); g["Sequence"] <= zom(cho(g["CUT"], g["Prefix"])); g["Prefix"] <= seq(opt(cho(g["AND"], g["NOT"])), g["SuffixWithLabel"]); @@ -4193,17 +5295,19 @@ private: seq(g["Suffix"], opt(seq(g["LABEL"], g["Identifier"]))); g["Suffix"] <= seq(g["Primary"], opt(g["Loop"])); g["Loop"] <= cho(g["QUESTION"], g["STAR"], g["PLUS"], g["Repetition"]); - g["Primary"] <= cho(seq(g["Ignore"], g["IdentCont"], g["Arguments"], - npd(g["LEFTARROW"])), - seq(g["Ignore"], g["Identifier"], - npd(seq(opt(g["Parameters"]), g["LEFTARROW"]))), - seq(g["OPEN"], g["Expression"], g["CLOSE"]), - seq(g["BeginTok"], g["Expression"], g["EndTok"]), - g["CapScope"], - seq(g["BeginCap"], g["Expression"], g["EndCap"]), - g["BackRef"], g["DictionaryI"], g["LiteralI"], - g["Dictionary"], g["Literal"], g["NegatedClassI"], - g["NegatedClass"], g["ClassI"], g["Class"], g["DOT"]); + // Left-factored: a macro reference (`Name(args)`) and a plain reference + // (`Name`) share the leading `Ignore IdentCont`, so parse it once and let + // `opt(Arguments)` decide. opt() pushes the argument list only for a macro + // reference, so vs.size() distinguishes the two in the action. + g["Primary"] <= + cho(seq(g["Ignore"], g["IdentCont"], opt(g["Arguments"]), g["Spacing"], + npd(seq(opt(g["Parameters"]), g["LEFTARROW"]))), + seq(g["OPEN"], g["Expression"], g["CLOSE"]), + seq(g["BeginTok"], g["Expression"], g["EndTok"]), g["CapScope"], + seq(g["BeginCap"], g["Expression"], g["EndCap"]), g["BackRef"], + g["DictionaryI"], g["LiteralI"], g["Dictionary"], g["Literal"], + g["NegatedClassI"], g["NegatedClass"], g["ClassI"], g["Class"], + g["DOT"]); g["Identifier"] <= seq(g["IdentCont"], g["Spacing"]); g["IdentCont"] <= tok(seq(g["IdentStart"], zom(g["IdentRest"]))); @@ -4252,8 +5356,12 @@ private: // NOTE: This is different from The original Brian Ford's paper, and this // modification allows us to specify `[+-]` as a valid char class. - g["Range"] <= - cho(seq(g["Char"], chr('-'), npd(chr(']')), g["Char"]), g["Char"]); + g["Range"] <= cho(seq(g["Char"], chr('-'), npd(chr(']')), g["Char"]), + g["ClassEscape"], g["PosixClass"], g["Char"]); + + g["ClassEscape"] <= seq(chr('\\'), cls("dDwWsS")); + g["PosixClass"] <= + seq(lit("[:"), opt(chr('^')), oom(cls("a-z")), lit(":]")); g["Char"] <= cho(seq(chr('\\'), cls("fnrtv'\"[]\\^-")), @@ -4323,8 +5431,8 @@ private: opt(seq(g["InstructionItem"], zom(seq(g["InstructionItemSeparator"], g["InstructionItem"])))), g["EndBracket"]); - g["InstructionItem"] <= - cho(g["PrecedenceClimbing"], g["ErrorMessage"], g["NoAstOpt"]); + g["InstructionItem"] <= cho(g["PrecedenceClimbing"], g["ErrorMessage"], + g["NoAstOpt"], g["NoWhitespace"], g["AstName"]); ~g["InstructionItemSeparator"] <= seq(chr(';'), g["Spacing"]); ~g["SpacesZom"] <= zom(g["Space"]); @@ -4357,6 +5465,13 @@ private: // No Ast node optimization instruction g["NoAstOpt"] <= seq(lit("no_ast_opt"), g["SpacesZom"]); + // No whitespace skipping instruction + g["NoWhitespace"] <= seq(lit("no_whitespace"), g["SpacesZom"]); + + // AST node name override instruction: `{ ast_name: NodeTag }` + g["AstName"] <= seq(lit("ast_name"), g["SpacesZom"], lit(":"), + g["SpacesZom"], g["Identifier"], g["SpacesZom"]); + // Set definition names for (auto &x : g) { x.second.name = x.first; @@ -4367,7 +5482,10 @@ private: g["Definition"] = [&](const SemanticValues &vs, std::any &dt) { auto &data = *std::any_cast(dt); - auto is_macro = vs.choice() == 0; + // Macro iff the optional Parameters matched: its value (the parameter + // name list) then sits at vs[2]. A plain definition has LEFTARROW's value + // there instead. + auto is_macro = vs[2].type() == typeid(std::vector); auto ignore = std::any_cast(vs[0]); auto name = std::any_cast(vs[1]); @@ -4393,9 +5511,6 @@ private: if (types.find(type) == types.end()) { data.instructions[name].push_back(instruction); types.insert(instruction.type); - if (type == "declare_symbol" || type == "check_symbol") { - if (!TokenChecker::is_token(*ope)) { ope = tok(ope); } - } } else { data.duplicates_of_instruction.emplace_back(type, instruction.sv.data()); @@ -4414,7 +5529,9 @@ private: rule.is_macro = is_macro; rule.params = params; - if (data.start.empty()) { + // Reserved `%`-prefixed rules (%whitespace, %word, ...) are directives, + // not parseable entry points, so they must not become the start rule. + if (data.start.empty() && name[0] != '%') { data.start = rule.name; data.start_pos = rule.s_; } @@ -4531,9 +5648,9 @@ private: auto &data = *std::any_cast(dt); switch (vs.choice()) { - case 0: // Macro Reference - case 1: { // Reference - auto is_macro = vs.choice() == 0; + case 0: { // Reference / Macro reference (left-factored) + // Macro reference iff opt(Arguments) matched and pushed the arg list. + auto is_macro = vs.size() > 2; auto ignore = std::any_cast(vs[0]); const auto &ident = std::any_cast(vs[1]); @@ -4551,16 +5668,16 @@ private: return ope; } } - case 2: { // (Expression) + case 1: { // (Expression) return std::any_cast>(vs[0]); } - case 3: { // TokenBoundary + case 2: { // TokenBoundary return tok(std::any_cast>(vs[0])); } - case 4: { // CaptureScope + case 3: { // CaptureScope return csc(std::any_cast>(vs[0])); } - case 5: { // Capture + case 4: { // Capture const auto &name = std::any_cast(vs[0]); auto ope = std::any_cast>(vs[1]); @@ -4607,23 +5724,36 @@ private: return resolve_escape_sequence(tok.data(), tok.size()); }; - g["Class"] = [](const SemanticValues &vs) { - auto ranges = vs.transform>(); - return cls(ranges); + // A Range produces either a single range (std::pair) or a range list + // (std::vector) for `\d`-style escapes and POSIX classes. + auto collect_ranges = [](const SemanticValues &vs) { + std::vector> ranges; + for (const auto &v : vs) { + if (v.type() == typeid(std::pair)) { + ranges.push_back(std::any_cast>(v)); + } else { + const auto &vec = + std::any_cast> &>( + v); + ranges.insert(ranges.end(), vec.begin(), vec.end()); + } + } + return ranges; }; - g["ClassI"] = [](const SemanticValues &vs) { - auto ranges = vs.transform>(); - return cls(ranges, true); + + g["Class"] = [collect_ranges](const SemanticValues &vs) { + return cls(collect_ranges(vs)); }; - g["NegatedClass"] = [](const SemanticValues &vs) { - auto ranges = vs.transform>(); - return ncls(ranges); + g["ClassI"] = [collect_ranges](const SemanticValues &vs) { + return cls(collect_ranges(vs), true); }; - g["NegatedClassI"] = [](const SemanticValues &vs) { - auto ranges = vs.transform>(); - return ncls(ranges, true); + g["NegatedClass"] = [collect_ranges](const SemanticValues &vs) { + return ncls(collect_ranges(vs)); }; - g["Range"] = [](const SemanticValues &vs) { + g["NegatedClassI"] = [collect_ranges](const SemanticValues &vs) { + return ncls(collect_ranges(vs), true); + }; + g["Range"] = [](const SemanticValues &vs) -> std::any { switch (vs.choice()) { case 0: { auto s1 = std::any_cast(vs[0]); @@ -4636,7 +5766,10 @@ private: } return std::pair(cp1, cp2); } - case 1: { + case 1: // ClassEscape + case 2: // PosixClass + return vs[0]; + case 3: { auto s = std::any_cast(vs[0]); auto cp = decode_codepoint(s.data(), s.length()); return std::pair(cp, cp); @@ -4644,6 +5777,33 @@ private: } return std::pair(0, 0); }; + g["ClassEscape"] = [](const SemanticValues &vs) { + auto ch = vs.sv()[1]; + const char *name = nullptr; + switch (ch) { + case 'd': + case 'D': name = "digit"; break; + case 's': + case 'S': name = "space"; break; + default: name = "word"; break; + } + auto ranges = *predefined_character_class(name); + if (ch == 'D' || ch == 'S' || ch == 'W') { + ranges = complement_character_ranges(ranges); + } + return ranges; + }; + g["PosixClass"] = [](const SemanticValues &vs) { + auto sv = vs.sv(); // `[:name:]` or `[:^name:]` + auto negated = sv[2] == '^'; + auto name = sv.substr(negated ? 3 : 2, sv.size() - (negated ? 5 : 4)); + auto ranges = predefined_character_class(name); + if (!ranges) { + auto msg = "invalid POSIX character class '" + std::string(name) + "'"; + throw SyntaxErrorException(msg.c_str(), vs.line_info()); + } + return negated ? complement_character_ranges(*ranges) : *ranges; + }; g["Char"] = [](const SemanticValues &vs) { return resolve_escape_sequence(vs.sv().data(), vs.sv().length()); }; @@ -4773,6 +5933,21 @@ private: return instruction; }; + g["NoWhitespace"] = [](const SemanticValues &vs) { + Instruction instruction; + instruction.type = "no_whitespace"; + instruction.sv = vs.sv(); + return instruction; + }; + + g["AstName"] = [](const SemanticValues &vs) { + Instruction instruction; + instruction.type = "ast_name"; + instruction.data = std::any_cast(vs[0]); + instruction.sv = vs.sv(); + return instruction; + }; + g["Instruction"] = [](const SemanticValues &vs) { return vs.transform(); }; @@ -5076,14 +6251,23 @@ private: rule.error_message = std::any_cast(instruction.data); } else if (instruction.type == "no_ast_opt") { rule.no_ast_opt = true; + } else if (instruction.type == "no_whitespace") { + rule.no_whitespace = true; + } else if (instruction.type == "ast_name") { + rule.ast_name = std::any_cast(instruction.data); } } } - // Setup First-Set and ISpan optimizations - for (auto &x : grammar) { + // Setup First-Set and ISpan optimizations. A single visitor is shared + // across all rules so its first-set cache and visited-rule set persist: + // each rule's first-sets are computed once (O(N)) instead of re-walking + // every reachable rule once per referencing rule (O(N^2)). + { SetupFirstSets vis; - x.second.accept(vis); + for (auto &x : grammar) { + x.second.accept(vis); + } } return {data.grammar, start, data.enablePackratParsing}; @@ -5117,21 +6301,24 @@ template struct AstBase : public Annotation { AstBase(const char *path, size_t line, size_t column, const char *name, const std::vector> &nodes, size_t position = 0, size_t length = 0, size_t choice_count = 0, - size_t choice = 0) + size_t choice = 0, bool preserve_position = false) : path(path ? path : ""), line(line), column(column), name(name), position(position), length(length), choice_count(choice_count), choice(choice), original_name(name), original_choice_count(choice_count), original_choice(choice), - tag(str2tag(name)), original_tag(tag), is_token(false), nodes(nodes) {} + tag(str2tag(name)), original_tag(tag), is_token(false), + preserve_position(preserve_position), nodes(nodes) {} AstBase(const char *path, size_t line, size_t column, const char *name, const std::string_view &token, size_t position = 0, size_t length = 0, - size_t choice_count = 0, size_t choice = 0) + size_t choice_count = 0, size_t choice = 0, + bool preserve_position = false) : path(path ? path : ""), line(line), column(column), name(name), position(position), length(length), choice_count(choice_count), choice(choice), original_name(name), original_choice_count(choice_count), original_choice(choice), - tag(str2tag(name)), original_tag(tag), is_token(true), token(token) {} + tag(str2tag(name)), original_tag(tag), is_token(true), + preserve_position(preserve_position), token(token) {} AstBase(const AstBase &ast, const char *original_name, size_t position = 0, size_t length = 0, size_t original_choice_count = 0, @@ -5142,7 +6329,8 @@ template struct AstBase : public Annotation { original_choice_count(original_choice_count), original_choice(original_choice), tag(ast.tag), original_tag(str2tag(original_name)), is_token(ast.is_token), - token(ast.token), nodes(ast.nodes), parent(ast.parent) {} + preserve_position(ast.preserve_position), token(ast.token), + nodes(ast.nodes), parent(ast.parent) {} const std::string path; const size_t line = 1; @@ -5160,6 +6348,7 @@ template struct AstBase : public Annotation { const unsigned int original_tag; const bool is_token; + const bool preserve_position; const std::string_view token; std::vector>> nodes; @@ -5222,8 +6411,10 @@ struct AstOptimizer { if (opt && original->nodes.size() == 1) { auto child = optimize(original->nodes[0], parent); - auto ast = std::make_shared(*child, original->name.data(), - original->position, original->length, + auto pos = + child->preserve_position ? child->position : original->position; + auto len = child->preserve_position ? child->length : original->length; + auto ast = std::make_shared(*child, original->name.data(), pos, len, original->choice_count, original->choice); for (auto &node : ast->nodes) { node->parent = ast; @@ -5253,18 +6444,23 @@ template void add_ast_action(Definition &rule) { rule.action = [&](const SemanticValues &vs) { auto line = vs.line_info(); + // `{ ast_name: X }` overrides the node's name/tag (falls back to the + // rule's own name when unset). + const char *node_name = + rule.ast_name.empty() ? rule.name.data() : rule.ast_name.data(); + if (rule.is_token()) { return std::make_shared( - vs.path, line.first, line.second, rule.name.data(), vs.token(), + vs.path, line.first, line.second, node_name, vs.token(), std::distance(vs.ss, vs.sv().data()), vs.sv().length(), - vs.choice_count(), vs.choice()); + vs.choice_count(), vs.choice(), rule.no_ast_opt); } - auto ast = - std::make_shared(vs.path, line.first, line.second, rule.name.data(), - vs.transform>(), - std::distance(vs.ss, vs.sv().data()), - vs.sv().length(), vs.choice_count(), vs.choice()); + auto ast = std::make_shared(vs.path, line.first, line.second, node_name, + vs.transform>(), + std::distance(vs.ss, vs.sv().data()), + vs.sv().length(), vs.choice_count(), + vs.choice(), rule.no_ast_opt); for (auto &node : ast->nodes) { node->parent = ast; @@ -5461,10 +6657,32 @@ public: return load_grammar(sv.data(), sv.size(), Rules(), start); } + // Serialize the loaded grammar to a portable byte blob (see GrammarBlob). + // Semantic callbacks are not included; throws if the grammar is not + // serializable (uses the `User` operator or a Capture with a match action). + std::vector serialize_grammar() const { + return GrammarBlob::serialize(*grammar_, start_); + } + + // Load a grammar from a blob produced by serialize_grammar() / GrammarBlob, + // skipping the meta-parse. Re-apply enable_ast() etc. afterwards as needed. + bool load_blob(const std::vector &blob) { + try { + grammar_ = GrammarBlob::deserialize(blob, start_); + } catch (const std::exception &) { return false; } + if (grammar_ != nullptr) { + // Symmetry with load_grammar(): restore the parser-level packrat flag + // from the blob so a later enable_packrat_parsing() re-applies it + // instead of resetting the start rule to the false member default. + enablePackratParsing_ = (*grammar_)[start_].enablePackratParsing; + } + return grammar_ != nullptr; + } + bool parse_n(const char *s, size_t n, const char *path = nullptr) const { if (grammar_ != nullptr) { const auto &rule = (*grammar_)[start_]; - auto result = rule.parse(s, n, path, log_); + auto result = rule.parse(s, n, path, log_, error_reporter_); return post_process(s, n, result); } return false; @@ -5474,7 +6692,7 @@ public: const char *path = nullptr) const { if (grammar_ != nullptr) { const auto &rule = (*grammar_)[start_]; - auto result = rule.parse(s, n, dt, path, log_); + auto result = rule.parse(s, n, dt, path, log_, error_reporter_); return post_process(s, n, result); } return false; @@ -5485,7 +6703,8 @@ public: const char *path = nullptr) const { if (grammar_ != nullptr) { const auto &rule = (*grammar_)[start_]; - auto result = rule.parse_and_get_value(s, n, val, path, log_); + auto result = + rule.parse_and_get_value(s, n, val, path, log_, error_reporter_); return post_process(s, n, result); } return false; @@ -5496,7 +6715,8 @@ public: const char *path = nullptr) const { if (grammar_ != nullptr) { const auto &rule = (*grammar_)[start_]; - auto result = rule.parse_and_get_value(s, n, dt, val, path, log_); + auto result = + rule.parse_and_get_value(s, n, dt, val, path, log_, error_reporter_); return post_process(s, n, result); } return false; @@ -5613,6 +6833,12 @@ public: void set_logger(Log log) { log_ = log; } + // Receive structured error information instead of (or in addition to) the + // formatted string passed to the logger. + void set_error_reporter(ErrorReporter reporter) { + error_reporter_ = reporter; + } + void set_logger( std::function log) { @@ -5622,14 +6848,20 @@ public: private: bool post_process(const char *s, size_t n, Definition::Result &r) const { - if (log_ && !r.ret) { r.error_info.output_log(log_, s, n); } + if ((log_ || error_reporter_) && !r.ret) { + r.error_info.output_log(log_, error_reporter_, s, n); + } return r.ret && !r.recovered; } std::vector get_no_ast_opt_rules() const { std::vector rules; for (auto &[name, rule] : *grammar_) { - if (rule.no_ast_opt) { rules.push_back(name); } + // The optimizer keeps nodes by their emitted name, so honor the + // `ast_name` override when present (else the rule's own name). + if (rule.no_ast_opt) { + rules.push_back(rule.ast_name.empty() ? name : rule.ast_name); + } } return rules; } @@ -5639,6 +6871,7 @@ private: bool enableLeftRecursion_ = true; bool enablePackratParsing_ = false; Log log_; + ErrorReporter error_reporter_; }; /*----------------------------------------------------------------------------- diff --git a/libcockatrice_utility/libcockatrice/utility/playmat_params.h b/libcockatrice_utility/libcockatrice/utility/playmat_params.h new file mode 100644 index 000000000..c64a6f2ae --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/playmat_params.h @@ -0,0 +1,52 @@ +#ifndef COCKATRICE_PLAYMAT_PARAMS_H +#define COCKATRICE_PLAYMAT_PARAMS_H + +#include "card_ref.h" + +#include + +/** + * @struct PlaymatParams + * @ingroup Decks + * @brief Positioning parameters for a playmat card image. + * + * Controls how the cropped card art is positioned within the + * combined table+stack play area. The coordinate system is + * relative to the cropped art source image. + */ +struct PlaymatParams +{ + double marginPctL = 0.07; ///< Left margin as fraction of card width (0.0–0.95). + double marginPctR = 0.07; ///< Right margin as fraction of card width (0.0–0.95). + double verticalOffset = 0.33; ///< Vertical position within card (0.0=top, 1.0=bottom). + double zoom = 1.0; ///< Scale factor (0.1–4.0). + + bool operator==(const PlaymatParams &other) const + { + return qFuzzyCompare(marginPctL, other.marginPctL) && qFuzzyCompare(marginPctR, other.marginPctR) && + qFuzzyCompare(verticalOffset, other.verticalOffset) && qFuzzyCompare(zoom, other.zoom); + } + + bool operator!=(const PlaymatParams &other) const + { + return !(*this == other); + } +}; + +/** + * @struct PlaymatInfo + * @ingroup Decks + * @brief A resolved playmat (card + positioning parameters). + */ +struct PlaymatInfo +{ + CardRef card; ///< The card whose art is used as playmat. + PlaymatParams params; ///< Positioning parameters for the playmat card image. + + bool operator==(const PlaymatInfo &other) const + { + return card == other.card && params == other.params; + } +}; + +#endif // COCKATRICE_PLAYMAT_PARAMS_H diff --git a/libcockatrice_utility/libcockatrice/utility/qt_utils.h b/libcockatrice_utility/libcockatrice/utility/qt_utils.h index 334e56027..8e5212031 100644 --- a/libcockatrice_utility/libcockatrice/utility/qt_utils.h +++ b/libcockatrice_utility/libcockatrice/utility/qt_utils.h @@ -1,5 +1,6 @@ #ifndef COCKATRICE_QT_UTILS_H #define COCKATRICE_QT_UTILS_H +#include #include namespace QtUtils diff --git a/libcockatrice_utility/libcockatrice/utility/server_rate_limiter.cpp b/libcockatrice_utility/libcockatrice/utility/server_rate_limiter.cpp new file mode 100644 index 000000000..2a1e3b283 --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/server_rate_limiter.cpp @@ -0,0 +1,85 @@ +#include "server_rate_limiter.h" + +bool ServerRateLimiter::isRateLimited(const QString &host, const QDateTime &now) const +{ + auto it = backoffUntil.constFind(host); + return it != backoffUntil.constEnd() && now < it.value(); +} + +int ServerRateLimiter::rounds(const QString &host) const +{ + return retryRounds.value(host, 0); +} + +QDateTime ServerRateLimiter::deadline(const QString &host) const +{ + return backoffUntil.value(host); +} + +QDateTime ServerRateLimiter::earliestDeadline(const QDateTime &now) const +{ + QDateTime earliest; + for (auto it = backoffUntil.cbegin(); it != backoffUntil.cend(); ++it) { + if (now < it.value() && (!earliest.isValid() || it.value() < earliest)) { + earliest = it.value(); + } + } + return earliest; +} + +QDateTime ServerRateLimiter::on429(const QString &host, const QDateTime &now, qint64 retryAfterMs) +{ + QDateTime existing = backoffUntil.value(host); + int round = retryRounds.value(host, 0); + + if (!existing.isValid() || now >= existing) { + if (last429.value(host).isValid() && now >= last429.value(host).addMSecs(RESET_GRACE_MS)) { + round = 0; + } + round = qMin(round + 1, MAX_RETRIES); + retryRounds.insert(host, round); + } + last429.insert(host, now); + + qint64 delay; + if (round >= MAX_RETRIES) { + delay = EXHAUSTED_BACKOFF_MS; + } else { + delay = qMax(MIN_429_BACKOFF_MS, retryAfterMs); + delay = qMin(delay, MAX_BACKOFF_MS); + } + + QDateTime deadline = now.addMSecs(delay); + if (existing.isValid() && existing > deadline) { + deadline = existing; // never shorten an active backoff + } + backoffUntil.insert(host, deadline); + + return deadline; +} + +void ServerRateLimiter::onSuccess(const QString &host) +{ + backoffUntil.remove(host); + retryRounds.remove(host); + last429.remove(host); +} + +void ServerRateLimiter::clearExpired(const QDateTime &now) +{ + auto it = backoffUntil.begin(); + while (it != backoffUntil.end()) { + if (now < it.value()) { + ++it; + continue; + } + + QString host = it.key(); + bool budgetStillFresh = last429.value(host).isValid() && now < last429.value(host).addMSecs(RESET_GRACE_MS); + it = backoffUntil.erase(it); + if (!budgetStillFresh) { + retryRounds.remove(host); + last429.remove(host); + } + } +} diff --git a/libcockatrice_utility/libcockatrice/utility/server_rate_limiter.h b/libcockatrice_utility/libcockatrice/utility/server_rate_limiter.h new file mode 100644 index 000000000..e071b9189 --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/server_rate_limiter.h @@ -0,0 +1,73 @@ +#ifndef SERVER_RATE_LIMITER_H +#define SERVER_RATE_LIMITER_H + +#include +#include +#include + +/** + * @class ServerRateLimiter + * @ingroup Utility + * @brief Tracks per-server backoff state triggered by HTTP 429 responses. + * + * Keeps a monotonic backoff deadline per host and a retry-round budget that + * escalates once per backoff window (a burst of concurrent 429s counts as a + * single round). The budget is refreshed after a long period without 429s so a + * server is not permanently blacklisted after a past overload. + */ +class ServerRateLimiter +{ +public: + static constexpr int MAX_RETRIES = 5; ///< Max 429 rounds per host before the budget is exhausted + static constexpr int MIN_429_BACKOFF_MS = 30000; ///< Minimum wait after a 429 (scryfall documented cool-down) + static constexpr int MAX_BACKOFF_MS = 60000; ///< Cap for a single backoff period + static constexpr int EXHAUSTED_BACKOFF_MS = 60000; ///< Cool-down once the retry budget is exhausted + static constexpr qint64 RESET_GRACE_MS = 300000; ///< Idle time after which the retry budget refreshes + + /** + * @brief Checks whether a host is currently in backoff. + * @param host The host to check + * @param now The current time + * @return True if requests to the host should be paused + */ + [[nodiscard]] bool isRateLimited(const QString &host, const QDateTime &now) const; + + /** @return The number of consecutive 429 rounds recorded for the host. */ + [[nodiscard]] int rounds(const QString &host) const; + + /** @return The current backoff deadline for the host, or an invalid QDateTime. */ + [[nodiscard]] QDateTime deadline(const QString &host) const; + + /** @return The earliest active backoff deadline across all hosts, or an invalid QDateTime. */ + [[nodiscard]] QDateTime earliestDeadline(const QDateTime &now) const; + + /** + * @brief Registers a 429 response for the given host. + * @param host The host that returned 429 + * @param now The time the response was received + * @param retryAfterMs A Retry-After hint in milliseconds, or 0 if absent + * @return The new backoff deadline for the host + * + * The round counter only escalates once the previous backoff window has + * fully passed, so concurrent 429s from a burst do not consume the whole + * budget. The deadline is extended monotonically and never shortened. + */ + QDateTime on429(const QString &host, const QDateTime &now, qint64 retryAfterMs = 0); + + /** @brief Clears all penalty state for a host after a successful request. */ + void onSuccess(const QString &host); + + /** + * @brief Removes expired backoffs, refreshing the round budget of hosts + * that have been idle past the reset grace period. + * @param now The current time + */ + void clearExpired(const QDateTime &now); + +private: + QMap backoffUntil; ///< When each host may be contacted again + QMap retryRounds; ///< Consecutive 429 rounds per host + QMap last429; ///< When each host was last rate limited +}; + +#endif // SERVER_RATE_LIMITER_H diff --git a/libcockatrice_utility/libcockatrice/utility/string_limits.h b/libcockatrice_utility/libcockatrice/utility/string_limits.h new file mode 100644 index 000000000..5079ec46d --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/string_limits.h @@ -0,0 +1,44 @@ +#ifndef STRING_LIMITS_H +#define STRING_LIMITS_H + +#include +#include +#include + +/** @brief Max size for short strings, like names and things that are generally a single phrase. */ +constexpr int MAX_NAME_LENGTH = 0xff; +/** @brief Max size for chat messages and text contents. */ +constexpr int MAX_TEXT_LENGTH = 0xfff; +/** @brief Max size for deck files and pictures (about 2 megabytes). */ +constexpr int MAX_FILE_LENGTH = 0x1fffff; + +/** @brief Returns a QString from a std::string, truncated to at most MAX_NAME_LENGTH bytes. */ +inline QString nameFromStdString(const std::string &_string) +{ + return QString::fromUtf8(_string.data(), std::min(int(_string.size()), MAX_NAME_LENGTH)); +} +/** @brief Returns a QString from a std::string, truncated to at most MAX_TEXT_LENGTH bytes. */ +inline QString textFromStdString(const std::string &_string) +{ + return QString::fromUtf8(_string.data(), std::min(int(_string.size()), MAX_TEXT_LENGTH)); +} +/** @brief Returns a QString from a std::string, truncated to at most MAX_TEXT_LENGTH bytes, keeping the tail. */ +inline QString textTailFromStdString(const std::string &_string) +{ + if (int(_string.size()) <= MAX_TEXT_LENGTH) { + return QString::fromUtf8(_string.data(), int(_string.size())); + } + + int start = int(_string.size()) - MAX_TEXT_LENGTH; + while (start < int(_string.size()) && (static_cast(_string[start]) & 0xC0) == 0x80) { + ++start; + } + return QString::fromUtf8(_string.data() + start, int(_string.size()) - start); +} +/** @brief Returns a QString from a std::string, truncated to at most MAX_FILE_LENGTH bytes. */ +inline QString fileFromStdString(const std::string &_string) +{ + return QString::fromUtf8(_string.data(), std::min(int(_string.size()), MAX_FILE_LENGTH)); +} + +#endif // STRING_LIMITS_H diff --git a/libcockatrice_utility/libcockatrice/utility/trice_limits.h b/libcockatrice_utility/libcockatrice/utility/trice_limits.h deleted file mode 100644 index 833ce1b98..000000000 --- a/libcockatrice_utility/libcockatrice/utility/trice_limits.h +++ /dev/null @@ -1,38 +0,0 @@ -#ifndef TRICE_LIMITS_H -#define TRICE_LIMITS_H - -#include - -// max size for short strings, like names and things that are generally a single phrase -constexpr int MAX_NAME_LENGTH = 0xff; -// max size for chat messages and text contents -constexpr int MAX_TEXT_LENGTH = 0xfff; -// max size for deck files and pictures -constexpr int MAX_FILE_LENGTH = 0x1fffff; // about 2 megabytes - -constexpr uint MINIMUM_DIE_SIDES = 2; -constexpr uint MAXIMUM_DIE_SIDES = 1000000; -constexpr uint MINIMUM_DICE_TO_ROLL = 1; -constexpr uint MAXIMUM_DICE_TO_ROLL = 100; - -// Card counter value bounds [0, MAX_COUNTERS_ON_CARD]. -// Counters on cards (e.g., +1/+1 counters, charge counters) are non-negative physical game objects. -// The max of 999 is a display constraint (3-digit rendering) and reasonable gameplay limit. -// Server enforces these bounds; client may also check for UX optimization. -constexpr int MAX_COUNTERS_ON_CARD = 999; - -// optimized functions to get qstrings that are at most that long -static inline QString nameFromStdString(const std::string &_string) -{ - return QString::fromUtf8(_string.data(), std::min(int(_string.size()), MAX_NAME_LENGTH)); -} -static inline QString textFromStdString(const std::string &_string) -{ - return QString::fromUtf8(_string.data(), std::min(int(_string.size()), MAX_TEXT_LENGTH)); -} -static inline QString fileFromStdString(const std::string &_string) -{ - return QString::fromUtf8(_string.data(), std::min(int(_string.size()), MAX_FILE_LENGTH)); -} - -#endif // TRICE_LIMITS_H diff --git a/libcockatrice_utility/libcockatrice/utility/warning_categories.cpp b/libcockatrice_utility/libcockatrice/utility/warning_categories.cpp new file mode 100644 index 000000000..b5ca563ff --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/warning_categories.cpp @@ -0,0 +1,24 @@ +#include "warning_categories.h" + +QList parseWarningCategories(const QString &value) +{ + QList categories; + const QStringList entries = value.split(',', Qt::SkipEmptyParts); + for (const QString &entry : entries) { + const QStringList parts = entry.split('|'); + WarningCategory category; + category.name = parts.first().trimmed(); + if (category.name.isEmpty()) { + continue; + } + if (parts.size() > 1) { + bool ok = false; + const int il = parts.at(1).trimmed().toInt(&ok); + if (ok && il > 0) { + category.startingIl = il; + } + } + categories.append(category); + } + return categories; +} diff --git a/libcockatrice_utility/libcockatrice/utility/warning_categories.h b/libcockatrice_utility/libcockatrice/utility/warning_categories.h new file mode 100644 index 000000000..ce5308cf4 --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/warning_categories.h @@ -0,0 +1,28 @@ +#ifndef WARNING_CATEGORIES_H +#define WARNING_CATEGORIES_H + +#include +#include + +/** + * A warning category the server offers to moderators, optionally carrying a + * recommended starting intervention level (see the moderator guide). + */ +struct WarningCategory +{ + QString name; + int startingIl = 1; +}; + +/** + * Parses the `server/officialwarnings` setting value into warning categories. + * + * Entries are separated by commas. Each entry is a category name, optionally + * followed by "|" and the recommended starting intervention level: + * "Abusive Language|1,Cheating|2,Spamming" + * Entries without an explicit level default to intervention level 1. + * Empty entries are skipped. + */ +QList parseWarningCategories(const QString &value); + +#endif // WARNING_CATEGORIES_H diff --git a/oracle/CMakeLists.txt b/oracle/CMakeLists.txt index a51982625..392184b6e 100644 --- a/oracle/CMakeLists.txt +++ b/oracle/CMakeLists.txt @@ -23,11 +23,12 @@ set(oracle_SOURCES src/pages.cpp src/pagetemplates.cpp src/parsehelpers.cpp - src/qt-json/json.cpp + src/raw_json_scanner.cpp ../cockatrice/src/client/settings/cache_settings.cpp ../cockatrice/src/client/settings/card_counter_settings.cpp ../cockatrice/src/client/settings/shortcuts_settings.cpp ../cockatrice/src/client/network/update/client/release_channel.cpp + ../cockatrice/src/interface/pixel_map_generator.cpp ../cockatrice/src/interface/theme_config.cpp ../cockatrice/src/interface/theme_manager.cpp ../cockatrice/src/interface/widgets/quick_settings/settings_button_widget.cpp @@ -64,11 +65,7 @@ set(oracle_RESOURCES oracle.qrc) # ------------------------ # Qt resources # ------------------------ -if(Qt6_FOUND) - qt6_add_resources(oracle_RESOURCES_RCC ${oracle_RESOURCES}) -elseif(Qt5_FOUND) - qt5_add_resources(oracle_RESOURCES_RCC ${oracle_RESOURCES}) -endif() +qt6_add_resources(oracle_RESOURCES_RCC ${oracle_RESOURCES}) # ------------------------ # Include directories @@ -106,37 +103,18 @@ set(ORACLE_MAC_QM_INSTALL_DIR "oracle.app/Contents/Resources/translations") set(ORACLE_UNIX_QM_INSTALL_DIR "share/oracle/translations") set(ORACLE_WIN32_QM_INSTALL_DIR "translations") -if(Qt6_FOUND) - # Qt6 Translations are linked after the executable is created in manual mode - qt6_add_executable( - oracle - WIN32 - MACOSX_BUNDLE - ${oracle_SOURCES} - ${oracle_RESOURCES_RCC} - ${oracle_MOC_SRCS} - MANUAL_FINALIZATION - ) -elseif(Qt5_FOUND) - # Qt5 Translations need to be linked at executable creation time - if(Qt5LinguistTools_FOUND) - if(UPDATE_TRANSLATIONS) - qt5_create_translation(oracle_QM ${translate_SRCS} ${oracle_TS}) - else() - qt5_add_translation(oracle_QM ${oracle_TS}) - endif() - endif() - add_executable(oracle WIN32 MACOSX_BUNDLE ${oracle_MOC_SRCS} ${oracle_QM} ${oracle_RESOURCES_RCC} ${oracle_SOURCES}) - if(UNIX) - if(APPLE) - install(FILES ${oracle_QM} DESTINATION ${ORACLE_MAC_QM_INSTALL_DIR}) - else() - install(FILES ${oracle_QM} DESTINATION ${ORACLE_UNIX_QM_INSTALL_DIR}) - endif() - elseif(WIN32) - install(FILES ${oracle_QM} DESTINATION ${ORACLE_WIN32_QM_INSTALL_DIR}) - endif() -endif() +# Qt6 Translations are linked after the executable is created in manual mode +qt6_add_executable( + oracle + WIN32 + MACOSX_BUNDLE + ${oracle_SOURCES} + ${oracle_RESOURCES_RCC} + ${oracle_MOC_SRCS} + MANUAL_FINALIZATION +) + +target_precompile_headers(oracle PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtwidgets_pch.h") # ------------------------ # Link libraries @@ -285,7 +263,7 @@ endif() # ------------------------ # Qt translations # ------------------------ -if(Qt6_FOUND AND Qt6LinguistTools_FOUND) +if(Qt6LinguistTools_FOUND) #Qt6 Translations happen after the executable is built up if(UPDATE_TRANSLATIONS) qt6_add_translations( @@ -312,6 +290,4 @@ if(Qt6_FOUND AND Qt6LinguistTools_FOUND) endif() endif() -if(Qt6_FOUND) - qt6_finalize_target(oracle) -endif() +qt6_finalize_target(oracle) diff --git a/oracle/oracle_en@source.ts b/oracle/oracle_en@source.ts index 943b44a97..b3263f861 100644 --- a/oracle/oracle_en@source.ts +++ b/oracle/oracle_en@source.ts @@ -278,7 +278,7 @@ OracleImporter - + Dummy set containing tokens @@ -286,7 +286,7 @@ OracleWizard - + Oracle Importer diff --git a/oracle/src/main.cpp b/oracle/src/main.cpp index 5def0c887..0f962f9be 100644 --- a/oracle/src/main.cpp +++ b/oracle/src/main.cpp @@ -10,6 +10,7 @@ #include #include #include +#include QTranslator *translator, *qtTranslator; ThemeManager *themeManager; @@ -21,14 +22,10 @@ bool isBackgrounded; void installNewTranslator() { - QString lang = SettingsCache::instance().getLang(); + QString lang = SettingsCache::instance().personal().getLang(); QString qtNameHint = "qt_" + lang; -#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) QString qtTranslationPath = QLibraryInfo::path(QLibraryInfo::TranslationsPath); -#else - QString qtTranslationPath = QLibraryInfo::location(QLibraryInfo::TranslationsPath); -#endif bool qtTranslationLoaded = qtTranslator->load(qtNameHint, qtTranslationPath); if (!qtTranslationLoaded) { @@ -53,8 +50,8 @@ int main(int argc, char *argv[]) QApplication app(argc, argv); QCoreApplication::setOrganizationName("Cockatrice"); - QCoreApplication::setOrganizationDomain("cockatrice"); - // this can't be changed, as it influences the default save path for cards.xml + QCoreApplication::setOrganizationDomain("Cockatrice"); + // This can't be changed, as it influences the default save path for cards.xml QCoreApplication::setApplicationName("Cockatrice"); // If the program is opened with the -s flag, it will only do spoilers. Otherwise it will do MTGJSON/Tokens @@ -86,7 +83,7 @@ int main(int argc, char *argv[]) QIcon icon("theme:appicon.svg"); wizard.setWindowIcon(icon); // set name of the app desktop file; used by wayland to load the window icon - QGuiApplication::setDesktopFileName("oracle"); + QGuiApplication::setDesktopFileName("Oracle"); wizard.show(); diff --git a/oracle/src/oracleimporter.cpp b/oracle/src/oracleimporter.cpp index bace63508..88b522197 100644 --- a/oracle/src/oracleimporter.cpp +++ b/oracle/src/oracleimporter.cpp @@ -3,12 +3,16 @@ #include "libcockatrice/interfaces/noop_card_preference_provider.h" #include "libcockatrice/interfaces/noop_card_set_priority_controller.h" #include "parsehelpers.h" -#include "qt-json/json.h" #include +#include +#include +#include #include +#include #include #include +#include #include #include @@ -18,9 +22,10 @@ static const QList kSingletonCounts = {{1, "legal"}, {0, "banned"} SplitCardPart::SplitCardPart(const QString &_name, const QString &_text, - const QVariantHash &_properties, - const PrintingInfo &_printingInfo) - : name(_name), text(_text), properties(_properties), printingInfo(_printingInfo) + const QHash &_properties, + const PrintingInfo &_printingInfo, + const QString &_localizedText) + : name(_name), text(_text), localizedText(_localizedText), properties(_properties), printingInfo(_printingInfo) { } @@ -30,6 +35,42 @@ OracleImporter::OracleImporter(QObject *parent) : QObject(parent) { } +void OracleImporter::setCardLang(const QString &lang) +{ + cardLang = lang.trimmed().toLower(); + localizationEnabled = cardLang != "en" && CardLocalization::supportedLanguages().contains(cardLang); +} + +/** + * @brief Maps the MTGJSON foreignData language names to the short codes used by + * Scryfall and stored in cards.xml (e.g. "German" -> "de"). + * @param language The language name found in the MTGJSON foreignData entries. + * @return The short language code, or an empty string if unknown. + */ +static QString mtgjsonLanguageToCode(const QString &language) +{ + static const QHash map = { + {"Chinese Simplified", "zhs"}, + {"Chinese Traditional", "zht"}, + {"English", "en"}, + {"French", "fr"}, + {"German", "de"}, + {"Greek", "grc"}, + {"Ancient Greek", "grc"}, + {"Hebrew", "he"}, + {"Italian", "it"}, + {"Japanese", "ja"}, + {"Korean", "ko"}, + {"Latin", "la"}, + {"Phyrexian", "ph"}, + {"Portuguese (Brazil)", "pt"}, + {"Russian", "ru"}, + {"Sanskrit", "sa"}, + {"Spanish", "es"}, + }; + return map.value(language); +} + static CardSet::Priority getSetPriority(const QString &setType, const QString &shortName) { if (!setTypePriorities.contains(setType.toLower())) { @@ -42,31 +83,37 @@ static CardSet::Priority getSetPriority(const QString &setType, const QString &s return priority; } -bool OracleImporter::readSetsFromByteArray(const QByteArray &data) +bool OracleImporter::readSetsFromByteArray(QByteArray data) { - bool ok; - auto setsMap = QtJson::Json::parse(QString(data), ok).toMap().value("data").toMap(); - if (!ok) { - qDebug() << "error: QtJson::Json::parse()"; + const RawJson::ScanProgressCallback progress = + progressReporting + ? [this]( + qsizetype bytesRead, + qsizetype + totalBytes) { emit dataReadProgress(static_cast(bytesRead), static_cast(totalBytes)); } + : RawJson::ScanProgressCallback{}; + + RawJson::ScanError error; + const QList ranges = RawJson::scanSetRanges(data, &error, progress); + if (error.isError()) { + qDebug() << "error: RawJson::scanSetRanges():" << error.message; return false; } QList newSetList; + newSetList.reserve(ranges.size()); - QListIterator it(setsMap.values()); - - while (it.hasNext()) { - QVariantMap map = it.next().toMap(); - QString shortName = map.value("code").toString().toUpper(); - QString longName = map.value("name").toString(); - QList setCards = map.value("cards").toList(); - QString setType = map.value("type").toString(); - QDate releaseDate = map.value("releaseDate").toDate(); + for (const RawJson::SetRange &range : ranges) { + QString shortName = range.code.toUpper(); + QString longName = range.name; + QString setType = range.type; + QDate releaseDate = QDate::fromString(range.releaseDate, Qt::ISODate); CardSet::Priority priority = getSetPriority(setType, shortName); // capitalize set type if (setType.length() > 0) { // basic grammar for words that aren't capitalized, like in "From the Vault" - const QStringList noCapitalize = {"the", "a", "an", "on", "to", "for", "of", "in", "and", "with", "or"}; + static const QStringList noCapitalize = {"the", "a", "an", "on", "to", "for", + "of", "in", "and", "with", "or"}; QStringList words = setType.split("_"); setType.clear(); bool first = false; @@ -74,13 +121,15 @@ bool OracleImporter::readSetsFromByteArray(const QByteArray &data) if (first && noCapitalize.contains(item)) { setType += item + QString(" "); } else { - setType += item[0].toUpper() + item.mid(1, -1) + QString(" "); + setType += item[0].toUpper() + item.mid(1) + QString(" "); first = true; } } setType = setType.trimmed(); } - newSetList.append(SetToDownload(shortName, longName, setCards, priority, setType, releaseDate)); + SetToDownload set(shortName, longName, priority, setType, releaseDate); + set.setRawRange(range.dataRange); + newSetList.append(set); } std::sort(newSetList.begin(), newSetList.end()); @@ -89,19 +138,34 @@ bool OracleImporter::readSetsFromByteArray(const QByteArray &data) return false; } allSets = newSetList; + rawSetsData = std::move(data); return true; } +/** + * The priority order used to pick a card's main type when a card has multiple + * types (e.g. "Artifact Creature") or multiple faces (e.g. split/adventure cards). + * A lower index means a higher priority. + */ +static const QStringList MAIN_CARD_TYPE_PRIORITY = {"Planeswalker", "Creature", "Land", "Sorcery", + "Instant", "Artifact", "Enchantment"}; + +/** + * Returns the priority (index) of the given main card type. Known types map to their + * position in {@link mainCardTypePriority()}, unknown types map to -1 (lowest priority). + */ +static int mainCardTypePriority(const QString &mainCardType) +{ + return MAIN_CARD_TYPE_PRIORITY.indexOf(mainCardType); +} + static QString getMainCardType(const QStringList &typeList) { if (typeList.isEmpty()) { return {}; } - static const QStringList typePriority = {"Planeswalker", "Creature", "Land", "Sorcery", - "Instant", "Artifact", "Enchantment"}; - - for (const auto &type : typePriority) { + for (const auto &type : MAIN_CARD_TYPE_PRIORITY) { if (typeList.contains(type)) { return type; } @@ -122,29 +186,26 @@ static void sortAndReduceColors(QString &colors) std::sort(colors.begin(), colors.end(), [](const QChar a, const QChar b) { return colorOrder.value(a, INT_MAX) < colorOrder.value(b, INT_MAX); }); // reduce - QChar lastChar = '\0'; - for (int i = 0; i < colors.size(); ++i) { - if (colors.at(i) == lastChar) { - colors.remove(i, 1); - } else { - lastChar = colors.at(i); - } - } + auto last = std::unique(colors.begin(), colors.end()); + colors.erase(last, colors.end()); } CardInfoPtr OracleImporter::addCard(QString name, const QString &text, bool isToken, - QVariantHash properties, + QHash properties, const QList &relatedCards, const PrintingInfo &printingInfo) { // Workaround for card name weirdness name = name.replace("Æ", "AE"); name = name.replace("’", "'"); - if (cards.contains(name)) { - CardInfoPtr card = cards.value(name); + auto existingIt = cards.constFind(name); + if (existingIt != cards.constEnd()) { + CardInfoPtr card = existingIt.value(); card->addToSet(printingInfo.getSet(), printingInfo); + // Only merge legalities when the card has none yet, so multi-format + // printings don't overwrite each other's legality lists. if (card->getProperties().filter(formatRegex).empty()) { card->combineLegalities(properties); } @@ -152,7 +213,7 @@ CardInfoPtr OracleImporter::addCard(QString name, } // Remove {} around mana costs, except if it's split cost - QString manacost = properties.value("manacost").toString(); + QString manacost = properties.value("manacost"); if (!manacost.isEmpty()) { QStringList symbols = manacost.split("}"); QString formattedCardCost; @@ -169,12 +230,12 @@ CardInfoPtr OracleImporter::addCard(QString name, } // fix colors - QString allColors = properties.value("colors").toString(); + QString allColors = properties.value("colors"); if (allColors.size() > 1) { sortAndReduceColors(allColors); properties.insert("colors", allColors); } - QString allColorIdent = properties.value("coloridentity").toString(); + QString allColorIdent = properties.value("coloridentity"); if (allColorIdent.size() > 1) { sortAndReduceColors(allColorIdent); properties.insert("coloridentity", allColorIdent); @@ -182,16 +243,16 @@ CardInfoPtr OracleImporter::addCard(QString name, // DETECT CARD POSITIONING INFO - bool landscapeOrientation = properties.value("maintype").toString() == "Battle" || - properties.value("layout").toString() == "split" || - properties.value("layout").toString() == "planar"; + QString layoutVal = properties.value("layout"); + bool landscapeOrientation = + properties.value("maintype") == "Battle" || layoutVal == "split" || layoutVal == "planar"; // cards that enter the field tapped bool cipt = parseCipt(name, text) || landscapeOrientation; // table row int tableRow = 1; - QString mainCardType = properties.value("maintype").toString(); + QString mainCardType = properties.value("maintype"); if (mainCardType == "Land") { tableRow = 0; } else if (mainCardType == "Sorcery" || mainCardType == "Instant") { @@ -201,11 +262,11 @@ CardInfoPtr OracleImporter::addCard(QString name, } // card side - QString side = properties.value("side").toString() == "b" ? "back" : "front"; + QString side = properties.value("side") == "b" ? "back" : "front"; properties.insert("side", side); // upsideDown (flip cards) - QString layout = properties.value("layout").toString(); + QString layout = properties.value("layout"); bool upsideDown = layout == "flip" && side == "back"; // insert the card and its properties @@ -223,12 +284,110 @@ CardInfoPtr OracleImporter::addCard(QString name, return newCard; } -static QString getStringPropertyFromMap(const QVariantMap &card, const QString &propertyName) +static QString getJsonString(const QJsonObject &obj, const QString &key) { - return card.contains(propertyName) ? card.value(propertyName).toString() : QString(""); + // QVariant coerces numbers and booleans to text, while QJsonValue::toString() + // returns a null string for them — some MTGJSON fields (manaValue, + // convertedManaCost, isOnlineOnly, isRebalanced) carry those types. + return obj.value(key).toVariant().toString(); } -int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QList &cardsList) +static QString normalizeCardName(QString name) +{ + // Mirror of the name cleanup applied in addCard(), so collected localization + // keys line up with the card map keys (Æ → AE, curly apostrophe → straight). + name = name.replace("Æ", "AE"); + name = name.replace("’", "'"); + return name; +} + +static QString matchingForeignEntryText(const QJsonObject &card, const QString &cardLang) +{ + // Multi-face cards (split/aftermath/adventure/prepare) expose each face as a + // separate card object, each with its own foreignData entry carrying that + // face's rules text; single-face cards carry the full text in one entry. + const QJsonArray foreignData = card.value("foreignData").toArray(); + for (const QJsonValue &entryValue : foreignData) { + const QJsonObject entry = entryValue.toObject(); + // MTGJSON reports languages by long-form name ("German"); match the + // short code ("de") that Scryfall and cards.xml use. + if (mtgjsonLanguageToCode(getJsonString(entry, "language")) == cardLang) { + return getJsonString(entry, "text"); + } + } + return QString(); +} + +void OracleImporter::collectForeignData(const QString &cardKey, + const CardSetPtr ¤tSet, + const QJsonObject &card, + bool collectText) +{ + if (!localizationEnabled) { + return; + } + + LocalizedCardEntry incoming; + bool found = false; + const QJsonArray foreignData = card.value("foreignData").toArray(); + for (const QJsonValue &entryValue : foreignData) { + const QJsonObject entry = entryValue.toObject(); + // MTGJSON reports languages by long-form name ("German"); match the + // short code ("de") that Scryfall and cards.xml use. + if (mtgjsonLanguageToCode(getJsonString(entry, "language")) != cardLang) { + continue; + } + incoming.name = getJsonString(entry, "name"); + incoming.text = collectText ? getJsonString(entry, "text") : QString(); + found = true; + break; + } + if (!found) { + return; + } + + // Prefer the entry from the highest-priority set (lower enum value = more + // authoritative); printings of equal priority keep the first one seen. + incoming.priority = currentSet->getPriority(); + const auto existing = localizedEntries.constFind(cardKey); + if (existing == localizedEntries.constEnd() || incoming.priority < existing->priority) { + localizedEntries.insert(cardKey, incoming); + } +} + +void OracleImporter::applyLocalizedData() +{ + if (!localizationEnabled) { + return; + } + for (auto it = localizedEntries.constBegin(); it != localizedEntries.constEnd(); ++it) { + CardInfoPtr card = cards.value(it.key()); + if (card.isNull()) { + continue; + } + const LocalizedCardEntry &entry = it.value(); + if (!entry.name.isEmpty()) { + card->setLocalizedName(cardLang, entry.name); + } + if (!entry.text.isEmpty()) { + card->setLocalizedText(cardLang, entry.text); + } + } + localizedEntries.clear(); + + for (auto it = splitLocalizedTexts.constBegin(); it != splitLocalizedTexts.constEnd(); ++it) { + CardInfoPtr card = cards.value(it.key()); + if (card.isNull()) { + continue; + } + if (!it.value().text.isEmpty()) { + card->setLocalizedText(cardLang, it.value().text); + } + } + splitLocalizedTexts.clear(); +} + +int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJsonArray &cardsList) { // mtgjson name => xml name static const QMap cardProperties{ @@ -238,15 +397,18 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QList }; // mtgjson name => xml name - static const QMap setInfoProperties{ - {"number", "num"}, {"rarity", "rarity"}, {"isOnlineOnly", "isOnlineOnly"}, {"isRebalanced", "isRebalanced"}}; + static const QMap setInfoProperties{{"number", "num"}, + {"rarity", "rarity"}, + {"isOnlineOnly", "isOnlineOnly"}, + {"isRebalanced", "isRebalanced"}, + {"artist", "artist"}}; // mtgjson name => xml name static const QMap identifierProperties{{"multiverseId", "muid"}, {"scryfallId", "uuid"}}; static const QString ptSeparator = "/"; static constexpr bool isToken = false; - static const QList setsWithCardsWithSameNameButDifferentText = {"UST"}; + static const QSet setsWithCardsWithSameNameButDifferentText = {"UST"}; int numCards = 0; @@ -254,16 +416,16 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QList QMap, QString>> splitCards; // Keeps track of all names encountered so far - QList allNameProps; + QSet allNameProps; - for (const QVariant &cardVar : cardsList) { - QVariantMap card = cardVar.toMap(); + for (const QJsonValue &cardVal : cardsList) { + QJsonObject card = cardVal.toObject(); /* Currently used layouts are: * augment, double_faced_token, flip, host, leveler, meld, normal, planar, * saga, scheme, split, token, transform, vanguard */ - QString layout = getStringPropertyFromMap(card, "layout"); + QString layout = getJsonString(card, "layout"); // don't import tokens from the json file if (layout == "token") { @@ -271,52 +433,49 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QList } // normal cards handling - QString name = getStringPropertyFromMap(card, "name"); - QString text = getStringPropertyFromMap(card, "text"); - QString faceName = getStringPropertyFromMap(card, "faceName"); + QString name = getJsonString(card, "name"); + QString text = getJsonString(card, "text"); + QString faceName = getJsonString(card, "faceName"); if (faceName.isEmpty()) { faceName = name; } // card properties - QVariantHash properties; + QHash properties; for (auto i = cardProperties.cbegin(), end = cardProperties.cend(); i != end; ++i) { - QString mtgjsonProperty = i.key(); - QString xmlPropertyName = i.value(); - QString propertyValue = getStringPropertyFromMap(card, mtgjsonProperty); + QString propertyValue = getJsonString(card, i.key()); if (!propertyValue.isEmpty()) { - properties.insert(xmlPropertyName, propertyValue); + properties.insert(i.value(), propertyValue); } } // per-set properties - PrintingInfo printingInfo = PrintingInfo(currentSet); + QHash printingProps; for (auto i = setInfoProperties.cbegin(), end = setInfoProperties.cend(); i != end; ++i) { - QString mtgjsonProperty = i.key(); - QString xmlPropertyName = i.value(); - QString propertyValue = getStringPropertyFromMap(card, mtgjsonProperty); + QString propertyValue = getJsonString(card, i.key()); if (!propertyValue.isEmpty()) { - printingInfo.setProperty(xmlPropertyName, propertyValue); + printingProps.insert(i.value(), propertyValue); } } // handle flavorNames specially due to double-faced cards - QString faceFlavorName = getStringPropertyFromMap(card, "faceFlavorName"); - QString flavorName = !faceFlavorName.isEmpty() ? faceFlavorName : getStringPropertyFromMap(card, "flavorName"); + QString faceFlavorName = getJsonString(card, "faceFlavorName"); + QString flavorName = !faceFlavorName.isEmpty() ? faceFlavorName : getJsonString(card, "flavorName"); if (!flavorName.isEmpty()) { - printingInfo.setProperty("flavorName", flavorName); + printingProps.insert("flavorName", flavorName); } // Identifiers + QJsonObject identifiers = card.value("identifiers").toObject(); for (auto i = identifierProperties.cbegin(), end = identifierProperties.cend(); i != end; ++i) { - QString mtgjsonProperty = i.key(); - QString xmlPropertyName = i.value(); - QString propertyValue = getStringPropertyFromMap(card.value("identifiers").toMap(), mtgjsonProperty); + QString propertyValue = getJsonString(identifiers, i.key()); if (!propertyValue.isEmpty()) { - printingInfo.setProperty(xmlPropertyName, propertyValue); + printingProps.insert(i.value(), propertyValue); } } + PrintingInfo printingInfo(currentSet, LazyPropertiesHash(printingProps)); + QString numComponent; const QString numProperty = printingInfo.getProperty("num"); const QChar lastChar = numProperty.isEmpty() ? QChar() : numProperty.back(); @@ -327,21 +486,26 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QList allNameProps.contains(faceName) && layout == "normal" && lastChar.isLetter()) { numComponent = " (" + QString(lastChar).toLower() + ")"; } - allNameProps.append(faceName); + allNameProps.insert(faceName); // special handling properties - QString colors = card.value("colors").toStringList().join(""); + QString colors; + for (const QJsonValue &color : card.value("colors").toArray()) { + colors += color.toString(); + } if (!colors.isEmpty()) { properties.insert("colors", colors); } - // special handling properties - QString colorIdentity = card.value("colorIdentity").toStringList().join(""); + QString colorIdentity; + for (const QJsonValue &color : card.value("colorIdentity").toArray()) { + colorIdentity += color.toString(); + } if (!colorIdentity.isEmpty()) { properties.insert("coloridentity", colorIdentity); } - const auto &mainCardType = getMainCardType(card.value("types").toStringList()); + const auto &mainCardType = getMainCardType(card.value("types").toVariant().toStringList()); if (mainCardType.isEmpty()) { qDebug() << "warning: no mainCardType for card:" << name; } else { @@ -350,39 +514,47 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QList // Depending on whether power and/or toughness are present, the format // is either P/T (most common), P (no toughness), or /T (no power). - QString power = getStringPropertyFromMap(card, "power"); - QString toughness = getStringPropertyFromMap(card, "toughness"); + QString power = getJsonString(card, "power"); + QString toughness = getJsonString(card, "toughness"); if (toughness.isEmpty() && !power.isEmpty()) { properties.insert("pt", power); } else if (!toughness.isEmpty()) { properties.insert("pt", power + ptSeparator + toughness); } - auto legalities = card.value("legalities").toMap(); - for (auto i = legalities.cbegin(), end = legalities.cend(); i != end; ++i) { + auto legalities = card.value("legalities").toObject(); + for (auto i = legalities.constBegin(), end = legalities.constEnd(); i != end; ++i) { properties.insert(QString("format-%1").arg(i.key()), i.value().toString().toLower()); } // split cards are considered a single card, enqueue for later merging if (layout == "split" || layout == "aftermath" || layout == "adventure" || layout == "prepare") { - auto _faceName = getStringPropertyFromMap(card, "faceName"); - SplitCardPart split(_faceName, text, properties, printingInfo); + auto _faceName = getJsonString(card, "faceName"); + // MTGJSON exposes each face as a separate card object, each with its + // own foreignData entry holding that face's rules text; collect it so + // the per-face texts can be joined the same way as the English text. + const QString faceLocalizedText = + localizationEnabled ? matchingForeignEntryText(card, cardLang) : QString(); + SplitCardPart split(_faceName, text, properties, printingInfo, faceLocalizedText); auto found_iter = splitCards.find(name + numProperty); if (found_iter == splitCards.end()) { splitCards.insert(name + numProperty, {{split}, name}); } else { found_iter->first.append(split); } + // MTGJSON's foreignData name is the joined name present on every + // face, so collect the name once. + collectForeignData(normalizeCardName(name), currentSet, card, false); } else { // relations QList relatedCards; // add other face for split cards as card relation - if (!getStringPropertyFromMap(card, "side").isEmpty()) { - auto faceManaValue = getStringPropertyFromMap(card, "faceManaValue"); + if (!getJsonString(card, "side").isEmpty()) { + auto faceManaValue = getJsonString(card, "faceManaValue"); if (faceManaValue.isEmpty()) { // check the old name for the property, for backwards compatibility purposes - faceManaValue = getStringPropertyFromMap(card, "faceConvertedManaCost"); + faceManaValue = getJsonString(card, "faceConvertedManaCost"); } properties["cmc"] = faceManaValue; @@ -402,20 +574,23 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QList name = faceName; } - // mtgjon related cards - if (card.contains("relatedCards")) { - QVariantMap givenRelated = card.value("relatedCards").toMap(); + // mtgjson related cards + QJsonObject givenRelated = card.value("relatedCards").toObject(); + if (!givenRelated.isEmpty()) { // conjured cards from a spellbook - if (givenRelated.contains("spellbook")) { - auto spbk = givenRelated.value("spellbook").toStringList(); - for (const QString &spbkName : spbk) { - relatedCards.append( - new CardRelation(spbkName, CardRelationType::DoesNotAttach, false, false, 1, true)); + QJsonArray spellbook = givenRelated.value("spellbook").toArray(); + if (!spellbook.isEmpty()) { + for (const QJsonValue &spbkVal : spellbook) { + relatedCards.append(new CardRelation(spbkVal.toString(), CardRelationType::DoesNotAttach, false, + false, 1, true)); } } } - CardInfoPtr newCard = addCard(name + numComponent, text, isToken, properties, relatedCards, printingInfo); + collectForeignData(normalizeCardName(name + numComponent), currentSet, card); + + CardInfoPtr newCard = + addCard(name + numComponent, text, isToken, std::move(properties), relatedCards, printingInfo); numCards++; } } @@ -423,12 +598,13 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QList // split cards handling static const QString splitCardPropSeparator = QString(" // "); static const QString splitCardTextSeparator = QString("\n\n---\n\n"); - static const QList noRelatedCards = {}; QList, QString>> partsAndNames = splitCards.values(); for (auto [splitCardParts, name] : partsAndNames) { QString text; - QVariantHash properties; + QString localizedText; + bool localizedTextComplete = true; + QHash properties; PrintingInfo printingInfo; for (const SplitCardPart &tmp : splitCardParts) { @@ -437,22 +613,44 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QList } text.append(tmp.getText()); + // Build the cardLang text by joining each face's translated text with + // the same separator as the English text. Any face missing a complete + // translation abandons the whole join, falling back to the English text. + if (localizedTextComplete) { + const QString partLocalizedText = tmp.getLocalizedText(); + if (partLocalizedText.isEmpty()) { + localizedTextComplete = false; + } else { + if (!localizedText.isEmpty()) { + localizedText.append(splitCardTextSeparator); + } + localizedText.append(partLocalizedText); + } + } + if (properties.isEmpty()) { properties = tmp.getProperties(); printingInfo = tmp.getPrintingInfo(); } else { - const QVariantHash &tmpProps = tmp.getProperties(); + const QHash &tmpProps = tmp.getProperties(); for (auto i = tmpProps.cbegin(), end = tmpProps.cend(); i != end; ++i) { QString prop = i.key(); - QString originalPropertyValue = properties.value(prop).toString(); - QString thisCardPropertyValue = i.value().toString(); + QString originalPropertyValue = properties.value(prop); + QString thisCardPropertyValue = i.value(); if (!thisCardPropertyValue.isEmpty() && originalPropertyValue != thisCardPropertyValue) { if (originalPropertyValue.isEmpty()) { // don't create //es if one field is empty properties.insert(prop, thisCardPropertyValue); - } else if (prop == "colors") { // the card is both colors + } else if (prop == "colors" || prop == "coloridentity") { // the card is both colors properties.insert(prop, originalPropertyValue + thisCardPropertyValue); - } else if (prop == "maintype") { // don't create maintypes with //es in them - continue; + } else if (prop == "maintype") { + // Use the same priority as getMainCardType() to pick the + // "best" type across faces — e.g. Creature over Instant + // for adventure cards like Bonecrusher Giant. + int currentPriority = mainCardTypePriority(originalPropertyValue); + int newPriority = mainCardTypePriority(thisCardPropertyValue); + if (newPriority >= 0 && (currentPriority < 0 || newPriority < currentPriority)) { + properties.insert(prop, thisCardPropertyValue); + } } else { properties.insert(prop, originalPropertyValue + splitCardPropSeparator + thisCardPropertyValue); @@ -461,20 +659,32 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QList } } } - CardInfoPtr newCard = addCard(name, text, isToken, properties, noRelatedCards, printingInfo); + CardInfoPtr newCard = addCard(name, text, isToken, std::move(properties), {}, printingInfo); + if (localizationEnabled && localizedTextComplete && !localizedText.isEmpty()) { + // Same priority policy as collectForeignData(): the joined text from the + // highest-priority set seen so far wins, applied once all printings are in. + LocalizedCardEntry entry; + entry.text = localizedText; + entry.priority = currentSet->getPriority(); + const QString entryKey = normalizeCardName(name); + const auto existing = splitLocalizedTexts.constFind(entryKey); + if (existing == splitLocalizedTexts.constEnd() || entry.priority < existing->priority) { + splitLocalizedTexts.insert(entryKey, entry); + } + } numCards++; } return numCards; } -FormatRulesNameMap OracleImporter::createDefaultMagicFormats() +static FormatRulesNameMap buildDefaultMagicFormats() { // Predefined common exceptions CardCondition superTypeIsBasic; superTypeIsBasic.field = "type"; superTypeIsBasic.matchType = "regex"; - superTypeIsBasic.value = "\bBasic\b[^—]+\bLand\b"; + superTypeIsBasic.value = R"(\bBasic\b[^—]+\bLand\b)"; ExceptionRule basicLands; basicLands.conditions.append(superTypeIsBasic); @@ -487,7 +697,6 @@ FormatRulesNameMap OracleImporter::createDefaultMagicFormats() ExceptionRule mayContainAnyNumber; mayContainAnyNumber.conditions.append(anyNumberAllowed); - // Map to store default rules FormatRulesNameMap defaultFormatRulesNameMap; // ----------------- Helper lambda to create format ----------------- @@ -533,10 +742,29 @@ FormatRulesNameMap OracleImporter::createDefaultMagicFormats() return defaultFormatRulesNameMap; } +const FormatRulesNameMap &OracleImporter::createDefaultMagicFormats() +{ + static const FormatRulesNameMap cached = buildDefaultMagicFormats(); + return cached; +} + int OracleImporter::startImport() { static ICardSetPriorityController *noOpController = new NoopCardSetPriorityController(); + importCancelled.storeRelease(0); + + // Pre-allocate the cards hash to avoid rehashing during import. Keys are + // distinct card names while raw ranges only count printings (AllPrintings + // ~100k printings vs ~35k names), so this over-reserves somewhat; an exact + // distinct-name count would require eagerly parsing, which the lazy reader + // deliberately avoids. It's a capacity hint, so the overshoot is harmless. + int estimatedCards = 0; + for (const SetToDownload &curSetToParse : allSets) { + estimatedCards += curSetToParse.getRawRange().cardCount; + } + cards.reserve(estimatedCards); + // add an empty set for tokens CardSetPtr tokenSet = CardSet::newInstance(noOpController, CardSet::TOKENS_SETNAME, tr("Dummy set containing tokens"), "Tokens"); @@ -545,20 +773,61 @@ int OracleImporter::startImport() int setIndex = 0; for (const SetToDownload &curSetToParse : allSets) { + if (importCancelled.loadAcquire()) { + // The wizard was closed mid-import: stop at the next set boundary so + // the caller can wait for this future without processing every set. + break; + } + CardSetPtr newSet = CardSet::newInstance(noOpController, curSetToParse.getShortName(), curSetToParse.getLongName(), curSetToParse.getSetType(), curSetToParse.getReleaseDate(), curSetToParse.getPriority()); + + // parse only this set's slice of the raw document so the whole JSON tree is + // never kept in memory at once + const RawJson::SetDataRange &rawRange = curSetToParse.getRawRange(); + const qsizetype rangeEnd = rawRange.start + rawRange.length; + if (rawRange.start < 0 || rawRange.length <= 0 || rangeEnd > rawSetsData.size()) { + // rawSetsData is cleared by releaseSetData() while SetToDownload copies + // taken from getSets() keep their ranges, and nothing else enforces the + // pairing — so never index past the buffer on stale/mismatched ranges. + qWarning() << "error: out-of-bounds raw range for set" << curSetToParse.getShortName() << "skipping"; + ++setIndex; + emit setIndexChanged(0, setIndex, curSetToParse.getLongName()); + continue; + } + // sliced() shares the buffer instead of deep-copying the slice; the largest + // sets in AllPrintings are tens of MB, so the copy is worth avoiding here. + const QByteArray setBytes = rawSetsData.sliced(rawRange.start, rawRange.length); + QJsonParseError parseError; + const QJsonDocument setDoc = QJsonDocument::fromJson(setBytes, &parseError); + if (parseError.error != QJsonParseError::NoError) { + qWarning() << "error: parsing card data for set" << curSetToParse.getShortName() << ":" + << parseError.errorString(); + ++setIndex; + // Keep the progress accounting honest: a set that failed to parse + // still advanced the index, so report it (with zero imported cards) + // rather than letting SaveSetsPage's bar stall per failed set. + emit setIndexChanged(0, setIndex, curSetToParse.getLongName()); + continue; + } + + // Only add the set to the database once its slice parsed cleanly; + // a set that fails here must not persist as an empty set in cards.xml. if (!sets.contains(newSet->getShortName())) { sets.insert(newSet->getShortName(), newSet); } - int numCardsInSet = importCardsFromSet(newSet, curSetToParse.getCards()); + const QJsonArray setCards = setDoc.object().value("cards").toArray(); + int numCardsInSet = importCardsFromSet(newSet, setCards); ++setIndex; emit setIndexChanged(numCardsInSet, setIndex, curSetToParse.getLongName()); } + applyLocalizedData(); + emit setIndexChanged(0, setIndex, QString()); // total number of sets @@ -572,9 +841,18 @@ bool OracleImporter::saveToFile(const QString &fileName, const QString &sourceUr return parser.saveToFile(createDefaultMagicFormats(), sets, cards, fileName, sourceUrl, sourceVersion); } +void OracleImporter::releaseSetData() +{ + allSets.clear(); + rawSetsData.clear(); +} + void OracleImporter::clear() { sets.clear(); cards.clear(); allSets.clear(); + rawSetsData.clear(); + localizedEntries.clear(); + splitLocalizedTexts.clear(); } diff --git a/oracle/src/oracleimporter.h b/oracle/src/oracleimporter.h index 5bf352594..c8d3a19bf 100644 --- a/oracle/src/oracleimporter.h +++ b/oracle/src/oracleimporter.h @@ -1,6 +1,12 @@ #ifndef ORACLEIMPORTER_H #define ORACLEIMPORTER_H +#include "raw_json_scanner.h" + +#include +#include +#include +#include #include #include #include @@ -44,10 +50,12 @@ class SetToDownload { private: QString shortName, longName; - QList cards; QDate releaseDate; QString setType; CardSet::Priority priority; + // Byte range of this set's object within the importer's raw JSON text. Parsing + // one set at a time keeps peak memory low instead of holding the whole document. + RawJson::SetDataRange rawRange; public: const QString &getShortName() const @@ -58,10 +66,6 @@ public: { return longName; } - const QList &getCards() const - { - return cards; - } const QString &getSetType() const { return setType; @@ -74,16 +78,23 @@ public: { return priority; } + const RawJson::SetDataRange &getRawRange() const + { + return rawRange; + } SetToDownload(QString _shortName, QString _longName, - QList _cards, CardSet::Priority _priority, QString _setType = QString(), const QDate &_releaseDate = QDate()) - : shortName(std::move(_shortName)), longName(std::move(_longName)), cards(std::move(_cards)), - releaseDate(_releaseDate), setType(std::move(_setType)), priority(_priority) + : shortName(std::move(_shortName)), longName(std::move(_longName)), releaseDate(_releaseDate), + setType(std::move(_setType)), priority(_priority) { } + void setRawRange(const RawJson::SetDataRange &_rawRange) + { + rawRange = _rawRange; + } bool operator<(const SetToDownload &set) const { return longName.compare(set.longName, Qt::CaseInsensitive) < 0; @@ -95,8 +106,9 @@ class SplitCardPart public: SplitCardPart(const QString &_name, const QString &_text, - const QVariantHash &_properties, - const PrintingInfo &_printingInfo); + const QHash &_properties, + const PrintingInfo &_printingInfo, + const QString &_localizedText = QString()); inline const QString &getName() const { return name; @@ -105,7 +117,14 @@ public: { return text; } - inline const QVariantHash &getProperties() const + /** + * @brief The cardLang rules text of this face's foreignData entry, if any. + */ + inline const QString &getLocalizedText() const + { + return localizedText; + } + inline const QHash &getProperties() const { return properties; } @@ -117,10 +136,18 @@ public: private: QString name; QString text; - QVariantHash properties; + QString localizedText; + QHash properties; PrintingInfo printingInfo; }; +struct LocalizedCardEntry +{ + QString name; + QString text; + CardSet::Priority priority = CardSet::PriorityLowest; +}; + class OracleImporter : public QObject { Q_OBJECT @@ -139,23 +166,130 @@ private: QList allSets; + /** + * The raw JSON text of the source document, retained for lazy per-set + * parsing during startImport(). Frees the card data as each set is imported. + */ + QByteArray rawSetsData; + + /** + * Whether readSetsFromByteArray() should report scan progress via + * dataReadProgress. A background run routes that signal to stdout (for the + * hosting Cockatrice client to parse); the flag exists to skip the scanner + * instrumentation entirely when no consumer needs it. + */ + bool progressReporting = true; + + /** + * Atomic "please stop importing" flag. startImport() checks it between sets + * so a wizard being closed mid-import can be torn down without waiting for + * the whole import (or racing it). + */ + QAtomicInt importCancelled; + + /** + * The ISO-639 language code whose foreignData is imported; "en" by default. + */ + QString cardLang = "en"; + + /** + * Whether cardLang is a supported language other than English, so per-card + * foreignData scanning can be skipped entirely when disabled. + */ + bool localizationEnabled = false; + + /** + * Localized name/text collected per imported card key while parsing sets, + * applied to the CardInfo objects by applyLocalizedData() once all + * printings have been seen so the best-priority one wins. + */ + QMap localizedEntries; + + /** + * cardLang rules text collected for split-card names while parsing sets, + * applied by applyLocalizedData(). Kept apart from localizedEntries because + * MTGJSON emits each split face as its own card object with the joined name + * on every foreignData entry: names and the per-face text join have different + * completeness and must not overwrite each other under the same key. + */ + QMap splitLocalizedTexts; + CardInfoPtr addCard(QString name, const QString &text, bool isToken, - QVariantHash properties, + QHash properties, const QList &relatedCards, const PrintingInfo &printingInfo); + + /** + * Records the first foreignData entry matching cardLang for the given card + * key, keeping the entry from the highest-priority set seen so far. + * + * Multi-face cards (split, adventure, aftermath, prepare) pass collectText = + * false: MTGJSON emits one foreignData entry per face with the same joined + * name but only that face's text, so the name is collected here while the + * per-face texts are joined during the split-card merge. + */ + void collectForeignData(const QString &cardKey, + const CardSetPtr ¤tSet, + const QJsonObject &card, + bool collectText = true); signals: void setIndexChanged(int cardsImported, int setIndex, const QString &setName); void dataReadProgress(int bytesRead, int totalBytes); public: explicit OracleImporter(QObject *parent = nullptr); - bool readSetsFromByteArray(const QByteArray &data); + /** + * @brief Controls whether readSetsFromByteArray() instruments the raw scan. + * + * When enabled (the default) the raw scanner reports progress via + * dataReadProgress(), which an interactive wizard shows on its progress bar + * and a background run routes to stdout for the hosting client. Switch it + * off only when nothing will consume scan progress. + */ + void setProgressReporting(bool enabled) + { + progressReporting = enabled; + } + /** + * Selects the ISO-639 language code whose foreignData is imported. + * English (the default) and unsupported codes disable localization. + */ + void setCardLang(const QString &lang); + const QString &getCardLang() const + { + return cardLang; + } + /** + * Scans the given JSON document for set metadata. Takes the data by value so + * the wizard can hand over its decompressed buffer without copying it. + */ + bool readSetsFromByteArray(QByteArray data); int startImport(); + /** + * @brief Requests an in-flight startImport() to stop at the next set boundary. + * + * Works by setting an atomic flag that startImport() polls between sets, so + * cancelImport() followed by a short waitForFinished() on the running future is + * safe the moment the wizard is about to be destroyed. + */ + void cancelImport() + { + importCancelled.storeRelease(1); + } + /** + * Applies the collected localized names/texts to the imported cards. + * Called automatically at the end of startImport(); exposed separately so + * tests can drive it after importing sets directly. + */ + void applyLocalizedData(); bool saveToFile(const QString &fileName, const QString &sourceUrl, const QString &sourceVersion); - int importCardsFromSet(const CardSetPtr ¤tSet, const QList &cardsList); - FormatRulesNameMap createDefaultMagicFormats(); + int importCardsFromSet(const CardSetPtr ¤tSet, const QJsonArray &cardsList); + /** + * @brief Returns the default format rules. The result is memoized on first use and must be treated as immutable. + */ + const FormatRulesNameMap &createDefaultMagicFormats(); const CardNameMap &getCardList() const { return cards; @@ -164,6 +298,11 @@ public: { return allSets; } + const QByteArray &getRawSetsData() const + { + return rawSetsData; + } + void releaseSetData(); void clear(); }; diff --git a/oracle/src/oraclewizard.cpp b/oracle/src/oraclewizard.cpp index 2edb9e561..9ce0d7b51 100644 --- a/oracle/src/oraclewizard.cpp +++ b/oracle/src/oraclewizard.cpp @@ -15,6 +15,8 @@ #include #include #include +#include +#include OracleWizard::OracleWizard(QWidget *parent) : QWizard(parent) { @@ -33,9 +35,12 @@ OracleWizard::OracleWizard(QWidget *parent) : QWizard(parent) migrateOracleSettings(); } - connect(&SettingsCache::instance(), &SettingsCache::langChanged, this, &OracleWizard::updateLanguage); + connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &OracleWizard::updateLanguage); importer = new OracleImporter(this); + // Import card text in the language the client displays, if supported: + // foreignData for any other language is never imported. + importer->setCardLang(SettingsCache::instance().cardsDisplay().getCardLang()); nam = new QNetworkAccessManager(this); @@ -109,6 +114,24 @@ void OracleWizard::accept() QDialog::accept(); } +void OracleWizard::reject() +{ + // The wizard is being closed while a page may still run a worker on the + // importer. Ask it to stop before the wizard (and the importer child) is + // destroyed, so the worker thread never touches freed memory. + if (auto *active = dynamic_cast(currentPage())) { + active->cancelWork(); + } + QWizard::reject(); +} + +void OracleWizard::runInBackground() +{ + backgroundMode = true; + hide(); + currentPage()->initializePage(); +} + void OracleWizard::enableButtons() { button(QWizard::NextButton)->setDisabled(false); diff --git a/oracle/src/oraclewizard.h b/oracle/src/oraclewizard.h index 78427175c..9a509ce5e 100644 --- a/oracle/src/oraclewizard.h +++ b/oracle/src/oraclewizard.h @@ -23,6 +23,7 @@ class OracleWizard : public QWizard public: explicit OracleWizard(QWidget *parent = nullptr); void accept() override; + void reject() override; void enableButtons(); void disableButtons(); void retranslateUi(); @@ -52,12 +53,7 @@ public: } bool saveTokensToFile(const QString &fileName); - void runInBackground() - { - backgroundMode = true; - hide(); - currentPage()->initializePage(); - } + void runInBackground(); public: OracleImporter *importer; diff --git a/oracle/src/pages.cpp b/oracle/src/pages.cpp index 7629a291b..df0c3f51d 100644 --- a/oracle/src/pages.cpp +++ b/oracle/src/pages.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -20,14 +21,18 @@ #include #include #include +#include #include #include #include #include #include #include +#include #include #include +#include +#include #ifdef HAS_LZMA #include "lzma/decompress.h" @@ -52,6 +57,115 @@ #define ALLSETS_URL "https://www.mtgjson.com/api/v5/AllPrintings.json" #endif +/** + * @brief Emits one machine-readable background-run progress line to stdout. + * + * Used only in background mode, so the hosting Cockatrice client can parse these + * lines to drive a determinate progress bar. stderr stays reserved for + * human-readable log output. + */ +static void emitBackgroundProgress(const char *stage, qint64 done, qint64 total) +{ + QTextStream out(stdout); + out << "PROGRESS " << stage << ' ' << done << ' ' << total << '\n'; + out.flush(); +} + +namespace +{ + +/** + * @brief Decompresses and dispatches a sets-file payload on a worker thread. + * + * Iteratively unwraps xz/zip compression, then either hands the JSON to the + * importer (which reports scan progress via dataReadProgress) or returns the raw + * XML for the plain-XML path. Must never touch the wizard or the page: the caller + * consumes the returned LoadSetsResult on the UI thread in importFinished(). + */ +LoadSetsResult loadSetsData(const QPointer &importer, QByteArray data) +{ + LoadSetsResult result; + + while (true) { + if (data.startsWith(XZ_SIGNATURE)) { +#ifdef HAS_LZMA + QBuffer inBuffer(&data); + QByteArray decompressed; + QBuffer outBuffer(&decompressed); + inBuffer.open(QBuffer::ReadOnly); + outBuffer.open(QBuffer::WriteOnly); + XzDecompressor xz; + if (!xz.decompress(&inBuffer, &outBuffer)) { + result.errorMessage = LoadSetsPage::tr("Xz extraction failed."); + result.offerUncompressedFallback = true; + return result; + } + data = decompressed; + continue; +#else + result.errorMessage = + LoadSetsPage::tr("Sorry, this version of Oracle does not support xz compressed files."); + result.offerUncompressedFallback = true; + return result; +#endif + } + + if (data.startsWith(ZIP_SIGNATURE)) { +#ifdef HAS_ZLIB + QBuffer inBuffer(&data); + UnZip uz; + const UnZip::ErrorCode openEc = uz.openArchive(&inBuffer); + if (openEc != UnZip::Ok) { + result.errorMessage = LoadSetsPage::tr("Failed to open Zip archive: %1.").arg(uz.formatError(openEc)); + result.offerUncompressedFallback = true; + return result; + } + if (uz.fileList().size() != 1) { + result.errorMessage = + LoadSetsPage::tr("Zip extraction failed: the Zip archive doesn't contain exactly one file."); + result.offerUncompressedFallback = true; + return result; + } + const QString fileName = uz.fileList().at(0); + QByteArray decompressed; + QBuffer outBuffer(&decompressed); + outBuffer.open(QBuffer::ReadWrite); + const UnZip::ErrorCode ec = uz.extractFile(fileName, &outBuffer); + uz.closeArchive(); + if (ec != UnZip::Ok) { + result.errorMessage = LoadSetsPage::tr("Zip extraction failed: %1.").arg(uz.formatError(ec)); + result.offerUncompressedFallback = true; + return result; + } + data = decompressed; + continue; +#else + result.errorMessage = LoadSetsPage::tr("Sorry, this version of Oracle does not support zipped files."); + result.offerUncompressedFallback = true; + return result; +#endif + } + break; + } + + if (data.startsWith("<")) { + result.ok = true; + result.plainXml = true; + result.xmlData = std::move(data); + return result; + } + + if (data.startsWith("{")) { + result.ok = importer && importer->readSetsFromByteArray(std::move(data)); + return result; + } + + result.errorMessage = LoadSetsPage::tr("Failed to interpret downloaded data."); + return result; +} + +} // namespace + #define TOKENS_URL "https://raw.githubusercontent.com/Cockatrice/Magic-Token/master/tokens.xml" #define SPOILERS_URL "https://raw.githubusercontent.com/Cockatrice/Magic-Spoiler/files/spoiler.xml" @@ -119,7 +233,7 @@ QString IntroPage::languageName(const QString &lang) void IntroPage::languageBoxChanged(int index) { - SettingsCache::instance().setLang(languageBox->itemData(index).toString()); + SettingsCache::instance().personal().setLang(languageBox->itemData(index).toString()); } void IntroPage::retranslateUi() @@ -181,6 +295,11 @@ LoadSetsPage::LoadSetsPage(QWidget *parent) : OracleWizardPage(parent) setLayout(layout); } +bool LoadSetsPage::isComplete() const +{ + return !loadActive; +} + void LoadSetsPage::initializePage() { urlLineEdit->setText(wizard()->settings->value("allsetsurl", ALLSETS_URL).toString()); @@ -259,7 +378,7 @@ bool LoadSetsPage::validatePage() return false; } - progressLabel->setText(tr("Downloading (0MB)")); + progressLabel->setText(tr("Downloading (0 MB)")); // show an infinite progressbar progressBar->setMaximum(0); progressBar->setMinimum(0); @@ -278,18 +397,13 @@ bool LoadSetsPage::validatePage() return false; } - if (!setsFile.open(QIODevice::ReadOnly)) { - QMessageBox::critical(nullptr, tr("Error"), tr("Cannot open file '%1'.").arg(fileLineEdit->text())); - return false; - } - wizard()->disableButtons(); setEnabled(false); wizard()->setCardSourceUrl(setsFile.fileName()); wizard()->setCardSourceVersion("unknown"); - readSetsFromByteArray(setsFile.readAll()); + readSetsFromFile(setsFile.fileName()); } return false; @@ -338,11 +452,14 @@ void LoadSetsPage::downloadSetsFile(const QUrl &url) void LoadSetsPage::actDownloadProgressSetsFile(qint64 received, qint64 total) { + if (wizard()->backgroundMode) { + emitBackgroundProgress("download", received, total); + } if (total > 0) { progressBar->setMaximum(static_cast(total)); progressBar->setValue(static_cast(received)); } - progressLabel->setText(tr("Downloading (%1MB)").arg((int)received / (1024 * 1024))); + progressLabel->setText(tr("Downloading (%1 MB)").arg((int)received / (1024 * 1024))); } void LoadSetsPage::actDownloadFinishedSetsFile() @@ -383,106 +500,97 @@ void LoadSetsPage::actDownloadFinishedSetsFile() reply->deleteLater(); } -void LoadSetsPage::readSetsFromByteArray(QByteArray _data) +void LoadSetsPage::updateParsingProgress(int bytesRead, int totalBytes) { - // show an infinite progressbar + if (totalBytes <= 0) { + return; + } + progressBar->setRange(0, totalBytes); + progressBar->setValue(bytesRead); + const int percent = static_cast((100.0 * bytesRead) / totalBytes); + progressLabel->setText(tr("Parsing file (%1%)").arg(percent)); +} + +void LoadSetsPage::scanProgressToStdout(int bytesRead, int totalBytes) +{ + emitBackgroundProgress("scan", bytesRead, totalBytes); +} + +void LoadSetsPage::beginLoadSets(bool compressedFile) +{ + // Show an infinite progressbar while the worker decompresses; the scan + // steals the label via dataReadProgress as soon as it starts. progressBar->setMaximum(0); progressBar->setMinimum(0); progressBar->setValue(0); - progressLabel->setText(tr("Parsing file")); + progressLabel->setText(compressedFile ? tr("Extracting file...") : tr("Parsing file")); progressLabel->show(); progressBar->show(); + // Keep Next disabled (via completeChanged) until the worker reports in; + // updateButtonStates() re-evaluates button state whenever we re-enable. + loadActive = true; + emit completeChanged(); + wizard()->downloadedPlainXml = false; wizard()->xmlData.clear(); - readSetsFromByteArrayRef(_data); + + if (wizard()->backgroundMode) { + connect(wizard()->importer, &OracleImporter::dataReadProgress, this, &LoadSetsPage::scanProgressToStdout, + Qt::UniqueConnection); + } else { + connect(wizard()->importer, &OracleImporter::dataReadProgress, this, &LoadSetsPage::updateParsingProgress, + Qt::UniqueConnection); + } } -void LoadSetsPage::readSetsFromByteArrayRef(QByteArray &_data) +void LoadSetsPage::readSetsFromByteArray(QByteArray _data) { - // unzip the file if needed - if (_data.startsWith(XZ_SIGNATURE)) { -#ifdef HAS_LZMA - // zipped file - auto *inBuffer = new QBuffer(&_data); - auto newData = QByteArray(); - auto *outBuffer = new QBuffer(&newData); - inBuffer->open(QBuffer::ReadOnly); - outBuffer->open(QBuffer::WriteOnly); - XzDecompressor xz; - if (!xz.decompress(inBuffer, outBuffer)) { - zipDownloadFailed(tr("Xz extraction failed.")); - return; + const bool compressed = _data.startsWith(XZ_SIGNATURE) || _data.startsWith(ZIP_SIGNATURE); + beginLoadSets(compressed); + + // Decompress and scan off the UI thread so a large download can't freeze the window. + const QPointer importer = wizard()->importer; + future = QtConcurrent::run( + [importer, data = std::move(_data)]() mutable { return loadSetsData(importer, std::move(data)); }); + watcher.setFuture(future); +} + +void LoadSetsPage::readSetsFromFile(const QString &fileName) +{ + // Peek at the header on the UI thread so the status text can distinguish + // "Extracting file..." from a plain JSON parse; the full read happens in the worker. + QFile headerFile(fileName); + bool compressed = false; + if (headerFile.open(QIODevice::ReadOnly)) { + const QByteArray header = headerFile.read(6); + compressed = header.startsWith(XZ_SIGNATURE) || header.startsWith(ZIP_SIGNATURE); + } + beginLoadSets(compressed); + + // Read, decompress and scan off the UI thread (a plain JSON can be hundreds + // of MB, so even the read itself must not block the window). + const QPointer importer = wizard()->importer; + future = QtConcurrent::run([importer, fileName]() mutable -> LoadSetsResult { + QFile file(fileName); + if (!file.open(QIODevice::ReadOnly)) { + LoadSetsResult readError; + readError.errorMessage = LoadSetsPage::tr("Cannot open file '%1'.").arg(fileName); + return readError; } - _data.clear(); - readSetsFromByteArrayRef(newData); - return; -#else - zipDownloadFailed(tr("Sorry, this version of Oracle does not support xz compressed files.")); + return loadSetsData(importer, file.readAll()); + }); + watcher.setFuture(future); +} - wizard()->enableButtons(); - setEnabled(true); - progressLabel->hide(); - progressBar->hide(); - return; -#endif - } else if (_data.startsWith(ZIP_SIGNATURE)) { -#ifdef HAS_ZLIB - // zipped file - auto *inBuffer = new QBuffer(&_data); - auto newData = QByteArray(); - auto *outBuffer = new QBuffer(&newData); - QString fileName; - UnZip::ErrorCode ec; - UnZip uz; - - ec = uz.openArchive(inBuffer); - if (ec != UnZip::Ok) { - zipDownloadFailed(tr("Failed to open Zip archive: %1.").arg(uz.formatError(ec))); - return; - } - - if (uz.fileList().size() != 1) { - zipDownloadFailed(tr("Zip extraction failed: the Zip archive doesn't contain exactly one file.")); - return; - } - fileName = uz.fileList().at(0); - - outBuffer->open(QBuffer::ReadWrite); - ec = uz.extractFile(fileName, outBuffer); - if (ec != UnZip::Ok) { - zipDownloadFailed(tr("Zip extraction failed: %1.").arg(uz.formatError(ec))); - uz.closeArchive(); - return; - } - _data.clear(); - readSetsFromByteArrayRef(newData); - return; -#else - zipDownloadFailed(tr("Sorry, this version of Oracle does not support zipped files.")); - - wizard()->enableButtons(); - setEnabled(true); - progressLabel->hide(); - progressBar->hide(); - return; -#endif - } else if (_data.startsWith("{")) { - // Start the computation. - jsonData = std::move(_data); - future = QtConcurrent::run([this] { return wizard()->importer->readSetsFromByteArray(std::move(jsonData)); }); - watcher.setFuture(future); - } else if (_data.startsWith("<")) { - // save xml file and don't do any processing - wizard()->downloadedPlainXml = true; - wizard()->xmlData = std::move(_data); - importFinished(); - } else { - wizard()->enableButtons(); - setEnabled(true); - progressLabel->hide(); - progressBar->hide(); - QMessageBox::critical(this, tr("Error"), tr("Failed to interpret downloaded data.")); +void LoadSetsPage::cancelWork() +{ + // The scan is short-lived; just wait it out before the wizard (and its + // importer) can be torn down underneath the worker thread. + if (future.isRunning()) { + future.cancel(); + watcher.cancel(); + future.waitForFinished(); } } @@ -508,17 +616,65 @@ void LoadSetsPage::zipDownloadFailed(const QString &message) void LoadSetsPage::importFinished() { + loadActive = false; + emit completeChanged(); wizard()->enableButtons(); setEnabled(true); - progressLabel->hide(); - progressBar->hide(); - if (wizard()->downloadedPlainXml || watcher.future().result()) { - wizard()->next(); - } else { - QMessageBox::critical(this, tr("Error"), - tr("The file was retrieved successfully, but it does not contain any sets data.")); + const LoadSetsResult result = watcher.result(); + + if (result.plainXml) { + wizard()->downloadedPlainXml = true; + wizard()->xmlData = result.xmlData; } + + if (wizard()->backgroundMode) { + progressLabel->hide(); + progressBar->hide(); + if (!result.errorMessage.isEmpty()) { + qWarning() << result.errorMessage; + } else if (!result.ok && !result.plainXml) { + qWarning() << tr("The file was retrieved successfully, but it does not contain any sets data."); + } + emit readyToContinue(); + return; + } + + const auto fail = [this](const QString &message) { + progressLabel->hide(); + progressBar->hide(); + QMessageBox::critical(this, tr("Error"), message); + }; + + if (!result.errorMessage.isEmpty()) { + if (result.offerUncompressedFallback) { + zipDownloadFailed(result.errorMessage); + return; + } + fail(result.errorMessage); + return; + } + + if (!result.ok && !result.plainXml) { + fail(tr("The file was retrieved successfully, but it does not contain any sets data.")); + return; + } + + // Snap the bar to 100% and hold it there for a moment so the completed state + // is actually visible before the next page's own import progress takes over + // (a zero-length deferral can still fire before the repaint is delivered). + progressBar->setMaximum(1); + progressBar->setValue(1); + progressLabel->setText(tr("Parsing file (100%)")); + QTimer::singleShot(500, this, [this] { + if (wizard()->currentPage() == this) { + // Leave the page pristine: hide the completed load bar so a later + // Back from the save page doesn't show stale progress. + progressLabel->hide(); + progressBar->hide(); + wizard()->next(); + } + }); } SaveSetsPage::SaveSetsPage(QWidget *parent) : OracleWizardPage(parent) @@ -526,46 +682,113 @@ SaveSetsPage::SaveSetsPage(QWidget *parent) : OracleWizardPage(parent) pathLabel = new QLabel(this); saveLabel = new QLabel(this); + progressBar = new QProgressBar(this); + progressBar->hide(); + defaultPathCheckBox = new QCheckBox(this); messageLog = new QTextEdit(this); messageLog->setReadOnly(true); auto *layout = new QGridLayout(this); - layout->addWidget(messageLog, 0, 0); - layout->addWidget(saveLabel, 1, 0); - layout->addWidget(pathLabel, 2, 0); - layout->addWidget(defaultPathCheckBox, 3, 0); + layout->addWidget(progressBar, 0, 0); + layout->addWidget(messageLog, 1, 0); + layout->addWidget(saveLabel, 2, 0); + layout->addWidget(pathLabel, 3, 0); + layout->addWidget(defaultPathCheckBox, 4, 0); setLayout(layout); } +bool SaveSetsPage::isComplete() const +{ + return !importActive; +} + void SaveSetsPage::cleanupPage() { + cancelWork(); + disconnect(wizard()->importer, &OracleImporter::setIndexChanged, this, &SaveSetsPage::updateTotalProgress); + disconnect(&importWatcher, &QFutureWatcher::finished, this, &SaveSetsPage::importFinished); wizard()->importer->clear(); - disconnect(wizard()->importer, &OracleImporter::setIndexChanged, nullptr, nullptr); } void SaveSetsPage::initializePage() { - messageLog->clear(); - retranslateUi(); if (wizard()->downloadedPlainXml) { messageLog->hide(); - } else { - messageLog->show(); - connect(wizard()->importer, &OracleImporter::setIndexChanged, this, &SaveSetsPage::updateTotalProgress); - - int setsImported = wizard()->importer->startImport(); - - if (setsImported == 0) { - QMessageBox::critical(this, tr("Error"), tr("No set has been imported.")); + progressBar->hide(); + if (wizard()->backgroundMode) { + emit readyToContinue(); } + return; + } + + messageLog->clear(); + messageLog->show(); + progressBar->show(); + + totalSets = wizard()->importer->getSets().size(); + progressBar->setRange(0, totalSets); + progressBar->setValue(0); + + connect(wizard()->importer, &OracleImporter::setIndexChanged, this, &SaveSetsPage::updateTotalProgress, + Qt::UniqueConnection); + connect(&importWatcher, &QFutureWatcher::finished, this, &SaveSetsPage::importFinished, Qt::UniqueConnection); + + wizard()->disableButtons(); + importActive = true; + emit completeChanged(); + + const QPointer importer = wizard()->importer; + importFuture = QtConcurrent::run([importer] { return importer ? importer->startImport() : 0; }); + importWatcher.setFuture(importFuture); +} + +void SaveSetsPage::cancelWork() +{ + if (!importActive) { + return; + } + // Ask the worker to stop at the next set boundary, then wait it out so the + // wizard (and the importer it owns) is never torn down under a running thread. + importActive = false; + emit completeChanged(); + wizard()->importer->cancelImport(); + importFuture.cancel(); + importWatcher.cancel(); + importFuture.waitForFinished(); +} + +void SaveSetsPage::importFinished() +{ + if (!importActive) { + return; + } + importActive = false; + emit completeChanged(); + + wizard()->enableButtons(); + + const int setsImported = importWatcher.result(); + const QPointer importer = wizard()->importer; + if (importer) { + importer->releaseSetData(); } if (wizard()->backgroundMode) { + if (setsImported == 0) { + qWarning() << tr("No set has been imported."); + } emit readyToContinue(); + return; + } + + progressBar->setValue(progressBar->maximum()); + + if (setsImported == 0) { + QMessageBox::critical(this, tr("Error"), tr("No set has been imported.")); } } @@ -573,7 +796,7 @@ void SaveSetsPage::retranslateUi() { setTitle(tr("Sets imported")); if (wizard()->downloadedPlainXml) { - setSubTitle(tr("A cockatrice database file of %1 MB has been downloaded.") + setSubTitle(tr("A Cockatrice card database file of %1 MB has been downloaded.") .arg(qRound(wizard()->xmlData.size() / 1000000.0))); } else { setSubTitle(tr("The following sets have been found:")); @@ -587,13 +810,28 @@ void SaveSetsPage::retranslateUi() setButtonText(QWizard::NextButton, tr("&Save")); } -void SaveSetsPage::updateTotalProgress(int cardsImported, int /* setIndex */, const QString &setName) +void SaveSetsPage::updateTotalProgress(int cardsImported, int setIndex, const QString &setName) { + if (!importActive) { + return; + } if (setName.isEmpty()) { - messageLog->append("" + tr("Import finished: %1 cards.").arg(wizard()->importer->getCardList().size()) + - ""); + progressBar->setValue(progressBar->maximum()); + const int cardCount = wizard()->importer->getCardList().size(); + if (wizard()->backgroundMode) { + qInfo() << tr("Import finished: %1 cards.").arg(cardCount); + emitBackgroundProgress("import", totalSets, totalSets); + } else { + messageLog->append("" + tr("Import finished: %1 cards.").arg(cardCount) + ""); + } } else { - messageLog->append(tr("%1: %2 cards imported").arg(setName).arg(cardsImported)); + progressBar->setValue(setIndex); + if (wizard()->backgroundMode) { + qInfo() << tr("%1: %2 cards imported").arg(setName).arg(cardsImported); + emitBackgroundProgress("import", setIndex, totalSets); + } else { + messageLog->append(tr("%1: %2 cards imported").arg(setName).arg(cardsImported)); + } } messageLog->verticalScrollBar()->setValue(messageLog->verticalScrollBar()->maximum()); @@ -739,4 +977,4 @@ void LoadSpoilersPage::retranslateUi() pathLabel->setText(tr("The spoiler database will be saved at the following location:") + "
" + SettingsCache::instance().getSpoilerCardDatabasePath()); defaultPathCheckBox->setText(tr("Save to a custom path (not recommended)")); -} \ No newline at end of file +} diff --git a/oracle/src/pages.h b/oracle/src/pages.h index 066cc2e1b..d0a9fcd43 100644 --- a/oracle/src/pages.h +++ b/oracle/src/pages.h @@ -3,8 +3,10 @@ #include "pagetemplates.h" +#include #include #include +#include #include #include #include @@ -57,19 +59,31 @@ protected: void initializePage() override; }; +/** @brief Result of a worker-thread sets-file load (read + decompress + dispatch). */ +struct LoadSetsResult +{ + bool ok = false; ///< JSON scan produced set data (or plain XML was handled) + bool plainXml = false; ///< input was a plain Cockatrice XML database + QByteArray xmlData; ///< raw XML for the plain-XML path + QString errorMessage; ///< set when the input could not be processed + bool offerUncompressedFallback = false; ///< decompression-only failure: offer the uncompressed URL +}; + class LoadSetsPage : public OracleWizardPage { Q_OBJECT public: explicit LoadSetsPage(QWidget *parent = nullptr); void retranslateUi() override; + bool isComplete() const override; protected: void initializePage() override; bool validatePage() override; void readSetsFromByteArray(QByteArray _data); - void readSetsFromByteArrayRef(QByteArray &_data); + void readSetsFromFile(const QString &fileName); void downloadSetsFile(const QUrl &url); + void cancelWork() override; private: QRadioButton *urlRadioButton; @@ -81,15 +95,19 @@ private: QLabel *progressLabel; QProgressBar *progressBar; - QFutureWatcher watcher; - QFuture future; - QByteArray jsonData; + QFutureWatcher watcher; + QFuture future; + bool loadActive = false; + + void beginLoadSets(bool compressedFile = false); private slots: void actLoadSetsFile(); void actRestoreDefaultUrl(); void actDownloadProgressSetsFile(qint64 received, qint64 total); void actDownloadFinishedSetsFile(); + void updateParsingProgress(int bytesRead, int totalBytes); + void scanProgressToStdout(int bytesRead, int totalBytes); void importFinished(); void zipDownloadFailed(const QString &message); }; @@ -100,19 +118,28 @@ class SaveSetsPage : public OracleWizardPage public: explicit SaveSetsPage(QWidget *parent = nullptr); void retranslateUi() override; + bool isComplete() const override; private: QTextEdit *messageLog; + QProgressBar *progressBar; QCheckBox *defaultPathCheckBox; QLabel *pathLabel; QLabel *saveLabel; + QFutureWatcher importWatcher; + QFuture importFuture; + int totalSets = 0; + bool importActive = false; + protected: void initializePage() override; void cleanupPage() override; bool validatePage() override; + void cancelWork() override; private slots: + void importFinished(); void updateTotalProgress(int cardsImported, int setIndex, const QString &setName); }; diff --git a/oracle/src/pagetemplates.h b/oracle/src/pagetemplates.h index 6e79c867e..ccad7ec62 100644 --- a/oracle/src/pagetemplates.h +++ b/oracle/src/pagetemplates.h @@ -20,6 +20,14 @@ public: } virtual void retranslateUi() = 0; + /** + * @brief Asks an active page to stop any background worker before the wizard + * (and its importer) can be torn down underneath it. Default is a no-op. + */ + virtual void cancelWork() + { + } + signals: void readyToContinue(); diff --git a/oracle/src/parsehelpers.cpp b/oracle/src/parsehelpers.cpp index a97bf67c9..ee7c4eb2e 100644 --- a/oracle/src/parsehelpers.cpp +++ b/oracle/src/parsehelpers.cpp @@ -20,7 +20,7 @@ * Note that "...enters tapped unless..." returns false. * * @param name The name of the card - * @param text The oracle text of the card + * @param text The Oracle text of the card */ bool parseCipt(const QString &name, const QString &text) { diff --git a/oracle/src/qt-json/AUTHORS b/oracle/src/qt-json/AUTHORS deleted file mode 100644 index 29a85929f..000000000 --- a/oracle/src/qt-json/AUTHORS +++ /dev/null @@ -1,3 +0,0 @@ -Eeli Reilin -Luis Gustavo S. Barreto -Stephen Kockentiedt diff --git a/oracle/src/qt-json/LICENSE b/oracle/src/qt-json/LICENSE deleted file mode 100644 index 3c42b515a..000000000 --- a/oracle/src/qt-json/LICENSE +++ /dev/null @@ -1,27 +0,0 @@ -Copyright 2011 Eeli Reilin. All rights reserved. - -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: - - 1. Redistributions of source code must retain the above copyright notice, - this list of conditions and the following disclaimer. - - 2. Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -THIS SOFTWARE IS PROVIDED BY ''AS IS'' AND ANY EXPRESS OR -IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO -EVENT SHALL EELI REILIN OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, -INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, -OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF -LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE -OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF -ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -The views and conclusions contained in the software and documentation -are those of the authors and should not be interpreted as representing -official policies, either expressed or implied, of Eeli Reilin. - diff --git a/oracle/src/qt-json/README b/oracle/src/qt-json/README deleted file mode 100644 index b60c1599b..000000000 --- a/oracle/src/qt-json/README +++ /dev/null @@ -1,96 +0,0 @@ -######################################################################## -1. INTRODUCTION - -The Json class is a simple class for parsing JSON data into a QVariant -hierarchies. Now, we can also reverse the process and serialize -QVariant hierarchies into valid JSON data. - - -######################################################################## -2. HOW TO USE - -The parser is really easy to use. Let's say we have the following -QString of JSON data: - ------------------------------------------------------------------------- -{ - "encoding" : "UTF-8", - "plug-ins" : [ - "python", - "c++", - "ruby" - ], - "indent" : { - "length" : 3, - "use_space" : true - } -} ------------------------------------------------------------------------- - -We would first call the parse-method: - ------------------------------------------------------------------------- -//Say that we're using the QtJson namespace -using namespace QtJson; -bool ok; -//json is a QString containing the JSON data -QVariantMap result = Json::parse(json, ok).toMap(); - -if(!ok) { - qFatal("An error occurred during parsing"); - exit(1); -} ------------------------------------------------------------------------- - -Assuming the parsing process completed without errors, we would then -go through the hierarchy: - ------------------------------------------------------------------------- -qDebug() << "encoding:" << result["encoding"].toString(); -qDebug() << "plugins:"; - -foreach(QVariant plugin, result["plug-ins"].toList()) { - qDebug() << "\t-" << plugin.toString(); -} - -QVariantMap nestedMap = result["indent"].toMap(); -qDebug() << "length:" << nestedMap["length"].toInt(); -qDebug() << "use_space:" << nestedMap["use_space"].toBool(); ------------------------------------------------------------------------- - -The previous code would print out the following: - ------------------------------------------------------------------------- -encoding: "UTF-8" -plugins: - - "python" - - "c++" - - "ruby" -length: 3 -use_space: true ------------------------------------------------------------------------- - -To write JSON data from Qt object is as simple as parsing: - ------------------------------------------------------------------------- -QVariantMap map; -map["name"] = "Name"; -map["age"] = 22; - -QByteArray data = Json::serialize(map); ------------------------------------------------------------------------- - -The byte array 'data' contains valid JSON data: - ------------------------------------------------------------------------- -{ - name: "Luis Gustavo", - age: 22, -} ------------------------------------------------------------------------- - - -######################################################################## -4. CONTRIBUTING - -The code is available to download at GitHub. Contribute if you dare! diff --git a/oracle/src/qt-json/json.cpp b/oracle/src/qt-json/json.cpp deleted file mode 100644 index 2fffd0f70..000000000 --- a/oracle/src/qt-json/json.cpp +++ /dev/null @@ -1,573 +0,0 @@ -/* Copyright 2011 Eeli Reilin. All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, - * this list of conditions and the following disclaimer. - * - * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * - * THIS SOFTWARE IS PROVIDED BY ''AS IS'' AND ANY EXPRESS OR - * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF - * MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO - * EVENT SHALL EELI REILIN OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, - * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, - * OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF - * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING - * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, - * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - * - * The views and conclusions contained in the software and documentation - * are those of the authors and should not be interpreted as representing - * official policies, either expressed or implied, of Eeli Reilin. - */ - -/** - * \file json.cpp - */ - -#include "json.h" - -#include -#include - -namespace QtJson -{ - -static QString sanitizeString(QString str) -{ - str.replace(QLatin1String("\\"), QLatin1String("\\\\")); - str.replace(QLatin1String("\""), QLatin1String("\\\"")); - str.replace(QLatin1String("\b"), QLatin1String("\\b")); - str.replace(QLatin1String("\f"), QLatin1String("\\f")); - str.replace(QLatin1String("\n"), QLatin1String("\\n")); - str.replace(QLatin1String("\r"), QLatin1String("\\r")); - str.replace(QLatin1String("\t"), QLatin1String("\\t")); - return QString(QLatin1String("\"%1\"")).arg(str); -} - -static QByteArray join(const QList &list, const QByteArray &sep) -{ - QByteArray res; - for (const QByteArray &i : list) { - if (!res.isEmpty()) { - res += sep; - } - res += i; - } - return res; -} - -/** - * parse - */ -QVariant Json::parse(const QString &json) -{ - bool success = true; - return Json::parse(json, success); -} - -/** - * parse - */ -QVariant Json::parse(const QString &json, bool &success) -{ - success = true; - - // Return an empty QVariant if the JSON data is either null or empty - if (!json.isNull() || !json.isEmpty()) { - // We'll start from index 0 - int index = 0; - - // Parse the first value - QVariant value = Json::parseValue(json, index, success); - - // Return the parsed value - return value; - } else { - // Return the empty QVariant - return QVariant(); - } -} - -QByteArray Json::serialize(const QVariant &data) -{ - bool success = true; - return Json::serialize(data, success); -} - -QByteArray Json::serialize(const QVariant &data, bool &success) -{ - QByteArray str; - success = true; - - if (!data.isValid()) // invalid or null? - { - str = "null"; - } -#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) - else if ((data.typeId() == QMetaType::Type::QVariantList) || - (data.typeId() == QMetaType::Type::QStringList)) // variant is a list? -#else - else if ((data.type() == QVariant::List) || (data.type() == QVariant::StringList)) // variant is a list? -#endif - { - QList values; - const QVariantList list = data.toList(); - for (const QVariant &v : list) { - QByteArray serializedValue = serialize(v); - if (serializedValue.isNull()) { - success = false; - break; - } - values << serializedValue; - } - - str = "[ " + join(values, ", ") + " ]"; - } -#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) - else if ((data.typeId() == QMetaType::Type::QVariantHash)) // variant is a list? -#else - else if (data.type() == QVariant::Hash) // variant is a hash? -#endif - { - const QVariantHash vhash = data.toHash(); - QHashIterator it(vhash); - str = "{ "; - QList pairs; - - while (it.hasNext()) { - it.next(); - QByteArray serializedValue = serialize(it.value()); - - if (serializedValue.isNull()) { - success = false; - break; - } - - pairs << sanitizeString(it.key()).toUtf8() + " : " + serializedValue; - } - - str += join(pairs, ", "); - str += " }"; - } -#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) - else if ((data.typeId() == QMetaType::Type::QVariantMap)) // variant is a list? -#else - else if (data.type() == QVariant::Map) // variant is a map? -#endif - { - const QVariantMap vmap = data.toMap(); - QMapIterator it(vmap); - str = "{ "; - QList pairs; - while (it.hasNext()) { - it.next(); - QByteArray serializedValue = serialize(it.value()); - if (serializedValue.isNull()) { - success = false; - break; - } - pairs << sanitizeString(it.key()).toUtf8() + " : " + serializedValue; - } - str += join(pairs, ", "); - str += " }"; - } -#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) - else if ((data.typeId() == QMetaType::Type::QString) || - (data.typeId() == QMetaType::Type::QByteArray)) // variant is a list? -#else - else if ((data.type() == QVariant::String) || (data.type() == QVariant::ByteArray)) // a string or a byte array? -#endif - { - str = sanitizeString(data.toString()).toUtf8(); - } -#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) - else if (data.typeId() == QMetaType::Type::Double) -#else - else if (data.type() == QVariant::Double) // double? -#endif - { - str = QByteArray::number(data.toDouble(), 'g', 20); - if (!str.contains(".") && !str.contains("e")) { - str += ".0"; - } - } -#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) - else if (data.typeId() == QMetaType::Type::Bool) -#else - else if (data.type() == QVariant::Bool) // boolean value? -#endif - { - str = data.toBool() ? "true" : "false"; - } -#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) - else if (data.typeId() == QMetaType::Type::ULongLong) -#else - else if (data.type() == QVariant::ULongLong) // large unsigned number? -#endif - { - str = QByteArray::number(data.value()); - } else if (data.canConvert()) // any signed number? - { - str = QByteArray::number(data.value()); - } else if (data.canConvert()) { - str = QString::number(data.value()).toUtf8(); - } else if (data.canConvert()) // can value be converted to string? - { - // this will catch QDate, QDateTime, QUrl, ... - str = sanitizeString(data.toString()).toUtf8(); - } else { - success = false; - } - if (success) { - return str; - } else { - return QByteArray(); - } -} - -/** - * parseValue - */ -QVariant Json::parseValue(const QString &json, int &index, bool &success) -{ - // Determine what kind of data we should parse by - // checking out the upcoming token - switch (Json::lookAhead(json, index)) { - case JsonTokenString: - return Json::parseString(json, index, success); - case JsonTokenNumber: - return Json::parseNumber(json, index); - case JsonTokenCurlyOpen: - return Json::parseObject(json, index, success); - case JsonTokenSquaredOpen: - return Json::parseArray(json, index, success); - case JsonTokenTrue: - Json::nextToken(json, index); - return QVariant(true); - case JsonTokenFalse: - Json::nextToken(json, index); - return QVariant(false); - case JsonTokenNull: - Json::nextToken(json, index); - return QVariant(); - case JsonTokenNone: - break; - } - - // If there were no tokens, flag the failure and return an empty QVariant - success = false; - return QVariant(); -} - -/** - * parseObject - */ -QVariant Json::parseObject(const QString &json, int &index, bool &success) -{ - QVariantMap map; - int token; - - // Get rid of the whitespace and increment index - Json::nextToken(json, index); - - // Loop through all of the key/value pairs of the object - bool done = false; - while (!done) { - // Get the upcoming token - token = Json::lookAhead(json, index); - - if (token == JsonTokenNone) { - success = false; - return QVariantMap(); - } else if (token == JsonTokenComma) { - Json::nextToken(json, index); - } else if (token == JsonTokenCurlyClose) { - Json::nextToken(json, index); - return map; - } else { - // Parse the key/value pair's name - QString name = Json::parseString(json, index, success).toString(); - - if (!success) { - return QVariantMap(); - } - - // Get the next token - token = Json::nextToken(json, index); - - // If the next token is not a colon, flag the failure - // return an empty QVariant - if (token != JsonTokenColon) { - success = false; - return QVariant(QVariantMap()); - } - - // Parse the key/value pair's value - QVariant value = Json::parseValue(json, index, success); - - if (!success) { - return QVariantMap(); - } - - // Assign the value to the key in the map - map[name] = value; - } - } - - // Return the map successfully - return QVariant(map); -} - -/** - * parseArray - */ -QVariant Json::parseArray(const QString &json, int &index, bool &success) -{ - QVariantList list; - - Json::nextToken(json, index); - - bool done = false; - while (!done) { - int token = Json::lookAhead(json, index); - - if (token == JsonTokenNone) { - success = false; - return QVariantList(); - } else if (token == JsonTokenComma) { - Json::nextToken(json, index); - } else if (token == JsonTokenSquaredClose) { - Json::nextToken(json, index); - break; - } else { - QVariant value = Json::parseValue(json, index, success); - - if (!success) { - return QVariantList(); - } - - list.push_back(value); - } - } - - return QVariant(list); -} - -/** - * parseString - */ -QVariant Json::parseString(const QString &json, int &index, bool &success) -{ - QString s; - QChar c; - - Json::eatWhitespace(json, index); - - c = json[index++]; - - bool complete = false; - while (!complete) { - if (index == json.size()) { - break; - } - - c = json[index++]; - - if (c == '\"') { - complete = true; - break; - } else if (c == '\\') { - if (index == json.size()) { - break; - } - - c = json[index++]; - - if (c == '\"') { - s.append('\"'); - } else if (c == '\\') { - s.append('\\'); - } else if (c == '/') { - s.append('/'); - } else if (c == 'b') { - s.append('\b'); - } else if (c == 'f') { - s.append('\f'); - } else if (c == 'n') { - s.append('\n'); - } else if (c == 'r') { - s.append('\r'); - } else if (c == 't') { - s.append('\t'); - } else if (c == 'u') { - int remainingLength = json.size() - index; - - if (remainingLength >= 4) { - QString unicodeStr = json.mid(index, 4); - - int symbol = unicodeStr.toInt(0, 16); - - s.append(QChar(symbol)); - - index += 4; - } else { - break; - } - } - } else { - s.append(c); - } - } - - if (!complete) { - success = false; - return QVariant(); - } - - return QVariant(s); -} - -/** - * parseNumber - */ -QVariant Json::parseNumber(const QString &json, int &index) -{ - Json::eatWhitespace(json, index); - - int lastIndex = Json::lastIndexOfNumber(json, index); - int charLength = (lastIndex - index) + 1; - QString numberStr; - - numberStr = json.mid(index, charLength); - - index = lastIndex + 1; - - if (numberStr.contains('.')) { - return QVariant(numberStr.toDouble(NULL)); - } else if (numberStr.startsWith('-')) { - return QVariant(numberStr.toLongLong(NULL)); - } else { - return QVariant(numberStr.toULongLong(NULL)); - } -} - -/** - * lastIndexOfNumber - */ -int Json::lastIndexOfNumber(const QString &json, int index) -{ - static const QString numericCharacters("0123456789+-.eE"); - int lastIndex; - - for (lastIndex = index; lastIndex < json.size(); lastIndex++) { - if (numericCharacters.indexOf(json[lastIndex]) == -1) { - break; - } - } - - return lastIndex - 1; -} - -/** - * eatWhitespace - */ -void Json::eatWhitespace(const QString &json, int &index) -{ - static const QString whitespaceChars(" \t\n\r"); - for (; index < json.size(); index++) { - if (whitespaceChars.indexOf(json[index]) == -1) { - break; - } - } -} - -/** - * lookAhead - */ -int Json::lookAhead(const QString &json, int index) -{ - int saveIndex = index; - return Json::nextToken(json, saveIndex); -} - -/** - * nextToken - */ -int Json::nextToken(const QString &json, int &index) -{ - Json::eatWhitespace(json, index); - - if (index == json.size()) { - return JsonTokenNone; - } - - QChar c = json[index]; - index++; - switch (c.toLatin1()) { - case '{': - return JsonTokenCurlyOpen; - case '}': - return JsonTokenCurlyClose; - case '[': - return JsonTokenSquaredOpen; - case ']': - return JsonTokenSquaredClose; - case ',': - return JsonTokenComma; - case '"': - return JsonTokenString; - case '0': - case '1': - case '2': - case '3': - case '4': - case '5': - case '6': - case '7': - case '8': - case '9': - case '-': - return JsonTokenNumber; - case ':': - return JsonTokenColon; - } - - index--; - - int remainingLength = json.size() - index; - - // True - if (remainingLength >= 4) { - if (json[index] == 't' && json[index + 1] == 'r' && json[index + 2] == 'u' && json[index + 3] == 'e') { - index += 4; - return JsonTokenTrue; - } - } - - // False - if (remainingLength >= 5) { - if (json[index] == 'f' && json[index + 1] == 'a' && json[index + 2] == 'l' && json[index + 3] == 's' && - json[index + 4] == 'e') { - index += 5; - return JsonTokenFalse; - } - } - - // Null - if (remainingLength >= 4) { - if (json[index] == 'n' && json[index + 1] == 'u' && json[index + 2] == 'l' && json[index + 3] == 'l') { - index += 4; - return JsonTokenNull; - } - } - - return JsonTokenNone; -} - -} // namespace QtJson diff --git a/oracle/src/qt-json/json.h b/oracle/src/qt-json/json.h deleted file mode 100644 index cf0499d4e..000000000 --- a/oracle/src/qt-json/json.h +++ /dev/null @@ -1,204 +0,0 @@ -/* Copyright 2011 Eeli Reilin. All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, - * this list of conditions and the following disclaimer. - * - * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * - * THIS SOFTWARE IS PROVIDED BY ''AS IS'' AND ANY EXPRESS OR - * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF - * MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO - * EVENT SHALL EELI REILIN OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, - * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, - * OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF - * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING - * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, - * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - * - * The views and conclusions contained in the software and documentation - * are those of the authors and should not be interpreted as representing - * official policies, either expressed or implied, of Eeli Reilin. - */ - -/** - * \file json.h - */ - -#ifndef JSON_H -#define JSON_H - -#include -#include - -namespace QtJson -{ - -/** - * \enum JsonToken - */ -enum JsonToken -{ - JsonTokenNone = 0, - JsonTokenCurlyOpen = 1, - JsonTokenCurlyClose = 2, - JsonTokenSquaredOpen = 3, - JsonTokenSquaredClose = 4, - JsonTokenColon = 5, - JsonTokenComma = 6, - JsonTokenString = 7, - JsonTokenNumber = 8, - JsonTokenTrue = 9, - JsonTokenFalse = 10, - JsonTokenNull = 11 -}; - -/** - * \class Json - * \brief A JSON data parser - * - * Json parses a JSON data into a QVariant hierarchy. - */ -class Json -{ - public: - /** - * Parse a JSON string - * - * \param json The JSON data - */ - static QVariant parse(const QString &json); - - /** - * Parse a JSON string - * - * \param json The JSON data - * \param success The success of the parsing - */ - static QVariant parse(const QString &json, bool &success); - - /** - * This method generates a textual JSON representation - * - * \param data The JSON data generated by the parser. - * \param success The success of the serialization - */ - static QByteArray serialize(const QVariant &data); - - /** - * This method generates a textual JSON representation - * - * \param data The JSON data generated by the parser. - * \param success The success of the serialization - * - * \return QByteArray Textual JSON representation - */ - static QByteArray serialize(const QVariant &data, bool &success); - - private: - /** - * Parses a value starting from index - * - * \param json The JSON data - * \param index The start index - * \param success The success of the parse process - * - * \return QVariant The parsed value - */ - static QVariant parseValue(const QString &json, int &index, - bool &success); - - /** - * Parses an object starting from index - * - * \param json The JSON data - * \param index The start index - * \param success The success of the object parse - * - * \return QVariant The parsed object map - */ - static QVariant parseObject(const QString &json, int &index, - bool &success); - - /** - * Parses an array starting from index - * - * \param json The JSON data - * \param index The starting index - * \param success The success of the array parse - * - * \return QVariant The parsed variant array - */ - static QVariant parseArray(const QString &json, int &index, - bool &success); - - /** - * Parses a string starting from index - * - * \param json The JSON data - * \param index The starting index - * \param success The success of the string parse - * - * \return QVariant The parsed string - */ - static QVariant parseString(const QString &json, int &index, - bool &success); - - /** - * Parses a number starting from index - * - * \param json The JSON data - * \param index The starting index - * - * \return QVariant The parsed number - */ - static QVariant parseNumber(const QString &json, int &index); - - /** - * Get the last index of a number starting from index - * - * \param json The JSON data - * \param index The starting index - * - * \return The last index of the number - */ - static int lastIndexOfNumber(const QString &json, int index); - - /** - * Skip unwanted whitespace symbols starting from index - * - * \param json The JSON data - * \param index The start index - */ - static void eatWhitespace(const QString &json, int &index); - - /** - * Check what token lies ahead - * - * \param json The JSON data - * \param index The starting index - * - * \return int The upcoming token - */ - static int lookAhead(const QString &json, int index); - - /** - * Get the next JSON token - * - * \param json The JSON data - * \param index The starting index - * - * \return int The next JSON token - */ - static int nextToken(const QString &json, int &index); -}; - - -} //end namespace - -#endif //JSON_H diff --git a/oracle/src/raw_json_scanner.cpp b/oracle/src/raw_json_scanner.cpp new file mode 100644 index 000000000..8c4633598 --- /dev/null +++ b/oracle/src/raw_json_scanner.cpp @@ -0,0 +1,677 @@ +#include "raw_json_scanner.h" + +#include +#include + +namespace +{ + +// Nesting cap matching QJsonDocument's limit, so a pathologically deep document +// fails shallowly instead of overflowing the stack through the recursive +// skipValue/skipArray/skipObject walk (Qt's parser caps at 1024 for the same +// reason and reports DeepNesting). +constexpr int kMaxNestingDepth = 1024; + +/** + * @brief Throttled byte-position reporting for scanSetRanges(). + * + * Threaded through the skip walk so progress can be reported without materializing + * the whole document. Reports are rate-limited so a GUI showing progress isn't + * flooded with interrupts: a callback is invoked at most ~100 times per scan + * regardless of element count. The closing stretch (the last ~1%) is reported + * more finely so a large document doesn't stall the progress bar on the final + * percent before the scan wraps up. + */ +struct ScanProgress +{ + const char *begin = nullptr; + qsizetype size = 0; + RawJson::ScanProgressCallback callback; + qsizetype step = 1; + qsizetype lastReported = 0; + + /** + * @brief Reports the scanner's absolute offset, unless within @p step bytes + * of the previous report and not yet at the end of the document. + */ + void report(const char *p) + { + if (!callback) { + return; + } + const qsizetype offset = p - begin; + if (offset == lastReported) { + return; // the final element often already sits exactly at the end + } + const qsizetype reportingStep = offset >= size - step ? std::max(1, step / 16) : step; + if (offset - lastReported < reportingStep && offset < size) { + return; + } + lastReported = offset; + callback(offset, size); + } +}; + +inline bool isWhitespace(char c) +{ + return c == ' ' || c == '\t' || c == '\r' || c == '\n'; +} + +const char *skipWhitespace(const char *p, const char *end) +{ + while (p < end && isWhitespace(*p)) { + ++p; + } + return p; +} + +inline bool isHexDigit(char c) +{ + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); +} + +inline quint8 hexValue(char c) +{ + if (c >= '0' && c <= '9') { + return c - '0'; + } + if (c >= 'a' && c <= 'f') { + return c - 'a' + 10; + } + return c - 'A' + 10; +} + +/** + * @brief Skips past a JSON string without decoding it, validating escapes. + * @param p In: pointing at the opening quote. Out: pointing past the closing quote. + */ +bool skipString(const char *&p, const char *end) +{ + ++p; // opening quote + for (;;) { + const void *quote = memchr(p, '"', static_cast(end - p)); + if (!quote) { + return false; // unterminated string + } + // Backslash escapes can only appear before the closing quote, so bound + // the scan to the string extent instead of the rest of the document. + const void *backslash = memchr(p, '\\', static_cast(static_cast(quote) - p)); + if (!backslash) { + p = static_cast(quote) + 1; + return true; + } + const char *b = static_cast(backslash); + if (end - b < 2) { + return false; + } + const char escaped = b[1]; + if (escaped == 'u') { + if (end - b < 6) { + return false; + } + quint32 codepoint = 0; + for (int i = 0; i < 4; ++i) { + if (!isHexDigit(b[2 + i])) { + return false; + } + codepoint = codepoint * 16 + hexValue(b[2 + i]); + } + p = b + 6; + if (codepoint >= 0xD800 && codepoint <= 0xDBFF) { + // expect the low-surrogate escape for the second half + if (end - p < 6 || p[0] != '\\' || p[1] != 'u') { + return false; // unpaired high surrogate + } + quint32 low = 0; + for (int i = 0; i < 4; ++i) { + if (!isHexDigit(p[2 + i])) { + return false; + } + low = low * 16 + hexValue(p[2 + i]); + } + if (low < 0xDC00 || low > 0xDFFF) { + return false; + } + p += 6; + } else if (codepoint >= 0xDC00 && codepoint <= 0xDFFF) { + return false; // unpaired low surrogate + } + continue; + } + switch (escaped) { + case '"': + case '\\': + case '/': + case 'b': + case 'f': + case 'n': + case 'r': + case 't': + p = b + 2; + continue; + default: + return false; // invalid escape + } + } +} + +/** + * @brief Decodes a JSON string into @p out, validating it as it goes. + * @param p In: pointing at the opening quote. Out: pointing past the closing quote. + */ +bool decodeString(const char *&p, const char *end, QString &out) +{ + out.clear(); + QByteArray utf8; + auto flush = [&out, &utf8]() { + if (!utf8.isEmpty()) { + out += QString::fromUtf8(utf8); + utf8.clear(); + } + }; + + ++p; // opening quote + while (p < end) { + const char c = *p; + if (c == '\\') { + flush(); + ++p; // escaped character + if (p >= end) { + return false; + } + const char escaped = *p; + if (escaped == 'u') { + ++p; // first hex digit + if (p + 4 > end) { + return false; + } + quint32 codepoint = 0; + for (int i = 0; i < 4; ++i) { + if (!isHexDigit(p[i])) { + return false; + } + codepoint = codepoint * 16 + hexValue(p[i]); + } + p += 4; + if (codepoint >= 0xD800 && codepoint <= 0xDBFF) { + // expect a low-surrogate escape for the second half + if (p + 6 > end || p[0] != '\\' || p[1] != 'u') { + return false; // unpaired high surrogate + } + quint32 low = 0; + for (int i = 0; i < 4; ++i) { + if (!isHexDigit(p[2 + i])) { + return false; + } + low = low * 16 + hexValue(p[2 + i]); + } + if (low < 0xDC00 || low > 0xDFFF) { + return false; + } + out += QChar(codepoint); + out += QChar(low); + p += 6; + } else if (codepoint >= 0xDC00 && codepoint <= 0xDFFF) { + return false; // unpaired low surrogate + } else { + out += QChar(codepoint); + } + continue; + } + switch (escaped) { + case '"': + out += '"'; + break; + case '\\': + out += '\\'; + break; + case '/': + out += '/'; + break; + case 'b': + out += '\b'; + break; + case 'f': + out += '\f'; + break; + case 'n': + out += '\n'; + break; + case 'r': + out += '\r'; + break; + case 't': + out += '\t'; + break; + default: + return false; + } + ++p; + continue; + } + if (c == '"') { + ++p; + flush(); + return true; + } + // Deliberately accept unescaped control characters (e.g. a tab inside + // a set name): QJsonDocument and skipString accept them too, so + // rejecting them here would fail the whole document on a byte that + // Qt is fine with — the very total-failure mode this scanner avoids. + utf8 += c; + ++p; + } + return false; +} + +/** + * @brief Reads a set-metadata field, tolerating null and non-string values. + * + * A set's metadata may carry null or non-string values in otherwise-valid + * payloads ("releaseDate": null, "type": 7). The token itself was already + * structurally validated by skipValue, so a non-string value is accepted and + * leaves @p out at its default (empty) — one bad set must not abort the + * import of every other set in the document. + */ +bool decodeStringMember(const char *&fs, const char *&fe, QString &out) +{ + if (fs >= fe) { + return false; + } + if (*fs != '"') { + return true; + } + return decodeString(fs, fe, out); +} + +bool matchLiteral(const char *&p, const char *end, const char *literal, int length) +{ + if (end - p < length || memcmp(p, literal, static_cast(length)) != 0) { + return false; + } + const char *after = p + length; + if (after < end && (QChar::isLetter(*after) || QChar::isDigit(*after) || *after == '_')) { + return false; + } + p = after; + return true; +} + +bool skipNumber(const char *&p, const char *end) +{ + // JSON number: -?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)? + if (p < end && *p == '-') { + ++p; + } + if (p < end && *p == '0') { + ++p; + } else if (p < end && *p >= '1' && *p <= '9') { + ++p; + while (p < end && QChar::isDigit(*p)) { + ++p; + } + } else { + return false; + } + if (p < end && *p == '.') { + ++p; + if (p >= end || !QChar::isDigit(*p)) { + return false; + } + while (p < end && QChar::isDigit(*p)) { + ++p; + } + } + if (p < end && (*p == 'e' || *p == 'E')) { + ++p; + if (p < end && (*p == '+' || *p == '-')) { + ++p; + } + if (p >= end || !QChar::isDigit(*p)) { + return false; + } + while (p < end && QChar::isDigit(*p)) { + ++p; + } + } + return true; +} + +bool skipValue(const char *&p, const char *end, int depth, ScanProgress &scan); +bool skipObject(const char *&p, const char *end, int depth, ScanProgress &scan); +bool skipArray(const char *&p, const char *end, int depth, ScanProgress &scan); + +bool skipPrimitive(const char *&p, const char *end) +{ + if (p >= end) { + return false; + } + const char c = *p; + if (c == '"') { + return skipString(p, end); + } + if (c == 't') { + return matchLiteral(p, end, "true", 4); + } + if (c == 'f') { + return matchLiteral(p, end, "false", 5); + } + if (c == 'n') { + return matchLiteral(p, end, "null", 4); + } + if (c == '-' || (c >= '0' && c <= '9')) { + return skipNumber(p, end); + } + return false; +} + +bool skipObject(const char *&p, const char *end, int depth, ScanProgress &scan) +{ + if (depth <= 0) { + return false; // nest deeper than the cap + } + ++p; // '{' + p = skipWhitespace(p, end); + if (p < end && *p == '}') { + ++p; + return true; + } + for (;;) { + p = skipWhitespace(p, end); + if (p >= end || *p != '"') { + return false; + } + if (!skipString(p, end)) { + return false; + } + p = skipWhitespace(p, end); + if (p >= end || *p != ':') { + return false; + } + ++p; + if (!skipValue(p, end, depth - 1, scan)) { + return false; + } + scan.report(p); + p = skipWhitespace(p, end); + if (p >= end) { + return false; + } + if (*p == ',') { + ++p; + continue; + } + if (*p == '}') { + ++p; + return true; + } + return false; + } +} + +bool skipArray(const char *&p, const char *end, int depth, ScanProgress &scan) +{ + if (depth <= 0) { + return false; // nest deeper than the cap + } + ++p; // '[' + p = skipWhitespace(p, end); + if (p < end && *p == ']') { + ++p; + return true; + } + for (;;) { + if (!skipValue(p, end, depth - 1, scan)) { + return false; + } + scan.report(p); + p = skipWhitespace(p, end); + if (p >= end) { + return false; + } + if (*p == ',') { + ++p; + continue; + } + if (*p == ']') { + ++p; + return true; + } + return false; + } +} + +bool skipValue(const char *&p, const char *end, int depth, ScanProgress &scan) +{ + p = skipWhitespace(p, end); + if (p >= end) { + return false; + } + const char c = *p; + if (c == '{') { + // pass depth through: skipObject consumes the single decrement for this level + return skipObject(p, end, depth, scan); + } + if (c == '[') { + return skipArray(p, end, depth, scan); + } + // a primitive is a leaf, so it never wastes a nesting level + return skipPrimitive(p, end); +} + +/** + * @brief Iterates the members of the object starting at @p p. + * + * For each member invokes @p memberCallback with the key and the byte range of + * its value. Advancing @p p is unaffected by the callback. + */ +template +bool forEachObjectMember(const char *&p, const char *end, int depth, F &&memberCallback, ScanProgress &scan) +{ + if (depth <= 0) { + return false; // nest deeper than the cap + } + ++p; // '{' + p = skipWhitespace(p, end); + if (p < end && *p == '}') { + ++p; + return true; + } + for (;;) { + p = skipWhitespace(p, end); + if (p >= end || *p != '"') { + return false; + } + QString key; + if (!decodeString(p, end, key)) { + return false; + } + p = skipWhitespace(p, end); + if (p >= end || *p != ':') { + return false; + } + ++p; + const char *valueStart = skipWhitespace(p, end); + const char *valueEnd = valueStart; + if (!skipValue(valueEnd, end, depth - 1, scan)) { + return false; + } + if (!memberCallback(key, valueStart, valueEnd)) { + return false; + } + p = valueEnd; + p = skipWhitespace(p, end); + if (p >= end) { + return false; + } + if (*p == ',') { + ++p; + continue; + } + if (*p == '}') { + ++p; + return true; + } + return false; + } +} + +// Counts the direct elements of an array value; returns -1 if the array is malformed. +int countArrayElements(const char *p, const char *end, int depth, ScanProgress &scan) +{ + if (depth <= 0) { + return -1; // nest deeper than the cap + } + ++p; // '[' + p = skipWhitespace(p, end); + int count = 0; + if (p < end && *p == ']') { + return 0; + } + for (;;) { + if (!skipValue(p, end, depth - 1, scan)) { + return -1; + } + scan.report(p); + ++count; + p = skipWhitespace(p, end); + if (p >= end) { + return -1; + } + if (*p == ',') { + ++p; + continue; + } + if (*p == ']') { + return count; + } + return -1; + } +} + +} // namespace + +namespace RawJson +{ + +QList scanSetRanges(const QByteArray &json, ScanError *error, const ScanProgressCallback &progress) +{ + QList ranges; + if (error) { + *error = ScanError{}; + } + + const auto fail = [&](const QString &message) -> QList { + if (error) { + error->message = message; + } + return {}; + }; + + const char *begin = json.constData(); + const char *end = begin + json.size(); + if (begin >= end) { + return fail(QStringLiteral("empty JSON document")); + } + + // Throttle reports to ~100 per scan so a GUI thread unthrottling them never + // drowns under per-card interrupts, whatever the document size. + ScanProgress scan; + scan.begin = begin; + scan.size = end - begin; + scan.step = std::max(1, scan.size / 100); + scan.callback = progress; + + const char *p = skipWhitespace(begin, end); + if (p >= end || *p != '{') { + return fail(QStringLiteral("top-level JSON must be an object")); + } + + bool foundData = false; + bool malformedSetData = false; + + const auto topLevelCallback = [&](const QString &key, const char *valueStart, const char *valueEnd) { + if (key == QStringLiteral("data")) { + foundData = true; + if (valueStart >= valueEnd || *valueStart != '{') { + malformedSetData = true; + return false; + } + const char *setP = valueStart; + const bool ok = forEachObjectMember( + setP, valueEnd, kMaxNestingDepth - 1, + [&](const QString &setCode, const char *setStart, const char *setEnd) { + if (setStart >= setEnd || *setStart != '{') { + malformedSetData = true; + return false; + } + SetRange range; + range.dataRange.start = setStart - begin; + range.dataRange.length = setEnd - setStart; + range.code = setCode; + + const char *memberP = setStart; + const bool metaOk = forEachObjectMember( + memberP, setEnd, kMaxNestingDepth - 2, + [&](const QString &field, const char *fs, const char *fe) { + if (field == QStringLiteral("code")) { + return decodeStringMember(fs, fe, range.code); + } + if (field == QStringLiteral("name")) { + return decodeStringMember(fs, fe, range.name); + } + if (field == QStringLiteral("type")) { + return decodeStringMember(fs, fe, range.type); + } + if (field == QStringLiteral("releaseDate")) { + return decodeStringMember(fs, fe, range.releaseDate); + } + if (field == QStringLiteral("cards")) { + if (fs >= fe) { + return false; + } + if (*fs != '[') { + // e.g. "cards": null — treat as an empty array, + // matching Qt's tolerance. + return true; + } + range.dataRange.cardCount = countArrayElements(fs, fe, kMaxNestingDepth - 2, scan); + return range.dataRange.cardCount >= 0; + } + return true; + }, + scan); + if (!metaOk) { + malformedSetData = true; + return false; + } + ranges.append(range); + return true; + }, + scan); + if (!ok) { + malformedSetData = true; + return false; + } + } + return true; + }; + + if (!forEachObjectMember(p, end, kMaxNestingDepth, topLevelCallback, scan)) { + return fail(malformedSetData ? QStringLiteral("malformed set data") : QStringLiteral("malformed JSON")); + } + p = skipWhitespace(p, end); + if (p != end) { + return fail(QStringLiteral("trailing content after top-level JSON object")); + } + if (!foundData) { + return fail(QStringLiteral("missing \"data\" object")); + } + if (ranges.isEmpty()) { + return fail(QStringLiteral("no sets found in \"data\"")); + } + scan.report(end); + return ranges; +} + +} // namespace RawJson \ No newline at end of file diff --git a/oracle/src/raw_json_scanner.h b/oracle/src/raw_json_scanner.h new file mode 100644 index 000000000..04fcc52e4 --- /dev/null +++ b/oracle/src/raw_json_scanner.h @@ -0,0 +1,89 @@ +#ifndef RAW_JSON_SCANNER_H +#define RAW_JSON_SCANNER_H + +#include +#include +#include +#include + +namespace RawJson +{ + +/** + * @brief The byte extent of a set's object inside the scanned document, plus + * the size of its cards array. This is the slice SetToDownload needs for lazy + * per-set parsing; the metadata strings live in SetRange alongside it. + */ +struct SetDataRange +{ + /** @brief Byte offset of the set's object within the scanned buffer. */ + qsizetype start = -1; + /** @brief Byte length of the set's object, including the surrounding braces. */ + qsizetype length = 0; + /** @brief Number of entries in the set's "cards" array. */ + int cardCount = 0; +}; + +struct SetRange +{ + /** @brief The byte slice of this set within the document. */ + SetDataRange dataRange; + QString code; + QString name; + QString type; + QString releaseDate; +}; + +struct ScanError +{ + bool isError() const + { + return !message.isEmpty(); + } + QString message; +}; + +/** + * @brief Optional progress callback receiving @c (bytesRead, totalBytes) while + * the document is walked. Invoked from the scanning thread; the caller decides + * how the throttled offsets are relayed to a GUI event loop. + */ +using ScanProgressCallback = std::function; + +/** + * @brief Scans a full MTGJSON document without materializing the JSON tree. + * + * Splits the top-level "data" object into per-set byte ranges and reads each + * set's metadata directly from the raw bytes. The Oracle importer can then + * parse one set at a time during import, keeping peak memory far below a single + * QJsonDocument::fromJson() over the whole file. + * + * The whole document is structurally validated while scanning (strings, + * escapes, braces, and a trailing-content check) and nesting depth is capped at + * 1024 to match QJsonDocument, so pathologically deep documents fail shallowly + * instead of exhausting the stack. Verdicts agree with QJsonDocument::fromJson + * on structurally malformed input; unlike Qt, string metadata fields + * ("name", "type", "releaseDate", "code") tolerate null / non-string values by + * defaulting to empty rather than rejecting the whole document, so one broken + * set cannot abort the import of the rest. + * + * Following QJsonDocument::fromJson's convention, the parsed ranges are + * returned by value and any failure is reported through the @p error out + * parameter. + * + * @param json The raw MTGJSON document bytes. + * @param error Out parameter. Set to an error ScanError when the document + * cannot be parsed, otherwise left empty. Passing a null + * pointer disables error reporting. + * @param progress Optional progress callback. When non-empty it is invoked as + * the scanner advances through the document, throttled to a + * tiny fraction of the total size. + * @return The detected per-set ranges, or an empty list on failure. + */ +QList scanSetRanges(const QByteArray &json, + ScanError *error = nullptr, + const ScanProgressCallback &progress = ScanProgressCallback()); + +} // namespace RawJson + +#endif // RAW_JSON_SCANNER_H \ No newline at end of file diff --git a/oracle/translations/oracle_de.ts b/oracle/translations/oracle_de.ts index df12f40f4..196315a36 100644 --- a/oracle/translations/oracle_de.ts +++ b/oracle/translations/oracle_de.ts @@ -276,7 +276,7 @@ OracleImporter - + Dummy set containing tokens Platzhalter Edition mit Spielsteinen @@ -284,7 +284,7 @@ OracleWizard - + Oracle Importer Oracle Importer diff --git a/oracle/translations/oracle_el.ts b/oracle/translations/oracle_el.ts index 7934997cf..d3a2e081c 100644 --- a/oracle/translations/oracle_el.ts +++ b/oracle/translations/oracle_el.ts @@ -276,7 +276,7 @@ OracleImporter - + Dummy set containing tokens Εικονικό σετ που περιέχει tokens @@ -284,7 +284,7 @@ OracleWizard - + Oracle Importer Εισαγωγέας Oracle diff --git a/oracle/translations/oracle_en_US.ts b/oracle/translations/oracle_en_US.ts index 4b664bf57..394e10b92 100644 --- a/oracle/translations/oracle_en_US.ts +++ b/oracle/translations/oracle_en_US.ts @@ -276,7 +276,7 @@ OracleImporter - + Dummy set containing tokens Dummy set containing tokens @@ -284,7 +284,7 @@ OracleWizard - + Oracle Importer Oracle Importer diff --git a/oracle/translations/oracle_es.ts b/oracle/translations/oracle_es.ts index 33dbbdc3a..c11c1740a 100644 --- a/oracle/translations/oracle_es.ts +++ b/oracle/translations/oracle_es.ts @@ -276,7 +276,7 @@ OracleImporter - + Dummy set containing tokens Set dedicado para tokens @@ -284,7 +284,7 @@ OracleWizard - + Oracle Importer Importador de Oracle diff --git a/oracle/translations/oracle_et.ts b/oracle/translations/oracle_et.ts index 9f2c560cd..5a5e915db 100644 --- a/oracle/translations/oracle_et.ts +++ b/oracle/translations/oracle_et.ts @@ -276,7 +276,7 @@ OracleImporter - + Dummy set containing tokens Nukk-komplekt mis sisaldab märgistusi @@ -284,7 +284,7 @@ OracleWizard - + Oracle Importer Oracle sissetooja diff --git a/oracle/translations/oracle_fi.ts b/oracle/translations/oracle_fi.ts index c7657cf7a..e2a193ca4 100644 --- a/oracle/translations/oracle_fi.ts +++ b/oracle/translations/oracle_fi.ts @@ -276,7 +276,7 @@ OracleImporter - + Dummy set containing tokens Tokeneja sisältävä mallisetti @@ -284,7 +284,7 @@ OracleWizard - + Oracle Importer Oracle-lataaja diff --git a/oracle/translations/oracle_fr.ts b/oracle/translations/oracle_fr.ts index 47ab5d0fa..590a0b879 100644 --- a/oracle/translations/oracle_fr.ts +++ b/oracle/translations/oracle_fr.ts @@ -276,7 +276,7 @@ OracleImporter - + Dummy set containing tokens Fausse édition contenant les jetons @@ -284,7 +284,7 @@ OracleWizard - + Oracle Importer Importateur Oracle diff --git a/oracle/translations/oracle_it.ts b/oracle/translations/oracle_it.ts index 34bcb3cbc..e266209d1 100644 --- a/oracle/translations/oracle_it.ts +++ b/oracle/translations/oracle_it.ts @@ -277,7 +277,7 @@ e pedine che verranno usate da Cockatrice.
OracleImporter - + Dummy set containing tokens Set finto contenente i token @@ -285,7 +285,7 @@ e pedine che verranno usate da Cockatrice. OracleWizard - + Oracle Importer Oracle Importer diff --git a/oracle/translations/oracle_ja.ts b/oracle/translations/oracle_ja.ts index 8319f37a2..a437db796 100644 --- a/oracle/translations/oracle_ja.ts +++ b/oracle/translations/oracle_ja.ts @@ -276,7 +276,7 @@ OracleImporter - + Dummy set containing tokens ダミーセットを含むトークン @@ -284,7 +284,7 @@ OracleWizard - + Oracle Importer Oracle Importer - オラクル・インポーター diff --git a/oracle/translations/oracle_ko.ts b/oracle/translations/oracle_ko.ts index c5c24583f..3b0b0187c 100644 --- a/oracle/translations/oracle_ko.ts +++ b/oracle/translations/oracle_ko.ts @@ -276,7 +276,7 @@ OracleImporter - + Dummy set containing tokens 토큰 정보가 들어있는 더미 확장판 @@ -284,7 +284,7 @@ OracleWizard - + Oracle Importer 오라클 diff --git a/oracle/translations/oracle_nb.ts b/oracle/translations/oracle_nb.ts index b40868cc1..698076fd5 100644 --- a/oracle/translations/oracle_nb.ts +++ b/oracle/translations/oracle_nb.ts @@ -276,7 +276,7 @@ OracleImporter - + Dummy set containing tokens Dummy sett som inneholder tokens @@ -284,7 +284,7 @@ OracleWizard - + Oracle Importer Oracle importerer diff --git a/oracle/translations/oracle_nl.ts b/oracle/translations/oracle_nl.ts index ed9a5fae9..3fd3cc543 100644 --- a/oracle/translations/oracle_nl.ts +++ b/oracle/translations/oracle_nl.ts @@ -276,7 +276,7 @@ OracleImporter - + Dummy set containing tokens Token voorbeeldset @@ -284,7 +284,7 @@ OracleWizard - + Oracle Importer Oracle importer diff --git a/oracle/translations/oracle_pl.ts b/oracle/translations/oracle_pl.ts index d5d632f70..cad6309a9 100644 --- a/oracle/translations/oracle_pl.ts +++ b/oracle/translations/oracle_pl.ts @@ -276,7 +276,7 @@ OracleImporter - + Dummy set containing tokens Dodatek-atrapa, zawierający tokeny. @@ -284,7 +284,7 @@ OracleWizard - + Oracle Importer Oracle – kreator importu diff --git a/oracle/translations/oracle_pt.ts b/oracle/translations/oracle_pt.ts index c578a0365..069f3013e 100644 --- a/oracle/translations/oracle_pt.ts +++ b/oracle/translations/oracle_pt.ts @@ -276,7 +276,7 @@ OracleImporter - + Dummy set containing tokens Set básico contendo fichas @@ -284,7 +284,7 @@ OracleWizard - + Oracle Importer Importar Oracle diff --git a/oracle/translations/oracle_pt_BR.ts b/oracle/translations/oracle_pt_BR.ts index 96a6382ec..30c4e5e65 100644 --- a/oracle/translations/oracle_pt_BR.ts +++ b/oracle/translations/oracle_pt_BR.ts @@ -276,7 +276,7 @@ OracleImporter - + Dummy set containing tokens Esta expansão contém fichas. @@ -284,7 +284,7 @@ OracleWizard - + Oracle Importer Importador Oracle diff --git a/oracle/translations/oracle_ru.ts b/oracle/translations/oracle_ru.ts index e2ff5b5a0..4e1bcd3c6 100644 --- a/oracle/translations/oracle_ru.ts +++ b/oracle/translations/oracle_ru.ts @@ -276,7 +276,7 @@ OracleImporter - + Dummy set containing tokens Пример сета с фишками @@ -284,7 +284,7 @@ OracleWizard - + Oracle Importer Импортер Oracle diff --git a/oracle/translations/oracle_sr.ts b/oracle/translations/oracle_sr.ts index 04d48688a..9532030b3 100644 --- a/oracle/translations/oracle_sr.ts +++ b/oracle/translations/oracle_sr.ts @@ -276,7 +276,7 @@ OracleImporter - + Dummy set containing tokens @@ -284,7 +284,7 @@ OracleWizard - + Oracle Importer diff --git a/oracle/translations/oracle_tr.ts b/oracle/translations/oracle_tr.ts index 517f9248e..e65124c40 100644 --- a/oracle/translations/oracle_tr.ts +++ b/oracle/translations/oracle_tr.ts @@ -276,7 +276,7 @@ OracleImporter - + Dummy set containing tokens @@ -284,7 +284,7 @@ OracleWizard - + Oracle Importer diff --git a/oracle/translations/oracle_yue.ts b/oracle/translations/oracle_yue.ts index 13a621b9e..d13848c34 100644 --- a/oracle/translations/oracle_yue.ts +++ b/oracle/translations/oracle_yue.ts @@ -276,7 +276,7 @@ OracleImporter - + Dummy set containing tokens 包含衍生物的虚拟牌組 @@ -284,7 +284,7 @@ OracleWizard - + Oracle Importer Oracle導入器 diff --git a/oracle/translations/oracle_zh-Hans.ts b/oracle/translations/oracle_zh-Hans.ts index ff19ab463..75ebdc138 100644 --- a/oracle/translations/oracle_zh-Hans.ts +++ b/oracle/translations/oracle_zh-Hans.ts @@ -276,7 +276,7 @@ OracleImporter - + Dummy set containing tokens 包含衍生物的虚拟牌组 @@ -284,7 +284,7 @@ OracleWizard - + Oracle Importer Oracle导入器 diff --git a/servatrice/CMakeLists.txt b/servatrice/CMakeLists.txt index 6e4191beb..68e422d8c 100644 --- a/servatrice/CMakeLists.txt +++ b/servatrice/CMakeLists.txt @@ -6,7 +6,9 @@ project(Servatrice VERSION "${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}.${ set(servatrice_SOURCES src/email_parser.cpp + src/event_loop_watchdog.cpp src/main.cpp + src/metrics_registry.cpp src/servatrice.cpp src/servatrice_connection_pool.cpp src/servatrice_database_interface.cpp @@ -43,11 +45,7 @@ if(APPLE) set(servatrice_SOURCES ${servatrice_SOURCES} ${CMAKE_CURRENT_SOURCE_DIR}/resources/appicon.icns) endif(APPLE) -if(Qt6_FOUND) - qt6_add_resources(servatrice_RESOURCES_RCC ${servatrice_RESOURCES}) -elseif(Qt5_FOUND) - qt5_add_resources(servatrice_RESOURCES_RCC ${servatrice_RESOURCES}) -endif() +qt6_add_resources(servatrice_RESOURCES_RCC ${servatrice_RESOURCES}) set(QT_DONT_USE_QTGUI TRUE) @@ -99,6 +97,8 @@ set(DESKTOPDIR # Build servatrice binary and link it add_executable(servatrice MACOSX_BUNDLE ${servatrice_MOC_SRCS} ${servatrice_RESOURCES_RCC} ${servatrice_SOURCES}) +target_precompile_headers(servatrice PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h") + if(CMAKE_HOST_SYSTEM MATCHES "FreeBSD") target_link_libraries( servatrice libcockatrice_deck_list libcockatrice_network_server_remote Threads::Threads ${SERVATRICE_QT_MODULES} diff --git a/servatrice/migrations/servatrice_0034_to_0035.sql b/servatrice/migrations/servatrice_0034_to_0035.sql new file mode 100644 index 000000000..acaad9c8b --- /dev/null +++ b/servatrice/migrations/servatrice_0034_to_0035.sql @@ -0,0 +1,19 @@ +ALTER TABLE `cockatrice_users` ADD COLUMN `card_art_params` TEXT DEFAULT NULL, ALGORITHM=INSTANT; + +CREATE TABLE IF NOT EXISTS `cockatrice_card_art_name_rules` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `card_name` varchar(255) NOT NULL, + `card_provider_id` varchar(255) NOT NULL, + `mode` enum('ALLOW','DENY') NOT NULL, + `reason` varchar(255) DEFAULT NULL, + `created_by` int(7) unsigned DEFAULT NULL, + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_provider_card_name` (`card_provider_id`, `card_name`), + KEY `idx_mode` (`mode`), + FOREIGN KEY (`created_by`) REFERENCES `cockatrice_users`(`id`) + ON DELETE SET NULL + ON UPDATE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci; + +UPDATE cockatrice_schema_version SET version=35 WHERE version=34; diff --git a/servatrice/migrations/servatrice_0035_to_0036.sql b/servatrice/migrations/servatrice_0035_to_0036.sql new file mode 100644 index 000000000..9727eeba5 --- /dev/null +++ b/servatrice/migrations/servatrice_0035_to_0036.sql @@ -0,0 +1,53 @@ +-- Servatrice db migration from version 35 to version 36 + +CREATE TABLE IF NOT EXISTS `cockatrice_reports` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `reporter_id` int(7) unsigned NULL, + `reporter_name` varchar(35) NOT NULL, + `reported_user_id` int(7) unsigned NULL, + `reported_user_name` varchar(35) NOT NULL, + `game_id` int(7) unsigned NULL, + `room_id` int(7) unsigned NULL, + `category` varchar(255) NOT NULL, + `description` text NOT NULL, + `chat_log` mediumtext NULL, + `created_at` datetime NOT NULL, + `resolution_time` datetime NULL, + `status` enum('open','assigned','resolved','dismissed') NOT NULL DEFAULT 'open', + `assigned_to` int(7) unsigned NULL, + `resolved_by` int(7) unsigned NULL, + `resolution_note` text, + `notified` tinyint(1) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + INDEX `idx_status` (`status`), + INDEX `idx_created` (`created_at`), + INDEX `idx_reporter_id_created` (`reporter_id`, `created_at`), + INDEX `idx_reported_user_name` (`reported_user_name`), + INDEX `idx_status_created` (`status`, `created_at`), + FOREIGN KEY (`reporter_id`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE, + FOREIGN KEY (`reported_user_id`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE, + FOREIGN KEY (`assigned_to`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE, + FOREIGN KEY (`resolved_by`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `cockatrice_report_comments` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `report_id` int(11) unsigned NOT NULL, + `author_name` varchar(35) NOT NULL, + `author_id` int(7) unsigned NULL, + `comment_text` text NOT NULL, + `created_at` datetime NOT NULL, + `is_moderator` tinyint(1) NOT NULL DEFAULT 0, + `notified` tinyint(1) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + INDEX `idx_report_id` (`report_id`), + INDEX `idx_notified` (`notified`), + FOREIGN KEY (`report_id`) REFERENCES `cockatrice_reports`(`id`) ON DELETE CASCADE ON UPDATE CASCADE, + FOREIGN KEY (`author_id`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; + +ALTER TABLE `cockatrice_users` + ADD COLUMN `force_password_change` tinyint(1) NOT NULL DEFAULT 0 + AFTER `passwordLastChangedDate`; + +UPDATE cockatrice_schema_version SET version=36 WHERE version=35; diff --git a/servatrice/servatrice.ini.example b/servatrice/servatrice.ini.example index fac743c39..23eca3f1b 100644 --- a/servatrice/servatrice.ini.example +++ b/servatrice/servatrice.ini.example @@ -79,8 +79,10 @@ requiredfeatures="" ; You can define custom warnings that users are sent when the moderation staff uses the right client warn user ; menu option. This list is comma seperated that each item will appear in the drop down list for staff members -; to choose from. Example: "Flaming,Foul Language" -officialwarnings="Flaming,Spamming,Causing Drama,Abusive Language" +; to choose from. Each entry may optionally carry a recommended starting intervention level (see the moderator +; guide) by appending "|" and the level number. Entries without an explicit level default to intervention +; level 1. Example: "Flaming,Foul Language" +officialwarnings="Abusive Language|1,Calling Out User|1,Causing Drama|1,Cheating|2,Disrespecting Staff|1,Disrupting a Draft|1,Inappropriate Avatar|3,Inappropriate Game Name|1,Kicking Without Valid Reason|1,Spamming|1,Targeted Harassment|2" ; Maximum time in seconds a player can stay connected but idle. Default is 3600 (0 = disabled) ; Clients will be notified at the 90% time period of pending disconnection if they do not take action. @@ -373,6 +375,26 @@ command_counting_interval=10 ; Maximum number of game commands in an interval before new commands gets dropped; default is 20 max_command_count_per_interval=20 +[reporting] +; Maximum number of user reports a single user can file per day; default is 10; set to 0 to disable the limit +max_reports_per_day=10 + +; Maximum number of report comments a single user can post per hour; default is 30; set to 0 to disable the limit +max_comments_per_hour=30 + +[metrics] +; Command containers that take longer than this many milliseconds are logged +; as slow commands. Set to 0 to disable the log line. A latency spike produces +; one warning per slow container with no rate limiting of its own -- a bad +; patch can briefly flood the log, which is how you notice it. +slow_command_ms=500 + +; Each socket pool thread runs a watchdog heartbeat. If a heartbeat arrives +; this many milliseconds late, the stall is logged and exposed as +; servatrice_eventloop_* metrics in the Developer tab. Set to 0 to disable +; the watchdogs. +stall_warn_ms=2000 + [logging] ; Admin/Moderators can query the stored logs for information when looking up reports by various players. This ; option can allow or disallow them from doing so. diff --git a/servatrice/servatrice.sql b/servatrice/servatrice.sql index fa644dbc0..cfb1ef5d8 100644 --- a/servatrice/servatrice.sql +++ b/servatrice/servatrice.sql @@ -20,11 +20,14 @@ CREATE TABLE IF NOT EXISTS `cockatrice_schema_version` ( PRIMARY KEY (`version`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; -INSERT INTO cockatrice_schema_version VALUES(34); +INSERT INTO cockatrice_schema_version VALUES(36); -- users and user data tables CREATE TABLE IF NOT EXISTS `cockatrice_users` ( `id` int(7) unsigned zerofill NOT NULL auto_increment, + -- Bitfield of staff levels: 1 = admin (implies moderator), 2 = moderator, + -- 4 = judge, 8 = developer. Operators set these by hand with + -- "UPDATE cockatrice_users SET admin = ...". `admin` tinyint(1) NOT NULL, `name` varchar(35) NOT NULL, `realname` varchar(255) NOT NULL, @@ -41,8 +44,10 @@ CREATE TABLE IF NOT EXISTS `cockatrice_users` ( `privlevelStartDate` datetime NOT NULL, `privlevelEndDate` datetime NOT NULL, `passwordLastChangedDate` datetime NOT NULL DEFAULT '0000-00-00 00:00:00', + `force_password_change` tinyint(1) NOT NULL DEFAULT 0, `leftPawnColorOverride` varchar(255), `rightPawnColorOverride` varchar(255), + `card_art_params` TEXT DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `name` (`name`), KEY `token` (`token`), @@ -232,6 +237,52 @@ CREATE TABLE IF NOT EXISTS `cockatrice_warnings` ( INDEX `idx_user_name` (`user_name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS `cockatrice_reports` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `reporter_id` int(7) unsigned NULL, + `reporter_name` varchar(35) NOT NULL, + `reported_user_id` int(7) unsigned NULL, + `reported_user_name` varchar(35) NOT NULL, + `game_id` int(7) unsigned NULL, + `room_id` int(7) unsigned NULL, + `category` varchar(255) NOT NULL, + `description` text NOT NULL, + `chat_log` mediumtext NULL, + `created_at` datetime NOT NULL, + `resolution_time` datetime NULL, + `status` enum('open','assigned','resolved','dismissed') NOT NULL DEFAULT 'open', + `assigned_to` int(7) unsigned NULL, + `resolved_by` int(7) unsigned NULL, + `resolution_note` text, + `notified` tinyint(1) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + INDEX `idx_status` (`status`), + INDEX `idx_created` (`created_at`), + INDEX `idx_reporter_id_created` (`reporter_id`, `created_at`), + INDEX `idx_reported_user_name` (`reported_user_name`), + INDEX `idx_status_created` (`status`, `created_at`), + FOREIGN KEY (`reporter_id`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE, + FOREIGN KEY (`reported_user_id`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE, + FOREIGN KEY (`assigned_to`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE, + FOREIGN KEY (`resolved_by`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `cockatrice_report_comments` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `report_id` int(11) unsigned NOT NULL, + `author_name` varchar(35) NOT NULL, + `author_id` int(7) unsigned NULL, + `comment_text` text NOT NULL, + `created_at` datetime NOT NULL, + `is_moderator` tinyint(1) NOT NULL DEFAULT 0, + `notified` tinyint(1) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + INDEX `idx_report_id` (`report_id`), + INDEX `idx_notified` (`notified`), + FOREIGN KEY (`report_id`) REFERENCES `cockatrice_reports`(`id`) ON DELETE CASCADE ON UPDATE CASCADE, + FOREIGN KEY (`author_id`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; + CREATE TABLE IF NOT EXISTS `cockatrice_log` ( `log_time` datetime NOT NULL, `sender_id` int(7) unsigned NULL, @@ -300,3 +351,19 @@ CREATE TABLE IF NOT EXISTS `cockatrice_audit` ( PRIMARY KEY (`id`), KEY `user_name` (`name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `cockatrice_card_art_name_rules` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `card_name` varchar(255) NOT NULL, + `card_provider_id` varchar(255) NOT NULL, + `mode` enum('ALLOW','DENY') NOT NULL, + `reason` varchar(255) DEFAULT NULL, + `created_by` int(7) unsigned DEFAULT NULL, + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_provider_card_name` (`card_provider_id`, `card_name`), + KEY `idx_mode` (`mode`), + FOREIGN KEY (`created_by`) REFERENCES `cockatrice_users`(`id`) + ON DELETE SET NULL + ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/servatrice/src/event_loop_watchdog.cpp b/servatrice/src/event_loop_watchdog.cpp new file mode 100644 index 000000000..e50bd4201 --- /dev/null +++ b/servatrice/src/event_loop_watchdog.cpp @@ -0,0 +1,34 @@ +/** + * @file event_loop_watchdog.cpp + * @ingroup Servatrice + */ + +#include "event_loop_watchdog.h" + +#include "servatrice.h" + +#include + +EventLoopWatchdog::EventLoopWatchdog(Servatrice *_servatrice, QString _threadName) + : QObject(nullptr), servatrice(_servatrice), threadName(std::move(_threadName)) +{ +} + +void EventLoopWatchdog::start() +{ + heartbeatTimer = new QTimer(this); + sinceLastTick.start(); + connect(heartbeatTimer, &QTimer::timeout, this, &EventLoopWatchdog::checkHeartbeat); + heartbeatTimer->start(HeartbeatIntervalMs); +} + +void EventLoopWatchdog::checkHeartbeat() +{ + const qint64 elapsedMs = sinceLastTick.restart(); + const qint64 overshootMs = qMax(0, elapsedMs - HeartbeatIntervalMs); + if (overshootMs < servatrice->getMetricsStallWarnMs()) { + return; + } + + servatrice->observeEventLoopStall(threadName, overshootMs); +} diff --git a/servatrice/src/event_loop_watchdog.h b/servatrice/src/event_loop_watchdog.h new file mode 100644 index 000000000..b9061ff97 --- /dev/null +++ b/servatrice/src/event_loop_watchdog.h @@ -0,0 +1,50 @@ +/** + * @file event_loop_watchdog.h + * @ingroup Servatrice + */ + +#ifndef EVENT_LOOP_WATCHDOG_H +#define EVENT_LOOP_WATCHDOG_H + +#include +#include +#include + +class Servatrice; +class QTimer; + +/** + * @brief Detects blocked or overloaded worker event loops. + * + * One instance lives in each socket pool thread. A heartbeat timer tick that + * arrives late means the loop spent that time elsewhere: busy work, a queued + * slot, or a hard wedge. Overshoots past the configured threshold bump + * lock-free counters on the metrics registry and log one warning per stall, + * so a stuck pool thread becomes visible instead of silent lag. + */ +class EventLoopWatchdog : public QObject +{ + Q_OBJECT +public: + /// How often the heartbeat expects to fire. Small enough to catch short stalls. + static constexpr int HeartbeatIntervalMs = 500; + + EventLoopWatchdog(Servatrice *_servatrice, QString _threadName); + + /** + * Starts the heartbeat timer. Must be invoked queued after the instance + * was moved to its target thread so the timer lives there too. + */ + void start(); + +private slots: + void checkHeartbeat(); + +private: + Servatrice *servatrice; + QString threadName; + QElapsedTimer sinceLastTick; + QTimer *heartbeatTimer = nullptr; +}; + +#endif diff --git a/servatrice/src/main.cpp b/servatrice/src/main.cpp index 9e7fe38d9..13bf95a82 100644 --- a/servatrice/src/main.cpp +++ b/servatrice/src/main.cpp @@ -33,6 +33,7 @@ #include #include #include +#include #include RNG_Abstract *rng; @@ -169,7 +170,7 @@ int main(int argc, char *argv[]) signalhandler = new SignalHandler(); - rng = new RNG_SFMT; + rng = new RNG_SFMT(CryptoUtil::randomUInt64()); std::cerr << "Servatrice " << VERSION_STRING << " starting." << std::endl; std::cerr << "-------------------------" << std::endl; diff --git a/servatrice/src/metrics_registry.cpp b/servatrice/src/metrics_registry.cpp new file mode 100644 index 000000000..99ce390f1 --- /dev/null +++ b/servatrice/src/metrics_registry.cpp @@ -0,0 +1,70 @@ +#include "metrics_registry.h" + +#include + +void MetricsRegistry::observeCommand(int typeId, qint64 elapsedMs) +{ + if (typeId < 0 || typeId >= MaxTypes) { + typeId = MaxTypes - 1; // overflow slot keeps misrouted ids visible + } + if (elapsedMs < 0) { + elapsedMs = 0; + } + + TypeStats &stats = slotFor(typeId); + stats.count.fetch_add(1, std::memory_order_relaxed); + stats.totalMs.fetch_add(elapsedMs, std::memory_order_relaxed); + totalCommandsCounter.fetch_add(1, std::memory_order_relaxed); + totalTimeCounter.fetch_add(elapsedMs, std::memory_order_relaxed); +} + +void MetricsRegistry::observeGameStartDurationMs(qint64 elapsedMs) +{ + if (elapsedMs < 0) { + elapsedMs = 0; + } + + gameStartStats.count.fetch_add(1, std::memory_order_relaxed); + gameStartStats.totalMs.fetch_add(elapsedMs, std::memory_order_relaxed); +} + +MetricsRegistry::TypeStats &MetricsRegistry::slotFor(int typeId) +{ + return typeSlots[static_cast(typeId)]; +} + +const MetricsRegistry::TypeStats &MetricsRegistry::slotFor(int typeId) const +{ + return typeSlots[static_cast(typeId)]; +} + +int MetricsRegistry::activeTypeCount() const +{ + int active = 0; + for (int type = 0; type < MaxTypes; ++type) { + if (slotFor(type).count.load(std::memory_order_relaxed) > 0) { + ++active; + } + } + return active; +} + +QList MetricsRegistry::collectActiveStats() const +{ + QList result; + for (int type = 0; type < MaxTypes; ++type) { + const TypeStats &stats = slotFor(type); + const qint64 count = stats.count.load(std::memory_order_relaxed); + if (count == 0) { + continue; + } + result.append({type, count, stats.totalMs.load(std::memory_order_relaxed)}); + } + return result; +} + +MetricsRegistry::GameStartSnapshot MetricsRegistry::getGameStartSnapshot() const +{ + return {gameStartStats.count.load(std::memory_order_relaxed), + gameStartStats.totalMs.load(std::memory_order_relaxed)}; +} \ No newline at end of file diff --git a/servatrice/src/metrics_registry.h b/servatrice/src/metrics_registry.h new file mode 100644 index 000000000..4df1057c2 --- /dev/null +++ b/servatrice/src/metrics_registry.h @@ -0,0 +1,120 @@ +/** + * @file metrics_registry.h + * @ingroup Servatrice + */ + +#ifndef METRICS_REGISTRY_H +#define METRICS_REGISTRY_H + +#include +#include +#include +#include + +/** + * @brief Lock-free accumulation of command processing statistics. + * + * observeCommand() is called once per processed command from whichever socket + * thread handled it. It uses relaxed atomic adds on preallocated storage only, + * so it introduces no locks, allocations, or shared cache-line ping-pong + * beyond the unavoidable counter updates. + * + * Reading happens rarely (metrics scraping), accepts momentary tears between + * related counters, and therefore also needs no synchronization. + * + * Only counts and totals are retained. An earlier Prometheus-style cumulative + * histogram (per-type, time-bucketed) was cut because nothing in the server + * ever wrote it out; it belongs to the future /metrics exporter that needs it. + */ +class MetricsRegistry +{ +public: + /** + * Extension numbers are only unique per command kind, so recorded ids + * combine the kind index with the protobuf extension number. + * + * The stride is only as wide as it needs to be: 1280 is the first round + * number above the largest extension actually in use (ModeratorCommand = + * 1206) and keeps the preallocated TypeStats array small. Bump it if a new + * command exceeds it. + */ + static constexpr int KindStride = 1280; + + static constexpr int NumKinds = 6; + + static constexpr const char *KindNames[NumKinds] = {"session", "room", "game", "moderator", "admin", "developer"}; + + /// Upper bound on distinct command type ids (see typeIdFor). + static constexpr int MaxTypes = NumKinds * KindStride; + + /// Guard against typeIdFor() overflowing into the neighbouring kind's slots. + static_assert(KindStride > 1206, "KindStride must exceed the highest command extension number in use"); + + static int typeIdFor(int kindIndex, int extensionNumber) + { + return kindIndex * KindStride + extensionNumber; + } + + void observeCommand(int typeId, qint64 elapsedMs); + + /** + * Records how long one game start took to bring every player's zones + * online. Kept separate from command timings because it is triggered by + * the server itself and can dwarf any single command when decks are huge. + */ + void observeGameStartDurationMs(qint64 elapsedMs); + + /// Total number of observed commands across all types. + qint64 totalCommands() const + { + return totalCommandsCounter.load(std::memory_order_relaxed); + } + + /// Cumulative processing milliseconds across all types. + qint64 totalTimeMs() const + { + return totalTimeCounter.load(std::memory_order_relaxed); + } + + /// Number of distinct type slots that have seen at least one sample. + int activeTypeCount() const; + + struct ActiveTypeStats + { + int typeId; + qint64 count; + qint64 totalMs; + }; + + /** + * Returns stats for every type slot that has seen at least one sample. + * Callers resolve the numeric type id to a human-readable label via + * typeIdFor()/KindNames as needed. + */ + QList collectActiveStats() const; + + struct GameStartSnapshot + { + qint64 count; + qint64 totalMs; + }; + + GameStartSnapshot getGameStartSnapshot() const; + +private: + struct TypeStats + { + std::atomic count{0}; + std::atomic totalMs{0}; + }; + + TypeStats &slotFor(int typeId); + const TypeStats &slotFor(int typeId) const; + + std::array typeSlots{}; + TypeStats gameStartStats{}; + std::atomic totalCommandsCounter{0}; + std::atomic totalTimeCounter{0}; +}; + +#endif \ No newline at end of file diff --git a/servatrice/src/servatrice.cpp b/servatrice/src/servatrice.cpp index aa50e068a..26352ccd7 100644 --- a/servatrice/src/servatrice.cpp +++ b/servatrice/src/servatrice.cpp @@ -20,6 +20,7 @@ #include "servatrice.h" #include "email_parser.h" +#include "event_loop_watchdog.h" #include "isl_interface.h" #include "main.h" #include "servatrice_connection_pool.h" @@ -38,6 +39,7 @@ #include #include #include +#include #include #include #include @@ -63,6 +65,7 @@ Servatrice_GameServer::Servatrice_GameServer(Servatrice *_server, server->addDatabaseInterface(newThread, newDatabaseInterface); newThread->start(); + server->watchWorkerThread(newThread); QMetaObject::invokeMethod(newDatabaseInterface, "initDatabase", Qt::BlockingQueuedConnection, Q_ARG(QSqlDatabase, _sqlDatabase)); @@ -86,7 +89,6 @@ void Servatrice_GameServer::incomingConnection(qintptr socketDescriptor) Servatrice_ConnectionPool *pool = findLeastUsedConnectionPool(); auto ssi = new TcpServerSocketInterface(server, pool->getDatabaseInterface()); - connect(ssi, SIGNAL(incTxBytes(qint64)), this, SLOT(incTxBytes(qint64))); ssi->moveToThread(pool->thread()); pool->addClient(); connect(ssi, SIGNAL(destroyed()), pool, SLOT(removeClient())); @@ -131,6 +133,7 @@ Servatrice_WebsocketGameServer::Servatrice_WebsocketGameServer(Servatrice *_serv server->addDatabaseInterface(newThread, newDatabaseInterface); newThread->start(); + server->watchWorkerThread(newThread); QMetaObject::invokeMethod(newDatabaseInterface, "initDatabase", Qt::BlockingQueuedConnection, Q_ARG(QSqlDatabase, _sqlDatabase)); @@ -156,7 +159,6 @@ void Servatrice_WebsocketGameServer::onNewConnection() Servatrice_ConnectionPool *pool = findLeastUsedConnectionPool(); auto ssi = new WebsocketServerSocketInterface(server, pool->getDatabaseInterface()); - connect(ssi, SIGNAL(incTxBytes(quint64)), this, SLOT(incTxBytes(quint64))); /* * Due to a Qt limitation, websockets can't be moved to another thread. * This will hopefully change in Qt6 if QtWebSocket will be integrated in QtNetwork @@ -226,6 +228,13 @@ bool Servatrice::initServer() { serverId = getServerID(); + + // METRICS (always active. Slow-command logging and stall watchdogs are + // controlled by their respective thresholds below). Read up front so the + // values are available before any pool thread is started and watchdogged. + metricsSlowCommandMs = settingsCache->value("metrics/slow_command_ms", 500).toInt(); + metricsStallWarnMs = qMax(0, settingsCache->value("metrics/stall_warn_ms", 2000).toInt()); + if (getAuthenticationMethodString() == "sql") { qDebug() << "Authenticating method: sql"; authenticationMethod = AuthenticationSql; @@ -470,9 +479,55 @@ bool Servatrice::initServer() } setRequiredFeatures(getRequiredFeatures()); + return true; } +void Servatrice::observeGameStartDurationMs(qint64 elapsedMs) +{ + metricsRegistry.observeGameStartDurationMs(elapsedMs); +} + +void Servatrice::observeEventLoopStall(const QString &threadName, qint64 overshootMs) +{ + eventLoopStallsTotal.fetch_add(1, std::memory_order_relaxed); + eventLoopLastStallMs.store(overshootMs, std::memory_order_relaxed); + qint64 prevMax = eventLoopMaxStallMs.load(std::memory_order_relaxed); + while (overshootMs > prevMax && + !eventLoopMaxStallMs.compare_exchange_weak(prevMax, overshootMs, std::memory_order_relaxed)) { + // retry until the max is at least as high as the new sample + } + + qWarning() << "Event loop stall in" << threadName << "- heartbeat overshot by" << overshootMs << "ms"; +} + +void Servatrice::watchWorkerThread(QThread *thread) +{ + if (metricsStallWarnMs <= 0) { + return; // watchdogs disabled via metrics/stall_warn_ms = 0 + } + + auto *watchdog = new EventLoopWatchdog(this, thread->objectName()); + connect(thread, &QThread::finished, watchdog, &QObject::deleteLater); + watchdog->moveToThread(thread); + QMetaObject::invokeMethod(watchdog, &EventLoopWatchdog::start, Qt::QueuedConnection); +} + +qint64 Servatrice::getCardsInGamesTotal() const +{ + qint64 total = 0; + QReadLocker roomsLocker(&roomsLock); // locking order: roomsLock before gamesLock/gameMutex + QMapIterator roomIterator(rooms); + while (roomIterator.hasNext()) { + Server_Room *room = roomIterator.next().value(); + QReadLocker gamesLocker(&room->gamesLock); + for (auto *game : room->getGames()) { + total += game->getCardsInGame(); + } + } + return total; +} + void Servatrice::addDatabaseInterface(QThread *thread, Servatrice_DatabaseInterface *databaseInterface) { databaseInterfaces.insert(thread, databaseInterface); @@ -514,6 +569,23 @@ QList Servatrice::getServerList() const return result; } +std::shared_ptr Servatrice::getCachedReportStats() const +{ + QMutexLocker locker(&reportStatsMutex); + if (!reportStatsTimestamp.isValid() || + reportStatsTimestamp.secsTo(QDateTime::currentDateTime()) >= reportStatsCacheTtlSeconds) { + return nullptr; + } + return reportStatsCache; +} + +void Servatrice::cacheReportStats(const Response_ReportStats &stats) +{ + QMutexLocker locker(&reportStatsMutex); + reportStatsCache = std::make_shared(stats); + reportStatsTimestamp = QDateTime::currentDateTime(); +} + int Servatrice::getUsersWithAddress(const QHostAddress &address) const { int result = 0; @@ -673,8 +745,27 @@ void Servatrice::statusUpdate() } } +SessionEvent *Servatrice::makeShutdownEvent() const +{ + Event_ServerShutdown event; + event.set_reason(shutdownReason.toStdString()); + event.set_minutes(static_cast(shutdownMinutes)); + return Server_ProtocolHandler::prepareSessionEvent(event); +} + +SessionEvent *Servatrice::getLoginSessionEvent() const +{ + // Notify newly logged-in users of a pending server shutdown + QMutexLocker locker(&shutdownStateMutex); + if (shutdownTimer && shutdownMinutes > 0) { + return makeShutdownEvent(); + } + return nullptr; +} + void Servatrice::scheduleShutdown(const QString &reason, int minutes) { + shutdownStateMutex.lock(); shutdownReason = reason; shutdownMinutes = minutes; nextShutdownMessageMinutes = shutdownMinutes; @@ -683,6 +774,7 @@ void Servatrice::scheduleShutdown(const QString &reason, int minutes) connect(shutdownTimer, SIGNAL(timeout()), this, SLOT(shutdownTimeout())); shutdownTimer->start(60000); } + shutdownStateMutex.unlock(); shutdownTimeout(); } @@ -702,6 +794,7 @@ void Servatrice::incRxBytes(quint64 num) void Servatrice::shutdownTimeout() { + QMutexLocker locker(&shutdownStateMutex); // Show every time counter cut in half & every minute for last 5 minutes if (shutdownMinutes <= 5 || shutdownMinutes == nextShutdownMessageMinutes) { if (shutdownMinutes == nextShutdownMessageMinutes) { @@ -710,10 +803,7 @@ void Servatrice::shutdownTimeout() SessionEvent *se; if (shutdownMinutes) { - Event_ServerShutdown event; - event.set_reason(shutdownReason.toStdString()); - event.set_minutes(static_cast(shutdownMinutes)); - se = Server_ProtocolHandler::prepareSessionEvent(event); + se = makeShutdownEvent(); } else { Event_ConnectionClosed event; event.set_reason(Event_ConnectionClosed::SERVER_SHUTDOWN); diff --git a/servatrice/src/servatrice.h b/servatrice/src/servatrice.h index 62fb382cb..8d964a52b 100644 --- a/servatrice/src/servatrice.h +++ b/servatrice/src/servatrice.h @@ -20,6 +20,9 @@ #ifndef SERVATRICE_H #define SERVATRICE_H +#include "metrics_registry.h" + +#include #include #include #include @@ -29,6 +32,9 @@ #include #include #include +#include +#include +#include #include #include @@ -158,6 +164,7 @@ private: Servatrice_IslServer *islServer; mutable QMutex loginMessageMutex; QString loginMessage; + mutable QMutex shutdownStateMutex; QString dbPrefix; QMap serverRequiredFeatureList; QString officialWarnings; @@ -166,12 +173,23 @@ private: int uptime; QMutex txBytesMutex, rxBytesMutex; quint64 txBytes, rxBytes; + MetricsRegistry metricsRegistry; + int metricsSlowCommandMs = 500; + int metricsStallWarnMs = 2000; + std::atomic eventLoopStallsTotal{0}; ///< heartbeat overshoots past the warn threshold + std::atomic eventLoopLastStallMs{0}; ///< overshoot of the most recent stall + std::atomic eventLoopMaxStallMs{0}; ///< worst overshoot seen since process start QString shutdownReason; int shutdownMinutes; int nextShutdownMessageMinutes; QTimer *shutdownTimer; + mutable QMutex reportStatsMutex; + QDateTime reportStatsTimestamp; + std::shared_ptr reportStatsCache; + static constexpr int reportStatsCacheTtlSeconds = 60; + mutable QMutex serverListMutex; QList serverList; void updateServerList(); @@ -216,6 +234,8 @@ public: QMutexLocker locker(&loginMessageMutex); return loginMessage; } + SessionEvent *getLoginSessionEvent() const override; + SessionEvent *makeShutdownEvent() const; QString getRequiredFeatures() const override; QString getAuthenticationMethodString() const; QString getDBTypeString() const; @@ -275,11 +295,59 @@ public: void incRxBytes(quint64 num); void addDatabaseInterface(QThread *thread, Servatrice_DatabaseInterface *databaseInterface); + // Metrics (see [metrics] section in servatrice.ini.example) + MetricsRegistry &getMetricsRegistry() + { + return metricsRegistry; + } + /** + * Sums cards across all zones of all running games. Each game takes its + * own gameMutex -- the hot per-game lock every game action contends on -- + * and then iterates every player's zones, so the scrape cost is really + * O(total cards in play) plus one mutex acquisition per live game. Keep + * scrapes infrequent in big multiplayer rooms. + */ + qint64 getCardsInGamesTotal() const; + int getMetricsSlowCommandMs() const + { + return metricsSlowCommandMs; + } + /// Heartbeat overshoot that counts as a stall. A value of 0 disables the watchdogs. + int getMetricsStallWarnMs() const + { + return metricsStallWarnMs; + } + void observeGameStartDurationMs(qint64 elapsedMs) override; + qint64 getEventLoopStallsTotal() const + { + return eventLoopStallsTotal.load(std::memory_order_relaxed); + } + qint64 getEventLoopLastStallMs() const + { + return eventLoopLastStallMs.load(std::memory_order_relaxed); + } + qint64 getEventLoopMaxStallMs() const + { + return eventLoopMaxStallMs.load(std::memory_order_relaxed); + } + /// Records one heartbeat overshoot and logs a single warning for it. + void observeEventLoopStall(const QString &threadName, qint64 overshootMs); + /** + * Installs an EventLoopWatchdog in @p thread. Called once per socket pool + * thread right after it starts. + */ + void watchWorkerThread(QThread *thread); + bool islConnectionExists(int _serverId) const; void addIslInterface(int _serverId, IslInterface *interface); void removeIslInterface(int _serverId); QReadWriteLock islLock; + // The moderation queue statistics are shared between all connected moderators and + // cached briefly to avoid re-running several full-table queries on every refresh. + std::shared_ptr getCachedReportStats() const; + void cacheReportStats(const Response_ReportStats &stats); + QList getServerList() const; }; diff --git a/servatrice/src/servatrice_database_interface.cpp b/servatrice/src/servatrice_database_interface.cpp index 73643825e..af6118646 100644 --- a/servatrice/src/servatrice_database_interface.cpp +++ b/servatrice/src/servatrice_database_interface.cpp @@ -7,11 +7,15 @@ #include #include #include +#include +#include #include #include #include +#include #include #include +#include #include inline Q_LOGGING_CATEGORY(DatabaseInterfaceLog, "database_interface"); @@ -83,9 +87,10 @@ bool Servatrice_DatabaseInterface::openDatabase() << dbversion << "to version" << expectedversion; return false; } else if (dbversion > expectedversion) { - qCCritical(DatabaseInterfaceLog) << poolStr << "Error opening database: the database schema version" - << dbversion << "is too new, you need to update servatrice" - << "(this servatrice actually uses version" << expectedversion << ")"; + qCCritical(DatabaseInterfaceLog) + << poolStr << "Error opening database: the database schema version" << dbversion + << "is too new, you need to update Servatrice" << "(Currently running Servatrice actually uses version" + << expectedversion << ")"; return false; } } else { @@ -95,12 +100,59 @@ bool Servatrice_DatabaseInterface::openDatabase() return false; } + if (sqlDatabase.driverName() != "QMYSQL") { + qCCritical(DatabaseInterfaceLog) + << poolStr + << "Error opening database: connection is not a MySQL/MariaDB database, Servatrice only " + "supports the QMYSQL driver (actual driver:" + << sqlDatabase.driverName() << ")."; + return false; + } + + bool strictModeCheckOk = false; + const bool strictModeEnabled = isStrictModeEnabled(strictModeCheckOk); + if (!strictModeCheckOk) { + qCCritical(DatabaseInterfaceLog) << poolStr + << "Error opening database: unable to determine whether MySQL/MariaDB strict " + "mode is enabled"; + return false; + } + if (strictModeEnabled) { + qCCritical(DatabaseInterfaceLog) << poolStr + << "Error opening database: MySQL/MariaDB strict mode is enabled, which " + "breaks most Servatrice database operations. Please disable strict mode " + "by removing STRICT_TRANS_TABLES and STRICT_ALL_TABLES from sql_mode, " + "for example by adding 'sql_mode=NO_ENGINE_SUBSTITUTION' under [mysqld] " + "in your my.cnf (or my.ini on Windows) and restarting the database " + "server."; + return false; + } + // reset all prepared statements qDeleteAll(preparedStatements); preparedStatements.clear(); return true; } +bool Servatrice_DatabaseInterface::isStrictModeEnabled(bool &ok) const +{ + ok = true; + + QSqlQuery query(sqlDatabase); + if (!query.exec("SELECT @@GLOBAL.sql_mode")) { + ok = false; + return false; + } + + const QStringList modes = query.next() ? query.value(0).toString().split(',') : QStringList(); + for (const QString &mode : modes) { + if (mode.trimmed() == "STRICT_TRANS_TABLES" || mode.trimmed() == "STRICT_ALL_TABLES") { + return true; + } + } + return false; +} + bool Servatrice_DatabaseInterface::checkSql() { if (!sqlDatabase.isValid()) { @@ -337,8 +389,8 @@ AuthenticationResult Servatrice_DatabaseInterface::checkUserPassword(Server_Prot return UserIsBanned; } - QSqlQuery *passwordQuery = - prepareQuery("select password_sha512, active from {prefix}_users where name = :name"); + QSqlQuery *passwordQuery = prepareQuery( + "select password_sha512, active, force_password_change from {prefix}_users where name = :name"); passwordQuery->bindValue(":name", user); if (!execSqlQuery(passwordQuery)) { qCWarning(DatabaseInterfaceLog) << "Login denied: SQL error"; @@ -348,6 +400,7 @@ AuthenticationResult Servatrice_DatabaseInterface::checkUserPassword(Server_Prot if (passwordQuery->next()) { const QString correctPasswordSha512 = passwordQuery->value(0).toString(); const bool userIsActive = passwordQuery->value(1).toBool(); + const bool forceChange = passwordQuery->value(2).toBool(); if (!userIsActive) { qCWarning(DatabaseInterfaceLog) << "Login denied: user not active"; return UserIsInactive; @@ -359,6 +412,10 @@ AuthenticationResult Servatrice_DatabaseInterface::checkUserPassword(Server_Prot hashedPassword = password; } if (correctPasswordSha512 == hashedPassword) { + if (forceChange) { + qCDebug(DatabaseInterfaceLog) << "Login accepted but password change required"; + return PasswordChangeRequired; + } qCDebug(DatabaseInterfaceLog) << "Login accepted: password right"; return PasswordRight; } else { @@ -633,6 +690,10 @@ ServerInfo_User Servatrice_DatabaseInterface::evalUserQueryResult(const QSqlQuer userLevel |= ServerInfo_User::IsJudge; } + if (is_admin & 8) { + userLevel |= ServerInfo_User::IsDeveloper; + } + result.set_user_level(userLevel); const QString country = query->value(3).toString(); @@ -681,6 +742,33 @@ ServerInfo_User Servatrice_DatabaseInterface::evalUserQueryResult(const QSqlQuer if (!clientid.isEmpty()) { result.set_clientid(clientid.toStdString()); } + + const QString cardArtParamsJson = query->value(12).toString(); + if (!cardArtParamsJson.isEmpty()) { + const QJsonDocument doc = QJsonDocument::fromJson(cardArtParamsJson.toUtf8()); + if (doc.isObject()) { + const QJsonObject obj = doc.object(); + auto *cap = result.mutable_card_art_params(); + if (obj.contains("card_name")) { + cap->set_card_name(obj["card_name"].toString().toStdString()); + } + if (obj.contains("card_provider_id")) { + cap->set_card_provider_id(obj["card_provider_id"].toString().toStdString()); + } + if (obj.contains("marginPctL")) { + cap->set_margin_pct_l(obj["marginPctL"].toDouble(0.33)); + } + if (obj.contains("marginPctR")) { + cap->set_margin_pct_r(obj["marginPctR"].toDouble(0.02)); + } + if (obj.contains("verticalOffset")) { + cap->set_vertical_offset(obj["verticalOffset"].toDouble(0.35)); + } + if (obj.contains("zoom")) { + cap->set_zoom(obj["zoom"].toDouble(1.0)); + } + } + } } return result; } @@ -698,7 +786,7 @@ ServerInfo_User Servatrice_DatabaseInterface::getUserData(const QString &name, b QSqlQuery *query = prepareQuery("select id, name, admin, country, privlevel, leftPawnColorOverride, " "rightPawnColorOverride, realname, avatar_bmp, registrationDate, " - "email, clientid from {prefix}_users where " + "email, clientid, card_art_params from {prefix}_users where " "name = :name and active = 1"); query->bindValue(":name", name); if (!execSqlQuery(query)) { @@ -1055,11 +1143,22 @@ bool Servatrice_DatabaseInterface::changeUserPassword(const QString &user, "passwordLastChangedDate = NOW() where name = :name"); passwordQuery->bindValue(":password", passwordSha512); passwordQuery->bindValue(":name", user); - if (execSqlQuery(passwordQuery)) { - return true; + if (!execSqlQuery(passwordQuery)) { + return false; + } + return passwordQuery->numRowsAffected() > 0; +} + +void Servatrice_DatabaseInterface::setForcePasswordChange(const QString &user, bool force) +{ + if (!checkSql()) { + return; } - return false; + QSqlQuery *query = prepareQuery("UPDATE {prefix}_users SET force_password_change = :force WHERE name = :name"); + query->bindValue(":force", force ? 1 : 0); + query->bindValue(":name", user); + execSqlQuery(query); } bool Servatrice_DatabaseInterface::changeUserPassword(const QString &user, @@ -1285,6 +1384,204 @@ QList Servatrice_DatabaseInterface::getUserWarnHistory(const return results; } +QList Servatrice_DatabaseInterface::getUserSessions(const QString &userName, int limit) +{ + QList results; + + if (!checkSql()) { + return results; + } + + QSqlQuery *query = prepareQuery("SELECT user_name, ip_address, clientid, " + "UNIX_TIMESTAMP(start_time), UNIX_TIMESTAMP(end_time), connection_type " + "FROM {prefix}_sessions WHERE user_name = :user_name " + "ORDER BY start_time DESC LIMIT :limit"); + query->bindValue(":user_name", userName); + query->bindValue(":limit", limit); + + if (!execSqlQuery(query)) { + qCWarning(DatabaseInterfaceLog) << "Failed to collect session history information: SQL Error"; + return results; + } + + while (query->next()) { + ServerInfo_UserSession sessionDetails; + sessionDetails.set_user_name(query->value(0).toString().toStdString()); + sessionDetails.set_ip_address(query->value(1).toString().toStdString()); + sessionDetails.set_clientid(query->value(2).toString().toStdString()); + sessionDetails.set_start_time(query->value(3).toLongLong()); + if (!query->value(4).isNull()) { + sessionDetails.set_end_time(query->value(4).toLongLong()); + } + sessionDetails.set_connection_type(query->value(5).toString().toStdString()); + results << sessionDetails; + } + + return results; +} + +QList Servatrice_DatabaseInterface::getUserAlts(const QString &userName) +{ + QList results; + + if (!checkSql()) { + return results; + } + + // Seed account identifiers used to find related accounts + QSqlQuery *seedQuery = prepareQuery("SELECT email, clientid FROM {prefix}_users WHERE name = :user_name"); + seedQuery->bindValue(":user_name", userName); + if (!execSqlQuery(seedQuery) || !seedQuery->next()) { + return results; + } + const QString seedEmail = seedQuery->value(0).toString(); + const QString seedClientId = seedQuery->value(1).toString(); + + QString queryString = "SELECT u.name, u.email, u.clientid, UNIX_TIMESTAMP(u.registrationDate), " + "UNIX_TIMESTAMP(a.last_login), " + "(SELECT COUNT(*) FROM {prefix}_warnings w WHERE w.user_id = u.id), " + "(SELECT COUNT(*) FROM {prefix}_bans b WHERE b.user_name = u.name), " + "u.active " + "FROM {prefix}_users u " + "LEFT JOIN {prefix}_user_analytics a ON a.id = u.id " + "WHERE u.name = :user_name"; + if (!seedEmail.isEmpty()) { + queryString.append(" OR u.email = :seed_email"); + } + if (!seedClientId.isEmpty()) { + queryString.append(" OR u.clientid = :seed_clientid"); + } + queryString.append(" OR u.name IN (SELECT DISTINCT s.user_name FROM {prefix}_sessions s " + "WHERE s.ip_address IN (SELECT DISTINCT s2.ip_address FROM {prefix}_sessions s2 " + "WHERE s2.user_name = :user_name" + " AND s2.start_time >= DATE_SUB(NOW(), INTERVAL 6 MONTH))" + " AND s.start_time >= DATE_SUB(NOW(), INTERVAL 6 MONTH)) " + "ORDER BY u.name LIMIT 200"); + + QSqlQuery *query = prepareQuery(queryString); + query->bindValue(":user_name", userName); + if (!seedEmail.isEmpty()) { + query->bindValue(":seed_email", seedEmail); + } + if (!seedClientId.isEmpty()) { + query->bindValue(":seed_clientid", seedClientId); + } + + if (!execSqlQuery(query)) { + qCWarning(DatabaseInterfaceLog) << "Failed to collect user alt information: SQL Error"; + return results; + } + + while (query->next()) { + ServerInfo_UserAlt altDetails; + altDetails.set_user_name(query->value(0).toString().toStdString()); + altDetails.set_email(query->value(1).toString().toStdString()); + altDetails.set_clientid(query->value(2).toString().toStdString()); + altDetails.set_registration_time(query->value(3).toLongLong()); + if (!query->value(4).isNull()) { + altDetails.set_last_login(query->value(4).toLongLong()); + } + altDetails.set_warn_count(query->value(5).toInt()); + altDetails.set_ban_count(query->value(6).toInt()); + altDetails.set_is_active(query->value(7).toBool()); + results << altDetails; + } + + return results; +} + +QList Servatrice_DatabaseInterface::getModeratorLastLogins() +{ + QList results; + + if (!checkSql()) { + return results; + } + + QSqlQuery *query = prepareQuery("SELECT u.name, u.admin, UNIX_TIMESTAMP(a.last_login) " + "FROM {prefix}_users u " + "LEFT JOIN {prefix}_user_analytics a ON a.id = u.id " + "WHERE (u.admin & 15) <> 0 ORDER BY u.name"); + + if (!execSqlQuery(query)) { + qCWarning(DatabaseInterfaceLog) << "Failed to collect moderator login information: SQL Error"; + return results; + } + + while (query->next()) { + ServerInfo_ModeratorLogin loginDetails; + loginDetails.set_user_name(query->value(0).toString().toStdString()); + + const int isAdmin = query->value(1).toInt(); + int userLevel = ServerInfo_User::IsUser | ServerInfo_User::IsRegistered; + if (isAdmin & 1) { + userLevel |= ServerInfo_User::IsAdmin | ServerInfo_User::IsModerator; + } else if (isAdmin & 2) { + userLevel |= ServerInfo_User::IsModerator; + } + if (isAdmin & 4) { + userLevel |= ServerInfo_User::IsJudge; + } + if (isAdmin & 8) { + userLevel |= ServerInfo_User::IsDeveloper; + } + loginDetails.set_user_level(userLevel); + + if (!query->value(2).isNull()) { + loginDetails.set_last_login(query->value(2).toLongLong()); + } + results << loginDetails; + } + + return results; +} + +Servatrice_DatabaseInterface::UptimeSnapshot Servatrice_DatabaseInterface::getLatestUptimeSnapshot(int serverId) +{ + UptimeSnapshot snapshot; + + if (!checkSql()) { + return snapshot; + } + + QSqlQuery *query = prepareQuery("SELECT users_count, mods_count, games_count, tx_bytes, rx_bytes, uptime, " + "UNIX_TIMESTAMP(timest) FROM {prefix}_uptime " + "WHERE id_server = :id_server ORDER BY timest DESC LIMIT 1"); + query->bindValue(":id_server", serverId); + + if (!execSqlQuery(query)) { + qCWarning(DatabaseInterfaceLog) << "Failed to collect server stats snapshot: SQL Error"; + return snapshot; + } + + if (query->next()) { + snapshot.valid = true; + snapshot.usersCount = query->value(0).toULongLong(); + snapshot.modsCount = query->value(1).toULongLong(); + snapshot.gamesCount = query->value(2).toULongLong(); + snapshot.txBytes = query->value(3).toULongLong(); + snapshot.rxBytes = query->value(4).toULongLong(); + snapshot.uptimeSecs = query->value(5).toULongLong(); + snapshot.timest = query->value(6).toULongLong(); + } + + return snapshot; +} + +bool Servatrice_DatabaseInterface::removeUserAvatar(const QString &userName) +{ + if (!checkSql()) { + return false; + } + + QSqlQuery *query = prepareQuery("UPDATE {prefix}_users SET avatar_bmp = '' WHERE name = :user_name"); + query->bindValue(":user_name", userName); + if (!execSqlQuery(query)) { + return false; + } + return query->numRowsAffected() > 0; +} + QList Servatrice_DatabaseInterface::getMessageLogHistory(const QString &user, const QString &ipaddress, const QString &gamename, diff --git a/servatrice/src/servatrice_database_interface.h b/servatrice/src/servatrice_database_interface.h index 68080404c..f0e369449 100644 --- a/servatrice/src/servatrice_database_interface.h +++ b/servatrice/src/servatrice_database_interface.h @@ -6,11 +6,14 @@ #include #include #include +#include +#include +#include #include #include #include -#define DATABASE_SCHEMA_VERSION 34 +#define DATABASE_SCHEMA_VERSION 36 class Servatrice; @@ -29,6 +32,7 @@ private: bool checkUserIsIpBanned(const QString &ipAddress, QString &banReason, int &banSecondsRemaining); /** Must be called after checkSql and server is known to be in auth mode. */ bool checkUserIsNameBanned(QString const &userName, QString &banReason, int &banSecondsRemaining); + bool isStrictModeEnabled(bool &ok) const; protected: AuthenticationResult checkUserPassword(Server_ProtocolHandler *handler, @@ -119,6 +123,7 @@ public: bool oldPasswordNeedsHash, const QString &newPassword, bool newPasswordNeedsHash) override; + void setForcePasswordChange(const QString &user, bool force) override; QList getUserBanHistory(const QString userName); bool addWarning(const QString userName, const QString adminName, const QString warningReason, const QString clientID); @@ -133,6 +138,25 @@ public: bool &room, int &range, int &maxresults); + QList getUserSessions(const QString &userName, int limit); + QList getUserAlts(const QString &userName); + QList getModeratorLastLogins(); + + // Uptime snapshot as recorded by Servatrice::statusUpdate() into the + // {prefix}_uptime table. valid is false when no snapshot exists yet. + struct UptimeSnapshot + { + bool valid = false; + quint64 usersCount = 0; + quint64 modsCount = 0; + quint64 gamesCount = 0; + quint64 txBytes = 0; + quint64 rxBytes = 0; + quint64 uptimeSecs = 0; + quint64 timest = 0; + }; + UptimeSnapshot getLatestUptimeSnapshot(int serverId); + bool removeUserAvatar(const QString &userName); bool addForgotPassword(const QString &user); bool removeForgotPassword(const QString &user) override; bool doesForgotPasswordExist(const QString &user); diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index bc90a3ef1..aeffd7081 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -30,27 +30,43 @@ #include #include +#include #include +#include +#include #include #include #include #include #include #include +#include #include #include +#include #include #include #include #include #include #include +#include #include #include +#include #include #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include #include #include @@ -59,27 +75,46 @@ #include #include #include +#include #include #include +#include #include #include #include #include #include +#include +#include #include #include +#include #include +#include #include #include +#include +#include +#include +#include +#include +#include +#include +#include #include #include #include #include #include #include +#include #include #include -#include +#include +#include +#include +#include +#include #include #include #include @@ -160,6 +195,77 @@ void AbstractServerSocketInterface::logDebugMessage(const QString &message) logger->logMessage(message, this); } +void AbstractServerSocketInterface::processCommandContainer(const CommandContainer &cont) +{ + QElapsedTimer timer; + timer.start(); + Server_ProtocolHandler::processCommandContainer(cont); + const qint64 elapsedMs = timer.nsecsElapsed() / 1000000; + + // The base dispatch is an if/else-if chain — at most one family is + // actually processed. Recording every family in the container would + // let an unauthenticated client stampforge developer/moderator/admin + // samples by batching them alongside a session command the server + // actually runs. Mirror the base's selection and skip entirely when + // deleted or when no family matched. + if (deleted) { + return; + } + + // When getPbExtension returns -1 (no extension set) and the kind is + // non-zero, typeIdFor wraps into the previous kind's range instead of + // hitting the typeId < 0 guard in observeCommand. Skip such entries. + int kind = -1; + if (cont.game_command_size()) { + kind = 2; + } else if (cont.room_command_size()) { + kind = 1; + } else if (cont.session_command_size()) { + kind = 0; + } else if (cont.moderator_command_size()) { + kind = 3; + } else if (cont.admin_command_size()) { + kind = 4; + } else if (cont.developer_command_size()) { + kind = 5; + } + + if (kind >= 0) { + auto recordDispatched = [&](int familyKind, const auto &cmds) { + for (const auto &cmd : cmds) { + const int ext = getPbExtension(cmd); + if (ext >= 0) { + servatrice->getMetricsRegistry().observeCommand(MetricsRegistry::typeIdFor(familyKind, ext), + elapsedMs); + } + } + }; + + if (kind == 0) { + recordDispatched(kind, cont.session_command()); + } else if (kind == 1) { + recordDispatched(kind, cont.room_command()); + } else if (kind == 2) { + recordDispatched(kind, cont.game_command()); + } else if (kind == 3) { + recordDispatched(kind, cont.moderator_command()); + } else if (kind == 4) { + recordDispatched(kind, cont.admin_command()); + } else { + recordDispatched(kind, cont.developer_command()); + } + } + + const int slowCommandMs = servatrice->getMetricsSlowCommandMs(); + if (slowCommandMs > 0 && elapsedMs >= slowCommandMs) { + const ServerInfo_User *info = getUserInfo(); + const QString user = authState == PasswordRight && info ? QString::fromStdString(info->name()) + : QStringLiteral("unauthenticated"); + qCWarning(AbstractServerSocketInterfaceLog) << "slow command container from" << user << "processed in" + << elapsedMs << "ms (" << cont.ByteSizeLong() << "bytes)"; + } +} + Response::ResponseCode AbstractServerSocketInterface::processExtendedSessionCommand(int cmdType, const SessionCommand &cmd, ResponseContainer &rc) @@ -212,11 +318,21 @@ Response::ResponseCode AbstractServerSocketInterface::processExtendedSessionComm return cmdAccountEdit(cmd.GetExtension(Command_AccountEdit::ext), rc); case SessionCommand::ACCOUNT_IMAGE: return cmdAccountImage(cmd.GetExtension(Command_AccountImage::ext), rc); + case SessionCommand::SET_CARD_ART_PARAMS: + return cmdSetCardArtParams(cmd.GetExtension(Command_SetCardArtParams::ext), rc); case SessionCommand::ACCOUNT_PASSWORD: return cmdAccountPassword(cmd.GetExtension(Command_AccountPassword::ext), rc); case SessionCommand::REQUEST_PASSWORD_SALT: return cmdRequestPasswordSalt(cmd.GetExtension(Command_RequestPasswordSalt::ext), rc); break; + case SessionCommand::REPORT: + return cmdReport(cmd.GetExtension(Command_Report::ext), rc); + case SessionCommand::REPORT_MY_LIST: + return cmdReportMyList(cmd.GetExtension(Command_ReportMyList::ext), rc); + case SessionCommand::REPORT_ADD_COMMENT: + return cmdReportAddComment(cmd.GetExtension(Command_ReportAddComment::ext), rc); + case SessionCommand::REPORT_DETAILS: + return cmdReportDetails(cmd.GetExtension(Command_ReportDetails::ext), rc); default: return Response::RespFunctionNotAllowed; } @@ -237,16 +353,42 @@ Response::ResponseCode AbstractServerSocketInterface::processExtendedModeratorCo return cmdGetWarnHistory(cmd.GetExtension(Command_GetWarnHistory::ext), rc); case ModeratorCommand::WARN_LIST: return cmdGetWarnList(cmd.GetExtension(Command_GetWarnList::ext), rc); + case ModeratorCommand::REPORT_LIST: + return cmdReportList(cmd.GetExtension(Command_ReportList::ext), rc); + case ModeratorCommand::REPORT_ASSIGN: + return cmdReportAssign(cmd.GetExtension(Command_ReportAssign::ext), rc); + case ModeratorCommand::REPORT_RESOLVE: + return cmdReportResolve(cmd.GetExtension(Command_ReportResolve::ext), rc); case ModeratorCommand::VIEWLOG_HISTORY: - return cmdGetLogHistory(cmd.GetExtension(Command_ViewLogHistory::ext), rc); + return cmdGetLogHistory(cmd.GetExtension(Command_ViewLogHistory::ext), rc, true); case ModeratorCommand::GRANT_REPLAY_ACCESS: return cmdGrantReplayAccess(cmd.GetExtension(Command_GrantReplayAccess::ext), rc); + case ModeratorCommand::REPLAY_DOWNLOAD_BY_GAME_ID: + return cmdReplayDownloadByGameId(cmd.GetExtension(Command_ReplayDownloadByGameId::ext), rc); case ModeratorCommand::FORCE_ACTIVATE_USER: return cmdForceActivateUser(cmd.GetExtension(Command_ForceActivateUser::ext), rc); case ModeratorCommand::GET_ADMIN_NOTES: return cmdGetAdminNotes(cmd.GetExtension(Command_GetAdminNotes::ext), rc); case ModeratorCommand::UPDATE_ADMIN_NOTES: return cmdUpdateAdminNotes(cmd.GetExtension(Command_UpdateAdminNotes::ext), rc); + case ModeratorCommand::ADD_CARD_ART_RULE: + return cmdAddCardArtRule(cmd.GetExtension((Command_AddCardArtRule::ext)), rc); + case ModeratorCommand::REMOVE_CARD_ART_RULE: + return cmdRemoveCardArtRule(cmd.GetExtension((Command_RemoveCardArtRule::ext)), rc); + case ModeratorCommand::LIST_CARD_ART_RULES: + return cmdListCardArtRules(cmd.GetExtension((Command_ListCardArtRules::ext)), rc); + case ModeratorCommand::REPORT_USER_INFO: + return cmdReportUserInfo(cmd.GetExtension(Command_ReportUserInfo::ext), rc); + case ModeratorCommand::REPORT_STATS: + return cmdReportStats(cmd.GetExtension(Command_ReportStats::ext), rc); + case ModeratorCommand::GET_USER_SESSIONS: + return cmdGetUserSessions(cmd.GetExtension(Command_GetUserSessions::ext), rc); + case ModeratorCommand::GET_USER_ALTS: + return cmdGetUserAlts(cmd.GetExtension(Command_GetUserAlts::ext), rc); + case ModeratorCommand::GET_MODERATOR_LAST_LOGINS: + return cmdGetModeratorLastLogins(cmd.GetExtension(Command_GetModeratorLastLogins::ext), rc); + case ModeratorCommand::REMOVE_USER_AVATAR: + return cmdRemoveUserAvatar(cmd.GetExtension(Command_RemoveUserAvatar::ext), rc); default: return Response::RespFunctionNotAllowed; } @@ -264,6 +406,28 @@ AbstractServerSocketInterface::processExtendedAdminCommand(int cmdType, const Ad return cmdReloadConfig(cmd.GetExtension(Command_ReloadConfig::ext), rc); case AdminCommand::ADJUST_MOD: return cmdAdjustMod(cmd.GetExtension(Command_AdjustMod::ext), rc); + case AdminCommand::RESET_USER_PASSWORD: + return cmdResetUserPassword(cmd.GetExtension(Command_ResetUserPassword::ext), rc); + default: + return Response::RespFunctionNotAllowed; + } +} + +// DEVELOPER FUNCTIONS. +// Permission is checked by processDeveloperCommandContainer. Only stats-style +// queries live here, never community moderation or server administration. +Response::ResponseCode AbstractServerSocketInterface::processExtendedDeveloperCommand(int cmdType, + const DeveloperCommand &cmd, + ResponseContainer &rc) +{ + switch ((DeveloperCommand::DeveloperCommandType)cmdType) { + case DeveloperCommand::GET_SERVER_STATS: + return cmdGetServerStats(cmd.GetExtension(Command_GetServerStats::ext), rc); + case DeveloperCommand::VIEWLOG_HISTORY: { + // Same query as the moderator log view, carried by the developer + // command family, but narrows out private chats and sender IPs. + return cmdGetLogHistory(cmd.GetExtension(Command_ViewLogHistory::dev_ext), rc, false); + } default: return Response::RespFunctionNotAllowed; } @@ -313,6 +477,10 @@ Response::ResponseCode AbstractServerSocketInterface::cmdAddToList(const Command Event_AddToList event; event.set_list_name(cmd.list()); event.mutable_user_info()->CopyFrom(databaseInterface->getUserData(user)); + // The buddy/ignore list entry is only used to display the user's basic + // profile: never leak the target's email address or client id. + event.mutable_user_info()->clear_email(); + event.mutable_user_info()->clear_clientid(); rc.enqueuePreResponseItem(ServerMessage::SESSION_EVENT, prepareSessionEvent(event)); return Response::RespOk; @@ -880,6 +1048,10 @@ Response::ResponseCode AbstractServerSocketInterface::cmdReplayGetCode(const Com Response::ResponseCode AbstractServerSocketInterface::cmdReplaySubmitCode(const Command_ReplaySubmitCode &cmd, ResponseContainer & /*rc*/) { + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + // code is of the form - QString code = QString::fromStdString(cmd.replay_code()); QStringList split = code.split("-"); @@ -944,12 +1116,13 @@ Response::ResponseCode AbstractServerSocketInterface::cmdReplaySubmitCode(const // MODERATOR FUNCTIONS. // May be called by admins and moderators. Permission is checked by the calling function. Response::ResponseCode AbstractServerSocketInterface::cmdGetLogHistory(const Command_ViewLogHistory &cmd, - ResponseContainer &rc) + ResponseContainer &rc, + bool allowPrivateChat) { QList messageList; QString userName = nameFromStdString(cmd.user_name()); - QString ipAddress = nameFromStdString(cmd.ip_address()); + QString ipAddress = allowPrivateChat ? nameFromStdString(cmd.ip_address()) : QString(); QString gameName = nameFromStdString(cmd.game_name()); QString gameID = nameFromStdString(cmd.game_id()); QString message = textFromStdString(cmd.message()); @@ -964,11 +1137,21 @@ Response::ResponseCode AbstractServerSocketInterface::cmdGetLogHistory(const Com if (nameFromStdString(cmd.log_location(i)).simplified() == "game") { gameType = true; } - if (nameFromStdString(cmd.log_location(i)).simplified() == "chat") { + if (nameFromStdString(cmd.log_location(i)).simplified() == "chat" && allowPrivateChat) { chatType = true; } } + // For callers that must not see private conversations, never leave the + // target-type filter empty: if the request only asked for "chat" (or for + // nothing at all) the query below would carry no target_type restriction + // and would return every row, private messages included. Fall back to the + // game/room diagnostics the caller is allowed to see. + if (!allowPrivateChat && !gameType && !roomType) { + gameType = true; + roomType = true; + } + int dateRange = cmd.date_range(); int maximumResults = cmd.maximum_results(); @@ -978,7 +1161,11 @@ Response::ResponseCode AbstractServerSocketInterface::cmdGetLogHistory(const Com QListIterator messageIterator(sqlInterface->getMessageLogHistory( userName, ipAddress, gameName, gameID, message, chatType, gameType, roomType, dateRange, maximumResults)); while (messageIterator.hasNext()) { - re->add_log_message()->CopyFrom(messageIterator.next()); + ServerInfo_ChatMessage chatMessage = messageIterator.next(); + if (!allowPrivateChat) { + chatMessage.clear_sender_ip(); + } + re->add_log_message()->CopyFrom(chatMessage); } } else { ServerInfo_ChatMessage chatMessage; @@ -1047,9 +1234,10 @@ Response::ResponseCode AbstractServerSocketInterface::cmdGetWarnList(const Comma Response_WarnList *re = new Response_WarnList; QString officialWarnings = settingsCache->value("server/officialwarnings").toString(); - QStringList warningsList = officialWarnings.split(",", Qt::SkipEmptyParts); - for (const QString &warning : warningsList) { - re->add_warning(warning.toStdString()); + const QList categories = parseWarningCategories(officialWarnings); + for (const WarningCategory &category : categories) { + re->add_warning(category.name.toStdString()); + re->add_warning_il(category.startingIl); } re->set_user_name(nameFromStdString(cmd.user_name()).toStdString()); re->set_user_clientid(nameFromStdString(cmd.user_clientid()).toStdString()); @@ -1233,6 +1421,1083 @@ Response::ResponseCode AbstractServerSocketInterface::cmdBanFromServer(const Com return Response::RespOk; } +Response::ResponseCode AbstractServerSocketInterface::cmdReportList(const Command_ReportList &cmd, + ResponseContainer &rc) +{ + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + const bool unresolvedOnly = cmd.unresolved_only(); + const int offset = static_cast(cmd.offset()); + const int limit = qMin(static_cast(cmd.limit()), 1000); + + // Columns: 0=id, 1=reporter_name, 2=reported_user_name, 3=game_id, + // 4=category, 5=description, 6=created_at, 7=status, + // 8=resolution_note, 9=assigned_mod_name, + // 10=room_id, 11=replay_id + QString whereClause; + if (unresolvedOnly) { + whereClause = "WHERE r.status = 'open' OR r.status = 'assigned' "; + } + + // Total count query + QSqlQuery *countQuery = sqlInterface->prepareQuery("SELECT COUNT(*) FROM {prefix}_reports r " + whereClause); + int totalCount = 0; + if (sqlInterface->execSqlQuery(countQuery) && countQuery->next()) { + totalCount = countQuery->value(0).toInt(); + } + + QString queryStr = "SELECT r.id, r.reporter_name, r.reported_user_name, r.game_id, " + "r.category, r.description, r.created_at, r.status, " + "r.resolution_note, u.name AS assigned_mod_name, r.room_id, " + "(SELECT id FROM {prefix}_replays WHERE id_game = r.game_id ORDER BY id DESC LIMIT 1) " + "AS replay_id " + "FROM {prefix}_reports r " + "LEFT JOIN {prefix}_users u ON r.assigned_to = u.id "; + + if (unresolvedOnly) { + queryStr += "WHERE r.status = 'open' OR r.status = 'assigned' "; + } + + queryStr += "ORDER BY r.created_at DESC LIMIT :limit OFFSET :offset"; + + QSqlQuery *query = sqlInterface->prepareQuery(queryStr); + query->bindValue(":limit", limit); + query->bindValue(":offset", offset); + if (!sqlInterface->execSqlQuery(query)) { + return Response::RespInternalError; + } + + Response_ReportList *re = new Response_ReportList; + re->set_total_count(totalCount); + + while (query->next()) { + ServerInfo_Report *info = re->add_reports(); + + info->set_report_id(query->value(0).toInt()); + info->set_reporter_name(query->value(1).toString().toStdString()); + info->set_reported_user_name(query->value(2).toString().toStdString()); + + if (!query->value(3).isNull()) { + info->set_game_id(query->value(3).toInt()); + } + + info->set_category(query->value(4).toString().toStdString()); + info->set_description(query->value(5).toString().toStdString()); + info->set_report_time(query->value(6).toDateTime().toSecsSinceEpoch()); + info->set_status(query->value(7).toString().toStdString()); + + if (!query->value(8).isNull()) { + info->set_resolution_note(query->value(8).toString().toStdString()); + } + + if (!query->value(9).isNull()) { + info->set_assigned_mod_name(query->value(9).toString().toStdString()); + } + + if (!query->value(10).isNull()) { + info->set_room_id(query->value(10).toInt()); + } + + if (!query->value(11).isNull()) { + info->set_replay_id(query->value(11).toInt()); + } + } + + rc.setResponseExtension(re); + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdReportAssign(const Command_ReportAssign &cmd, + ResponseContainer & /*rc*/) +{ + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + int reportId = cmd.report_id(); + int modId = userInfo->id(); + + QSqlQuery *lookupQuery = sqlInterface->prepareQuery("SELECT status FROM {prefix}_reports WHERE id = :id"); + lookupQuery->bindValue(":id", reportId); + + if (!sqlInterface->execSqlQuery(lookupQuery)) { + return Response::RespInternalError; + } + + if (!lookupQuery->next()) { + return Response::RespNameNotFound; + } + + if (lookupQuery->value(0).toString() != "open") { + return Response::RespInvalidData; + } + + QSqlQuery *query = sqlInterface->prepareQuery("UPDATE {prefix}_reports " + "SET status = 'assigned', assigned_to = :mod_id " + "WHERE id = :id AND status = 'open'"); + + query->bindValue(":mod_id", modId); + query->bindValue(":id", reportId); + + if (!sqlInterface->execSqlQuery(query)) { + return Response::RespInternalError; + } + + if (query->numRowsAffected() == 0) { + // The report was taken by another moderator between the lookup and this update. + return Response::RespInvalidData; + } + + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdReportResolve(const Command_ReportResolve &cmd, + ResponseContainer & /*rc*/) +{ + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + int reportId = cmd.report_id(); + QString note = textFromStdString(cmd.resolution_note()); + bool dismissed = cmd.dismissed(); + + QString newStatus = dismissed ? "dismissed" : "resolved"; + + QSqlQuery *query = sqlInterface->prepareQuery("UPDATE {prefix}_reports " + "SET status = :status, resolution_note = :note, " + "resolution_time = NOW(), resolved_by = :mod_id " + "WHERE id = :id AND status IN ('open', 'assigned')"); + + query->bindValue(":status", newStatus); + query->bindValue(":note", note); + query->bindValue(":mod_id", userInfo->id()); + query->bindValue(":id", reportId); + + if (!sqlInterface->execSqlQuery(query)) { + return Response::RespInternalError; + } + + if (query->numRowsAffected() == 0) { + return Response::RespInvalidData; + } + + sqlInterface->addAuditRecord(QString::number(reportId), this->getAddress(), + QString::fromStdString(userInfo->clientid()), + dismissed ? "REPORT_DISMISSED" : "REPORT_RESOLVED", + QString("Report #%1 %2").arg(reportId).arg(newStatus), true); + + // Notifying the reporter is best-effort: the resolve already succeeded. If any of the lookup + // queries below fail, the report is simply left with notified = 0 and the notification is + // delivered later via sendPendingReportNotifications. + QSqlQuery *lookupQuery = + sqlInterface->prepareQuery("SELECT reporter_id, reported_user_name FROM {prefix}_reports WHERE id = :id"); + lookupQuery->bindValue(":id", reportId); + + QString reporterName; + QString reportedUser; + if (sqlInterface->execSqlQuery(lookupQuery) && lookupQuery->next()) { + int reporterId = lookupQuery->value(0).toInt(); + reportedUser = lookupQuery->value(1).toString(); + + QSqlQuery *nameQuery = sqlInterface->prepareQuery("SELECT name FROM {prefix}_users WHERE id = :id"); + nameQuery->bindValue(":id", reporterId); + if (sqlInterface->execSqlQuery(nameQuery) && nameQuery->next()) { + reporterName = nameQuery->value(0).toString(); + } + } + + const QString ownName = QString::fromStdString(userInfo->name()); + if (!reporterName.isEmpty()) { + if (reporterName == ownName) { + // Resolving your own report: no self-notification needed, but mark it as notified + // so it is not delivered on the next login via sendPendingReportNotifications. + QSqlQuery *notifiedQuery = + sqlInterface->prepareQuery("UPDATE {prefix}_reports SET notified = 1 WHERE id = :id"); + notifiedQuery->bindValue(":id", reportId); + sqlInterface->execSqlQuery(notifiedQuery); + } else { + QReadLocker clientsLocker(&servatrice->clientsLock); + AbstractServerSocketInterface *reporter = + static_cast(server->getUsers().value(reporterName)); + if (reporter) { + Event_NotifyUser event; + event.set_type(Event_NotifyUser::REPORT_RESOLVED); + event.set_custom_title(dismissed ? tr("Report Dismissed").toStdString() + : tr("Report Resolved").toStdString()); + + QString content = dismissed ? tr("Your report about %1 has been dismissed.").arg(reportedUser) + : tr("Your report about %1 has been resolved.").arg(reportedUser); + if (!note.isEmpty()) { + content += "\n" + tr("Note: %1").arg(note); + } + event.set_custom_content(content.toStdString()); + + SessionEvent *se = reporter->prepareSessionEvent(event); + reporter->sendProtocolItem(*se); + delete se; + + // Only mark the report as notified if the reporter is still connected; otherwise the + // notification is delivered on their next login via sendPendingReportNotifications. + QSqlQuery *notifiedQuery = + sqlInterface->prepareQuery("UPDATE {prefix}_reports SET notified = 1 WHERE id = :id"); + notifiedQuery->bindValue(":id", reportId); + sqlInterface->execSqlQuery(notifiedQuery); + } + } + } + + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdReportUserInfo(const Command_ReportUserInfo &cmd, + ResponseContainer &rc) +{ + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + QString userName = nameFromStdString(cmd.user_name()); + + QSqlQuery *userQuery = sqlInterface->prepareQuery("SELECT u.admin, u.active, u.registrationDate, u.adminnotes " + "FROM {prefix}_users u " + "WHERE u.name = :name"); + userQuery->bindValue(":name", userName); + + if (!sqlInterface->execSqlQuery(userQuery)) { + return Response::RespInternalError; + } + + if (!userQuery->next()) { + return Response::RespNameNotFound; + } + + Response_ReportUserInfo *re = new Response_ReportUserInfo; + re->set_user_name(cmd.user_name()); + + re->set_is_admin(userQuery->value(0).toBool()); + re->set_is_active(userQuery->value(1).toBool()); + re->set_registration_time(userQuery->value(2).toDateTime().toSecsSinceEpoch()); + + if (!userQuery->value(3).isNull()) { + re->set_admin_notes(userQuery->value(3).toString().toStdString()); + } + + QSqlQuery *reportCountQuery = + sqlInterface->prepareQuery("SELECT COUNT(*) FROM {prefix}_reports WHERE reported_user_name = :name"); + reportCountQuery->bindValue(":name", userName); + if (sqlInterface->execSqlQuery(reportCountQuery) && reportCountQuery->next()) { + re->set_total_reports(reportCountQuery->value(0).toInt()); + } + + QSqlQuery *banCountQuery = sqlInterface->prepareQuery("SELECT COUNT(*) FROM {prefix}_bans WHERE user_name = :name"); + banCountQuery->bindValue(":name", userName); + if (sqlInterface->execSqlQuery(banCountQuery) && banCountQuery->next()) { + re->set_total_bans(banCountQuery->value(0).toInt()); + } + + QSqlQuery *warnCountQuery = + sqlInterface->prepareQuery("SELECT COUNT(*) FROM {prefix}_warnings WHERE user_name = :name"); + warnCountQuery->bindValue(":name", userName); + if (sqlInterface->execSqlQuery(warnCountQuery) && warnCountQuery->next()) { + re->set_total_warns(warnCountQuery->value(0).toInt()); + } + + QSqlQuery *lastLoginQuery = sqlInterface->prepareQuery("SELECT UNIX_TIMESTAMP(a.last_login) " + "FROM {prefix}_user_analytics a " + "JOIN {prefix}_users u ON u.id = a.id " + "WHERE u.name = :name"); + lastLoginQuery->bindValue(":name", userName); + if (sqlInterface->execSqlQuery(lastLoginQuery) && lastLoginQuery->next() && !lastLoginQuery->value(0).isNull()) { + re->set_last_login(lastLoginQuery->value(0).toLongLong()); + } + + QSqlQuery *recentQuery = + sqlInterface->prepareQuery("SELECT r.id, r.reporter_name, r.reported_user_name, r.game_id, " + "r.category, r.description, r.created_at, r.status, " + "r.resolution_note, ru.name AS assigned_mod_name " + "FROM {prefix}_reports r " + "LEFT JOIN {prefix}_users ru ON r.assigned_to = ru.id " + "WHERE r.reported_user_name = :name " + "ORDER BY r.created_at DESC LIMIT 10"); + recentQuery->bindValue(":name", userName); + + if (sqlInterface->execSqlQuery(recentQuery)) { + while (recentQuery->next()) { + ServerInfo_Report *info = re->add_recent_reports(); + info->set_report_id(recentQuery->value(0).toInt()); + info->set_reporter_name(recentQuery->value(1).toString().toStdString()); + info->set_reported_user_name(recentQuery->value(2).toString().toStdString()); + + if (!recentQuery->value(3).isNull()) { + info->set_game_id(recentQuery->value(3).toInt()); + } + + info->set_category(recentQuery->value(4).toString().toStdString()); + info->set_description(recentQuery->value(5).toString().toStdString()); + info->set_report_time(recentQuery->value(6).toDateTime().toSecsSinceEpoch()); + info->set_status(recentQuery->value(7).toString().toStdString()); + + if (!recentQuery->value(8).isNull()) { + info->set_resolution_note(recentQuery->value(8).toString().toStdString()); + } + + if (!recentQuery->value(9).isNull()) { + info->set_assigned_mod_name(recentQuery->value(9).toString().toStdString()); + } + } + } + + rc.setResponseExtension(re); + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdGetServerStats(const Command_GetServerStats & /*cmd */, + ResponseContainer &rc) +{ + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + // Servatrice::statusUpdate() periodically snapshots server health into the + // uptime table. Serve the freshest snapshot for this server. + const auto snapshot = sqlInterface->getLatestUptimeSnapshot(servatrice->getServerID()); + if (!snapshot.valid) { + // No snapshot yet (fresh server, or statusUpdate() has not ticked). + return Response::RespInternalError; + } + + auto *re = new Response_GetServerStats; + re->set_users_count(snapshot.usersCount); + re->set_mods_count(snapshot.modsCount); + re->set_games_count(snapshot.gamesCount); + re->set_tx_bytes(snapshot.txBytes); + re->set_rx_bytes(snapshot.rxBytes); + re->set_uptime_secs(snapshot.uptimeSecs); + re->set_timest(snapshot.timest); + + // Live metrics from the in-process MetricsRegistry (resets on server restart) + re->set_cards_in_games(static_cast(servatrice->getCardsInGamesTotal())); + re->set_eventloop_stalls_total(static_cast(servatrice->getEventLoopStallsTotal())); + re->set_eventloop_last_stall_ms(static_cast(servatrice->getEventLoopLastStallMs())); + re->set_eventloop_max_stall_ms(static_cast(servatrice->getEventLoopMaxStallMs())); + re->set_total_commands(static_cast(servatrice->getMetricsRegistry().totalCommands())); + re->set_total_command_time_ms( + static_cast(servatrice->getMetricsRegistry().totalTimeMs())); + re->set_active_command_types(servatrice->getMetricsRegistry().activeTypeCount()); + + const auto gameStart = servatrice->getMetricsRegistry().getGameStartSnapshot(); + re->set_game_start_count(static_cast(gameStart.count)); + re->set_game_start_total_ms(static_cast(gameStart.totalMs)); + + // Per-command breakdown: resolve protobuf extension names via the descriptor pool + static const char *messageNames[] = {"SessionCommand", "RoomCommand", "GameCommand", + "ModeratorCommand", "AdminCommand", "DeveloperCommand"}; + const auto activeStats = servatrice->getMetricsRegistry().collectActiveStats(); + for (const auto &stat : activeStats) { + const int kind = stat.typeId / MetricsRegistry::KindStride; + const int number = stat.typeId % MetricsRegistry::KindStride; + + QString label; + if (kind >= 0 && kind < MetricsRegistry::NumKinds) { + const google::protobuf::DescriptorPool *pool = google::protobuf::DescriptorPool::generated_pool(); + const google::protobuf::Descriptor *message = pool->FindMessageTypeByName(messageNames[kind]); + const google::protobuf::FieldDescriptor *extension = + message ? pool->FindExtensionByNumber(message, number) : nullptr; + if (extension) { + label = QString::fromLatin1(MetricsRegistry::KindNames[kind]) + QStringLiteral("/") + + QString::fromStdString(std::string(extension->message_type()->name())); + } + } + if (label.isEmpty()) { + label = QString::number(stat.typeId); + } + + CommandStats *cs = re->add_command_stats(); + cs->set_kind_index(static_cast(kind)); + cs->set_extension_number(static_cast(number)); + cs->set_command_name(label.toStdString()); + cs->set_count(static_cast(stat.count)); + cs->set_total_ms(static_cast(stat.totalMs)); + } + + rc.setResponseExtension(re); + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdReportStats(const Command_ReportStats & /*cmd */, + ResponseContainer &rc) +{ + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + if (const auto cached = servatrice->getCachedReportStats()) { + rc.setResponseExtension(new Response_ReportStats(*cached)); + return Response::RespOk; + } + + Response_ReportStats *re = new Response_ReportStats; + + QSqlQuery *overviewQuery = sqlInterface->prepareQuery( + "SELECT COUNT(*) AS total, " + "SUM(status IN ('open', 'assigned')) AS pending, " + "SUM(status = 'assigned') AS assigned, " + "SUM(status IN ('resolved', 'dismissed')) AS resolved, " + "SUM(created_at >= DATE_SUB(NOW(), INTERVAL 1 DAY)) AS last24h, " + "SUM(created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)) AS last7d, " + "SUM(created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)) AS last30d, " + "SUM(created_at >= DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY)) AS this_week " + "FROM {prefix}_reports"); + if (!sqlInterface->execSqlQuery(overviewQuery) || !overviewQuery->next()) { + delete re; + return Response::RespInternalError; + } + + re->set_total_reports(overviewQuery->value(0).toInt()); + re->set_total_pending(overviewQuery->value(1).toInt()); + re->set_total_assigned(overviewQuery->value(2).toInt()); + re->set_total_resolved(overviewQuery->value(3).toInt()); + re->set_reports_last_24h(overviewQuery->value(4).toInt()); + re->set_reports_last_7d(overviewQuery->value(5).toInt()); + re->set_reports_last_30d(overviewQuery->value(6).toInt()); + re->set_reports_this_week(overviewQuery->value(7).toInt()); + + bool allQueriesOk = true; + + QSqlQuery *lastWeekQuery = + sqlInterface->prepareQuery("SELECT COUNT(*) FROM {prefix}_reports WHERE created_at >= DATE_SUB(CURDATE(), " + "INTERVAL WEEKDAY(CURDATE()) + 7 DAY) " + "AND created_at < DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY)"); + if (sqlInterface->execSqlQuery(lastWeekQuery) && lastWeekQuery->next()) { + re->set_reports_last_week(lastWeekQuery->value(0).toInt()); + } else { + allQueriesOk = false; + } + + QSqlQuery *avgResQuery = sqlInterface->prepareQuery( + "SELECT AVG(TIMESTAMPDIFF(HOUR, created_at, resolution_time)) " + "FROM {prefix}_reports WHERE status IN ('resolved','dismissed') AND resolution_time IS NOT NULL"); + if (sqlInterface->execSqlQuery(avgResQuery) && avgResQuery->next()) { + if (!avgResQuery->value(0).isNull()) { + re->set_avg_resolution_hours(avgResQuery->value(0).toDouble()); + } + } else { + allQueriesOk = false; + } + + QSqlQuery *catQuery = sqlInterface->prepareQuery( + "SELECT category, COUNT(*) AS cnt FROM {prefix}_reports GROUP BY category ORDER BY cnt DESC LIMIT 10"); + if (sqlInterface->execSqlQuery(catQuery)) { + while (catQuery->next()) { + ReportCategoryCount *cc = re->add_category_counts(); + cc->set_category(catQuery->value(0).toString().toStdString()); + cc->set_count(catQuery->value(1).toInt()); + } + } else { + allQueriesOk = false; + } + + QSqlQuery *topReportedQuery = + sqlInterface->prepareQuery("SELECT reported_user_name, COUNT(*) AS cnt FROM {prefix}_reports " + "GROUP BY reported_user_name ORDER BY cnt DESC LIMIT 10"); + if (sqlInterface->execSqlQuery(topReportedQuery)) { + while (topReportedQuery->next()) { + ReportTopUser *tu = re->add_top_reported_users(); + tu->set_user_name(topReportedQuery->value(0).toString().toStdString()); + tu->set_count(topReportedQuery->value(1).toInt()); + } + } else { + allQueriesOk = false; + } + + QSqlQuery *topReporterQuery = + sqlInterface->prepareQuery("SELECT reporter_name, COUNT(*) AS cnt FROM {prefix}_reports " + "GROUP BY reporter_name ORDER BY cnt DESC LIMIT 10"); + if (sqlInterface->execSqlQuery(topReporterQuery)) { + while (topReporterQuery->next()) { + ReportTopUser *tu = re->add_top_reporters(); + tu->set_user_name(topReporterQuery->value(0).toString().toStdString()); + tu->set_count(topReporterQuery->value(1).toInt()); + } + } else { + allQueriesOk = false; + } + + if (allQueriesOk) { + servatrice->cacheReportStats(*re); + } + + rc.setResponseExtension(re); + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdGetUserSessions(const Command_GetUserSessions &cmd, + ResponseContainer &rc) +{ + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + const QString userName = nameFromStdString(cmd.user_name()); + if (userName.isEmpty()) { + return Response::RespContextError; + } + + Response_UserSessions *re = new Response_UserSessions; + const int limit = qMin(static_cast(cmd.limit()), 500); + const QList sessions = sqlInterface->getUserSessions(userName, limit); + for (const ServerInfo_UserSession &session : sessions) { + re->add_sessions()->CopyFrom(session); + } + rc.setResponseExtension(re); + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdGetUserAlts(const Command_GetUserAlts &cmd, + ResponseContainer &rc) +{ + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + const QString userName = nameFromStdString(cmd.user_name()); + if (userName.isEmpty()) { + return Response::RespContextError; + } + + Response_UserAlts *re = new Response_UserAlts; + const QList alts = sqlInterface->getUserAlts(userName); + for (const ServerInfo_UserAlt &alt : alts) { + re->add_alts()->CopyFrom(alt); + } + rc.setResponseExtension(re); + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdGetModeratorLastLogins(const Command_GetModeratorLastLogins &, + ResponseContainer &rc) +{ + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + Response_ModeratorLastLogins *re = new Response_ModeratorLastLogins; + const QList logins = sqlInterface->getModeratorLastLogins(); + for (const ServerInfo_ModeratorLogin &login : logins) { + re->add_logins()->CopyFrom(login); + } + rc.setResponseExtension(re); + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdResetUserPassword(const Command_ResetUserPassword &cmd, + ResponseContainer &rc) +{ + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + const QString userName = nameFromStdString(cmd.user_name()).simplified(); + if (userName.isEmpty()) { + return Response::RespContextError; + } + + // Look up the target user's privilege level to prevent escalation. + QSqlQuery *privQuery = sqlInterface->prepareQuery("SELECT admin FROM {prefix}_users WHERE name = :name"); + privQuery->bindValue(":name", userName); + if (!sqlInterface->execSqlQuery(privQuery) || !privQuery->next()) { + return Response::RespNameNotFound; + } + const int targetAdmin = privQuery->value(0).toInt(); + const bool targetIsAdmin = targetAdmin & 1; + const bool targetIsMod = targetAdmin & 2; + + const bool callerIsAdmin = userInfo->user_level() & ServerInfo_User::IsAdmin; + + // A moderator must not reset the password of an admin or another moderator. + if (!callerIsAdmin && (targetIsAdmin || targetIsMod)) { + return Response::RespAccessDenied; + } + + const QString tempPassword = PasswordHasher::generateRandomSalt(); + if (!sqlInterface->changeUserPassword(userName, tempPassword, true)) { + return Response::RespInternalError; + } + sqlInterface->setForcePasswordChange(userName, true); + + sqlInterface->addAuditRecord(userName, this->getAddress(), QString::fromStdString(userInfo->clientid()), + "PASSWORD_RESET", "Admin password reset", true); + + // Notify the affected user if they are currently online. + QReadLocker clientsLocker(&servatrice->clientsLock); + AbstractServerSocketInterface *targetSession = + static_cast(server->getUsers().value(userName)); + if (targetSession) { + Event_NotifyUser event; + event.set_type(Event_NotifyUser::CUSTOM); + event.set_custom_title(tr("Password Reset").toStdString()); + event.set_custom_content(tr("An administrator has reset your password. Please log in with the new " + "password provided to you and change it immediately.") + .toStdString()); + SessionEvent *se = targetSession->prepareSessionEvent(event); + targetSession->sendProtocolItem(*se); + delete se; + } + + Response_ResetUserPassword *re = new Response_ResetUserPassword; + re->set_user_name(userName.toStdString()); + re->set_temporary_password(tempPassword.toStdString()); + rc.setResponseExtension(re); + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdRemoveUserAvatar(const Command_RemoveUserAvatar &cmd, + ResponseContainer &rc) +{ + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + const QString userName = nameFromStdString(cmd.user_name()).simplified(); + if (userName.isEmpty()) { + return Response::RespContextError; + } + + if (!sqlInterface->removeUserAvatar(userName)) { + return Response::RespInternalError; + } + + sqlInterface->addAuditRecord(userName, this->getAddress(), QString::fromStdString(userInfo->clientid()), + "REMOVE_USER_AVATAR", "Moderator removed user avatar", true); + + Response_RemoveUserAvatar *re = new Response_RemoveUserAvatar; + re->set_user_name(userName.toStdString()); + rc.setResponseExtension(re); + return Response::RespOk; +} + +void AbstractServerSocketInterface::sendPendingReportNotifications(ResponseContainer &rc) +{ + if (!sqlInterface->checkSql()) { + return; + } + + QSqlQuery *query = sqlInterface->prepareQuery("SELECT id, reported_user_name, status, resolution_note " + "FROM {prefix}_reports " + "WHERE reporter_id = :reporter_id AND notified = 0 " + "AND status IN ('resolved', 'dismissed')"); + query->bindValue(":reporter_id", userInfo->id()); + + if (!sqlInterface->execSqlQuery(query)) { + return; + } + + while (query->next()) { + const int reportId = query->value(0).toInt(); + const QString reportedUser = query->value(1).toString(); + const bool dismissed = query->value(2).toString() == "dismissed"; + const QString note = query->value(3).toString(); + + Event_NotifyUser event; + event.set_type(Event_NotifyUser::REPORT_RESOLVED); + event.set_custom_title(dismissed ? tr("Report Dismissed").toStdString() : tr("Report Resolved").toStdString()); + + QString content = dismissed ? tr("Your report about %1 has been dismissed.").arg(reportedUser) + : tr("Your report about %1 has been resolved.").arg(reportedUser); + if (!note.isEmpty()) { + content += "\n" + tr("Note: %1").arg(note); + } + event.set_custom_content(content.toStdString()); + + rc.enqueuePreResponseItem(ServerMessage::SESSION_EVENT, prepareSessionEvent(event)); + + QSqlQuery *notifiedQuery = + sqlInterface->prepareQuery("UPDATE {prefix}_reports SET notified = 1 WHERE id = :id"); + notifiedQuery->bindValue(":id", reportId); + sqlInterface->execSqlQuery(notifiedQuery); + } + + QSqlQuery *commentQuery = sqlInterface->prepareQuery("SELECT c.id, c.report_id, c.author_name, c.comment_text " + "FROM {prefix}_report_comments c " + "JOIN {prefix}_reports r ON r.id = c.report_id " + "WHERE c.notified = 0 " + "AND c.author_id != :user_id " + "AND ((c.is_moderator = 1 AND r.reporter_id = :user_id) " + "OR (c.is_moderator = 0 AND r.assigned_to = :user_id) " + "OR (c.is_moderator = 1 AND r.assigned_to = :user_id)) " + "ORDER BY c.created_at ASC"); + commentQuery->bindValue(":user_id", userInfo->id()); + + if (!sqlInterface->execSqlQuery(commentQuery)) { + return; + } + + while (commentQuery->next()) { + const qlonglong commentId = commentQuery->value(0).toLongLong(); + const int reportId = commentQuery->value(1).toInt(); + const QString authorName = commentQuery->value(2).toString(); + const QString commentText = commentQuery->value(3).toString(); + + Event_NotifyUser event; + event.set_type(Event_NotifyUser::REPORT_COMMENT); + event.set_custom_title(tr("New Comment on Report #%1").arg(reportId).toStdString()); + event.set_custom_content(tr("%1 commented:\n%2").arg(authorName, commentText).toStdString()); + + rc.enqueuePreResponseItem(ServerMessage::SESSION_EVENT, prepareSessionEvent(event)); + + QSqlQuery *notifiedQuery = + sqlInterface->prepareQuery("UPDATE {prefix}_report_comments SET notified = 1 WHERE id = :id"); + notifiedQuery->bindValue(":id", commentId); + sqlInterface->execSqlQuery(notifiedQuery); + } +} + +void AbstractServerSocketInterface::onLogin(ResponseContainer &rc) +{ + sendPendingReportNotifications(rc); +} + +Response::ResponseCode AbstractServerSocketInterface::cmdReportMyList(const Command_ReportMyList & /*cmd */, + ResponseContainer &rc) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + sendPendingReportNotifications(rc); + + QString queryStr = "SELECT r.id, r.reporter_name, r.reported_user_name, r.game_id, " + "r.category, r.description, r.created_at, r.status, " + "r.resolution_note, u.name AS assigned_mod_name, r.room_id, " + "(SELECT id FROM {prefix}_replays WHERE id_game = r.game_id ORDER BY id DESC LIMIT 1) " + "AS replay_id " + "FROM {prefix}_reports r " + "LEFT JOIN {prefix}_users u ON r.assigned_to = u.id " + "WHERE r.reporter_id = :reporter_id " + "ORDER BY r.created_at DESC LIMIT 200"; + + QSqlQuery *query = sqlInterface->prepareQuery(queryStr); + query->bindValue(":reporter_id", userInfo->id()); + + if (!sqlInterface->execSqlQuery(query)) { + return Response::RespInternalError; + } + + Response_ReportMyList *re = new Response_ReportMyList; + + while (query->next()) { + ServerInfo_Report *info = re->add_reports(); + + info->set_report_id(query->value(0).toInt()); + info->set_reporter_name(query->value(1).toString().toStdString()); + info->set_reported_user_name(query->value(2).toString().toStdString()); + + if (!query->value(3).isNull()) { + info->set_game_id(query->value(3).toInt()); + } + + info->set_category(query->value(4).toString().toStdString()); + info->set_description(query->value(5).toString().toStdString()); + info->set_report_time(query->value(6).toDateTime().toSecsSinceEpoch()); + info->set_status(query->value(7).toString().toStdString()); + + if (!query->value(8).isNull()) { + info->set_resolution_note(query->value(8).toString().toStdString()); + } + + if (!query->value(9).isNull()) { + info->set_assigned_mod_name(query->value(9).toString().toStdString()); + } + + if (!query->value(10).isNull()) { + info->set_room_id(query->value(10).toInt()); + } + + if (!query->value(11).isNull()) { + info->set_replay_id(query->value(11).toInt()); + } + } + + rc.setResponseExtension(re); + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdReportDetails(const Command_ReportDetails &cmd, + ResponseContainer &rc) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + const int reportId = cmd.report_id(); + + // Columns: 0=id, 1=reporter_id, 2=reporter_name, 3=reported_user_name, 4=game_id, + // 5=category, 6=description, 7=created_at, 8=status, + // 9=resolution_note, 10=assigned_mod_name, 11=chat_log, + // 12=room_id, 13=resolution_time, 14=replay_id + QString queryStr = "SELECT r.id, r.reporter_id, r.reporter_name, r.reported_user_name, r.game_id, " + "r.category, r.description, r.created_at, r.status, " + "r.resolution_note, u.name AS assigned_mod_name, r.chat_log, r.room_id, " + "r.resolution_time, " + "(SELECT id FROM {prefix}_replays WHERE id_game = r.game_id ORDER BY id DESC LIMIT 1) " + "AS replay_id " + "FROM {prefix}_reports r " + "LEFT JOIN {prefix}_users u ON r.assigned_to = u.id " + "WHERE r.id = :id"; + QSqlQuery *query = sqlInterface->prepareQuery(queryStr); + query->bindValue(":id", reportId); + + if (!sqlInterface->execSqlQuery(query)) { + return Response::RespInternalError; + } + + if (!query->next()) { + return Response::RespNameNotFound; + } + + const bool isMod = userInfo->user_level() & ServerInfo_User::IsModerator; + const int reporterId = query->value(1).toInt(); + if (reporterId != userInfo->id() && !isMod) { + return Response::RespAccessDenied; + } + + ServerInfo_Report *info = new ServerInfo_Report; + info->set_report_id(query->value(0).toInt()); + info->set_reporter_name(query->value(2).toString().toStdString()); + info->set_reported_user_name(query->value(3).toString().toStdString()); + + if (!query->value(4).isNull()) { + info->set_game_id(query->value(4).toInt()); + } + + info->set_category(query->value(5).toString().toStdString()); + info->set_description(query->value(6).toString().toStdString()); + info->set_report_time(query->value(7).toDateTime().toSecsSinceEpoch()); + info->set_status(query->value(8).toString().toStdString()); + + if (!query->value(9).isNull()) { + info->set_resolution_note(query->value(9).toString().toStdString()); + } + + if (!query->value(10).isNull()) { + info->set_assigned_mod_name(query->value(10).toString().toStdString()); + } + + if (!query->value(11).isNull()) { + info->set_chat_log(query->value(11).toString().toStdString()); + } + + if (!query->value(12).isNull()) { + info->set_room_id(query->value(12).toInt()); + } + + if (!query->value(13).isNull()) { + info->set_resolution_time(query->value(13).toDateTime().toSecsSinceEpoch()); + } + + if (!query->value(14).isNull()) { + info->set_replay_id(query->value(14).toInt()); + } + + QSqlQuery *commentQuery = + sqlInterface->prepareQuery("SELECT c.author_name, c.comment_text, c.created_at, c.is_moderator " + "FROM {prefix}_report_comments c " + "WHERE c.report_id = :report_id ORDER BY c.created_at ASC"); + commentQuery->bindValue(":report_id", reportId); + + if (sqlInterface->execSqlQuery(commentQuery)) { + while (commentQuery->next()) { + ServerInfo_ReportComment *comment = info->add_comments(); + comment->set_author_name(commentQuery->value(0).toString().toStdString()); + comment->set_comment_text(commentQuery->value(1).toString().toStdString()); + comment->set_comment_time(commentQuery->value(2).toDateTime().toSecsSinceEpoch()); + comment->set_is_moderator(commentQuery->value(3).toBool()); + } + } + + Response_ReportDetails *re = new Response_ReportDetails; + re->set_allocated_report(info); + rc.setResponseExtension(re); + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdReportAddComment(const Command_ReportAddComment &cmd, + ResponseContainer & /*rc*/) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + int reportId = cmd.report_id(); + QString commentText = textFromStdString(cmd.comment()).trimmed(); + + if (commentText.isEmpty()) { + return Response::RespInvalidData; + } + + const int maxCommentsPerHour = settingsCache->value("reporting/max_comments_per_hour", 30).toInt(); + if (maxCommentsPerHour > 0) { + QSqlQuery *countQuery = + sqlInterface->prepareQuery("SELECT COUNT(*) FROM {prefix}_report_comments WHERE author_id = :id " + "AND created_at >= DATE_SUB(NOW(), INTERVAL 1 HOUR)"); + countQuery->bindValue(":id", userInfo->id()); + if (sqlInterface->execSqlQuery(countQuery) && countQuery->next() && + countQuery->value(0).toInt() >= maxCommentsPerHour) { + return Response::RespTooManyRequests; + } + } + + QSqlQuery *checkQuery = sqlInterface->prepareQuery( + "SELECT reporter_id, reporter_name, assigned_to, status FROM {prefix}_reports WHERE id = :id"); + checkQuery->bindValue(":id", reportId); + + if (!sqlInterface->execSqlQuery(checkQuery)) { + return Response::RespInternalError; + } + + if (!checkQuery->next()) { + return Response::RespNameNotFound; + } + + int reporterId = checkQuery->value(0).toInt(); + QString reporterName = checkQuery->value(1).toString(); + int assignedToId = checkQuery->value(2).toInt(); + + bool isMod = userInfo->user_level() & ServerInfo_User::IsModerator; + + // Only the reporter (by id) or a moderator may comment on a report. + if (reporterId != userInfo->id() && !isMod) { + return Response::RespAccessDenied; + } + + QString reportStatus = checkQuery->value(3).toString(); + if (reportStatus == "resolved" || reportStatus == "dismissed") { + return Response::RespInvalidData; + } + + QSqlQuery *insertQuery = sqlInterface->prepareQuery( + "INSERT INTO {prefix}_report_comments (report_id, author_name, author_id, comment_text, created_at, " + "is_moderator) " + "VALUES (:report_id, :author_name, :author_id, :comment_text, NOW(), :is_moderator)"); + insertQuery->bindValue(":report_id", reportId); + insertQuery->bindValue(":author_name", QString::fromStdString(userInfo->name())); + insertQuery->bindValue(":author_id", userInfo->id()); + insertQuery->bindValue(":comment_text", commentText); + insertQuery->bindValue(":is_moderator", isMod); + + if (!sqlInterface->execSqlQuery(insertQuery)) { + return Response::RespInternalError; + } + + const qlonglong commentId = insertQuery->lastInsertId().toLongLong(); + + QStringList recipients; + if (isMod) { + // A moderator comment reaches both the reporter and (if assigned) the assigned moderator. + recipients.append(reporterName); + if (assignedToId > 0) { + QSqlQuery *modNameQuery = sqlInterface->prepareQuery("SELECT name FROM {prefix}_users WHERE id = :id"); + modNameQuery->bindValue(":id", assignedToId); + if (sqlInterface->execSqlQuery(modNameQuery) && modNameQuery->next()) { + recipients.append(modNameQuery->value(0).toString()); + } + } + } else if (assignedToId > 0) { + QSqlQuery *modNameQuery = sqlInterface->prepareQuery("SELECT name FROM {prefix}_users WHERE id = :id"); + modNameQuery->bindValue(":id", assignedToId); + if (sqlInterface->execSqlQuery(modNameQuery) && modNameQuery->next()) { + recipients.append(modNameQuery->value(0).toString()); + } + } + + const QString ownName = QString::fromStdString(userInfo->name()); + bool allNotified = !recipients.isEmpty(); + QReadLocker clientsLocker(&servatrice->clientsLock); + for (const QString ¬ifyName : recipients) { + if (notifyName == ownName) { + continue; + } + + AbstractServerSocketInterface *notify = + static_cast(server->getUsers().value(notifyName)); + if (!notify) { + // The recipient is offline; leave `notified` = 0 so the notification is + // delivered on their next login via sendPendingReportNotifications. + allNotified = false; + continue; + } + + Event_NotifyUser event; + event.set_type(Event_NotifyUser::REPORT_COMMENT); + event.set_custom_title(tr("New Comment on Report #%1").arg(reportId).toStdString()); + event.set_custom_content( + tr("%1 commented:\n%2").arg(QString::fromStdString(userInfo->name()), commentText).toStdString()); + + SessionEvent *se = notify->prepareSessionEvent(event); + notify->sendProtocolItem(*se); + delete se; + } + + if (allNotified) { + QSqlQuery *notifiedQuery = + sqlInterface->prepareQuery("UPDATE {prefix}_report_comments SET notified = 1 WHERE id = :id"); + notifiedQuery->bindValue(":id", commentId); + sqlInterface->execSqlQuery(notifiedQuery); + } + + return Response::RespOk; +} + +Response::ResponseCode +AbstractServerSocketInterface::cmdReplayDownloadByGameId(const Command_ReplayDownloadByGameId &cmd, + ResponseContainer &rc) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + QSqlQuery *query = sqlInterface->prepareQuery("SELECT r.id, r.replay FROM {prefix}_replays r " + "WHERE r.id_game = :game_id ORDER BY r.id DESC LIMIT 1"); + query->bindValue(":game_id", cmd.game_id()); + + if (!sqlInterface->execSqlQuery(query)) { + return Response::RespInternalError; + } + + if (!query->next()) { + return Response::RespNameNotFound; + } + + int replayId = query->value(0).toInt(); + QByteArray data = query->value(1).toByteArray(); + + Response_ReplayDownloadByGameId *re = new Response_ReplayDownloadByGameId; + re->set_replay_data(data.data(), data.size()); + re->set_replay_id(replayId); + rc.setResponseExtension(re); + + return Response::RespOk; +} + Response::ResponseCode AbstractServerSocketInterface::cmdRegisterAccount(const Command_Register &cmd, ResponseContainer &rc) { @@ -1565,6 +2830,173 @@ Response::ResponseCode AbstractServerSocketInterface::cmdAccountImage(const Comm return Response::RespOk; } +bool AbstractServerSocketInterface::isCardNameAllowed(const QString &cardName, const QString &cardProviderId) +{ + QSqlQuery *q = sqlInterface->prepareQuery( + "SELECT mode FROM {prefix}_card_art_name_rules WHERE card_name = :name AND card_provider_id = :provider"); + + q->bindValue(":name", cardName); + q->bindValue(":provider", cardProviderId); + + if (!sqlInterface->execSqlQuery(q)) { + qWarning() << "Card art rule lookup failed; failing open for" << cardName; + return true; + } + + if (!q->next()) { + return true; // default allow + } + + return q->value(0).toString() != "DENY"; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdSetCardArtParams(const Command_SetCardArtParams &cmd, + ResponseContainer & /* rc */) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + const QString cardName = QString::fromStdString(cmd.card_name()); + const QString cardProviderId = QString::fromStdString(cmd.card_provider_id()); + + if (cardName.length() > MAX_NAME_LENGTH || cardProviderId.length() > MAX_NAME_LENGTH) { + return Response::RespInvalidData; + } + + if (cardName.isEmpty()) { + // Removal path + QSqlQuery *q = sqlInterface->prepareQuery("UPDATE {prefix}_users SET card_art_params = NULL WHERE id = :id"); + q->bindValue(":id", userInfo->id()); + if (!sqlInterface->execSqlQuery(q)) { + return Response::RespInternalError; + } + userInfo->clear_card_art_params(); + server->broadcastUserInfoUpdate(this); + return Response::RespOk; + } + + if (!isCardNameAllowed(cardName, cardProviderId)) { + return Response::RespFunctionNotAllowed; + } + + // Clamp everything to sane ranges server-side so a malicious client + // can't store garbage that breaks other clients' rendering. + const double marginPctL = qBound(0.0, cmd.margin_pct_l(), 0.95); + const double marginPctR = qBound(0.0, cmd.margin_pct_r(), 0.95); + const double verticalOffset = qBound(0.0, cmd.vertical_offset(), 1.0); + const double zoom = qBound(0.1, cmd.zoom(), 4.0); + + QJsonObject obj; + obj["card_name"] = cardName; + obj["card_provider_id"] = cardProviderId; + obj["marginPctL"] = marginPctL; + obj["marginPctR"] = marginPctR; + obj["verticalOffset"] = verticalOffset; + obj["zoom"] = zoom; + const QString json = QString::fromUtf8(QJsonDocument(obj).toJson(QJsonDocument::Compact)); + + QSqlQuery *query = sqlInterface->prepareQuery("update {prefix}_users set card_art_params=:params where id=:id"); + query->bindValue(":params", json); + query->bindValue(":id", userInfo->id()); + if (!sqlInterface->execSqlQuery(query)) { + return Response::RespInternalError; + } + + // Keep the in-memory userInfo in sync + auto *cap = userInfo->mutable_card_art_params(); + cap->set_card_name(cmd.card_name()); + cap->set_card_provider_id(cmd.card_provider_id()); + cap->set_margin_pct_l(marginPctL); + cap->set_margin_pct_r(marginPctR); + cap->set_vertical_offset(verticalOffset); + cap->set_zoom(zoom); + + const QString name = QString::fromStdString(userInfo->name()); + server->broadcastUserInfoUpdate(this); + + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdAddCardArtRule(const Command_AddCardArtRule &cmd, + ResponseContainer &) +{ + const QString cardName = QString::fromStdString(cmd.card_name()); + const QString cardProviderId = QString::fromStdString(cmd.card_provider_id()); + const QString mode = QString::fromStdString(cmd.mode()); + + if (mode != "ALLOW" && mode != "DENY") { + return Response::RespInvalidData; + } + if (cardName.isEmpty() || cardName.length() > MAX_NAME_LENGTH || cardProviderId.length() > MAX_NAME_LENGTH) { + return Response::RespInvalidData; + } + + QSqlQuery *q = sqlInterface->prepareQuery("INSERT INTO {prefix}_card_art_name_rules " + "(card_name, card_provider_id, mode, reason, created_by) " + "VALUES (:name, :provider, :mode, :reason, :uid) " + "ON DUPLICATE KEY UPDATE mode=:mode2, reason=:reason2"); + + q->bindValue(":name", cardName); + q->bindValue(":provider", cardProviderId); + q->bindValue(":mode", mode); + q->bindValue(":mode2", mode); + q->bindValue(":reason", QString::fromStdString(cmd.reason())); + q->bindValue(":reason2", QString::fromStdString(cmd.reason())); + q->bindValue(":uid", userInfo->id()); + + if (!sqlInterface->execSqlQuery(q)) { + return Response::RespInternalError; + } + + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdRemoveCardArtRule(const Command_RemoveCardArtRule &cmd, + ResponseContainer &) +{ + auto cardName = QString::fromStdString(cmd.card_name()); + auto cardProviderId = QString::fromStdString(cmd.card_provider_id()); + if (cardName.length() > MAX_NAME_LENGTH || cardProviderId.length() > MAX_NAME_LENGTH) { + return Response::RespInvalidData; + } + QSqlQuery *q = sqlInterface->prepareQuery( + "DELETE FROM {prefix}_card_art_name_rules WHERE card_name=:name AND card_provider_id=:provider"); + + q->bindValue(":name", cardName); + q->bindValue(":provider", cardProviderId); + + if (!sqlInterface->execSqlQuery(q)) { + return Response::RespInternalError; + } + + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdListCardArtRules(const Command_ListCardArtRules &, + ResponseContainer &rc) +{ + QSqlQuery *q = sqlInterface->prepareQuery( + "SELECT card_name, card_provider_id, mode, reason FROM {prefix}_card_art_name_rules"); + + if (!sqlInterface->execSqlQuery(q)) { + return Response::RespInternalError; + } + + auto *re = new Response_ListCardArtRules; + + while (q->next()) { + auto *entry = re->add_entries(); + entry->set_card_name(q->value(0).toString().toStdString()); + entry->set_card_provider_id(q->value(1).toString().toStdString()); + entry->set_mode(q->value(2).toString().toStdString()); + entry->set_reason(q->value(3).toString().toStdString()); + } + + rc.setResponseExtension(re); + return Response::RespOk; +} + Response::ResponseCode AbstractServerSocketInterface::cmdAccountPassword(const Command_AccountPassword &cmd, ResponseContainer & /* rc */) { @@ -1598,6 +3030,8 @@ Response::ResponseCode AbstractServerSocketInterface::cmdAccountPassword(const C return Response::RespWrongPassword; } + databaseInterface->setForcePasswordChange(userName, false); + return Response::RespOk; } @@ -1734,6 +3168,7 @@ Response::ResponseCode AbstractServerSocketInterface::cmdForgotPasswordReset(con "PASSWORD_RESET", "", true); } + sqlInterface->setForcePasswordChange(nameFromStdString(cmd.user_name()), false); sqlInterface->removeForgotPassword(nameFromStdString(cmd.user_name())); return Response::RespOk; } @@ -1802,6 +3237,100 @@ Response::ResponseCode AbstractServerSocketInterface::cmdRequestPasswordSalt(con return Response::RespOk; } +Response::ResponseCode AbstractServerSocketInterface::cmdReport(const Command_Report &cmd, ResponseContainer & /*rc*/) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + if (!cmd.has_reported_user() || !cmd.has_category() || !cmd.has_description()) { + return Response::RespInvalidData; + } + + QString reporterName = QString::fromStdString(userInfo->name()); + QString reportedUser = nameFromStdString(cmd.reported_user()); + QString category = nameFromStdString(cmd.category()); + QString description = textFromStdString(cmd.description()); + QString chatLog = textTailFromStdString(cmd.chat_log()); + + if (reportedUser.isEmpty() || category.isEmpty() || description.isEmpty()) { + return Response::RespInvalidData; + } + + static const QStringList validCategories = {"cheating", "bug_abuse", "verbal_abuse", "other"}; + if (!validCategories.contains(category.toLower())) { + return Response::RespInvalidData; + } + + const int maxReportsPerDay = settingsCache->value("reporting/max_reports_per_day", 10).toInt(); + if (maxReportsPerDay > 0) { + QSqlQuery *countQuery = + sqlInterface->prepareQuery("SELECT COUNT(*) FROM {prefix}_reports WHERE reporter_id = :id " + "AND created_at >= DATE_SUB(NOW(), INTERVAL 1 DAY)"); + countQuery->bindValue(":id", userInfo->id()); + if (sqlInterface->execSqlQuery(countQuery) && countQuery->next() && + countQuery->value(0).toInt() >= maxReportsPerDay) { + return Response::RespTooManyRequests; + } + } + + int reportedUserId = -1; + QSqlQuery *lookupQuery = sqlInterface->prepareQuery("SELECT id FROM {prefix}_users WHERE name = :name"); + lookupQuery->bindValue(":name", reportedUser); + if (sqlInterface->execSqlQuery(lookupQuery) && lookupQuery->next()) { + reportedUserId = lookupQuery->value(0).toInt(); + } + + if (reportedUserId == -1) { + return Response::RespNameNotFound; + } + + int roomId = 0; + const int gameId = cmd.game_id(); + if (gameId > 0) { + QReadLocker roomsLocker(&servatrice->roomsLock); + const QMap &rooms = servatrice->getRooms(); + for (auto it = rooms.constBegin(); it != rooms.constEnd(); ++it) { + QReadLocker gamesLocker(&it.value()->gamesLock); + if (it.value()->getGames().contains(gameId)) { + roomId = it.key(); + break; + } + } + } + + QSqlQuery *query = + sqlInterface->prepareQuery("insert into {prefix}_reports " + "(reporter_id, reporter_name, reported_user_id, reported_user_name, " + "game_id, room_id, category, description, chat_log, created_at, status) " + "values " + "(:reporter_id, :reporter_name, :reported_user_id, :reported_user_name, " + ":game_id, :room_id, :category, :description, :chat_log, NOW(), 'open')"); + + query->bindValue(":reporter_id", userInfo->id()); + query->bindValue(":reporter_name", reporterName); + query->bindValue(":reported_user_id", reportedUserId); + query->bindValue(":reported_user_name", reportedUser); + + query->bindValue(":game_id", gameId > 0 ? gameId : QVariant()); + + query->bindValue(":room_id", roomId > 0 ? roomId : QVariant()); + + query->bindValue(":category", category); + query->bindValue(":description", description); + query->bindValue(":chat_log", chatLog.isEmpty() ? QVariant() : chatLog); + + if (!sqlInterface->execSqlQuery(query)) { + return Response::RespInternalError; + } + + return Response::RespOk; +} + // ADMIN FUNCTIONS. // Permission is checked by the calling function. @@ -1870,7 +3399,7 @@ bool AbstractServerSocketInterface::removeAdminFlagFromUser(const QString &userN if (user) { Event_ConnectionClosed event; event.set_reason(Event_ConnectionClosed::DEMOTED); - event.set_reason_str("Your moderator and/or judge status has been revoked."); + event.set_reason_str("Your moderator, judge, and/or developer status has been revoked."); event.set_end_time(QDateTime::currentDateTime().toSecsSinceEpoch()); SessionEvent *se = user->prepareSessionEvent(event); @@ -1912,6 +3441,18 @@ Response::ResponseCode AbstractServerSocketInterface::cmdAdjustMod(const Command } } + if (cmd.has_should_be_developer()) { + if (cmd.should_be_developer()) { + if (!addAdminFlagToUser(userName, 8)) { + return Response::RespInternalError; + } + } else { + if (!removeAdminFlagFromUser(userName, 8)) { + return Response::RespInternalError; + } + } + } + return Response::RespOk; } diff --git a/servatrice/src/serversocketinterface.h b/servatrice/src/serversocketinterface.h index e10aa0dde..b464e6a9b 100644 --- a/servatrice/src/serversocketinterface.h +++ b/servatrice/src/serversocketinterface.h @@ -24,6 +24,17 @@ #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include class Servatrice; @@ -50,6 +61,7 @@ class Command_BanFromServer; class Command_UpdateServerMessage; class Command_ShutdownServer; class Command_ReloadConfig; +class Command_ReplayDownloadByGameId; class Command_AccountEdit; class Command_AccountImage; @@ -67,8 +79,9 @@ signals: void incTxBytes(qint64 amount); protected: - void logDebugMessage(const QString &message); + void logDebugMessage(const QString &message) override; bool tooManyRegistrationAttempts(const QString &ipAddress); + void processCommandContainer(const CommandContainer &cont) override; virtual void writeToSocket(QByteArray &data) = 0; virtual void flushSocket() = 0; @@ -102,13 +115,19 @@ private: Response::ResponseCode cmdReplayGetCode(const Command_ReplayGetCode &cmd, ResponseContainer &rc); Response::ResponseCode cmdReplaySubmitCode(const Command_ReplaySubmitCode &cmd, ResponseContainer &rc); Response::ResponseCode cmdBanFromServer(const Command_BanFromServer &cmd, ResponseContainer &rc); + Response::ResponseCode cmdReportList(const Command_ReportList &cmd, ResponseContainer &rc); Response::ResponseCode cmdWarnUser(const Command_WarnUser &cmd, ResponseContainer &rc); - Response::ResponseCode cmdGetLogHistory(const Command_ViewLogHistory &cmd, ResponseContainer &rc); + Response::ResponseCode + cmdGetLogHistory(const Command_ViewLogHistory &cmd, ResponseContainer &rc, bool allowPrivateChat); Response::ResponseCode cmdGetBanHistory(const Command_GetBanHistory &cmd, ResponseContainer &rc); Response::ResponseCode cmdGetWarnList(const Command_GetWarnList &cmd, ResponseContainer &rc); Response::ResponseCode cmdGetWarnHistory(const Command_GetWarnHistory &cmd, ResponseContainer &rc); Response::ResponseCode cmdShutdownServer(const Command_ShutdownServer &cmd, ResponseContainer &rc); Response::ResponseCode cmdUpdateServerMessage(const Command_UpdateServerMessage &cmd, ResponseContainer &rc); + Response::ResponseCode cmdReportAssign(const Command_ReportAssign &cmd, ResponseContainer &); + Response::ResponseCode cmdReportResolve(const Command_ReportResolve &cmd, ResponseContainer &); + Response::ResponseCode cmdReportUserInfo(const Command_ReportUserInfo &cmd, ResponseContainer &rc); + Response::ResponseCode cmdReportStats(const Command_ReportStats &cmd, ResponseContainer &rc); Response::ResponseCode cmdRegisterAccount(const Command_Register &cmd, ResponseContainer &rc); Response::ResponseCode cmdActivateAccount(const Command_Activate &cmd, ResponseContainer & /* rc */); Response::ResponseCode cmdReloadConfig(const Command_ReloadConfig & /* cmd */, ResponseContainer & /*rc*/); @@ -122,13 +141,29 @@ private: Response::ResponseCode cmdForgotPasswordChallenge(const Command_ForgotPasswordChallenge &cmd, ResponseContainer &rc); Response::ResponseCode cmdRequestPasswordSalt(const Command_RequestPasswordSalt &cmd, ResponseContainer &rc); - Response::ResponseCode processExtendedSessionCommand(int cmdType, const SessionCommand &cmd, ResponseContainer &rc); + Response::ResponseCode cmdReport(const Command_Report &cmd, ResponseContainer &); + Response::ResponseCode cmdReportMyList(const Command_ReportMyList &cmd, ResponseContainer &rc); + void sendPendingReportNotifications(ResponseContainer &rc); + void onLogin(ResponseContainer &rc) override; + Response::ResponseCode cmdReportDetails(const Command_ReportDetails &cmd, ResponseContainer &rc); + Response::ResponseCode cmdReportAddComment(const Command_ReportAddComment &cmd, ResponseContainer &rc); + Response::ResponseCode cmdReplayDownloadByGameId(const Command_ReplayDownloadByGameId &cmd, ResponseContainer &rc); Response::ResponseCode - processExtendedModeratorCommand(int cmdType, const ModeratorCommand &cmd, ResponseContainer &rc); - Response::ResponseCode processExtendedAdminCommand(int cmdType, const AdminCommand &cmd, ResponseContainer &rc); + processExtendedSessionCommand(int cmdType, const SessionCommand &cmd, ResponseContainer &rc) override; + Response::ResponseCode + processExtendedModeratorCommand(int cmdType, const ModeratorCommand &cmd, ResponseContainer &rc) override; + Response::ResponseCode + processExtendedAdminCommand(int cmdType, const AdminCommand &cmd, ResponseContainer &rc) override; + Response::ResponseCode + processExtendedDeveloperCommand(int cmdType, const DeveloperCommand &cmd, ResponseContainer &rc) override; Response::ResponseCode cmdAccountEdit(const Command_AccountEdit &cmd, ResponseContainer &rc); Response::ResponseCode cmdAccountImage(const Command_AccountImage &cmd, ResponseContainer &rc); + bool isCardNameAllowed(const QString &cardName, const QString &cardProviderId); + Response::ResponseCode cmdSetCardArtParams(const Command_SetCardArtParams &cmd, ResponseContainer &); + Response::ResponseCode cmdAddCardArtRule(const Command_AddCardArtRule &cmd, ResponseContainer &); + Response::ResponseCode cmdRemoveCardArtRule(const Command_RemoveCardArtRule &cmd, ResponseContainer &); + Response::ResponseCode cmdListCardArtRules(const Command_ListCardArtRules &, ResponseContainer &rc); Response::ResponseCode cmdAccountPassword(const Command_AccountPassword &cmd, ResponseContainer &rc); Response::ResponseCode cmdGrantReplayAccess(const Command_GrantReplayAccess &cmd, ResponseContainer &rc); Response::ResponseCode cmdForceActivateUser(const Command_ForceActivateUser &cmd, ResponseContainer &rc); @@ -136,6 +171,14 @@ private: Response::ResponseCode cmdGetAdminNotes(const Command_GetAdminNotes &cmd, ResponseContainer &rc); Response::ResponseCode cmdUpdateAdminNotes(const Command_UpdateAdminNotes &cmd, ResponseContainer &rc); + Response::ResponseCode cmdGetUserSessions(const Command_GetUserSessions &cmd, ResponseContainer &rc); + Response::ResponseCode cmdGetUserAlts(const Command_GetUserAlts &cmd, ResponseContainer &rc); + Response::ResponseCode cmdGetModeratorLastLogins(const Command_GetModeratorLastLogins &cmd, ResponseContainer &rc); + Response::ResponseCode cmdResetUserPassword(const Command_ResetUserPassword &cmd, ResponseContainer &rc); + Response::ResponseCode cmdRemoveUserAvatar(const Command_RemoveUserAvatar &cmd, ResponseContainer &rc); + + Response::ResponseCode cmdGetServerStats(const Command_GetServerStats &cmd, ResponseContainer &rc); + bool addAdminFlagToUser(const QString &user, int flag); bool removeAdminFlagFromUser(const QString &user, int flag); @@ -152,9 +195,9 @@ public: bool initSession(); virtual QHostAddress getPeerAddress() const = 0; - virtual QString getAddress() const = 0; + QString getAddress() const override = 0; - void transmitProtocolItem(const ServerMessage &item); + void transmitProtocolItem(const ServerMessage &item) override; }; class TcpServerSocketInterface : public AbstractServerSocketInterface diff --git a/servatrice/src/smtp/qxtsmtp.cpp b/servatrice/src/smtp/qxtsmtp.cpp index 6326b101d..c81e0955c 100644 --- a/servatrice/src/smtp/qxtsmtp.cpp +++ b/servatrice/src/smtp/qxtsmtp.cpp @@ -54,13 +54,8 @@ QxtSmtp::QxtSmtp(QObject *parent) : QObject(parent) // QObject::connect(socket(), SIGNAL(encrypted()), &qxt_d(), SLOT(ehlo())); QObject::connect(socket(), SIGNAL(connected()), this, SIGNAL(connected())); QObject::connect(socket(), SIGNAL(disconnected()), this, SIGNAL(disconnected())); -#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) QObject::connect(socket(), SIGNAL(errorOccurred(QAbstractSocket::SocketError)), &qxt_d(), SLOT(socketError(QAbstractSocket::SocketError))); -#else - QObject::connect(socket(), SIGNAL(error(QAbstractSocket::SocketError)), &qxt_d(), - SLOT(socketError(QAbstractSocket::SocketError))); -#endif QObject::connect(this, SIGNAL(authenticated()), &qxt_d(), SLOT(sendNext())); QObject::connect(socket(), SIGNAL(readyRead()), &qxt_d(), SLOT(socketRead())); } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 04ac7fcee..7bb834d7e 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -4,23 +4,54 @@ enable_testing() add_test(NAME dummy_test COMMAND dummy_test) add_test(NAME expression_test COMMAND expression_test) +add_test(NAME clamped_arithmetic_test COMMAND clamped_arithmetic_test) add_test(NAME test_age_formatting COMMAND test_age_formatting) add_test(NAME password_hash_test COMMAND password_hash_test) +add_test(NAME playmat_resolver_test COMMAND playmat_resolver_test) add_test(NAME server_card_counter_test COMMAND server_card_counter_test) add_test(NAME server_counter_test COMMAND server_counter_test) +add_test(NAME server_rate_limiter_test COMMAND server_rate_limiter_test) +add_test(NAME server_developer_role_test COMMAND server_developer_role_test) +add_test(NAME server_game_join_test COMMAND server_game_join_test) +add_test(NAME warning_categories_test COMMAND warning_categories_test) +add_test(NAME lag_monitor_test COMMAND lag_monitor_test) +add_test(NAME latency_tracker_test COMMAND latency_tracker_test) +add_test(NAME metrics_registry_test COMMAND metrics_registry_test) +add_test(NAME loader_local_matching_test COMMAND loader_local_matching_test) add_test(NAME deck_hash_performance_test COMMAND deck_hash_performance_test) -set_tests_properties(deck_hash_performance_test PROPERTIES TIMEOUT 5) +set_tests_properties(deck_hash_performance_test PROPERTIES TIMEOUT 15) # Find GTest add_executable(dummy_test dummy_test.cpp) add_executable(expression_test expression_test.cpp) +add_executable(clamped_arithmetic_test clamped_arithmetic_test.cpp) add_executable(test_age_formatting test_age_formatting.cpp) add_executable(password_hash_test password_hash_test.cpp) +add_executable(playmat_resolver_test playmat_resolver_test.cpp) add_executable(deck_hash_performance_test deck_hash_performance_test.cpp) add_executable(server_card_counter_test server_card_counter_test.cpp) add_executable(server_counter_test server_counter_test.cpp) +add_executable(server_rate_limiter_test server_rate_limiter_test.cpp) +add_executable(server_developer_role_test server_developer_role_test.cpp) +add_executable(server_game_join_test server_game_join_test.cpp) +add_executable(warning_categories_test warning_categories_test.cpp) +add_executable(lag_monitor_test ${CMAKE_SOURCE_DIR}/cockatrice/src/client/lag_monitor.cpp lag_monitor_test.cpp) +target_include_directories(lag_monitor_test PRIVATE ${CMAKE_SOURCE_DIR}/cockatrice/src) +add_executable(latency_tracker_test latency_tracker_test.cpp) +add_executable(metrics_registry_test ../servatrice/src/metrics_registry.cpp metrics_registry_test.cpp) +add_executable( + loader_local_matching_test + ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/cache_settings.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/card_counter_settings.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/shortcuts_settings.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/client/network/update/client/release_channel.cpp + ${VERSION_STRING_CPP} + loader_local_matching_test.cpp +) +target_include_directories(loader_local_matching_test PRIVATE ${CMAKE_SOURCE_DIR}/cockatrice/src) find_package(GTest) @@ -49,22 +80,39 @@ if(NOT GTEST_FOUND) set(GTEST_BOTH_LIBRARIES gtest) add_dependencies(dummy_test gtest) add_dependencies(expression_test gtest) + add_dependencies(clamped_arithmetic_test gtest) add_dependencies(test_age_formatting gtest) add_dependencies(password_hash_test gtest) + add_dependencies(playmat_resolver_test gtest) add_dependencies(deck_hash_performance_test gtest) add_dependencies(server_card_counter_test gtest) add_dependencies(server_counter_test gtest) + add_dependencies(server_rate_limiter_test gtest) + add_dependencies(server_developer_role_test gtest) + add_dependencies(server_game_join_test gtest) + add_dependencies(warning_categories_test gtest) + add_dependencies(lag_monitor_test gtest) + add_dependencies(latency_tracker_test gtest) + add_dependencies(metrics_registry_test gtest) + add_dependencies(loader_local_matching_test gtest) endif() include_directories(${GTEST_INCLUDE_DIRS}) target_link_libraries(dummy_test Threads::Threads ${GTEST_BOTH_LIBRARIES}) target_link_libraries(expression_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES}) +target_link_libraries( + clamped_arithmetic_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} +) target_link_libraries( test_age_formatting libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} ) target_link_libraries( password_hash_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} ) +target_link_libraries( + playmat_resolver_test libcockatrice_deck_list libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} + ${TEST_QT_MODULES} +) target_link_libraries( deck_hash_performance_test libcockatrice_deck_list libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} @@ -75,9 +123,35 @@ target_link_libraries( target_link_libraries( server_counter_test libcockatrice_network Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} ) +target_link_libraries( + server_rate_limiter_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} +) +target_link_libraries( + server_developer_role_test libcockatrice_network libcockatrice_rng Threads::Threads ${GTEST_BOTH_LIBRARIES} + ${TEST_QT_MODULES} +) +target_link_libraries( + server_game_join_test libcockatrice_network_server_remote libcockatrice_rng Threads::Threads ${GTEST_BOTH_LIBRARIES} + ${TEST_QT_MODULES} +) +target_link_libraries( + warning_categories_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} +) +target_link_libraries(lag_monitor_test Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES}) +target_link_libraries( + latency_tracker_test libcockatrice_network Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} +) +target_include_directories(metrics_registry_test PRIVATE ${CMAKE_SOURCE_DIR}/servatrice/src) +target_link_libraries(metrics_registry_test ${TEST_QT_MODULES} Threads::Threads ${GTEST_BOTH_LIBRARIES}) +target_link_libraries( + loader_local_matching_test libcockatrice_settings Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} +) add_subdirectory(card_zone_algorithms) add_subdirectory(carddatabase) +add_subdirectory(deck_list_model) +add_subdirectory(deck_list_zones) add_subdirectory(loading_from_clipboard) add_subdirectory(movecard_tests) add_subdirectory(oracle) +add_subdirectory(settings) diff --git a/tests/card_zone_algorithms/card_zone_algorithms_test.cpp b/tests/card_zone_algorithms/card_zone_algorithms_test.cpp index cc098cae9..8fab42566 100644 --- a/tests/card_zone_algorithms/card_zone_algorithms_test.cpp +++ b/tests/card_zone_algorithms/card_zone_algorithms_test.cpp @@ -134,6 +134,35 @@ TEST_F(AddCardAlgorithmTest, MidListInsertionPreservesOrder) EXPECT_EQ(knownList.at(2), &b); } +// Reconnecting to a game rebuilds zones from a ServerInfo_Zone. Non-coordinate zones +// (hand, piles, stack) report x == 0 on every card, so inserting each rebuilt card at +// that index would reverse the received server order. Appending (-1) keeps it. +TEST_F(AddCardAlgorithmTest, RebuildInsertAtZeroReversesServerOrder) +{ + MockCard a, b, c; + CardZoneAlgorithms::addCardToList(knownList, &a, 0, false); + CardZoneAlgorithms::addCardToList(knownList, &b, 0, false); + CardZoneAlgorithms::addCardToList(knownList, &c, 0, false); + + EXPECT_EQ(knownList.size(), 3); + EXPECT_EQ(knownList.at(0), &c); + EXPECT_EQ(knownList.at(1), &b); + EXPECT_EQ(knownList.at(2), &a); +} + +TEST_F(AddCardAlgorithmTest, RebuildAppendPreservesServerOrder) +{ + MockCard a, b, c; + CardZoneAlgorithms::addCardToList(knownList, &a, -1, false); + CardZoneAlgorithms::addCardToList(knownList, &b, -1, false); + CardZoneAlgorithms::addCardToList(knownList, &c, -1, false); + + EXPECT_EQ(knownList.size(), 3); + EXPECT_EQ(knownList.at(0), &a); + EXPECT_EQ(knownList.at(1), &b); + EXPECT_EQ(knownList.at(2), &c); +} + TEST_F(AddCardAlgorithmTest, KeepAnnotationsFalsePassedThrough) { MockCard card; diff --git a/tests/carddatabase/CMakeLists.txt b/tests/carddatabase/CMakeLists.txt index 987e23cd5..811cebc1d 100644 --- a/tests/carddatabase/CMakeLists.txt +++ b/tests/carddatabase/CMakeLists.txt @@ -21,6 +21,24 @@ target_link_libraries( add_test(NAME carddatabase_test COMMAND carddatabase_test) +# ------------------------ +# Load Benchmark (manual, not run in CI) +# ------------------------ +add_executable(load_benchmark ${MOCKS_SOURCES} ${VERSION_STRING_CPP} load_benchmark.cpp mocks.cpp) + +target_link_libraries( + load_benchmark + PRIVATE libcockatrice_card + PRIVATE libcockatrice_models + PRIVATE Threads::Threads + PRIVATE ${GTEST_BOTH_LIBRARIES} + PRIVATE ${TEST_QT_MODULES} +) + +if(NOT GTEST_FOUND) + add_dependencies(load_benchmark gtest) +endif() + # ------------------------ # Filter String Test # (guard must match the condition for libcockatrice_filters in the root CMakeLists.txt) diff --git a/tests/carddatabase/carddatabase_test.cpp b/tests/carddatabase/carddatabase_test.cpp index 3fa0e3834..392f6395a 100644 --- a/tests/carddatabase/carddatabase_test.cpp +++ b/tests/carddatabase/carddatabase_test.cpp @@ -2,9 +2,14 @@ #include "test_card_database_path_provider.h" #include "gtest/gtest.h" +#include +#include +#include +#include +#include +#include #include #include - namespace { @@ -33,6 +38,49 @@ TEST(CardDatabaseTest, LoadXml) ASSERT_EQ(0, db->query()->getAllMainCardTypes().size()) << "Types not empty after clear"; ASSERT_EQ(NotLoaded, db->getLoadStatus()) << "Incorrect status after clear"; } + +TEST(CardDatabaseTest, Xml4LocalizedDataRoundTrip) +{ + NoopCardSetPriorityController controller; + CardSetPtr set = + CardSet::newInstance(&controller, "TST", "Test Set", "expansion", QDate(), CardSet::PriorityPrimary); + + QHash props; + props["manacost"] = "1R"; + PrintingInfo printing(set, LazyPropertiesHash(props)); + SetToPrintingsMap setsInfo; + setsInfo["TST"].append(printing); + + CardInfo::UiAttributes attributes = {.tableRow = 1}; + CardInfoPtr card = + CardInfo::newInstance("Lightning Bolt", "Deal 3 damage.", false, {}, {}, {}, setsInfo, attributes); + card->setLocalizedName("de", "Blitzschlag"); + card->setLocalizedText("de", "Blitzschlag fügt 3 Schadenspunkte zu."); + + SetNameMap sets; + sets.insert("TST", set); + CardNameMap cards; + cards.insert("Lightning Bolt", card); + + QTemporaryDir tempDir; + const QString fileName = tempDir.filePath("cards.xml"); + NoopCardPreferenceProvider prefProvider; + CockatriceXml4Parser writer(&prefProvider, &controller); + ASSERT_TRUE(writer.saveToFile({}, sets, cards, fileName)); + + CardDatabaseData data; + CockatriceXml4Parser parser(&prefProvider, &controller); + QFile file(fileName); + ASSERT_TRUE(file.open(QIODevice::ReadOnly)); + parser.parseFileInto(file, data); + + CardInfoPtr loaded = data.cards.value("Lightning Bolt"); + ASSERT_FALSE(loaded.isNull()); + ASSERT_EQ(loaded->getName(), "Lightning Bolt"); + ASSERT_EQ(loaded->getLocalizedName("de"), "Blitzschlag"); + ASSERT_EQ(loaded->getLocalizedText("de"), "Blitzschlag fügt 3 Schadenspunkte zu."); + ASSERT_EQ(loaded->getLocalizedText("fr"), "Deal 3 damage."); +} } // namespace int main(int argc, char **argv) diff --git a/tests/carddatabase/load_benchmark.cpp b/tests/carddatabase/load_benchmark.cpp new file mode 100644 index 000000000..8e9e80e4d --- /dev/null +++ b/tests/carddatabase/load_benchmark.cpp @@ -0,0 +1,235 @@ +/* + * Standalone benchmark for card database loading. + * + * Measures wall-clock cost and model churn: + * - Cold load (XML -> write cache) + * - Warm load (read cache) + * + * Run: + * load_benchmark [--carddb PATH] [--tokens PATH] [--spoilers PATH] [--custom DIR] + * + * All arguments are optional; they default to the XDG Cockatrice data location + * (~/.local/share/Cockatrice/Cockatrice/...). + */ + +#include "mocks.h" + +#include "gtest/gtest.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static QString defaultXdgPath(const QString &file) +{ + QString base = QDir::homePath() + "/.local/share/Cockatrice/Cockatrice/"; + return base + file; +} + +class CliCardDatabasePathProvider : public ICardDatabasePathProvider +{ +public: + QString cardDb; + QString tokens; + QString spoilers; + QString custom; + + QString getCardDatabasePath() const override + { + return cardDb; + } + QString getTokenDatabasePath() const override + { + return tokens; + } + QString getSpoilerCardDatabasePath() const override + { + return spoilers; + } + QString getCustomCardDatabasePath() const override + { + return custom; + } +}; + +namespace +{ +struct ChurnCounters +{ + qint64 cardAddedEmissions = 0; + qint64 cardRemovedEmissions = 0; + qint64 modelRowsInserted = 0; + qint64 modelRowsRemoved = 0; + qint64 loadingFinishedSignals = 0; + qint64 databaseResetSignals = 0; + qint64 modelResets = 0; +}; + +struct RunResult +{ + qint64 elapsedMs = 0; + qint64 cardCount = 0; + qint64 setCount = 0; + LoadStatus status = NotLoaded; + ChurnCounters churn; + qint64 propsQueried = 0; + qint64 propTotal = 0; +}; + +void deleteCache(const QString &cardDbPath) +{ + const QString cachePath = cardDbPath + ".cache"; + if (QFile::exists(cachePath)) { + QFile::remove(cachePath); + } +} + +RunResult runLoad(CardDatabase *db, ChurnCounters &counters) +{ + const ChurnCounters before = counters; + + QElapsedTimer timer; + timer.start(); + + db->loadCardDatabases(); + + RunResult result; + result.elapsedMs = timer.elapsed(); + result.cardCount = db->getCardList().size(); + result.setCount = db->getSetList().size(); + result.status = db->getLoadStatus(); + + result.churn.cardAddedEmissions = counters.cardAddedEmissions - before.cardAddedEmissions; + result.churn.cardRemovedEmissions = counters.cardRemovedEmissions - before.cardRemovedEmissions; + result.churn.modelRowsInserted = counters.modelRowsInserted - before.modelRowsInserted; + result.churn.modelRowsRemoved = counters.modelRowsRemoved - before.modelRowsRemoved; + result.churn.loadingFinishedSignals = counters.loadingFinishedSignals - before.loadingFinishedSignals; + result.churn.databaseResetSignals = counters.databaseResetSignals - before.databaseResetSignals; + result.churn.modelResets = counters.modelResets - before.modelResets; + + for (const CardInfoPtr &card : db->getCardList()) { + result.propTotal += card->getPropertiesHash().size(); + if (!card->getProperty("colors").isEmpty()) { + ++result.propsQueried; + } + } + + return result; +} + +void printResult(const QString &label, const RunResult &r) +{ + qInfo() << "----------------------------------------"; + qInfo() << label; + qInfo() << " Load wall-clock time :" << r.elapsedMs << "ms"; + qInfo() << " Cards loaded :" << r.cardCount; + qInfo() << " Sets loaded :" << r.setCount; + qInfo() << " Load status :" << r.status; + qInfo() << " Properties queried :" << r.propsQueried << "/" << r.cardCount + << " cards, total props:" << r.propTotal; + qInfo() << " CHURN during load:"; + qInfo() << " cardAdded emissions :" << r.churn.cardAddedEmissions; + qInfo() << " cardRemoved emissions :" << r.churn.cardRemovedEmissions; + qInfo() << " model rowsInserted :" << r.churn.modelRowsInserted; + qInfo() << " model rowsRemoved :" << r.churn.modelRowsRemoved; + qInfo() << " loadingFinished signals :" << r.churn.loadingFinishedSignals; + qInfo() << " databaseReset signals :" << r.churn.databaseResetSignals; + qInfo() << " model resets (batch) :" << r.churn.modelResets; +} +} // namespace + +int main(int argc, char **argv) +{ + QCoreApplication app(argc, argv); + + QStringList args = app.arguments(); + CliCardDatabasePathProvider pathProvider; + pathProvider.cardDb = defaultXdgPath("cards.xml"); + pathProvider.tokens = defaultXdgPath("tokens.xml"); + pathProvider.spoilers = defaultXdgPath("spoiler.xml"); + pathProvider.custom = defaultXdgPath("customsets/"); + + for (int i = 1; i < args.size(); ++i) { + const QString &a = args.at(i); + if (a == "--carddb" && i + 1 < args.size()) { + pathProvider.cardDb = args.at(++i); + } else if (a == "--tokens" && i + 1 < args.size()) { + pathProvider.tokens = args.at(++i); + } else if (a == "--spoilers" && i + 1 < args.size()) { + pathProvider.spoilers = args.at(++i); + } else if (a == "--custom" && i + 1 < args.size()) { + pathProvider.custom = args.at(++i); + } else if (a == "--help" || a == "-h") { + qInfo() << "Usage: load_benchmark [--carddb PATH] [--tokens PATH] [--spoilers PATH] [--custom DIR]"; + return 0; + } + } + + QLoggingCategory::setFilterRules("card_database.loading=true\n" + "card_database.loading.success=true\n"); + + qInfo() << "=== Card Database Load Benchmark ==="; + qInfo() << "carddb :" << pathProvider.cardDb; + qInfo() << "tokens :" << pathProvider.tokens; + qInfo() << "spoilers :" << pathProvider.spoilers; + qInfo() << "custom :" << pathProvider.custom; + qInfo() << ""; + + ChurnCounters counters; + + auto runScenario = [&](bool deleteCacheBefore) -> RunResult { + if (deleteCacheBefore) { + deleteCache(pathProvider.cardDb); + } + + // Fresh database each scenario so signals don't accumulate across scenarios + // for the churn counters, but we keep the same counters object to get a + // running total if desired. + CardDatabase db(nullptr, new NoopCardPreferenceProvider(), &pathProvider, new NoopCardSetPriorityController()); + + // Re-wire churn counters for this db instance + QObject::connect(&db, &CardDatabase::cardAdded, &app, + [&counters](const CardInfoPtr &) { ++counters.cardAddedEmissions; }); + QObject::connect(&db, &CardDatabase::cardRemoved, &app, + [&counters](CardInfoPtr) { ++counters.cardRemovedEmissions; }); + QObject::connect(&db, &CardDatabase::cardDatabaseLoadingFinished, &app, + [&counters]() { ++counters.loadingFinishedSignals; }); + QObject::connect(&db, &CardDatabase::cardDatabaseReset, &app, + [&counters]() { ++counters.databaseResetSignals; }); + + CardDatabaseModel model(&db, false); + QObject::connect(&model, &QAbstractItemModel::rowsInserted, &app, + [&counters](const QModelIndex &, int, int) { ++counters.modelRowsInserted; }); + QObject::connect(&model, &QAbstractItemModel::rowsRemoved, &app, + [&counters](const QModelIndex &, int, int) { ++counters.modelRowsRemoved; }); + QObject::connect(&model, &QAbstractItemModel::modelReset, &app, [&counters]() { ++counters.modelResets; }); + + return runLoad(&db, counters); + }; + + // ---- Scenario 1: Cold load (XML -> write cache) ---- + printResult("Scenario 1: Cold load (XML -> cache)", runScenario(true)); + + // ---- Scenario 2: Warm load (read cache) ---- + printResult("Scenario 2: Warm load (read cache)", runScenario(false)); + + // Teardown + deleteCache(pathProvider.cardDb); + + qInfo() << "----------------------------------------"; + qInfo() << "Done."; + + return 0; +} diff --git a/tests/clamped_arithmetic_test.cpp b/tests/clamped_arithmetic_test.cpp new file mode 100644 index 000000000..2471d5870 --- /dev/null +++ b/tests/clamped_arithmetic_test.cpp @@ -0,0 +1,44 @@ +/** @file clamped_arithmetic_test.cpp + * @brief Tests for shared helpers in clamped_arithmetic.h. + * @ingroup Tests + */ + +#include +#include +#include + +TEST(AddClamped, AddsWithinBounds) +{ + EXPECT_EQ(addClamped(5, 3, 0, 100), 8); + EXPECT_EQ(addClamped(10, -3, 0, 100), 7); +} + +TEST(AddClamped, ClampsToUpperAndLowerBound) +{ + EXPECT_EQ(addClamped(99, 5, 0, 100), 100); // saturates at max + EXPECT_EQ(addClamped(2, -10, 0, 100), 0); // saturates at min + EXPECT_EQ(addClamped(999, 1, 0, 999), 999); // crossing the counter cap holds at the bound +} + +TEST(AddClamped, IntOverflowDoesNotWrap) +{ + // The 64-bit intermediate must prevent signed-int overflow UB. + constexpr int intMax = std::numeric_limits::max(); + constexpr int intMin = std::numeric_limits::min(); + EXPECT_EQ(addClamped(intMax, 1, intMin, intMax), intMax); + EXPECT_EQ(addClamped(intMax, intMax, intMin, intMax), intMax); +} + +TEST(AddClamped, IntUnderflowDoesNotWrap) +{ + constexpr int intMax = std::numeric_limits::max(); + constexpr int intMin = std::numeric_limits::min(); + EXPECT_EQ(addClamped(intMin, -1, intMin, intMax), intMin); + EXPECT_EQ(addClamped(intMin, intMin, intMin, intMax), intMin); +} + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/deck_list_model/CMakeLists.txt b/tests/deck_list_model/CMakeLists.txt new file mode 100644 index 000000000..e3096c559 --- /dev/null +++ b/tests/deck_list_model/CMakeLists.txt @@ -0,0 +1,33 @@ +add_executable(deck_list_model_custom_zones_test ${VERSION_STRING_CPP} deck_list_model_custom_zones_test.cpp) + +if(NOT GTEST_FOUND) + add_dependencies(deck_list_model_custom_zones_test gtest) +endif() + +target_link_libraries( + deck_list_model_custom_zones_test + libcockatrice_models + libcockatrice_card + libcockatrice_deck_list + Threads::Threads + ${GTEST_BOTH_LIBRARIES} + ${TEST_QT_MODULES} +) +add_test(NAME deck_list_model_custom_zones_test COMMAND deck_list_model_custom_zones_test) + +add_executable(deck_list_model_zone_integration_test ${VERSION_STRING_CPP} deck_list_model_zone_integration_test.cpp) + +if(NOT GTEST_FOUND) + add_dependencies(deck_list_model_zone_integration_test gtest) +endif() + +target_link_libraries( + deck_list_model_zone_integration_test + libcockatrice_models + libcockatrice_card + libcockatrice_deck_list + Threads::Threads + ${GTEST_BOTH_LIBRARIES} + ${TEST_QT_MODULES} +) +add_test(NAME deck_list_model_zone_integration_test COMMAND deck_list_model_zone_integration_test) diff --git a/tests/deck_list_model/deck_list_model_custom_zones_test.cpp b/tests/deck_list_model/deck_list_model_custom_zones_test.cpp new file mode 100644 index 000000000..7f4ab81e1 --- /dev/null +++ b/tests/deck_list_model/deck_list_model_custom_zones_test.cpp @@ -0,0 +1,276 @@ +/** + * @file deck_list_model_custom_zones_test.cpp + * @brief Tests for the deck list model's custom-zone shadow-tree helpers. + * + * DeckListModelCustomZones centralizes every "what is / where is a custom zone" + * decision for the model's shadow tree: type testing, mirroring from the deck + * tree, name lookup, and the sort-with-custom-zones-last ordering. These tests + * exercise that logic directly on hand-built shadow trees, independent of the + * full model and card database machinery. + */ + +#include +#include +#include +#include + +namespace +{ + +DecklistModelCardNode *cardNode(InnerDecklistNode *parent, const QString &name, int number) +{ + // The underlying data node is detached; only the model wrapper is attached to the shadow tree. + auto *data = new DecklistCardNode(name, number, nullptr); + return new DecklistModelCardNode(data, parent); +} + +QStringList childNames(const InnerDecklistNode *node) +{ + QStringList names; + for (int i = 0; i < node->size(); ++i) { + names.append(node->at(i)->getName()); + } + return names; +} + +} // namespace + +// ===================================================================================================================== +// isCustomZone +// ===================================================================================================================== + +TEST(DeckListModelCustomZones, IsCustomZoneDistinguishesZoneFromGroup) +{ + InnerDecklistNode root; + auto *board = new InnerDecklistNode(DECK_ZONE_MAIN, &root); + auto *group = new InnerDecklistNode("Creature", board); + auto *zone = new DecklistModelSubZoneNode("Removal", board); + + auto *card = cardNode(group, "A", 1); + + EXPECT_FALSE(DeckListModelCustomZones::isCustomZone(board)); + EXPECT_FALSE(DeckListModelCustomZones::isCustomZone(group)); + EXPECT_FALSE(DeckListModelCustomZones::isCustomZone(card)); + EXPECT_TRUE(DeckListModelCustomZones::isCustomZone(zone)); +} + +// ===================================================================================================================== +// findSubZoneByName +// ===================================================================================================================== + +TEST(DeckListModelCustomZones, FindSubZoneByNameFindsAcrossBoards) +{ + InnerDecklistNode root; + auto *main = new InnerDecklistNode(DECK_ZONE_MAIN, &root); + auto *side = new InnerDecklistNode(DECK_ZONE_SIDE, &root); + new DecklistModelSubZoneNode("Removal", main); + new DecklistModelSubZoneNode("Utility", side); + new InnerDecklistNode("Plain", main); // not a custom zone + + auto *removal = DeckListModelCustomZones::findSubZoneByName(&root, "Removal"); + ASSERT_NE(removal, nullptr); + EXPECT_EQ(removal->getName(), QString("Removal")); + + auto *utility = DeckListModelCustomZones::findSubZoneByName(&root, "Utility"); + ASSERT_NE(utility, nullptr); + EXPECT_EQ(utility->getName(), QString("Utility")); + + // Names are deck-unique; a plain group or built-in board is not matched. + EXPECT_EQ(DeckListModelCustomZones::findSubZoneByName(&root, "Plain"), nullptr); + EXPECT_EQ(DeckListModelCustomZones::findSubZoneByName(&root, DECK_ZONE_MAIN), nullptr); + EXPECT_EQ(DeckListModelCustomZones::findSubZoneByName(&root, "Missing"), nullptr); +} + +// ===================================================================================================================== +// mirrorCustomZones +// ===================================================================================================================== + +TEST(DeckListModelCustomZones, MirrorCustomZonesCopiesCardsFlat) +{ + // Deck-tree board zone: one direct card plus one nested custom zone. + auto *deckBoard = new InnerDecklistNode(DECK_ZONE_MAIN); + new DecklistCardNode("Direct", 2, deckBoard); + + auto *deckZone = new InnerDecklistNode("Removal", deckBoard); + auto *deckCard1 = new DecklistCardNode("Bolt", 3, deckZone); + auto *deckCard2 = new DecklistCardNode("Swords", 1, deckZone); + + InnerDecklistNode shadowRoot; + auto *shadowBoard = new InnerDecklistNode(DECK_ZONE_MAIN, &shadowRoot); + + DeckListModelCustomZones::mirrorCustomZones(deckBoard, shadowBoard); + + // Only the custom zone is mirrored as a sub-zone; the direct card is not. + ASSERT_EQ(shadowBoard->size(), 1); + auto *shadowZone = dynamic_cast(shadowBoard->at(0)); + ASSERT_NE(shadowZone, nullptr); + EXPECT_EQ(shadowZone->getName(), QString("Removal")); + + // Cards live flat (un-grouped) inside the mirrored zone, wrapping the same data nodes. + ASSERT_EQ(shadowZone->size(), 2); + auto *shadowCard1 = dynamic_cast(shadowZone->at(0)); + auto *shadowCard2 = dynamic_cast(shadowZone->at(1)); + ASSERT_NE(shadowCard1, nullptr); + ASSERT_NE(shadowCard2, nullptr); + EXPECT_EQ(shadowCard1->getDataNode(), deckCard1); + EXPECT_EQ(shadowCard2->getDataNode(), deckCard2); +} + +TEST(DeckListModelCustomZones, MirrorCustomZonesWithNoCustomZonesIsNoop) +{ + // A board zone with only direct cards has nothing to mirror. + auto *deckBoard = new InnerDecklistNode(DECK_ZONE_MAIN); + new DecklistCardNode("Direct", 2, deckBoard); + + InnerDecklistNode shadowRoot; + auto *shadowBoard = new InnerDecklistNode(DECK_ZONE_MAIN, &shadowRoot); + + DeckListModelCustomZones::mirrorCustomZones(deckBoard, shadowBoard); + EXPECT_EQ(shadowBoard->size(), 0); +} + +TEST(DeckListModelCustomZones, MirrorCustomZonesFlattensNestedSubzones) +{ + // Cards deeper than one level under a custom zone still get a model row. + auto *deckBoard = new InnerDecklistNode(DECK_ZONE_MAIN); + auto *deckZone = new InnerDecklistNode("Removal", deckBoard); + auto *deckCard1 = new DecklistCardNode("Bolt", 1, deckZone); + auto *deeper = new InnerDecklistNode("Deeper", deckZone); + auto *deckCard2 = new DecklistCardNode("Swords", 1, deeper); + + InnerDecklistNode shadowRoot; + auto *shadowBoard = new InnerDecklistNode(DECK_ZONE_MAIN, &shadowRoot); + + DeckListModelCustomZones::mirrorCustomZones(deckBoard, shadowBoard); + + ASSERT_EQ(shadowBoard->size(), 1); + auto *shadowZone = dynamic_cast(shadowBoard->at(0)); + ASSERT_NE(shadowZone, nullptr); + EXPECT_EQ(shadowZone->getName(), QString("Removal")); + + // Both cards are flattened into the mirrored zone, preserving order. + ASSERT_EQ(shadowZone->size(), 2); + auto *shadowCard1 = dynamic_cast(shadowZone->at(0)); + auto *shadowCard2 = dynamic_cast(shadowZone->at(1)); + ASSERT_NE(shadowCard1, nullptr); + ASSERT_NE(shadowCard2, nullptr); + EXPECT_EQ(shadowCard1->getDataNode(), deckCard1); + EXPECT_EQ(shadowCard2->getDataNode(), deckCard2); +} + +// ===================================================================================================================== +// findGroupChild +// ===================================================================================================================== + +TEST(DeckListModelCustomZones, FindGroupChildSkipsCustomZones) +{ + InnerDecklistNode root; + auto *board = new InnerDecklistNode(DECK_ZONE_MAIN, &root); + auto *group = new InnerDecklistNode("Creature", board); + new DecklistModelSubZoneNode("Creature", board); + + EXPECT_EQ(DeckListModelCustomZones::findGroupChild(board, "Creature"), group); + EXPECT_EQ(DeckListModelCustomZones::findGroupChild(board, "Missing"), nullptr); + EXPECT_EQ(DeckListModelCustomZones::findGroupChild(&root, DECK_ZONE_MAIN), board); +} + +// ===================================================================================================================== +// sortWithCustomZonesLast +// ===================================================================================================================== + +TEST(DeckListModelCustomZones, SortBoardKeepsCustomZonesAfterGroupsAscending) +{ + InnerDecklistNode root; + auto *board = new InnerDecklistNode(DECK_ZONE_MAIN, &root); + new DecklistModelSubZoneNode("Zebra", board); + new InnerDecklistNode("Creature", board); + new InnerDecklistNode("Instant", board); + new DecklistModelSubZoneNode("Alpha", board); + + root.setSortMethod(DeckSortMethod::ByName); + + auto mapping = DeckListModelCustomZones::sortWithCustomZonesLast(&root, board, Qt::AscendingOrder); + + // Groups sort first (by name), then custom zones (by name), always after groups. + EXPECT_EQ(childNames(board), (QStringList{"Creature", "Instant", "Alpha", "Zebra"})); + + // Some non-identity movement occurred. + EXPECT_FALSE(mapping.isEmpty()); +} + +TEST(DeckListModelCustomZones, SortBoardKeepsCustomZonesAfterGroupsDescending) +{ + InnerDecklistNode root; + auto *board = new InnerDecklistNode(DECK_ZONE_MAIN, &root); + new DecklistModelSubZoneNode("Zebra", board); + new InnerDecklistNode("Creature", board); + new InnerDecklistNode("Instant", board); + new DecklistModelSubZoneNode("Alpha", board); + + root.setSortMethod(DeckSortMethod::ByName); + + (void)DeckListModelCustomZones::sortWithCustomZonesLast(&root, board, Qt::DescendingOrder); + + // Groups still lead (descending), custom zones still last. + EXPECT_EQ(childNames(board), (QStringList{"Instant", "Creature", "Zebra", "Alpha"})); +} + +TEST(DeckListModelCustomZones, SortBoardMappingIsConsistent) +{ + InnerDecklistNode root; + auto *board = new InnerDecklistNode(DECK_ZONE_MAIN, &root); + + QList originalOrder; + auto *g0 = new InnerDecklistNode("Creature", board); + originalOrder.append(g0); + auto *z0 = new DecklistModelSubZoneNode("Zebra", board); + originalOrder.append(z0); + auto *g1 = new InnerDecklistNode("Instant", board); + originalOrder.append(g1); + auto *z1 = new DecklistModelSubZoneNode("Alpha", board); + originalOrder.append(z1); + + root.setSortMethod(DeckSortMethod::ByName); + + auto mapping = DeckListModelCustomZones::sortWithCustomZonesLast(&root, board, Qt::AscendingOrder); + + // The mapping reports, for each final row, the original row of the node now sitting there. + ASSERT_EQ(mapping.size(), board->size()); + for (const auto &move : mapping) { + const int preSortRow = move.first; + const int finalRow = move.second; + ASSERT_GE(preSortRow, 0); + ASSERT_LT(preSortRow, originalOrder.size()); + EXPECT_EQ(board->at(finalRow), originalOrder[preSortRow]) << "row " << finalRow; + } + + // Final order sanity: groups first in name order, then custom zones. + EXPECT_EQ(childNames(board), (QStringList{"Creature", "Instant", "Alpha", "Zebra"})); +} + +TEST(DeckListModelCustomZones, SortPlainNodeDoesNotReorderCustomZones) +{ + // A non-board node (e.g. a group whose children are cards) is sorted plainly; + // custom zones are not a special case there. Cards sort by name. + InnerDecklistNode root; + auto *board = new InnerDecklistNode(DECK_ZONE_MAIN, &root); + auto *group = new InnerDecklistNode("Creature", board); + cardNode(group, "Swords", 1); + cardNode(group, "Bolt", 3); + + root.setSortMethod(DeckSortMethod::ByName); + + auto mapping = DeckListModelCustomZones::sortWithCustomZonesLast(&root, group, Qt::AscendingOrder); + EXPECT_EQ(childNames(group), (QStringList{"Bolt", "Swords"})); + ASSERT_EQ(mapping.size(), 2); + EXPECT_EQ(mapping[0].first, 1); // "Bolt" was originally at row 1 + EXPECT_EQ(mapping[0].second, 0); + EXPECT_EQ(mapping[1].first, 0); + EXPECT_EQ(mapping[1].second, 1); +} + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/deck_list_model/deck_list_model_zone_integration_test.cpp b/tests/deck_list_model/deck_list_model_zone_integration_test.cpp new file mode 100644 index 000000000..a4562c95d --- /dev/null +++ b/tests/deck_list_model/deck_list_model_zone_integration_test.cpp @@ -0,0 +1,283 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + +int totalCustomZoneRows(const DeckListModel &model) +{ + int count = 0; + const int rootRows = model.rowCount(QModelIndex()); + for (int r = 0; r < rootRows; ++r) { + const QModelIndex board = model.index(r, 0, QModelIndex()); + const int childRows = model.rowCount(board); + for (int c = 0; c < childRows; ++c) { + const QModelIndex child = model.index(c, 0, board); + if (child.data(DeckRoles::IsCustomZoneRole).toBool()) { + ++count; + } + } + } + return count; +} + +QModelIndex findBoardIndex(const DeckListModel &model, const QString &boardName) +{ + for (int r = 0; r < model.rowCount(QModelIndex()); ++r) { + const QModelIndex idx = model.index(r, 0, QModelIndex()); + if (idx.data(DeckRoles::IsCardRole).toBool()) { + continue; + } + const QString name = idx.sibling(idx.row(), DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString(); + if (name == boardName) { + return idx; + } + } + return {}; +} + +QModelIndex findZoneRow(const DeckListModel &model, const QModelIndex &board) +{ + for (int r = 0; r < model.rowCount(board); ++r) { + const QModelIndex child = model.index(r, 0, board); + if (child.data(DeckRoles::IsCustomZoneRole).toBool()) { + return child; + } + } + return {}; +} + +} // namespace + +// The "Add to Zone" combobox/submenu lists getCustomZoneNames(), which reads the +// deck tree. These verify the source data a freshly-created zone populates. + +TEST(DeckListModelZoneIntegration, CreateZoneThenReadCustomZoneNames) +{ + QSharedPointer deck(new DeckList()); + DeckListModel model(nullptr, deck); + auto *tree = deck->getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + EXPECT_EQ(model.getCustomZoneNames(DECK_ZONE_MAIN), (QStringList{"Removal"})); +} + +TEST(DeckListModelZoneIntegration, CreateTwoZonesThenReadBoth) +{ + QSharedPointer deck(new DeckList()); + DeckListModel model(nullptr, deck); + auto *tree = deck->getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Utility"), nullptr); + EXPECT_EQ(model.getCustomZoneNames(DECK_ZONE_MAIN), (QStringList{"Removal", "Utility"})); +} + +// Mirroring regression: rebuildTree must mirror each custom zone exactly once. +TEST(DeckListModelZoneIntegration, RebuildTreeMirrorsEachZoneOnce) +{ + QSharedPointer deck(new DeckList()); + DeckListModel model(nullptr, deck); + auto *tree = deck->getTree(); + + // One direct mainboard card plus two nested custom zones. + tree->addCard("Lightning Bolt", 2, DECK_ZONE_MAIN, -1); + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + tree->addCard("Swords to Plowshares", 1, "Removal", -1); + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Utility"), nullptr); + + model.rebuildTree(); + + EXPECT_EQ(model.getCustomZoneNames(DECK_ZONE_MAIN), (QStringList{"Removal", "Utility"})); + EXPECT_EQ(totalCustomZoneRows(model), 2); +} + +// ===================================================================================================================== +// Model behaviour: addCard routing, findCard lookup, removeRows guard, empty-zone survival. +// ===================================================================================================================== + +TEST(DeckListModelZoneIntegration, AddCardRoutesIntoMirroredCustomZone) +{ + QSharedPointer deck(new DeckList()); + DeckListModel model(nullptr, deck); + auto *tree = deck->getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + model.rebuildTree(); + + QModelIndex added = model.addCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), "Removal"); + ASSERT_TRUE(added.isValid()); + + // The card is a direct child of the mirrored custom zone, not a new top-level zone. + const QModelIndex zoneParent = added.parent(); + ASSERT_TRUE(zoneParent.isValid()); + EXPECT_TRUE(zoneParent.data(DeckRoles::IsCustomZoneRole).toBool()); + EXPECT_EQ(zoneParent.sibling(zoneParent.row(), DeckListModelColumns::CARD_NAME).data(Qt::DisplayRole).toString(), + QString("Removal")); + + // No "Removal" top-level zone appeared in the deck tree. + auto *listRoot = tree->getRoot(); + bool topLevelRemoval = false; + for (int i = 0; i < listRoot->size(); ++i) { + if (auto *zone = dynamic_cast(listRoot->at(i))) { + topLevelRemoval |= zone->getName() == "Removal"; + } + } + EXPECT_FALSE(topLevelRemoval); +} + +TEST(DeckListModelZoneIntegration, AddCardToUnmirroredCustomZoneRebuildsNotCreatesTopLevel) +{ + QSharedPointer deck(new DeckList()); + DeckListModel model(nullptr, deck); + auto *tree = deck->getTree(); + + // The zone exists on the deck tree but the shadow tree has never mirrored it. + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + + QModelIndex added = model.addCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), "Removal"); + ASSERT_TRUE(added.isValid()); + + const QModelIndex zoneParent = added.parent(); + ASSERT_TRUE(zoneParent.isValid()); + EXPECT_TRUE(zoneParent.data(DeckRoles::IsCustomZoneRole).toBool()); + EXPECT_EQ(zoneParent.sibling(zoneParent.row(), DeckListModelColumns::CARD_NAME).data(Qt::DisplayRole).toString(), + QString("Removal")); +} + +TEST(DeckListModelZoneIntegration, AddCardCreatesGroupSeparatelyFromSameNamedZone) +{ + QSharedPointer deck(new DeckList()); + DeckListModel model(nullptr, deck); + auto *tree = deck->getTree(); + + // A custom zone named exactly like a grouping criterion. + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Creature"), nullptr); + model.rebuildTree(); + + CardInfoPtr bear = CardInfo::newInstance("Grizzly Bears"); + bear->setProperty(Mtg::MainCardType, "Creature"); + + QModelIndex added = model.addCard(ExactCard(bear), DECK_ZONE_MAIN); + ASSERT_TRUE(added.isValid()); + + // The card lands in a *group* node called "Creature", not swallowed by the custom zone. + const QModelIndex groupParent = added.parent(); + ASSERT_TRUE(groupParent.isValid()); + EXPECT_FALSE(groupParent.data(DeckRoles::IsCustomZoneRole).toBool()); + EXPECT_EQ(groupParent.sibling(groupParent.row(), DeckListModelColumns::CARD_NAME).data(Qt::DisplayRole).toString(), + QString("Creature")); + + // The board keeps both rows: the "Creature" group and the "Creature" custom zone. + const QModelIndex boardIndex = groupParent.parent(); + ASSERT_TRUE(boardIndex.isValid()); + EXPECT_EQ(model.rowCount(boardIndex), 2); +} + +TEST(DeckListModelZoneIntegration, FindCardResolvesCardInsideCustomZone) +{ + QSharedPointer deck(new DeckList()); + DeckListModel model(nullptr, deck); + auto *tree = deck->getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + model.rebuildTree(); + + // findCard resolves through the card database; register the card we add. + const QString cardName = "Swords to Plowshares"; + CardInfoPtr info = CardInfo::newInstance(cardName); + CardDatabaseManager::getInstance()->addCard(info); + + QModelIndex added = model.addCard(ExactCard(info), "Removal"); + ASSERT_TRUE(added.isValid()); + + QModelIndex found = model.findCard(cardName, "Removal"); + EXPECT_TRUE(found.isValid()); + EXPECT_EQ(found, added); +} + +TEST(DeckListModelZoneIntegration, RemoveRowsRefusesCustomZoneRow) +{ + QSharedPointer deck(new DeckList()); + DeckListModel model(nullptr, deck); + auto *tree = deck->getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + tree->addCard("Lightning Bolt", 2, DECK_ZONE_MAIN, -1); + model.rebuildTree(); + + const QModelIndex mainIndex = findBoardIndex(model, DECK_ZONE_MAIN); + ASSERT_TRUE(mainIndex.isValid()); + const QModelIndex zoneRow = findZoneRow(model, mainIndex); + ASSERT_TRUE(zoneRow.isValid()); + + EXPECT_FALSE(model.removeRow(zoneRow.row(), zoneRow.parent())); + EXPECT_EQ(model.rowCount(mainIndex), 2); // the zone survives, alongside the card group +} + +TEST(DeckListModelZoneIntegration, EmptyCustomZoneSurvivesMirrorAndPruning) +{ + QSharedPointer deck(new DeckList()); + DeckListModel model(nullptr, deck); + auto *tree = deck->getTree(); + + // An empty custom zone must be mirrored (the stack deliberately keeps it alive). + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + model.rebuildTree(); + + const QModelIndex mainIndex = findBoardIndex(model, DECK_ZONE_MAIN); + ASSERT_TRUE(mainIndex.isValid()); + EXPECT_EQ(model.rowCount(mainIndex), 1); + EXPECT_TRUE(findZoneRow(model, mainIndex).isValid()); +} + +// Regression: a board card named like the requested zone must not be mistaken for +// a zone. Previously `findChild` matched any child by name, so a mainboard card +// called "Lightning Bolt" made addCard believe a "Lightning Bolt" zone existed and +// recurse through rebuildTree forever. +TEST(DeckListModelZoneIntegration, AddCardToCardNamedZoneDoesNotRecurse) +{ + QSharedPointer deck(new DeckList()); + DeckListModel model(nullptr, deck); + auto *tree = deck->getTree(); + + tree->addCard("Lightning Bolt", 2, DECK_ZONE_MAIN, -1); + + QModelIndex added = model.addCard(ExactCard(CardInfo::newInstance("Swords to Plowshares")), "Lightning Bolt"); + ASSERT_TRUE(added.isValid()); +} + +// Regression: adding to a custom zone that holds a nested sub-zone mirrored the +// nested cards as flattened shadow rows, so the sorted shadow row index pointed +// past the deck zone's direct children. The card must be appended to the deck +// zone instead of being written out of range. +TEST(DeckListModelZoneIntegration, AddCardToCustomZoneWithNestedSubZoneAppends) +{ + QSharedPointer deck(new DeckList()); + DeckListModel model(nullptr, deck); + auto *tree = deck->getTree(); + + auto *removal = tree->addCustomZone(DECK_ZONE_MAIN, "Removal"); + ASSERT_NE(removal, nullptr); + auto *deeper = new InnerDecklistNode("Deeper", removal); + new DecklistCardNode("Lightning Bolt", 2, deeper, -1); + model.rebuildTree(); + + QModelIndex added = model.addCard(ExactCard(CardInfo::newInstance("Swords to Plowshares")), "Removal"); + ASSERT_TRUE(added.isValid()); + ASSERT_TRUE(added.parent().data(DeckRoles::IsCustomZoneRole).toBool()); +} + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/deck_list_zones/CMakeLists.txt b/tests/deck_list_zones/CMakeLists.txt new file mode 100644 index 000000000..0710be94d --- /dev/null +++ b/tests/deck_list_zones/CMakeLists.txt @@ -0,0 +1,10 @@ +add_executable(deck_list_zones_test deck_list_zones_test.cpp) + +if(NOT GTEST_FOUND) + add_dependencies(deck_list_zones_test gtest) +endif() + +target_link_libraries( + deck_list_zones_test libcockatrice_deck_list Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} +) +add_test(NAME deck_list_zones_test COMMAND deck_list_zones_test) diff --git a/tests/deck_list_zones/deck_list_zones_test.cpp b/tests/deck_list_zones/deck_list_zones_test.cpp new file mode 100644 index 000000000..801f226a9 --- /dev/null +++ b/tests/deck_list_zones/deck_list_zones_test.cpp @@ -0,0 +1,392 @@ +/** + * @file deck_list_zones_test.cpp + * @brief Tests for custom deck zones (deck-unique zones nested under a board zone). + * + * Custom zones allow players to organize cards within a board (e.g. "Removal" under + * the mainboard) without changing the board semantics: cards in a custom zone under + * "main" are still mainboard cards for hashing, sideboard size, legality and export. + */ + +#include +#include +#include +#include +#include +#include + +namespace +{ + +/** + * @brief Collects (board zone name, card node) pairs via forEachCard. + */ +struct BoardCardPair +{ + QString boardZone; + QString cardName; + int amount; +}; + +QList collectBoardCardPairs(const DeckList &deck) +{ + QList result; + deck.forEachCard([&result](InnerDecklistNode *boardZone, DecklistCardNode *card) { + result.append({boardZone->getName(), card->getName(), card->getNumber()}); + }); + return result; +} + +bool hasPair(const QList &pairs, const QString &boardZone, const QString &cardName) +{ + for (const auto &pair : pairs) { + if (pair.boardZone == boardZone && pair.cardName == cardName) { + return true; + } + } + return false; +} + +int totalCards(const QList &pairs) +{ + int total = 0; + for (const auto &pair : pairs) { + total += pair.amount; + } + return total; +} + +} // namespace + +// ===================================================================================================================== +// Zone creation +// ===================================================================================================================== + +TEST(DeckListZones, AddCustomZoneNestsUnderBoard) +{ + DeckList deck; + auto *tree = deck.getTree(); + + auto *zone = tree->addCustomZone(DECK_ZONE_MAIN, "Removal"); + ASSERT_NE(zone, nullptr); + EXPECT_EQ(zone->getName(), QString("Removal")); + ASSERT_NE(zone->getParent(), nullptr); + EXPECT_EQ(zone->getParent()->getName(), QString(DECK_ZONE_MAIN)); + + // The custom zone is nested, not a new top-level zone. + auto topLevelZones = tree->getZoneNodes(); + QStringList topLevelNames; + for (auto *node : topLevelZones) { + topLevelNames.append(node->getName()); + } + EXPECT_FALSE(topLevelNames.contains("Removal")); + + // It is discoverable through the board zone. + auto customZones = tree->getCustomZones(DECK_ZONE_MAIN); + ASSERT_EQ(customZones.size(), 1); + EXPECT_EQ(customZones.first()->getName(), QString("Removal")); +} + +TEST(DeckListZones, CustomZoneNamesAreDeckUnique) +{ + DeckList deck; + auto *tree = deck.getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + // Same name on a different board is rejected. + EXPECT_EQ(tree->addCustomZone(DECK_ZONE_SIDE, "Removal"), nullptr); + // A name that collides with a built-in board zone is rejected. + EXPECT_EQ(tree->addCustomZone(DECK_ZONE_MAIN, DECK_ZONE_MAIN), nullptr); + EXPECT_EQ(tree->addCustomZone(DECK_ZONE_MAIN, DECK_ZONE_SIDE), nullptr); + EXPECT_EQ(tree->addCustomZone(DECK_ZONE_MAIN, DECK_ZONE_MAYBEBOARD), nullptr); + EXPECT_EQ(tree->addCustomZone(DECK_ZONE_MAIN, DECK_ZONE_TOKENS), nullptr); +} + +TEST(DeckListZones, AddCustomZoneUnknownBoardFails) +{ + DeckList deck; + auto *tree = deck.getTree(); + + EXPECT_EQ(tree->addCustomZone("not_a_board", "Removal"), nullptr); +} + +TEST(DeckListZones, MaybeboardIsLazilyCreated) +{ + DeckList deck; + auto *tree = deck.getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAYBEBOARD, "Candidates"), nullptr); + + // The maybeboard board zone now exists, with the custom zone nested inside. + auto customZones = tree->getCustomZones(DECK_ZONE_MAYBEBOARD); + ASSERT_EQ(customZones.size(), 1); + EXPECT_EQ(customZones.first()->getName(), QString("Candidates")); +} + +// ===================================================================================================================== +// Card placement +// ===================================================================================================================== + +TEST(DeckListZones, AddCardToCustomZoneKeepsBoardSemantics) +{ + DeckList deck; + auto *tree = deck.getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + tree->addCard("Lightning Bolt", 4, "Removal", -1); + + // The card is reported as a mainboard card. + auto pairs = collectBoardCardPairs(deck); + EXPECT_TRUE(hasPair(pairs, DECK_ZONE_MAIN, "Lightning Bolt")); + EXPECT_FALSE(hasPair(pairs, DECK_ZONE_SIDE, "Lightning Bolt")); + + // It is physically nested inside the custom zone. + auto customZones = tree->getCustomZones(DECK_ZONE_MAIN); + ASSERT_EQ(customZones.size(), 1); + ASSERT_EQ(customZones.first()->size(), 1); + auto *card = dynamic_cast(customZones.first()->at(0)); + ASSERT_NE(card, nullptr); + EXPECT_EQ(card->getName(), QString("Lightning Bolt")); + EXPECT_EQ(card->getNumber(), 4); + + // Zone-scoped queries include it. + EXPECT_TRUE(deck.getCardList({DECK_ZONE_MAIN}).contains("Lightning Bolt")); + EXPECT_FALSE(deck.getCardList({DECK_ZONE_SIDE}).contains("Lightning Bolt")); + EXPECT_EQ(deck.getCardNodes({DECK_ZONE_MAIN}).size(), 1); +} + +TEST(DeckListZones, LegacyTopLevelZoneStillWorks) +{ + DeckList deck; + auto *tree = deck.getTree(); + + // Unknown zone names create a legacy top-level zone (backwards compatibility). + tree->addCard("Legacy Card", 2, "custom_legacy_zone", -1); + + auto pairs = collectBoardCardPairs(deck); + EXPECT_TRUE(hasPair(pairs, "custom_legacy_zone", "Legacy Card")); + EXPECT_EQ(deck.getCardList({}).count("Legacy Card"), 1); +} + +// ===================================================================================================================== +// Zone management +// ===================================================================================================================== + +TEST(DeckListZones, RenameCustomZone) +{ + DeckList deck; + auto *tree = deck.getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + tree->addCard("Lightning Bolt", 2, "Removal", -1); + + EXPECT_TRUE(tree->renameCustomZone("Removal", "Bolt Zone")); + EXPECT_TRUE(hasPair(collectBoardCardPairs(deck), DECK_ZONE_MAIN, "Lightning Bolt")); + EXPECT_EQ(tree->getCustomZones(DECK_ZONE_MAIN).size(), 1); + EXPECT_EQ(tree->getCustomZones(DECK_ZONE_MAIN).first()->getName(), QString("Bolt Zone")); + + // Renaming to a taken name fails. + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Other"), nullptr); + EXPECT_FALSE(tree->renameCustomZone("Bolt Zone", "Other")); + // Renaming a nonexistent zone fails. + EXPECT_FALSE(tree->renameCustomZone("Ghost Zone", "Whatever")); +} + +TEST(DeckListZones, MoveCustomZoneMovesCards) +{ + DeckList deck; + auto *tree = deck.getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + tree->addCard("Lightning Bolt", 2, "Removal", -1); + + EXPECT_TRUE(tree->moveCustomZone("Removal", DECK_ZONE_SIDE)); + + auto pairs = collectBoardCardPairs(deck); + EXPECT_TRUE(hasPair(pairs, DECK_ZONE_SIDE, "Lightning Bolt")); + EXPECT_FALSE(hasPair(pairs, DECK_ZONE_MAIN, "Lightning Bolt")); + + // The custom zone is now nested under side. + EXPECT_EQ(tree->getCustomZones(DECK_ZONE_MAIN).size(), 0); + EXPECT_EQ(tree->getCustomZones(DECK_ZONE_SIDE).size(), 1); + + // Moving to an unknown board fails. + EXPECT_FALSE(tree->moveCustomZone("Removal", "not_a_board")); +} + +TEST(DeckListZones, MoveCustomZoneFailsForUnknownBoard) +{ + DeckList deck; + auto *tree = deck.getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + tree->addCard("Lightning Bolt", 2, "Removal", -1); + + EXPECT_FALSE(tree->moveCustomZone("Removal", "not_a_board")); + + // The zone is still under main. + EXPECT_EQ(tree->getCustomZones(DECK_ZONE_MAIN).size(), 1); +} + +// Regression: findCustomZoneByName walks every top-level zone, so a custom zone +// an imported deck carries under a non-standard board (tokens) is still found and +// movable. The pre-fix manager-level moveCustomZone only scanned the standard +// boards and returned false for these with no feedback. +TEST(DeckListZones, MoveCustomZoneNestedUnderTokensBoard) +{ + DeckList deck; + auto *tree = deck.getTree(); + auto *root = tree->getRoot(); + + auto *tokens = new InnerDecklistNode(DECK_ZONE_TOKENS, root); + auto *removal = new InnerDecklistNode("Removal", tokens); + new DecklistCardNode("Lightning Bolt", 2, removal, -1); + + EXPECT_TRUE(tree->findCustomZoneByName("Removal")); + EXPECT_TRUE(tree->moveCustomZone("Removal", DECK_ZONE_SIDE)); + + auto pairs = collectBoardCardPairs(deck); + EXPECT_FALSE(hasPair(pairs, DECK_ZONE_TOKENS, "Lightning Bolt")); + EXPECT_TRUE(hasPair(pairs, DECK_ZONE_SIDE, "Lightning Bolt")); +} + +TEST(DeckListZones, RemoveCustomZoneRemovesCards) +{ + DeckList deck; + auto *tree = deck.getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + tree->addCard("Lightning Bolt", 2, "Removal", -1); + + EXPECT_TRUE(tree->removeCustomZone("Removal")); + EXPECT_EQ(tree->getCustomZones(DECK_ZONE_MAIN).size(), 0); + EXPECT_TRUE(deck.getCardList({DECK_ZONE_MAIN}).isEmpty()); + EXPECT_FALSE(tree->removeCustomZone("Removal")); +} + +TEST(DeckListZones, EmptyCustomZoneIsKeptOnCardDeletion) +{ + DeckList deck; + auto *tree = deck.getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + auto *card = tree->addCard("Lightning Bolt", 1, "Removal", -1); + + // Deleting the last card must not delete the empty custom zone. + EXPECT_TRUE(tree->deleteNode(card)); + EXPECT_EQ(tree->getCustomZones(DECK_ZONE_MAIN).size(), 1); +} + +// ===================================================================================================================== +// Deck-wide behavior +// ===================================================================================================================== + +TEST(DeckListZones, HashCountsCustomZoneCardsByBoard) +{ + // Deck A: cards directly in main and side. + DeckList direct; + direct.addCard("Mountain", DECK_ZONE_MAIN); + direct.addCard("Lightning Bolt", DECK_ZONE_MAIN); + direct.addCard("Island", DECK_ZONE_SIDE); + + // Deck B: identical, but organized in custom zones. + DeckList organized; + auto *tree = organized.getTree(); + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Lands"), nullptr); + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + ASSERT_NE(tree->addCustomZone(DECK_ZONE_SIDE, "Side Tech"), nullptr); + tree->addCard("Mountain", 1, "Lands", -1); + tree->addCard("Lightning Bolt", 1, "Removal", -1); + tree->addCard("Island", 1, "Side Tech", -1); + + EXPECT_EQ(direct.getDeckHash(), organized.getDeckHash()); +} + +TEST(DeckListZones, SideboardSizeCountsCustomZoneCards) +{ + DeckList deck; + auto *tree = deck.getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_SIDE, "Side Tech"), nullptr); + tree->addCard("Island", 3, "Side Tech", -1); + tree->addCard("Forest", 2, DECK_ZONE_SIDE, -1); + + EXPECT_EQ(deck.getSideboardSize(), 5); +} + +TEST(DeckListZones, PlainExportIncludesMainAndSideCustomZones) +{ + DeckList deck; + auto *tree = deck.getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + ASSERT_NE(tree->addCustomZone(DECK_ZONE_SIDE, "Side Tech"), nullptr); + tree->addCard("Lightning Bolt", 2, "Removal", -1); + tree->addCard("Island", 1, "Side Tech", -1); + + const QString plain = deck.writeToString_Plain(false, false); + EXPECT_TRUE(plain.contains("2 Lightning Bolt")); + EXPECT_TRUE(plain.contains("1 Island")); +} + +TEST(DeckListZones, PlainExportSkipsMaybeboardCustomZones) +{ + DeckList deck; + auto *tree = deck.getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAYBEBOARD, "Candidates"), nullptr); + tree->addCard("Wish Card", 4, "Candidates", -1); + tree->addCard("Mountain", 1, DECK_ZONE_MAIN, -1); + + const QString plain = deck.writeToString_Plain(false, false); + EXPECT_FALSE(plain.contains("Wish Card")); + EXPECT_TRUE(plain.contains("1 Mountain")); +} + +TEST(DeckListZones, NativeRoundTripPreservesCustomZones) +{ + DeckList deck; + auto *tree = deck.getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + ASSERT_NE(tree->addCustomZone(DECK_ZONE_SIDE, "Side Tech"), nullptr); + tree->addCard("Lightning Bolt", 2, "Removal", -1); + tree->addCard("Island", 3, "Side Tech", -1); + tree->addCard("Mountain", 1, DECK_ZONE_MAIN, -1); + + // Round-trip through the native format. + DeckList restored(deck.writeToString_Native()); + auto *restoredTree = restored.getTree(); + + EXPECT_EQ(restored.getDeckHash(), deck.getDeckHash()); + EXPECT_EQ(restoredTree->getCustomZones(DECK_ZONE_MAIN).size(), 1); + EXPECT_EQ(restoredTree->getCustomZones(DECK_ZONE_MAIN).first()->getName(), QString("Removal")); + EXPECT_EQ(restoredTree->getCustomZones(DECK_ZONE_SIDE).size(), 1); + + auto pairs = collectBoardCardPairs(restored); + EXPECT_TRUE(hasPair(pairs, DECK_ZONE_MAIN, "Lightning Bolt")); + EXPECT_TRUE(hasPair(pairs, DECK_ZONE_SIDE, "Island")); + EXPECT_TRUE(hasPair(pairs, DECK_ZONE_MAIN, "Mountain")); + EXPECT_EQ(totalCards(pairs), 6); +} + +TEST(DeckListZones, MaybeboardCustomZoneCardsAreExcludedFromHash) +{ + // Maybeboard cards are editor-only and must never affect the deck hash. + DeckList deck; + auto *tree = deck.getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAYBEBOARD, "Candidates"), nullptr); + tree->addCard("Wish Card", 4, "Candidates", -1); + tree->addCard("Mountain", 1, DECK_ZONE_MAIN, -1); + + DeckList expected; + expected.addCard("Mountain", DECK_ZONE_MAIN); + + EXPECT_EQ(deck.getDeckHash(), expected.getDeckHash()); +} + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/lag_monitor_test.cpp b/tests/lag_monitor_test.cpp new file mode 100644 index 000000000..9948eeabb --- /dev/null +++ b/tests/lag_monitor_test.cpp @@ -0,0 +1,103 @@ +#include "client/lag_monitor.h" + +#include +#include +#include +#include +#include + +namespace +{ + +/// Timestamps are taken at recording time; allow generous scheduler slack. +constexpr qint64 TIMESTAMP_SLACK_MS = 10000; + +} // namespace + +class LagMonitorTest : public ::testing::Test +{ +protected: + LagMonitor monitor; +}; + +TEST_F(LagMonitorTest, GapAtOrBelowThresholdIsIgnored) +{ + monitor.recordGap(0); + monitor.recordGap(LagMonitor::TICK_INTERVAL_MS); + monitor.recordGap(LagMonitor::STALL_THRESHOLD_MS); + + EXPECT_TRUE(monitor.recentStalls().isEmpty()); +} + +TEST_F(LagMonitorTest, GapAboveThresholdIsRecorded) +{ + monitor.recordGap(LagMonitor::STALL_THRESHOLD_MS + 1); + + const QList stalls = monitor.recentStalls(); + ASSERT_EQ(1, stalls.size()); + EXPECT_EQ(LagMonitor::STALL_THRESHOLD_MS + 1, stalls.first().durationMs); +} + +TEST_F(LagMonitorTest, RecordedTimestampIsFresh) +{ + monitor.recordGap(LagMonitor::STALL_THRESHOLD_MS + 1); + + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + ASSERT_EQ(1, monitor.recentStalls().size()); + EXPECT_LE(qAbs(monitor.recentStalls().first().timestampMsSinceEpoch - now), TIMESTAMP_SLACK_MS); +} + +TEST_F(LagMonitorTest, RecordsAreTrimmedToMaxOldestFirst) +{ + for (int i = 0; i < LagMonitor::MAX_RECORDED_STALLS + 5; ++i) { + monitor.recordGap(LagMonitor::STALL_THRESHOLD_MS + 1 + i); + } + + const QList stalls = monitor.recentStalls(); + ASSERT_EQ(LagMonitor::MAX_RECORDED_STALLS, stalls.size()); + EXPECT_EQ(LagMonitor::STALL_THRESHOLD_MS + 6, stalls.first().durationMs); + EXPECT_EQ(LagMonitor::STALL_THRESHOLD_MS + 5 + LagMonitor::MAX_RECORDED_STALLS, stalls.last().durationMs); +} + +TEST_F(LagMonitorTest, GapAtPlausibilityCapIsKept) +{ + monitor.recordGap(LagMonitor::MAX_PLAUSIBLE_STALL_MS); + + ASSERT_EQ(1, monitor.recentStalls().size()); + EXPECT_EQ(LagMonitor::MAX_PLAUSIBLE_STALL_MS, monitor.recentStalls().first().durationMs); +} + +TEST_F(LagMonitorTest, GapBeyondPlausibilityCapIsDropped) +{ + monitor.recordGap(LagMonitor::MAX_PLAUSIBLE_STALL_MS + 1); + + EXPECT_TRUE(monitor.recentStalls().isEmpty()); +} + +TEST_F(LagMonitorTest, ClearStallsEmptiesList) +{ + monitor.recordGap(LagMonitor::STALL_THRESHOLD_MS + 1); + ASSERT_EQ(1, monitor.recentStalls().size()); + + monitor.clearStalls(); + + EXPECT_TRUE(monitor.recentStalls().isEmpty()); +} + +TEST_F(LagMonitorTest, ApplicationStateChangeDoesNotRecordAStall) +{ + QObject probe; + QEvent event(QEvent::ApplicationStateChange); + + QCoreApplication::sendEvent(&probe, &event); + + EXPECT_TRUE(monitor.recentStalls().isEmpty()); +} + +int main(int argc, char **argv) +{ + QLoggingCategory::setFilterRules("lag_monitor.*=false"); + QCoreApplication app(argc, argv); + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/latency_tracker_test.cpp b/tests/latency_tracker_test.cpp new file mode 100644 index 000000000..68631b7ca --- /dev/null +++ b/tests/latency_tracker_test.cpp @@ -0,0 +1,149 @@ +#include +#include + +TEST(LatencyTrackerTest, EmptyTrackerYieldsZeroedStats) +{ + LatencyTracker tracker; + + const auto stats = tracker.stats(); + + EXPECT_EQ(0, stats.sampleCount); + EXPECT_EQ(0, stats.lastMs); + EXPECT_EQ(0, stats.medianMs); + EXPECT_EQ(0, stats.p95Ms); + EXPECT_EQ(0, stats.maxMs); +} + +TEST(LatencyTrackerTest, SingleSampleIsEveryStatistic) +{ + LatencyTracker tracker; + tracker.addSample(42); + + const auto stats = tracker.stats(); + + EXPECT_EQ(1, stats.sampleCount); + EXPECT_EQ(42, stats.lastMs); + EXPECT_EQ(42, stats.medianMs); + EXPECT_EQ(42, stats.p95Ms); + EXPECT_EQ(42, stats.maxMs); +} + +TEST(LatencyTrackerTest, OddSizedWindowMedianAndP95) +{ + LatencyTracker tracker; + for (qint64 ms : {qint64(50), qint64(10), qint64(30), qint64(20), qint64(40)}) { + tracker.addSample(ms); + } + + const auto stats = tracker.stats(); + + EXPECT_EQ(5, stats.sampleCount); + EXPECT_EQ(40, stats.lastMs); + EXPECT_EQ(30, stats.medianMs); + // nearest-rank p95 of 5 samples: ceil(4.75) = 5th smallest + EXPECT_EQ(50, stats.p95Ms); + EXPECT_EQ(50, stats.maxMs); +} + +TEST(LatencyTrackerTest, EvenSizedWindowMedianIsAverageOfMiddleTwo) +{ + LatencyTracker tracker; + for (qint64 ms : {qint64(10), qint64(20), qint64(30), qint64(40)}) { + tracker.addSample(ms); + } + + const auto stats = tracker.stats(); + + EXPECT_EQ(4, stats.sampleCount); + EXPECT_EQ(25, stats.medianMs); + // nearest-rank p95 of 4 samples: ceil(3.8) = 4th smallest + EXPECT_EQ(40, stats.p95Ms); +} + +TEST(LatencyTrackerTest, WindowEvictsOldestSamples) +{ + LatencyTracker tracker; + for (int i = 0; i <= 99; ++i) { + tracker.addSample(i); + } + + const auto stats = tracker.stats(); + + EXPECT_EQ(LatencyTracker::WindowSize, stats.sampleCount); + EXPECT_EQ(99, stats.lastMs); + EXPECT_EQ(99, stats.maxMs); + // window now contains 36..99 (64 samples) + EXPECT_EQ(67, stats.medianMs); // (67 + 68) / 2 with integer division + EXPECT_EQ(96, stats.p95Ms); // ceil(0.95 * 64) - 1 = index 60 -> 36 + 60 +} + +TEST(LatencyTrackerTest, ClearResetsAllState) +{ + LatencyTracker tracker; + for (int i = 0; i <= 99; ++i) { + tracker.addSample(i); + } + + tracker.clear(); + const auto cleared = tracker.stats(); + EXPECT_EQ(0, cleared.sampleCount); + + tracker.addSample(7); + const auto stats = tracker.stats(); + EXPECT_EQ(1, stats.sampleCount); + EXPECT_EQ(7, stats.lastMs); + EXPECT_EQ(7, stats.medianMs); +} + +TEST(LatencyTrackerTest, LastSampleSurvivesWraparound) +{ + LatencyTracker tracker; + for (int i = 0; i < LatencyTracker::WindowSize; ++i) { + tracker.addSample(i); + } + tracker.addSample(1000); + + EXPECT_EQ(1000, tracker.stats().lastMs); +} + +TEST(LatencyTrackerTest, RecentSamplesAreChronologicalOldestFirst) +{ + LatencyTracker tracker; + for (qint64 ms : {qint64(50), qint64(10), qint64(30)}) { + tracker.addSample(ms); + } + + const QList samples = tracker.recentSamples(); + + EXPECT_EQ((QList{50, 10, 30}), samples); +} + +TEST(LatencyTrackerTest, RecentSamplesFollowRingBufferWraparound) +{ + LatencyTracker tracker; + for (int i = 0; i <= 99; ++i) { + tracker.addSample(i); + } + + const QList samples = tracker.recentSamples(); + + ASSERT_EQ(LatencyTracker::WindowSize, samples.size()); + EXPECT_EQ(36, samples.first()); + EXPECT_EQ(99, samples.last()); +} + +TEST(LatencyTrackerTest, RecentSamplesEmptyOnFreshAndClearedTracker) +{ + LatencyTracker tracker; + EXPECT_TRUE(tracker.recentSamples().isEmpty()); + + tracker.addSample(5); + tracker.clear(); + EXPECT_TRUE(tracker.recentSamples().isEmpty()); +} + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/loader_local_matching_test.cpp b/tests/loader_local_matching_test.cpp new file mode 100644 index 000000000..7ffa7157d --- /dev/null +++ b/tests/loader_local_matching_test.cpp @@ -0,0 +1,187 @@ +#include "client/settings/cache_settings.h" +#include "interface/card_picture_loader/card_picture_loader_local.h" + +#include "gtest/gtest.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + +/** + * @brief Builds an ExactCard with the requested identity fields. + * + * Mirrors how the client constructs cards: the set short name feeds tryLoad()'s + * setName, and the "num" printing property feeds the collector number. + */ +ExactCard cardFor(const QString &name, const QString &setShortName, const QString &collectorNumber) +{ + CardSetPtr set; + if (!setShortName.isEmpty()) { + set = CardSet::newInstance(new NoopCardSetPriorityController(), setShortName, setShortName); + } + + LazyPropertiesHash properties; + if (!collectorNumber.isEmpty()) { + properties.insert("num", collectorNumber); + } + + return ExactCard(CardInfo::newInstance(name), PrintingInfo(set, properties)); +} + +class LocalMatcherTest : public ::testing::Test +{ +protected: + QTemporaryDir tempDir; ///< Sandboxed "pics" root for every test. + CardPictureLoaderLocal *loader = nullptr; ///< Constructed per test against the sandboxed paths. + + QString picsPath() const + { + return tempDir.path() + "/pics"; + } + + void SetUp() override + { + // The loader ctor snapshots the global picture paths once, so point them at the + // sandbox before constructing it. + SettingsCache::instance().paths().setPicsPath(picsPath()); + SettingsCache::instance().paths().setCustomPicsPath(picsPath() + "/CUSTOM/"); + + loader = new CardPictureLoaderLocal(nullptr); + } + + void TearDown() override + { + delete loader; + loader = nullptr; + } + + /** + * @brief Writes a valid 1x1 PNG under the sandboxed pics path. + */ + void writePngUnderPics(const QString &relativePath, const QColor &color = Qt::red) + { + const QString fullPath = picsPath() + "/" + relativePath; + ASSERT_TRUE(QDir().mkpath(QFileInfo(fullPath).absolutePath())); + + QImage image(1, 1, QImage::Format_RGB32); + image.fill(color); + + QImageWriter writer(fullPath, "PNG"); + ASSERT_TRUE(writer.write(image)); + } +}; + +TEST_F(LocalMatcherTest, ExactMatchBareFileWinsOverSuffixedCompanion) +{ + writePngUnderPics("downloadedPics/TestCard.png"); + writePngUnderPics("downloadedPics/TestCard (1).png"); + + const QImage image = loader->tryLoad(cardFor("TestCard", "", "")); + + EXPECT_FALSE(image.isNull()) << "The bare TestCard.png must be picked over its suffixed companion"; +} + +TEST_F(LocalMatcherTest, SuffixedFileWithoutExactMatchIsNotLoaded) +{ + // The pre-refactor prefix match would have accepted "TestCard (1).png" for "TestCard". + writePngUnderPics("downloadedPics/TestCard (1).png"); + + const QImage image = loader->tryLoad(cardFor("TestCard", "", "")); + + EXPECT_TRUE(image.isNull()) << "A suffixed file must not satisfy an exact card-name lookup"; +} + +TEST_F(LocalMatcherTest, SetFolderLookupIgnoresSuffixedFiles) +{ + writePngUnderPics("M10/TestCard (1).png"); + + const QImage image = loader->tryLoad(cardFor("TestCard", "M10", "")); + + EXPECT_TRUE(image.isNull()) << "Set-folder lookups must also require an exact name match"; +} + +TEST_F(LocalMatcherTest, SetFolderLookupStillResolvesExactFile) +{ + writePngUnderPics("M10/TestCard.png"); + writePngUnderPics("M10/TestCard (1).png"); + + const QImage image = loader->tryLoad(cardFor("TestCard", "M10", "")); + + EXPECT_FALSE(image.isNull()) << "The exact file in the set folder must still resolve"; +} + +TEST_F(LocalMatcherTest, RootDownloadedPicsFallbackResolvesSchemeFilename) +{ + // Non-set-folder export schemes (Name_Set_Collector) write straight into downloadedPics/. + writePngUnderPics("downloadedPics/TestCard_M10_1.png"); + + const QImage image = loader->tryLoad(cardFor("TestCard", "M10", "1")); + + EXPECT_FALSE(image.isNull()) << "downloadedPics/TestCard_M10_1.png must resolve via the root fallback"; +} + +TEST_F(LocalMatcherTest, RootFallbackResolvesDashSeparatedVariant) +{ + writePngUnderPics("downloadedPics/TestCard-M10-1.png"); + + const QImage image = loader->tryLoad(cardFor("TestCard", "M10", "1")); + + EXPECT_FALSE(image.isNull()) << "The dash-separated import variant must resolve via the root fallback"; +} + +TEST_F(LocalMatcherTest, DownloadedPicsSetSubfolderStillResolves) +{ + writePngUnderPics("downloadedPics/M10/TestCard_M10_1.png"); + + const QImage image = loader->tryLoad(cardFor("TestCard", "M10", "1")); + + EXPECT_FALSE(image.isNull()) << "The set-subfolder export scheme must keep resolving"; +} + +TEST_F(LocalMatcherTest, SetFolderCandidateTakesPrecedenceOverRootFallback) +{ + writePngUnderPics("M10/TestCard_M10_1.png", Qt::red); + writePngUnderPics("downloadedPics/TestCard_M10_1.png", Qt::blue); + + const QImage image = loader->tryLoad(cardFor("TestCard", "M10", "1")); + + ASSERT_FALSE(image.isNull()); + EXPECT_EQ(image.pixelColor(0, 0), QColor(Qt::red)) << "The set-folder candidate must be preferred"; +} + +} // namespace + +int main(int argc, char **argv) +{ + // Redirect SettingsCache reads/writes (app-data location) away from the real user profile. + QStandardPaths::setTestModeEnabled(true); + + // Some CI containers run as a uid without a passwd entry (e.g. GitHub's docker + // runner), so HOME resolves to "/" and the test-mode qttest data dir cannot be + // created. SettingsCache's QSettings then silently drops every write, reads come + // back empty, and the paths the loader searches are "". Give the test a writable + // HOME for the duration of the run so settings behave like on a normal machine. + QTemporaryDir home; + if (home.isValid()) { + qputenv("HOME", home.path().toLocal8Bit()); + } + + QCoreApplication app(argc, argv); + QLoggingCategory::setFilterRules("card_picture_loader.*=false\nsettings_cache.*=false"); + + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} \ No newline at end of file diff --git a/tests/metrics_registry_test.cpp b/tests/metrics_registry_test.cpp new file mode 100644 index 000000000..1abef58e1 --- /dev/null +++ b/tests/metrics_registry_test.cpp @@ -0,0 +1,83 @@ +#include +#include +#include +#include + +TEST(MetricsRegistryTest, EmptyRegistryHasZeroedCounters) +{ + MetricsRegistry registry; + + EXPECT_EQ(0, registry.totalCommands()); + EXPECT_EQ(0, registry.totalTimeMs()); + EXPECT_EQ(0, registry.activeTypeCount()); + EXPECT_EQ(0, registry.getGameStartSnapshot().count); +} + +TEST(MetricsRegistryTest, SampleIsRecordedInTotalsAndSlot) +{ + MetricsRegistry registry; + registry.observeCommand(MetricsRegistry::typeIdFor(0, 1000), 7); + + EXPECT_EQ(1, registry.totalCommands()); + EXPECT_EQ(7, registry.totalTimeMs()); + EXPECT_EQ(1, registry.activeTypeCount()); + + const auto stats = registry.collectActiveStats(); + ASSERT_EQ(1, stats.size()); + EXPECT_EQ(MetricsRegistry::typeIdFor(0, 1000), stats[0].typeId); + EXPECT_EQ(1, stats[0].count); + EXPECT_EQ(7, stats[0].totalMs); +} + +TEST(MetricsRegistryTest, KindEncodingSeparatesSameExtensionNumber) +{ + MetricsRegistry registry; + const int sessionPing = MetricsRegistry::typeIdFor(0, 1000); + const int roomLeaveRoom = MetricsRegistry::typeIdFor(1, 1000); + ASSERT_NE(sessionPing, roomLeaveRoom); + + registry.observeCommand(sessionPing, 1); + registry.observeCommand(roomLeaveRoom, 5000); + + EXPECT_EQ(2, registry.activeTypeCount()); +} + +TEST(MetricsRegistryTest, OutOfRangeIdsLandInOverflowSlot) +{ + MetricsRegistry registry; + registry.observeCommand(-1, 4); + registry.observeCommand(MetricsRegistry::MaxTypes + 12345, 4); + + EXPECT_EQ(2, registry.totalCommands()); + EXPECT_EQ(1, registry.activeTypeCount()); // both collapsed into one slot + EXPECT_EQ(8, registry.totalTimeMs()); +} + +TEST(MetricsRegistryTest, NegativeDurationsAreClamped) +{ + MetricsRegistry registry; + registry.observeCommand(MetricsRegistry::typeIdFor(0, 1000), -50); + + EXPECT_EQ(0, registry.totalTimeMs()); +} + +TEST(MetricsRegistryTest, GameStartTrackedSeparatelyFromCommands) +{ + MetricsRegistry registry; + registry.observeGameStartDurationMs(120); + + EXPECT_EQ(0, registry.totalCommands()); + EXPECT_EQ(0, registry.totalTimeMs()); + EXPECT_EQ(0, registry.activeTypeCount()); + + const auto snapshot = registry.getGameStartSnapshot(); + EXPECT_EQ(1, snapshot.count); + EXPECT_EQ(120, snapshot.totalMs); +} + +int main(int argc, char **argv) +{ + QCoreApplication app(argc, argv); + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} \ No newline at end of file diff --git a/tests/movecard_tests/reverse_card_move_test.cpp b/tests/movecard_tests/reverse_card_move_test.cpp index 2231a7e3b..64c93078c 100644 --- a/tests/movecard_tests/reverse_card_move_test.cpp +++ b/tests/movecard_tests/reverse_card_move_test.cpp @@ -1,3 +1,4 @@ +#include "game/game_config.h" #include "game/server_abstract_player.h" #include "game/server_card.h" #include "game/server_cardzone.h" @@ -22,7 +23,21 @@ TEST(ReverseCardMoveTest, MoveCardFromBottomTest) // instantiate a fake server instance FakeServer server; Server_Room room(0, 0, "", "", "", "", false, "", {}, &server); - Server_Game game(user, 1, "", "", 2, QList(), false, false, false, false, false, false, 20, false, &room); + GameConfig config{.creatorInfo = user, + .gameId = 1, + .description = QString(), + .password = QString(), + .maxPlayers = 2, + .gameTypes = QList(), + .onlyBuddies = false, + .onlyRegistered = false, + .spectatorsAllowed = false, + .spectatorsNeedPassword = false, + .spectatorsCanTalk = false, + .spectatorsSeeEverything = false, + .startingLifeTotal = 20, + .shareDecklistsOnLoad = false}; + Server_Game game(config, &room); Server_AbstractPlayer player(&game, 1, user, false, nullptr); Server_CardZone deckZone(&player, ZoneNames::DECK, true, ServerInfo_Zone::PublicZone); Server_CardZone exileZone(&player, ZoneNames::EXILE, true, ServerInfo_Zone::PublicZone); diff --git a/tests/oracle/CMakeLists.txt b/tests/oracle/CMakeLists.txt index c5c1e9097..9bc5ee5be 100644 --- a/tests/oracle/CMakeLists.txt +++ b/tests/oracle/CMakeLists.txt @@ -7,3 +7,63 @@ endif() target_link_libraries(parse_cipt_test Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES}) add_test(NAME parse_cipt_test COMMAND parse_cipt_test) + +# Oracle importer unit tests +add_executable( + oracle_importer_test ${VERSION_STRING_CPP} ../../oracle/src/oracleimporter.cpp ../../oracle/src/parsehelpers.cpp + ../../oracle/src/raw_json_scanner.cpp oracle_importer_test.cpp +) + +if(NOT GTEST_FOUND) + add_dependencies(oracle_importer_test gtest) +endif() + +target_link_libraries( + oracle_importer_test libcockatrice_card libcockatrice_interfaces Threads::Threads ${GTEST_BOTH_LIBRARIES} + ${TEST_QT_MODULES} +) + +add_test(NAME oracle_importer_test COMMAND oracle_importer_test) + +# Oracle importer benchmark tests (manual, not run in CI, incl. RAM benchmark) +# Optional compression libs, mirrored from oracle/CMakeLists.txt, so the benchmark +# can download and decompress whatever AllPrintings format the default URL selects. +find_package(ZLIB) +if(ZLIB_FOUND) + add_definitions("-DHAS_ZLIB") + set(_ORACLE_BENCH_EXTRA_SOURCES ../../oracle/src/zip/unzip.cpp ../../oracle/src/zip/zipglobal.cpp) + set(_ORACLE_BENCH_EXTRA_LIBRARIES ${ZLIB_LIBRARIES}) + include_directories(${ZLIB_INCLUDE_DIRS}) +else() + message(STATUS "Oracle tests: zlib not found; zip download benchmark disabled") +endif() + +find_package(LibLZMA) +if(LIBLZMA_FOUND) + add_definitions("-DHAS_LZMA") + list(APPEND _ORACLE_BENCH_EXTRA_SOURCES ../../oracle/src/lzma/decompress.cpp) + list(APPEND _ORACLE_BENCH_EXTRA_LIBRARIES ${LIBLZMA_LIBRARIES}) + include_directories(${LIBLZMA_INCLUDE_DIRS}) +else() + message(STATUS "Oracle tests: LibLZMA not found; xz download benchmark disabled") +endif() + +add_executable( + oracle_importer_benchmark_test + ${VERSION_STRING_CPP} ../../oracle/src/oracleimporter.cpp ../../oracle/src/parsehelpers.cpp + ../../oracle/src/raw_json_scanner.cpp oracle_importer_benchmark_test.cpp ${_ORACLE_BENCH_EXTRA_SOURCES} +) + +if(NOT GTEST_FOUND) + add_dependencies(oracle_importer_benchmark_test gtest) +endif() + +target_link_libraries( + oracle_importer_benchmark_test + libcockatrice_card + libcockatrice_interfaces + Threads::Threads + ${GTEST_BOTH_LIBRARIES} + ${TEST_QT_MODULES} + ${_ORACLE_BENCH_EXTRA_LIBRARIES} +) diff --git a/tests/oracle/oracle_importer_benchmark_test.cpp b/tests/oracle/oracle_importer_benchmark_test.cpp new file mode 100644 index 000000000..633c9e40a --- /dev/null +++ b/tests/oracle/oracle_importer_benchmark_test.cpp @@ -0,0 +1,578 @@ +#include "../../oracle/src/oracleimporter.h" + +#include "gtest/gtest.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(HAS_LZMA) +#include "../../oracle/src/lzma/decompress.h" +#endif +#if defined(HAS_ZLIB) +#include "../../oracle/src/zip/unzip.h" +#endif +#if defined(Q_OS_MACOS) +#include +#include +#endif + +// Helper: build a synthetic MTGJSON-style JSON with the given number of sets and cards per set +static QByteArray buildSyntheticData(int numSets, int cardsPerSet) +{ + QJsonObject dataObj; + for (int s = 0; s < numSets; ++s) { + QJsonArray cardsArray; + for (int c = 0; c < cardsPerSet; ++c) { + QJsonObject card; + card["name"] = QString("Card %1").arg(s * cardsPerSet + c); + card["text"] = "This is a test card with some rules text."; + card["layout"] = "normal"; + card["manaCost"] = "{W}"; + card["type"] = "Creature — Human"; + card["power"] = "2"; + card["toughness"] = "2"; + card["colors"] = QJsonArray{"W"}; + card["colorIdentity"] = QJsonArray{"W"}; + card["types"] = QJsonArray{"Creature"}; + // Real MTGJSON types: floats and booleans, not strings. This + // exercises the QVariant coercion in the property reader. + card["convertedManaCost"] = 1.0; + card["manaValue"] = 1.0; + card["isOnlineOnly"] = false; + card["isRebalanced"] = false; + + QJsonObject legalities; + legalities["standard"] = "legal"; + legalities["modern"] = "legal"; + legalities["legacy"] = "legal"; + legalities["vintage"] = "legal"; + legalities["commander"] = "legal"; + card["legalities"] = legalities; + + QJsonObject identifiers; + identifiers["scryfallId"] = QString("id-%1-%2").arg(s).arg(c); + card["identifiers"] = identifiers; + + // In AllPrintings, number and rarity are flat fields on the card + // object, exactly as set below. + card["number"] = QString::number(c + 1); + card["rarity"] = "common"; + + cardsArray.append(card); + } + + QJsonObject setObj; + setObj["code"] = QString("T%1").arg(s, 2, 10, QChar('0')); + setObj["name"] = QString("Test Set %1").arg(s); + setObj["type"] = "expansion"; + setObj["releaseDate"] = "2024-01-01"; + setObj["cards"] = cardsArray; + + dataObj[QString("T%1").arg(s, 2, 10, QChar('0'))] = setObj; + } + + QJsonObject root; + root["data"] = dataObj; + return QJsonDocument(root).toJson(QJsonDocument::Compact); +} + +// ============================================================================ +// Import throughput benchmark +// ============================================================================ + +TEST(OracleBenchmark, ImportThroughput) +{ + static constexpr int numSets = 10; + static constexpr int cardsPerSet = 500; + + QByteArray data = buildSyntheticData(numSets, cardsPerSet); + + OracleImporter importer; + + // Phase 1: Parse JSON + QElapsedTimer timer; + timer.start(); + bool ok = importer.readSetsFromByteArray(data); + ASSERT_TRUE(ok); + qint64 parseMs = timer.elapsed(); + + // Phase 2: Import cards + timer.restart(); + int importedSets = importer.startImport(); + qint64 importMs = timer.elapsed(); + + int totalImported = 0; + for (const auto &card : importer.getCardList()) { + Q_UNUSED(card); + totalImported++; + } + + // The fixture generates globally unique card names, so the expected + // counts are exact: a regression here means cards were dropped. + ASSERT_EQ(importedSets, numSets); + ASSERT_EQ(totalImported, numSets * cardsPerSet); + // Real-data probe: numeric convertedManaCost must be coerced to text + // (regression for the QJsonValue::toString() reader in #7214). + auto probeCard = importer.getCardList().value("Card 0"); + ASSERT_FALSE(probeCard.isNull()); + ASSERT_EQ(probeCard->getProperty("cmc"), "1"); + + qDebug().noquote() + << QString("Oracle Import Benchmark: %1 sets, %2 unique cards").arg(importedSets).arg(totalImported); + qDebug().noquote() << QString(" JSON parse: %1 ms").arg(parseMs); + qDebug().noquote() << QString(" Card import: %1 ms").arg(importMs); + qDebug().noquote() << QString(" Total: %1 ms").arg(parseMs + importMs); + if (importMs > 0) { + qDebug().noquote() << QString(" Throughput: %1 cards/sec") + .arg(static_cast(totalImported) / importMs * 1000.0, 0, 'f', 0); + } +} + +// ============================================================================ +// readSetsFromByteArray benchmark +// ============================================================================ + +TEST(OracleBenchmark, ParseJsonThroughput) +{ + static constexpr int numSets = 20; + static constexpr int cardsPerSet = 1000; + + QByteArray data = buildSyntheticData(numSets, cardsPerSet); + + // Run 5 iterations and report average + static constexpr int iterations = 5; + qint64 totalMs = 0; + + for (int i = 0; i < iterations; ++i) { + OracleImporter importer; + QByteArray source = data; + QElapsedTimer timer; + timer.start(); + bool ok = importer.readSetsFromByteArray(std::move(source)); + ASSERT_TRUE(ok); + totalMs += timer.elapsed(); + } + + qint64 avgMs = totalMs / iterations; + qDebug().noquote() << QString("Parse Benchmark (%1 iterations): avg %2 ms for %3 sets x %4 cards") + .arg(iterations) + .arg(avgMs) + .arg(numSets) + .arg(cardsPerSet); +} + +// ============================================================================ +// Split card merging benchmark +// ============================================================================ + +TEST(OracleBenchmark, SplitCardMerging) +{ + static constexpr int numSplitCards = 1000; + + QJsonArray cardsList; + for (int i = 0; i < numSplitCards; ++i) { + QJsonObject face1; + face1["name"] = QString("Fire %1 // Ice %1").arg(i); + face1["text"] = "Fire side text."; + face1["layout"] = "split"; + face1["side"] = "a"; + face1["faceName"] = QString("Fire %1").arg(i); + face1["colors"] = QJsonArray{"R"}; + face1["colorIdentity"] = QJsonArray{"R"}; + face1["types"] = QJsonArray{"Instant"}; + face1["manaCost"] = "{R}"; + face1["legalities"] = QJsonObject{{"standard", "not_legal"}}; + face1["identifiers"] = QJsonObject{{"scryfallId", QString("f-%1").arg(i)}}; + face1["number"] = QString::number(i + 1); + face1["rarity"] = "uncommon"; + + QJsonObject face2; + face2["name"] = QString("Fire %1 // Ice %1").arg(i); + face2["text"] = "Ice side text."; + face2["layout"] = "split"; + face2["side"] = "b"; + face2["faceName"] = QString("Ice %1").arg(i); + face2["colors"] = QJsonArray{"U"}; + face2["colorIdentity"] = QJsonArray{"U"}; + face2["types"] = QJsonArray{"Instant"}; + face2["manaCost"] = "{U}"; + face2["legalities"] = QJsonObject{{"standard", "not_legal"}}; + face2["identifiers"] = QJsonObject{{"scryfallId", QString("i-%1").arg(i)}}; + face2["number"] = QString::number(i + 1); + face2["rarity"] = "uncommon"; + + cardsList.append(face1); + cardsList.append(face2); + } + + NoopCardSetPriorityController controller; + OracleImporter importer; + CardSetPtr set = CardSet::newInstance(&controller, "TST", "Split Test"); + + QElapsedTimer timer; + timer.start(); + int count = importer.importCardsFromSet(set, cardsList); + qint64 ms = timer.elapsed(); + + ASSERT_EQ(count, numSplitCards); + qDebug().noquote() << QString("Split Card Merge Benchmark: %1 cards in %2 ms (%3 cards/sec)") + .arg(count) + .arg(ms) + .arg(ms > 0 ? static_cast(count) / ms * 1000.0 : 0.0, 0, 'f', 0); +} + +// ============================================================================ +// sortAndReduceColors microbenchmark +// ============================================================================ + +// We can't call sortAndReduceColors directly (it's static), so we benchmark +// through importCardsFromSet with color properties. + +TEST(OracleBenchmark, ImportCardsWithColors) +{ + static constexpr int numCards = 10000; + + NoopCardSetPriorityController controller; + OracleImporter importer; + CardSetPtr set = CardSet::newInstance(&controller, "TST", "Color Test"); + + QJsonArray cardsList; + for (int i = 0; i < numCards; ++i) { + QJsonObject card; + card["name"] = QString("Color Card %1").arg(i); + card["text"] = "Rules text."; + card["layout"] = "normal"; + card["manaCost"] = "{W}"; + card["type"] = "Creature — Human"; + card["types"] = QJsonArray{"Creature"}; + card["colors"] = QJsonArray{"B", "R", "G", "W", "U"}; + card["colorIdentity"] = QJsonArray{"B", "R", "G", "W", "U"}; + card["number"] = QString::number(i + 1); + card["rarity"] = "common"; + card["legalities"] = QJsonObject{{"standard", "legal"}}; + card["identifiers"] = QJsonObject{{"scryfallId", QString("c-%1").arg(i)}}; + cardsList.append(card); + } + + QElapsedTimer timer; + timer.start(); + int count = importer.importCardsFromSet(set, cardsList); + qint64 ms = timer.elapsed(); + + ASSERT_EQ(count, numCards); + qDebug().noquote() << QString("Import with Colors Benchmark: %1 cards in %2 ms (%3 cards/sec)") + .arg(count) + .arg(ms) + .arg(ms > 0 ? static_cast(count) / ms * 1000.0 : 0.0, 0, 'f', 0); +} + +// ============================================================================ +// RAM usage measurement +// ============================================================================ + +// Mirrors the default AllPrintings URL selection in oracle/src/pages.cpp. +#if defined(HAS_LZMA) +static const QUrl kDefaultAllPrintingsUrl("https://www.mtgjson.com/api/v5/AllPrintings.json.xz"); +#elif defined(HAS_ZLIB) +static const QUrl kDefaultAllPrintingsUrl("https://www.mtgjson.com/api/v5/AllPrintings.json.zip"); +#else +static const QUrl kDefaultAllPrintingsUrl("https://www.mtgjson.com/api/v5/AllPrintings.json"); +#endif + +// Magic bytes also from oracle/src/pages.cpp +static const QByteArray kXzSignature("\xFD\x37\x7A\x58\x5A", 6); +static const QByteArray kZipSignature("PK"); + +struct MemorySnapshot +{ + qint64 peakRssKb = -1; // process high-water mark (VmHWM on Linux, ru_maxrss on macOS) + qint64 rssKb = -1; // current resident set size + bool available = false; + + static MemorySnapshot current() + { + MemorySnapshot snap; +#if defined(Q_OS_LINUX) + QFile statusFile("/proc/self/status"); + if (statusFile.open(QIODevice::ReadOnly | QIODevice::Text)) { + // /proc files report size() == 0, so atEnd() is immediately true: read everything first. + const QList lines = statusFile.readAll().split('\n'); + for (const QByteArray &line : lines) { + if (line.startsWith("VmHWM:")) { + snap.peakRssKb = line.mid(6).trimmed().split(' ').value(0).toLongLong(); + } else if (line.startsWith("VmRSS:")) { + snap.rssKb = line.mid(6).trimmed().split(' ').value(0).toLongLong(); + } + } + snap.available = snap.peakRssKb >= 0; + } +#elif defined(Q_OS_MACOS) + struct rusage usage; + if (getrusage(RUSAGE_SELF, &usage) == 0) { + snap.peakRssKb = usage.ru_maxrss / 1024; // bytes -> kB + snap.available = snap.peakRssKb >= 0; + } + // getrusage has no current-RSS equivalent; task_info's resident_size + // is the closest macOS analog to Linux VmRSS. + mach_task_basic_info info = {}; + mach_msg_type_number_t count = MACH_TASK_BASIC_INFO_COUNT; + if (task_info(mach_task_self(), MACH_TASK_BASIC_INFO, reinterpret_cast(&info), &count) == + KERN_SUCCESS) { + snap.rssKb = info.resident_size / 1024; + } +#endif + return snap; + } +}; + +static QString formatKb(qint64 kb) +{ + if (kb < 0) { + return "N/A"; + } + return QString("%1 MB").arg(kb / 1024.0, 0, 'f', 1); +} + +static void logRamPhase(const QString &phase, const MemorySnapshot &baseline, const MemorySnapshot ¤t) +{ + if (!baseline.available || !current.available) { + qDebug().noquote() << QString(" %1: memory stats unavailable on this platform").arg(phase); + return; + } + // VmHWM / ru_maxrss are monotonically non-decreasing high-water marks, so a + // peak-based delta between phases is ~0.0 MB by construction once the + // fixture build has set the process peak. The live signals are current RSS + // and the process peak; the delta is meaningful only where the baseline was + // taken immediately before the phase it measures (e.g. the import phase, + // which compares afterParse against afterImport). + QString rssDelta = "N/A"; + if (current.rssKb >= 0 && baseline.rssKb >= 0) { + rssDelta = formatKb(current.rssKb - baseline.rssKb); + } + qDebug().noquote() << QString(" %1: current RSS %2 | delta vs baseline %3 | process peak %4") + .arg(phase) + .arg(formatKb(current.rssKb)) + .arg(rssDelta) + .arg(formatKb(current.peakRssKb)); +} + +// Decompresses the download payload when the default URL is a compressed build, +// mirroring the wizard's magic-byte handling in oracle/src/pages.cpp. +static QByteArray decompressSetsData(const QByteArray &payload) +{ + if (payload.startsWith(kXzSignature)) { +#if defined(HAS_LZMA) + QBuffer inBuffer(const_cast(&payload)); + QByteArray out; + QBuffer outBuffer(&out); + inBuffer.open(QIODevice::ReadOnly); + outBuffer.open(QIODevice::WriteOnly); + XzDecompressor xz; + if (!xz.decompress(&inBuffer, &outBuffer)) { + qDebug() << "RAM benchmark: xz decompression failed"; + return {}; + } + return out; +#else + qDebug() << "RAM benchmark: download is xz-compressed but this build has no LZMA support"; + return {}; +#endif + } + if (payload.startsWith(kZipSignature)) { +#if defined(HAS_ZLIB) + QBuffer inBuffer(const_cast(&payload)); + inBuffer.open(QIODevice::ReadOnly); + UnZip unzip; + if (unzip.openArchive(&inBuffer) != UnZip::Ok) { + qDebug() << "RAM benchmark: zip archive open failed"; + return {}; + } + if (unzip.fileList().size() != 1) { + qDebug() << "RAM benchmark: zip archive doesn't contain exactly one file"; + return {}; + } + QByteArray out; + QBuffer outBuffer(&out); + outBuffer.open(QIODevice::WriteOnly); + const auto errorCode = unzip.extractFile(unzip.fileList().value(0), &outBuffer); + unzip.closeArchive(); + if (errorCode != UnZip::Ok) { + qDebug() << "RAM benchmark: zip extraction failed"; + return {}; + } + return out; +#else + qDebug() << "RAM benchmark: download is zip-compressed but this build has no zlib support"; + return {}; +#endif + } + return payload; +} + +TEST(OracleBenchmark, ImportRamUsage) +{ + static constexpr int numSets = 30; + static constexpr int cardsPerSet = 2000; // ~60k cards, roughly AllPrintings scale + + // Baseline must precede the fixture build: a high-water mark set while + // generating the synthetic JSON would otherwise mask the importer phases. + // Where memory stats are unavailable (Windows), skip before doing the + // 60k-card fixture build, which would otherwise be pure wasted work. + const MemorySnapshot baseline = MemorySnapshot::current(); + if (!baseline.available) { + GTEST_SKIP() << "Memory stats unavailable on this platform"; + } + + const QByteArray data = buildSyntheticData(numSets, cardsPerSet); + + // The fixture build leaves freed-but-unreturned arenas behind (current RSS + // rarely falls once glibc allocates). Baseline immediately after it so the + // parse phase measures only the importer's own growth (~40 MB) rather than + // swallowing the fixture builder's spike. + const MemorySnapshot afterFixture = MemorySnapshot::current(); + logRamPhase("fixture build", baseline, afterFixture); + + NoopCardSetPriorityController controller; + OracleImporter importer; + + QElapsedTimer timer; + timer.start(); + ASSERT_TRUE(importer.readSetsFromByteArray(std::move(data))); + const qint64 parseMs = timer.elapsed(); + const MemorySnapshot afterParse = MemorySnapshot::current(); + + timer.restart(); + const int importedSets = importer.startImport(); + const qint64 importMs = timer.elapsed(); + const MemorySnapshot afterImport = MemorySnapshot::current(); + + importer.releaseSetData(); + const MemorySnapshot afterRelease = MemorySnapshot::current(); + + const int totalCards = importer.getCardList().size(); + qDebug().noquote() << QString("Oracle RAM Benchmark (synthetic): %1 sets, %2 cards, %3 MB JSON") + .arg(importedSets) + .arg(totalCards) + .arg(data.size() / (1024.0 * 1024.0), 0, 'f', 1); + qDebug().noquote() << QString(" JSON parse: %1 ms").arg(parseMs); + qDebug().noquote() << QString(" Card import: %1 ms").arg(importMs); + logRamPhase("parse", afterFixture, afterParse); + logRamPhase("import", afterParse, afterImport); + logRamPhase("after releaseSetData()", afterImport, afterRelease); + + // Freeing the parsed tree rarely moves current RSS (allocator reuse), so the + // meaningful signal that release actually dropped the buffers is emptiness, + // not an RSS delta. + ASSERT_TRUE(importer.getSets().isEmpty()); +} + +TEST(OracleBenchmark, ImportRamUsageAllPrintings) +{ + // Only "1" enables the download: unset (the default and the CI setup) and + // an explicit "0" both disable it. + bool envOk = false; + const int enabled = qEnvironmentVariableIntValue("COCKATRICE_ORACLE_RAM_BENCHMARK", &envOk); + if (!envOk || enabled == 0) { + GTEST_SKIP() << "Set COCKATRICE_ORACLE_RAM_BENCHMARK=1 to download the real AllPrintings dataset for this " + "RAM benchmark. Default URL: " + << kDefaultAllPrintingsUrl.toDisplayString().toStdString(); + } + + // Baseline must precede the request so the phase covers the download + + // decompress step, including the payload materialized by readAll(). + const MemorySnapshot baseline = MemorySnapshot::current(); + if (!baseline.available) { + GTEST_SKIP() << "Memory stats unavailable on this platform"; + } + + QNetworkAccessManager nam; + QNetworkRequest request(kDefaultAllPrintingsUrl); + request.setHeader(QNetworkRequest::UserAgentHeader, "Cockatrice Oracle RAM benchmark"); + QNetworkReply *reply = nam.get(request); + + QEventLoop loop; + QTimer timeoutTimer; + timeoutTimer.setSingleShot(true); + bool timedOut = false; + QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); + QObject::connect(&timeoutTimer, &QTimer::timeout, &loop, [&] { + timedOut = true; + reply->abort(); + }); + timeoutTimer.start(10 * 60 * 1000); + loop.exec(); + timeoutTimer.stop(); + + // abort() leaves reply->error() as OperationCanceledError, so a timed-out + // download takes the same GTEST_SKIP path as any other network error + // instead of reading a truncated body and failing the parse below. + if (timedOut || reply->error() != QNetworkReply::NoError) { + GTEST_SKIP() << "Download failed: " << reply->errorString().toStdString(); + } + const QByteArray payload = reply->readAll(); + reply->deleteLater(); + + // mtgjson can answer 200 with an HTML page (mirrors the wizard's '<' check + // in pages.cpp); reject it before trying to decompress/parse. + if (payload.startsWith("<")) { + GTEST_SKIP() << "Download returned a non-JSON body (HTML page instead of data), skipping"; + } + + const QByteArray setsData = decompressSetsData(payload); + const MemorySnapshot afterDownload = MemorySnapshot::current(); + if (setsData.isEmpty()) { + GTEST_SKIP() << "No data to import (download or decompression failed)"; + } + + NoopCardSetPriorityController controller; + OracleImporter importer; + + QElapsedTimer timer; + timer.start(); + ASSERT_TRUE(importer.readSetsFromByteArray(std::move(setsData))); + const qint64 parseMs = timer.elapsed(); + const MemorySnapshot afterParse = MemorySnapshot::current(); + + timer.restart(); + const int importedSets = importer.startImport(); + const qint64 importMs = timer.elapsed(); + const MemorySnapshot afterImport = MemorySnapshot::current(); + + importer.releaseSetData(); + const MemorySnapshot afterRelease = MemorySnapshot::current(); + + const int totalCards = importer.getCardList().size(); + qDebug().noquote() << QString("Oracle RAM Benchmark (real AllPrintings): %1 sets, %2 unique cards") + .arg(importedSets) + .arg(totalCards); + qDebug().noquote() << QString(" URL: %1").arg(kDefaultAllPrintingsUrl.toDisplayString()); + qDebug().noquote() << QString(" Downloaded: %1 MB, decompressed: %2 MB") + .arg(payload.size() / (1024.0 * 1024.0), 0, 'f', 1) + .arg(setsData.size() / (1024.0 * 1024.0), 0, 'f', 1); + qDebug().noquote() << QString(" JSON parse: %1 ms").arg(parseMs); + qDebug().noquote() << QString(" Card import: %1 ms").arg(importMs); + logRamPhase("download+decompress", baseline, afterDownload); + logRamPhase("parse", afterDownload, afterParse); + logRamPhase("import", afterParse, afterImport); + logRamPhase("after releaseSetData()", afterImport, afterRelease); +} + +int main(int argc, char **argv) +{ + // Required for the event loop used by the real-AllPrintings download benchmark + QCoreApplication app(argc, argv); + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/oracle/oracle_importer_test.cpp b/tests/oracle/oracle_importer_test.cpp new file mode 100644 index 000000000..f66616e37 --- /dev/null +++ b/tests/oracle/oracle_importer_test.cpp @@ -0,0 +1,1128 @@ +#include "../../oracle/src/oracleimporter.h" + +#include "gtest/gtest.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class OracleImporterTest : public ::testing::Test +{ +protected: + void SetUp() override + { + controller = new NoopCardSetPriorityController(); + importer = new OracleImporter(); + set = CardSet::newInstance(controller, "TST", "Test Set"); + } + + void TearDown() override + { + delete importer; + delete controller; + } + + // Helper: build a minimal card JSON object + QJsonObject makeCard(const QString &name, + const QString &colors = "", + const QString &colorIdentity = "", + const QVariantMap &legalities = {}) + { + QJsonObject card; + card["name"] = name; + card["text"] = "Rules text."; + card["layout"] = "normal"; + card["manaCost"] = "{W}"; + card["type"] = "Creature — Human"; + card["types"] = QJsonArray{"Creature"}; + card["number"] = "1"; + card["rarity"] = "common"; + + if (!colors.isEmpty()) { + QJsonArray arr; + for (const QChar &c : colors) { + arr.append(QString(c)); + } + card["colors"] = arr; + } + if (!colorIdentity.isEmpty()) { + QJsonArray arr; + for (const QChar &c : colorIdentity) { + arr.append(QString(c)); + } + card["colorIdentity"] = arr; + } + if (!legalities.isEmpty()) { + QJsonObject legalObj; + for (auto it = legalities.constBegin(); it != legalities.constEnd(); ++it) { + legalObj[it.key()] = it.value().toString(); + } + card["legalities"] = legalObj; + } + + QJsonObject identifiers; + identifiers["scryfallId"] = QUuid::createUuid().toString(QUuid::WithoutBraces); + card["identifiers"] = identifiers; + + return card; + } + + // Helper: build a single MTGJSON foreignData entry + QJsonObject makeForeignEntry(const QString &language, const QString &name, const QString &text) + { + QJsonObject entry; + entry["language"] = language; + entry["name"] = name; + if (!text.isEmpty()) { + entry["text"] = text; + } + return entry; + } + + NoopCardSetPriorityController *controller; + OracleImporter *importer; + CardSetPtr set; +}; + +// ============================================================================ +// sortAndReduceColors tests (tested via importCardsFromSet) +// ============================================================================ + +TEST_F(OracleImporterTest, SortAndReduceColorsSingleColor) +{ + QJsonArray cards{makeCard("Red Card", "R", "R")}; + importer->importCardsFromSet(set, cards); + + auto card = importer->getCardList().value("Red Card"); + ASSERT_FALSE(card.isNull()); + ASSERT_EQ(card->getProperty("colors"), "R"); +} + +TEST_F(OracleImporterTest, SortAndReduceColorsDeduplicates) +{ + QJsonArray cards{makeCard("Dedup Card", "WWUUB", "WU")}; + importer->importCardsFromSet(set, cards); + + auto card = importer->getCardList().value("Dedup Card"); + ASSERT_FALSE(card.isNull()); + ASSERT_EQ(card->getProperty("colors"), "WUB"); +} + +TEST_F(OracleImporterTest, SortAndReduceColorsSortsWUBRG) +{ + QJsonArray cards{makeCard("Sort Card", "RGW", "RGW")}; + importer->importCardsFromSet(set, cards); + + auto card = importer->getCardList().value("Sort Card"); + ASSERT_FALSE(card.isNull()); + ASSERT_EQ(card->getProperty("colors"), "WRG"); +} + +TEST_F(OracleImporterTest, SortAndReduceColorsAllFive) +{ + QJsonArray cards{makeCard("Five Color", "BRGWU", "BRGWU")}; + importer->importCardsFromSet(set, cards); + + auto card = importer->getCardList().value("Five Color"); + ASSERT_FALSE(card.isNull()); + ASSERT_EQ(card->getProperty("colors"), "WUBRG"); +} + +TEST_F(OracleImporterTest, SortAndReduceColorIdentity) +{ + QJsonArray cards{makeCard("Color Id Card", "W", "GWR")}; + importer->importCardsFromSet(set, cards); + + auto card = importer->getCardList().value("Color Id Card"); + ASSERT_FALSE(card.isNull()); + ASSERT_EQ(card->getProperty("coloridentity"), "WRG"); +} + +TEST_F(OracleImporterTest, SingleColorNotSorted) +{ + QJsonArray cards{makeCard("Single Card", "B", "B")}; + importer->importCardsFromSet(set, cards); + + auto card = importer->getCardList().value("Single Card"); + ASSERT_FALSE(card.isNull()); + ASSERT_EQ(card->getProperty("colors"), "B"); +} + +// ============================================================================ +// Legality guard tests +// ============================================================================ + +TEST_F(OracleImporterTest, NewCardKeepsLegalityProperties) +{ + // Verifies that format-* properties survive addCard on a fresh card + // (not the combineLegalities guard, which only runs on existing printings). + QVariantMap leg; + leg["standard"] = "legal"; + leg["modern"] = "legal"; + QJsonArray cards{makeCard("Legal Card", "", "", leg)}; + + importer->importCardsFromSet(set, cards); + auto card = importer->getCardList().value("Legal Card"); + ASSERT_FALSE(card.isNull()); + ASSERT_EQ(card->getProperty("format-standard"), "legal"); + ASSERT_EQ(card->getProperty("format-modern"), "legal"); +} + +TEST_F(OracleImporterTest, LegalityMergeAllowedWhenCardHasNoLegalities) +{ + // First printing carries no legalities at all, so the guard's + // `properties.filter(formatRegex).empty()` predicate is true and the + // second printing's legalities must be merged in. + QJsonArray cards1{makeCard("Unmerged Card")}; + importer->importCardsFromSet(set, cards1); + + CardSetPtr set2 = CardSet::newInstance(controller, "TS2", "Second Set"); + QVariantMap leg; + leg["standard"] = "legal"; + QJsonArray cards2{makeCard("Unmerged Card", "", "", leg)}; + importer->importCardsFromSet(set2, cards2); + + auto card = importer->getCardList().value("Unmerged Card"); + ASSERT_FALSE(card.isNull()); + ASSERT_EQ(card->getProperty("format-standard"), "legal"); +} + +TEST_F(OracleImporterTest, LegalityGuardPreservesFirstPrinting) +{ + // First printing: standard=legal, modern=legal + QVariantMap leg1; + leg1["standard"] = "legal"; + leg1["modern"] = "legal"; + QJsonArray cards1{makeCard("Guarded Card", "", "", leg1)}; + importer->importCardsFromSet(set, cards1); + + // Second printing: standard=banned, modern=not_legal + CardSetPtr set2 = CardSet::newInstance(controller, "TS2", "Second Set"); + QVariantMap leg2; + leg2["standard"] = "banned"; + leg2["modern"] = "not_legal"; + QJsonArray cards2{makeCard("Guarded Card", "", "", leg2)}; + importer->importCardsFromSet(set2, cards2); + + auto card = importer->getCardList().value("Guarded Card"); + ASSERT_FALSE(card.isNull()); + // Guard should preserve first printing's legalities + ASSERT_EQ(card->getProperty("format-standard"), "legal"); + ASSERT_EQ(card->getProperty("format-modern"), "legal"); +} + +// ============================================================================ +// createDefaultMagicFormats tests +// ============================================================================ + +TEST_F(OracleImporterTest, CreateDefaultMagicFormatsContainsExpectedFormats) +{ + auto formats = importer->createDefaultMagicFormats(); + ASSERT_TRUE(formats.contains("standard")); + ASSERT_TRUE(formats.contains("modern")); + ASSERT_TRUE(formats.contains("legacy")); + ASSERT_TRUE(formats.contains("vintage")); + ASSERT_TRUE(formats.contains("commander")); + ASSERT_TRUE(formats.contains("pauper")); + ASSERT_TRUE(formats.contains("pioneer")); + ASSERT_TRUE(formats.contains("brawl")); + ASSERT_TRUE(formats.contains("historic")); + ASSERT_TRUE(formats.contains("timeless")); + ASSERT_TRUE(formats.contains("duel")); + ASSERT_TRUE(formats.contains("oathbreaker")); +} + +TEST_F(OracleImporterTest, CreateDefaultMagicFormatsSingletonDeckSizes) +{ + auto formats = importer->createDefaultMagicFormats(); + auto commander = formats.value("commander"); + ASSERT_FALSE(commander.isNull()); + ASSERT_EQ(commander->minDeckSize, 100); + ASSERT_EQ(commander->maxDeckSize, 100); + ASSERT_EQ(commander->maxSideboardSize, 15); + + auto brawl = formats.value("brawl"); + ASSERT_FALSE(brawl.isNull()); + ASSERT_EQ(brawl->minDeckSize, 60); + ASSERT_EQ(brawl->maxDeckSize, 60); +} + +TEST_F(OracleImporterTest, CreateDefaultMagicFormatsVintageHasRestricted) +{ + auto formats = importer->createDefaultMagicFormats(); + auto vintage = formats.value("vintage"); + ASSERT_FALSE(vintage.isNull()); + bool hasRestricted = false; + for (const auto &ac : vintage->allowedCounts) { + if (ac.label == "restricted") { + hasRestricted = true; + ASSERT_EQ(ac.max, 1); + } + } + ASSERT_TRUE(hasRestricted); +} + +TEST_F(OracleImporterTest, CreateDefaultMagicFormatsRegexMatchesBasicLands) +{ + auto formats = importer->createDefaultMagicFormats(); + auto standard = formats.value("standard"); + ASSERT_FALSE(standard.isNull()); + ASSERT_FALSE(standard->exceptions.isEmpty()); + + auto &basicLandsException = standard->exceptions.first(); + ASSERT_FALSE(basicLandsException.conditions.isEmpty()); + + auto &condition = basicLandsException.conditions.first(); + ASSERT_EQ(condition.field, "type"); + ASSERT_EQ(condition.matchType, "regex"); + + // Verify the regex actually works (was broken before: \b = backspace, not word boundary) + QRegularExpression regex(condition.value); + ASSERT_TRUE(regex.isValid()); + ASSERT_TRUE(regex.match("Basic Land — Forest").hasMatch()); + ASSERT_TRUE(regex.match("Basic Snow Land — Mountain").hasMatch()); + ASSERT_FALSE(regex.match("Creature — Elf Warrior").hasMatch()); +} + +TEST_F(OracleImporterTest, CreateDefaultMagicFormatsCaching) +{ + // The memoized map returns the same FormatRulesPtr instances, so the + // shared pointers must be identical across calls. This is the only + // observable effect of the cache: contents would match either way. + auto first = importer->createDefaultMagicFormats(); + auto second = importer->createDefaultMagicFormats(); + ASSERT_EQ(first.value("standard").data(), second.value("standard").data()); +} + +// ============================================================================ +// readSetsFromByteArray tests +// ============================================================================ + +TEST_F(OracleImporterTest, ReadSetsFromByteArrayValidJson) +{ + QJsonObject setObj; + setObj["code"] = "tst"; + setObj["name"] = "Test Set"; + setObj["type"] = "expansion"; + setObj["releaseDate"] = "2024-01-01"; + setObj["cards"] = QJsonArray(); + + QJsonObject root; + root["data"] = QJsonObject{{"TST", setObj}}; + + QByteArray data = QJsonDocument(root).toJson(); + ASSERT_TRUE(importer->readSetsFromByteArray(data)); + ASSERT_EQ(importer->getSets().size(), 1); + ASSERT_EQ(importer->getSets().first().getShortName(), "TST"); +} + +TEST_F(OracleImporterTest, ReadSetsFromByteArrayInvalidJson) +{ + QByteArray data = "not valid json"; + ASSERT_FALSE(importer->readSetsFromByteArray(data)); +} + +TEST_F(OracleImporterTest, ReadSetsFromByteArrayEmptyData) +{ + QJsonObject root; + root["data"] = QJsonObject(); + + QByteArray data = QJsonDocument(root).toJson(); + ASSERT_FALSE(importer->readSetsFromByteArray(data)); +} + +TEST_F(OracleImporterTest, ReadSetsFromByteArrayCapitalizesSetType) +{ + QJsonObject setObj; + setObj["code"] = "ftv"; + setObj["name"] = "From The Vault"; + setObj["type"] = "from_the_vault"; + setObj["releaseDate"] = "2024-01-01"; + setObj["cards"] = QJsonArray(); + + QJsonObject root; + root["data"] = QJsonObject{{"FTV", setObj}}; + + QByteArray data = QJsonDocument(root).toJson(); + ASSERT_TRUE(importer->readSetsFromByteArray(data)); + ASSERT_EQ(importer->getSets().first().getSetType(), "From the Vault"); +} + +TEST_F(OracleImporterTest, ReadSetsFromByteArraySortsSetsByName) +{ + // QJsonObject iterates keys in lexicographic order ("AAA" before "ZZZ"), + // so leaving the natural order matching the alphabetical sort makes the + // assertion pass trivially. Inverting it keeps the sort meaningful: + // iteration yields "AAA" (Zeta Set) first, then the sort by name must + // promote "ZZZ" (Alpha Set) to the front. + QJsonObject setA; + setA["code"] = "aaa"; + setA["name"] = "Zeta Set"; + setA["type"] = "expansion"; + setA["releaseDate"] = "2024-01-01"; + setA["cards"] = QJsonArray(); + + QJsonObject setB; + setB["code"] = "zzz"; + setB["name"] = "Alpha Set"; + setB["type"] = "expansion"; + setB["releaseDate"] = "2024-01-01"; + setB["cards"] = QJsonArray(); + + QJsonObject root; + root["data"] = QJsonObject{{"AAA", setA}, {"ZZZ", setB}}; + + QByteArray data = QJsonDocument(root).toJson(); + ASSERT_TRUE(importer->readSetsFromByteArray(data)); + auto sets = importer->getSets(); + ASSERT_GE(sets.size(), 2); + ASSERT_EQ(sets.first().getShortName(), "ZZZ"); +} + +// ============================================================================ +// Split card coloridentity tests +// ============================================================================ + +TEST_F(OracleImporterTest, SplitCardColorIdentityConcatenated) +{ + QJsonObject leg{{"standard", "not_legal"}}; + + QJsonObject face1; + face1["name"] = "Fire // Ice"; + face1["text"] = "Fire deals 2 damage."; + face1["layout"] = "split"; + face1["side"] = "a"; + face1["faceName"] = "Fire"; + face1["colors"] = QJsonArray{"R"}; + face1["colorIdentity"] = QJsonArray{"R"}; + face1["types"] = QJsonArray{"Instant"}; + face1["manaCost"] = "{R}"; + face1["legalities"] = leg; + face1["identifiers"] = QJsonObject{{"scryfallId", "aaa"}}; + face1["number"] = "1"; + face1["rarity"] = "uncommon"; + + QJsonObject face2; + face2["name"] = "Fire // Ice"; + face2["text"] = "Ice taps target artifact."; + face2["layout"] = "split"; + face2["side"] = "b"; + face2["faceName"] = "Ice"; + face2["colors"] = QJsonArray{"U"}; + face2["colorIdentity"] = QJsonArray{"U"}; + face2["types"] = QJsonArray{"Instant"}; + face2["manaCost"] = "{U}"; + face2["legalities"] = leg; + face2["identifiers"] = QJsonObject{{"scryfallId", "bbb"}}; + face2["number"] = "1"; + face2["rarity"] = "uncommon"; + + QJsonArray cardsList{face1, face2}; + int count = importer->importCardsFromSet(set, cardsList); + ASSERT_EQ(count, 1); + + auto card = importer->getCardList().value("Fire // Ice"); + ASSERT_FALSE(card.isNull()); + + // coloridentity should be "RU" (concatenated), then sorted to "UR" + // by sortAndReduceColors when it reaches addCard + ASSERT_EQ(card->getProperty("coloridentity"), "UR"); +} + +TEST_F(OracleImporterTest, SplitCardColorsConcatenated) +{ + QJsonObject leg{{"standard", "not_legal"}}; + + QJsonObject face1; + face1["name"] = "Fire // Ice"; + face1["text"] = "Fire deals 2 damage."; + face1["layout"] = "split"; + face1["side"] = "a"; + face1["faceName"] = "Fire"; + face1["colors"] = QJsonArray{"R"}; + face1["colorIdentity"] = QJsonArray{"R"}; + face1["types"] = QJsonArray{"Instant"}; + face1["manaCost"] = "{R}"; + face1["legalities"] = leg; + face1["identifiers"] = QJsonObject{{"scryfallId", "aaa"}}; + face1["number"] = "1"; + face1["rarity"] = "uncommon"; + + QJsonObject face2; + face2["name"] = "Fire // Ice"; + face2["text"] = "Ice taps target artifact."; + face2["layout"] = "split"; + face2["side"] = "b"; + face2["faceName"] = "Ice"; + face2["colors"] = QJsonArray{"U"}; + face2["colorIdentity"] = QJsonArray{"U"}; + face2["types"] = QJsonArray{"Instant"}; + face2["manaCost"] = "{U}"; + face2["legalities"] = leg; + face2["identifiers"] = QJsonObject{{"scryfallId", "bbb"}}; + face2["number"] = "1"; + face2["rarity"] = "uncommon"; + + QJsonArray cardsList{face1, face2}; + importer->importCardsFromSet(set, cardsList); + + auto card = importer->getCardList().value("Fire // Ice"); + ASSERT_FALSE(card.isNull()); + + QString colors = card->getProperty("colors"); + ASSERT_FALSE(colors.contains("//")) << "colors should not contain '//', got: " << colors.toStdString(); + ASSERT_TRUE(colors.contains("R")); + ASSERT_TRUE(colors.contains("U")); +} + +// ============================================================================ +// Mana cost formatting tests +// ============================================================================ + +TEST_F(OracleImporterTest, ManaCostStripsBraces) +{ + QJsonObject card = makeCard("Mana Card"); + card["manaCost"] = "{2}{W}{B}"; + QJsonArray cards{card}; + + importer->importCardsFromSet(set, cards); + auto result = importer->getCardList().value("Mana Card"); + ASSERT_FALSE(result.isNull()); + ASSERT_EQ(result->getProperty("manacost"), "2WB"); +} + +// cmc comes through as a JSON number ("convertedManaCost"/"manaValue" are +// floats in AllPrintings), so this pins the number-to-text coercion that +// QJsonValue::toString() dropped in #7214. +TEST_F(OracleImporterTest, NumericManaValueCoercedToCmc) +{ + QJsonObject card = makeCard("Cmc Card"); + card["manaValue"] = 3; + QJsonArray cards{card}; + + importer->importCardsFromSet(set, cards); + auto result = importer->getCardList().value("Cmc Card"); + ASSERT_FALSE(result.isNull()); + ASSERT_EQ(result->getProperty("cmc"), "3"); +} + +TEST_F(OracleImporterTest, LegacyConvertedManaCostCoercedToCmc) +{ + QJsonObject card = makeCard("Legacy Cmc Card"); + card["convertedManaCost"] = 3.0; + QJsonArray cards{card}; + + importer->importCardsFromSet(set, cards); + auto result = importer->getCardList().value("Legacy Cmc Card"); + ASSERT_FALSE(result.isNull()); + ASSERT_EQ(result->getProperty("cmc"), "3"); +} + +// ============================================================================ +// Card deduplication tests +// ============================================================================ + +TEST_F(OracleImporterTest, DuplicateCardNameReturnsExisting) +{ + QJsonArray cards{makeCard("Dupe Card")}; + importer->importCardsFromSet(set, cards); + + CardSetPtr set2 = CardSet::newInstance(controller, "TS2", "Second Set"); + QJsonArray cards2{makeCard("Dupe Card")}; + importer->importCardsFromSet(set2, cards2); + + ASSERT_EQ(importer->getCardList().size(), 1); +} + +TEST_F(OracleImporterTest, AELigatureReplaced) +{ + QJsonObject card = makeCard(QString::fromUtf8("\xC3\x86ther Vial")); // Æther Vial + QJsonArray cards{card}; + + importer->importCardsFromSet(set, cards); + // Æ is replaced with AE, resulting in "AEther Vial" + ASSERT_FALSE(importer->getCardList().contains(QString::fromUtf8("\xC3\x86ther Vial"))); + ASSERT_TRUE(importer->getCardList().contains("AEther Vial")); +} + +TEST_F(OracleImporterTest, ApostropheNormalized) +{ + QJsonObject card = makeCard(QString::fromUtf8("Jace\u2019s Ingenuity")); + QJsonArray cards{card}; + + importer->importCardsFromSet(set, cards); + ASSERT_TRUE(importer->getCardList().contains("Jace's Ingenuity")); +} + +// ============================================================================ +// RawJson scanner tests +// ============================================================================ + +TEST_F(OracleImporterTest, ScanSetRangesMatchFullJsonParse) +{ + QJsonObject root; + QJsonObject data; + data["AAA"] = makeCard("Alpha Card"); + data["BBB"] = makeCard("Beta Card"); + root["data"] = data; + + const QByteArray bytes = QJsonDocument(root).toJson(QJsonDocument::Compact); + + RawJson::ScanError error; + const QList ranges = RawJson::scanSetRanges(bytes, &error); + ASSERT_FALSE(error.isError()) << error.message.toStdString(); + ASSERT_EQ(ranges.size(), 2); + + const QJsonObject wholeData = QJsonDocument::fromJson(bytes).object().value("data").toObject(); + for (const RawJson::SetRange &range : ranges) { + QJsonParseError parseError; + const QJsonDocument sliceDoc = QJsonDocument::fromJson( + QByteArray(bytes.constData() + range.dataRange.start, range.dataRange.length), &parseError); + ASSERT_EQ(parseError.error, QJsonParseError::NoError) + << range.code.toStdString() << ": " << parseError.errorString().toStdString(); + ASSERT_EQ(sliceDoc.object(), wholeData.value(range.code).toObject()) << "set " << range.code.toStdString(); + } +} + +TEST_F(OracleImporterTest, ScanSetRangesDecodesEscapesAndCountsCards) +{ + const QByteArray json = "{\"data\":{\"KEY\":{\"code\":\"zzz\",\"name\":\"\\u00c9tude \\ud83d\\ude00\"," + "\"type\":\"expansion\",\"releaseDate\":\"2024-01-05\"," + "\"cards\":[{\"name\":\"a\"},{\"name\":\"b\"},{\"name\":\"c\"}]}}}"; + + RawJson::ScanError error; + const QList ranges = RawJson::scanSetRanges(json, &error); + ASSERT_FALSE(error.isError()); + ASSERT_EQ(ranges.size(), 1); + + const RawJson::SetRange &range = ranges.first(); + ASSERT_EQ(range.code, "zzz"); // inner "code" wins over the object key + const QString expectedName = QString::fromUtf8("\xC3\x89tude ") + QChar(0xD83D) + QChar(0xDE00); + ASSERT_EQ(range.name, expectedName); + ASSERT_EQ(range.type, "expansion"); + ASSERT_EQ(range.releaseDate, "2024-01-05"); + ASSERT_EQ(range.dataRange.cardCount, 3); + + QJsonParseError parseError; + const QJsonDocument sliceDoc = QJsonDocument::fromJson( + QByteArray(json.constData() + range.dataRange.start, range.dataRange.length), &parseError); + ASSERT_EQ(parseError.error, QJsonParseError::NoError); + ASSERT_EQ(sliceDoc.object().value("name").toString(), expectedName); + ASSERT_EQ(sliceDoc.object().value("cards").toArray().size(), 3); +} + +TEST_F(OracleImporterTest, ScanSetRangesRejectsInvalidJson) +{ + const QList invalid = {"not json", + "[]", + "{\"data\":[]}", + "{\"data\":{}}", + "{\"other\":{}}", + "{\"data\":{\"A\":{\"code\":\"a\",\"name\":\"ok\",\"type\":\"x\"," + "\"releaseDate\":\"2024-01-01\",\"cards\":[]}}} trailing", + "{\"data\":{\"A\":{\"cards\":[{\"name\":\"\\uZZZZ\"}]}}}", + "{\"data\":{\"A\":{\"cards\":[{\"name\":\"bad \\q escape\"}]}}}", + "{\"data\":{\"A\":{\"cards\":[{\"name\":\"\\ud800\"}]}}}"}; + + for (const QByteArray &json : invalid) { + RawJson::ScanError error; + RawJson::scanSetRanges(json, &error); + EXPECT_TRUE(error.isError()) << "expected failure for: " << json.constData(); + } +} + +TEST_F(OracleImporterTest, ScanSetRangesMatchesFullJsonParseVerdicts) +{ + // Verdicts must agree with QJsonDocument::fromJson for the inputs below — + // including the metadata quirks ("name": null, "type": 7, "releaseDate": null, + // "cards": null) that used to make the scanner reject sets Qt accepts. + const QList inputs = { + "{\"data\":{\"A\":{\"code\":\"a\",\"name\":\"ok\",\"type\":\"x\",\"releaseDate\":\"2024-01-01\",\"cards\":[{" + "\"n\":1}]}}}", + "{\"data\":{\"A\":{\"code\":\"a\",\"name\":null,\"type\":\"x\",\"releaseDate\":\"2024-01-01\",\"cards\":[]}}}", + "{\"data\":{\"A\":{\"code\":\"a\",\"name\":\"ok\",\"type\":null,\"releaseDate\":\"2024-01-01\",\"cards\":[]}}}", + "{\"data\":{\"A\":{\"code\":\"a\",\"name\":\"ok\",\"type\":7,\"releaseDate\":\"2024-01-01\",\"cards\":null}}}", + "{\"data\":{\"A\":{\"code\":\"a\",\"name\":\"ok\",\"releaseDate\":\"2024-01-01\",\"cards\":[1,2,3]}}}", + // unescaped control character inside a string: QJsonDocument and + // skipString both accept it, so the scanner must not reject the whole doc + "{\"data\":{\"A\":{\"code\":\"a\",\"name\":\"N\tX\",\"releaseDate\":\"2024-01-01\",\"cards\":[{\"n\":1}]}}}", + // structurally invalid JSON (both parsers must reject) + "not json", + "{\"data\":{\"A\":{\"name\":\"unterminated}}", + }; + + for (const QByteArray &input : inputs) { + QJsonParseError qtError; + QJsonDocument::fromJson(input, &qtError); + const bool qtOk = qtError.error == QJsonParseError::NoError; + + RawJson::ScanError scanError; + const QList ranges = RawJson::scanSetRanges(input, &scanError); + EXPECT_EQ(qtOk, !scanError.isError()) << "verdict mismatch for: " << input.constData(); + if (scanError.isError()) { + continue; + } + for (const RawJson::SetRange &range : ranges) { + QJsonParseError sliceError; + QJsonDocument::fromJson(QByteArray(input.constData() + range.dataRange.start, range.dataRange.length), + &sliceError); + EXPECT_EQ(sliceError.error, QJsonParseError::NoError) << "bad range slice for: " << input.constData(); + } + } +} + +TEST_F(OracleImporterTest, ScanSetRangesRejectsDeepNesting) +{ + // Far beyond the shared 1024 container cap: Qt reports DeepNesting and the + // scanner must reject too, without overflowing the stack through its + // recursive skipValue walk. + QString nesting; + nesting.reserve(10000); + for (int i = 0; i < 5000; ++i) { + nesting += '['; + } + for (int i = 0; i < 5000; ++i) { + nesting += ']'; + } + const QByteArray json = ("{\"data\":{\"A\":{\"code\":\"a\",\"cards\":" + nesting + "}}}").toUtf8(); + + QJsonParseError qtError; + QJsonDocument::fromJson(json, &qtError); + ASSERT_NE(qtError.error, QJsonParseError::NoError) << "expected Qt to reject deep nesting"; + + RawJson::ScanError scanError; + RawJson::scanSetRanges(json, &scanError); + ASSERT_TRUE(scanError.isError()) << "scanner accepted a document Qt rejects as too deeply nested"; +} + +TEST_F(OracleImporterTest, ScanSetRangesAcceptsQtMaxNesting) +{ + // Pins the boundary rather than only the far-past case: a depth Qt still + // accepts must be accepted by the scanner too. Before the fix the scanner's + // cap was roughly half of Qt's (each level cost two decrements), so a + // depth of 1000 here was rejected even though QJsonDocument parses it. + constexpr int depth = 1000; + QString nesting; + nesting.reserve(2 * depth); + for (int i = 0; i < depth; ++i) { + nesting += '['; + } + for (int i = 0; i < depth; ++i) { + nesting += ']'; + } + const QByteArray json = ("{\"data\":{\"A\":{\"code\":\"a\",\"cards\":" + nesting + "}}}").toUtf8(); + + QJsonParseError qtError; + QJsonDocument::fromJson(json, &qtError); + ASSERT_EQ(qtError.error, QJsonParseError::NoError) << "expected Qt to accept depth " << depth; + + RawJson::ScanError scanError; + RawJson::scanSetRanges(json, &scanError); + ASSERT_FALSE(scanError.isError()) << "scanner rejected a document Qt accepts at depth " << depth; +} + +// ============================================================================ +// Lazy per-set parsing tests +// ============================================================================ + +TEST_F(OracleImporterTest, StartImportParsesSetsLazily) +{ + QJsonObject setObj = makeCard("Lazy Import Card"); + QJsonArray cards; + cards.append(setObj); + QJsonObject dataSet; + dataSet["code"] = "tst"; + dataSet["name"] = "Test Set"; + dataSet["type"] = "expansion"; + dataSet["releaseDate"] = "2024-01-01"; + dataSet["cards"] = cards; + + QJsonObject root; + root["data"] = QJsonObject{{"TST", dataSet}}; + + const QByteArray data = QJsonDocument(root).toJson(QJsonDocument::Compact); + ASSERT_TRUE(importer->readSetsFromByteArray(data)); + ASSERT_FALSE(importer->getRawSetsData().isEmpty()); + + const int importedSets = importer->startImport(); + ASSERT_EQ(importedSets, 1); + ASSERT_EQ(importer->getCardList().size(), 1); + ASSERT_FALSE(importer->getCardList().value("Lazy Import Card").isNull()); +} + +// ============================================================================ +// Scan progress reporting tests +// ============================================================================ + +TEST(OracleScanProgress, ScanProgressReportsMonotonicBytesToTotal) +{ + QJsonObject setObj; + setObj["code"] = "tst"; + setObj["name"] = "Test Set"; + setObj["type"] = "expansion"; + setObj["releaseDate"] = "2024-01-01"; + QJsonArray cards; + for (int i = 0; i < 40; ++i) { + QJsonObject card; + card["name"] = QString("Card %1").arg(i); + card["text"] = "Some rules text used to bulk up the card payload."; + card["layout"] = "normal"; + cards.append(card); + } + setObj["cards"] = cards; + + QJsonObject root; + root["data"] = QJsonObject{{"TST", setObj}}; + + const QByteArray data = QJsonDocument(root).toJson(QJsonDocument::Compact); + + QList> reports; + RawJson::ScanError error; + const QList ranges = + RawJson::scanSetRanges(data, &error, [&reports](qsizetype bytesRead, qsizetype totalBytes) { + reports.append({bytesRead, totalBytes}); + }); + + ASSERT_FALSE(error.isError()) << error.message.toStdString(); + ASSERT_EQ(ranges.size(), 1); + ASSERT_FALSE(reports.isEmpty()); + ASSERT_GT(reports.size(), 1); + + qsizetype last = 0; + for (const auto &[bytesRead, totalBytes] : reports) { + ASSERT_EQ(totalBytes, data.size()); + ASSERT_GE(bytesRead, last) << "scan progress must be monotonic"; + ASSERT_LE(bytesRead, totalBytes) << "scan progress must not overshoot the document size"; + last = bytesRead; + } + ASSERT_EQ(reports.constLast().first, data.size()) << "scan must end at 100%"; + ASSERT_LE(reports.size(), 160) << "scan reports must be throttled"; +} + +TEST(OracleScanProgress, ScanWithoutCallbackStillParses) +{ + QJsonObject setObj; + setObj["code"] = "tst"; + setObj["name"] = "Test Set"; + setObj["type"] = "expansion"; + setObj["releaseDate"] = "2024-01-01"; + setObj["cards"] = QJsonArray(); + + QJsonObject root; + root["data"] = QJsonObject{{"TST", setObj}}; + + const QByteArray data = QJsonDocument(root).toJson(QJsonDocument::Compact); + + RawJson::ScanError error; + const QList ranges = RawJson::scanSetRanges(data, &error); + + ASSERT_FALSE(error.isError()) << error.message.toStdString(); + ASSERT_EQ(ranges.size(), 1); + ASSERT_EQ(ranges.first().code, "tst"); +} + +TEST_F(OracleImporterTest, ReadSetsFromByteArrayEmitsScanProgress) +{ + QJsonObject setObj; + setObj["code"] = "tst"; + setObj["name"] = "Test Set"; + setObj["type"] = "expansion"; + setObj["releaseDate"] = "2024-01-01"; + QJsonArray cards; + for (int i = 0; i < 40; ++i) { + QJsonObject card; + card["name"] = QString("Card %1").arg(i); + cards.append(card); + } + setObj["cards"] = cards; + + QJsonObject root; + root["data"] = QJsonObject{{"TST", setObj}}; + + const QByteArray data = QJsonDocument(root).toJson(QJsonDocument::Compact); + + QList> emissions; + QObject::connect(importer, &OracleImporter::dataReadProgress, + [&emissions](int bytesRead, int totalBytes) { emissions.append({bytesRead, totalBytes}); }); + + ASSERT_TRUE(importer->readSetsFromByteArray(data)); + ASSERT_FALSE(emissions.isEmpty()); + for (const auto &[bytesRead, totalBytes] : emissions) { + ASSERT_EQ(totalBytes, data.size()); + ASSERT_GE(bytesRead, 0); + ASSERT_LE(bytesRead, totalBytes); + } + ASSERT_EQ(emissions.constLast().first, data.size()); +} + +TEST_F(OracleImporterTest, DisablingProgressReportingSuppressesScanEmissions) +{ + QJsonObject setObj; + setObj["code"] = "tst"; + setObj["name"] = "Test Set"; + setObj["type"] = "expansion"; + setObj["releaseDate"] = "2024-01-01"; + setObj["cards"] = QJsonArray(); + QJsonObject root; + root["data"] = QJsonObject{{"TST", setObj}}; + const QByteArray data = QJsonDocument(root).toJson(QJsonDocument::Compact); + + int emissions = 0; + QObject::connect(importer, &OracleImporter::dataReadProgress, [&emissions](int, int) { ++emissions; }); + + importer->setProgressReporting(false); + ASSERT_TRUE(importer->readSetsFromByteArray(data)); + ASSERT_EQ(emissions, 0); + + importer->setProgressReporting(true); + ASSERT_TRUE(importer->readSetsFromByteArray(data)); + ASSERT_GT(emissions, 0); +} + +// Localized card text tests +// ============================================================================ + +TEST_F(OracleImporterTest, ImportsLocalizedTextForRequestedLanguage) +{ + QJsonObject card = makeCard("Lightning Bolt"); + card["foreignData"] = + QJsonArray{makeForeignEntry("German", "Blitzschlag", "Blitzschlag fügt 3 Schadenspunkte zu.")}; + QJsonArray cards{card}; + + importer->setCardLang("de"); + importer->importCardsFromSet(set, cards); + importer->applyLocalizedData(); + + auto result = importer->getCardList().value("Lightning Bolt"); + ASSERT_FALSE(result.isNull()); + ASSERT_EQ(result->getLocalizedName("de"), "Blitzschlag"); + ASSERT_EQ(result->getLocalizedText("de"), "Blitzschlag fügt 3 Schadenspunkte zu."); + // English identity untouched + ASSERT_EQ(result->getName(), "Lightning Bolt"); + ASSERT_EQ(result->getText(), "Rules text."); +} + +TEST_F(OracleImporterTest, ImportsLocalizedNameAndTextForMultiFaceCards) +{ + // MTGJSON reports multi-face cards (adventure/split/aftermath/prepare) as one + // card object per face; every face carries the joined name but only its own + // face's rules text in foreignData. The importer joins the per-face texts with + // the same separator as the English merge. + QJsonObject front = makeCard("Disruptive Stormbrood // Petty Revenge"); + front["layout"] = "adventure"; + front["faceName"] = "Disruptive Stormbrood"; + front["side"] = "a"; + front["foreignData"] = QJsonArray{ + makeForeignEntry("German", "Disruptive Stormbrood // Kleinliche Rache", + "Fliegend\nWenn diese Kreatur ins Spiel kommt, zerstöre bis zu ein Artefakt oder eine " + "Verzauberung deiner Wahl.")}; + QJsonObject back = makeCard("Disruptive Stormbrood // Petty Revenge"); + back["layout"] = "adventure"; + back["faceName"] = "Petty Revenge"; + back["side"] = "b"; + back["text"] = "Destroy target creature."; + back["foreignData"] = QJsonArray{makeForeignEntry("German", "Disruptive Stormbrood // Kleinliche Rache", + "Zerstöre eine Kreatur deiner Wahl mit Stärke 3 oder weniger.")}; + QJsonArray cards{front, back}; + + importer->setCardLang("de"); + importer->importCardsFromSet(set, cards); + importer->applyLocalizedData(); + + auto result = importer->getCardList().value("Disruptive Stormbrood // Petty Revenge"); + ASSERT_FALSE(result.isNull()); + ASSERT_EQ(result->getLocalizedName("de"), "Disruptive Stormbrood // Kleinliche Rache"); + ASSERT_EQ(result->getLocalizedText("de"), + "Fliegend\nWenn diese Kreatur ins Spiel kommt, zerstöre bis zu ein Artefakt oder eine Verzauberung " + "deiner Wahl.\n\n---\n\nZerstöre eine Kreatur deiner Wahl mit Stärke 3 oder weniger."); + // English identity untouched + ASSERT_EQ(result->getName(), "Disruptive Stormbrood // Petty Revenge"); + ASSERT_EQ(result->getText(), "Rules text.\n\n---\n\nDestroy target creature."); +} + +TEST_F(OracleImporterTest, MultiFaceCardsWithoutCompleteForeignTextKeepEnglishText) +{ + // Both faces must carry a foreignData text for the joined text; otherwise the + // rules text stays English while the localized name (from a later complete + // printing) is still applied. + QJsonObject front = makeCard("Wear // Tear"); + front["layout"] = "split"; + front["faceName"] = "Wear"; + front["side"] = "a"; + front["foreignData"] = QJsonArray{makeForeignEntry("German", "Verschleiß // Zerrreißung", "Verschleiß-Text.")}; + QJsonObject back = makeCard("Wear // Tear"); + back["layout"] = "split"; + back["faceName"] = "Tear"; + back["side"] = "b"; + back["text"] = "Tear rules text."; + back["foreignData"] = QJsonArray{makeForeignEntry("German", "Verschleiß // Zerrreißung", "")}; + QJsonArray cards{front, back}; + + importer->setCardLang("de"); + importer->importCardsFromSet(set, cards); + importer->applyLocalizedData(); + + auto result = importer->getCardList().value("Wear // Tear"); + ASSERT_FALSE(result.isNull()); + // The joined name is still applied. + ASSERT_EQ(result->getLocalizedName("de"), "Verschleiß // Zerrreißung"); + // The incomplete text must not become the card's localized text. + ASSERT_TRUE(result->getLocalizedTexts().isEmpty()); + ASSERT_EQ(result->getText(), "Rules text.\n\n---\n\nTear rules text."); +} + +TEST_F(OracleImporterTest, DefaultLanguageSkipsForeignData) +{ + QJsonObject card = makeCard("Lightning Bolt"); + card["foreignData"] = + QJsonArray{makeForeignEntry("German", "Blitzschlag", "Blitzschlag fügt 3 Schadenspunkte zu.")}; + QJsonArray cards{card}; + + // cardLang defaults to "en" — foreignData must never be imported + importer->importCardsFromSet(set, cards); + importer->applyLocalizedData(); + + auto result = importer->getCardList().value("Lightning Bolt"); + ASSERT_FALSE(result.isNull()); + ASSERT_TRUE(result->getLocalizedNames().isEmpty()); + ASSERT_TRUE(result->getLocalizedTexts().isEmpty()); +} + +TEST_F(OracleImporterTest, UnsupportedLanguageSkipsForeignData) +{ + QJsonObject card = makeCard("Lightning Bolt"); + card["foreignData"] = QJsonArray{makeForeignEntry("xx", "Kochanie", "Grzmot uderza.")}; + QJsonArray cards{card}; + + importer->setCardLang("xx"); + importer->importCardsFromSet(set, cards); + importer->applyLocalizedData(); + + auto result = importer->getCardList().value("Lightning Bolt"); + ASSERT_FALSE(result.isNull()); + ASSERT_TRUE(result->getLocalizedNames().isEmpty()); +} + +TEST_F(OracleImporterTest, NonMatchingLanguageNotCollected) +{ + QJsonObject card = makeCard("Lightning Bolt"); + card["foreignData"] = QJsonArray{makeForeignEntry("French", "Éclair", "L'Éclair inflige 3 blessures.")}; + QJsonArray cards{card}; + + importer->setCardLang("de"); + importer->importCardsFromSet(set, cards); + importer->applyLocalizedData(); + + auto result = importer->getCardList().value("Lightning Bolt"); + ASSERT_FALSE(result.isNull()); + ASSERT_TRUE(result->getLocalizedNames().isEmpty()); +} + +TEST_F(OracleImporterTest, HigherPrioritySetWinsForReprint) +{ + // First printing in a reprint set, then another in a (more authoritative) + // core set: the core set's German text must win even though it was seen later. + QJsonObject reprintCard = makeCard("Lightning Bolt"); + reprintCard["foreignData"] = QJsonArray{makeForeignEntry("German", "Blitzschlag", "Älterer deutscher Text.")}; + CardSetPtr reprintSet = + CardSet::newInstance(controller, "TS2", "Second Set", QString(), QDate(), CardSet::PriorityReprint); + importer->setCardLang("de"); + importer->importCardsFromSet(reprintSet, QJsonArray{reprintCard}); + + QJsonObject primaryCard = makeCard("Lightning Bolt"); + primaryCard["foreignData"] = + QJsonArray{makeForeignEntry("German", "Blitzschlag", "Blitzschlag fügt 3 Schadenspunkte zu.")}; + CardSetPtr primarySet = + CardSet::newInstance(controller, "TS3", "Third Set", QString(), QDate(), CardSet::PriorityPrimary); + importer->importCardsFromSet(primarySet, QJsonArray{primaryCard}); + importer->applyLocalizedData(); + + auto result = importer->getCardList().value("Lightning Bolt"); + ASSERT_FALSE(result.isNull()); + ASSERT_EQ(result->getLocalizedName("de"), "Blitzschlag"); + ASSERT_EQ(result->getLocalizedText("de"), "Blitzschlag fügt 3 Schadenspunkte zu."); + ASSERT_EQ(importer->getCardList().size(), 1); +} + +TEST_F(OracleImporterTest, HigherPrioritySplitSetWinsForReprint) +{ + // Split cards print each face as its own card object; the joined text is + // collected per set with the same priority policy as single-face cards, so a + // reprint set's German text must yield to the core set's even when reprints + // are imported first. + QJsonObject reprintFront = makeCard("Wear // Tear"); + reprintFront["layout"] = "split"; + reprintFront["faceName"] = "Wear"; + reprintFront["side"] = "a"; + reprintFront["foreignData"] = + QJsonArray{makeForeignEntry("German", "Verschleiß // Zerrreißung", "Wear alter Text.")}; + QJsonObject reprintBack = makeCard("Wear // Tear"); + reprintBack["layout"] = "split"; + reprintBack["faceName"] = "Tear"; + reprintBack["side"] = "b"; + reprintBack["foreignData"] = + QJsonArray{makeForeignEntry("German", "Verschleiß // Zerrreißung", "Tear alter Text.")}; + CardSetPtr reprintSet = + CardSet::newInstance(controller, "TS2", "Second Set", QString(), QDate(), CardSet::PriorityReprint); + importer->setCardLang("de"); + importer->importCardsFromSet(reprintSet, QJsonArray{reprintFront, reprintBack}); + + QJsonObject primaryFront = makeCard("Wear // Tear"); + primaryFront["layout"] = "split"; + primaryFront["faceName"] = "Wear"; + primaryFront["side"] = "a"; + primaryFront["foreignData"] = + QJsonArray{makeForeignEntry("German", "Verschleiß // Zerrreißung", "Wear neuer Text.")}; + QJsonObject primaryBack = makeCard("Wear // Tear"); + primaryBack["layout"] = "split"; + primaryBack["faceName"] = "Tear"; + primaryBack["side"] = "b"; + primaryBack["foreignData"] = + QJsonArray{makeForeignEntry("German", "Verschleiß // Zerrreißung", "Tear neuer Text.")}; + CardSetPtr primarySet = + CardSet::newInstance(controller, "TS3", "Third Set", QString(), QDate(), CardSet::PriorityPrimary); + importer->importCardsFromSet(primarySet, QJsonArray{primaryFront, primaryBack}); + importer->applyLocalizedData(); + + auto result = importer->getCardList().value("Wear // Tear"); + ASSERT_FALSE(result.isNull()); + ASSERT_EQ(result->getLocalizedName("de"), "Verschleiß // Zerrreißung"); + ASSERT_EQ(result->getLocalizedText("de"), "Wear neuer Text.\n\n---\n\nTear neuer Text."); + ASSERT_EQ(importer->getCardList().size(), 1); +} + +TEST_F(OracleImporterTest, StartImportAppliesLocalizedData) +{ + QJsonObject card = makeCard("Lightning Bolt"); + card["foreignData"] = + QJsonArray{makeForeignEntry("Portuguese (Brazil)", "Raio", "Raio causa 3 de dano a qualquer alvo.")}; + QJsonObject dataSet; + dataSet["code"] = "tst"; + dataSet["name"] = "Test Set"; + dataSet["type"] = "expansion"; + dataSet["releaseDate"] = "2024-01-01"; + dataSet["cards"] = QJsonArray{card}; + + QJsonObject root; + root["data"] = QJsonObject{{"TST", dataSet}}; + + importer->setCardLang("pt"); + ASSERT_TRUE(importer->readSetsFromByteArray(QJsonDocument(root).toJson(QJsonDocument::Compact))); + ASSERT_EQ(importer->startImport(), 1); + + auto result = importer->getCardList().value("Lightning Bolt"); + ASSERT_FALSE(result.isNull()); + ASSERT_EQ(result->getLocalizedName("pt"), "Raio"); + ASSERT_EQ(result->getLocalizedText("pt"), "Raio causa 3 de dano a qualquer alvo."); +} + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/password_hash_test.cpp b/tests/password_hash_test.cpp index 38d9b6315..2b8f8bdb7 100644 --- a/tests/password_hash_test.cpp +++ b/tests/password_hash_test.cpp @@ -1,25 +1,9 @@ #include "gtest/gtest.h" -#include -#include +#include #include -RNG_Abstract *rng; - namespace { -class PasswordHashTest : public ::testing::Test -{ -protected: - void SetUp() override - { - rng = new RNG_SFMT; - } - - void TearDown() override - { - delete rng; - } -}; TEST(PasswordHashTest, RegressionTest) { @@ -29,6 +13,29 @@ TEST(PasswordHashTest, RegressionTest) QString hash = PasswordHasher::computeHash(password, salt); ASSERT_EQ(hash, salt + expected) << "The computed hash value remains the same"; } + +TEST(PasswordHashTest, SaltUsesAlphanumericCharset) +{ + static const char alphanum[] = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + const QString salt = PasswordHasher::generateRandomSalt(); + ASSERT_EQ(salt.size(), 16); + for (const QChar &c : salt) { + ASSERT_NE(strchr(alphanum, c.toLatin1()), nullptr); + } +} + +TEST(PasswordHashTest, SaltsAreUnique) +{ + const QString salt1 = PasswordHasher::generateRandomSalt(); + const QString salt2 = PasswordHasher::generateRandomSalt(); + ASSERT_NE(salt1, salt2); +} + +TEST(PasswordHashTest, TokenHasExpectedLength) +{ + const QString token = PasswordHasher::generateActivationToken(); + ASSERT_EQ(token.size(), 16); +} } // namespace int main(int argc, char **argv) diff --git a/tests/playmat_resolver_test.cpp b/tests/playmat_resolver_test.cpp new file mode 100644 index 000000000..a931c0647 --- /dev/null +++ b/tests/playmat_resolver_test.cpp @@ -0,0 +1,126 @@ +#include +#include +#include + +namespace +{ + +PlaymatInfo makePlaymatInfo(const QString &name, const QString &providerId = QString()) +{ + PlaymatInfo info; + info.card = {name, providerId}; + return info; +} + +} // namespace + +TEST(PlaymatResolverTest, EmptyChainReturnsEmpty) +{ + DeckList deck; + const PlaymatInfo resolved = resolveEffectivePlaymat(deck, {}, {}, PlaymatFallbackModeFixed, 0); + EXPECT_TRUE(resolved.card.isEmpty()); +} + +TEST(PlaymatResolverTest, OverrideWinsOverDeckAndFallback) +{ + DeckList deck; + deck.setPlaymat({{QStringLiteral("Deck Mat"), QStringLiteral("deck-provider")}, {}}); + + const PlaymatInfo force = makePlaymatInfo(QStringLiteral("Force Mat"), QStringLiteral("force-provider")); + const QList fallback = {makePlaymatInfo(QStringLiteral("Fallback Mat"))}; + + const PlaymatInfo resolved = resolveEffectivePlaymat(deck, force, fallback, PlaymatFallbackModeFixed, 0); + EXPECT_EQ(resolved.card.name, QStringLiteral("Force Mat")); + EXPECT_EQ(resolved.card.providerId, QStringLiteral("force-provider")); +} + +TEST(PlaymatResolverTest, DeckWinsOverFallback) +{ + DeckList deck; + deck.setPlaymat({{QStringLiteral("Deck Mat"), QStringLiteral("deck-provider")}, {0.1, 0.2, 0.3, 1.5}}); + + const QList fallback = {makePlaymatInfo(QStringLiteral("Fallback Mat"))}; + + const PlaymatInfo resolved = resolveEffectivePlaymat(deck, {}, fallback, PlaymatFallbackModeFixed, 0); + EXPECT_EQ(resolved.card.name, QStringLiteral("Deck Mat")); + EXPECT_EQ(resolved.card.providerId, QStringLiteral("deck-provider")); + EXPECT_DOUBLE_EQ(resolved.params.marginPctL, 0.1); + EXPECT_DOUBLE_EQ(resolved.params.verticalOffset, 0.3); +} + +TEST(PlaymatResolverTest, FallbackUsedWhenDeckHasNone) +{ + DeckList deck; + const QList fallback = {makePlaymatInfo(QStringLiteral("Fallback Mat"))}; + + const PlaymatInfo resolved = resolveEffectivePlaymat(deck, {}, fallback, PlaymatFallbackModeFixed, 0); + EXPECT_EQ(resolved.card.name, QStringLiteral("Fallback Mat")); +} + +TEST(PlaymatResolverTest, FixedAlwaysUsesFirst) +{ + DeckList deck; + const QList fallback = {makePlaymatInfo(QStringLiteral("First")), + makePlaymatInfo(QStringLiteral("Second"))}; + + for (int i = 0; i < 5; ++i) { + const PlaymatInfo resolved = resolveEffectivePlaymat(deck, {}, fallback, PlaymatFallbackModeFixed, i); + EXPECT_EQ(resolved.card.name, QStringLiteral("First")); + } +} + +TEST(PlaymatResolverTest, RoundRobinCyclesAndWraps) +{ + DeckList deck; + const QList fallback = {makePlaymatInfo(QStringLiteral("First")), + makePlaymatInfo(QStringLiteral("Second")), + makePlaymatInfo(QStringLiteral("Third"))}; + + const QStringList expected = {QStringLiteral("First"), QStringLiteral("Second"), QStringLiteral("Third"), + QStringLiteral("First"), QStringLiteral("Second"), QStringLiteral("Third")}; + for (int i = 0; i < expected.size(); ++i) { + const PlaymatInfo resolved = resolveEffectivePlaymat(deck, {}, fallback, PlaymatFallbackModeRoundRobin, i); + EXPECT_EQ(resolved.card.name, expected.at(i)); + } +} + +TEST(PlaymatResolverTest, RoundRobinRespectsCursor) +{ + DeckList deck; + const QList fallback = {makePlaymatInfo(QStringLiteral("First")), + makePlaymatInfo(QStringLiteral("Second"))}; + + const PlaymatInfo resolved = resolveEffectivePlaymat(deck, {}, fallback, PlaymatFallbackModeRoundRobin, 5); + EXPECT_EQ(resolved.card.name, QStringLiteral("Second")); // 5 % 2 == 1 +} + +TEST(PlaymatResolverTest, RandomStaysWithinList) +{ + DeckList deck; + const QList fallback = {makePlaymatInfo(QStringLiteral("First")), + makePlaymatInfo(QStringLiteral("Second")), + makePlaymatInfo(QStringLiteral("Third"))}; + + for (int i = 0; i < 50; ++i) { + const PlaymatInfo resolved = resolveEffectivePlaymat(deck, {}, fallback, PlaymatFallbackModeRandom, i); + ASSERT_FALSE(resolved.card.name.isEmpty()); + EXPECT_TRUE(fallback.contains(resolved)); + } +} + +TEST(PlaymatResolverTest, ForceWithEmptyCardIgnoresFallbackParamsButNotFallback) +{ + DeckList deck; + deck.setPlaymat({{QStringLiteral("Deck Mat")}, {}}); + + // An empty force entry must not mask the deck-configured playmat. + const PlaymatInfo emptyForce; + const PlaymatInfo resolved = resolveEffectivePlaymat(deck, emptyForce, {}, PlaymatFallbackModeFixed, 0); + EXPECT_EQ(resolved.card.name, QStringLiteral("Deck Mat")); +} + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/server_card_counter_test.cpp b/tests/server_card_counter_test.cpp index ff906b906..c8bc43f8f 100644 --- a/tests/server_card_counter_test.cpp +++ b/tests/server_card_counter_test.cpp @@ -7,7 +7,7 @@ #include #include #include -#include +#include #include TEST(ServerCardCounter, IncrementNewCounter) @@ -28,9 +28,9 @@ TEST(ServerCardCounter, IncrementExistingCounter) TEST(ServerCardCounter, IncrementOverflowProtection) { Server_Card card(CardRef{"TestCard", ""}, 1, 0, 0); - ASSERT_TRUE(card.setCounter(1, MAX_COUNTERS_ON_CARD)); + ASSERT_TRUE(card.setCounter(1, MAX_COUNTER_VALUE)); EXPECT_FALSE(card.incrementCounter(1, 1)); - EXPECT_EQ(card.getCounter(1), MAX_COUNTERS_ON_CARD); + EXPECT_EQ(card.getCounter(1), MAX_COUNTER_VALUE); } TEST(ServerCardCounter, DecrementUnderflowProtection) @@ -113,13 +113,13 @@ TEST(ServerCardCounter, IncrementCounterPopulatesEvent) TEST(ServerCardCounter, IncrementCounterEventReflectsClampedValue) { Server_Card card(CardRef{"TestCard", ""}, 1, 0, 0); - ASSERT_TRUE(card.setCounter(1, MAX_COUNTERS_ON_CARD - 5)); + ASSERT_TRUE(card.setCounter(1, MAX_COUNTER_VALUE - 5)); Event_SetCardCounter event; EXPECT_TRUE(card.incrementCounter(1, 10, &event)); EXPECT_EQ(event.counter_id(), 1); - EXPECT_EQ(event.counter_value(), MAX_COUNTERS_ON_CARD); + EXPECT_EQ(event.counter_value(), MAX_COUNTER_VALUE); } TEST(ServerCardCounter, IncrementCounterNoEventWhenNullptr) @@ -133,7 +133,7 @@ TEST(ServerCardCounter, IncrementCounterNoEventWhenNullptr) TEST(ServerCardCounter, IncrementCounterEventNotPopulatedWhenUnchanged) { Server_Card card(CardRef{"TestCard", ""}, 1, 0, 0); - ASSERT_TRUE(card.setCounter(1, MAX_COUNTERS_ON_CARD)); + ASSERT_TRUE(card.setCounter(1, MAX_COUNTER_VALUE)); Event_SetCardCounter event; event.set_counter_id(999); @@ -156,7 +156,7 @@ TEST(ServerCardCounter, SetCounterClampsAboveMaxToMax) { Server_Card card(CardRef{"TestCard", ""}, 1, 0, 0); EXPECT_TRUE(card.setCounter(1, 1500)); - EXPECT_EQ(card.getCounter(1), MAX_COUNTERS_ON_CARD); + EXPECT_EQ(card.getCounter(1), MAX_COUNTER_VALUE); } TEST(ServerCardCounter, IncrementDoesNotGoBelowZero) @@ -171,9 +171,9 @@ TEST(ServerCardCounter, IncrementDoesNotGoBelowZero) TEST(ServerCardCounter, IncrementDoesNotExceedMax) { Server_Card card(CardRef{"TestCard", ""}, 1, 0, 0); - ASSERT_TRUE(card.setCounter(1, MAX_COUNTERS_ON_CARD - 5)); + ASSERT_TRUE(card.setCounter(1, MAX_COUNTER_VALUE - 5)); EXPECT_TRUE(card.incrementCounter(1, 10)); - EXPECT_EQ(card.getCounter(1), MAX_COUNTERS_ON_CARD); + EXPECT_EQ(card.getCounter(1), MAX_COUNTER_VALUE); } int main(int argc, char **argv) diff --git a/tests/server_developer_role_test.cpp b/tests/server_developer_role_test.cpp new file mode 100644 index 000000000..127f606a8 --- /dev/null +++ b/tests/server_developer_role_test.cpp @@ -0,0 +1,137 @@ +/** @file server_developer_role_test.cpp + * @brief Tests for the developer staff role authorization and dispatch. + * @ingroup Tests + */ + +#include +#include +#include +#include +#include +#include +#include +#include + +// The server_remote library references the global RNG, which is normally +// defined by the servatrice/client executable main(). Provide a stub so the +// unit test can link against it. +RNG_Abstract *rng = nullptr; + +namespace +{ + +class TestDeveloperHandler : public Server_ProtocolHandler +{ +public: + explicit TestDeveloperHandler(Server *_server) : Server_ProtocolHandler(_server, nullptr) + { + } + + QString getAddress() const override + { + return {}; + } + QString getConnectionType() const override + { + return {}; + } + + // Buffer the last response code sent to the client so tests can assert on + // the outcome of processCommandContainer(). + Response::ResponseCode lastResponseCode = Response::RespNothing; + int dispatchCount = 0; + +protected: + void transmitProtocolItem(const ServerMessage &item) override + { + if (item.message_type() == ServerMessage::RESPONSE) { + lastResponseCode = item.response().response_code(); + } + } + + Response::ResponseCode + processExtendedDeveloperCommand(int cmdType, const DeveloperCommand &, ResponseContainer &) override + { + ++dispatchCount; + // Fail closed for anything not explicitly handled. + if (cmdType != DeveloperCommand::GET_SERVER_STATS) { + return Response::RespFunctionNotAllowed; + } + return Response::RespOk; + } +}; + +class DeveloperRoleTest : public ::testing::Test +{ +protected: + Server server; + TestDeveloperHandler handler{&server}; + + void setUserLevel(uint32_t level) + { + ServerInfo_User user; + user.set_user_level(level); + handler.setUserInfo(user); + } +}; + +TEST_F(DeveloperRoleTest, RejectsWhenNotLoggedIn) +{ + CommandContainer cont; + cont.add_developer_command(); + handler.processCommandContainer(cont); + EXPECT_EQ(handler.lastResponseCode, Response::RespLoginNeeded); + EXPECT_EQ(handler.dispatchCount, 0); +} + +TEST_F(DeveloperRoleTest, RejectsPlainUser) +{ + setUserLevel(ServerInfo_User::IsUser | ServerInfo_User::IsRegistered); + + CommandContainer cont; + cont.add_developer_command(); + handler.processCommandContainer(cont); + EXPECT_EQ(handler.lastResponseCode, Response::RespLoginNeeded); + EXPECT_EQ(handler.dispatchCount, 0); +} + +TEST_F(DeveloperRoleTest, RejectsModeratorThatIsNotDeveloper) +{ + setUserLevel(ServerInfo_User::IsModerator); + + CommandContainer cont; + cont.add_developer_command(); + handler.processCommandContainer(cont); + EXPECT_EQ(handler.lastResponseCode, Response::RespLoginNeeded); +} + +TEST_F(DeveloperRoleTest, DispatchesToDeveloperCommandForDeveloper) +{ + setUserLevel(ServerInfo_User::IsDeveloper); + + CommandContainer cont; + DeveloperCommand *cmd = cont.add_developer_command(); + cmd->MutableExtension(Command_GetServerStats::ext); + handler.processCommandContainer(cont); + EXPECT_EQ(handler.lastResponseCode, Response::RespOk); + EXPECT_EQ(handler.dispatchCount, 1); +} + +TEST_F(DeveloperRoleTest, FailClosedForUnknownDeveloperCommand) +{ + setUserLevel(ServerInfo_User::IsDeveloper); + + CommandContainer cont; + cont.add_developer_command(); // no extension set -> getPbExtension() returns -1 + handler.processCommandContainer(cont); + EXPECT_EQ(handler.lastResponseCode, Response::RespFunctionNotAllowed); + EXPECT_EQ(handler.dispatchCount, 1); +} + +} // namespace + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/server_game_join_test.cpp b/tests/server_game_join_test.cpp new file mode 100644 index 000000000..c84e4e066 --- /dev/null +++ b/tests/server_game_join_test.cpp @@ -0,0 +1,174 @@ +/** @file server_game_join_test.cpp + * @brief Tests for the moderator/judge game-entry restriction override in Server_Game::checkJoin. + * @ingroup Tests + */ + +#include "game/server_game.h" +#include "server.h" +#include "server_database_interface.h" +#include "server_room.h" + +#include +#include +#include + +RNG_Abstract *rng = nullptr; // referenced by the server_remote library + +namespace +{ + +class MockDatabaseInterface : public Server_DatabaseInterface +{ +public: + AuthenticationResult checkUserPassword(Server_ProtocolHandler *, + const QString &, + const QString &, + const QString &, + QString &, + int &, + bool) override + { + return NotLoggedIn; + } + int getNextReplayId() override + { + return 1; + } + int getNextGameId() override + { + return 1; + } + int getActiveUserCount(QString) override + { + return 0; + } + ServerInfo_User getUserData(const QString &, bool) override + { + return ServerInfo_User(); + } +}; + +class FakeServer : public Server +{ +public: + FakeServer() + { + setDatabaseInterface(new MockDatabaseInterface()); + } +}; + +class GameJoinOverrideTest : public ::testing::Test +{ +protected: + FakeServer server; + Server_Room room{0, 0, "", "", "", "", false, "", {}, &server}; + ServerInfo_User creator; + ServerInfo_User plainUser; + ServerInfo_User unregisteredJudge; + ServerInfo_User moderator; + ServerInfo_User judge; + Server_Game *game = nullptr; + + void SetUp() override + { + creator.set_name("creator"); + creator.set_user_level(ServerInfo_User::IsUser | ServerInfo_User::IsRegistered); + plainUser.set_name("plain-user"); + plainUser.set_user_level(ServerInfo_User::IsUser | ServerInfo_User::IsRegistered); + unregisteredJudge.set_name("unregistered-judge"); + unregisteredJudge.set_user_level(ServerInfo_User::IsUser | ServerInfo_User::IsJudge); + moderator.set_name("moderator"); + moderator.set_user_level(ServerInfo_User::IsUser | ServerInfo_User::IsRegistered | + ServerInfo_User::IsModerator); + judge.set_name("judge"); + judge.set_user_level(ServerInfo_User::IsUser | ServerInfo_User::IsRegistered | ServerInfo_User::IsJudge); + } + + void TearDown() override + { + delete game; + } + + Server_Game *makeGame(bool passwordProtected, bool onlyRegistered, bool onlyBuddies, bool spectatorsAllowed) + { + GameConfig config{.creatorInfo = creator, + .gameId = 1, + .description = QString(), + .password = passwordProtected ? "secret" : QString(), + .maxPlayers = 2, + .gameTypes = QList(), + .onlyBuddies = onlyBuddies, + .onlyRegistered = onlyRegistered, + .spectatorsAllowed = spectatorsAllowed, + .spectatorsNeedPassword = true, + .spectatorsCanTalk = false, + .spectatorsSeeEverything = false, + .startingLifeTotal = 20, + .shareDecklistsOnLoad = false}; + return new Server_Game(config, &room); + } +}; + +TEST_F(GameJoinOverrideTest, StaffBypassPasswordRestriction) +{ + game = makeGame(true, false, false, true); + + // A plain user cannot override the password even with the override flag set. + EXPECT_EQ(game->checkJoin(&plainUser, "wrong", false, true, false), Response::RespWrongPassword); + // Moderators and judges may enter any game regardless of the password. + EXPECT_EQ(game->checkJoin(&moderator, "wrong", false, true, false), Response::RespOk); + EXPECT_EQ(game->checkJoin(&judge, "wrong", false, true, false), Response::RespOk); + // Without the override flag judges are still subject to the password. + EXPECT_EQ(game->checkJoin(&judge, "wrong", false, false, true), Response::RespWrongPassword); + EXPECT_EQ(game->checkJoin(&judge, "secret", false, false, true), Response::RespOk); +} + +TEST_F(GameJoinOverrideTest, StaffBypassRegisteredOnlyRestriction) +{ + game = makeGame(false, true, false, true); + + // Without the override flag the only-registered restriction still applies. + EXPECT_EQ(game->checkJoin(&unregisteredJudge, QString(), false, false, false), Response::RespUserLevelTooLow); + // An unregistered judge may enter when overriding restrictions. + EXPECT_EQ(game->checkJoin(&unregisteredJudge, QString(), false, true, false), Response::RespOk); +} + +TEST_F(GameJoinOverrideTest, StaffBypassBuddiesOnlyRestriction) +{ + game = makeGame(false, false, true, true); + + // A plain user who is not on the creator's buddy list gets rejected. + EXPECT_EQ(game->checkJoin(&plainUser, QString(), false, true, false), Response::RespOnlyBuddies); + // Moderators and judges bypass the buddies-only restriction. + EXPECT_EQ(game->checkJoin(&moderator, QString(), false, true, false), Response::RespOk); + EXPECT_EQ(game->checkJoin(&judge, QString(), false, true, false), Response::RespOk); +} + +TEST_F(GameJoinOverrideTest, StaffBypassSpectatorsNotAllowedRestriction) +{ + game = makeGame(false, false, false, false); + + // A plain user cannot spectate when the game disallows spectators. + EXPECT_EQ(game->checkJoin(&plainUser, QString(), true, false, false), Response::RespSpectatorsNotAllowed); + // Moderators and judges may spectate any game regardless of the password + // and the spectator restriction. + EXPECT_EQ(game->checkJoin(&moderator, "wrong", true, true, false), Response::RespOk); + EXPECT_EQ(game->checkJoin(&judge, "wrong", true, true, false), Response::RespOk); +} + +TEST_F(GameJoinOverrideTest, JudgeOverrideDoesNotGrantJudgeJoinToPlainUser) +{ + game = makeGame(false, false, false, true); + + // joining with join_as_judge still requires the judge flag even when overriding. + EXPECT_EQ(game->checkJoin(&plainUser, QString(), false, true, true), Response::RespUserLevelTooLow); + EXPECT_EQ(game->checkJoin(&judge, QString(), false, true, true), Response::RespOk); +} + +} // namespace + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} \ No newline at end of file diff --git a/tests/server_rate_limiter_test.cpp b/tests/server_rate_limiter_test.cpp new file mode 100644 index 000000000..bc15ca421 --- /dev/null +++ b/tests/server_rate_limiter_test.cpp @@ -0,0 +1,151 @@ +#include "gtest/gtest.h" +#include +#include +#include + +namespace +{ + +const QString HOST = "api.scryfall.com"; + +QDateTime timeAt(qint64 secsFromEpoch) +{ + return QDateTime::fromMSecsSinceEpoch(secsFromEpoch * 1000, QTimeZone::UTC); +} + +TEST(ServerRateLimiterTest, First429SetsThirtySecondBackoff) +{ + ServerRateLimiter limiter; + QDateTime now = timeAt(1000); + + QDateTime deadline = limiter.on429(HOST, now); + + EXPECT_TRUE(limiter.isRateLimited(HOST, now)); + EXPECT_EQ(now.addSecs(30), deadline); + EXPECT_EQ(1, limiter.rounds(HOST)); + EXPECT_FALSE(limiter.isRateLimited(HOST, deadline)); +} + +TEST(ServerRateLimiterTest, RetryAfterHeaderIsHonoredWhenLarger) +{ + ServerRateLimiter limiter; + QDateTime now = timeAt(1000); + + QDateTime deadline = limiter.on429(HOST, now, 45 * 1000); + + EXPECT_EQ(now.addSecs(45), deadline); +} + +TEST(ServerRateLimiterTest, RetryAfterSmallerThanFloorIsIgnored) +{ + ServerRateLimiter limiter; + QDateTime now = timeAt(1000); + + QDateTime deadline = limiter.on429(HOST, now, 5 * 1000); + + EXPECT_EQ(now.addSecs(30), deadline); +} + +TEST(ServerRateLimiterTest, Concurrent429sDoNotEscalateOrShorten) +{ + ServerRateLimiter limiter; + QDateTime now = timeAt(1000); + + QDateTime first = limiter.on429(HOST, now); + limiter.on429(HOST, now.addMSecs(100)); + limiter.on429(HOST, now.addMSecs(200)); + + // The burst counts as a single round, but each 429 slides the deadline forward. + EXPECT_EQ(1, limiter.rounds(HOST)); + EXPECT_EQ(now.addMSecs(30200), limiter.deadline(HOST)); + EXPECT_TRUE(limiter.deadline(HOST) >= first); +} + +TEST(ServerRateLimiterTest, EscalatesAfterWindowPasses) +{ + ServerRateLimiter limiter; + QDateTime now = timeAt(1000); + + limiter.on429(HOST, now); + limiter.on429(HOST, now.addSecs(31)); + + EXPECT_EQ(2, limiter.rounds(HOST)); +} + +TEST(ServerRateLimiterTest, BudgetExhaustionAppliesCooldown) +{ + ServerRateLimiter limiter; + QDateTime now = timeAt(1000); + + QDateTime deadline; + for (int i = 0; i < ServerRateLimiter::MAX_RETRIES; ++i) { + deadline = limiter.on429(HOST, now.addSecs(31 * i)); + } + + EXPECT_EQ(ServerRateLimiter::MAX_RETRIES, limiter.rounds(HOST)); + EXPECT_EQ(now.addSecs(31 * (ServerRateLimiter::MAX_RETRIES - 1) + 60), deadline); + + // A 429 inside the exhausted cooldown does not escalate, but keeps sliding the cooldown forward. + limiter.on429(HOST, now.addSecs(31 * (ServerRateLimiter::MAX_RETRIES - 1) + 30)); + EXPECT_EQ(ServerRateLimiter::MAX_RETRIES, limiter.rounds(HOST)); + EXPECT_EQ(now.addSecs(31 * (ServerRateLimiter::MAX_RETRIES - 1) + 30 + 60), limiter.deadline(HOST)); +} + +TEST(ServerRateLimiterTest, SuccessClearsPenalty) +{ + ServerRateLimiter limiter; + QDateTime now = timeAt(1000); + + limiter.on429(HOST, now); + limiter.onSuccess(HOST); + + EXPECT_EQ(0, limiter.rounds(HOST)); + EXPECT_FALSE(limiter.isRateLimited(HOST, now)); +} + +TEST(ServerRateLimiterTest, ClearExpiredRefreshesStaleBudget) +{ + ServerRateLimiter limiter; + QDateTime now = timeAt(1000); + + limiter.on429(HOST, now); + limiter.clearExpired(now.addSecs(400)); + + EXPECT_EQ(0, limiter.rounds(HOST)); + EXPECT_FALSE(limiter.isRateLimited(HOST, now.addSecs(400))); +} + +TEST(ServerRateLimiterTest, ClearExpiredKeepsRecentBudget) +{ + ServerRateLimiter limiter; + QDateTime now = timeAt(1000); + + for (int i = 0; i < ServerRateLimiter::MAX_RETRIES; ++i) { + limiter.on429(HOST, now.addSecs(31 * i)); + } + limiter.clearExpired(now.addSecs(200)); + + EXPECT_EQ(ServerRateLimiter::MAX_RETRIES, limiter.rounds(HOST)); + EXPECT_FALSE(limiter.isRateLimited(HOST, now.addSecs(200))); +} + +TEST(ServerRateLimiterTest, EarliestDeadlineAcrossHosts) +{ + ServerRateLimiter limiter; + QDateTime now = timeAt(1000); + + limiter.on429("api.scryfall.com", now, 40 * 1000); + limiter.on429("gatherer.wizards.com", now.addSecs(5)); + + EXPECT_EQ(now.addSecs(35), limiter.earliestDeadline(now)); + EXPECT_EQ(now.addSecs(40), limiter.deadline("api.scryfall.com")); + EXPECT_EQ(now.addSecs(40), limiter.earliestDeadline(now.addSecs(36))); +} + +} // namespace + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/settings/CMakeLists.txt b/tests/settings/CMakeLists.txt new file mode 100644 index 000000000..7ee32a645 --- /dev/null +++ b/tests/settings/CMakeLists.txt @@ -0,0 +1,26 @@ +add_executable(settings_manager_test settings_manager_test.cpp) +add_executable(settings_defaults_test settings_defaults_test.cpp) +add_executable(settings_migration_test settings_migration_test.cpp) + +target_link_libraries( + settings_manager_test libcockatrice_settings Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} +) + +target_link_libraries( + settings_defaults_test libcockatrice_settings Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} +) +target_compile_definitions(settings_defaults_test PRIVATE SETTINGS_UNIT_TEST) + +target_link_libraries( + settings_migration_test libcockatrice_settings Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} +) + +add_test(NAME settings_manager_test COMMAND settings_manager_test) +add_test(NAME settings_defaults_test COMMAND settings_defaults_test) +add_test(NAME settings_migration_test COMMAND settings_migration_test) + +if(NOT GTEST_FOUND) + add_dependencies(settings_manager_test gtest) + add_dependencies(settings_defaults_test gtest) + add_dependencies(settings_migration_test gtest) +endif() diff --git a/tests/settings/settings_defaults_test.cpp b/tests/settings/settings_defaults_test.cpp new file mode 100644 index 000000000..c8c5ad4c8 --- /dev/null +++ b/tests/settings/settings_defaults_test.cpp @@ -0,0 +1,612 @@ +#include "gtest/gtest.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + +class SettingsDefaultsTest : public ::testing::Test +{ +protected: + QTemporaryDir tempDir; + QString settingsPath; + + void SetUp() override + { + settingsPath = tempDir.path() + "/"; + } +}; + +// --- CacheStorageSettings --- + +TEST_F(SettingsDefaultsTest, CacheStorage_PixmapCacheSize_Default) +{ + CacheStorageSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getPixmapCacheSize(), PIXMAPCACHE_SIZE_DEFAULT); +} + +TEST_F(SettingsDefaultsTest, CacheStorage_PixmapCacheSize_SetAndGet) +{ + CacheStorageSettings s(settingsPath, nullptr); + s.setPixmapCacheSize(1024); + ASSERT_EQ(s.getPixmapCacheSize(), 1024); +} + +TEST_F(SettingsDefaultsTest, CacheStorage_NetworkCacheSizeInMB_Default) +{ + CacheStorageSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getNetworkCacheSizeInMB(), NETWORK_CACHE_SIZE_DEFAULT); +} + +TEST_F(SettingsDefaultsTest, CacheStorage_RedirectCacheTtl_Default) +{ + CacheStorageSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getRedirectCacheTtl(), NETWORK_REDIRECT_CACHE_TTL_DEFAULT); +} + +TEST_F(SettingsDefaultsTest, CacheStorage_CardPictureLoaderCacheMethod_Default) +{ + CacheStorageSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getCardPictureLoaderCacheMethod(), 0); +} + +TEST_F(SettingsDefaultsTest, CacheStorage_LocalCardImageStorageNamingScheme_Default) +{ + CacheStorageSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getLocalCardImageStorageNamingScheme(), LOCAL_CARD_IMAGE_NAMING_SCHEME_DEFAULT); +} + +// --- GameSettings --- + +TEST_F(SettingsDefaultsTest, Game_MaxPlayers_Default) +{ + GameSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getMaxPlayers(), 2); +} + +TEST_F(SettingsDefaultsTest, Game_MaxPlayers_SetAndGet) +{ + GameSettings s(settingsPath, nullptr); + s.setMaxPlayers(4); + ASSERT_EQ(s.getMaxPlayers(), 4); +} + +TEST_F(SettingsDefaultsTest, Game_DefaultStartingLifeTotal_Default) +{ + GameSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getDefaultStartingLifeTotal(), 20); +} + +TEST_F(SettingsDefaultsTest, Game_RememberGameSettings_Default) +{ + GameSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getRememberGameSettings(), true); +} + +TEST_F(SettingsDefaultsTest, Game_LocalGameMaxPlayers_Default) +{ + GameSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getLocalGameMaxPlayers(), 1); +} + +TEST_F(SettingsDefaultsTest, Game_LocalGameStartingLifeTotal_Default) +{ + GameSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getLocalGameStartingLifeTotal(), 20); +} + +TEST_F(SettingsDefaultsTest, Game_LocalGameRememberSettings_Default) +{ + GameSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getLocalGameRememberSettings(), false); +} + +// --- UpdatesSettings --- + +TEST_F(SettingsDefaultsTest, Updates_CheckUpdatesOnStartup_Default) +{ + UpdatesSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getCheckUpdatesOnStartup(), true); +} + +TEST_F(SettingsDefaultsTest, Updates_StartupCardUpdateCheckPromptForUpdate_Default) +{ + UpdatesSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getStartupCardUpdateCheckPromptForUpdate(), true); +} + +TEST_F(SettingsDefaultsTest, Updates_StartupCardUpdateCheckAlwaysUpdate_Default) +{ + UpdatesSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getStartupCardUpdateCheckAlwaysUpdate(), false); +} + +TEST_F(SettingsDefaultsTest, Updates_CardUpdateCheckInterval_Default) +{ + UpdatesSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getCardUpdateCheckInterval(), 7); +} + +TEST_F(SettingsDefaultsTest, Updates_AlwaysEnableNewSets_Default) +{ + UpdatesSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getAlwaysEnableNewSets(), false); +} + +TEST_F(SettingsDefaultsTest, Updates_NotifyAboutUpdates_Default) +{ + UpdatesSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getNotifyAboutUpdates(), true); +} + +TEST_F(SettingsDefaultsTest, Updates_NotifyAboutNewVersion_Default) +{ + UpdatesSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getNotifyAboutNewVersion(), true); +} + +TEST_F(SettingsDefaultsTest, Updates_UpdateReleaseChannelIndex_Default) +{ + UpdatesSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getUpdateReleaseChannelIndex(), 0); +} + +// --- SoundSettings --- + +TEST_F(SettingsDefaultsTest, Sound_SoundEnabled_Default) +{ + SoundSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getSoundEnabled(), false); +} + +TEST_F(SettingsDefaultsTest, Sound_MasterVolume_Default) +{ + SoundSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getMasterVolume(), 100); +} + +TEST_F(SettingsDefaultsTest, Sound_MasterVolume_SetAndGet) +{ + SoundSettings s(settingsPath, nullptr); + s.setMasterVolume(50); + ASSERT_EQ(s.getMasterVolume(), 50); +} + +// --- TabsSettings --- + +TEST_F(SettingsDefaultsTest, Tabs_StartupTab_Default) +{ + TabsSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getStartupTabIndex(), static_cast(StartupTab::StartupTabHome)); +} + +TEST_F(SettingsDefaultsTest, Tabs_StartupTab_SetAndGet) +{ + TabsSettings s(settingsPath, nullptr); + s.setStartupTabIndex(StartupTab::StartupTabServerRoom); + ASSERT_EQ(s.getStartupTabIndex(), static_cast(StartupTab::StartupTabServerRoom)); +} + +TEST_F(SettingsDefaultsTest, Tabs_StartupServer_Default) +{ + TabsSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getStartupServerHost(), QString()); + ASSERT_EQ(s.getStartupServerPort(), QString()); + ASSERT_EQ(s.getStartupRoomName(), QString()); +} + +TEST_F(SettingsDefaultsTest, Tabs_StartupServer_SetAndGet) +{ + TabsSettings s(settingsPath, nullptr); + s.setStartupServerHost("server.cockatrice.us"); + s.setStartupServerPort("4748"); + s.setStartupRoomName("General"); + ASSERT_EQ(s.getStartupServerHost(), QString("server.cockatrice.us")); + ASSERT_EQ(s.getStartupServerPort(), QString("4748")); + ASSERT_EQ(s.getStartupRoomName(), QString("General")); +} + +TEST_F(SettingsDefaultsTest, Tabs_AllTabsOpen_Default) +{ + TabsSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getTabVisualDeckStorageOpen(), true); + ASSERT_EQ(s.getTabServerOpen(), true); + ASSERT_EQ(s.getTabAccountOpen(), true); + ASSERT_EQ(s.getTabDeckStorageOpen(), true); + ASSERT_EQ(s.getTabReplaysOpen(), true); + ASSERT_EQ(s.getTabAdminOpen(), true); + ASSERT_EQ(s.getTabLogOpen(), true); +} + +TEST_F(SettingsDefaultsTest, Tabs_ModerationOpen_Default) +{ + TabsSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getTabModerationOpen(), false); +} + +TEST_F(SettingsDefaultsTest, Tabs_CardArtRulesOpen_Default) +{ + TabsSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getTabCardArtRulesOpen(), false); +} + +// --- ChatSettings --- + +TEST_F(SettingsDefaultsTest, Chat_Mention_Default) +{ + ChatSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getChatMention(), true); +} + +TEST_F(SettingsDefaultsTest, Chat_MentionCompleter_Default) +{ + ChatSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getChatMentionCompleter(), true); +} + +TEST_F(SettingsDefaultsTest, Chat_MentionColor_Default) +{ + ChatSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getChatMentionColor(), QString("A6120D")); +} + +TEST_F(SettingsDefaultsTest, Chat_HighlightColor_Default) +{ + ChatSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getChatHighlightColor(), QString("A6120D")); +} + +TEST_F(SettingsDefaultsTest, Chat_MentionForeground_Default) +{ + ChatSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getChatMentionForeground(), true); +} + +TEST_F(SettingsDefaultsTest, Chat_HighlightForeground_Default) +{ + ChatSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getChatHighlightForeground(), true); +} + +TEST_F(SettingsDefaultsTest, Chat_ShowMessagePopup_Default) +{ + ChatSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getShowMessagePopup(), true); +} + +TEST_F(SettingsDefaultsTest, Chat_ShowMentionPopup_Default) +{ + ChatSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getShowMentionPopup(), true); +} + +TEST_F(SettingsDefaultsTest, Chat_RoomHistory_Default) +{ + ChatSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getRoomHistory(), true); +} + +TEST_F(SettingsDefaultsTest, Chat_IgnoreAllPrivateMessages_Default) +{ + ChatSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getIgnoreAllPrivateMessages(), false); +} + +// --- PersonalSettings --- + +TEST_F(SettingsDefaultsTest, Personal_Lang_Default) +{ + PersonalSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getLang(), QString("")); +} + +TEST_F(SettingsDefaultsTest, Personal_ShowTipsOnStartup_Default) +{ + PersonalSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getShowTipsOnStartup(), true); +} + +// --- DownloadSettings --- + +TEST_F(SettingsDefaultsTest, Download_PicDownload_Default) +{ + DownloadSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getPicDownload(), true); +} + +TEST_F(SettingsDefaultsTest, Download_DownloadSpoilersStatus_Default) +{ + DownloadSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getDownloadSpoilersStatus(), false); +} + +// --- AppearanceSettings --- + +TEST_F(SettingsDefaultsTest, Appearance_ThemeName_Default) +{ + AppearanceSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getThemeName(), QString("")); +} + +TEST_F(SettingsDefaultsTest, Appearance_ThemeName_SetAndGet) +{ + AppearanceSettings s(settingsPath, nullptr); + s.setThemeName("my_theme"); + ASSERT_EQ(s.getThemeName(), QString("my_theme")); +} + +TEST_F(SettingsDefaultsTest, Appearance_StyleUserList_Default) +{ + AppearanceSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getStyleUserList(), true); +} + +TEST_F(SettingsDefaultsTest, Appearance_MaxFontSize_Default) +{ + AppearanceSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getMaxFontSize(), 12); +} + +TEST_F(SettingsDefaultsTest, Appearance_MaxFontSize_SetAndGet) +{ + AppearanceSettings s(settingsPath, nullptr); + s.setMaxFontSize(14); + ASSERT_EQ(s.getMaxFontSize(), 14); +} + +TEST_F(SettingsDefaultsTest, Appearance_HomeTabBackgroundSource_Default) +{ + AppearanceSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getHomeTabBackgroundSource(), QString("themed")); +} + +TEST_F(SettingsDefaultsTest, Appearance_HomeTabBackgroundShuffleFrequency_Default) +{ + AppearanceSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getHomeTabBackgroundShuffleFrequency(), 0); +} + +TEST_F(SettingsDefaultsTest, Appearance_HomeTabDisplayCardName_Default) +{ + AppearanceSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getHomeTabDisplayCardName(), true); +} + +TEST_F(SettingsDefaultsTest, Appearance_HomeTabBackgroundDim_Default) +{ + AppearanceSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getHomeTabBackgroundDim(), true); +} + +TEST_F(SettingsDefaultsTest, Appearance_HomeTabBackgroundDim_SetAndGet) +{ + AppearanceSettings s(settingsPath, nullptr); + s.setHomeTabBackgroundDim(false); + ASSERT_EQ(s.getHomeTabBackgroundDim(), false); +} + +// --- InterfaceSettings --- + +TEST_F(SettingsDefaultsTest, Interface_ShowStatusBar_Default) +{ + InterfaceSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getShowStatusBar(), false); +} + +TEST_F(SettingsDefaultsTest, Interface_ShowShortcuts_Default) +{ + InterfaceSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getShowShortcuts(), true); +} + +TEST_F(SettingsDefaultsTest, Interface_ShowGameSelectorFilterToolbar_Default) +{ + InterfaceSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getShowGameSelectorFilterToolbar(), true); +} + +TEST_F(SettingsDefaultsTest, Interface_NotificationsEnabled_Default) +{ + InterfaceSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getNotificationsEnabled(), true); +} + +TEST_F(SettingsDefaultsTest, Interface_SpectatorNotificationsEnabled_Default) +{ + InterfaceSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getSpectatorNotificationsEnabled(), false); +} + +TEST_F(SettingsDefaultsTest, Interface_BuddyConnectNotificationsEnabled_Default) +{ + InterfaceSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getBuddyConnectNotificationsEnabled(), true); +} + +// --- DeckEditorSettings --- + +TEST_F(SettingsDefaultsTest, DeckEditor_OpenDeckInNewTab_Default) +{ + DeckEditorSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getOpenDeckInNewTab(), false); +} + +TEST_F(SettingsDefaultsTest, DeckEditor_BannerCardComboBoxVisible_Default) +{ + DeckEditorSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getBannerCardComboBoxVisible(), true); +} + +TEST_F(SettingsDefaultsTest, DeckEditor_TagsWidgetVisible_Default) +{ + DeckEditorSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getTagsWidgetVisible(), true); +} + +TEST_F(SettingsDefaultsTest, DeckEditor_DefaultDeckEditorType_Default) +{ + DeckEditorSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getDefaultDeckEditorType(), 1); +} + +TEST_F(SettingsDefaultsTest, DeckEditor_VdeStartupTab_Default) +{ + DeckEditorSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getVdeStartupTab(), VdeStartupTabContext); +} + +TEST_F(SettingsDefaultsTest, DeckEditor_VdeStartupTab_SetAndGet) +{ + DeckEditorSettings s(settingsPath, nullptr); + s.setVdeStartupTab(VdeStartupTabDeckDisplay); + ASSERT_EQ(s.getVdeStartupTab(), VdeStartupTabDeckDisplay); + s.setVdeStartupTab(VdeStartupTabDatabaseDisplay); + ASSERT_EQ(s.getVdeStartupTab(), VdeStartupTabDatabaseDisplay); + s.setVdeStartupTab(VdeStartupTabContext); + ASSERT_EQ(s.getVdeStartupTab(), VdeStartupTabContext); +} + +// --- NetworkSettings --- + +TEST_F(SettingsDefaultsTest, Network_ClientID_Default) +{ + NetworkSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getClientID(), QString("notset")); +} + +TEST_F(SettingsDefaultsTest, Network_ClientVersion_Default) +{ + NetworkSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getClientVersion(), QString("notset")); +} + +TEST_F(SettingsDefaultsTest, Network_KeepAlive_Default) +{ + NetworkSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getKeepAlive(), 3); +} + +TEST_F(SettingsDefaultsTest, Network_TimeOut_Default) +{ + NetworkSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getTimeOut(), 5); +} + +TEST_F(SettingsDefaultsTest, Network_KnownMissingFeatures_Default) +{ + NetworkSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getKnownMissingFeatures(), QString("")); +} + +// --- CardsDisplaySettings --- + +TEST_F(SettingsDefaultsTest, CardsDisplay_PrintingSelectorCardSize_Default) +{ + CardsDisplaySettings s(settingsPath, nullptr); + ASSERT_EQ(s.getPrintingSelectorCardSize(), 100); +} + +TEST_F(SettingsDefaultsTest, CardsDisplay_VisualDeckStorageCardSize_Default) +{ + CardsDisplaySettings s(settingsPath, nullptr); + ASSERT_EQ(s.getVisualDeckStorageCardSize(), 100); +} + +TEST_F(SettingsDefaultsTest, CardsDisplay_VisualDatabaseDisplayCardSize_Default) +{ + CardsDisplaySettings s(settingsPath, nullptr); + ASSERT_EQ(s.getVisualDatabaseDisplayCardSize(), 100); +} + +TEST_F(SettingsDefaultsTest, CardsDisplay_VisualDeckEditorCardSize_Default) +{ + CardsDisplaySettings s(settingsPath, nullptr); + ASSERT_EQ(s.getVisualDeckEditorCardSize(), 100); +} + +TEST_F(SettingsDefaultsTest, CardsDisplay_EDHRecCardSize_Default) +{ + CardsDisplaySettings s(settingsPath, nullptr); + ASSERT_EQ(s.getEDHRecCardSize(), 100); +} + +TEST_F(SettingsDefaultsTest, CardsDisplay_ArchidektPreviewSize_Default) +{ + CardsDisplaySettings s(settingsPath, nullptr); + ASSERT_EQ(s.getArchidektPreviewSize(), 100); +} + +TEST_F(SettingsDefaultsTest, CardsDisplay_SampleHandSize_Default) +{ + CardsDisplaySettings s(settingsPath, nullptr); + ASSERT_EQ(s.getSampleHandSize(), 7); +} + +TEST_F(SettingsDefaultsTest, CardsDisplay_ArrowDrawAnimation_Default) +{ + CardsDisplaySettings s(settingsPath, nullptr); + ASSERT_EQ(s.getArrowDrawAnimation(), true); +} + +TEST_F(SettingsDefaultsTest, CardsDisplay_CardLang_Default) +{ + CardsDisplaySettings s(settingsPath, nullptr); + ASSERT_EQ(s.getCardLang(), QString("en")); +} + +TEST_F(SettingsDefaultsTest, CardsDisplay_CardLang_SetAndGet) +{ + CardsDisplaySettings s(settingsPath, nullptr); + s.setCardLang("de"); + ASSERT_EQ(s.getCardLang(), QString("de")); +} + +// --- VisualDeckStorageSettings --- + +TEST_F(SettingsDefaultsTest, VisualDeckStorage_SortingOrder_Default) +{ + VisualDeckStorageSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getVisualDeckStorageSortingOrder(), 0); +} + +TEST_F(SettingsDefaultsTest, VisualDeckStorage_ShowFolders_Default) +{ + VisualDeckStorageSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getVisualDeckStorageShowFolders(), true); +} + +TEST_F(SettingsDefaultsTest, VisualDeckStorage_DefaultTagsList_NotEmpty) +{ + VisualDeckStorageSettings s(settingsPath, nullptr); + ASSERT_GT(s.getVisualDeckStorageDefaultTagsList().size(), 0); +} + +TEST_F(SettingsDefaultsTest, VisualDeckStorage_DefaultTagsList_SetAndGet) +{ + VisualDeckStorageSettings s(settingsPath, nullptr); + QStringList custom = {"Tag1", "Tag2"}; + s.setVisualDeckStorageDefaultTagsList(custom); + ASSERT_EQ(s.getVisualDeckStorageDefaultTagsList(), custom); +} + +} // namespace + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/settings/settings_manager_test.cpp b/tests/settings/settings_manager_test.cpp new file mode 100644 index 000000000..51eb66dbb --- /dev/null +++ b/tests/settings/settings_manager_test.cpp @@ -0,0 +1,171 @@ +#include "gtest/gtest.h" +#include +#include +#include + +namespace +{ + +class SettingsManagerTest : public ::testing::Test +{ +protected: + QTemporaryDir tempDir; + QString settingsPath; + + void SetUp() override + { + settingsPath = tempDir.path() + "/"; + } + + void writeValue(const QString &file, const QString &key, const QVariant &value) + { + QSettings qs(file, QSettings::IniFormat); + qs.setValue(key, value); + } +}; + +TEST_F(SettingsManagerTest, GetValueNoDefaultReturnsInvalid) +{ + SettingsManager sm(settingsPath + "test.ini"); + QVariant val = sm.getValue("nonexistent"); + ASSERT_TRUE(val.isNull()); +} + +TEST_F(SettingsManagerTest, GetValueWithDefaultReturnsDefaultWhenMissing) +{ + SettingsManager sm(settingsPath + "test.ini"); + QVariant val = sm.getValue("nonexistent", QString(), QString(), 42); + ASSERT_EQ(val.toInt(), 42); +} + +TEST_F(SettingsManagerTest, GetValueWithDefaultReturnsStoredWhenPresent) +{ + writeValue(settingsPath + "test.ini", "mykey", 99); + + SettingsManager sm(settingsPath + "test.ini"); + QVariant val = sm.getValue("mykey", QString(), QString(), 42); + ASSERT_EQ(val.toInt(), 99); +} + +TEST_F(SettingsManagerTest, GetValueWithGroup) +{ + writeValue(settingsPath + "grouped.ini", "group1/greeting", "world"); + + SettingsManager sm(settingsPath + "grouped.ini"); + QVariant val = sm.getValue("greeting", "group1"); + ASSERT_EQ(val.toString(), QString("world")); +} + +TEST_F(SettingsManagerTest, GetValueWithGroupAndSubGroup) +{ + writeValue(settingsPath + "nested.ini", "outer/inner/depth", 1); + + SettingsManager sm(settingsPath + "nested.ini"); + QVariant val = sm.getValue("depth", "outer", "inner"); + ASSERT_EQ(val.toInt(), 1); +} + +TEST_F(SettingsManagerTest, DefaultGroupUsedInGetValue) +{ + writeValue(settingsPath + "defgroup.ini", "defaultGroup/key", "val"); + + SettingsManager sm(settingsPath + "defgroup.ini", "defaultGroup"); + QVariant val = sm.getValue("key"); + ASSERT_EQ(val.toString(), QString("val")); +} + +TEST_F(SettingsManagerTest, DefaultGroupScopesGetValue) +{ + writeValue(settingsPath + "scoped.ini", "key", "ungrouped"); + writeValue(settingsPath + "scoped.ini", "defaultGroup/key", "grouped"); + + SettingsManager sm(settingsPath + "scoped.ini", "defaultGroup"); + QVariant val = sm.getValue("key"); + ASSERT_EQ(val.toString(), QString("grouped")); +} + +TEST_F(SettingsManagerTest, DefaultSubGroupUsedInGetValue) +{ + writeValue(settingsPath + "sub.ini", "top/bottom/key", "nested"); + + SettingsManager sm(settingsPath + "sub.ini", "top", "bottom"); + QVariant val = sm.getValue("key"); + ASSERT_EQ(val.toString(), QString("nested")); +} + +TEST_F(SettingsManagerTest, BatchWriteWritesAllValues) +{ + SettingsManager sm(settingsPath + "batch.ini"); + sm.batchWrite([&](QSettings &qs) { + qs.setValue("a", 1); + qs.setValue("b", 2); + qs.setValue("c", 3); + }); + + SettingsManager sm2(settingsPath + "batch.ini"); + ASSERT_EQ(sm2.getValue("a", QString(), QString(), 0).toInt(), 1); + ASSERT_EQ(sm2.getValue("b", QString(), QString(), 0).toInt(), 2); + ASSERT_EQ(sm2.getValue("c", QString(), QString(), 0).toInt(), 3); +} + +TEST_F(SettingsManagerTest, BatchWriteWithGroup) +{ + SettingsManager sm(settingsPath + "batch_group.ini"); + sm.batchWrite([&](QSettings &qs) { + qs.beginGroup("section"); + qs.setValue("x", 10); + qs.endGroup(); + }); + + SettingsManager sm2(settingsPath + "batch_group.ini"); + ASSERT_EQ(sm2.getValue("x", "section", QString(), 0).toInt(), 10); +} + +TEST_F(SettingsManagerTest, SyncPersistsToDisk) +{ + QString filePath = settingsPath + "sync_test.ini"; + { + SettingsManager sm(filePath); + sm.batchWrite([&](QSettings &qs) { qs.setValue("persist", "yes"); }); + } + { + QSettings qs(filePath, QSettings::IniFormat); + ASSERT_EQ(qs.value("persist").toString(), QString("yes")); + } +} + +TEST_F(SettingsManagerTest, MultipleReadsAfterWrite) +{ + SettingsManager sm(settingsPath + "multi.ini"); + sm.batchWrite([&](QSettings &qs) { qs.setValue("k1", 1); }); + + QVariant v1 = sm.getValue("k1", QString(), QString(), 0); + QVariant v2 = sm.getValue("nonexistent", QString(), QString(), 99); + + ASSERT_EQ(v1.toInt(), 1); + ASSERT_EQ(v2.toInt(), 99); +} + +TEST_F(SettingsManagerTest, ValuesDeletedViaQSettingsAreReadAsDefault) +{ + writeValue(settingsPath + "del.ini", "deleteMe", "present"); + + SettingsManager sm(settingsPath + "del.ini"); + ASSERT_EQ(sm.getValue("deleteMe", QString(), QString(), "default").toString(), QString("present")); + + // Delete via QSettings directly (deleteValue is protected) + { + QSettings qs(settingsPath + "del.ini", QSettings::IniFormat); + qs.remove("deleteMe"); + } + + ASSERT_EQ(sm.getValue("deleteMe", QString(), QString(), "default").toString(), QString("default")); +} + +} // namespace + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/settings/settings_migration_test.cpp b/tests/settings/settings_migration_test.cpp new file mode 100644 index 000000000..8a3d756a0 --- /dev/null +++ b/tests/settings/settings_migration_test.cpp @@ -0,0 +1,710 @@ +#include "gtest/gtest.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + +class SettingsMigrationTest : public ::testing::Test +{ +protected: + QTemporaryDir tempDir; + QString settingsPath; + + void SetUp() override + { + settingsPath = tempDir.path() + "/"; + + // Isolate the settings used by the legacy migration tests inside the temporary + // directory so the tests never read or write the real user config (registry on + // Windows, plist on macOS, .conf on Linux), which would otherwise be shared + // across CI jobs and flaky. Default-format QSettings is forced to IniFormat and + // its UserScope path is redirected here; setPath wins over the XDG_CONFIG_HOME + // default on Unix, so every platform resolves to /config/... + const QString configDir = tempDir.path() + "/config"; + QDir().mkpath(configDir); + qputenv("XDG_CONFIG_HOME", configDir.toUtf8()); + QSettings::setPath(QSettings::IniFormat, QSettings::UserScope, configDir); + } + + bool fileExists(const QString &name) const + { + return QFile::exists(settingsPath + name); + } + + QVariant readFromIni(const QString &fileName, const QString &key) const + { + QSettings ini(settingsPath + fileName, QSettings::IniFormat); + return ini.value(key); + } + + // Checks for the exact legacy key in the raw INI content. On Windows, INI keys are + // case-insensitive, so reading back a lower-case legacy key would match its migrated + // camelCase counterpart and hide regressions. Checking the file bytes directly keeps + // the comparison case-sensitive on every platform. + bool iniFileHasKeyCaseSensitive(const QString &fileName, const QString &key) const + { + QFile f(settingsPath + fileName); + if (!f.open(QIODevice::ReadOnly)) { + return false; + } + const QString valueName = key.section('/', -1); + const QStringList lines = QString::fromUtf8(f.readAll()).split('\n'); + for (const auto &line : lines) { + if (line.startsWith(valueName + '=')) { + return true; + } + } + return false; + } +}; + +TEST_F(SettingsMigrationTest, NoGlobalIniDoesNothing) +{ + bool result = SettingsMigration::migrateSettingsFromGlobalIni(settingsPath); + ASSERT_FALSE(result); +} + +TEST_F(SettingsMigrationTest, EmptyGlobalIniGetsBackedUp) +{ + { + QFile f(settingsPath + "global.ini"); + ASSERT_TRUE(f.open(QIODevice::WriteOnly)); + f.close(); + } + bool result = SettingsMigration::migrateSettingsFromGlobalIni(settingsPath); + ASSERT_TRUE(result); + ASSERT_TRUE(fileExists("global.ini.old")); + ASSERT_TRUE(fileExists("global.ini")); + + // No per-file INIs should be created + ASSERT_FALSE(fileExists("tabs.ini")); + ASSERT_FALSE(fileExists("sound.ini")); + + // Sentinel should be in the new global.ini + ASSERT_EQ(readFromIni("global.ini", "migration/perfile_complete"), QVariant(true)); +} + +TEST_F(SettingsMigrationTest, MigratesAllSettingsGroups) +{ + { + QSettings g(settingsPath + "global.ini", QSettings::IniFormat); + + // tabs + g.setValue("tabs/visualDeckStorage", false); + g.setValue("tabs/server", true); + g.setValue("tabs/account", true); + g.setValue("tabs/deckStorage", true); + g.setValue("tabs/replays", false); + g.setValue("tabs/admin", false); + g.setValue("tabs/log", true); + + // sound + g.setValue("sound/enabled", true); + g.setValue("sound/theme", "custom_theme"); + g.setValue("sound/mastervolume", 75); + + // game + g.beginGroup("game"); + g.setValue("maxplayers", 4); + g.setValue("gamedescription", "test game"); + g.setValue("remembergamesettings", false); + g.setValue("gametypes", "commander"); + g.setValue("onlybuddies", true); + g.endGroup(); + + // localgameoptions + g.beginGroup("localgameoptions"); + g.setValue("maxplayers", 2); + g.setValue("startinglifetotal", 40); + g.endGroup(); + + // chat + g.beginGroup("chat"); + g.setValue("mention", false); + g.setValue("mentioncolor", "FF0000"); + g.setValue("showmessagepopups", false); + g.setValue("mentioncompleter", false); + g.setValue("roomhistory", false); + g.setValue("highlightcolor", "00FF00"); + g.endGroup(); + + // legacy highlight words (under [personal]) + g.setValue("personal/highlightWords", "alpha beta"); + + // cache storage (under [personal] group) + g.setValue("personal/pixmapCacheSize", 1024); + g.setValue("personal/networkCacheSize", 2048); + g.setValue("personal/redirectCacheTtl", 5); + g.setValue("personal/cardPictureLoaderCacheMethod", 1); + g.setValue("personal/localCardImageStorageNamingScheme", 2); + + // updates (under [personal] group) + g.setValue("personal/startupUpdateCheck", false); + g.setValue("personal/startupCardUpdateCheckPromptForUpdate", false); + g.setValue("personal/startupCardUpdateCheckAlwaysUpdate", true); + g.setValue("personal/cardUpdateCheckInterval", 14); + g.setValue("personal/lastCardUpdateCheck", QDate(2024, 1, 1)); + g.setValue("personal/alwaysEnableNewSets", true); + g.setValue("personal/updatenotification", false); + g.setValue("personal/newversionnotification", false); + + // personal + g.setValue("personal/lang", "de"); + + // downloads (previously under [personal]) + g.setValue("personal/picturedownload", true); + g.setValue("personal/downloadspoilers", true); + + // interface (previously under [personal]) + g.setValue("personal/showStatusBar", true); + + // theme + g.setValue("theme/name", "custom_theme"); + g.setValue("game/maxfontsize", 14); + + // appearance + g.setValue("appearance/styleUserList", false); + g.setValue("home/background/displayCardName", false); + g.setValue("menu/showshortcuts", false); + g.setValue("menu/showgameselectorfiltertoolbar", false); + + // deck editor + g.setValue("editor/openDeckInNewTab", false); + g.setValue("interface/deckeditortagswidgetvisible", false); + g.setValue("interface/defaultDeckEditorType", 0); + g.setValue("interface/visualdeckeditorsamplehandsize", 5); + + // personal home + g.setValue("home/background", "custom_bg"); + g.setValue("home/background/shuffleTimer", 30); + + // personal tipOfDay + g.setValue("tipOfDay/showTips", false); + g.setValue("tipOfDay/seenTips", QStringList{"1", "2", "3"}); + + // network + g.setValue("personal/keepalive", 10); + g.setValue("personal/timeout", 30); + g.setValue("personal/clientid", "test-client-id"); + g.setValue("personal/clientversion", "test-client-version"); + g.setValue("interface/knownmissingfeatures", "feature1,feature2"); + + // cards + g.setValue("cards/displaycardnames", false); + g.setValue("cards/roundcardcorners", false); + g.setValue("cards/overrideallcardartwithpersonalpreference", true); + g.setValue("cards/bumpsetswithcardsindecktotop", false); + g.setValue("cards/includerebalancedcards", false); + g.setValue("cards/autorotatesidewayslayoutcards", false); + g.setValue("cards/tapanimation", true); + g.setValue("cards/scaleCards", false); + g.setValue("cards/verticalCardOverlapPercent", 42); + g.setValue("cards/cardinfoviewmode", 1); + g.setValue("cards/printingselectorcardsize", 90); + g.setValue("cards/printingselectorsortorder", 3); + g.setValue("cards/printingselectornavigationbuttonsvisible", false); + // card counters (migrate into card_counters.ini) + g.setValue("cards/counters/0/color", QColor(Qt::red)); + + // interface + g.setValue("interface/usetearoffmenus", true); + g.setValue("interface/cardViewInitialRowsMax", 8); + g.setValue("interface/cardViewExpandedRowsMax", 12); + g.setValue("interface/closeEmptyCardView", false); + g.setValue("interface/focusCardViewSearchBar", false); + g.setValue("interface/keepGameChatFocus", true); + g.setValue("interface/notificationsenabled", false); + g.setValue("interface/specnotificationsenabled", true); + g.setValue("interface/buddyconnectnotificationsenabled", false); + g.setValue("interface/doubleclicktoplay", false); + g.setValue("interface/clickPlaysAllSelected", false); + g.setValue("interface/playtostack", false); + g.setValue("interface/doNotDeleteArrowsInSubPhases", false); + g.setValue("interface/startinghandsize", 5); + g.setValue("interface/annotatetokens", true); + g.setValue("interface/showlassoselectioncount", false); + g.setValue("interface/showpersistentselectioncount", false); + g.setValue("interface/tallyType", 2); + g.setValue("interface/leftjustified", true); + g.setValue("interface/min_players_multicolumn", 6); + g.setValue("interface/deckeditorbannercardcomboboxvisible", false); + // hand/table/replay/zoneview + g.setValue("hand/horizontal", true); + g.setValue("table/invert_vertical", true); + g.setValue("replay/rewindBufferingMs", 6000); + g.setValue("replay/fastForwardSpeed", 5); + g.setValue("zoneview/groupby", 2); + g.setValue("zoneview/sortby", 1); + g.setValue("zoneview/pileview", false); + + // paths + g.beginGroup("paths"); + g.setValue("decks", "/custom/decks"); + g.setValue("pics", "/custom/pics"); + g.endGroup(); + + // visual deck storage (under [interface] group) + g.setValue("interface/visualdeckstoragecardsize", 150); + g.setValue("interface/visualdeckstoragesortingorder", 2); + g.setValue("interface/visualdeckstorageshowfolders", false); + g.setValue("interface/visualdeckstorageshowtagfilter", false); + g.setValue("interface/visualdeckstoragedefaulttagslist", QStringList{"Alpha", "Beta"}); + g.setValue("interface/visualdeckstoragesearchfoldernames", false); + g.setValue("interface/visualdeckstorageshowcoloridentity", false); + g.setValue("interface/visualdeckstorageshowbannercardcombobox", false); + g.setValue("interface/visualdeckstorageshowtagsondeckpreviews", false); + g.setValue("interface/visualdeckstoragedrawunusedcoloridentities", false); + g.setValue("interface/visualdeckstorageunusedcoloridentitiesopacity", 35); + g.setValue("interface/visualdeckstoragetooltiptype", 1); + g.setValue("interface/visualdeckstoragepromptforconversion", false); + g.setValue("interface/visualdeckstoragealwaysconvert", true); + g.setValue("interface/visualdeckstorageingame", false); + g.setValue("interface/visualdeckstorageselectionanimation", false); + g.setValue("interface/visualdatabasedisplayfiltertomostrecentsetsenabled", true); + g.setValue("interface/visualdatabasedisplayfiltertomostrecentsetsamount", 25); + + // card sizes (migrate into cards_display.ini) + g.setValue("interface/visualdatabasedisplaycardsize", 80); + g.setValue("interface/visualdeckeditorcardsize", 70); + g.setValue("interface/edhreccardsize", 60); + g.setValue("interface/archidektpreviewsize", 50); + + g.sync(); + } + + bool result = SettingsMigration::migrateSettingsFromGlobalIni(settingsPath); + ASSERT_TRUE(result); + + // Original global.ini should be backed up + ASSERT_TRUE(fileExists("global.ini.old")); + + // Verify each per-file INI was created and has correct values + ASSERT_TRUE(fileExists("tabs.ini")); + ASSERT_EQ(readFromIni("tabs.ini", "tabs/visualDeckStorage"), QVariant(false)); + ASSERT_EQ(readFromIni("tabs.ini", "tabs/server"), QVariant(true)); + ASSERT_EQ(readFromIni("tabs.ini", "tabs/log"), QVariant(true)); + + ASSERT_TRUE(fileExists("sound.ini")); + ASSERT_EQ(readFromIni("sound.ini", "sound/enabled"), QVariant(true)); + ASSERT_EQ(readFromIni("sound.ini", "sound/theme"), QVariant("custom_theme")); + ASSERT_EQ(readFromIni("sound.ini", "sound/masterVolume"), QVariant(75)); + + ASSERT_TRUE(fileExists("game.ini")); + ASSERT_EQ(readFromIni("game.ini", "game/maxPlayers"), QVariant(4)); + ASSERT_EQ(readFromIni("game.ini", "game/gameDescription"), QVariant("test game")); + ASSERT_EQ(readFromIni("game.ini", "game/rememberGameSettings"), QVariant(false)); + ASSERT_EQ(readFromIni("game.ini", "game/gameTypes"), QVariant("commander")); + ASSERT_EQ(readFromIni("game.ini", "game/onlyBuddies"), QVariant(true)); + ASSERT_EQ(readFromIni("game.ini", "localgameoptions/maxPlayers"), QVariant(2)); + ASSERT_EQ(readFromIni("game.ini", "localgameoptions/startingLifeTotal"), QVariant(40)); + + ASSERT_TRUE(fileExists("chat.ini")); + ASSERT_EQ(readFromIni("chat.ini", "chat/mention"), QVariant(false)); + ASSERT_EQ(readFromIni("chat.ini", "chat/mentionColor"), QVariant("FF0000")); + ASSERT_EQ(readFromIni("chat.ini", "chat/showMessagePopups"), QVariant(false)); + ASSERT_EQ(readFromIni("chat.ini", "chat/mentionCompleter"), QVariant(false)); + ASSERT_EQ(readFromIni("chat.ini", "chat/roomHistory"), QVariant(false)); + ASSERT_EQ(readFromIni("chat.ini", "chat/highlightColor"), QVariant("00FF00")); + ASSERT_EQ(readFromIni("chat.ini", "chat/highlightWords"), QVariant("alpha beta")); + + ASSERT_TRUE(fileExists("cache_storage.ini")); + ASSERT_EQ(readFromIni("cache_storage.ini", "cache_storage/pixmapCacheSize"), QVariant(1024)); + ASSERT_EQ(readFromIni("cache_storage.ini", "cache_storage/networkCacheSize"), QVariant(2048)); + ASSERT_EQ(readFromIni("cache_storage.ini", "cache_storage/redirectCacheTtl"), QVariant(5)); + ASSERT_EQ(readFromIni("cache_storage.ini", "cache_storage/cardPictureLoaderCacheMethod"), QVariant(1)); + ASSERT_EQ(readFromIni("cache_storage.ini", "cache_storage/localCardImageStorageNamingScheme"), QVariant(2)); + + ASSERT_TRUE(fileExists("updates.ini")); + ASSERT_EQ(readFromIni("updates.ini", "updates/startupUpdateCheck"), QVariant(false)); + ASSERT_EQ(readFromIni("updates.ini", "updates/startupCardUpdateCheckPromptForUpdate"), QVariant(false)); + ASSERT_EQ(readFromIni("updates.ini", "updates/startupCardUpdateCheckAlwaysUpdate"), QVariant(true)); + ASSERT_EQ(readFromIni("updates.ini", "updates/cardUpdateCheckInterval"), QVariant(14)); + ASSERT_EQ(readFromIni("updates.ini", "updates/lastCardUpdateCheck"), QVariant(QDate(2024, 1, 1))); + ASSERT_EQ(readFromIni("updates.ini", "updates/alwaysEnableNewSets"), QVariant(true)); + ASSERT_EQ(readFromIni("updates.ini", "updates/updateNotification"), QVariant(false)); + ASSERT_EQ(readFromIni("updates.ini", "updates/newVersionNotification"), QVariant(false)); + + ASSERT_TRUE(fileExists("personal.ini")); + ASSERT_EQ(readFromIni("personal.ini", "personal/lang"), QVariant("de")); + ASSERT_EQ(readFromIni("personal.ini", "tipOfDay/showTips"), QVariant(false)); + ASSERT_EQ(readFromIni("personal.ini", "tipOfDay/seenTips"), QVariant(QStringList{"1", "2", "3"})); + + ASSERT_TRUE(fileExists("downloads.ini")); + ASSERT_EQ(readFromIni("downloads.ini", "downloads/pictureDownload"), QVariant(true)); + ASSERT_EQ(readFromIni("downloads.ini", "downloads/downloadSpoilers"), QVariant(true)); + + ASSERT_TRUE(fileExists("appearance.ini")); + ASSERT_EQ(readFromIni("appearance.ini", "appearance/themeName"), QVariant("custom_theme")); + ASSERT_EQ(readFromIni("appearance.ini", "appearance/maxFontSize"), QVariant(14)); + ASSERT_EQ(readFromIni("appearance.ini", "appearance/styleUserList"), QVariant(false)); + ASSERT_EQ(readFromIni("appearance.ini", "appearance/homeTabBackgroundSource"), QVariant("custom_bg")); + ASSERT_EQ(readFromIni("appearance.ini", "appearance/homeTabBackgroundShuffleFrequency"), QVariant(30)); + ASSERT_EQ(readFromIni("appearance.ini", "appearance/homeTabDisplayCardName"), QVariant(false)); + + ASSERT_TRUE(fileExists("network.ini")); + ASSERT_EQ(readFromIni("network.ini", "network/keepAlive"), QVariant(10)); + ASSERT_EQ(readFromIni("network.ini", "network/timeout"), QVariant(30)); + ASSERT_EQ(readFromIni("network.ini", "network/clientId"), QVariant("test-client-id")); + ASSERT_EQ(readFromIni("network.ini", "network/clientVersion"), QVariant("test-client-version")); + ASSERT_EQ(readFromIni("network.ini", "network/knownMissingFeatures"), QVariant("feature1,feature2")); + + ASSERT_TRUE(fileExists("cards_display.ini")); + ASSERT_EQ(readFromIni("cards_display.ini", "cards/displayCardNames"), QVariant(false)); + ASSERT_EQ(readFromIni("cards_display.ini", "cards/roundCardCorners"), QVariant(false)); + ASSERT_EQ(readFromIni("cards_display.ini", "cards/overrideAllCardArtWithPersonalPreference"), QVariant(true)); + ASSERT_EQ(readFromIni("cards_display.ini", "cards/bumpSetsWithCardsInDeckToTop"), QVariant(false)); + ASSERT_EQ(readFromIni("cards_display.ini", "cards/includerebalancedcards"), QVariant(false)); + ASSERT_EQ(readFromIni("cards_display.ini", "cards/autoRotateSidewaysLayoutCards"), QVariant(false)); + ASSERT_EQ(readFromIni("cards_display.ini", "cards/tapAnimation"), QVariant(true)); + ASSERT_EQ(readFromIni("cards_display.ini", "cards/scaleCards"), QVariant(false)); + ASSERT_EQ(readFromIni("cards_display.ini", "cards/verticalCardOverlapPercent"), QVariant(42)); + ASSERT_EQ(readFromIni("cards_display.ini", "cards/cardInfoViewMode"), QVariant(1)); + ASSERT_EQ(readFromIni("cards_display.ini", "cards/cardSize/printingSelector"), QVariant(90)); + ASSERT_EQ(readFromIni("cards_display.ini", "cards/printingSelector/sortOrder"), QVariant(3)); + ASSERT_EQ(readFromIni("cards_display.ini", "cards/printingSelector/navigationButtonsVisible"), QVariant(false)); + ASSERT_EQ(readFromIni("cards_display.ini", "cards/cardSize/visualDeckStorage"), QVariant(150)); + ASSERT_EQ(readFromIni("cards_display.ini", "cards/cardSize/visualDatabaseDisplay"), QVariant(80)); + ASSERT_EQ(readFromIni("cards_display.ini", "cards/cardSize/visualDeckEditor"), QVariant(70)); + ASSERT_EQ(readFromIni("cards_display.ini", "cards/cardSize/edhrec"), QVariant(60)); + ASSERT_EQ(readFromIni("cards_display.ini", "cards/cardSize/archidektPreview"), QVariant(50)); + ASSERT_EQ(readFromIni("cards_display.ini", "cards/cardSize/sampleHandSize"), QVariant(5)); + + ASSERT_TRUE(fileExists("card_counters.ini")); + ASSERT_EQ(readFromIni("card_counters.ini", "cards/counters/0/color").toString(), QColor(Qt::red).name()); + ASSERT_FALSE(readFromIni("global.ini", "cards/counters/0/color").isValid()); + + ASSERT_TRUE(fileExists("interface.ini")); + ASSERT_EQ(readFromIni("interface.ini", "interface/useTearOffMenus"), QVariant(true)); + ASSERT_EQ(readFromIni("interface.ini", "interface/cardViewInitialRowsMax"), QVariant(8)); + ASSERT_EQ(readFromIni("interface.ini", "interface/cardViewExpandedRowsMax"), QVariant(12)); + ASSERT_EQ(readFromIni("interface.ini", "interface/closeEmptyCardView"), QVariant(false)); + ASSERT_EQ(readFromIni("interface.ini", "interface/focusCardViewSearchBar"), QVariant(false)); + ASSERT_EQ(readFromIni("interface.ini", "interface/keepGameChatFocus"), QVariant(true)); + ASSERT_EQ(readFromIni("interface.ini", "interface/notifications/enabled"), QVariant(false)); + ASSERT_EQ(readFromIni("interface.ini", "interface/notifications/spectatorsEnabled"), QVariant(true)); + ASSERT_EQ(readFromIni("interface.ini", "interface/notifications/buddyConnectEnabled"), QVariant(false)); + ASSERT_EQ(readFromIni("interface.ini", "interface/doubleClickToPlay"), QVariant(false)); + ASSERT_EQ(readFromIni("interface.ini", "interface/clickPlaysAllSelected"), QVariant(false)); + ASSERT_EQ(readFromIni("interface.ini", "interface/playToStack"), QVariant(false)); + ASSERT_EQ(readFromIni("interface.ini", "interface/doNotDeleteArrowsInSubPhases"), QVariant(false)); + ASSERT_EQ(readFromIni("interface.ini", "interface/startingHandSize"), QVariant(5)); + ASSERT_EQ(readFromIni("interface.ini", "interface/annotateTokens"), QVariant(true)); + ASSERT_EQ(readFromIni("interface.ini", "interface/showLassoSelectionCount"), QVariant(false)); + ASSERT_EQ(readFromIni("interface.ini", "interface/showPersistentSelectionCount"), QVariant(false)); + ASSERT_EQ(readFromIni("interface.ini", "interface/tallyType"), QVariant(2)); + ASSERT_EQ(readFromIni("interface.ini", "interface/leftJustified"), QVariant(true)); + ASSERT_EQ(readFromIni("interface.ini", "interface/minPlayersMulticolumn"), QVariant(6)); + ASSERT_EQ(readFromIni("interface.ini", "interface/showStatusBar"), QVariant(true)); + ASSERT_EQ(readFromIni("interface.ini", "interface/showShortcuts"), QVariant(false)); + ASSERT_EQ(readFromIni("interface.ini", "interface/showGameSelectorFilterToolbar"), QVariant(false)); + ASSERT_EQ(readFromIni("interface.ini", "hand/horizontal"), QVariant(true)); + ASSERT_EQ(readFromIni("interface.ini", "table/invertVertical"), QVariant(true)); + ASSERT_EQ(readFromIni("interface.ini", "replay/rewindBufferingMs"), QVariant(6000)); + ASSERT_EQ(readFromIni("interface.ini", "replay/fastForwardSpeed"), QVariant(5)); + ASSERT_EQ(readFromIni("interface.ini", "zoneview/groupBy"), QVariant(2)); + ASSERT_EQ(readFromIni("interface.ini", "zoneview/sortBy"), QVariant(1)); + ASSERT_EQ(readFromIni("interface.ini", "zoneview/pileView"), QVariant(false)); + + ASSERT_TRUE(fileExists("deck_editor.ini")); + ASSERT_EQ(readFromIni("deck_editor.ini", "deckeditor/openDeckInNewTab"), QVariant(false)); + ASSERT_EQ(readFromIni("deck_editor.ini", "deckeditor/bannerCardComboBoxVisible"), QVariant(false)); + ASSERT_EQ(readFromIni("deck_editor.ini", "deckeditor/tagsWidgetVisible"), QVariant(false)); + ASSERT_EQ(readFromIni("deck_editor.ini", "deckeditor/defaultDeckEditorType"), QVariant(0)); + + ASSERT_TRUE(fileExists("paths.ini")); + ASSERT_EQ(readFromIni("paths.ini", "paths/decks"), QVariant("/custom/decks")); + ASSERT_EQ(readFromIni("paths.ini", "paths/pics"), QVariant("/custom/pics")); + + ASSERT_TRUE(fileExists("visual_deck_storage.ini")); + ASSERT_EQ(readFromIni("visual_deck_storage.ini", "interface/visualDeckStorage/sortingOrder"), QVariant(2)); + ASSERT_EQ(readFromIni("visual_deck_storage.ini", "interface/visualDeckStorage/showFolders"), QVariant(false)); + ASSERT_EQ(readFromIni("visual_deck_storage.ini", "interface/visualDeckStorage/showTagFilter"), QVariant(false)); + ASSERT_EQ(readFromIni("visual_deck_storage.ini", "interface/visualDeckStorage/defaultTagsList"), + QVariant(QStringList{"Alpha", "Beta"})); + ASSERT_EQ(readFromIni("visual_deck_storage.ini", "interface/visualDeckStorage/searchFolderNames"), QVariant(false)); + ASSERT_EQ(readFromIni("visual_deck_storage.ini", "interface/visualDeckStorage/showColorIdentity"), QVariant(false)); + ASSERT_EQ(readFromIni("visual_deck_storage.ini", "interface/visualDeckStorage/showBannerCardComboBox"), + QVariant(false)); + ASSERT_EQ(readFromIni("visual_deck_storage.ini", "interface/visualDeckStorage/showTagsOnDeckPreviews"), + QVariant(false)); + ASSERT_EQ(readFromIni("visual_deck_storage.ini", "interface/visualDeckStorage/drawUnusedColorIdentities"), + QVariant(false)); + ASSERT_EQ(readFromIni("visual_deck_storage.ini", "interface/visualDeckStorage/unusedColorIdentitiesOpacity"), + QVariant(35)); + ASSERT_EQ(readFromIni("visual_deck_storage.ini", "interface/visualDeckStorage/tooltipType"), QVariant(1)); + ASSERT_EQ(readFromIni("visual_deck_storage.ini", "interface/visualDeckStorage/promptForConversion"), + QVariant(false)); + ASSERT_EQ(readFromIni("visual_deck_storage.ini", "interface/visualDeckStorage/alwaysConvert"), QVariant(true)); + ASSERT_EQ(readFromIni("visual_deck_storage.ini", "interface/visualDeckStorage/inGame"), QVariant(false)); + ASSERT_EQ(readFromIni("visual_deck_storage.ini", "interface/visualDeckStorage/selectionAnimation"), + QVariant(false)); + ASSERT_EQ(readFromIni("visual_deck_storage.ini", "interface/visualDatabaseDisplay/filterToMostRecentSetsEnabled"), + QVariant(true)); + ASSERT_EQ(readFromIni("visual_deck_storage.ini", "interface/visualDatabaseDisplay/filterToMostRecentSetsAmount"), + QVariant(25)); + + // No legacy flat keys should remain in the per-file INIs + ASSERT_FALSE(iniFileHasKeyCaseSensitive("visual_deck_storage.ini", "interface/visualdeckstorageshowfolders")); + ASSERT_FALSE(iniFileHasKeyCaseSensitive("visual_deck_storage.ini", "interface/visualdeckstoragecardsize")); + ASSERT_FALSE(iniFileHasKeyCaseSensitive("deck_editor.ini", "deckeditor/sampleHandSize")); + ASSERT_FALSE(iniFileHasKeyCaseSensitive("deck_editor.ini", "deckeditor/cardSize")); + ASSERT_FALSE(iniFileHasKeyCaseSensitive("interface.ini", "interface/notificationsenabled")); + ASSERT_FALSE(iniFileHasKeyCaseSensitive("cards_display.ini", "cards/printingselectorsortorder")); + ASSERT_FALSE(iniFileHasKeyCaseSensitive("cards_display.ini", "cards/visualDeckStorage/cardSize")); + + // No legacy non-camelCase keys should remain in the per-file INIs + ASSERT_FALSE(iniFileHasKeyCaseSensitive("game.ini", "game/gamedescription")); + ASSERT_FALSE(iniFileHasKeyCaseSensitive("game.ini", "localgameoptions/maxplayers")); + ASSERT_FALSE(iniFileHasKeyCaseSensitive("chat.ini", "chat/roomhistory")); + ASSERT_FALSE(iniFileHasKeyCaseSensitive("chat.ini", "chat/highlightwords")); + ASSERT_FALSE(iniFileHasKeyCaseSensitive("sound.ini", "sound/mastervolume")); + ASSERT_FALSE(iniFileHasKeyCaseSensitive("downloads.ini", "downloads/picturedownload")); + ASSERT_FALSE(iniFileHasKeyCaseSensitive("network.ini", "network/keepalive")); + ASSERT_FALSE(iniFileHasKeyCaseSensitive("network.ini", "network/knownmissingfeatures")); + ASSERT_FALSE(iniFileHasKeyCaseSensitive("updates.ini", "updates/updatenotification")); + ASSERT_FALSE(iniFileHasKeyCaseSensitive("cards_display.ini", "cards/displaycardnames")); + ASSERT_FALSE(iniFileHasKeyCaseSensitive("cards_display.ini", "cards/cardinfoviewmode")); + ASSERT_FALSE(iniFileHasKeyCaseSensitive("interface.ini", "interface/usetearoffmenus")); + ASSERT_FALSE(iniFileHasKeyCaseSensitive("interface.ini", "interface/doubleclicktoplay")); + ASSERT_FALSE(iniFileHasKeyCaseSensitive("interface.ini", "interface/min_players_multicolumn")); + ASSERT_FALSE(iniFileHasKeyCaseSensitive("interface.ini", "table/invert_vertical")); + ASSERT_FALSE(iniFileHasKeyCaseSensitive("interface.ini", "zoneview/groupby")); + ASSERT_FALSE(iniFileHasKeyCaseSensitive("interface.ini", "zoneview/pileview")); + + // Verify sentinel was written + ASSERT_EQ(readFromIni("global.ini", "migration/perfile_complete"), QVariant(true)); +} + +TEST_F(SettingsMigrationTest, SecondCallDoesNothing) +{ + { + QSettings g(settingsPath + "global.ini", QSettings::IniFormat); + g.setValue("tabs/visualDeckStorage", true); + g.setValue("sound/enabled", true); + g.sync(); + } + + ASSERT_TRUE(SettingsMigration::migrateSettingsFromGlobalIni(settingsPath)); + ASSERT_FALSE(SettingsMigration::migrateSettingsFromGlobalIni(settingsPath)); +} + +TEST_F(SettingsMigrationTest, SecondCallDoesNotOverwriteUserChanges) +{ + { + QSettings g(settingsPath + "global.ini", QSettings::IniFormat); + g.setValue("tabs/visualDeckStorage", true); + g.setValue("sound/enabled", true); + g.sync(); + } + + ASSERT_TRUE(SettingsMigration::migrateSettingsFromGlobalIni(settingsPath)); + + // User changes the value after migration + { + QSettings tabsIni(settingsPath + "tabs.ini", QSettings::IniFormat); + tabsIni.setValue("tabs/visualDeckStorage", false); + tabsIni.sync(); + } + + // Second migration should NOT overwrite the user's change + ASSERT_FALSE(SettingsMigration::migrateSettingsFromGlobalIni(settingsPath)); + + ASSERT_EQ(readFromIni("tabs.ini", "tabs/visualDeckStorage"), QVariant(false)); +} + +TEST_F(SettingsMigrationTest, PartialMigrationOnlyMigratesPresentKeys) +{ + { + QSettings g(settingsPath + "global.ini", QSettings::IniFormat); + g.setValue("tabs/visualDeckStorage", true); + g.setValue("tabs/server", false); + g.sync(); + } + + ASSERT_TRUE(SettingsMigration::migrateSettingsFromGlobalIni(settingsPath)); + + ASSERT_TRUE(fileExists("global.ini.old")); + ASSERT_TRUE(fileExists("tabs.ini")); + ASSERT_EQ(readFromIni("tabs.ini", "tabs/visualDeckStorage"), QVariant(true)); + ASSERT_EQ(readFromIni("tabs.ini", "tabs/server"), QVariant(false)); + + // Other files should NOT exist since no keys were present for them + ASSERT_FALSE(fileExists("sound.ini")); + ASSERT_FALSE(fileExists("game.ini")); + ASSERT_FALSE(fileExists("chat.ini")); +} + +TEST_F(SettingsMigrationTest, KeyMapTranslationIsCorrect) +{ + { + QSettings g(settingsPath + "global.ini", QSettings::IniFormat); + g.setValue("personal/cardUpdateCheckInterval", 30); + g.setValue("personal/updatereleasechannel", 1); + g.sync(); + } + + ASSERT_TRUE(SettingsMigration::migrateSettingsFromGlobalIni(settingsPath)); + + ASSERT_TRUE(fileExists("global.ini.old")); + ASSERT_TRUE(fileExists("updates.ini")); + // The key should be translated from "personal/cardUpdateCheckInterval" to "updates/cardUpdateCheckInterval" + ASSERT_EQ(readFromIni("updates.ini", "updates/cardUpdateCheckInterval"), QVariant(30)); + ASSERT_EQ(readFromIni("updates.ini", "updates/updateReleaseChannel"), QVariant(1)); +} + +TEST_F(SettingsMigrationTest, CardsKeysKeepGroupPrefix) +{ + { + QSettings g(settingsPath + "global.ini", QSettings::IniFormat); + g.setValue("cards/displaycardnames", false); + g.setValue("interface/deckeditorbannercardcomboboxvisible", true); + g.sync(); + } + + ASSERT_TRUE(SettingsMigration::migrateSettingsFromGlobalIni(settingsPath)); + + ASSERT_TRUE(fileExists("global.ini.old")); + ASSERT_TRUE(fileExists("cards_display.ini")); + ASSERT_TRUE(fileExists("deck_editor.ini")); + // "cards/displaycardnames" should be stored with its group prefix + ASSERT_EQ(readFromIni("cards_display.ini", "cards/displayCardNames"), QVariant(false)); + // deck editor keys belong to the deck editor settings now + ASSERT_EQ(readFromIni("deck_editor.ini", "deckeditor/bannerCardComboBoxVisible"), QVariant(true)); +} + +TEST_F(SettingsMigrationTest, LegacyMigrationIsIdempotent) +{ + { + QSettings nativeSettings; + nativeSettings.setValue("server/previoushostlogin", "test_user"); + nativeSettings.setValue("server/auto_connect", true); + nativeSettings.sync(); + } + + ASSERT_TRUE(SettingsMigration::migrateLegacySettings(settingsPath)); + + // Change the migrated value + { + QSettings serversIni(settingsPath + "servers.ini", QSettings::IniFormat); + serversIni.setValue("server/previousHostLogin", "modified_user"); + serversIni.sync(); + } + + // Second migration should NOT overwrite the change + ASSERT_FALSE(SettingsMigration::migrateLegacySettings(settingsPath)); + ASSERT_EQ(readFromIni("servers.ini", "server/previousHostLogin"), QVariant("modified_user")); +} + +TEST_F(SettingsMigrationTest, LegacyMigrationCamelCasesKeys) +{ + { + QSettings nativeSettings; + nativeSettings.setValue("sets/AAA/sortkey", 2); + nativeSettings.setValue("sets/AAA/enabled", false); + nativeSettings.setValue("sets/AAA/isknown", false); + nativeSettings.setValue("server/previoushostlogin", "legacy_user"); + nativeSettings.setValue("server/auto_connect", 1); + nativeSettings.setValue("server/fpport", "5080"); + nativeSettings.setValue("messages/count", 1); + nativeSettings.setValue("messages/msg0", "hello"); + nativeSettings.setValue("filter_games/hide_full_games", true); + nativeSettings.setValue("filter_games/min_players", 3); + nativeSettings.setValue("filter_games/max_players", 5); + nativeSettings.setValue("filter_games/game_type/deadbeef", true); + nativeSettings.sync(); + } + + ASSERT_TRUE(SettingsMigration::migrateLegacySettings(settingsPath)); + + ASSERT_TRUE(fileExists("cardDatabase.ini")); + ASSERT_EQ(readFromIni("cardDatabase.ini", "sets/AAA/sortKey"), QVariant(2)); + ASSERT_EQ(readFromIni("cardDatabase.ini", "sets/AAA/enabled"), QVariant(false)); + ASSERT_EQ(readFromIni("cardDatabase.ini", "sets/AAA/isKnown"), QVariant(false)); + + ASSERT_TRUE(fileExists("servers.ini")); + ASSERT_EQ(readFromIni("servers.ini", "server/previousHostLogin"), QVariant("legacy_user")); + ASSERT_EQ(readFromIni("servers.ini", "server/autoConnect"), QVariant(1)); + ASSERT_EQ(readFromIni("servers.ini", "server/fpPort"), QVariant("5080")); + + ASSERT_TRUE(fileExists("messages.ini")); + ASSERT_EQ(readFromIni("messages.ini", "messages/count"), QVariant(1)); + ASSERT_EQ(readFromIni("messages.ini", "messages/msg0"), QVariant("hello")); + + ASSERT_TRUE(fileExists("gamefilters.ini")); + ASSERT_EQ(readFromIni("gamefilters.ini", "filter_games/hideFullGames"), QVariant(true)); + ASSERT_EQ(readFromIni("gamefilters.ini", "filter_games/minPlayers"), QVariant(3)); + ASSERT_EQ(readFromIni("gamefilters.ini", "filter_games/maxPlayers"), QVariant(5)); + ASSERT_EQ(readFromIni("gamefilters.ini", "filter_games/gameType/deadbeef"), QVariant(true)); +} + +TEST_F(SettingsMigrationTest, LegacyMigrationEmptyNativeFormatWritesSentinel) +{ + QSettings().clear(); + QSettings().sync(); + + ASSERT_TRUE(SettingsMigration::migrateLegacySettings(settingsPath)); + ASSERT_TRUE(fileExists("personal.ini")); + ASSERT_EQ(readFromIni("personal.ini", "migration/legacy_complete"), QVariant(true)); + ASSERT_FALSE(fileExists("messages.ini")); + ASSERT_FALSE(fileExists("gamefilters.ini")); + ASSERT_FALSE(fileExists("cardDatabase.ini")); + + // Second call should be a no-op + ASSERT_FALSE(SettingsMigration::migrateLegacySettings(settingsPath)); +} + +// --- migrateSettingsFromGlobalIni rename-failure test --- + +TEST_F(SettingsMigrationTest, RenameFailureWithExistingBackup) +{ + { + QSettings g(settingsPath + "global.ini", QSettings::IniFormat); + g.setValue("tabs/visualDeckStorage", true); + g.setValue("sound/enabled", true); + g.sync(); + } + + // Create a stale backup + { + QFile oldBackup(settingsPath + "global.ini.old"); + ASSERT_TRUE(oldBackup.open(QIODevice::WriteOnly)); + oldBackup.close(); + } + + // Migration should still succeed + ASSERT_TRUE(SettingsMigration::migrateSettingsFromGlobalIni(settingsPath)); + + // Per-file INIs should be created + ASSERT_TRUE(fileExists("tabs.ini")); + ASSERT_TRUE(fileExists("sound.ini")); + + // Sentinel should be set in the new global.ini + ASSERT_EQ(readFromIni("global.ini", "migration/perfile_complete"), QVariant(true)); + + // Original data should be backed up (stale backup was replaced) + ASSERT_TRUE(fileExists("global.ini.old")); + ASSERT_EQ(readFromIni("global.ini.old", "tabs/visualDeckStorage"), QVariant(true)); + + // Second call should be a no-op + ASSERT_FALSE(SettingsMigration::migrateSettingsFromGlobalIni(settingsPath)); +} + +} // namespace + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + QCoreApplication app(argc, argv); + app.setOrganizationName("CockatriceTest"); + app.setApplicationName("SettingsMigrationTest"); + QSettings::setDefaultFormat(QSettings::IniFormat); + return RUN_ALL_TESTS(); +} diff --git a/tests/warning_categories_test.cpp b/tests/warning_categories_test.cpp new file mode 100644 index 000000000..432885dd9 --- /dev/null +++ b/tests/warning_categories_test.cpp @@ -0,0 +1,89 @@ +#include "gtest/gtest.h" +#include +#include + +TEST(WarningCategoriesTest, EmptyValueYieldsNoCategories) +{ + EXPECT_TRUE(parseWarningCategories(QString()).isEmpty()); + EXPECT_TRUE(parseWarningCategories(QString("")).isEmpty()); +} + +TEST(WarningCategoriesTest, PlainNamesDefaultToInterventionLevelOne) +{ + const QList categories = parseWarningCategories("Flaming,Spamming,Causing Drama"); + + ASSERT_EQ(3, categories.size()); + EXPECT_EQ("Flaming", categories.at(0).name); + EXPECT_EQ(1, categories.at(0).startingIl); + EXPECT_EQ("Spamming", categories.at(1).name); + EXPECT_EQ(1, categories.at(1).startingIl); + EXPECT_EQ("Causing Drama", categories.at(2).name); + EXPECT_EQ(1, categories.at(2).startingIl); +} + +TEST(WarningCategoriesTest, ExplicitInterventionLevelsAreParsed) +{ + const QList categories = parseWarningCategories("Cheating|2,Inappropriate Avatar|3"); + + ASSERT_EQ(2, categories.size()); + EXPECT_EQ("Cheating", categories.at(0).name); + EXPECT_EQ(2, categories.at(0).startingIl); + EXPECT_EQ("Inappropriate Avatar", categories.at(1).name); + EXPECT_EQ(3, categories.at(1).startingIl); +} + +TEST(WarningCategoriesTest, MixedEntriesKeepDefaultsForThoseWithoutLevels) +{ + const QList categories = parseWarningCategories("Abusive Language|1,Cheating|2,Spamming"); + + ASSERT_EQ(3, categories.size()); + EXPECT_EQ(1, categories.at(0).startingIl); + EXPECT_EQ(2, categories.at(1).startingIl); + EXPECT_EQ("Spamming", categories.at(2).name); + EXPECT_EQ(1, categories.at(2).startingIl); +} + +TEST(WarningCategoriesTest, EmptyEntriesAreSkipped) +{ + const QList categories = parseWarningCategories("Spamming,,Cheating|2,"); + + ASSERT_EQ(2, categories.size()); + EXPECT_EQ("Spamming", categories.at(0).name); + EXPECT_EQ("Cheating", categories.at(1).name); +} + +TEST(WarningCategoriesTest, WhitespaceIsTrimmed) +{ + const QList categories = parseWarningCategories(" Abusive Language , Cheating | 2 "); + + ASSERT_EQ(2, categories.size()); + EXPECT_EQ("Abusive Language", categories.at(0).name); + EXPECT_EQ(1, categories.at(0).startingIl); + EXPECT_EQ("Cheating", categories.at(1).name); + EXPECT_EQ(2, categories.at(1).startingIl); +} + +TEST(WarningCategoriesTest, InvalidInterventionLevelsFallBackToOne) +{ + const QList categories = parseWarningCategories("Spamming|abc,Cheating|0,Targeted Harassment|-3"); + + ASSERT_EQ(3, categories.size()); + EXPECT_EQ(1, categories.at(0).startingIl); + EXPECT_EQ(1, categories.at(1).startingIl); + EXPECT_EQ(1, categories.at(2).startingIl); +} + +TEST(WarningCategoriesTest, EntryWithOnlyLevelIsSkipped) +{ + const QList categories = parseWarningCategories("|2,Spamming|2"); + + ASSERT_EQ(1, categories.size()); + EXPECT_EQ("Spamming", categories.at(0).name); + EXPECT_EQ(2, categories.at(0).startingIl); +} + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/vcpkg b/vcpkg index 56bb24116..9e593bb18 160000 --- a/vcpkg +++ b/vcpkg @@ -1 +1 @@ -Subproject commit 56bb2411609227288b70117ead2c47585ba07713 +Subproject commit 9e593bb18ea69cc5095e012465dcd675a822ed0d diff --git a/vcpkg.json b/vcpkg.json index ac7b75d07..f12227024 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -1,5 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/microsoft/vcpkg-tool/main/docs/vcpkg.schema.json", + "builtin-baseline": "9e593bb18ea69cc5095e012465dcd675a822ed0d", "dependencies": [ "gtest", "liblzma",