diff --git a/.ci/Arch/Dockerfile b/.ci/Arch/Dockerfile index b08e568f3..4dc2f5e90 100644 --- a/.ci/Arch/Dockerfile +++ b/.ci/Arch/Dockerfile @@ -1,9 +1,9 @@ -from archlinux:latest +FROM archlinux:latest RUN pacman --sync --refresh --sysupgrade --needed --noconfirm \ - base-devel \ ccache \ cmake \ + gcc \ git \ gtest \ mariadb-libs \ diff --git a/.ci/Debian12/Dockerfile b/.ci/Debian12/Dockerfile index 4da910dc0..03f33b38e 100644 --- a/.ci/Debian12/Dockerfile +++ b/.ci/Debian12/Dockerfile @@ -1,16 +1,17 @@ -FROM debian:12 +FROM debian:12-slim RUN apt-get update && \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - build-essential \ ca-certificates \ ccache \ cmake \ + dpkg-dev \ file \ g++ \ git \ libcurl4-openssl-dev \ libgl-dev \ + libgtest-dev \ liblzma-dev \ libmariadb-dev-compat \ libprotobuf-dev \ diff --git a/.ci/Debian13/Dockerfile b/.ci/Debian13/Dockerfile index 94f711398..8ee8cccff 100644 --- a/.ci/Debian13/Dockerfile +++ b/.ci/Debian13/Dockerfile @@ -1,16 +1,17 @@ -FROM debian:13 +FROM debian:13-slim RUN apt-get update && \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - build-essential \ ca-certificates \ ccache \ cmake \ + dpkg-dev \ file \ g++ \ git \ libcurl4-openssl-dev \ libgl-dev \ + libgtest-dev \ liblzma-dev \ libmariadb-dev-compat \ libprotobuf-dev \ diff --git a/.ci/Fedora44/Dockerfile b/.ci/Fedora44/Dockerfile index 15204fa1a..d02649b4d 100644 --- a/.ci/Fedora44/Dockerfile +++ b/.ci/Fedora44/Dockerfile @@ -1,11 +1,12 @@ FROM fedora:44 -RUN dnf install -y \ +RUN dnf install -y --nodocs \ ca-certificates \ ccache \ cmake \ gcc-c++ \ git \ + gtest-devel \ libcurl-devel \ mariadb-devel \ ninja-build \ diff --git a/.ci/Fedora43/Dockerfile b/.ci/Fedora45/Dockerfile similarity index 85% rename from .ci/Fedora43/Dockerfile rename to .ci/Fedora45/Dockerfile index 8aa345651..5527aac87 100644 --- a/.ci/Fedora43/Dockerfile +++ b/.ci/Fedora45/Dockerfile @@ -1,11 +1,12 @@ -FROM fedora:43 +FROM fedora:45 -RUN dnf install -y \ +RUN dnf install -y --nodocs \ ca-certificates \ ccache \ cmake \ gcc-c++ \ git \ + gtest-devel \ libcurl-devel \ mariadb-devel \ ninja-build \ diff --git a/.ci/Servatrice_Debian12/Dockerfile b/.ci/Servatrice_Debian12/Dockerfile index f1a16843a..8c166a85b 100644 --- a/.ci/Servatrice_Debian12/Dockerfile +++ b/.ci/Servatrice_Debian12/Dockerfile @@ -1,10 +1,10 @@ -FROM debian:12 +FROM debian:12-slim RUN apt-get update && \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - build-essential \ ccache \ cmake \ + dpkg-dev \ file \ g++ \ git \ diff --git a/.ci/Ubuntu24.04/Dockerfile b/.ci/Ubuntu24.04/Dockerfile index 43497dd3b..47f927f41 100644 --- a/.ci/Ubuntu24.04/Dockerfile +++ b/.ci/Ubuntu24.04/Dockerfile @@ -2,15 +2,16 @@ FROM ubuntu:24.04 RUN apt-get update && \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - build-essential \ ca-certificates \ ccache \ cmake \ + dpkg-dev \ file \ g++ \ git \ libcurl4-openssl-dev \ libgl-dev \ + libgtest-dev \ liblzma-dev \ libmariadb-dev-compat \ libprotobuf-dev \ diff --git a/.ci/Ubuntu26.04/Dockerfile b/.ci/Ubuntu26.04/Dockerfile index 4dc861ae9..f50a3cec2 100644 --- a/.ci/Ubuntu26.04/Dockerfile +++ b/.ci/Ubuntu26.04/Dockerfile @@ -2,16 +2,17 @@ FROM ubuntu:26.04 RUN apt-get update && \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - build-essential \ ca-certificates \ ccache \ cmake \ + dpkg-dev \ file \ g++ \ gcovr \ git \ libcurl4-openssl-dev \ libgl-dev \ + libgtest-dev \ liblzma-dev \ libmariadb-dev-compat \ libprotobuf-dev \ diff --git a/.ci/compile.sh b/.ci/compile.sh index 569ae9f6b..c58563f61 100755 --- a/.ci/compile.sh +++ b/.ci/compile.sh @@ -20,6 +20,7 @@ # --profile writes an MSBuild performance summary and binary log, Windows only # uses env: BUILDTYPE MAKE_INSTALL MAKE_PACKAGE PACKAGE_TYPE PACKAGE_SUFFIX MAKE_SERVER MAKE_NO_CLIENT MAKE_TEST USE_CCACHE CCACHE_SIZE CCACHE_EVICTION_AGE BUILD_DIR CMAKE_GENERATOR TARGET_MACOS_VERSION MAKE_SANITIZE MAKE_COVERAGE VCPKG_TARGET_MACOS_VERSION BUILD_PARALLEL_LEVEL BUILD_PROFILE # (correspond to args: --debug/--release --install --package --suffix --server --test --sanitize --coverage --ccache --dir --profile) +# boolean env flags are enabled with the value 1 (e.g. MAKE_SERVER=1) # exitcode: 1 for failure, 3 for invalid arguments # Read arguments @@ -138,17 +139,21 @@ done set -e -# Setup -./servatrice/check_schema_version.sh -if [[ ! $BUILDTYPE ]]; then +# Schema version consistency (only relevant to the server) +if [[ ${MAKE_SERVER:-} == "1" ]]; then + ./servatrice/check_schema_version.sh +fi + +# Use default values if unset +if [[ -z $BUILDTYPE ]]; then BUILDTYPE=Release fi -if [[ ! $BUILD_DIR ]]; then +if [[ -z $BUILD_DIR ]]; then BUILD_DIR="build" fi mkdir -p "$BUILD_DIR" -if [[ $MAKE_COVERAGE ]]; then +if [[ ${MAKE_COVERAGE:-} == "1" ]]; then # Resolved while the working directory is still the source root. $0 is relative to the # directory the script was invoked from, so after the cd below it no longer resolves, and # under `set -e` the failed subshell would abort the build rather than just yielding an empty @@ -163,16 +168,16 @@ export CMAKE_POLICY_VERSION_MINIMUM=3.10 # Add cmake flags flags=("-DCMAKE_BUILD_TYPE=$BUILDTYPE") -if [[ $MAKE_SERVER ]]; then +if [[ ${MAKE_SERVER:-} == "1" ]]; then flags+=("-DWITH_SERVER=1") fi -if [[ $MAKE_NO_CLIENT ]]; then +if [[ ${MAKE_NO_CLIENT:-} == "1" ]]; then flags+=("-DWITH_CLIENT=0" "-DWITH_ORACLE=0") fi -if [[ $MAKE_TEST ]]; then +if [[ ${MAKE_TEST:-} == "1" ]]; then flags+=("-DTEST=1") fi -if [[ $MAKE_SANITIZE ]]; then +if [[ ${MAKE_SANITIZE:-} == "1" ]]; then flags+=("-DENABLE_SANITIZERS=ON") else # Every leg states the flag explicitly rather than relying on the CMake @@ -180,20 +185,20 @@ else # never leak libasan/libubsan into a shipped binary. flags+=("-DENABLE_SANITIZERS=OFF") fi -if [[ $MAKE_COVERAGE ]]; then +if [[ ${MAKE_COVERAGE:-} == "1" ]]; then flags+=("-DENABLE_COVERAGE=ON") fi -if [[ $USE_CCACHE ]]; then +if [[ ${USE_CCACHE:-} == "1" ]]; then flags+=("-DUSE_CCACHE=1") - if [[ $CCACHE_SIZE ]]; then - # note, this setting persists after running the script + if [[ -n $CCACHE_SIZE ]]; then + # This setting persists after running the script ccache --max-size "$CCACHE_SIZE" fi fi -if [[ $PACKAGE_TYPE ]]; then +if [[ -n $PACKAGE_TYPE ]]; then flags+=("-DCPACK_GENERATOR=$PACKAGE_TYPE") fi -if [[ $USE_VCPKG ]]; then +if [[ ${USE_VCPKG:-} == "1" ]]; then flags+=("-DUSE_VCPKG=1") flags+=("-DVCPKG_INSTALL_OPTIONS=--x-abi-tools-use-exact-versions") fi @@ -203,6 +208,11 @@ if [[ ${WITH_CRASH_REPORTING:-} == "ON" ]]; then # developer builds never reach Sentry. flags+=("-DWITH_CRASH_REPORTING=ON") fi +if [[ ${COCKATRICE_DEBUG_INFO_LEVEL:-} ]]; then + # CI lowers the DWARF level so one build configuration fits its compiler-cache bound; + # see the option in CMakeLists.txt. Empty keeps the built-in flags. + flags+=("-DCOCKATRICE_DEBUG_INFO_LEVEL=$COCKATRICE_DEBUG_INFO_LEVEL") +fi if [[ ${BUILDTYPE:-Release} == "Release" && -n ${COCKATRICE_SENTRY_DSN:-} ]]; then # Release-only: Debug builds stay DSN-free so crashes from developer builds # never reach Sentry. Empty (local builds, forks without the secret, and non-tag @@ -216,7 +226,8 @@ fi buildflags=(--config "$BUILDTYPE") function ccachestatsverbose() { - # note, verbose only works on newer ccache, discard the error + ccache --version + # Verbose only works on newer ccache, discard the error local got if got="$(ccache --show-stats --verbose 2>/dev/null)"; then echo "$got" @@ -256,42 +267,44 @@ if [[ $RUNNER_OS == macOS ]]; then # this works independent of the qt version as there should be only one version installed on the runner at a time export QTDIR - if [[ $TARGET_MACOS_VERSION ]]; then - # CMAKE_OSX_DEPLOYMENT_TARGET is a vanilla cmake flag needed to compile to target macOS version + if [[ -n $TARGET_MACOS_VERSION ]]; then + # CMAKE_OSX_DEPLOYMENT_TARGET is a vanilla CMake flag needed to compile to the target macOS version flags+=("-DCMAKE_OSX_DEPLOYMENT_TARGET=$TARGET_MACOS_VERSION") - # vcpkg dependencies need a vcpkg triplet file to compile to the target macOS version - # an easy way is to copy the x64-osx.cmake file and modify it - triplets_dir="/tmp/cmake/triplets" - triplet_version="custom-triplet" - triplet_file="$triplets_dir/$triplet_version.cmake" - arch=$(uname -m) - if [[ $arch == x86_64 ]]; then - arch="x64" + if [[ ${USE_VCPKG:-} == "1" ]]; then + # vcpkg dependencies need a vcpkg triplet file to compile to the target macOS version + # An easy way is to copy the x64-osx.cmake file and modify it + triplets_dir="/tmp/cmake/triplets" + triplet_version="custom-triplet" + triplet_file="$triplets_dir/$triplet_version.cmake" + arch=$(uname -m) + if [[ $arch == x86_64 ]]; then + arch="x64" + fi + mkdir -p "$triplets_dir" + triplet_source="../vcpkg/triplets/$arch-osx.cmake" + if [[ ! -f "$triplet_source" ]]; then + triplet_source="../vcpkg/triplets/community/$arch-osx.cmake" + fi + cp "$triplet_source" "$triplet_file" + # The triplet is what vcpkg hashes into the ABI hash of every port, so two legs that + # agree here reuse each other's binary cache. Targets 14, 15 and 26 on the same Xcode + # produce identical binaries when they share the lowest deployment target, so they all + # build against 14 to keep one vcpkg cache scope instead of three. + # Keep in sync with "vcpkg_scope" and "vcpkg_deployment_target" in desktop-build.yml. + vcpkg_target="${VCPKG_TARGET_MACOS_VERSION:-$TARGET_MACOS_VERSION}" + echo "set(VCPKG_CMAKE_SYSTEM_VERSION $vcpkg_target)" >>"$triplet_file" + echo "set(VCPKG_OSX_DEPLOYMENT_TARGET $vcpkg_target)" >>"$triplet_file" + flags+=("-DVCPKG_OVERLAY_TRIPLETS=$triplets_dir") + flags+=("-DVCPKG_HOST_TRIPLET=$triplet_version") + flags+=("-DVCPKG_TARGET_TRIPLET=$triplet_version") + echo "::group::Generated triplet $triplet_file" + cat "$triplet_file" + echo "::endgroup::" fi - mkdir -p "$triplets_dir" - triplet_source="../vcpkg/triplets/$arch-osx.cmake" - if [[ ! -f "$triplet_source" ]]; then - triplet_source="../vcpkg/triplets/community/$arch-osx.cmake" - fi - cp "$triplet_source" "$triplet_file" - # The triplet is what vcpkg hashes into the ABI hash of every port, so two legs that - # agree here reuse each other's binary cache. Targets 14, 15 and 26 on the same Xcode - # produce identical binaries when they share the lowest deployment target, so they all - # build against 14 to keep one vcpkg cache scope instead of three. - # Keep in sync with "vcpkg_scope" and "vcpkg_deployment_target" in desktop-build.yml. - vcpkg_target="${VCPKG_TARGET_MACOS_VERSION:-$TARGET_MACOS_VERSION}" - echo "set(VCPKG_CMAKE_SYSTEM_VERSION $vcpkg_target)" >>"$triplet_file" - echo "set(VCPKG_OSX_DEPLOYMENT_TARGET $vcpkg_target)" >>"$triplet_file" - flags+=("-DVCPKG_OVERLAY_TRIPLETS=$triplets_dir") - flags+=("-DVCPKG_HOST_TRIPLET=$triplet_version") - flags+=("-DVCPKG_TARGET_TRIPLET=$triplet_version") - echo "::group::Generated triplet $triplet_file" - cat "$triplet_file" - echo "::endgroup::" fi - echo "::group::Signing Certificate" + echo "::group::Setup signing certificate" if [[ -n "$MACOS_CERTIFICATE_NAME" ]]; then echo "$MACOS_CERTIFICATE" | base64 --decode >"certificate.p12" security create-keychain -p "$MACOS_CI_KEYCHAIN_PWD" build.keychain @@ -306,9 +319,13 @@ if [[ $RUNNER_OS == macOS ]]; then fi echo "::endgroup::" - if [[ $MAKE_PACKAGE ]]; then - # Workaround https://github.com/actions/runner-images/issues/7522 - # have hdiutil repeat the command 10 times in hope of success. + if [[ ${MAKE_PACKAGE:-} == "1" ]]; then + # Workaround https://github.com/actions/runner-images/issues/7522: XProtect holds a + # freshly written DMG for a moment ("Resource busy"), and a mount that outlived a + # failed detach stays busy for the rest of the job. Retry every hdiutil call through + # a wrapper that widens the delay so the scan can finish, retries a detach with + # -force, and kills a wedged DiskImages helper instead of letting it hold the job + # until the 100 minute timeout. # The wrapper has to exit non-zero once it gives up: CPack reads that status to # decide whether the disk image was created, so a zero exit turns a failed hdiutil # into a much later, misleading "Problem copying the package" error that hides @@ -317,17 +334,74 @@ if [[ $RUNNER_OS == macOS ]]; then # shellcheck disable=SC2016 echo '#!/bin/bash attempts=10 - i=0 - while true; do - hdiutil "$@" && exit 0 - rc=$? - if (( ++i >= attempts )); then - echo "::error::hdiutil $1 failed $i times (last status $rc), giving up" >&2 - exit "$rc" + timeout_seconds=${HDIUTIL_TIMEOUT:-300} + subcommand=$1 + # CPACK_DMG_VOLUME_NAME in CMakeLists.txt; a -volname argument wins when there is one. + volume_name=Cockatrice + arg_count=$# + for (( i = 1; i < arg_count; i++ )); do + if [[ ${!i} == -volname ]] && (( i + 1 <= arg_count )); then + (( i++ )) + volume_name=${!i} fi - echo "hdiutil $1 attempt $i/$attempts failed (status $rc), retrying" >&2 - sleep 1 - done' >"$hdiutil_script" + done + + # macOS ships no timeout(1), so a watchdog kills the supervised call. It writes to + # /dev/null because a process holding the output pipe open would make CPack wait for + # the watchdog instead of for hdiutil; it is killed once the call returns. + run_supervised() { + "$@" & + pid=$! + ( sleep "$timeout_seconds" && kill -9 "$pid" ) >/dev/null 2>&1 & + watchdog=$! + wait "$pid" + rc=$? + kill "$watchdog" 2>/dev/null + wait "$watchdog" 2>/dev/null + return "$rc" + } + + force_detach_stale_mount() { + [[ -d /Volumes/$volume_name ]] || return 0 + echo "hdiutil $subcommand: force-detaching stale /Volumes/$volume_name" >&2 + run_supervised hdiutil detach -force "/Volumes/$volume_name" >/dev/null 2>&1 + return 0 + } + + timeouts=0 + attempt=0 + delay=1 + while (( attempt < attempts )); do + (( attempt++ )) + cmd=(hdiutil "$@") + # A busy volume is what a detach fails on, and -force is what clears it. + if (( attempt > 1 )) && [[ $subcommand == detach ]]; then + cmd=(hdiutil detach -force "${@:2}") + fi + run_supervised "${cmd[@]}" + rc=$? + (( rc == 0 )) && exit 0 + if (( rc == 137 )); then + (( timeouts++ )) + echo "hdiutil $subcommand attempt $attempt/$attempts timed out after ${timeout_seconds}s" >&2 + # A wedged DiskImages helper is not the busy-file flake: two kills are all the + # evidence the loop needs before failing is a better use of the job budget. + if (( timeouts >= 2 )); then + echo "::error::hdiutil $subcommand timed out $timeouts times, giving up" >&2 + exit "$rc" + fi + else + echo "hdiutil $subcommand attempt $attempt/$attempts failed (status $rc), retrying" >&2 + fi + (( attempt < attempts )) || break + if [[ $subcommand == create || $subcommand == attach ]]; then + force_detach_stale_mount + fi + sleep "$delay" + (( delay = delay * 2 > 30 ? 30 : delay * 2 )) + done + echo "::error::hdiutil $subcommand failed $attempt times (last status $rc), giving up" >&2 + exit "$rc"' >"$hdiutil_script" chmod +x "$hdiutil_script" flags+=(-DCPACK_COMMAND_HDIUTIL="$hdiutil_script") fi @@ -337,7 +411,7 @@ elif [[ $RUNNER_OS == Windows ]]; then # cmake only hands MSBuild a job count when one is asked for, and without it # MSBuild builds a single node: one project at a time, whatever MTT does inside # it. Ask for a node per core. - if [[ ! $BUILD_PARALLEL_LEVEL ]]; then + if [[ -z $BUILD_PARALLEL_LEVEL ]]; then BUILD_PARALLEL_LEVEL="${NUMBER_OF_PROCESSORS:-1}" fi buildflags+=(--parallel "$BUILD_PARALLEL_LEVEL") @@ -363,19 +437,20 @@ elif [[ $RUNNER_OS == Windows ]]; then fi fi -if [[ $USE_CCACHE ]]; then +if [[ ${USE_CCACHE:-} == "1" ]]; then echo "::group::Show ccache stats" ccachestatsverbose echo "::endgroup::" fi -echo "::group::Configure cmake" +# Configure CMake +echo "::group::Generate build system" cmake --version -echo "Running cmake with flags: ${flags[*]}" +echo "Running CMake configuration with the following flags: ${flags[*]}" cmake .. "${flags[@]}" echo "::endgroup::" -if [[ $USE_CCACHE ]]; then +if [[ ${USE_CCACHE:-} == "1" ]]; then # PCH-aware caching is required or ccache refuses to cache any TU that consumes a # precompiled header, silently recompiling everything on every run. The mtime and ctime # entries cover files created during the build itself: the moc/uic/rcc output and the @@ -390,9 +465,10 @@ if [[ $USE_CCACHE ]]; then ccache --set-config sloppiness=pch_defines,time_macros,include_file_mtime,include_file_ctime fi +# Build echo "::group::Build project" cpuinfo -echo "Running cmake --build with flags: ${buildflags[*]}" +echo "Running CMake with the following build flags: ${buildflags[*]}" cmake --build . "${buildflags[@]}" echo "::endgroup::" @@ -404,16 +480,16 @@ if [[ $BUILD_PROFILE && $RUNNER_OS == Windows && -f msbuild-perf.log ]]; then echo "::endgroup::" fi -if [[ $USE_CCACHE ]]; then - if [[ $CCACHE_EVICTION_AGE ]]; then - echo "::group::evict ccache files older than $CCACHE_EVICTION_AGE" +if [[ ${USE_CCACHE:-} == "1" ]]; then + if [[ -n $CCACHE_EVICTION_AGE ]]; then + echo "::group::Evict ccache files older than $CCACHE_EVICTION_AGE" ccache --evict-older-than "$CCACHE_EVICTION_AGE" echo "::endgroup::" fi echo "::group::Show ccache stats again" ccachestatsverbose echo "::endgroup::" -elif [[ $CCACHE_EVICTION_AGE ]]; then +elif [[ -n $CCACHE_EVICTION_AGE ]]; then echo "::error file=$0::ccache eviction is enabled while ccache is disabled!" fi @@ -430,9 +506,9 @@ if [[ $RUNNER_OS == macOS ]]; then echo "::endgroup::" fi -if [[ $MAKE_TEST ]]; then +if [[ ${MAKE_TEST:-} == "1" ]]; then echo "::group::Run tests" - if [[ $MAKE_SANITIZE ]]; then + if [[ ${MAKE_SANITIZE:-} == "1" ]]; then # detect_leaks=0: Qt and the sanitizers' own runtimes both leak at exit by design, and # LeakSanitizer's default is aggressive enough to bury our real findings under hundreds # of process-teardown reports. Malloc/free misuse and use-after-free are what ASan is for @@ -450,11 +526,12 @@ if [[ $MAKE_TEST ]]; then # bound and -j recovers nearly all of the per-process overhead. The test binaries are safe to # run concurrently: the only one that claims a fixed resource is single_instance_manager_test, # which already scopes its local socket name to its own pid so parallel copies cannot collide. + ctest --version ctest -C "$BUILDTYPE" --output-on-failure -j "${CTEST_JOBS:-4}" echo "::endgroup::" fi -if [[ $MAKE_COVERAGE ]]; then +if [[ ${MAKE_COVERAGE:-} == "1" ]]; then # gcovr is not a build dependency and is not available everywhere, so a missing one is reported # and skipped rather than failing an otherwise green build. echo "::group::Coverage report" @@ -479,7 +556,7 @@ if [[ $MAKE_COVERAGE ]]; then echo "::endgroup::" fi -if [[ $RUNNER_OS == Windows && ! $MAKE_NO_CLIENT ]]; then +if [[ $RUNNER_OS == Windows && ${MAKE_NO_CLIENT:-} != "1" ]]; then echo "::group::Probe the installer's holder lookup" # The working directory is still the build dir here: the package steps below are what # leave and re-enter it, and that is what made a $BUILD_DIR-prefixed path name @@ -679,18 +756,21 @@ POWERSHELL echo "::endgroup::" fi -if [[ $MAKE_INSTALL ]]; then +# Install +if [[ ${MAKE_INSTALL:-} == "1" ]]; then echo "::group::Install" cmake --build . --target install --config "$BUILDTYPE" echo "::endgroup::" fi -if [[ $MAKE_PACKAGE ]]; then +# Package +if [[ ${MAKE_PACKAGE:-} == "1" ]]; then echo "::group::Create package" + cpack --version cmake --build . --target package --config "$BUILDTYPE" echo "::endgroup::" - if [[ $PACKAGE_SUFFIX ]]; then + if [[ -n $PACKAGE_SUFFIX ]]; then echo "::group::Update package name" cd .. BUILD_DIR="$BUILD_DIR" .ci/name_build.sh "$PACKAGE_SUFFIX" diff --git a/.ci/docker.sh b/.ci/docker.sh index 64ea6baa7..c78ba3c31 100644 --- a/.ci/docker.sh +++ b/.ci/docker.sh @@ -181,6 +181,11 @@ function RUN () if [[ ${WITH_CRASH_REPORTING:-} ]]; then args+=(--env "WITH_CRASH_REPORTING=${WITH_CRASH_REPORTING}") fi + # Same reason: the DWARF level reaches compile.sh only if it is handed to the container, + # and without it CI would cache objects twice the size its bounds were measured for. + if [[ ${COCKATRICE_DEBUG_INFO_LEVEL:-} ]]; then + args+=(--env "COCKATRICE_DEBUG_INFO_LEVEL=${COCKATRICE_DEBUG_INFO_LEVEL}") + fi # shellcheck disable=2086 docker run "${args[@]}" $RUN_ARGS "$IMAGE_NAME" bash "$BUILD_SCRIPT" $RUN_OPTS "$@" return $? diff --git a/.ci/release_template.md b/.ci/release_template.md index 23b475150..8eb4c7ebd 100644 --- a/.ci/release_template.md +++ b/.ci/release_template.md @@ -21,8 +21,8 @@ Available pre-compiled binaries for installation: • Ubuntu 24.04 LTS Noble Numbat • Debian 13 Trixie • Debian 12 Bookworm + • Fedora 45 • Fedora 44 - • Fedora 43 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. diff --git a/.dockerignore b/.dockerignore index 8e0b69332..fc07b2b9d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,6 +2,6 @@ build/ .github/ .tx/ cockatrice/ -doc/ +docs/ oracle/ Dockerfile diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 404cac62e..3085bde24 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -15,8 +15,11 @@ updates: # Check for updates once a month schedule: interval: "monthly" + # A new version is not considered for a version update until x days after its release (default = 3) + cooldown: + default-days: 3 # Limit the amout of open PR's (default = 5, disabled = 0, security updates are not impacted) - open-pull-requests-limit: 2 + open-pull-requests-limit: 3 # # Enable version updates for Docker # Not yet possible to bump from one LTS version to the next and skip others, see: @@ -27,6 +30,9 @@ updates: # # Check for updates once a week # schedule: # interval: "weekly" + # # A new version is not considered for a version update until x days after its release (default = 3) + # cooldown: + # default-days: 3 # # Limit the amout of open PR's (default = 5, disabled = 0, security updates are not impacted) # open-pull-requests-limit: 1 @@ -37,5 +43,8 @@ updates: # Check for updates to GitHub Actions once a week schedule: interval: "weekly" + # A new version is not considered for a version update until x days after its release (default = 3) + cooldown: + default-days: 3 # Limit the amout of open PR's (default = 5, disabled = 0, security updates are not impacted) - open-pull-requests-limit: 2 + open-pull-requests-limit: 5 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 75494dc3e..a2c460d62 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -40,10 +40,10 @@ jobs: steps: - name: "Checkout repository" - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: "Initialize CodeQL" - uses: github/codeql-action/init@v4 + uses: github/codeql-action/init@33d7b85da1a04171e211befe2c739303b9a4c066 # v4.39.3 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -92,6 +92,6 @@ jobs: run: cmake --build build - name: "Perform CodeQL Analysis" - uses: github/codeql-action/analyze@v4 + uses: github/codeql-action/analyze@33d7b85da1a04171e211befe2c739303b9a4c066 # v4.39.3 with: category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 4ac28d3a5..731bff41b 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -15,7 +15,7 @@ on: - '!**.md' - '!.github/**' - '!.tx/**' - - '!doc/**' + - '!docs/**' - '.github/workflows/desktop-build.yml' - 'CMakeLists.txt' - 'vcpkg.json' @@ -28,7 +28,7 @@ on: - '!**.md' - '!.github/**' - '!.tx/**' - - '!doc/**' + - '!docs/**' - '.github/workflows/desktop-build.yml' - 'CMakeLists.txt' - 'vcpkg.json' @@ -47,6 +47,20 @@ concurrency: group: "${{ github.workflow }} @ ${{ github.ref_name }} (${{ github.event_name }})" cancel-in-progress: ${{ github.ref_type != 'tag' }} +# Qt's online installer ships its base package as separate archives. Installing only the +# ones Cockatrice loads drops the full 25+ archive download to this handful (Windows Qt +# cache: 440 MB → 180 MB, macOS thin Qt: ~200 MB → 60 MB). The addons Cockatrice also needs +# (Multimedia, WebSockets, ShaderTools, ImageFormats) are not base archives: they stay in +# each leg's qt_modules. +# +# The list mirrors what cmake/FindQtRuntime.cmake requires, so a component that is missing +# here fails configure with "No suitable version of Qt was found" instead of at runtime: +# qtbase supplies Core, Gui, Widgets, Network, Sql, Xml, Concurrent and PrintSupport; +# qtdeclarative supplies Qml, Quick and QuickWidgets; qtsvg supplies Svg; qttools supplies +# Linguist (lrelease/lupdate); qttranslations ships Qt's own catalogs. +env: + QT_ARCHIVES: qtbase qtdeclarative qtsvg qttools qttranslations + jobs: configure: name: Configure @@ -77,7 +91,7 @@ jobs: - name: "Checkout" if: steps.configure.outputs.tag != null - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 # fetch all history for all branches and tags @@ -89,7 +103,7 @@ jobs: TAG: ${{ steps.configure.outputs.tag }} run: .ci/prep_release.sh - - name: "Create release" + - name: "Create or update draft release" if: steps.configure.outputs.tag != null id: create_release shell: bash @@ -101,20 +115,42 @@ jobs: body_path: ${{ steps.prepare.outputs.body_path }} prerelease: ${{ steps.prepare.outputs.is_beta }} run: | - args=() - [[ $prerelease == yes ]] && args+=(--prerelease) + # A release for this tag may already exist: a maintainer can save a draft in + # the GitHub UI before pushing the tag, or an earlier run may have left one. + # Never touch a published release, and never overwrite a draft that already + # has a body (generated notes or a maintainer's edits) - only fill in an + # empty draft. Prints " "; empty when there is none. + release_state="$(gh release view "$tag_name" --json isDraft,body \ + --template '{{.isDraft}} {{len (or .body "")}}' 2>/dev/null || true)" - gh release create "$tag_name" --verify-tag --draft "${args[@]}" \ - --target "$target" \ - --title "$release_name" \ - --notes-file "$body_path" + if [[ -z "$release_state" ]]; then + args=() + [[ $prerelease == yes ]] && args+=(--prerelease) + + gh release create "$tag_name" --verify-tag --draft "${args[@]}" \ + --target "$target" \ + --title "$release_name" \ + --notes-file "$body_path" + elif [[ "$release_state" == "true 0" ]]; then + echo "Filling in the empty draft release $tag_name" + args=(--prerelease=false) + [[ $prerelease == yes ]] && args=(--prerelease) + + gh release edit "$tag_name" "${args[@]}" \ + --title "$release_name" \ + --notes-file "$body_path" + elif [[ "$release_state" == true* ]]; then + echo "::warning::Draft release $tag_name already has a body, leaving it untouched" + else + echo "::warning::Release $tag_name is already published, leaving it untouched" + fi vcpkg-baseline: name: vcpkg baseline runs-on: ubuntu-slim steps: - name: "Checkout" - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # vcpkg-tool reads versions/baseline.json out of the commit named by # `builtin-baseline`, through the `vcpkg` submodule's git history. A submodule @@ -149,7 +185,8 @@ jobs: allow-failure: yes package: skip # We are packaged in Arch already - debug_test: yes # see the note on Fedora 44 below + debug_test: yes # see the note on Fedora 45 below + ccache_size: 850M # Debug plus the suite: 680 MB working set at -g1 - distro: Servatrice_Debian version: 12 @@ -157,46 +194,47 @@ jobs: package: DEB server_only: yes test: skip - ccache_size: 400M # server only, no client to compile + # 4.7 min and the server alone: nothing here for a compiler cache to save. + persist_ccache: 'false' - distro: Debian version: 13 package: DEB - ccache_size: 850M # the Release build plus the suite it runs + ccache_size: 800M # Release plus the suite: 625 MB working set at -g1 + + # The single Fedora leg used to build Debug for the suite and Release for the + # package in one job (measured on Fedora 44: 30.8 min, the only leg slower than + # Windows), so the package compile waited behind the Debug one. Two legs run in + # parallel and each keeps a cache that holds exactly one configuration. + # + # Arch and Fedora 45 are the two newest toolchains, and the -Wno-error= exceptions + # in CMakeLists.txt exist for exactly their warnings. This leg compiles Debug + # because only Debug passes -Werror, so it is where a new warning still fails. + - distro: Fedora + version: 45 + variant: debug # separate image and ccache from the release leg below + + package: skip + debug_test: yes + ccache_size: 850M # Debug plus the suite: 660 MB of objects at -g1 - distro: Fedora - version: 44 + version: 45 + variant: release # separate image and ccache from the debug leg above package: RPM - # Arch and Fedora 44 are the two newest toolchains, and the -Wno-error= exceptions - # in CMakeLists.txt exist for exactly their warnings. They keep compiling Debug: - # only the Debug configuration passes -Werror, so these two are the legs where a - # new warning in either compiler still fails the build. - debug_test: yes - ccache_size: 900M # Debug for the suite and Release for the package + test: skip # The debug leg above runs this distro's suite + ccache_size: 800M # Release plus the package: 614 MB of objects at -g1 - distro: Ubuntu version: 26.04 package: DEB - test: skip # The sanitizer leg below runs this distro's suite - - # The one leg that runs the suite under AddressSanitizer and - # UndefinedBehaviorSanitizer. Debian 13 and Fedora 44 keep covering the - # uninstrumented suite, so nothing is lost by instrumenting this one. - # - # It packages nothing. A sanitized Debug build is not something to ship, and - # carrying the package build here meant 18 minutes of Release compile queued - # behind the instrumented one in a single job. The leg above publishes this - # distro's DEB. - - distro: Ubuntu - version: 26.04 - variant: sanitize # separate image and ccache, same distro - - package: skip - sanitize: yes - ccache_size: 800M # instrumented objects are larger + test: skip # Debian 13 covers the uninstrumented suite on pull requests + # 18.1 min, shorter than the Windows leg. The sanitize leg in the nightly + # matrix runs this distro's instrumented suite. + persist_ccache: 'false' name: ${{ matrix.distro }} ${{ matrix.version }}${{ matrix.variant && format(' {0}', matrix.variant) || '' }} needs: configure @@ -211,25 +249,71 @@ jobs: CCACHE_EVICTION_AGE: 7d # The bound is sized against the repository's 10 GB cache quota # (https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy), - # not against what one leg would like. The quota currently holds about 1.1 GB of - # CodeQL trap, 1.1 GB of vcpkg, 0.7 GB of Qt and 2.1 GB of docker buildkit layers - # (mode=min in docker-release.yml, added here, brings the last one down to roughly - # 0.3 GB - the one input here that is an expectation rather than a measurement, since - # mode=min still writes one layer per pushed binary) plus the compiler caches of the nine legs a pull request runs. The five - # nightly-only legs keep no compiler cache (see PERSIST_CCACHE below), which is what - # makes these per-leg bounds affordable. Accounting for all of it: 6.45G of ccache - # bounds + 3.2G of the fixed entries = 9.65G against the 10G quota. + # not against what one leg would like. Measured 2026-11-07, the quota holds 2.97 GB + # of these four ccache entries, 0.92 GB of Qt, 1.24 GB of vcpkg, 0.91 GB of buildkit + # layers (mode=min in docker-release.yml) and 4.26 GB of CodeQL trap caches: one entry + # per analyzed commit, about 1.07 GB each, of which every one but the newest is dead. + # That is 10.30 GB against the 10 GB quota, so every save past this point costs some + # other entry, and least-recently-used order reaches the buildkit layers before the + # trap caches. prune-caches below keeps only the newest trap cache; the live total is + # then 7.1 GB, which is what leaves room for the Windows and macOS compiler caches. # - # A configuration needs roughly 500-850 MB on Linux, so the old uniform 600M bound sat - # below what a leg writes: ccache spent each build deleting the entries the next run - # asked for (measured: 2624-3720 cleanup passes per build, 8% of calls served). Each - # leg states the size it needs instead: one configuration for most, two for Fedora 44 - # (Debug for the suite, Release for the package), the server alone for Servatrice. - # Raising these further means caching fewer legs, since GitHub evicts a whole entry - # when the total is exceeded. + # Measured 2026-11-04: one configuration costs 1.2 GB with full DWARF and 660 MB with + # -g1 (COCKATRICE_DEBUG_INFO_LEVEL below), and the size of the cache decides the hit + # rate far more sharply than its fill suggests. A 6G cache served 1059 of 1059 calls; + # the 700M bound on the same leg served 14%, because ccache fills to the limit and then + # evicts the entries the next build is about to ask for. A cache that cannot hold a + # whole configuration therefore buys almost nothing, and the quota goes to fewer legs + # at a size that holds one: + # + # Arch Debug + suite 680 MB working set 850M + # Debian 13 Release + suite 625 MB 800M + # Fedora 45 debug Debug + suite 660 MB 850M + # Fedora 45 release Release + package 614 MB 800M + # Windows 10 Release + package 890 MB working set 950M + # macOS 14/15 Release + package 0.5 GB ccache at -g1 700M + # macOS 27 Debug Debug + suite 0.5 GB ccache at -g1 700M + # + # That is 6.35 GB of bounds; what lands in the quota is 5.15 GB of compiler cache, + # 2.97 GB stored by the Linux legs, 0.95 GB by Windows and 1.23 GB by the three macOS + # legs, for 9.3 GB of 10 in total. + # So only legs worth their share get one. A leg keeps no cache when it duplicates + # another leg's coverage (Ubuntu 26.04 behind Debian 13's Release + suite) or finishes + # well inside the Windows leg (Servatrice covers the server alone in 4.7 min). The + # macOS legs compile the client with a different toolchain (Apple clang), and one + # configuration writes only ~0.5 GB, so all three fit beside the Windows entry even + # though a macOS cache served 42% and saved about 32s on a 20 min leg. The four Linux + # legs that stay compile the client on the toolchains a change can break and are the + # ones a cache shortens by the most. + # Measured job totals on master 2026-11-04 also show the ceiling this leaves: + # Windows 10 is 29.5 min and compiles for 23.7 of it, so Windows sets the + # pull-request wall clock no matter how warm these legs are. That is why the Windows + # leg keeps the largest bound (see build-vcpkg): MSVC has no -g level to shrink, and + # with /Z7 the whole configuration is cacheable, PCH included. + # + # Steady-state master data corrected this on 2026-11-07: Arch 90.1% and Debian 13 + # 96.75% per-run hits at the 850M/800M bounds, Fedora 45 debug 99.18% and release + # 99.2%. The 75%/82% seen the day before were warming runs - the -g1 switch had + # invalidated the cache one commit earlier - not thrashing at the bound. A cold 3G + # run on the same day measured one configuration at 680 MB (Arch) and 625 MB + # (Debian 13), so those bounds already hold the working set with ~170 MB of slack for + # commit-to-commit churn, and raising them buys hit rate the numbers do not show a + # need for. The three macOS entries are 700M each because one configuration wrote a + # 0.5 GB ccache directory at -g1 and level 0 shrinks it further, so the bound holds the + # whole configuration with room to spare. + # + # Re-add a leg only with a bound that holds its whole configuration: a smaller one is + # worse than none. # # Every key listed in LIVE_CCACHE_KEYS in prune-caches below must match this matrix. CCACHE_SIZE: ${{ matrix.ccache_size || '700M' }} + # Debug information dominates a cached object: with the built-in flags one + # configuration is 1.2 GB and no per-leg bound can hold it, so CI lowers the level to + # 1 (file and line, which is what a failing test or a sanitizer backtrace reports) and + # one configuration halves. Empty on tags and on the nightly legs, which ship binaries + # and keep full DWARF for the Sentry symbols; a tag restores a cache it cannot use and + # is slower, which is accepted because a tag is rare and only pull requests gate. + COCKATRICE_DEBUG_INFO_LEVEL: ${{ github.ref_type != 'tag' && '1' || '' }} CMAKE_GENERATOR: 'Ninja' NAME: ${{ matrix.distro }}${{ matrix.version }} IMAGE_SUFFIX: ${{ matrix.variant }} @@ -242,21 +326,23 @@ jobs: # also package, so testing and packaging cost one compile instead of two. DEBUG_TEST: ${{ matrix.sanitize == 'yes' || matrix.coverage == 'yes' || matrix.debug_test == 'yes' }} RUN_TESTS: ${{ matrix.test != 'skip' && !(matrix.sanitize == 'yes' || matrix.coverage == 'yes' || matrix.debug_test == 'yes') }} - # Whether this leg restores and saves its compiler cache. Every leg a pull request - # runs keeps one; the nightly-only legs do not, so that the quota is spent on the - # builds that review a change. A tag build runs both sets, and the nightly legs are - # cold there - accepted, because a tag is rare and only the pull-request legs gate. - PERSIST_CCACHE: 'true' + # Whether this leg restores and saves its compiler cache. Not every pull-request leg + # keeps one: a cache only pays for itself when its bound holds a whole configuration + # (see the sizing comment above), and a leg that duplicates another leg's coverage + # spends quota without adding any. The nightly-only legs keep none either. Fedora 45 + # states it per leg: its two configurations have their own job and key, so neither + # bound is shared with a build whose objects it would evict. + PERSIST_CCACHE: ${{ matrix.persist_ccache || 'true' }} # Shared with build-linux-nightly so the two leg sets run the same steps. steps: &linux-steps - name: "Checkout" - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: "Restore compiler cache (ccache)" id: restore_ccache if: env.PERSIST_CCACHE == 'true' - uses: actions/cache/restore@v6 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: key: ccache-${{ matrix.distro }}${{ matrix.version }}${{ matrix.variant }} path: ${{ env.CACHE }}/.ccache @@ -303,7 +389,7 @@ jobs: - name: "Upload coverage artifact" if: matrix.coverage == 'yes' - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: coverage-report path: coverage/ @@ -399,6 +485,14 @@ jobs: # comes from the client event itself (COCKATRICE_SENTRY_RELEASE). sentry-cli debug-files upload --org cockatrice --project cockatrice-desktop "${paths[@]}" + # Pull requests and tags only restore; only a push to refs/heads/master writes. A PR save + # would replace the objects the next master build restores, and a PR that compiles a + # different configuration would evict the warm set for everyone. Tags build with full + # DWARF (COCKATRICE_DEBUG_INFO_LEVEL is empty), so a tag's cache would poison the -g1 + # set if it shared the master scope; it does not, because the save is master-only. The + # nightly legs persist nothing at all (PERSIST_CCACHE is false) for the same reason: + # a schedule shares refs/heads/master with the push. + # # 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' && env.PERSIST_CCACHE == 'true' && steps.restore_ccache.outputs.cache-hit @@ -413,7 +507,7 @@ jobs: - name: "Save updated compiler cache (ccache)" if: github.ref == 'refs/heads/master' && env.PERSIST_CCACHE == 'true' - uses: actions/cache/save@v6 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: key: ${{ steps.restore_ccache.outputs.cache-primary-key }} path: ${{ env.CACHE }}/.ccache @@ -421,7 +515,7 @@ jobs: - name: "Upload artifact" id: upload_artifact if: matrix.package != 'skip' - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: archive: false if-no-files-found: error @@ -441,7 +535,7 @@ jobs: - name: "Attest binary provenance" id: attestation if: steps.upload_release.outcome == 'success' - uses: actions/attest@v4 + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 with: show-summary: false subject-path: ${{ steps.build.outputs.path }} @@ -466,7 +560,7 @@ jobs: test: skip # Running tests on all distros is superfluous - distro: Fedora - version: 43 # previous release, still covered by the nightly + version: 44 # previous release, still covered by the nightly package: RPM test: skip # Running tests on all distros is superfluous @@ -477,6 +571,28 @@ jobs: package: DEB test: skip # Running tests on all distros is superfluous + # The one leg that runs the suite under AddressSanitizer and + # UndefinedBehaviorSanitizer. It runs nightly rather than on pull requests: it is + # not on the pull-request critical path (Windows sets that), and its 1.2G cache + # was the largest single entry in the 10 GB quota. Debian 13 and Fedora 45 keep + # covering the uninstrumented suite on pull requests, so nothing is lost there. + # + # It packages nothing. A sanitized Debug build is not something to ship; the + # pull-request Ubuntu 26.04 leg publishes this distro's DEB. + - distro: Ubuntu + version: 26.04 + variant: sanitize # separate image, same distro as the coverage leg below + + package: skip + sanitize: yes + # A sanitizer finding must not redden the nightly run: vcpkg-cache-import only + # imports the archives of a run whose conclusion is success. The job itself + # still reports failure. + allow-failure: yes + # Scratch only (nightly legs persist no cache); sized so the instrumented + # objects, which are larger than the 614 MB measured, do not thrash mid-build. + ccache_size: 1200M + # The one leg that runs instrumented for line coverage and publishes the report as # a workflow artifact. It stays a separate leg rather than a second flag on the # sanitizer leg because the two instruments answer different questions: the @@ -501,7 +617,7 @@ jobs: name: ${{ matrix.distro }} ${{ matrix.version }}${{ matrix.variant && format(' {0}', matrix.variant) || '' }} needs: configure # A nightly and a tag build cover the legs a pull request or a merge does not wait for: - # the previous release of each distro, and the one instrument that only reports. + # the previous release of each distro, and the instrumented legs. if: github.event_name == 'schedule' || github.ref_type == 'tag' runs-on: ubuntu-latest # https://github.com/actions/runner-images continue-on-error: ${{ matrix.allow-failure == 'yes' }} @@ -514,10 +630,16 @@ jobs: # A cold nightly build costs minutes on a runner nobody waits for; a cold pull request # build costs a review. CCACHE_SIZE is still passed so ccache trims its scratch dir. PERSIST_CCACHE: 'false' - CCACHE_SIZE: 600M + CCACHE_SIZE: ${{ matrix.ccache_size || '600M' }} CMAKE_GENERATOR: 'Ninja' NAME: ${{ matrix.distro }}${{ matrix.version }} IMAGE_SUFFIX: ${{ matrix.variant }} + # The shared steps below gate on these, so they have to be derived here too. Left + # undefined they silently skipped the build the leg exists for: the coverage leg + # found build/ empty and its report collection failed on every nightly and tag run. + # Same derivation as the pull request job above. + DEBUG_TEST: ${{ matrix.sanitize == 'yes' || matrix.coverage == 'yes' || matrix.debug_test == 'yes' }} + RUN_TESTS: ${{ matrix.test != 'skip' && !(matrix.sanitize == 'yes' || matrix.coverage == 'yes' || matrix.debug_test == 'yes') }} steps: *linux-steps build-vcpkg: @@ -530,6 +652,7 @@ jobs: runner: xcode-27 # https://github.com/actions/runner-images/blob/main/images/macos/xcode-27-arm64-Readme.md ccache_eviction_age: 7d + ccache_size: 700M # Release + package: ~0.4 GB at level 0 cmake_generator: Ninja make_package: 1 override_target: 14 @@ -548,6 +671,7 @@ jobs: runner: xcode-27 # https://github.com/actions/runner-images/blob/main/images/macos/xcode-27-arm64-Readme.md ccache_eviction_age: 7d + ccache_size: 700M # Release + package: ~0.4 GB at level 0 cmake_generator: Ninja make_package: 1 override_target: 15 @@ -565,6 +689,7 @@ jobs: runner: xcode-27 # https://github.com/actions/runner-images/blob/main/images/macos/xcode-27-arm64-Readme.md ccache_eviction_age: 7d + ccache_size: 700M # Debug + suite: ~0.5 GB at level 0 cmake_generator: Ninja qt_version: 6.11.* qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets @@ -589,6 +714,9 @@ jobs: qt_version: 6.11.* qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets type: Release + ccache_eviction_age: 7d + ccache_size: 950M # Release plus the package: 890 MB working set without /Z7 + use_ccache: 1 # Scope is named after the vs2026 toolchain, not the generator: the # ABI hash covers cl.exe, which is identical, so this leg keeps # restoring the warm archives. Renaming would orphan them. @@ -602,40 +730,60 @@ jobs: runs-on: ${{ matrix.runner }} timeout-minutes: 100 env: + # The Windows leg and all three pull-request macOS legs keep a compiler cache. ccache + # drives MSVC as long as the compiler emits /Z7, which CMakeLists.txt sets for exactly + # this reason, and it drives clang on macOS the same way; both cache the PCH-backed + # targets with the sloppiness that .ci/compile.sh re-asserts. Every leg builds without + # debug information on non-tag refs, so one macOS configuration writes ~0.4-0.5 GB and + # the 700M bound holds it with room for commit-to-commit churn (see build-linux for the + # quota arithmetic). CCACHE_DIR: ${{ github.workspace }}/.cache/ - # macOS configurations compile about twice the TUs of a Linux one, so each of these - # three legs gets a bound above what it has been measured to write. The four macOS - # legs used to be bounded at 600M from the same quota as the Linux ones; the three - # pull-request legs now get 700M each while the nightly Intel leg keeps none, and - # that is what fits (see CCACHE_SIZE in build-linux for the full arithmetic). - CCACHE_SIZE: 700M - PERSIST_CCACHE: 'true' + CCACHE_SIZE: ${{ matrix.ccache_size || '700M' }} + PERSIST_CCACHE: ${{ matrix.persist_ccache || 'true' }} + # MSVC has no -g level, so there is nothing to ask for on Windows: level 0 is the one + # value with a meaning there, and it drops /Z7 rather than lowering a level. Pull-request + # builds carry no debug information, which is most of what a compiler cache stores; a + # tag is the build whose binary is shipped and symbolicated, so it keeps the full set. + COCKATRICE_DEBUG_INFO_LEVEL: ${{ github.ref_type != 'tag' && '0' || '' }} # Shared with build-vcpkg-nightly so the two leg sets run the same steps. steps: &vcpkg-steps - name: "Checkout" - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: recursive - name: "[Windows] Add msbuild to PATH" if: matrix.os == 'Windows' - uses: microsoft/setup-msbuild@v3 + uses: microsoft/setup-msbuild@30375c66a4eea26614e0d39710365f22f8b0af57 # v3.0.0 with: msbuild-architecture: x64 + - name: "[Windows] Setup ccache" + if: matrix.os == 'Windows' && matrix.use_ccache == 1 + shell: bash + run: choco install ccache --no-progress -y + - name: "[macOS] Setup ccache" if: matrix.os == 'macOS' && matrix.use_ccache == 1 run: brew install ccache - - name: "[macOS] Restore compiler cache (ccache)" - if: matrix.os == 'macOS' && matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true' + - name: "Restore compiler cache (ccache)" + if: matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true' id: restore_ccache - uses: actions/cache/restore@v6 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - key: ccache-${{ matrix.os }}${{ matrix.override_target }}-${{ matrix.runner }} + key: ccache-${{ matrix.os }}${{ matrix.target }} path: ${{ env.CCACHE_DIR }} + # Every macOS leg that keeps a cache restores the master entry for its own target. + # Measured on master 2026-11-07 (before the debug-information change), one macOS + # configuration wrote a 0.5 GB ccache directory at -g1 against the 700M bound, and the + # restored cache served 42% of calls. Level 0 shrinks that directory to ~0.4 GB, so the + # bound holds the whole configuration and the three entries fit the quota (see the + # sizing note in build-linux). macOS 14 and 15 build the same Release + package + # configuration for two deployment targets, but each is cheap enough to keep its own. + - name: "[macOS] Install aqtinstall" if: matrix.os == 'macOS' run: pipx install aqtinstall @@ -652,17 +800,18 @@ jobs: - name: "[macOS] Restore thin Qt ${{ steps.resolve_qt_version.outputs.version }}" if: matrix.os == 'macOS' id: restore_qt - uses: actions/cache/restore@v6 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - key: Qt-${{ steps.resolve_qt_version.outputs.version }}-macOS-${{ matrix.soc }}-${{ matrix.qt_modules }}-thin + key: Qt-${{ steps.resolve_qt_version.outputs.version }}-macOS-${{ matrix.soc }}-${{ matrix.qt_modules }}-${{ env.QT_ARCHIVES }}-thin path: ${{ github.workspace }}/Qt # Using jurplel/install-qt-action to install Qt without using brew # Qt build using vcpkg either just fails or takes too long to build - name: "[macOS] Install fat Qt ${{ matrix.qt_version }}" if: matrix.os == 'macOS' && steps.restore_qt.outputs.cache-hit != 'true' - uses: jurplel/install-qt-action@v4 + uses: jurplel/install-qt-action@b344308e4483fc892e0d27b3cd1b173ebdd95f06 # v4.5.0 with: + archives: ${{ env.QT_ARCHIVES }} cache: false # cache-key-prefix: Qt dir: ${{ github.workspace }} # thinning script depends on this location @@ -675,17 +824,18 @@ jobs: - name: "[macOS] Cache thin Qt libraries" if: matrix.os == 'macOS' && steps.restore_qt.outputs.cache-hit != 'true' && github.ref == 'refs/heads/master' - uses: actions/cache/save@v6 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: 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 + uses: jurplel/install-qt-action@b344308e4483fc892e0d27b3cd1b173ebdd95f06 # v4.5.0 with: # Qt 6.11.0 only works with aqtinstall directly from git until aqtinstall 3.4 is released aqtsource: git+https://github.com/miurahr/aqtinstall.git + archives: ${{ env.QT_ARCHIVES }} cache: ${{ github.ref == 'refs/heads/master' }} cache-key-prefix: Qt modules: ${{ matrix.qt_modules }} @@ -694,7 +844,7 @@ jobs: - name: "[Windows] Install NSIS" if: matrix.os == 'Windows' shell: bash - run: choco install nsis + run: choco install nsis --no-progress # Ninja invokes cl.exe directly and needs the MSVC environment on PATH. - name: "[Windows] Set up MSVC environment" @@ -705,7 +855,7 @@ jobs: - name: "Setup vcpkg cache" id: vcpkg-cache - uses: TAServers/vcpkg-cache@v3 + uses: TAServers/vcpkg-cache@4f07be616a4604dd29f3c6885975cf2c04abee71 # v3.4.2 with: # Every entry is keyed by vcpkg ABI hash, and one hash set exists per platform, # target and toolchain. The action restores every entry it finds under the @@ -774,6 +924,21 @@ jobs: WITH_CRASH_REPORTING: ${{ matrix.type == 'Release' && 'ON' || '' }} run: .ci/compile.sh --server --test --vcpkg + # What the compiler cache has to hold for this leg. ccache stores the objects it + # compiles, so their total is the working set a bound has to fit, and the cache + # directory shows what this build actually wrote. Debug information dominates both, + # which is why the level above is reported next to them. + - name: "Report compiler cache footprint" + if: always() + shell: bash + run: | + echo "=== ccache directory" + du -sh "$CCACHE_DIR" 2>/dev/null || echo "(none)" + echo "=== objects and archives" + find build -type f \( -name '*.o' -o -name '*.obj' -o -name '*.a' -o -name '*.lib' \) -exec du -k {} + 2>/dev/null \ + | awk '{n++; s+=$1} END {printf "%d files, %.2f GB\n", n, s/1048576}' + echo "=== debug info level in use: '${COCKATRICE_DEBUG_INFO_LEVEL}'" + - name: "Install sentry-cli" if: needs.configure.outputs.tag != null && matrix.make_package == '1' # Same as the Linux leg above: the install downloads from Sentry's registry, so a @@ -950,7 +1115,7 @@ jobs: - name: "Upload new vcpkg entries" # An empty count means the step above did not run at all, on master and on forks if: steps.vcpkg_new.outputs.count != '' && steps.vcpkg_new.outputs.count != '0' - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: if-no-files-found: error # a count above zero with nothing staged is a bug name: vcpkg-new-${{ steps.vcpkg_new.outputs.slug }} @@ -958,9 +1123,11 @@ jobs: retention-days: 1 # 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 && env.PERSIST_CCACHE == 'true' && github.ref == 'refs/heads/master' && steps.restore_ccache.outputs.cache-hit + - name: "Delete remote compiler cache (ccache)" + if: matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true' && github.ref == 'refs/heads/master' && steps.restore_ccache.outputs.cache-hit continue-on-error: true + # Bash, not the Windows runner's default pwsh, which cannot parse this `if`. + shell: bash env: CACHE_PRIMARY_KEY: ${{ steps.restore_ccache.outputs.cache-primary-key }} GH_TOKEN: ${{ github.token }} @@ -969,9 +1136,9 @@ jobs: echo "Cache deleted successfully" fi - - name: "[macOS] Cache updated compiler cache (ccache)" - if: matrix.os == 'macOS' && matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true' && github.ref == 'refs/heads/master' - uses: actions/cache/save@v6 + - name: "Cache updated compiler cache (ccache)" + if: matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true' && github.ref == 'refs/heads/master' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: key: ${{ steps.restore_ccache.outputs.cache-primary-key }} path: ${{ env.CCACHE_DIR }} @@ -1026,7 +1193,7 @@ jobs: - name: "Upload artifact" if: matrix.make_package id: upload_artifact - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: archive: false if-no-files-found: error @@ -1058,7 +1225,7 @@ jobs: # debug identifier, and the PE is what carries the unwind information. - name: "[Windows] Upload debug symbols (exe + pdb)" if: matrix.os == 'Windows' && needs.configure.outputs.tag != null - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: if-no-files-found: error name: ${{ steps.build.outputs.name }}-Symbols @@ -1073,7 +1240,7 @@ jobs: - name: "Attest binary provenance" if: steps.upload_release.outcome == 'success' id: attestation - uses: actions/attest@v4 + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 with: show-summary: false subject-path: ${{ steps.build.outputs.path }} @@ -1096,7 +1263,6 @@ jobs: 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 @@ -1105,7 +1271,6 @@ jobs: qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets soc: Intel type: Release - use_ccache: 1 vcpkg_scope: 'vcpkg/macos13-x64-xcode26.3/' vcpkg_upload: 1 # only one leg per vcpkg_scope hands its archives over @@ -1116,12 +1281,6 @@ jobs: if: github.event_name == 'schedule' || github.ref_type == 'tag' runs-on: ${{ matrix.runner }} timeout-minutes: 100 - env: - CCACHE_DIR: ${{ github.workspace }}/.cache/ - # Like build-linux-nightly: this leg keeps no compiler cache, so the quota stays with - # the pull-request legs (see PERSIST_CCACHE there). - PERSIST_CCACHE: 'false' - CCACHE_SIZE: 600M steps: *vcpkg-steps prune-caches: @@ -1149,6 +1308,16 @@ jobs: # age is the right test. Dropping a blob an index still lists costs the next image build # one rebuilt layer; docker-release gates nothing, so that is cheaper than keeping the # entries forever. + # + # codeql.yml's analyze job keeps its extracted database as one entry per analyzed commit, + # keyed codeql-trap----, about 1.07 GB each. Each run + # restores the newest entry by prefix and saves its own, so every run that analyzes a new + # commit makes the previous entry dead, and the action never deletes them: four had + # accumulated by 2026-11-07 (4.26 GB, 40% of the quota). Retiring them cannot wait for + # the age rule, because they are not the oldest entries and so not what least-recently- + # used eviction reaches: the buildkit layers are. Only the newest can be restored again, + # and a miss costs one re-extraction in a job that reports rather than gates, so keeping + # exactly that one is safe. - name: "Delete unusable cache entries" shell: bash env: @@ -1159,9 +1328,10 @@ jobs: LIVE_VCPKG_SCOPES: 'vcpkg/macos13-x64-xcode26.3/ vcpkg/macos14-arm64-xcode26.6/ vcpkg/windows10-x64-vs2026/' # Must list the ccache key of every leg that keeps a compiler cache # (PERSIST_CCACHE in build-linux and build-vcpkg), or a live entry is deleted. - # The nightly legs keep none, so their leftover entries are reclaimed here - # instead of waiting out the age rule. - LIVE_CCACHE_KEYS: 'ccache-Arch ccache-Servatrice_Debian12 ccache-Debian13 ccache-Fedora44 ccache-Ubuntu26.04 ccache-Ubuntu26.04sanitize ccache-macOS14-xcode-27 ccache-macOS15-xcode-27 ccache-macOS-xcode-27' + # The nightly legs and the pull-request legs that finish under the Windows leg + # keep none, so their leftover entries are reclaimed here instead of waiting out + # the age rule. + LIVE_CCACHE_KEYS: 'ccache-Arch ccache-Debian13 ccache-Fedora45debug ccache-Fedora45release ccache-Windows10 ccache-macOS14 ccache-macOS15 ccache-macOS27' run: | gh api --paginate "repos/$GITHUB_REPOSITORY/actions/caches?per_page=100" \ --jq '.actions_caches[] | [.id, .ref, .key, .last_accessed_at] | @tsv' >"$RUNNER_TEMP/caches.tsv" @@ -1169,15 +1339,19 @@ jobs: cutoff="$(( $(date -u +%s) - 7 * 24 * 3600 ))" live=" ${LIVE_VCPKG_SCOPES} " live_ccache=" ${LIVE_CCACHE_KEYS} " + # The one trap cache a later run can still restore, by prefix. ISO-8601 sorts + # lexicographically, so this is a plain string compare. Entries scoped to another + # ref are deleted by the ref rule below whatever this says. + newest_trap="$(awk -F'\t' '$2 == "refs/heads/master" && $3 ~ /^codeql-trap-/ && $4 > m { m = $4; k = $3 } END { print k }' "$RUNNER_TEMP/caches.tsv")" deleted=0 while IFS=$'\t' read -r id ref key accessed; do [[ -n $id ]] || continue # Only ever touch the key shapes our workflows create (buildkit-* and index-* - # come from docker-release.yml), so that an entry of some other workflow can - # never be deleted by accident + # come from docker-release.yml, codeql-trap-* from codeql-action in codeql.yml), + # so that an entry of some other workflow can never be deleted by accident case "$key" in - vcpkg/* | ccache-* | Qt* | buildkit-* | index-*) ;; + vcpkg/* | ccache-* | Qt* | buildkit-* | index-* | codeql-trap-*) ;; *) continue ;; esac @@ -1185,8 +1359,9 @@ jobs: if [[ $ref != refs/heads/master ]]; then reason="scoped to $ref" elif [[ $key == ccache-* ]]; then - # An exact token compare: ccache-Ubuntu26.04 is a prefix of - # ccache-Ubuntu26.04sanitize, and both keys are live + # An exact token compare, because one key can be a prefix of another: + # ccache-Fedora45 is a prefix of ccache-Fedora45debug and + # ccache-Fedora45release, and only the latter two are live. if [[ $live_ccache != *" $key "* ]]; then reason="no pull-request leg keeps it" fi @@ -1197,6 +1372,10 @@ jobs: if [[ $live != *" $scope "* ]]; then reason="no matrix leg uses $scope" fi + elif [[ $key == codeql-trap-* ]]; then + if [[ $key != "$newest_trap" ]]; then + reason="superseded by the newest trap cache" + fi fi if [[ -z $reason ]]; then stamp="$(date -u -d "$accessed" +%s 2>/dev/null || echo 0)" diff --git a/.github/workflows/desktop-lint.yml b/.github/workflows/desktop-lint.yml index 93ba79464..be08420ca 100644 --- a/.github/workflows/desktop-lint.yml +++ b/.github/workflows/desktop-lint.yml @@ -1,7 +1,7 @@ name: Code Style (C++) permissions: - contents: read + contents: read # needed to checkout repo on: # Push trigger not needed for linting, we do not allow direct pushes to master @@ -12,7 +12,7 @@ on: - '!.ci/**' - '!.github/**' - '!.tx/**' - - '!doc/**' + - '!docs/**' - '.ci/lint_cpp.sh' - '.github/workflows/desktop-lint.yml' - '.clang-format' @@ -21,11 +21,12 @@ on: jobs: format: - runs-on: ubuntu-slim + name: "Check formatting" + runs-on: ubuntu-slim # https://github.com/actions/runner-images/blob/main/images/ubuntu-slim/ubuntu-slim-Readme.md steps: - name: "Checkout" - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 20 # should be enough to find merge base diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index 1537d6d4d..3b8515bff 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -56,7 +56,7 @@ jobs: include: - label: x86 platform: linux/amd64 - runner: ubuntu-latest # https://github.com/actions/runner-images + runner: ubuntu-latest # https://github.com/actions/runner-images#available-images - label: arm platform: linux/arm64 @@ -67,17 +67,17 @@ jobs: steps: - name: "Checkout" - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ case(github.event_name == 'workflow_dispatch', format('refs/tags/{0}', inputs.tag), '') }} - name: "Set up Docker buildx" - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: "Login to GitHub Container Registry (GHCR)" if: (github.event_name == 'release' && github.event.release.prerelease == false) || github.event_name == 'workflow_dispatch' id: login - uses: docker/login-action@v4 + uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -92,7 +92,7 @@ jobs: # cache held. The final runtime stage is the part that still pays off. - name: "Build image" if: steps.login.outcome != 'success' - uses: docker/build-push-action@v7 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: cache-from: type=gha,scope=${{ env.CACHE_SCOPE }} cache-to: ${{ case(github.ref == 'refs/heads/master', format('type=gha,mode=min,scope={0}', env.CACHE_SCOPE), '') }} @@ -104,7 +104,7 @@ jobs: - name: "Build image and push by digest" if: steps.login.outcome == 'success' id: build - uses: docker/build-push-action@v7 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: cache-from: type=gha,scope=${{ env.CACHE_SCOPE }} cache-to: type=gha,mode=min,scope=${{ env.CACHE_SCOPE }} @@ -115,7 +115,7 @@ jobs: 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 + provenance: mode=max # do not pass secrets as build arguments with this option sbom: true - name: "Export digest" @@ -128,7 +128,7 @@ jobs: - name: "Upload digest" if: steps.login.outcome == 'success' - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: if-no-files-found: error name: digest-${{ matrix.label }} @@ -145,14 +145,14 @@ jobs: steps: - name: "Download digests" - uses: actions/download-artifact@v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: path: ${{ runner.temp }}/digests pattern: digest-* merge-multiple: true - name: "Login to GitHub Container Registry (GHCR)" - uses: docker/login-action@v4 + uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -160,7 +160,7 @@ jobs: - name: "Docker metadata" id: metadata - uses: docker/metadata-action@v6 + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: images: ${{ env.GHCR_IMAGE }} flavor: | diff --git a/.github/workflows/documentation-build.yml b/.github/workflows/documentation-build.yml index 91ee14e3e..fbc9db61e 100644 --- a/.github/workflows/documentation-build.yml +++ b/.github/workflows/documentation-build.yml @@ -1,4 +1,4 @@ -name: Generate Docs +name: Build Documentation permissions: contents: read # write permission to the destination repo come from 'deploy_key' @@ -6,7 +6,7 @@ permissions: on: pull_request: paths: - - 'doc/doxygen/**' + - 'docs/**' - '.github/workflows/documentation-build.yml' - 'Doxyfile' release: @@ -24,12 +24,12 @@ env: jobs: docs: - name: Doxygen - runs-on: ubuntu-latest + name: "Doxygen" + runs-on: ubuntu-latest # https://github.com/actions/runner-images#available-images steps: - name: "Checkout" - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: recursive @@ -40,9 +40,9 @@ jobs: dot -V - name: "Install Doxygen" - uses: ssciwr/doxygen-install@v2 + uses: ssciwr/doxygen-install@fa3e94d34131d3990b6b8ba939ab982964aa3758 # v2.0.3 with: - version: "1.18.0" + version: "1.18.0" # https://www.doxygen.nl/manual/changelog.html - name: "Update Doxygen Configuration" shell: bash @@ -65,13 +65,13 @@ jobs: - name: "Deploy to cockatrice.github.io/docs" if: github.event_name == 'release' || github.event_name == 'workflow_dispatch' - uses: peaceiris/actions-gh-pages@v4 + uses: peaceiris/actions-gh-pages@84c30a85c19949d7eee79c4ff27748b70285e453 # v4.1.0 with: deploy_key: ${{ secrets.DOCS_DEPLOY_KEY }} destination_dir: docs # docs will be available at https://cockatrice.github.io/docs/ external_repository: Cockatrice/cockatrice.github.io publish_branch: master - publish_dir: ./docs/html + publish_dir: ./docs-output/html - name: Link to Documentation Page if: github.event_name == 'release' || github.event_name == 'workflow_dispatch' diff --git a/.github/workflows/translations-pull.yml b/.github/workflows/translations-pull.yml index da3df1714..e2da19a9e 100644 --- a/.github/workflows/translations-pull.yml +++ b/.github/workflows/translations-pull.yml @@ -16,20 +16,19 @@ on: jobs: translations: + name: "Pull languages" # Do not run the scheduled workflow on forks if: github.event_name != 'schedule' || github.repository_owner == 'Cockatrice' - - name: Pull languages - runs-on: ubuntu-slim + runs-on: ubuntu-slim # https://github.com/actions/runner-images/blob/main/images/ubuntu-slim/ubuntu-slim-Readme.md steps: - name: "Checkout repo" - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: "Pull translated strings from Transifex" # Do not run this step for PR's from forks, they don't have access to the secret if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false - uses: transifex/cli-action@v2 + uses: transifex/cli-action@584fd205cbe598773b5a81ce711fa44842678189 # v2 with: # Used config file: https://github.com/Cockatrice/Cockatrice/blob/master/.tx/config # Docs: https://github.com/transifex/cli#pulling-files-from-transifex @@ -39,7 +38,7 @@ jobs: - name: "Create pull request" if: github.event_name != 'pull_request' id: create_pr - uses: peter-evans/create-pull-request@v8 + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: add-paths: | cockatrice/translations/*.ts diff --git a/.github/workflows/translations-push.yml b/.github/workflows/translations-push.yml index 17645cab2..9f59fbdfc 100644 --- a/.github/workflows/translations-push.yml +++ b/.github/workflows/translations-push.yml @@ -16,15 +16,14 @@ on: jobs: translations: + name: "Push strings" # Do not run the scheduled workflow on forks if: github.event_name != 'schedule' || github.repository_owner == 'Cockatrice' - - name: Push strings - runs-on: ubuntu-slim + runs-on: ubuntu-slim # https://github.com/actions/runner-images/blob/main/images/ubuntu-slim/ubuntu-slim-Readme.md steps: - name: "Checkout repo" - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: "Install lupdate" shell: bash @@ -37,20 +36,22 @@ jobs: FILE: cockatrice/cockatrice_en@source.ts id: cockatrice shell: bash - run: > - DIRS="cockatrice/src $(find . -maxdepth 1 -type d -name 'libcockatrice_*')" + run: | + export DIRS="cockatrice/src $(find . -maxdepth 1 -type d -name 'libcockatrice_*')" .ci/update_translation_source_strings.sh - name: "Update Oracle translation source" id: oracle shell: bash env: - DIRS: 'oracle/src' - FILE: 'oracle/oracle_en@source.ts' + DIRS: oracle/src + FILE: oracle/oracle_en@source.ts run: .ci/update_translation_source_strings.sh - name: "Render template" id: template + # Not pinnable to a SHA: the action downloads its release binary by ref name, + # so a commit ref matches no release tag. uses: chuhlomin/render-template/binary@v1 with: template: .ci/update_translation_source_strings_template.md @@ -62,7 +63,7 @@ jobs: - name: "Create pull request" if: github.event_name != 'pull_request' id: create_pr - uses: peter-evans/create-pull-request@v8 + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: add-paths: | cockatrice/cockatrice_en@source.ts diff --git a/.github/workflows/vcpkg-cache-import.yml b/.github/workflows/vcpkg-cache-import.yml index 0a377a8fd..699d8324b 100644 --- a/.github/workflows/vcpkg-cache-import.yml +++ b/.github/workflows/vcpkg-cache-import.yml @@ -23,8 +23,10 @@ permissions: on: workflow_run: - workflows: ["Build Desktop"] - types: [completed] + workflows: + - 'Build Desktop' + types: + - completed # Every import writes the same scopes, so let them queue instead of racing each other concurrency: @@ -33,12 +35,12 @@ concurrency: jobs: list: - name: List new vcpkg entries + name: "List new vcpkg entries" # Only a successful push to the default branch has archives worth importing. # Only code that landed on master may populate the scope master runs restore. # Bot actors are excluded on top: their runs must never promote binaries into # the shared scope even if they somehow target the default branch. - if: >- + if: | github.event.workflow_run.conclusion == 'success' && (github.event.workflow_run.event == 'push' || github.event.workflow_run.event == 'schedule') && github.event.workflow_run.head_branch == 'master' && @@ -46,6 +48,7 @@ jobs: !endsWith(github.event.workflow_run.actor.login, '[bot]') && !endsWith(github.event.workflow_run.triggering_actor.login, '[bot]') runs-on: ubuntu-slim # https://github.com/actions/runner-images/blob/main/images/ubuntu-slim/ubuntu-slim-Readme.md + # workflow_run is a low-trust trigger, so cache access is read-only by default even # though this run resolves to the default branch. Assert below that this took effect, # because a save under read-only access only warns and would import nothing silently. @@ -55,7 +58,7 @@ jobs: steps: - name: "Download new vcpkg entries" - uses: actions/download-artifact@v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: pattern: vcpkg-new-* path: incoming @@ -66,7 +69,7 @@ jobs: shell: bash run: | if [[ $ACTIONS_CACHE_MODE != *write* ]]; then - echo "::error::expected a write-capable cache, got '$ACTIONS_CACHE_MODE'" + echo "::error::Expected a write-capable cache, got '$ACTIONS_CACHE_MODE'" exit 1 fi @@ -88,14 +91,16 @@ jobs: # Compact, because a multi line value cannot be written to GITHUB_OUTPUT entries="$(jq -cs '.' <<<"$entries")" echo "entries=$entries" >>"$GITHUB_OUTPUT" - echo "::notice::importing $(jq length <<<"$entries") vcpkg entries" + echo "::notice::Importing $(jq length <<<"$entries") vcpkg entries" save: name: "Save ${{ matrix.entry.key }}" - needs: list if: needs.list.outputs.entries - runs-on: ubuntu-slim + needs: list + runs-on: ubuntu-slim # https://github.com/actions/runner-images/blob/main/images/ubuntu-slim/ubuntu-slim-Readme.md + cache-mode: write + strategy: # One job per archive, they run in parallel and each only uploads a single file fail-fast: false @@ -105,7 +110,7 @@ jobs: steps: - name: "Download new vcpkg entries" - uses: actions/download-artifact@v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: pattern: ${{ matrix.entry.artifact }} path: incoming @@ -113,7 +118,7 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} - name: "Save vcpkg entry" - uses: actions/cache/save@v6 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: # The payload is a Windows or macOS archive while the upload runs on Linux, and # without this the entry would be stamped as a Linux cache that neither of them diff --git a/.gitignore b/.gitignore index 54939bdcf..ef88b1f58 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,4 @@ compile_commands.json .cache .gdb_history cockatrice/resources/config/qtlogging.ini -docs/ +docs-output/ diff --git a/.gitmodules b/.gitmodules index e69c8e686..e6f115eb5 100644 --- a/.gitmodules +++ b/.gitmodules @@ -2,7 +2,7 @@ path = vcpkg url = https://github.com/microsoft/vcpkg.git [submodule "doxygen-awesome-css"] - path = doc/doxygen/theme + path = docs/doxygen/theme url = https://github.com/jothepro/doxygen-awesome-css.git [submodule "external/qttesting"] path = external/qttesting diff --git a/CMakeLists.txt b/CMakeLists.txt index 9044bc005..71f195385 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,11 +1,11 @@ # Cockatrice's main CMakeLists.txt # -# This is basically a wrapper to enable/disable the compilation -# of the different projects: servatrice, cockatrice, test +# This is basically a wrapper to enable/disable the compilation of the different projects: +# Cockatrice, Oracle, Servatrice, Test # This file sets all the variables shared between the projects # like the installation path, compilation flags etc.. -# 3.16 required for Qt6 and target_precompile_headers() +# CMake 3.16 is required for Qt6 and target_precompile_headers() cmake_minimum_required(VERSION 3.16) # Use compiler cache (ccache) @@ -40,24 +40,20 @@ if(WITH_GUI_TEST AND NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/external/qttesting/ endif() # Default to "Release" build type -# User-provided value for CMAKE_BUILD_TYPE must be checked before the PROJECT() call -if(DEFINED CMAKE_BUILD_TYPE) - set(CMAKE_BUILD_TYPE - ${CMAKE_BUILD_TYPE} - CACHE STRING "Type of build" - ) -else() +# A user-provided CMAKE_BUILD_TYPE must be checked before the PROJECT() call +if(NOT CMAKE_BUILD_TYPE) set(CMAKE_BUILD_TYPE Release - CACHE STRING "Type of build" + CACHE STRING "Build type" ) endif() -# 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) +# ccache drives MSVC too (since 4.7), but only when the compiler writes debug +# information into the object file: /Zi and /ZI keep it in a shared PDB, and ccache +# reports them as unsupported rather than risk a stale one. The MSVC flags below +# therefore use /Z7. MSVC precompiled headers are cached as well, with the same +# sloppiness the other toolchains need. +if(USE_CCACHE) find_program(CCACHE_PROGRAM ccache) if(CCACHE_PROGRAM) # Support Unix Makefiles and Ninja @@ -71,10 +67,10 @@ if(USE_CCACHE AND NOT WIN32) # check for every project on the machine, not just this one. execute_process(COMMAND ${CCACHE_PROGRAM} --set-config sloppiness=pch_defines,time_macros) message(STATUS "Found CCache ${CCACHE_PROGRAM}") + else() + # An explicit opt-in must not disappear silently. + message(STATUS "ccache requested but not found; building without a compiler cache") 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) @@ -108,20 +104,21 @@ endif() # Version can be overriden by git tags, see cmake/getversion.cmake project("Cockatrice" VERSION 3.2.0) -# Set release name if not provided via env/cmake var +# Set release name if not provided via ENV/CMake var if(NOT DEFINED GIT_TAG_RELEASENAME) set(GIT_TAG_RELEASENAME "Reality Check") endif() -# Use c++20 for all targets +# Use C++20 for all targets set(CMAKE_CXX_STANDARD 20 CACHE STRING "C++ ISO Standard" ) -set(CMAKE_CXX_STANDARD_REQUIRED True) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) # Set conventional loops -set(CMAKE_ALLOW_LOOSE_LOOP_CONSTRUCTS true) +set(CMAKE_ALLOW_LOOSE_LOOP_CONSTRUCTS ON) # Search path for cmake modules set(COCKATRICE_CMAKE_PATH "${PROJECT_SOURCE_DIR}/cmake") @@ -134,31 +131,31 @@ include(createversionfile) # Define a proper install path if(UNIX) - if(APPLE) - # macOS - # Due to the special bundle structure ignore - # the prefix eventually set by the user. + if(APPLE) # macOS + # Due to the special bundle structure ignore the prefix eventually set by the user set(CMAKE_INSTALL_PREFIX ${CMAKE_BINARY_DIR}/release) - # Force ccache usage if available - get_property(RULE_LAUNCH_COMPILE GLOBAL PROPERTY RULE_LAUNCH_COMPILE) - if(RULE_LAUNCH_COMPILE) - message(STATUS "Force enabling CCache usage under macOS") - # Set up wrapper scripts - configure_file("${COCKATRICE_CMAKE_PATH}/launch-c.in" launch-c) - configure_file("${COCKATRICE_CMAKE_PATH}/launch-cxx.in" launch-cxx) - execute_process(COMMAND chmod a+rx "${CMAKE_BINARY_DIR}/launch-c" "${CMAKE_BINARY_DIR}/launch-cxx") + # Force ccache usage if requested and available + if(USE_CCACHE) + get_property(RULE_LAUNCH_COMPILE GLOBAL PROPERTY RULE_LAUNCH_COMPILE) + if(RULE_LAUNCH_COMPILE) + message(STATUS "Force enabling ccache usage under macOS") - # Set Xcode project attributes to route compilation through our scripts - set(CMAKE_XCODE_ATTRIBUTE_CC "${CMAKE_BINARY_DIR}/launch-c") - set(CMAKE_XCODE_ATTRIBUTE_CXX "${CMAKE_BINARY_DIR}/launch-cxx") - set(CMAKE_XCODE_ATTRIBUTE_LD "${CMAKE_BINARY_DIR}/launch-c") - set(CMAKE_XCODE_ATTRIBUTE_LDPLUSPLUS "${CMAKE_BINARY_DIR}/launch-cxx") + # Set up wrapper scripts + configure_file("${COCKATRICE_CMAKE_PATH}/launch-c.in" launch-c) + configure_file("${COCKATRICE_CMAKE_PATH}/launch-cxx.in" launch-cxx) + execute_process(COMMAND chmod a+rx "${CMAKE_BINARY_DIR}/launch-c" "${CMAKE_BINARY_DIR}/launch-cxx") + + # Set Xcode project attributes to route compilation through our scripts + set(CMAKE_XCODE_ATTRIBUTE_CC "${CMAKE_BINARY_DIR}/launch-c") + set(CMAKE_XCODE_ATTRIBUTE_CXX "${CMAKE_BINARY_DIR}/launch-cxx") + set(CMAKE_XCODE_ATTRIBUTE_LD "${CMAKE_BINARY_DIR}/launch-c") + set(CMAKE_XCODE_ATTRIBUTE_LDPLUSPLUS "${CMAKE_BINARY_DIR}/launch-cxx") + endif() endif() - else() - # Linux / BSD + else() # Linux / BSD if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) - #fix package build + # Fix package build if(PREFIX) set(CMAKE_INSTALL_PREFIX ${PREFIX}) else() @@ -166,47 +163,68 @@ if(UNIX) endif() endif() endif() -elseif(WIN32) +elseif(WIN32) # Windows (including 64-bit) set(CMAKE_INSTALL_PREFIX ${CMAKE_BINARY_DIR}/rundir/${CMAKE_BUILD_TYPE}) endif() -# Define proper compilation flags -if(MSVC) - # Disable Warning C4251, C++20 compatibility, Multi-threaded Builds, Warn Detection, Unwind Semantics, Debug Symbols - set(CMAKE_CXX_FLAGS "/wd4251 /Zc:__cplusplus /std:c++20 /permissive- /W4 /MP /EHsc /Zi") - # Visual Studio: Maximum Optimization, Multi-threaded DLL - set(CMAKE_CXX_FLAGS_RELEASE "/Ox /MD") - # Visual Studio: No Optimization, Multi-threaded Debug DLL +# Define compiler flags +if(MSVC) # Microsoft Visual C++ compiler + # /EHsc Enable standard C++ exception handling + # /MP Enable parallel compilation + # /permissive- Enable more standards-conforming behavior + # /utf-8 Set source file encoding and execution character set to UTF-8 + # /W4 Enable warning level 4 + # /Zc:__cplusplus Enable correct __cplusplus reporting + # /Z7 Generate debugging information in each object file (ccache-cacheable) + # instead of a compiler-shared PDB, which ccache rejects + set(CMAKE_CXX_FLAGS "/EHsc /MP /permissive- /utf-8 /W4 /Zc:__cplusplus /Z7") + # /O2 Balanced optimization + # /MD Link against the multi-threaded DLL runtime library (Release CRT) + set(CMAKE_CXX_FLAGS_RELEASE "/O2 /MD") + # /Od Disable optimization + # /MDd Link against the multi-threaded Debug DLL runtime library (Debug CRT) set(CMAKE_CXX_FLAGS_DEBUG "/Od /MDd") - # Generate PDB, even when in release (So developers can better analyze crash logs) + # Generate PDBs, even in release builds, so developers can better analyze crash logs + # /DEBUG Enable debug symbols and PDB generation + # /OPT:REF Remove unreferenced functions/data + # /OPT:ICF Fold identical COMDAT functions/data set(CMAKE_EXE_LINKER_FLAGS_RELEASE "${CMAKE_EXE_LINKER_FLAGS_RELEASE} /DEBUG /OPT:REF /OPT:ICF") add_compile_definitions(_SILENCE_STDEXT_ARR_ITERS_DEPRECATION_WARNING) -elseif(CMAKE_COMPILER_IS_GNUCXX) - # linux/gcc, bsd/gcc, windows/mingw +elseif(CMAKE_CXX_COMPILER_ID STREQUAL "GNU") # GCC compiler include(CheckCXXCompilerFlag) - set(CMAKE_CXX_FLAGS_RELEASE "-s -O2") + # -O2 Balanced optimization + # -s Remove symbols from the executable + set(CMAKE_CXX_FLAGS_RELEASE "-O2 -s") + + # -ggdb Produce GDB debugging symbols + # -O0 Disable optimization + # -Wall Enable a broad set of useful warnings + # -Wextra Enable a set of extra warnings + # -Werror Treat warnings as compilation errors if(WARNING_AS_ERROR) set(CMAKE_CXX_FLAGS_DEBUG "-ggdb -O0 -Wall -Wextra -Werror") else() set(CMAKE_CXX_FLAGS_DEBUG "-ggdb -O0 -Wall -Wextra") endif() - if(APPLE) - set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=gnu++20") - endif() - + # -Wcast-align Catch unsafe pointer casts + # -Wmissing-declarations Catch defined functions without a prior declaration + # -Wno-error=extra Downgrade some -Wextra warnings from errors to warnings + # -Wno-error=delete-non-virtual-dtor Downgrade delete-non-virtual-dtor from error to warning + # -Wno-error=sign-compare Downgrade signed/unsigned comparison from error to warning + # -Wno-error=missing-declarations Downgrade -Wmissing-declarations from error to warning + # -Wno-error=sfinae-incomplete GCC 16+: Qt MOC + protobuf forward decls trigger this set(ADDITIONAL_DEBUG_FLAGS -Wcast-align -Wmissing-declarations - -Wno-long-long -Wno-error=extra -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 ) foreach(FLAG ${ADDITIONAL_DEBUG_FLAGS}) @@ -218,9 +236,16 @@ elseif(CMAKE_COMPILER_IS_GNUCXX) # 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 +elseif(CMAKE_CXX_COMPILER_ID MATCHES "Clang") # Clang and AppleClang compilers + # -O2 Balanced optimization set(CMAKE_CXX_FLAGS_RELEASE "-O2") + + # -g Include debug information + # -O0 Disable optimization + # -Wall Enable a broad set of useful warnings + # -Wextra Enable a set of extra warnings + # -Werror Treat warnings as compilation errors + # -Wno-unused-parameter Suppress warnings about unused function parameters (common in Qt callbacks) if(WARNING_AS_ERROR) set(CMAKE_CXX_FLAGS_DEBUG "-g -O0 -Wall -Wextra -Werror -Wno-unused-parameter") else() @@ -229,6 +254,8 @@ else() # Reduce compiler I/O by using pipes between stages instead of temp files set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pipe") +else() # Unknown compiler + message(WARNING "Unknown C++ compiler: ${CMAKE_CXX_COMPILER_ID}") endif() # Always included, because they define cockatrice_disable_sanitizers_for() and @@ -238,9 +265,37 @@ include(Sanitizers) include(Coverage) include(CrashReporting) -# GNU systems need to define the Mersenne exponent for the RNG to compile w/o warning +# Debug information is what makes a cached object large, and a build configuration needs more +# room than a per-leg cache bound can spare when it carries full DWARF: ccache then fills to +# its limit and evicts the entries the next build is about to ask for, so it serves a few +# percent of calls while holding most of a build. CI sets a lower level so one whole +# configuration fits; level 1 keeps file and line, which is what a failing test or a sanitizer +# backtrace reports. Empty (the default) leaves local, distro and release builds exactly as +# they were. This comes after CrashReporting, so the -g it adds for Sentry symbols cannot +# raise the level again. +set(COCKATRICE_DEBUG_INFO_LEVEL + "" + CACHE STRING "DWARF level for Debug and Release objects (empty keeps the built-in flags)" +) +if(NOT COCKATRICE_DEBUG_INFO_LEVEL STREQUAL "") + if(MSVC) + # MSVC has no -g level, so /Z7 is either there or it is not. Level 0 is the only value + # that means something here: without /Z7 the objects carry no debug information, which is + # what a cache that has to fit a bound holds. Anything else is the full debug info. + if(COCKATRICE_DEBUG_INFO_LEVEL STREQUAL "0") + string(REPLACE " /Z7" "" CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS}") + else() + message(WARNING "COCKATRICE_DEBUG_INFO_LEVEL is ignored on MSVC except 0") + endif() + else() + string(APPEND CMAKE_CXX_FLAGS_DEBUG " -g${COCKATRICE_DEBUG_INFO_LEVEL}") + string(APPEND CMAKE_CXX_FLAGS_RELEASE " -g${COCKATRICE_DEBUG_INFO_LEVEL}") + endif() +endif() + +# GNU systems need to define the Mersenne exponent for SFMT, so the RNG compiles without warnings if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") - add_definitions("-DSFMT_MEXP=19937") + add_compile_definitions(SFMT_MEXP=19937) endif() find_package(Threads REQUIRED) @@ -293,15 +348,15 @@ if(${Protobuf_VERSION} VERSION_LESS "3.21.0.0" AND NOT EXISTS "${Protobuf_PROTOC message(FATAL_ERROR "No protoc command found!") endif() -#Find OpenSSL -if(WIN32) +# Find OpenSSL +if(WIN32) # Windows (including 64-bit) find_package(OpenSSL REQUIRED) if(OPENSSL_FOUND) include_directories(${OPENSSL_INCLUDE_DIRS}) endif() endif() -#Find VCredist +# Find Visual C++ Redistributable if(MSVC) find_package(VCredistRuntime) endif() @@ -514,3 +569,10 @@ if(Qt6_FOUND AND Qt6_VERSION_MINOR GREATER_EQUAL 3) # Qt6.3+ requires project finalization to support translations qt_finalize_project() endif() + +# Print compiler identification at configuration time +message(STATUS "C++ compiler: ${CMAKE_CXX_COMPILER_ID}") +message(STATUS "C++ compiler version: ${CMAKE_CXX_COMPILER_VERSION}") +message(STATUS "C++ compiler path: ${CMAKE_CXX_COMPILER}") +message(STATUS "C++ standard: ${CMAKE_CXX_STANDARD}") +message(STATUS "C++ extensions: ${CMAKE_CXX_EXTENSIONS}") diff --git a/Doxyfile b/Doxyfile index c12ff4a8a..0d508ca38 100644 --- a/Doxyfile +++ b/Doxyfile @@ -74,7 +74,7 @@ PROJECT_ICON = cockatrice/resources/cockatrice.png # entered, it will be relative to the location where Doxygen was started. If # left blank the current directory will be used. -OUTPUT_DIRECTORY = docs +OUTPUT_DIRECTORY = docs-output # If the CREATE_SUBDIRS tag is set to YES then Doxygen will create up to 4096 # sub-directories (in 2 levels) under the output directory of each output format @@ -874,7 +874,7 @@ FILE_VERSION_FILTER = # DoxygenLayout.xml, Doxygen will parse it automatically even if the LAYOUT_FILE # tag is left empty. -LAYOUT_FILE = doc/doxygen/DoxygenLayout.xml +LAYOUT_FILE = docs/doxygen/DoxygenLayout.xml # The CITE_BIB_FILES tag can be used to specify one or more bib files containing # the reference definitions. This must be a list of .bib files. The .bib @@ -1091,8 +1091,8 @@ RECURSIVE = YES EXCLUDE = build/ \ cmake/ \ cmake-build-debug/ \ - doc/doxygen/theme/docs/ \ - doc/doxygen/theme/include/ \ + docs/doxygen/theme/docs/ \ + docs/doxygen/theme/include/ \ vcpkg/ # The EXCLUDE_SYMLINKS tag can be used to select whether or not files or @@ -1144,7 +1144,7 @@ EXAMPLE_RECURSIVE = NO # that contain images that are to be included in the documentation (see the # \image command). -IMAGE_PATH = doc/doxygen/images +IMAGE_PATH = docs/doxygen/images # The INPUT_FILTER tag can be used to specify a program that Doxygen should # invoke to filter for each input file. Doxygen will invoke the filter program @@ -1183,7 +1183,7 @@ INPUT_FILTER = # need to set EXTENSION_MAPPING for the extension otherwise the files are not # properly processed by Doxygen. -FILTER_PATTERNS = "*.proto=python doc/doxygen/filters/proto2cpp.py" +FILTER_PATTERNS = "*.proto=python docs/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 @@ -1414,7 +1414,7 @@ HTML_FILE_EXTENSION = .html # of the possible markers and block names see the documentation. # This tag requires that the tag GENERATE_HTML is set to YES. -HTML_HEADER = doc/doxygen/html/header.html +HTML_HEADER = docs/doxygen/html/header.html # The HTML_FOOTER tag can be used to specify a user-defined HTML footer for each # generated HTML page. If the tag is left blank Doxygen will generate a standard @@ -1424,7 +1424,7 @@ HTML_HEADER = doc/doxygen/html/header.html # that Doxygen normally uses. # This tag requires that the tag GENERATE_HTML is set to YES. -HTML_FOOTER = doc/doxygen/html/footer.html +HTML_FOOTER = docs/doxygen/html/footer.html # The HTML_STYLESHEET tag can be used to specify a user-defined cascading style # sheet that is used by each HTML page. It can be used to fine-tune the look of @@ -1454,9 +1454,9 @@ HTML_STYLESHEET = # documentation. # This tag requires that the tag GENERATE_HTML is set to YES. -HTML_EXTRA_STYLESHEET = doc/doxygen/theme/doxygen-awesome.css \ - doc/doxygen/css/hide_nav_sync.css \ - doc/doxygen/css/cockatrice_docs_style.css +HTML_EXTRA_STYLESHEET = docs/doxygen/theme/doxygen-awesome.css \ + docs/doxygen/css/hide_nav_sync.css \ + docs/doxygen/css/cockatrice_docs_style.css # The HTML_EXTRA_FILES tag can be used to specify one or more extra images or # other source files which should be copied to the HTML output directory. Note @@ -1466,7 +1466,7 @@ HTML_EXTRA_STYLESHEET = doc/doxygen/theme/doxygen-awesome.css \ # files will be copied as-is; there are no commands or markers available. # This tag requires that the tag GENERATE_HTML is set to YES. -HTML_EXTRA_FILES = doc/doxygen/js/graph_toggle.js +HTML_EXTRA_FILES = docs/doxygen/js/graph_toggle.js # The HTML_COLORSTYLE tag can be used to specify if the generated HTML output # should be rendered with a dark or light theme. diff --git a/README.md b/README.md index fa9272c0f..03f02e540 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ Join our [Discord community](https://discord.gg/3Z9yzmA) to connect with other p ### Code -To commit code to the project, please review our [Contribution Guidelines](https://github.com/Cockatrice/Cockatrice/blob/master/.github/CONTRIBUTING.md) first.
+To commit code to the project, please review our [Contribution Guidelines](https://github.com/Cockatrice/Cockatrice/blob/master/docs/CONTRIBUTING.md) first.
We maintain two tags for contributors to easier find issues to potentially work on: - [![Good first issue](https://img.shields.io/github/issues/cockatrice/cockatrice/Good%20First%20Issue)](https://github.com/Cockatrice/Cockatrice/issues?utf8=%E2%9C%93&q=is%3Aopen%20is%3Aissue%20label%3A%22Good%20first%20issue%22%20)
Issues tagged in this way provide a simple way to get started. They don't require much experience to be worked on. @@ -103,7 +103,7 @@ There are various places where useful information for different needs are mainta - [Official Wiki](https://github.com/Cockatrice/Cockatrice/wiki) `Community supported` - [Official Webpage](https://cockatrice.github.io/) - [Official README](https://github.com/Cockatrice/Cockatrice/blob/master/README.md) `This file` -- [Official CONTRIBUTING](https://github.com/Cockatrice/Cockatrice/blob/master/.github/CONTRIBUTING.md) +- [Official CONTRIBUTING](https://github.com/Cockatrice/Cockatrice/blob/master/docs/CONTRIBUTING.md) Cockatrice tries to use the [Google Developer Documentation Style Guide](https://developers.google.com/style/) to ensure consistent documentation. We encourage everybody to improve the documentation by suggesting edits based on this guide. @@ -163,7 +163,7 @@ The following flags (with their non-default values) can be passed to `cmake`: | `-DTEST=1` | Enable regression tests
**Note:** `make test` to run tests, *googletest* will be downloaded if not available | > [!TIP] -> Build code documentation ([Doxygen](https://www.doxygen.nl)) according to our [configuration](https://github.com/Cockatrice/Cockatrice/blob/master/Doxyfile) into `/docs`: +> Build code documentation ([Doxygen](https://www.doxygen.nl)) according to our [configuration](https://github.com/Cockatrice/Cockatrice/blob/master/Doxyfile) into `/docs-output`: > ```bash > doxygen Doxyfile > ``` diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index f34961b0b..77cb7f014 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -216,6 +216,8 @@ set(cockatrice_SOURCES 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/game_hosting/create_game_form_widget.cpp + src/interface/widgets/game_hosting/game_creation_options.cpp src/interface/widgets/general/background_sources.cpp src/interface/widgets/general/display/background_plate_widget.cpp src/interface/widgets/general/display/banner_widget.cpp @@ -291,9 +293,14 @@ set(cockatrice_SOURCES 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/epoch_format.h src/interface/widgets/utility/get_text_with_max.cpp + src/interface/widgets/utility/pill_painter.cpp + src/interface/widgets/utility/pill_painter.h src/interface/widgets/utility/report_utils.cpp src/interface/widgets/utility/report_utils.h + src/interface/widgets/utility/table_activation.cpp + src/interface/widgets/utility/table_activation.h src/interface/widgets/utility/sequence_edit.cpp src/interface/widgets/utility/visibility_change_listener.cpp src/interface/widgets/utility/visibility_change_listener.h @@ -381,9 +388,12 @@ set(cockatrice_SOURCES src/interface/widgets/tabs/api/edhrec/display/top_tags/edhrec_top_tags_api_response_display_widget.cpp src/interface/widgets/tabs/api/edhrec/tab_edhrec.cpp src/interface/widgets/tabs/api/edhrec/tab_edhrec_main.cpp + src/interface/widgets/tabs/alt_table_delegate.cpp + src/interface/widgets/tabs/report_status_delegate.cpp 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_alts.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 diff --git a/cockatrice/src/client/network/update/client/update_downloader.cpp b/cockatrice/src/client/network/update/client/update_downloader.cpp index 8868876fa..8b5ef3d6c 100644 --- a/cockatrice/src/client/network/update/client/update_downloader.cpp +++ b/cockatrice/src/client/network/update/client/update_downloader.cpp @@ -1,15 +1,16 @@ #include "update_downloader.h" -#include -#include #include -#include +#include #include #include #include +#include +#include #include #include #include +#include UpdateDownloader::UpdateDownloader(QObject *parent) : QObject(parent), response(nullptr) { @@ -23,48 +24,116 @@ void UpdateDownloader::beginDownload(QUrl downloadUrl) originalUrl = downloadUrl; } + if (auto *old = std::exchange(response, nullptr)) { + old->abort(); + old->deleteLater(); + } + + errorReported = false; + cancelRequested = false; + + // The transfer timeout comes from the manager (NetworkManagerFactory). response = netMan->get(QNetworkRequest(downloadUrl)); connect(response, &QNetworkReply::finished, this, &UpdateDownloader::fileFinished); + connect(response, &QNetworkReply::errorOccurred, this, &UpdateDownloader::downloadError); connect(response, &QNetworkReply::downloadProgress, this, &UpdateDownloader::downloadProgress); - connect(this, &UpdateDownloader::stopDownload, response, &QNetworkReply::abort); + connect(this, &UpdateDownloader::stopDownload, this, &UpdateDownloader::cancelDownload); } -void UpdateDownloader::downloadError(QNetworkReply::NetworkError) +void UpdateDownloader::downloadError(QNetworkReply::NetworkError networkError) { - if (response == nullptr) { + // A reply that was superseded by beginDownload() can still deliver a + // queued error; it must not be reported against the current reply. + if (response == nullptr || sender() != response || errorReported) { return; } - emit error(response->errorString().toUtf8()); + // A transfer timeout aborts the reply, so it arrives as OperationCanceledError. + // Only an abort the user asked for is suppressed; every other cancel is a + // stalled transfer and has to be reported. + if (networkError == QNetworkReply::OperationCanceledError) { + if (cancelRequested) { + return; + } + + errorReported = true; + emit error(tr("The download timed out. Check your connection and try again.")); + return; + } + + errorReported = true; + emit error(response->errorString()); +} + +void UpdateDownloader::cancelDownload() +{ + cancelRequested = true; + if (response != nullptr) { + response->abort(); + } } void UpdateDownloader::fileFinished() { - // If we finished but there's a redirect, follow it - QVariant redirect = response->attribute(QNetworkRequest::RedirectionTargetAttribute); - if (!redirect.isNull()) { - beginDownload(redirect.toUrl()); + // An aborted, superseded reply also emits finished(). Only the reply + // currently stored in response may be consumed. + if (response == nullptr || sender() != response) { return; } - // Handle any errors we had - if (response->error()) { - emit error(response->errorString()); + // errorOccurred normally reports this first; keep the check for errors that + // only surface when the reply finishes. + if (response->error() != QNetworkReply::NoError) { + downloadError(response->error()); + response->deleteLater(); + response = nullptr; return; } - // Work out the file name of the download - QString fileName = QDir::temp().path() + QDir::separator() + originalUrl.toString().section('/', -1); + // Give the update its own private directory. The name is unpredictable, so a + // local user cannot pre-create or symlink the path, and the original file + // name (including the installer suffix) is preserved inside it. + QTemporaryDir downloadDir(QDir::temp().filePath(QStringLiteral("cockatrice-update-XXXXXX"))); + if (!downloadDir.isValid()) { + emit error(tr("Could not create a temporary directory for the download.")); + response->deleteLater(); + response = nullptr; + return; + } + downloadDir.setAutoRemove(false); - // Save the build in a temporary directory - QFile file(fileName); + const QString remoteName = QFileInfo(originalUrl.path()).fileName(); + const QString fileName = downloadDir.filePath(remoteName.isEmpty() ? QStringLiteral("update") : remoteName); + + QSaveFile file(fileName); if (!file.open(QIODevice::WriteOnly)) { - emit error(tr("Could not open the file for reading.")); + downloadDir.remove(); + emit error(tr("Could not open the file for writing: %1").arg(file.errorString())); + response->deleteLater(); + response = nullptr; return; } - file.write(response->readAll()); - file.close(); + const QByteArray data = response->readAll(); + if (file.write(data) != data.size()) { + file.cancelWriting(); + downloadDir.remove(); + emit error(tr("Could not write the downloaded file to disk.")); + response->deleteLater(); + response = nullptr; + return; + } + + if (!file.commit()) { + downloadDir.remove(); + emit error(tr("Could not save the downloaded file: %1").arg(file.errorString())); + response->deleteLater(); + response = nullptr; + return; + } + + response->deleteLater(); + response = nullptr; // Emit the success signal with a URL to the download file emit downloadSuccessful(QUrl::fromLocalFile(fileName)); diff --git a/cockatrice/src/client/network/update/client/update_downloader.h b/cockatrice/src/client/network/update/client/update_downloader.h index 8aeefb22a..7704eca38 100644 --- a/cockatrice/src/client/network/update/client/update_downloader.h +++ b/cockatrice/src/client/network/update/client/update_downloader.h @@ -32,10 +32,13 @@ private: QUrl originalUrl; QNetworkAccessManager *netMan; QNetworkReply *response; + bool errorReported = false; + bool cancelRequested = false; private slots: void fileFinished(); void downloadProgress(qint64 bytesRead, qint64 totalBytes); void downloadError(QNetworkReply::NetworkError); + void cancelDownload(); }; #endif // COCKATRICE_UPDATEDOWNLOADER_H diff --git a/cockatrice/src/game_graphics/board/arrow_item.cpp b/cockatrice/src/game_graphics/board/arrow_item.cpp index f7df98ab5..b07a156bd 100644 --- a/cockatrice/src/game_graphics/board/arrow_item.cpp +++ b/cockatrice/src/game_graphics/board/arrow_item.cpp @@ -88,7 +88,7 @@ void ArrowItem::onTargetDestroyed() // 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(); + cancel(); return; } @@ -103,6 +103,12 @@ void ArrowItem::delArrow() deleteLater(); } +void ArrowItem::cancel() +{ + ungrabMouse(); + delArrow(); +} + void ArrowItem::updatePath() { if (!targetItem) { @@ -350,6 +356,17 @@ void ArrowDragItem::addChildArrow(ArrowDragItem *child) childArrows.append(child); } +void ArrowDragItem::cancel() +{ + for (const auto &child : childArrows) { + if (child) { + child->cancel(); + } + } + childArrows.clear(); + ArrowItem::cancel(); +} + void ArrowDragItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event) { if (targetLocked || !startItem) { @@ -393,8 +410,10 @@ void ArrowDragItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event) targetItem ? updatePath() : updatePath(endPos); update(); - for (auto *child : childArrows) { - child->mouseMoveEvent(event); + for (const auto &child : childArrows) { + if (child) { + child->mouseMoveEvent(event); + } } } @@ -403,10 +422,7 @@ 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); - } + cancel(); return; } @@ -415,7 +431,7 @@ void ArrowDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) // For now, we can safely assume that the start item is always a card. // The target item can be a player as well. if (!startCard) { - delArrow(); + cancel(); return; } @@ -435,7 +451,7 @@ void ArrowDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) } else if (auto *targetPlayer = qgraphicsitem_cast(targetItem)) { arrow.targetPlayerId = targetPlayer->getOwner()->getPlayerInfo()->getId(); } else { - delArrow(); + cancel(); return; } @@ -448,9 +464,12 @@ void ArrowDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) } delArrow(); - for (auto *child : childArrows) { - child->mouseReleaseEvent(event); + for (const auto &child : childArrows) { + if (child) { + child->mouseReleaseEvent(event); + } } + childArrows.clear(); } // ArrowAttachItem @@ -471,6 +490,17 @@ void ArrowAttachItem::addChildArrow(ArrowAttachItem *child) childArrows.append(child); } +void ArrowAttachItem::cancel() +{ + for (const auto &child : childArrows) { + if (child) { + child->cancel(); + } + } + childArrows.clear(); + ArrowItem::cancel(); +} + void ArrowAttachItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event) { if (targetLocked || !startItem) { @@ -509,8 +539,10 @@ void ArrowAttachItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event) targetItem ? updatePath() : updatePath(endPos); update(); - for (auto *child : childArrows) { - child->mouseMoveEvent(event); + for (const auto &child : childArrows) { + if (child) { + child->mouseMoveEvent(event); + } } } @@ -519,17 +551,16 @@ 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); - } + cancel(); return; } // Attaching could move startItem under the current cursor position, causing all children to retarget to it right // before they are processed. Prevent that. - for (auto *child : childArrows) { - child->setTargetLocked(true); + for (const auto &child : childArrows) { + if (child) { + child->setTargetLocked(true); + } } if (targetItem && targetItem != startItem) { @@ -541,7 +572,10 @@ void ArrowAttachItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) } delArrow(); - for (auto *child : childArrows) { - child->mouseReleaseEvent(event); + for (const auto &child : childArrows) { + if (child) { + child->mouseReleaseEvent(event); + } } + childArrows.clear(); } diff --git a/cockatrice/src/game_graphics/board/arrow_item.h b/cockatrice/src/game_graphics/board/arrow_item.h index 64a48cd14..c70a1030b 100644 --- a/cockatrice/src/game_graphics/board/arrow_item.h +++ b/cockatrice/src/game_graphics/board/arrow_item.h @@ -70,6 +70,8 @@ public: void onTargetDestroyed(); void delArrow(); + /// Abandons an in-progress drag arrow, discarding any child arrows with it. + virtual void cancel(); void updatePath(); void updatePath(const QPointF &endPoint); void startDrawAnimation(); @@ -112,12 +114,13 @@ class ArrowDragItem : public ArrowItem private: PlayerLogic *player; int deleteInPhase; - QList childArrows; + QList> childArrows; QMetaObject::Connection positionConnection; public: ArrowDragItem(PlayerLogic *_owner, ArrowTarget *_startItem, const QColor &_color, int _deleteInPhase); void addChildArrow(ArrowDragItem *child); + void cancel() override; protected: void mouseMoveEvent(QGraphicsSceneMouseEvent *event) override; @@ -129,12 +132,13 @@ class ArrowAttachItem : public ArrowItem Q_OBJECT private: PlayerLogic *player; - QList childArrows; + QList> childArrows; QMetaObject::Connection positionConnection; public: explicit ArrowAttachItem(ArrowTarget *_startItem); void addChildArrow(ArrowAttachItem *child); + void cancel() override; protected: void mouseMoveEvent(QGraphicsSceneMouseEvent *event) override; diff --git a/cockatrice/src/game_graphics/board/card_item.cpp b/cockatrice/src/game_graphics/board/card_item.cpp index acd0764c9..02c0427d7 100644 --- a/cockatrice/src/game_graphics/board/card_item.cpp +++ b/cockatrice/src/game_graphics/board/card_item.cpp @@ -294,6 +294,13 @@ void CardItem::drawArrow(const QColor &arrowColor) return; } + // A held shortcut repeats the action before the previous arrow is placed. + // That arrow has lost the mouse grab to the new one, so it would never + // receive the release that deletes it; discard it (and its children). + if (pendingArrow) { + pendingArrow->cancel(); + } + auto *game = owner->getGame(); PlayerLogic *arrowOwner = game->getPlayerManager()->getActiveLocalPlayer(game->getGameState()->getActivePlayer()); int phase = 0; // 0 means to not set the phase @@ -303,6 +310,7 @@ void CardItem::drawArrow(const QColor &arrowColor) } ArrowDragItem *arrow = new ArrowDragItem(arrowOwner, this, arrowColor, phase); scene()->addItem(arrow); + pendingArrow = arrow; arrow->grabMouse(); for (const auto &item : scene()->selectedItems()) { @@ -326,8 +334,14 @@ void CardItem::drawAttachArrow() return; } + // See drawArrow(): a repeated trigger must not orphan the previous arrow. + if (pendingArrow) { + pendingArrow->cancel(); + } + auto *arrow = new ArrowAttachItem(this); scene()->addItem(arrow); + pendingArrow = arrow; arrow->grabMouse(); for (const auto &item : scene()->selectedItems()) { diff --git a/cockatrice/src/game_graphics/board/card_item.h b/cockatrice/src/game_graphics/board/card_item.h index 01dacc021..ae22d9493 100644 --- a/cockatrice/src/game_graphics/board/card_item.h +++ b/cockatrice/src/game_graphics/board/card_item.h @@ -13,11 +13,13 @@ #include #include #include +#include #include #include #include #include +class ArrowItem; class CardDragItem; class ServerInfo_Card; class PlayerLogic; @@ -34,6 +36,7 @@ class CardItem : public AbstractCardItem Q_OBJECT private: CardDragItem *dragItem; + QPointer pendingArrow; void prepareDelete(); void handleClickedToPlay(bool shiftHeld); diff --git a/cockatrice/src/game_graphics/log/message_log_widget.cpp b/cockatrice/src/game_graphics/log/message_log_widget.cpp index d526766af..9fe89c04b 100644 --- a/cockatrice/src/game_graphics/log/message_log_widget.cpp +++ b/cockatrice/src/game_graphics/log/message_log_widget.cpp @@ -64,13 +64,13 @@ MessageLogWidget::getFromStr(CardZoneLogic *zone, QString cardName, int position QString zoneName = zone->getName(); if (zoneName == ZoneNames::TABLE) { - fromStr = tr(" from play"); + fromStr = tr("from play"); } else if (zoneName == ZoneNames::GRAVE) { - fromStr = tr(" from their graveyard"); + fromStr = tr("from their graveyard"); } else if (zoneName == ZoneNames::EXILE) { - fromStr = tr(" from exile"); + fromStr = tr("from exile"); } else if (zoneName == ZoneNames::HAND) { - fromStr = tr(" from their hand"); + fromStr = tr("from their hand"); } else if (zoneName == ZoneNames::DECK) { if (position == 0) { if (cardName.isEmpty()) { @@ -82,9 +82,9 @@ MessageLogWidget::getFromStr(CardZoneLogic *zone, QString cardName, int position cardNameContainsStartZone = true; } else { if (ownerChange) { - fromStr = tr(" from the top of %1's library").arg(zone->getPlayer()->getPlayerInfo()->getName()); + fromStr = tr("from the top of %1's library").arg(zone->getPlayer()->getPlayerInfo()->getName()); } else { - fromStr = tr(" from the top of their library"); + fromStr = tr("from the top of their library"); } } } else if (position == zone->getCards().size()) { @@ -97,29 +97,34 @@ MessageLogWidget::getFromStr(CardZoneLogic *zone, QString cardName, int position cardNameContainsStartZone = true; } else { if (ownerChange) { - fromStr = tr(" from the bottom of %1's library").arg(zone->getPlayer()->getPlayerInfo()->getName()); + fromStr = tr("from the bottom of %1's library").arg(zone->getPlayer()->getPlayerInfo()->getName()); } else { - fromStr = tr(" from the bottom of their library"); + fromStr = tr("from the bottom of their library"); } } } else { if (ownerChange) { - fromStr = tr(" from %1's library").arg(zone->getPlayer()->getPlayerInfo()->getName()); + fromStr = tr("from %1's library").arg(zone->getPlayer()->getPlayerInfo()->getName()); } else { - fromStr = tr(" from their library"); + fromStr = tr("from their library"); } } } else if (zoneName == ZoneNames::SIDEBOARD) { - fromStr = tr(" from sideboard"); + fromStr = tr("from sideboard"); } else if (zoneName == ZoneNames::STACK) { - fromStr = tr(" from the stack"); + fromStr = tr("from the stack"); } else { - fromStr = tr(" from custom zone '%1'").arg(zoneName); + fromStr = tr("from custom zone '%1'").arg(zoneName); } if (!cardNameContainsStartZone) { cardName.clear(); } + // The separator space is added here rather than stored in the tr() string so + // translators never have to preserve an invisible leading blank. + if (!fromStr.isEmpty()) { + fromStr.prepend(' '); + } return {cardName, fromStr}; } diff --git a/cockatrice/src/game_graphics/player/menu/card_menu.cpp b/cockatrice/src/game_graphics/player/menu/card_menu.cpp index 9f00c0cba..4f3520814 100644 --- a/cockatrice/src/game_graphics/player/menu/card_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/card_menu.cpp @@ -123,7 +123,7 @@ CardMenu::CardMenu(PlayerGraphicsItem *_player, CardItem *_card, bool _shortcuts aRevealToAll = new QAction(this); - mCardCounters = new QMenu; + mCardCounters = new QMenu(this); // Card counters for (int i = 0; i < 6; ++i) { @@ -225,7 +225,7 @@ void CardMenu::createTableMenu(bool canModifyCard) } addSeparator(); addAction(aClone); - addMenu(new MoveMenu(player)); + addMenu(new MoveMenu(player, this)); addSeparator(); addAction(aAttach); if (card->getAttachedTo()) { @@ -233,7 +233,7 @@ void CardMenu::createTableMenu(bool canModifyCard) } addAction(aDrawArrow); addSeparator(); - addMenu(new PtMenu(player)); + addMenu(new PtMenu(player, this)); addAction(aSetAnnotation); addSeparator(); addAction(aReduceLifeByPower); @@ -275,7 +275,7 @@ void CardMenu::createStackMenu(bool canModifyCard) addAction(aPlayFacedown); addSeparator(); addAction(aClone); - addMenu(new MoveMenu(player)); + addMenu(new MoveMenu(player, this)); addSeparator(); addAction(aAttach); addAction(aDrawArrow); @@ -304,7 +304,7 @@ void CardMenu::createGraveyardOrExileMenu(bool canModifyCard) addAction(aPlayFacedown); addSeparator(); addAction(aClone); - addMenu(new MoveMenu(player)); + addMenu(new MoveMenu(player, this)); addSeparator(); addAction(aAttach); addAction(aDrawArrow); @@ -342,7 +342,7 @@ void CardMenu::createHandOrCustomZoneMenu(bool canModifyCard) addSeparator(); addAction(aClone); - addMenu(new MoveMenu(player)); + addMenu(new MoveMenu(player, this)); // actions that are really wonky when done from deck or sideboard if (card->getZone()->getName() == ZoneNames::HAND) { @@ -366,7 +366,7 @@ void CardMenu::createHandOrCustomZoneMenu(bool canModifyCard) void CardMenu::createZonelessMenu(bool canModifyCard) { if (canModifyCard) { - addMenu(new MoveMenu(player)); + addMenu(new MoveMenu(player, this)); } } @@ -415,7 +415,7 @@ void CardMenu::addRelatedCardView() } addSeparator(); - auto viewRelatedCards = new QMenu(tr("View related cards")); + auto viewRelatedCards = new QMenu(tr("View related cards"), this); addMenu(viewRelatedCards); for (const CardRelation *relatedCard : relatedCards) { QString relatedCardName = relatedCard->getName(); @@ -463,16 +463,16 @@ void CardMenu::addRelatedCardActions() relatedCardName = relatedCard.getName(); // "name" } - QString text = tr("Token: "); + QString text; if (cardRelation->getDoesAttach()) { - text += - tr(cardRelation->getDoesTransform() ? "Transform into " : "Attach to ") + "\"" + relatedCardName + "\""; + text = cardRelation->getDoesTransform() ? tr("Token: Transform into \"%1\"").arg(relatedCardName) + : tr("Token: Attach to \"%1\"").arg(relatedCardName); } else if (cardRelation->getIsVariable()) { - text += "X " + relatedCardName; + text = tr("Token: X %1").arg(relatedCardName); } else if (cardRelation->getDefaultCount() != 1) { - text += QString::number(cardRelation->getDefaultCount()) + "x " + relatedCardName; + text = tr("Token: %1x %2").arg(cardRelation->getDefaultCount()).arg(relatedCardName); } else { - text += relatedCardName; + text = tr("Token: %1").arg(relatedCardName); } if (createRelatedCards == nullptr) { @@ -587,4 +587,4 @@ void CardMenu::setShortcutsActive() aRemoveCounter[i]->setShortcuts(shortcuts.getShortcut("Player/aRC" + colorWords[i])); aSetCounter[i]->setShortcuts(shortcuts.getShortcut("Player/aSC" + colorWords[i])); } -} \ No newline at end of file +} diff --git a/cockatrice/src/game_graphics/player/menu/hand_menu.cpp b/cockatrice/src/game_graphics/player/menu/hand_menu.cpp index e992f3ff5..8ef47d4cb 100644 --- a/cockatrice/src/game_graphics/player/menu/hand_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/hand_menu.cpp @@ -6,6 +6,9 @@ #include "../player_graphics_item.h" #include +#include +#include +#include #include #include #include @@ -67,6 +70,15 @@ HandMenu::HandMenu(PlayerGraphicsItem *_player, QWidget *parent) : TearOffMenu(p aRevealRandomHandCardToAll->setData(-1); connect(aRevealRandomHandCardToAll, &QAction::triggered, this, &HandMenu::onRevealRandomHandCardTriggered); + mRevealRandomHandCards = addMenu(QString()); + mRevealRandomHandCards->setObjectName("revealRandomHandCardsMenu"); + connect(mRevealRandomHandCards, &QMenu::aboutToShow, this, + &HandMenu::populateRevealRandomHandCardsMenuWithActivePlayers); + aRevealRandomHandCardsToAll = new QAction(this); + aRevealRandomHandCardsToAll->setData(-1); + connect(aRevealRandomHandCardsToAll, &QAction::triggered, this, &HandMenu::onRevealRandomHandCardsTriggered); + mRevealRandomHandCards->addAction(aRevealRandomHandCardsToAll); + // We still need to add these actions to menu here so that the shortcuts are active right away mRevealHand->addAction(aRevealHandToAll); mRevealRandomHandCard->addAction(aRevealRandomHandCardToAll); @@ -146,6 +158,8 @@ void HandMenu::retranslateUi() mRevealRandomHandCard->setTitle(tr("Reveal r&andom card to...")); aRevealRandomHandCardToAll->setText(tr("All players")); + mRevealRandomHandCards->setTitle(tr("Reveal a chosen number of random cards to...")); + aRevealRandomHandCardsToAll->setText(tr("All players")); } } @@ -233,3 +247,51 @@ void HandMenu::onRevealRandomHandCardTriggered() const int targetId = action->data().toInt(); player->getLogic()->getPlayerActions()->actRevealRandomHandCard(targetId); } + +void HandMenu::populateRevealRandomHandCardsMenuWithActivePlayers() +{ + const bool hasCards = !player->getLogic()->getHandZone()->getCards().isEmpty(); + mRevealRandomHandCards->clear(); + aRevealRandomHandCardsToAll->setEnabled(hasCards); + mRevealRandomHandCards->addAction(aRevealRandomHandCardsToAll); + mRevealRandomHandCards->addSeparator(); + const auto &players = player->getLogic()->getGame()->getPlayerManager()->getPlayers().values(); + for (auto *other : players) { + if (other == player->getLogic()) { + continue; + } + auto *action = mRevealRandomHandCards->addAction(other->getPlayerInfo()->getName()); + action->setData(other->getPlayerInfo()->getId()); + action->setEnabled(hasCards); + connect(action, &QAction::triggered, this, &HandMenu::onRevealRandomHandCardsTriggered); + } +} + +void HandMenu::onRevealRandomHandCardsTriggered() +{ + const auto *action = qobject_cast(sender()); + const int handSize = player->getLogic()->getHandZone()->getCards().size(); + if (!action || handSize == 0) { + return; + } + const int targetId = action->data().toInt(); + auto *actions = player->getLogic()->getPlayerActions(); + auto *dialog = new QInputDialog(dialogParent()); + dialog->setAttribute(Qt::WA_DeleteOnClose); + dialog->setWindowTitle(tr("Reveal Random Hand Cards")); + dialog->setLabelText(tr("Number of distinct random cards to reveal to %1:").arg(action->text())); + dialog->setInputMode(QInputDialog::IntInput); + dialog->setIntRange(1, handSize); + dialog->setIntValue(qMin(2, handSize)); + connect(dialog, &QDialog::accepted, actions, + [dialog, actions, targetId] { actions->actRevealRandomHandCards(targetId, dialog->intValue()); }); + dialog->open(); +} + +QWidget *HandMenu::dialogParent() const +{ + if (auto *view = player->scene() ? player->scene()->views().value(0) : nullptr) { + return view->window(); + } + return nullptr; +} diff --git a/cockatrice/src/game_graphics/player/menu/hand_menu.h b/cockatrice/src/game_graphics/player/menu/hand_menu.h index de7726064..03ecd7544 100644 --- a/cockatrice/src/game_graphics/player/menu/hand_menu.h +++ b/cockatrice/src/game_graphics/player/menu/hand_menu.h @@ -42,8 +42,11 @@ private slots: void populateRevealRandomHandCardMenuWithActivePlayers(); void onRevealHandTriggered(); void onRevealRandomHandCardTriggered(); + void populateRevealRandomHandCardsMenuWithActivePlayers(); + void onRevealRandomHandCardsTriggered(); private: + QWidget *dialogParent() const; PlayerGraphicsItem *player; QAction *aViewHand = nullptr; @@ -62,6 +65,9 @@ private: QMenu *mRevealRandomHandCard = nullptr; QAction *aRevealRandomHandCardToAll = nullptr; + QMenu *mRevealRandomHandCards = nullptr; + QAction *aRevealRandomHandCardsToAll = nullptr; + QMenu *mMoveHandMenu = nullptr; QAction *aMoveHandToTopLibrary = nullptr; QAction *aMoveHandToBottomLibrary = nullptr; diff --git a/cockatrice/src/game_graphics/player/menu/move_menu.cpp b/cockatrice/src/game_graphics/player/menu/move_menu.cpp index b81c4619b..24bfa3b0c 100644 --- a/cockatrice/src/game_graphics/player/menu/move_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/move_menu.cpp @@ -10,7 +10,7 @@ #include #include -MoveMenu::MoveMenu(PlayerGraphicsItem *player) : QMenu(tr("Move to")) +MoveMenu::MoveMenu(PlayerGraphicsItem *player, QWidget *parent) : QMenu(tr("Move to"), parent) { aMoveToTopLibrary = new QAction(this); aMoveToTopLibrary->setData(cmMoveToTopLibrary); diff --git a/cockatrice/src/game_graphics/player/menu/move_menu.h b/cockatrice/src/game_graphics/player/menu/move_menu.h index f1f239abd..a2cb304aa 100644 --- a/cockatrice/src/game_graphics/player/menu/move_menu.h +++ b/cockatrice/src/game_graphics/player/menu/move_menu.h @@ -17,7 +17,7 @@ class MoveMenu : public QMenu Q_OBJECT public: - explicit MoveMenu(PlayerGraphicsItem *player); + explicit MoveMenu(PlayerGraphicsItem *player, QWidget *parent); void setShortcutsActive(); void retranslateUi(); diff --git a/cockatrice/src/game_graphics/player/menu/player_menu.cpp b/cockatrice/src/game_graphics/player/menu/player_menu.cpp index 582586ac2..27949356f 100644 --- a/cockatrice/src/game_graphics/player/menu/player_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/player_menu.cpp @@ -23,6 +23,7 @@ #include #include #include +#include #include #include #include @@ -70,6 +71,11 @@ PlayerMenu::PlayerMenu(PlayerGraphicsItem *_player) : QObject(_player), player(_ connect(&SettingsCache::instance().shortcuts(), &ShortcutsSettings::shortCutChanged, this, &PlayerMenu::refreshShortcuts); + + // Re-evaluate shortcuts when the game starts or ends, so the lobby only exposes the roll-die shortcut. + connect(player->getLogic()->getGame()->getGameMetaInfo(), &GameMetaInfo::startedChanged, this, + &PlayerMenu::refreshShortcuts); + refreshShortcuts(); retranslateUi(); @@ -144,6 +150,12 @@ void PlayerMenu::refreshShortcuts() void PlayerMenu::setShortcutsActive() { shortcutsActive = true; + + if (!isGameStarted()) { + applyLobbyShortcuts(); + return; + } + for (auto *c : managedComponents) { c->setShortcutsActive(); } @@ -156,5 +168,24 @@ void PlayerMenu::setShortcutsInactive() for (auto *c : managedComponents) { c->setShortcutsInactive(); } + emit shortcutsDeactivated(); +} + +bool PlayerMenu::isGameStarted() const +{ + return player->getLogic()->getGame()->getGameMetaInfo()->started(); +} + +void PlayerMenu::applyLobbyShortcuts() +{ + // Everything stays unbound until the game starts; only the roll-die shortcut is usable in the lobby. + for (auto *c : managedComponents) { + c->setShortcutsInactive(); + } + + if (utilityMenu) { + utilityMenu->setLobbyShortcutsActive(); + } + emit shortcutsDeactivated(); } \ No newline at end of file diff --git a/cockatrice/src/game_graphics/player/menu/player_menu.h b/cockatrice/src/game_graphics/player/menu/player_menu.h index e21a689de..56de3c918 100644 --- a/cockatrice/src/game_graphics/player/menu/player_menu.h +++ b/cockatrice/src/game_graphics/player/menu/player_menu.h @@ -73,17 +73,24 @@ public: return utilityMenu; } + /** @brief Whether this player currently holds its shortcuts and the game has started. */ [[nodiscard]] bool getShortcutsActive() const { - return shortcutsActive; + return shortcutsActive && isGameStarted(); } - /** @brief Bind keyboard shortcuts. Called when this player gains focus. */ + /** @brief Bind keyboard shortcuts. Only the roll-die shortcut is bound before the game starts. */ void setShortcutsActive(); /** @brief Unbind keyboard shortcuts. Called when this player loses focus. */ void setShortcutsInactive(); private: + /** @brief True once the game has started; false while players are still in the deck-selection lobby. */ + [[nodiscard]] bool isGameStarted() const; + + /** @brief Unbinds everything except the shortcuts that remain usable before the game starts. */ + void applyLobbyShortcuts(); + PlayerGraphicsItem *player; TearOffMenu *playerMenu; QMenu *countersMenu; diff --git a/cockatrice/src/game_graphics/player/menu/pt_menu.cpp b/cockatrice/src/game_graphics/player/menu/pt_menu.cpp index e6c65fcfb..67e98bd76 100644 --- a/cockatrice/src/game_graphics/player/menu/pt_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/pt_menu.cpp @@ -9,7 +9,7 @@ #include #include -PtMenu::PtMenu(PlayerGraphicsItem *player) : QMenu(tr("Power / toughness")) +PtMenu::PtMenu(PlayerGraphicsItem *player, QWidget *parent) : QMenu(tr("Power / toughness"), parent) { PlayerActions *playerActions = player->getLogic()->getPlayerActions(); diff --git a/cockatrice/src/game_graphics/player/menu/pt_menu.h b/cockatrice/src/game_graphics/player/menu/pt_menu.h index aaf17cead..987f033f8 100644 --- a/cockatrice/src/game_graphics/player/menu/pt_menu.h +++ b/cockatrice/src/game_graphics/player/menu/pt_menu.h @@ -18,7 +18,7 @@ class PtMenu : public QMenu Q_OBJECT public: - explicit PtMenu(PlayerGraphicsItem *player); + explicit PtMenu(PlayerGraphicsItem *player, QWidget *parent); void retranslateUi(); void setShortcutsActive(); diff --git a/cockatrice/src/game_graphics/player/menu/utility_menu.cpp b/cockatrice/src/game_graphics/player/menu/utility_menu.cpp index 3b8affff5..249fc57e6 100644 --- a/cockatrice/src/game_graphics/player/menu/utility_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/utility_menu.cpp @@ -150,4 +150,13 @@ void UtilityMenu::setShortcutsInactive() aCreateAnotherToken->setShortcut(QKeySequence()); aIncrementAllCardCounters->setShortcut(QKeySequence()); } +} + +void UtilityMenu::setLobbyShortcutsActive() +{ + if (!player->getLogic()->getPlayerInfo()->getLocalOrJudge()) { + return; + } + + aRollDie->setShortcuts(SettingsCache::instance().shortcuts().getShortcut("Player/aRollDie")); } \ No newline at end of file diff --git a/cockatrice/src/game_graphics/player/menu/utility_menu.h b/cockatrice/src/game_graphics/player/menu/utility_menu.h index 009e4aca3..625c7aa62 100644 --- a/cockatrice/src/game_graphics/player/menu/utility_menu.h +++ b/cockatrice/src/game_graphics/player/menu/utility_menu.h @@ -32,6 +32,9 @@ public slots: public: explicit UtilityMenu(PlayerGraphicsItem *player, QMenu *playerMenu); + /** @brief Binds only the shortcuts that remain usable before the game starts. */ + void setLobbyShortcutsActive(); + [[nodiscard]] bool createAnotherTokenActionExists() const { return aCreateAnotherToken != nullptr; diff --git a/cockatrice/src/game_graphics/zones/view_zone.cpp b/cockatrice/src/game_graphics/zones/view_zone.cpp index be2e471d4..1716fc24d 100644 --- a/cockatrice/src/game_graphics/zones/view_zone.cpp +++ b/cockatrice/src/game_graphics/zones/view_zone.cpp @@ -1,21 +1,27 @@ #include "view_zone.h" #include "../../client/settings/cache_settings.h" +#include "../../interface/theme_manager.h" #include "../board/card_drag_item.h" #include "../board/card_item.h" #include "../card_dimensions.h" +#include "../z_values.h" #include "libcockatrice/card/printing/exact_card.h" #include "libcockatrice/filters/filter_string.h" #include "libcockatrice/protocol/pb/response.pb.h" #include "libcockatrice/protocol/pb/serverinfo_zone.pb.h" #include "libcockatrice/utility/card_ref.h" +#include #include #include #include +#include +#include #include #include #include +#include #include #include #include @@ -34,12 +40,85 @@ class QGraphicsItem; +namespace +{ +constexpr qreal MARKER_HORIZONTAL_PADDING = 4.0; +constexpr qreal MARKER_VERTICAL_PADDING = 2.0; +constexpr qreal MARKER_CORNER_RADIUS = 4.0; +constexpr qreal MARKER_INSET = 2.0; +constexpr int MARKER_FONT_SIZE = 9; +// Vertical pitch of a card column: each card shows this much before the next +// card overlaps it. Marker placement uses it to stay on the visible strip. +constexpr qreal CARD_STACK_OFFSET = CardDimensions::HEIGHT_F / 3.0; + +QFont markerFont() +{ + QFont font = themeManager != nullptr ? themeManager->appFont(AppFont::CardText) : QFont(); + font.setPixelSize(MARKER_FONT_SIZE); + font.setWeight(QFont::Bold); + return font; +} +} // namespace + +LibraryEdgeMarker::LibraryEdgeMarker(QGraphicsItem *parent) : QGraphicsItem(parent) +{ + setAcceptedMouseButtons(Qt::NoButton); + setAcceptHoverEvents(false); +} + +void LibraryEdgeMarker::setLabel(const QString &newLabel, const QString &toolTip) +{ + if (label == newLabel && this->toolTip() == toolTip) { + return; + } + + prepareGeometryChange(); + label = newLabel; + + const QFontMetrics metrics(markerFont()); + markerRect = QRectF(0, 0, metrics.horizontalAdvance(label) + 2 * MARKER_HORIZONTAL_PADDING, + metrics.height() + 2 * MARKER_VERTICAL_PADDING); + + setToolTip(toolTip); + update(); +} + +QRectF LibraryEdgeMarker::boundingRect() const +{ + return markerRect; +} + +void LibraryEdgeMarker::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*option*/, QWidget * /*widget*/) +{ + if (label.isEmpty()) { + return; + } + + painter->save(); + painter->setRenderHint(QPainter::Antialiasing, true); + painter->setFont(markerFont()); + + // ToolTipBase/ToolTipText are the palette's designed background/text pair + // for small overlays, so the chip keeps its contrast in every theme. + const QPalette palette = qApp->palette(); + painter->setPen(QPen(palette.color(QPalette::Mid), 1)); + painter->setBrush(palette.color(QPalette::ToolTipBase)); + painter->drawRoundedRect(markerRect.adjusted(0.5, 0.5, -0.5, -0.5), MARKER_CORNER_RADIUS, MARKER_CORNER_RADIUS); + + painter->setPen(palette.color(QPalette::ToolTipText)); + painter->drawText(markerRect, Qt::AlignCenter, label); + painter->restore(); +} + /** * @param parent the parent QGraphicsWidget containing the reveal zone */ ZoneViewZone::ZoneViewZone(ZoneViewZoneLogic *_logic, QGraphicsItem *parent) : SelectZone(_logic, parent), bRect(QRectF()), minRows(0), groupBy(CardList::NoSort), sortBy(CardList::NoSort) { + libraryEdgeMarker = new LibraryEdgeMarker(this); + libraryEdgeMarker->setVisible(false); + auto *viewLogic = qobject_cast(getLogic()); if (!(viewLogic->getRevealZone() && !viewLogic->getWriteableRevealZone())) { viewLogic->getOriginalZone()->getViews().append(viewLogic); @@ -183,6 +262,8 @@ void ZoneViewZone::reorganizeCards() gridSize = positionCardsForDisplay(cardsToDisplay); } + updateLibraryEdgeMarker(cardsToDisplay); + // determine bounding rect qreal aleft = 0; qreal atop = 0; @@ -232,7 +313,7 @@ ZoneViewZone::GridSize ZoneViewZone::positionCardsForDisplay(CardList &cards, Ca lastColumnProp = columnProp; qreal x = col * CardDimensions::WIDTH_F; - qreal y = row * CardDimensions::HEIGHT_F / 3; + qreal y = row * CARD_STACK_OFFSET; cItem->setPos(HORIZONTAL_PADDING + x, VERTICAL_PADDING + y); cItem->setRealZValue(i); longestRow = qMax(row, longestRow); @@ -261,7 +342,7 @@ ZoneViewZone::GridSize ZoneViewZone::positionCardsForDisplay(CardList &cards, Ca for (int i = 0; i < cardCount; i++) { auto *cItem = qobject_cast(cards.at(i)->parent()); qreal x = (i / rows) * CardDimensions::WIDTH_F; - qreal y = (i % rows) * CardDimensions::HEIGHT_F / 3; + qreal y = (i % rows) * CARD_STACK_OFFSET; cItem->setPos(HORIZONTAL_PADDING + x, VERTICAL_PADDING + y); cItem->setRealZValue(i); } @@ -270,6 +351,75 @@ ZoneViewZone::GridSize ZoneViewZone::positionCardsForDisplay(CardList &cards, Ca } } +void ZoneViewZone::updateLibraryEdgeMarker(const CardList &displayedCards) +{ + auto *viewLogic = qobject_cast(getLogic()); + if (viewLogic == nullptr || !viewLogic->isLibraryEdgeView() || displayedCards.isEmpty()) { + libraryEdgeMarkerAnchorPos = QPointF(); + libraryEdgeMarker->hide(); + return; + } + + const bool topEdge = viewLogic->showsTopOfLibrary(); + CardState *edgeState = topEdge ? displayedCards.first() : displayedCards.last(); + auto *edgeCard = edgeState == nullptr ? nullptr : qobject_cast(edgeState->parent()); + if (edgeCard == nullptr) { + libraryEdgeMarkerAnchorPos = QPointF(); + libraryEdgeMarker->hide(); + return; + } + + libraryEdgeMarkerAnchorPos = edgeCard->pos(); + applyLibraryEdgeMarkerText(); + positionLibraryEdgeMarker(); + + // Above the hovered-card layer: the chip sits inside its card, so hovering + // that card must not let the card repaint over the indicator. + libraryEdgeMarker->setZValue(ZValues::HOVERED_CARD + 0.5); + libraryEdgeMarker->show(); +} + +void ZoneViewZone::positionLibraryEdgeMarker() +{ + // The chip has to stay on the part of its card that is not covered by the + // next card in the column. The top card only shows its top strip, and its + // name is drawn top-left, so the chip goes to the right of that strip. The + // bottom card is fully visible, so the chip goes to its bottom-left, away + // from the bottom-right power/toughness. + const auto *viewLogic = qobject_cast(getLogic()); + const QRectF markerRect = libraryEdgeMarker->boundingRect(); + const bool topEdge = viewLogic != nullptr && viewLogic->showsTopOfLibrary(); + const QPointF offset = topEdge + ? QPointF(CardDimensions::WIDTH_F - markerRect.width() - MARKER_INSET, + CARD_STACK_OFFSET - markerRect.height() - MARKER_INSET) + : QPointF(MARKER_INSET, CardDimensions::HEIGHT_F - markerRect.height() - MARKER_INSET); + libraryEdgeMarker->setPos(libraryEdgeMarkerAnchorPos + offset); +} + +void ZoneViewZone::applyLibraryEdgeMarkerText() +{ + const auto *viewLogic = qobject_cast(getLogic()); + if (viewLogic == nullptr) { + return; + } + + const bool topEdge = viewLogic->showsTopOfLibrary(); + //: Short badge on the card at the top or bottom of a viewed library. The full phrase is in the tooltip. + libraryEdgeMarker->setLabel(topEdge ? tr("Top") : tr("Bottom"), + topEdge ? tr("This card is on top of the library") + : tr("This card is at the bottom of the library")); +} + +void ZoneViewZone::retranslateEdgeMarker() +{ + if (libraryEdgeMarker->isVisible()) { + // The translated label can be a different width, so reposition the chip + // to keep it inside its card. + applyLibraryEdgeMarkerText(); + positionLibraryEdgeMarker(); + } +} + void ZoneViewZone::setFilterString(const QString &_filterString) { const CardsDisplaySettings &cardsDisplay = SettingsCache::instance().cardsDisplay(); diff --git a/cockatrice/src/game_graphics/zones/view_zone.h b/cockatrice/src/game_graphics/zones/view_zone.h index f91b24834..9593ceeb9 100644 --- a/cockatrice/src/game_graphics/zones/view_zone.h +++ b/cockatrice/src/game_graphics/zones/view_zone.h @@ -9,9 +9,11 @@ #include "select_zone.h" +#include #include #include #include +#include #include #include #include @@ -33,6 +35,30 @@ inline Q_LOGGING_CATEGORY(ViewZoneLog, "view_zone"); class Response; class ServerInfo_Card; class QGraphicsSceneWheelEvent; +class QPainter; +class QStyleOptionGraphicsItem; +class QWidget; + +/** + * A small chip drawn on the card at the edge of a viewed library. + * + * It is a sibling of the card items rather than part of the zone widget, so it + * stays attached to its card when the view reflows into several columns or is + * resized. ZoneViewZone owns and positions it. + */ +class LibraryEdgeMarker : public QGraphicsItem +{ +public: + explicit LibraryEdgeMarker(QGraphicsItem *parent = nullptr); + + void setLabel(const QString &label, const QString &toolTip); + [[nodiscard]] QRectF boundingRect() const override; + void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override; + +private: + QRectF markerRect; + QString label; +}; /** * A CardZone that is a view into another CardZone. @@ -56,6 +82,8 @@ private: FilterString filterString = FilterString(""); CardList::SortOption groupBy, sortBy; bool pileView; + LibraryEdgeMarker *libraryEdgeMarker; + QPointF libraryEdgeMarkerAnchorPos; struct GridSize { @@ -64,6 +92,9 @@ private: }; GridSize positionCardsForDisplay(CardList &cards, CardList::SortOption pileOption = CardList::NoSort); + void updateLibraryEdgeMarker(const CardList &displayedCards); + void applyLibraryEdgeMarkerText(); + void positionLibraryEdgeMarker(); void handleDropEvent(const QList &dragItems, CardZoneLogic *startZone, const QPoint &dropPoint) override; @@ -79,6 +110,10 @@ public: { return optimumRect; } + + /** @brief Re-applies the translated library edge marker text after a language change. */ + void retranslateEdgeMarker(); + public slots: void addToViews(); void removeFromViews(); diff --git a/cockatrice/src/game_graphics/zones/view_zone_widget.cpp b/cockatrice/src/game_graphics/zones/view_zone_widget.cpp index 3c6cf9d02..2efa2fd13 100644 --- a/cockatrice/src/game_graphics/zones/view_zone_widget.cpp +++ b/cockatrice/src/game_graphics/zones/view_zone_widget.cpp @@ -294,6 +294,7 @@ void ZoneViewWidget::retranslateUi() shuffleCheckBox.setText(tr("shuffle when closing")); pileViewCheckBox.setText(tr("pile view")); + zone->retranslateEdgeMarker(); } void ZoneViewWidget::stopWindowDrag() diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_create_game.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_create_game.cpp index ada4237f7..f5ba5458c 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_create_game.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_create_game.cpp @@ -1,334 +1,109 @@ #include "dlg_create_game.h" #include "../../../client/settings/cache_settings.h" -#include "../interface/widgets/tabs/tab_room.h" +#include "../game_hosting/create_game_form_widget.h" +#include "../game_hosting/game_creation_options.h" +#include "../tabs/tab_room.h" #include "libcockatrice/protocol/pb/response.pb.h" #include "libcockatrice/protocol/pb/room_commands.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_game.pb.h" #include "libcockatrice/protocol/pb/serverinfo_user.pb.h" -#include "libcockatrice/utility/macros.h" +#include "libcockatrice/protocol/pending_command.h" +#include "libcockatrice/settings/game_settings.h" -#include -#include #include -#include -#include #include -#include -#include -#include #include -#include -#include -#include -#include -#include #include -#include -#include -#include -#include -#include -#include - -class QWidget; - -void DlgCreateGame::sharedCtor() -{ - rememberGameSettings = new QCheckBox(tr("Re&member settings")); - descriptionLabel = new QLabel(tr("&Description:")); - descriptionEdit = new QLineEdit; - descriptionEdit->setMaxLength(MAX_NAME_LENGTH); - descriptionLabel->setBuddy(descriptionEdit); - - maxPlayersLabel = new QLabel(tr("P&layers:")); - maxPlayersEdit = new QSpinBox(); - maxPlayersEdit->setMinimum(1); - maxPlayersEdit->setMaximum(100); - maxPlayersEdit->setValue(2); - maxPlayersLabel->setBuddy(maxPlayersEdit); - - auto *generalGrid = new QGridLayout; - generalGrid->addWidget(descriptionLabel, 0, 0); - generalGrid->addWidget(descriptionEdit, 0, 1); - generalGrid->addWidget(maxPlayersLabel, 1, 0); - generalGrid->addWidget(maxPlayersEdit, 1, 1); - generalGroupBox = new QGroupBox(tr("General")); - generalGroupBox->setLayout(generalGrid); - - auto *gameTypeLayout = new QVBoxLayout; - QMapIterator gameTypeIterator(gameTypes); - while (gameTypeIterator.hasNext()) { - gameTypeIterator.next(); - auto *gameTypeRadioButton = new QRadioButton(gameTypeIterator.value(), this); - gameTypeLayout->addWidget(gameTypeRadioButton); - gameTypeCheckBoxes.insert(gameTypeIterator.key(), gameTypeRadioButton); - bool isChecked = SettingsCache::instance().game().getGameTypes().contains(gameTypeIterator.value() + ", "); - gameTypeCheckBoxes[gameTypeIterator.key()]->setChecked(isChecked); - } - auto *gameTypeGroupBox = new QGroupBox(tr("Game type")); - gameTypeGroupBox->setLayout(gameTypeLayout); - - passwordLabel = new QLabel(tr("&Password:")); - passwordEdit = new QLineEdit; - passwordEdit->setMaxLength(MAX_NAME_LENGTH); - passwordLabel->setBuddy(passwordEdit); - - onlyBuddiesCheckBox = new QCheckBox(tr("Only &buddies can join")); - onlyRegisteredCheckBox = new QCheckBox(tr("Only ®istered users can join")); - if (room && room->getUserInfo()->user_level() & ServerInfo_User::IsRegistered) { - onlyRegisteredCheckBox->setChecked(true); - } else { - onlyBuddiesCheckBox->setEnabled(false); - onlyRegisteredCheckBox->setEnabled(false); - } - - auto *joinRestrictionsLayout = new QGridLayout; - joinRestrictionsLayout->addWidget(passwordLabel, 0, 0); - joinRestrictionsLayout->addWidget(passwordEdit, 0, 1); - joinRestrictionsLayout->addWidget(onlyBuddiesCheckBox, 1, 0, 1, 2); - joinRestrictionsLayout->addWidget(onlyRegisteredCheckBox, 2, 0, 1, 2); - - auto *joinRestrictionsGroupBox = new QGroupBox(tr("Joining restrictions")); - joinRestrictionsGroupBox->setLayout(joinRestrictionsLayout); - - spectatorsAllowedCheckBox = new QCheckBox(tr("&Spectators can watch")); - spectatorsAllowedCheckBox->setChecked(true); - connect(spectatorsAllowedCheckBox, &QCheckBox::QT_STATE_CHANGED, this, &DlgCreateGame::spectatorsAllowedChanged); - spectatorsNeedPasswordCheckBox = new QCheckBox(tr("Spectators &need a password to watch")); - spectatorsCanTalkCheckBox = new QCheckBox(tr("Spectators can &chat")); - spectatorsSeeEverythingCheckBox = new QCheckBox(tr("Spectators can see &hands")); - createGameAsSpectatorCheckBox = new QCheckBox(tr("Create game as spectator")); - auto *spectatorsLayout = new QVBoxLayout; - spectatorsLayout->addWidget(spectatorsAllowedCheckBox); - spectatorsLayout->addWidget(spectatorsNeedPasswordCheckBox); - spectatorsLayout->addWidget(spectatorsCanTalkCheckBox); - spectatorsLayout->addWidget(spectatorsSeeEverythingCheckBox); - spectatorsLayout->addWidget(createGameAsSpectatorCheckBox); - spectatorsGroupBox = new QGroupBox(tr("Spectators")); - spectatorsGroupBox->setLayout(spectatorsLayout); - - startingLifeTotalLabel = new QLabel(tr("Starting life total:")); - startingLifeTotalEdit = new QSpinBox(); - startingLifeTotalEdit->setMinimum(1); - startingLifeTotalEdit->setMaximum(99999); ///< Arbitrary but we can raise this when people start complaining. - startingLifeTotalEdit->setValue(20); - startingLifeTotalLabel->setBuddy(startingLifeTotalEdit); - - shareDecklistsOnLoadCheckBox = new QCheckBox(tr("Open decklists in lobby")); - - shufflePlayersCheckBox = new QCheckBox(tr("Randomize turn order (seats) on each game start")); - - createGameAsJudgeCheckBox = new QCheckBox(tr("Create game as judge")); - - auto *gameSetupOptionsLayout = new QGridLayout; - gameSetupOptionsLayout->addWidget(startingLifeTotalLabel, 0, 0); - gameSetupOptionsLayout->addWidget(startingLifeTotalEdit, 0, 1); - gameSetupOptionsLayout->addWidget(shareDecklistsOnLoadCheckBox, 1, 0, 1, 2); - gameSetupOptionsLayout->addWidget(shufflePlayersCheckBox, 2, 0, 1, 2); - if (room && room->getUserInfo()->user_level() & ServerInfo_User::IsJudge) { - gameSetupOptionsLayout->addWidget(createGameAsJudgeCheckBox, 3, 0, 1, 2); - } else { - createGameAsJudgeCheckBox->setChecked(false); - createGameAsJudgeCheckBox->setHidden(true); - } - gameSetupOptionsGroupBox = new QGroupBox(tr("Game setup options")); - gameSetupOptionsGroupBox->setLayout(gameSetupOptionsLayout); - - auto *grid = new QGridLayout; - - // Top row - grid->addWidget(generalGroupBox, 0, 0); - grid->addWidget(joinRestrictionsGroupBox, 0, 1); - - // Middle row: left column - grid->addWidget(gameTypeGroupBox, 1, 0); - - // Middle row: right column (game setup + spectators) - auto *rightLayout = new QVBoxLayout; - rightLayout->addWidget(spectatorsGroupBox, Qt::AlignTop); // top - rightLayout->addWidget(gameSetupOptionsGroupBox); // bottom - - grid->addLayout(rightLayout, 1, 1); - - // Bottom row - grid->addWidget(rememberGameSettings, 3, 0, 1, 2); // span both columns if needed - - buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok); - connect(buttonBox, &QDialogButtonBox::rejected, this, &DlgCreateGame::reject); - - auto *mainLayout = new QVBoxLayout; - mainLayout->addLayout(grid); - mainLayout->addWidget(buttonBox); - - setLayout(mainLayout); - - setFixedHeight(sizeHint().height()); -} DlgCreateGame::DlgCreateGame(TabRoom *_room, const QMap &_gameTypes, QWidget *parent) - : QDialog(parent), room(_room), gameTypes(_gameTypes) + : QDialog(parent), room(_room) { - sharedCtor(); + const bool registered = room && (room->getUserInfo()->user_level() & ServerInfo_User::IsRegistered); + const bool judge = room && (room->getUserInfo()->user_level() & ServerInfo_User::IsJudge); - 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().game().getOnlyBuddies()); - onlyRegisteredCheckBox->setChecked(SettingsCache::instance().game().getOnlyRegistered()); + form = new CreateGameFormWidget(_gameTypes, registered, judge, this); + const bool rememberSettings = SettingsCache::instance().game().getRememberGameSettings(); + form->setRememberSettingsChecked(rememberSettings); + if (rememberSettings) { + form->setOptions(GameCreationOptions::fromSettings(SettingsCache::instance().game(), _gameTypes, registered)); } else { - onlyBuddiesCheckBox->setEnabled(false); - onlyRegisteredCheckBox->setEnabled(false); + form->resetToDefaults(); } - 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()); - shufflePlayersCheckBox->setChecked(SettingsCache::instance().game().getShufflePlayers()); + form->focusDescription(); - if (!rememberGameSettings->isChecked()) { - actReset(); - } + errorLabel = new QLabel(this); + errorLabel->setObjectName("createGameErrorLabel"); + errorLabel->setWordWrap(true); + errorLabel->setVisible(false); - descriptionEdit->setFocus(); - clearButton = new QPushButton(tr("&Clear")); + buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok, this); + connect(buttonBox, &QDialogButtonBox::rejected, this, &DlgCreateGame::reject); + clearButton = new QPushButton(tr("&Clear"), this); buttonBox->addButton(QDialogButtonBox::Cancel); buttonBox->addButton(clearButton, QDialogButtonBox::ActionRole); connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgCreateGame::actOK); connect(clearButton, &QPushButton::clicked, this, &DlgCreateGame::actReset); + auto *mainLayout = new QVBoxLayout; + mainLayout->addWidget(form); + mainLayout->addWidget(errorLabel); + mainLayout->addWidget(buttonBox); + + setLayout(mainLayout); setWindowTitle(tr("Create game")); } DlgCreateGame::DlgCreateGame(const ServerInfo_Game &gameInfo, const QMap &_gameTypes, QWidget *parent) - : QDialog(parent), room(0), gameTypes(_gameTypes) + : QDialog(parent), room(nullptr) { - sharedCtor(); - - rememberGameSettings->setEnabled(false); - descriptionEdit->setEnabled(false); - maxPlayersEdit->setEnabled(false); - passwordEdit->setEnabled(false); - onlyBuddiesCheckBox->setEnabled(false); - onlyRegisteredCheckBox->setEnabled(false); - spectatorsAllowedCheckBox->setEnabled(false); - spectatorsNeedPasswordCheckBox->setEnabled(false); - spectatorsCanTalkCheckBox->setEnabled(false); - spectatorsSeeEverythingCheckBox->setEnabled(false); - createGameAsSpectatorCheckBox->setEnabled(false); - startingLifeTotalEdit->setEnabled(false); - shareDecklistsOnLoadCheckBox->setEnabled(false); - shufflePlayersCheckBox->setEnabled(false); - - descriptionEdit->setText(QString::fromStdString(gameInfo.description())); - maxPlayersEdit->setValue(gameInfo.max_players()); - onlyBuddiesCheckBox->setChecked(gameInfo.only_buddies()); - onlyRegisteredCheckBox->setChecked(gameInfo.only_registered()); - spectatorsAllowedCheckBox->setChecked(gameInfo.spectators_allowed()); - spectatorsNeedPasswordCheckBox->setChecked(gameInfo.spectators_need_password()); - spectatorsCanTalkCheckBox->setChecked(gameInfo.spectators_can_chat()); - spectatorsSeeEverythingCheckBox->setChecked(gameInfo.spectators_omniscient()); - shareDecklistsOnLoadCheckBox->setChecked(gameInfo.share_decklists_on_load()); - shufflePlayersCheckBox->setChecked(gameInfo.shuffle_players()); - - QSet types; - for (int i = 0; i < gameInfo.game_types_size(); ++i) { - types.insert(gameInfo.game_types(i)); - } - - QMapIterator gameTypeIterator(gameTypes); - while (gameTypeIterator.hasNext()) { - gameTypeIterator.next(); - - QRadioButton *gameTypeCheckBox = gameTypeCheckBoxes.value(gameTypeIterator.key()); - gameTypeCheckBox->setEnabled(false); - gameTypeCheckBox->setChecked(types.contains(gameTypeIterator.key())); - } + form = new CreateGameFormWidget(_gameTypes, false, false, this); + form->setOptions(GameCreationOptions::fromGameInfo(gameInfo, _gameTypes)); + form->setReadOnly(true); + buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok, this); connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgCreateGame::accept); + auto *mainLayout = new QVBoxLayout; + mainLayout->addWidget(form); + mainLayout->addWidget(buttonBox); + + setLayout(mainLayout); setWindowTitle(tr("Game information")); } void DlgCreateGame::actReset() { - descriptionEdit->setText(""); - maxPlayersEdit->setValue(2); - - passwordEdit->setText(""); - onlyBuddiesCheckBox->setChecked(false); - onlyRegisteredCheckBox->setChecked(room && room->getUserInfo()->user_level() & ServerInfo_User::IsRegistered); - - spectatorsAllowedCheckBox->setChecked(true); - spectatorsNeedPasswordCheckBox->setChecked(false); - spectatorsCanTalkCheckBox->setChecked(false); - spectatorsSeeEverythingCheckBox->setChecked(false); - createGameAsSpectatorCheckBox->setChecked(false); - - startingLifeTotalEdit->setValue(20); - shareDecklistsOnLoadCheckBox->setChecked(false); - shufflePlayersCheckBox->setChecked(false); - createGameAsJudgeCheckBox->setChecked(false); - - QMapIterator gameTypeCheckBoxIterator(gameTypeCheckBoxes); - while (gameTypeCheckBoxIterator.hasNext()) { - gameTypeCheckBoxIterator.next(); - // must set auto enclusive to false to be able to set the check to false - gameTypeCheckBoxIterator.value()->setAutoExclusive(false); - gameTypeCheckBoxIterator.value()->setChecked(false); - gameTypeCheckBoxIterator.value()->setAutoExclusive(true); - } - - descriptionEdit->setFocus(); + form->resetToDefaults(); + clearError(); } void DlgCreateGame::actOK() { - Command_CreateGame cmd; - cmd.set_description(descriptionEdit->text().simplified().toStdString()); - cmd.set_password(passwordEdit->text().toStdString()); - cmd.set_max_players(maxPlayersEdit->value()); - cmd.set_only_buddies(onlyBuddiesCheckBox->isChecked()); - cmd.set_only_registered(onlyRegisteredCheckBox->isChecked()); - cmd.set_spectators_allowed(spectatorsAllowedCheckBox->isChecked()); - cmd.set_spectators_need_password(spectatorsNeedPasswordCheckBox->isChecked()); - cmd.set_spectators_can_talk(spectatorsCanTalkCheckBox->isChecked()); - cmd.set_spectators_see_everything(spectatorsSeeEverythingCheckBox->isChecked()); - cmd.set_join_as_judge(createGameAsJudgeCheckBox->isChecked()); - cmd.set_join_as_spectator(createGameAsSpectatorCheckBox->isChecked()); - cmd.set_starting_life_total(startingLifeTotalEdit->value()); - cmd.set_share_decklists_on_load(shareDecklistsOnLoadCheckBox->isChecked()); - cmd.set_shuffle_players(shufflePlayersCheckBox->isChecked()); + clearError(); - auto _gameTypes = QString(); - QMapIterator gameTypeCheckBoxIterator(gameTypeCheckBoxes); - while (gameTypeCheckBoxIterator.hasNext()) { - gameTypeCheckBoxIterator.next(); - if (gameTypeCheckBoxIterator.value()->isChecked()) { - cmd.add_game_type_ids(gameTypeCheckBoxIterator.key()); - _gameTypes += gameTypeCheckBoxIterator.value()->text() + ", "; - } + const GameCreationOptions options = form->options(); + Command_CreateGame cmd = options.toCreateGameCommand(); + + const bool rememberSettings = form->isRememberSettingsChecked(); + auto &gameSettings = SettingsCache::instance().game(); + gameSettings.setRememberGameSettings(rememberSettings); + if (rememberSettings) { + gameSettings.setGameDescription(options.description); + gameSettings.setMaxPlayers(options.maxPlayers); + gameSettings.setOnlyBuddies(options.onlyBuddies); + gameSettings.setOnlyRegistered(options.onlyRegistered); + gameSettings.setSpectatorsAllowed(options.spectatorsAllowed); + gameSettings.setSpectatorsNeedPassword(options.spectatorsNeedPassword); + gameSettings.setSpectatorsCanTalk(options.spectatorsCanTalk); + gameSettings.setSpectatorsCanSeeEverything(options.spectatorsSeeEverything); + gameSettings.setCreateGameAsSpectator(options.joinAsSpectator); + gameSettings.setDefaultStartingLifeTotal(options.startingLifeTotal); + gameSettings.setShareDecklistsOnLoad(options.shareDecklistsOnLoad); + gameSettings.setShufflePlayers(options.shufflePlayers); + gameSettings.setGameTypes(form->selectedGameTypesSettingString()); } - SettingsCache::instance().game().setRememberGameSettings(rememberGameSettings->isChecked()); - if (rememberGameSettings->isChecked()) { - 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().setShufflePlayers(shufflePlayersCheckBox->isChecked()); - SettingsCache::instance().game().setGameTypes(_gameTypes); - } PendingCommand *pend = room->prepareRoomCommand(cmd); connect(pend, &PendingCommand::finished, this, &DlgCreateGame::checkResponse); room->sendRoomCommand(pend); @@ -343,14 +118,26 @@ void DlgCreateGame::checkResponse(const Response &response) if (response.response_code() == Response::RespOk) { accept(); } else { - QMessageBox::critical(this, tr("Error"), tr("Server error.")); - return; + showError(tr("Server error.")); } } -void DlgCreateGame::spectatorsAllowedChanged(QT_STATE_CHANGED_T state) +void DlgCreateGame::showError(const QString &message) { - spectatorsNeedPasswordCheckBox->setEnabled(state); - spectatorsCanTalkCheckBox->setEnabled(state); - spectatorsSeeEverythingCheckBox->setEnabled(state); + if (!errorLabel) { + return; + } + errorLabel->setText(message); + errorLabel->setVisible(true); + adjustSize(); +} + +void DlgCreateGame::clearError() +{ + if (!errorLabel) { + return; + } + errorLabel->clear(); + errorLabel->setVisible(false); + adjustSize(); } diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_create_game.h b/cockatrice/src/interface/widgets/dialogs/dlg_create_game.h index 58356c9b6..414c9398d 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_create_game.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_create_game.h @@ -2,7 +2,6 @@ * @file dlg_create_game.h * @ingroup RoomDialogs */ -//! \todo Document this file. #ifndef DLG_CREATEGAME_H #define DLG_CREATEGAME_H @@ -10,21 +9,15 @@ #include #include #include -#include #include -class QCheckBox; +class CreateGameFormWidget; class QDialogButtonBox; -class QGroupBox; class QLabel; -class QLineEdit; class QPushButton; -class QRadioButton; -class QSpinBox; class Response; class ServerInfo_Game; class TabRoom; -class QWidget; class DlgCreateGame : public QDialog { @@ -36,27 +29,16 @@ private slots: void actOK(); void actReset(); void checkResponse(const Response &response); - void spectatorsAllowedChanged(QT_STATE_CHANGED_T state); private: TabRoom *room; - QMap gameTypes; - QMap gameTypeCheckBoxes; + CreateGameFormWidget *form = nullptr; + QDialogButtonBox *buttonBox = nullptr; + QPushButton *clearButton = nullptr; + QLabel *errorLabel = nullptr; - QGroupBox *generalGroupBox, *spectatorsGroupBox, *gameSetupOptionsGroupBox; - QLabel *descriptionLabel, *passwordLabel, *maxPlayersLabel, *startingLifeTotalLabel, *shareDecklistsOnLoadLabel; - QLineEdit *descriptionEdit, *passwordEdit; - QSpinBox *maxPlayersEdit, *startingLifeTotalEdit; - QCheckBox *onlyBuddiesCheckBox, *onlyRegisteredCheckBox; - QCheckBox *spectatorsAllowedCheckBox, *spectatorsNeedPasswordCheckBox, *spectatorsCanTalkCheckBox, - *spectatorsSeeEverythingCheckBox, *createGameAsJudgeCheckBox, *createGameAsSpectatorCheckBox; - QCheckBox *shareDecklistsOnLoadCheckBox; - QCheckBox *shufflePlayersCheckBox; - QDialogButtonBox *buttonBox; - QPushButton *clearButton; - QCheckBox *rememberGameSettings; - - void sharedCtor(); + void showError(const QString &message); + void clearError(); }; -#endif +#endif // DLG_CREATEGAME_H 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 6c1b95b43..ca42fbf40 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_forgot_password_reset.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_forgot_password_reset.cpp @@ -56,6 +56,7 @@ DlgForgotPasswordReset::DlgForgotPasswordReset(QWidget *parent) : QDialog(parent playernameEdit->setMaxLength(MAX_NAME_LENGTH); playernameLabel->setBuddy(playernameEdit); + //: Code mailed to the player so they can reset their password. Not a game token. tokenLabel = new QLabel(tr("Token:")); tokenEdit = new QLineEdit(); tokenEdit->setMaxLength(MAX_NAME_LENGTH); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp index a7387a068..0f429677a 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp @@ -230,7 +230,8 @@ void DlgMyReports::onSelectionChanged() connect(pend, &PendingCommand::finished, this, &DlgMyReports::reportDetailsResponse); client->sendCommand(pend); - QString status = table->item(row, COL_STATUS)->text(); + // The cell text is the translated label; the raw status lives in UserRole. + const QString status = table->item(row, COL_STATUS)->data(Qt::UserRole).toString(); bool canComment = (status == "open" || status == "assigned"); commentButton->setEnabled(canComment); commentInput->setEnabled(canComment); diff --git a/cockatrice/src/interface/widgets/game_hosting/create_game_form_widget.cpp b/cockatrice/src/interface/widgets/game_hosting/create_game_form_widget.cpp new file mode 100644 index 000000000..7d9e8e694 --- /dev/null +++ b/cockatrice/src/interface/widgets/game_hosting/create_game_form_widget.cpp @@ -0,0 +1,260 @@ +#include "create_game_form_widget.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +CreateGameFormWidget::CreateGameFormWidget(const QMap &gameTypes, + bool _registeredUser, + bool judge, + QWidget *parent) + : QWidget(parent), registeredUser(_registeredUser) +{ + setObjectName("createGameForm"); + + rememberGameSettings = new QCheckBox(tr("Re&member settings")); + auto *descriptionLabel = new QLabel(tr("&Description:")); + descriptionEdit = new QLineEdit; + descriptionEdit->setMaxLength(MAX_NAME_LENGTH); + descriptionLabel->setBuddy(descriptionEdit); + + auto *maxPlayersLabel = new QLabel(tr("P&layers:")); + maxPlayersEdit = new QSpinBox(); + maxPlayersEdit->setMinimum(1); + maxPlayersEdit->setMaximum(100); + maxPlayersEdit->setValue(2); + maxPlayersLabel->setBuddy(maxPlayersEdit); + + auto *generalGrid = new QGridLayout; + generalGrid->addWidget(descriptionLabel, 0, 0); + generalGrid->addWidget(descriptionEdit, 0, 1); + generalGrid->addWidget(maxPlayersLabel, 1, 0); + generalGrid->addWidget(maxPlayersEdit, 1, 1); + auto *generalGroupBox = new QGroupBox(tr("General")); + generalGroupBox->setLayout(generalGrid); + + auto *gameTypeLayout = new QVBoxLayout; + for (auto it = gameTypes.cbegin(); it != gameTypes.cend(); ++it) { + auto *gameTypeRadioButton = new QRadioButton(it.value(), this); + gameTypeLayout->addWidget(gameTypeRadioButton); + gameTypeCheckBoxes.insert(it.key(), gameTypeRadioButton); + } + auto *gameTypeGroupBox = new QGroupBox(tr("Game type")); + gameTypeGroupBox->setLayout(gameTypeLayout); + + auto *passwordLabel = new QLabel(tr("&Password:")); + passwordEdit = new QLineEdit; + passwordEdit->setMaxLength(MAX_NAME_LENGTH); + passwordLabel->setBuddy(passwordEdit); + + onlyBuddiesCheckBox = new QCheckBox(tr("Only &buddies can join")); + onlyRegisteredCheckBox = new QCheckBox(tr("Only ®istered users can join")); + if (registeredUser) { + onlyRegisteredCheckBox->setChecked(true); + } else { + onlyBuddiesCheckBox->setEnabled(false); + onlyRegisteredCheckBox->setEnabled(false); + } + + auto *joinRestrictionsLayout = new QGridLayout; + joinRestrictionsLayout->addWidget(passwordLabel, 0, 0); + joinRestrictionsLayout->addWidget(passwordEdit, 0, 1); + joinRestrictionsLayout->addWidget(onlyBuddiesCheckBox, 1, 0, 1, 2); + joinRestrictionsLayout->addWidget(onlyRegisteredCheckBox, 2, 0, 1, 2); + + auto *joinRestrictionsGroupBox = new QGroupBox(tr("Joining restrictions")); + joinRestrictionsGroupBox->setLayout(joinRestrictionsLayout); + + spectatorsAllowedCheckBox = new QCheckBox(tr("&Spectators can watch")); + spectatorsAllowedCheckBox->setChecked(true); + connect(spectatorsAllowedCheckBox, &QCheckBox::QT_STATE_CHANGED, this, + &CreateGameFormWidget::spectatorsAllowedChanged); + spectatorsNeedPasswordCheckBox = new QCheckBox(tr("Spectators &need a password to watch")); + spectatorsCanTalkCheckBox = new QCheckBox(tr("Spectators can &chat")); + spectatorsSeeEverythingCheckBox = new QCheckBox(tr("Spectators can see &hands")); + createGameAsSpectatorCheckBox = new QCheckBox(tr("Create game as spectator")); + auto *spectatorsLayout = new QVBoxLayout; + spectatorsLayout->addWidget(spectatorsAllowedCheckBox); + spectatorsLayout->addWidget(spectatorsNeedPasswordCheckBox); + spectatorsLayout->addWidget(spectatorsCanTalkCheckBox); + spectatorsLayout->addWidget(spectatorsSeeEverythingCheckBox); + spectatorsLayout->addWidget(createGameAsSpectatorCheckBox); + auto *spectatorsGroupBox = new QGroupBox(tr("Spectators")); + spectatorsGroupBox->setLayout(spectatorsLayout); + + auto *startingLifeTotalLabel = new QLabel(tr("Starting life total:")); + startingLifeTotalEdit = new QSpinBox(); + startingLifeTotalEdit->setMinimum(1); + startingLifeTotalEdit->setMaximum(99999); ///< Arbitrary but we can raise this when people start complaining. + startingLifeTotalEdit->setValue(20); + startingLifeTotalLabel->setBuddy(startingLifeTotalEdit); + + shareDecklistsOnLoadCheckBox = new QCheckBox(tr("Open decklists in lobby")); + + shufflePlayersCheckBox = new QCheckBox(tr("Randomize turn order (seats) on each game start")); + + createGameAsJudgeCheckBox = new QCheckBox(tr("Create game as judge")); + + auto *gameSetupOptionsLayout = new QGridLayout; + gameSetupOptionsLayout->addWidget(startingLifeTotalLabel, 0, 0); + gameSetupOptionsLayout->addWidget(startingLifeTotalEdit, 0, 1); + gameSetupOptionsLayout->addWidget(shareDecklistsOnLoadCheckBox, 1, 0, 1, 2); + gameSetupOptionsLayout->addWidget(shufflePlayersCheckBox, 2, 0, 1, 2); + if (judge) { + gameSetupOptionsLayout->addWidget(createGameAsJudgeCheckBox, 3, 0, 1, 2); + } else { + createGameAsJudgeCheckBox->setChecked(false); + createGameAsJudgeCheckBox->setHidden(true); + } + auto *gameSetupOptionsGroupBox = new QGroupBox(tr("Game setup options")); + gameSetupOptionsGroupBox->setLayout(gameSetupOptionsLayout); + + auto *grid = new QGridLayout; + + // Top row + grid->addWidget(generalGroupBox, 0, 0); + grid->addWidget(joinRestrictionsGroupBox, 0, 1); + + // Middle row: left column + grid->addWidget(gameTypeGroupBox, 1, 0); + + // Middle row: right column (game setup + spectators) + auto *rightLayout = new QVBoxLayout; + rightLayout->addWidget(spectatorsGroupBox, Qt::AlignTop); // top + rightLayout->addWidget(gameSetupOptionsGroupBox); // bottom + + grid->addLayout(rightLayout, 1, 1); + + // Bottom row + grid->addWidget(rememberGameSettings, 3, 0, 1, 2); // span both columns if needed + + setLayout(grid); +} + +void CreateGameFormWidget::setOptions(const GameCreationOptions &options) +{ + descriptionEdit->setText(options.description); + maxPlayersEdit->setValue(options.maxPlayers); + passwordEdit->setText(options.password); + onlyBuddiesCheckBox->setChecked(options.onlyBuddies); + onlyRegisteredCheckBox->setChecked(options.onlyRegistered); + spectatorsAllowedCheckBox->setChecked(options.spectatorsAllowed); + spectatorsNeedPasswordCheckBox->setChecked(options.spectatorsNeedPassword); + spectatorsCanTalkCheckBox->setChecked(options.spectatorsCanTalk); + spectatorsSeeEverythingCheckBox->setChecked(options.spectatorsSeeEverything); + createGameAsJudgeCheckBox->setChecked(options.joinAsJudge); + createGameAsSpectatorCheckBox->setChecked(options.joinAsSpectator); + startingLifeTotalEdit->setValue(options.startingLifeTotal); + shareDecklistsOnLoadCheckBox->setChecked(options.shareDecklistsOnLoad); + shufflePlayersCheckBox->setChecked(options.shufflePlayers); + + for (auto it = gameTypeCheckBoxes.cbegin(); it != gameTypeCheckBoxes.cend(); ++it) { + it.value()->setChecked(options.gameTypeIds.contains(it.key())); + } +} + +GameCreationOptions CreateGameFormWidget::options() const +{ + GameCreationOptions options; + options.description = descriptionEdit->text(); + options.password = passwordEdit->text(); + options.maxPlayers = maxPlayersEdit->value(); + options.onlyBuddies = onlyBuddiesCheckBox->isChecked(); + options.onlyRegistered = onlyRegisteredCheckBox->isChecked(); + options.spectatorsAllowed = spectatorsAllowedCheckBox->isChecked(); + options.spectatorsNeedPassword = spectatorsNeedPasswordCheckBox->isChecked(); + options.spectatorsCanTalk = spectatorsCanTalkCheckBox->isChecked(); + options.spectatorsSeeEverything = spectatorsSeeEverythingCheckBox->isChecked(); + options.joinAsJudge = createGameAsJudgeCheckBox->isChecked(); + options.joinAsSpectator = createGameAsSpectatorCheckBox->isChecked(); + options.startingLifeTotal = startingLifeTotalEdit->value(); + options.shareDecklistsOnLoad = shareDecklistsOnLoadCheckBox->isChecked(); + options.shufflePlayers = shufflePlayersCheckBox->isChecked(); + + for (auto it = gameTypeCheckBoxes.cbegin(); it != gameTypeCheckBoxes.cend(); ++it) { + if (it.value()->isChecked()) { + options.gameTypeIds.append(it.key()); + } + } + + return options; +} + +QString CreateGameFormWidget::selectedGameTypesSettingString() const +{ + QString gameTypes; + for (auto it = gameTypeCheckBoxes.cbegin(); it != gameTypeCheckBoxes.cend(); ++it) { + if (it.value()->isChecked()) { + gameTypes += it.value()->text() + ", "; + } + } + return gameTypes; +} + +void CreateGameFormWidget::resetToDefaults() +{ + GameCreationOptions defaults; + defaults.onlyRegistered = registeredUser; + setOptions(defaults); + + // An exclusive button group refuses to uncheck its selected button, which setOptions + // cannot do, so clear the exclusive flag while resetting the game type selection. + for (auto *gameTypeCheckBox : gameTypeCheckBoxes) { + gameTypeCheckBox->setAutoExclusive(false); + gameTypeCheckBox->setChecked(false); + gameTypeCheckBox->setAutoExclusive(true); + } + + descriptionEdit->setFocus(); +} + +void CreateGameFormWidget::setReadOnly(bool readOnly) +{ + const bool editable = !readOnly; + rememberGameSettings->setEnabled(editable); + descriptionEdit->setEnabled(editable); + maxPlayersEdit->setEnabled(editable); + passwordEdit->setEnabled(editable); + onlyBuddiesCheckBox->setEnabled(editable && registeredUser); + onlyRegisteredCheckBox->setEnabled(editable && registeredUser); + spectatorsAllowedCheckBox->setEnabled(editable); + spectatorsNeedPasswordCheckBox->setEnabled(editable); + spectatorsCanTalkCheckBox->setEnabled(editable); + spectatorsSeeEverythingCheckBox->setEnabled(editable); + createGameAsSpectatorCheckBox->setEnabled(editable); + startingLifeTotalEdit->setEnabled(editable); + shareDecklistsOnLoadCheckBox->setEnabled(editable); + shufflePlayersCheckBox->setEnabled(editable); + for (auto *gameTypeCheckBox : gameTypeCheckBoxes) { + gameTypeCheckBox->setEnabled(editable); + } +} + +void CreateGameFormWidget::focusDescription() +{ + descriptionEdit->setFocus(); +} + +bool CreateGameFormWidget::isRememberSettingsChecked() const +{ + return rememberGameSettings->isChecked(); +} + +void CreateGameFormWidget::setRememberSettingsChecked(bool checked) +{ + rememberGameSettings->setChecked(checked); +} + +void CreateGameFormWidget::spectatorsAllowedChanged(QT_STATE_CHANGED_T state) +{ + spectatorsNeedPasswordCheckBox->setEnabled(state); + spectatorsCanTalkCheckBox->setEnabled(state); + spectatorsSeeEverythingCheckBox->setEnabled(state); +} diff --git a/cockatrice/src/interface/widgets/game_hosting/create_game_form_widget.h b/cockatrice/src/interface/widgets/game_hosting/create_game_form_widget.h new file mode 100644 index 000000000..43e56006e --- /dev/null +++ b/cockatrice/src/interface/widgets/game_hosting/create_game_form_widget.h @@ -0,0 +1,69 @@ +/** + * @file create_game_form_widget.h + * @ingroup RoomWidgets + */ + +#ifndef CREATE_GAME_FORM_WIDGET_H +#define CREATE_GAME_FORM_WIDGET_H + +#include "game_creation_options.h" + +#include +#include +#include +#include + +class QCheckBox; +class QLineEdit; +class QRadioButton; +class QSpinBox; + +/** + * @brief The editable create-game form, independent of settings, the network, and the room. + * + * The dialog loads @ref GameCreationOptions into it and reads them back; a future host + * activity can do the same without duplicating the widget layout. + */ +class CreateGameFormWidget : public QWidget +{ + Q_OBJECT +public: + CreateGameFormWidget(const QMap &gameTypes, + bool registeredUser, + bool judge, + QWidget *parent = nullptr); + + void setOptions(const GameCreationOptions &options); + [[nodiscard]] GameCreationOptions options() const; + [[nodiscard]] QString selectedGameTypesSettingString() const; + void resetToDefaults(); + void setReadOnly(bool readOnly); + void focusDescription(); + [[nodiscard]] bool isRememberSettingsChecked() const; + void setRememberSettingsChecked(bool checked); + +private slots: + void spectatorsAllowedChanged(QT_STATE_CHANGED_T state); + +private: + bool registeredUser; + QMap gameTypeCheckBoxes; + + QLineEdit *descriptionEdit; + QLineEdit *passwordEdit; + QSpinBox *maxPlayersEdit; + QSpinBox *startingLifeTotalEdit; + QCheckBox *onlyBuddiesCheckBox; + QCheckBox *onlyRegisteredCheckBox; + QCheckBox *spectatorsAllowedCheckBox; + QCheckBox *spectatorsNeedPasswordCheckBox; + QCheckBox *spectatorsCanTalkCheckBox; + QCheckBox *spectatorsSeeEverythingCheckBox; + QCheckBox *createGameAsJudgeCheckBox; + QCheckBox *createGameAsSpectatorCheckBox; + QCheckBox *shareDecklistsOnLoadCheckBox; + QCheckBox *shufflePlayersCheckBox; + QCheckBox *rememberGameSettings; +}; + +#endif // CREATE_GAME_FORM_WIDGET_H diff --git a/cockatrice/src/interface/widgets/game_hosting/game_creation_options.cpp b/cockatrice/src/interface/widgets/game_hosting/game_creation_options.cpp new file mode 100644 index 000000000..048899266 --- /dev/null +++ b/cockatrice/src/interface/widgets/game_hosting/game_creation_options.cpp @@ -0,0 +1,88 @@ +#include "game_creation_options.h" + +#include +#include +#include + +GameCreationOptions GameCreationOptions::fromSettings(const IGameSettingsProvider &settings, + const QMap &gameTypes, + bool registeredUser) +{ + GameCreationOptions options; + options.description = settings.getGameDescription(); + options.maxPlayers = settings.getMaxPlayers(); + if (registeredUser) { + options.onlyBuddies = settings.getOnlyBuddies(); + options.onlyRegistered = settings.getOnlyRegistered(); + } + options.spectatorsAllowed = settings.getSpectatorsAllowed(); + options.spectatorsNeedPassword = settings.getSpectatorsNeedPassword(); + options.spectatorsCanTalk = settings.getSpectatorsCanTalk(); + options.spectatorsSeeEverything = settings.getSpectatorsCanSeeEverything(); + options.joinAsSpectator = settings.getCreateGameAsSpectator(); + options.startingLifeTotal = settings.getDefaultStartingLifeTotal(); + options.shareDecklistsOnLoad = settings.getShareDecklistsOnLoad(); + options.shufflePlayers = settings.getShufflePlayers(); + + // The old dialog stored game type names as "Name, " in one string; keep the exact + // substring test so an existing game.ini selects the same types after the split. + const QString savedGameTypes = settings.getGameTypes(); + for (auto it = gameTypes.cbegin(); it != gameTypes.cend(); ++it) { + if (savedGameTypes.contains(it.value() + ", ")) { + options.gameTypeIds.append(it.key()); + } + } + + return options; +} + +GameCreationOptions GameCreationOptions::fromGameInfo(const ServerInfo_Game &gameInfo, + const QMap &gameTypes) +{ + GameCreationOptions options; + options.description = QString::fromStdString(gameInfo.description()); + options.maxPlayers = gameInfo.max_players(); + options.onlyBuddies = gameInfo.only_buddies(); + options.onlyRegistered = gameInfo.only_registered(); + options.spectatorsAllowed = gameInfo.spectators_allowed(); + options.spectatorsNeedPassword = gameInfo.spectators_need_password(); + options.spectatorsCanTalk = gameInfo.spectators_can_chat(); + options.spectatorsSeeEverything = gameInfo.spectators_omniscient(); + options.shareDecklistsOnLoad = gameInfo.share_decklists_on_load(); + options.shufflePlayers = gameInfo.shuffle_players(); + + QSet selectedTypes; + for (int i = 0; i < gameInfo.game_types_size(); ++i) { + selectedTypes.insert(gameInfo.game_types(i)); + } + for (auto it = gameTypes.cbegin(); it != gameTypes.cend(); ++it) { + if (selectedTypes.contains(it.key())) { + options.gameTypeIds.append(it.key()); + } + } + + return options; +} + +Command_CreateGame GameCreationOptions::toCreateGameCommand() const +{ + Command_CreateGame cmd; + cmd.set_description(description.simplified().toStdString()); + cmd.set_password(password.toStdString()); + cmd.set_max_players(maxPlayers); + cmd.set_only_buddies(onlyBuddies); + cmd.set_only_registered(onlyRegistered); + cmd.set_spectators_allowed(spectatorsAllowed); + cmd.set_spectators_need_password(spectatorsNeedPassword); + cmd.set_spectators_can_talk(spectatorsCanTalk); + cmd.set_spectators_see_everything(spectatorsSeeEverything); + cmd.set_join_as_judge(joinAsJudge); + cmd.set_join_as_spectator(joinAsSpectator); + cmd.set_starting_life_total(startingLifeTotal); + cmd.set_share_decklists_on_load(shareDecklistsOnLoad); + cmd.set_shuffle_players(shufflePlayers); + for (int gameTypeId : gameTypeIds) { + cmd.add_game_type_ids(gameTypeId); + } + return cmd; +} diff --git a/cockatrice/src/interface/widgets/game_hosting/game_creation_options.h b/cockatrice/src/interface/widgets/game_hosting/game_creation_options.h new file mode 100644 index 000000000..cbc941815 --- /dev/null +++ b/cockatrice/src/interface/widgets/game_hosting/game_creation_options.h @@ -0,0 +1,64 @@ +/** + * @file game_creation_options.h + * @ingroup RoomWidgets + */ + +#ifndef GAME_CREATION_OPTIONS_H +#define GAME_CREATION_OPTIONS_H + +#include "libcockatrice/protocol/pb/room_commands.pb.h" + +#include +#include +#include + +class IGameSettingsProvider; +class ServerInfo_Game; + +/** + * @brief The values needed to create a game, independent of the widgets that edit them. + * + * Pure value object: it holds no widget or settings dependencies, so every host entry + * point can build a @ref Command_CreateGame through the same conversion. + */ +struct GameCreationOptions +{ + QString description; + QString password; + int maxPlayers = 2; + bool onlyBuddies = false; + bool onlyRegistered = false; + bool spectatorsAllowed = true; + bool spectatorsNeedPassword = false; + bool spectatorsCanTalk = false; + bool spectatorsSeeEverything = false; + bool joinAsJudge = false; + bool joinAsSpectator = false; + int startingLifeTotal = 20; + bool shareDecklistsOnLoad = false; + bool shufflePlayers = false; + QList gameTypeIds; + + /** + * @brief Reads the remembered create-game settings. + * + * Join restrictions are only meaningful for registered users, so they are left + * unset for an unregistered host. Game type ids are resolved from the room's map + * by matching the remembered names. + */ + [[nodiscard]] static GameCreationOptions + fromSettings(const IGameSettingsProvider &settings, const QMap &gameTypes, bool registeredUser); + + /** + * @brief Reads the read-only fields shown in the game information view. + */ + [[nodiscard]] static GameCreationOptions fromGameInfo(const ServerInfo_Game &gameInfo, + const QMap &gameTypes); + + /** + * @brief Builds the room command, simplifying the description's whitespace. + */ + [[nodiscard]] Command_CreateGame toCreateGameCommand() const; +}; + +#endif diff --git a/cockatrice/src/interface/widgets/printing_selector/printing_selector.cpp b/cockatrice/src/interface/widgets/printing_selector/printing_selector.cpp index b8438134c..e21bf8a4e 100644 --- a/cockatrice/src/interface/widgets/printing_selector/printing_selector.cpp +++ b/cockatrice/src/interface/widgets/printing_selector/printing_selector.cpp @@ -21,12 +21,14 @@ #include #include #include +#include #include #include #include #include #include #include +#include #include #include #include @@ -54,6 +56,9 @@ PrintingSelector::PrintingSelector(QWidget *parent, AbstractTabDeckEditor *_deck setLayout(layout); widgetLoadingBufferTimer = new QTimer(this); + deckChangeTimer = new QTimer(this); + deckChangeTimer->setSingleShot(true); + connect(deckChangeTimer, &QTimer::timeout, this, &PrintingSelector::updateDisplay); flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAsNeeded); @@ -122,11 +127,36 @@ void PrintingSelector::retranslateUi() void PrintingSelector::printingsInDeckChanged() { if (SettingsCache::instance().cardsDisplay().getBumpSetsWithCardsInDeckToTop()) { - // Delay the update to avoid race conditions - QTimer::singleShot(100, this, &PrintingSelector::updateDisplay); + displayDirty = true; + if (isVisible()) { + // Coalesce deck changes while the model settles. + deckChangeTimer->start(100); + } else { + widgetLoadingBufferTimer->stop(); + } } } +void PrintingSelector::showEvent(QShowEvent *event) +{ + QWidget::showEvent(event); + if (displayDirty) { + updateDisplay(); + } +} + +void PrintingSelector::hideEvent(QHideEvent *event) +{ + // A partially built batch must be rebuilt on reveal, not resumed with + // stale printing data after the selected card or deck changes. + if (widgetLoadingBufferTimer->isActive() || deckChangeTimer->isActive()) { + widgetLoadingBufferTimer->stop(); + deckChangeTimer->stop(); + displayDirty = true; + } + QWidget::hideEvent(event); +} + /** * @return A map of uuid to amounts (main, side, tokens). */ @@ -173,6 +203,8 @@ void PrintingSelector::updateCardAmounts() */ void PrintingSelector::updateDisplay() { + deckChangeTimer->stop(); + displayDirty = false; widgetLoadingBufferTimer->stop(); widgetLoadingBufferTimer->deleteLater(); widgetLoadingBufferTimer = new QTimer(this); @@ -199,20 +231,18 @@ void PrintingSelector::updateDisplay() */ void PrintingSelector::setCard(const CardInfoPtr &newCard) { - if (newCard.isNull()) { - selectedCard = newCard; - updateDisplay(); - return; - } - - // we don't need to redraw the widget if the card is the same - if (!selectedCard.isNull() && selectedCard->getName() == newCard->getName()) { + // Same-name cards may be different database snapshots after a reload. + if (selectedCard == newCard) { return; } + widgetLoadingBufferTimer->stop(); + deckChangeTimer->stop(); selectedCard = newCard; if (isVisible()) { updateDisplay(); + } else { + displayDirty = true; } flowWidget->setMinimumSizeToMaxSizeHint(); flowWidget->scrollArea->verticalScrollBar()->setValue(0); diff --git a/cockatrice/src/interface/widgets/printing_selector/printing_selector.h b/cockatrice/src/interface/widgets/printing_selector/printing_selector.h index b9cb27ecd..7b4af95e0 100644 --- a/cockatrice/src/interface/widgets/printing_selector/printing_selector.h +++ b/cockatrice/src/interface/widgets/printing_selector/printing_selector.h @@ -18,6 +18,8 @@ class QCheckBox; class QHBoxLayout; class QString; class QTimer; +class QShowEvent; +class QHideEvent; class QVBoxLayout; class SettingsButtonWidget; template class QMap; @@ -47,6 +49,10 @@ public: void setCard(const CardInfoPtr &newCard); void getAllSetsForCurrentCard(); +protected: + void showEvent(QShowEvent *event) override; + void hideEvent(QHideEvent *event) override; + public slots: void retranslateUi(); void updateDisplay(); @@ -88,6 +94,8 @@ private: DeckStateManager *deckStateManager; CardInfoPtr selectedCard; QTimer *widgetLoadingBufferTimer; + QTimer *deckChangeTimer; + bool displayDirty = false; int currentIndex = 0; }; diff --git a/cockatrice/src/interface/widgets/server/game_selector.cpp b/cockatrice/src/interface/widgets/server/game_selector.cpp index 2c0050afe..9dc7bf1e8 100644 --- a/cockatrice/src/interface/widgets/server/game_selector.cpp +++ b/cockatrice/src/interface/widgets/server/game_selector.cpp @@ -266,9 +266,13 @@ void GameSelector::actCreate() return; } - DlgCreateGame dlg(room, room->getGameTypes(), this); - dlg.exec(); - updateTitle(); + // Heap-allocate the dialog: if this selector (or its room tab) is destroyed while the + // dialog is open, e.g. on server disconnect, Qt deletes its children. A stack-allocated + // child would then be deleted by Qt and crash. + auto *dlg = new DlgCreateGame(room, room->getGameTypes(), this); + dlg->setAttribute(Qt::WA_DeleteOnClose); + connect(dlg, &QDialog::finished, this, &GameSelector::updateTitle); + dlg->exec(); } void GameSelector::checkResponse(const Response &response) @@ -362,8 +366,9 @@ void GameSelector::customContextMenu(const QPoint &point) const ServerInfo_Game &gameInfo = gameListModel->getGame(index.data(Qt::UserRole).toInt()); const QMap &gameTypes = gameListModel->getGameTypes().value(gameInfo.room_id()); - DlgCreateGame dlg(gameInfo, gameTypes, this); - dlg.exec(); + auto *dlg = new DlgCreateGame(gameInfo, gameTypes, this); + dlg->setAttribute(Qt::WA_DeleteOnClose); + dlg->exec(); }); QAction copyLink(tr("Cop&y game link")); 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 447e63673..f61293013 100644 --- a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp @@ -83,6 +83,7 @@ UserContextMenu::UserContextMenu(TabSupervisor *_tabSupervisor, QWidget *parent, aDemoteFromDeveloper = new QAction(QString(), this); aGetAdminNotes = new QAction(QString(), this); aInvestigateUser = new QAction(QString(), this); + aInvestigateAlts = new QAction(QString(), this); retranslateUi(); } @@ -111,6 +112,7 @@ void UserContextMenu::retranslateUi() aDemoteFromDeveloper->setText(tr("Demote user from de&veloper")); aGetAdminNotes->setText(tr("View admin notes")); aInvestigateUser->setText(tr("Investigate user")); + aInvestigateAlts->setText(tr("Investigate &alts")); } void UserContextMenu::gamesOfUserReceived(const Response &resp, const CommandContainer &commandContainer) @@ -454,6 +456,7 @@ void UserContextMenu::showContextMenu(const QPoint &pos, menu->addSeparator(); menu->addAction(aGetAdminNotes); menu->addAction(aInvestigateUser); + menu->addAction(aInvestigateAlts); menu->addSeparator(); if (userLevel.testFlag(ServerInfo_User::IsModerator) && @@ -499,6 +502,7 @@ void UserContextMenu::showContextMenu(const QPoint &pos, aBanHistory->setEnabled(anotherUser); aGetAdminNotes->setEnabled(anotherUser); aInvestigateUser->setEnabled(anotherUser); + aInvestigateAlts->setEnabled(anotherUser); aPromoteToMod->setEnabled(anotherUser); aDemoteFromMod->setEnabled(anotherUser); aPromoteToJudge->setEnabled(anotherUser); @@ -553,6 +557,8 @@ void UserContextMenu::showContextMenu(const QPoint &pos, execAdminNotes(userName); } else if (actionClicked == aInvestigateUser) { execInvestigateUser(userName); + } else if (actionClicked == aInvestigateAlts) { + execInvestigateAlts(userName); } else if (actionClicked == aCopyToClipBoard) { QClipboard *clipboard = QGuiApplication::clipboard(); clipboard->setText(deckHash); @@ -745,6 +751,11 @@ void UserContextMenu::execInvestigateUser(const QString &userName) tabSupervisor->openTabModeration(userName); } +void UserContextMenu::execInvestigateAlts(const QString &userName) +{ + tabSupervisor->openTabAlts(userName); +} + void UserContextMenu::execAdjustMod(const QString &userName, bool shouldBeMod) { Command_AdjustMod cmd; 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 2536819a4..86be5eff2 100644 --- a/cockatrice/src/interface/widgets/server/user/user_context_menu.h +++ b/cockatrice/src/interface/widgets/server/user/user_context_menu.h @@ -54,6 +54,7 @@ private: QAction *aGetAdminNotes; std::function()> gameInviteLinkProvider; QAction *aInvestigateUser; + QAction *aInvestigateAlts; signals: void openMessageDialog(const QString &userName, bool focus); private slots: @@ -127,6 +128,7 @@ public: void execWarnHistory(const QString &userName); void execAdminNotes(const QString &userName); void execInvestigateUser(const QString &userName); + void execInvestigateAlts(const QString &userName); void execAdjustMod(const QString &userName, bool shouldBeMod); void execAdjustJudge(const QString &userName, bool shouldBeJudge); void execAdjustDeveloper(const QString &userName, bool shouldBeDeveloper); 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 de5f67b32..21c0a79e1 100644 --- a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp @@ -146,6 +146,7 @@ AppearanceSettingsPage::AppearanceSettingsPage() }); homeTabBackgroundShuffleFrequencySpinBox.setRange(0, 3600); + //: Unit following a number of seconds. Keep the leading space unless your language writes number and unit flush. homeTabBackgroundShuffleFrequencySpinBox.setSuffix(tr(" seconds")); homeTabBackgroundShuffleFrequencySpinBox.setValue(settings.appearance().getHomeTabBackgroundShuffleFrequency()); connect(&homeTabBackgroundShuffleFrequencySpinBox, qOverload(&QSpinBox::valueChanged), &settings.appearance(), @@ -675,7 +676,7 @@ void AppearanceSettingsPage::retranslateUi() } handGroupBox->setTitle(tr("Hand layout")); - horizontalHandCheckBox.setText(tr("Display hand horizontally (wastes space)")); + horizontalHandCheckBox.setText(tr("Display hand horizontally (requires more space)")); leftJustifiedHandCheckBox.setText(tr("Enable left justification")); tableGroupBox->setTitle(tr("Table grid layout")); 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 09dd9e9b0..274e55866 100644 --- a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp @@ -88,18 +88,21 @@ MessagesSettingsPage::MessagesSettingsPage() auto *chatGrid = new QGridLayout; chatGrid->addWidget(&chatMentionCheckBox, 0, 0); - chatGrid->addWidget(&invertMentionForeground, 0, 1); + chatGrid->addWidget(&mentionColorStringLabel, 0, 1, Qt::AlignRight); chatGrid->addWidget(mentionColor, 0, 2); - chatGrid->addWidget(&chatMentionCompleterCheckbox, 1, 0); - chatGrid->addWidget(&ignoreUnregUsersMainChat, 2, 0); - chatGrid->addWidget(&hexLabel, 1, 2); - chatGrid->addWidget(&ignoreUnregUserMessages, 3, 0); - chatGrid->addWidget(&ignoreNonBuddyUserMessages, 4, 0); - chatGrid->addWidget(&messagePopups, 5, 0); - chatGrid->addWidget(&mentionPopups, 6, 0); - chatGrid->addWidget(&roomHistory, 7, 0); - chatGrid->addWidget(&ignoreAllPrivateMessagesCheckBox, 8, 0); - chatGrid->addWidget(&useGameTimeCheckBox, 9, 0); + chatGrid->addWidget(&invertMentionForeground, 0, 3); + chatGrid->addWidget(&chatMentionCompleterCheckbox, 1, 0, 1, -1); + chatGrid->addWidget(&ignoreUnregUsersMainChat, 2, 0, 1, -1); + chatGrid->addWidget(&ignoreUnregUserMessages, 3, 0, 1, -1); + chatGrid->addWidget(&ignoreNonBuddyUserMessages, 4, 0, 1, -1); + chatGrid->addWidget(&messagePopups, 5, 0, 1, -1); + chatGrid->addWidget(&mentionPopups, 6, 0, 1, -1); + chatGrid->addWidget(&roomHistory, 7, 0, 1, -1); + chatGrid->addWidget(&ignoreAllPrivateMessagesCheckBox, 8, 0, 1, -1); + chatGrid->addWidget(&useGameTimeCheckBox, 9, 0, 1, -1); + // Expand the first column more than the following ones + chatGrid->setColumnStretch(0, 3); + chatGrid->setColumnStretch(1, 1); chatGroupBox = new QGroupBox; chatGroupBox->setLayout(chatGrid); @@ -109,11 +112,14 @@ MessagesSettingsPage::MessagesSettingsPage() connect(highlightColor, &QLineEdit::textChanged, this, &MessagesSettingsPage::updateHighlightColor); auto *highlightNotice = new QGridLayout; - highlightNotice->addWidget(highlightColor, 0, 2); - highlightNotice->addWidget(&invertHighlightForeground, 0, 1); - highlightNotice->addWidget(&hexHighlightLabel, 1, 2); highlightNotice->addWidget(customAlertString, 0, 0); - highlightNotice->addWidget(&customAlertStringLabel, 1, 0); + highlightNotice->addWidget(&highlightColorStringLabel, 0, 1, Qt::AlignRight); + highlightNotice->addWidget(highlightColor, 0, 2); + highlightNotice->addWidget(&invertHighlightForeground, 0, 3); + highlightNotice->addWidget(&customAlertStringLabel, 1, 0, 1, -1); + // Expand the first column more than the following ones + highlightNotice->setColumnStretch(0, 3); + highlightNotice->setColumnStretch(1, 1); customAlertStringLabel.setBuddy(customAlertString); highlightGroupBox = new QGroupBox; highlightGroupBox->setLayout(highlightNotice); @@ -131,10 +137,8 @@ MessagesSettingsPage::MessagesSettingsPage() auto *channelGrid = new QGridLayout; channelGrid->addWidget(&channelUnreadColorLabel, 0, 0); channelGrid->addWidget(channelUnreadColor, 0, 1); - channelGrid->addWidget(&hexChannelUnreadLabel, 0, 2); channelGrid->addWidget(&channelMentionColorLabel, 1, 0); channelGrid->addWidget(channelMentionColor, 1, 1); - channelGrid->addWidget(&hexChannelMentionLabel, 1, 2); channelGroupBox = new QGroupBox; channelGroupBox->setLayout(channelGrid); @@ -331,6 +335,8 @@ void MessagesSettingsPage::retranslateUi() channelGroupBox->setTitle(tr("Room channel tabs")); channelUnreadColorLabel.setText(tr("Unread messages color")); channelMentionColorLabel.setText(tr("Mentioned messages color")); + channelUnreadColor->setPlaceholderText(tr("Enter HEX code")); + channelMentionColor->setPlaceholderText(tr("Enter HEX code")); chatMentionCheckBox.setText(tr("Enable chat mentions")); chatMentionCompleterCheckbox.setText(tr("Enable mention completer")); messageGroupBox->setTitle(tr("In-game message macros")); @@ -341,15 +347,15 @@ void MessagesSettingsPage::retranslateUi() ignoreNonBuddyUserMessages.setText(tr("Ignore private messages sent by non-buddy users")); invertMentionForeground.setText(tr("Invert text color")); invertHighlightForeground.setText(tr("Invert text color")); + mentionColorStringLabel.setText(tr("Color")); + highlightColorStringLabel.setText(tr("Color")); + mentionColor->setPlaceholderText(tr("Enter HEX code")); + highlightColor->setPlaceholderText(tr("Enter HEX code")); 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")); useGameTimeCheckBox.setText(tr("Use game time instead of local time in game logs")); - hexLabel.setText(tr("(Color is hexadecimal)")); - hexHighlightLabel.setText(tr("(Color is hexadecimal)")); - hexChannelUnreadLabel.setText(tr("(Color is hexadecimal)")); - hexChannelMentionLabel.setText(tr("(Color is hexadecimal)")); customAlertStringLabel.setText(tr("Separate words with a space, alphanumeric characters only")); customAlertString->setPlaceholderText(tr("Word1 Word2 Word3")); aAdd->setText(tr("Add New Message")); 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 112ff4e29..06fabcef2 100644 --- a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.h @@ -58,13 +58,11 @@ private: QLineEdit *channelUnreadColor; QLineEdit *channelMentionColor; QLineEdit *customAlertString; - QLabel hexLabel; - QLabel hexHighlightLabel; QLabel channelUnreadColorLabel; QLabel channelMentionColorLabel; - QLabel hexChannelUnreadLabel; - QLabel hexChannelMentionLabel; QLabel customAlertStringLabel; + QLabel mentionColorStringLabel; + QLabel highlightColorStringLabel; QLabel explainMessagesLabel; void storeSettings(); diff --git a/cockatrice/src/interface/widgets/tabs/alt_table_delegate.cpp b/cockatrice/src/interface/widgets/tabs/alt_table_delegate.cpp new file mode 100644 index 000000000..9fb6a7ffe --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/alt_table_delegate.cpp @@ -0,0 +1,109 @@ +#include "alt_table_delegate.h" + +#include "../utility/pill_painter.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ +constexpr int ICON_TEXT_GAP = 4; +} // namespace + +AltTableDelegate::AltTableDelegate(int viaColumn, + int warnsColumn, + int bansColumn, + int sharedColumn, + int activeColumn, + QObject *parent) + : QStyledItemDelegate(parent), viaColumn(viaColumn), warnsColumn(warnsColumn), bansColumn(bansColumn), + sharedColumn(sharedColumn), activeColumn(activeColumn) +{ +} + +void AltTableDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const +{ + const bool selected = (option.state & QStyle::State_Selected) != 0; + + QStyleOptionViewItem opt(option); + initStyleOption(&opt, index); + painter->setFont(opt.font); + QStyle *style = opt.widget != nullptr ? opt.widget->style() : QApplication::style(); + const QString text = index.data(Qt::DisplayRole).toString(); + const QPalette::ColorRole emphasisRole = selected ? QPalette::HighlightedText : QPalette::WindowText; + + if (index.column() == viaColumn) { + bool sharedOk = false; + const unsigned shared = index.sibling(index.row(), sharedColumn).data(Qt::DisplayRole).toUInt(&sharedOk); + const bool ipMatch = text.startsWith(QStringLiteral("ip:")); + // shared_count for an IP match already includes the investigated account, + // so anything above 2 means the address is shared beyond that pair. + const bool dimmed = sharedOk && ipMatch && shared > 2; + const QPalette::ColorRole textRole = (dimmed && !selected) ? QPalette::PlaceholderText : emphasisRole; + + QPalette::ColorRole tintRole = QPalette::NoRole; + if (text == QStringLiteral("email")) { + tintRole = QPalette::Link; + } else if (text.startsWith(QStringLiteral("clientid:"))) { + tintRole = QPalette::Highlight; + } else if (ipMatch) { + tintRole = QPalette::PlaceholderText; + } + if (tintRole != QPalette::NoRole) { + if (!pill_painter::paint(painter, opt, tintRole, textRole, text)) { + QStyledItemDelegate::paint(painter, opt, index); + } + return; + } + QStyledItemDelegate::paint(painter, opt, index); + return; + } + + if (index.column() == warnsColumn || index.column() == bansColumn) { + bool countOk = false; + const int count = text.toInt(&countOk); + if (countOk && count > 0 && !selected) { + const QIcon icon = style->standardIcon(index.column() == bansColumn ? QStyle::SP_MessageBoxCritical + : QStyle::SP_MessageBoxWarning); + const int iconSize = qMax(0, opt.rect.height() - 2 * ICON_TEXT_GAP); + QRect textRect = opt.rect; + if (!icon.isNull() && iconSize > 0) { + const QRect iconRect(opt.rect.left() + ICON_TEXT_GAP, opt.rect.top() + ICON_TEXT_GAP, iconSize, + iconSize); + icon.paint(painter, iconRect); + textRect.setLeft(iconRect.right() + ICON_TEXT_GAP); + } + const QString elided = opt.fontMetrics.elidedText(text, opt.textElideMode, textRect.width()); + style->drawItemText(painter, textRect, Qt::AlignLeft | Qt::AlignVCenter, opt.palette, true, elided, + emphasisRole); + return; + } + } + + if (index.column() == activeColumn) { + const bool active = index.data(Qt::UserRole).toBool(); + const QPalette::ColorRole tintRole = active ? QPalette::Highlight : QPalette::PlaceholderText; + if (!pill_painter::paint(painter, opt, tintRole, emphasisRole, text)) { + QStyledItemDelegate::paint(painter, opt, index); + } + return; + } + + QStyledItemDelegate::paint(painter, opt, index); +} + +QSize AltTableDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const +{ + const QSize hint = QStyledItemDelegate::sizeHint(option, index); + if (index.column() == viaColumn || index.column() == activeColumn) { + return hint + QSize(pill_painter::PAD_X + 2 * ICON_TEXT_GAP, pill_painter::PAD_Y); + } + if (index.column() == warnsColumn || index.column() == bansColumn) { + return hint + QSize(2 * ICON_TEXT_GAP, 0); + } + return hint; +} diff --git a/cockatrice/src/interface/widgets/tabs/alt_table_delegate.h b/cockatrice/src/interface/widgets/tabs/alt_table_delegate.h new file mode 100644 index 000000000..6cd5c7fad --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/alt_table_delegate.h @@ -0,0 +1,37 @@ +#ifndef ALT_TABLE_DELEGATE_H +#define ALT_TABLE_DELEGATE_H + +#include +#include + +/** + * Badge + icon painting for the User Alts table. The match-evidence column + * renders as a palette-tinted pill per match type (email / client id / IP), and + * an IP shared by more than one other account is dimmed. Warn/ban counts gain + * the style's warning/critical icons when nonzero. The active column renders as + * a palette-tinted pill (Highlight when active, PlaceholderText when inactive, + * from Qt::UserRole). Selected rows and unknown content fall back to default + * painting. + */ +class AltTableDelegate : public QStyledItemDelegate +{ + Q_OBJECT +public: + explicit AltTableDelegate(int viaColumn, + int warnsColumn, + int bansColumn, + int sharedColumn, + int activeColumn, + QObject *parent = nullptr); + void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override; + [[nodiscard]] QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override; + +private: + int viaColumn; + int warnsColumn; + int bansColumn; + int sharedColumn; + int activeColumn; +}; + +#endif // ALT_TABLE_DELEGATE_H diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec_main.h b/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec_main.h index 5d0245ed4..ce0861ef7 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec_main.h +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec_main.h @@ -38,7 +38,8 @@ public: [[nodiscard]] QString getTabText() const override { auto cardName = cardToQuery.isNull() ? QString() : cardToQuery->getName(); - return tr("EDHRec: ") + cardName; + //: EDHRec is a website name, keep it as written. %1 is the card being looked up. + return tr("EDHRec: %1").arg(cardName); } CardSizeWidget *getCardSizeSlider() const diff --git a/cockatrice/src/interface/widgets/tabs/report_status_delegate.cpp b/cockatrice/src/interface/widgets/tabs/report_status_delegate.cpp new file mode 100644 index 000000000..361e89605 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/report_status_delegate.cpp @@ -0,0 +1,54 @@ +#include "report_status_delegate.h" + +#include "../utility/pill_painter.h" + +#include +#include +#include +#include +#include + +ReportStatusDelegate::ReportStatusDelegate(QObject *parent) : QStyledItemDelegate(parent) +{ +} + +void ReportStatusDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const +{ + const bool selected = (option.state & QStyle::State_Selected) != 0; + QString status = index.data(Qt::UserRole).toString(); + if (status.isEmpty()) { + status = index.data(Qt::DisplayRole).toString(); + } + + QPalette::ColorRole tintRole = QPalette::NoRole; + if (status == QStringLiteral("open")) { + tintRole = QPalette::Highlight; + } else if (status == QStringLiteral("assigned")) { + tintRole = QPalette::Link; + } else if (status == QStringLiteral("resolved") || status == QStringLiteral("dismissed")) { + tintRole = QPalette::PlaceholderText; + } + + QStyleOptionViewItem opt(option); + initStyleOption(&opt, index); + painter->setFont(opt.font); + + if (tintRole == QPalette::NoRole) { + const QPalette::ColorRole textRole = selected ? QPalette::HighlightedText : QPalette::PlaceholderText; + QStyle *fallbackStyle = opt.widget != nullptr ? opt.widget->style() : QApplication::style(); + fallbackStyle->drawItemText(painter, opt.rect, Qt::AlignLeft | Qt::AlignVCenter, opt.palette, true, opt.text, + textRole); + return; + } + + const QPalette::ColorRole textRole = selected ? QPalette::HighlightedText : QPalette::WindowText; + if (!pill_painter::paint(painter, opt, tintRole, textRole, opt.text.isEmpty() ? status : opt.text)) { + QStyledItemDelegate::paint(painter, opt, index); + } +} + +QSize ReportStatusDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const +{ + const QSize hint = QStyledItemDelegate::sizeHint(option, index); + return hint + QSize(pill_painter::PAD_X, pill_painter::PAD_Y); +} diff --git a/cockatrice/src/interface/widgets/tabs/report_status_delegate.h b/cockatrice/src/interface/widgets/tabs/report_status_delegate.h new file mode 100644 index 000000000..f01f6d3e3 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/report_status_delegate.h @@ -0,0 +1,22 @@ +#ifndef REPORT_STATUS_DELEGATE_H +#define REPORT_STATUS_DELEGATE_H + +#include +#include + +/** + * Status pill for the report queue table. Paints the status text on a + * translucent badge tinted with a palette role per status (open, assigned, + * resolved, dismissed), so the colors always follow the active theme. + * Falls back to default painting for selected rows and unknown statuses. + */ +class ReportStatusDelegate : public QStyledItemDelegate +{ + Q_OBJECT +public: + explicit ReportStatusDelegate(QObject *parent = nullptr); + void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override; + [[nodiscard]] QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override; +}; + +#endif // REPORT_STATUS_DELEGATE_H diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp new file mode 100644 index 000000000..d2cec3855 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp @@ -0,0 +1,699 @@ +#include "tab_alts.h" + +#include "../utility/epoch_format.h" +#include "../utility/table_activation.h" +#include "abstract_client.h" +#include "alt_table_delegate.h" +#include "libcockatrice/protocol/pb/response.pb.h" +#include "libcockatrice/protocol/pb/response_user_alts.pb.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 + +namespace +{ +constexpr int COL_ALTS_USER = 0; +constexpr int COL_ALTS_VIA = 1; +constexpr int COL_ALTS_DEPTH = 2; +constexpr int COL_ALTS_SHARED = 3; +constexpr int COL_ALTS_IPS = 4; +constexpr int COL_ALTS_EMAIL = 5; +constexpr int COL_ALTS_CLIENTID = 6; +constexpr int COL_ALTS_REGISTERED = 7; +constexpr int COL_ALTS_LAST_LOGIN = 8; +constexpr int COL_ALTS_WARNS = 9; +constexpr int COL_ALTS_BANS = 10; +constexpr int COL_ALTS_ACTIVE = 11; +constexpr int COL_ALTS_COUNT = 12; + +constexpr int COL_WARNS_TIME = 0; +constexpr int COL_WARNS_MODERATOR = 1; +constexpr int COL_WARNS_REASON = 2; +constexpr int COL_WARNS_COUNT = 3; + +constexpr int COL_BANS_TIME = 0; +constexpr int COL_BANS_MODERATOR = 1; +constexpr int COL_BANS_LENGTH = 2; +constexpr int COL_BANS_REASON = 3; +constexpr int COL_BANS_COUNT = 4; + +constexpr int MAX_ALT_DEPTH = 3; +// Starting default only (adjustable 1-100000): shared VPN exits routinely +// cover dozens of users, so 10 hides the obvious VPN noise but keeps +// small shared networks visible. +constexpr int DEFAULT_VPN_THRESHOLD = 10; + +void configureAltTable(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); +} +} // namespace + +TabAlts::TabAlts(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, &TabAlts::investigateUser); + + depthSpin = new QSpinBox; + depthSpin->setMinimum(1); + depthSpin->setMaximum(MAX_ALT_DEPTH); + depthSpin->setValue(1); + depthSpin->setKeyboardTracking(false); + connect(depthSpin, &QSpinBox::valueChanged, this, &TabAlts::onSearchFilterChanged); + + investigateButton = new QPushButton; + connect(investigateButton, &QPushButton::clicked, this, &TabAlts::investigateUser); + + backButton = new QPushButton; + backButton->setEnabled(false); + connect(backButton, &QPushButton::clicked, this, &TabAlts::goBack); + + statusLabel = new QLabel; + trailLabel = new QLabel; + trailLabel->setTextFormat(Qt::RichText); + trailLabel->setTextInteractionFlags(Qt::TextBrowserInteraction); + trailLabel->setOpenExternalLinks(false); + // The trail grows with every hop, so let it wrap instead of forcing the tab + // wider than the window. + trailLabel->setWordWrap(true); + connect(trailLabel, &QLabel::linkActivated, this, [this](const QString &userName) { + if (!userName.isEmpty() && userName != currentUser) { + goToTrailEntry(userName); + } + }); + + auto *topBar = new QHBoxLayout; + topBar->addWidget(backButton); + topBar->addWidget(searchEdit); + topBar->addWidget(depthSpin); + topBar->addWidget(investigateButton); + + auto makeChip = [this](const QString &text) { + auto *chip = new QPushButton(text); + chip->setCheckable(true); + chip->setChecked(true); + connect(chip, &QPushButton::toggled, this, &TabAlts::onSearchFilterChanged); + return chip; + }; + emailChip = makeChip(QString()); + clientIdChip = makeChip(QString()); + ipChip = makeChip(QString()); + vpnLabel = new QLabel; + vpnSpin = new QSpinBox; + vpnSpin->setMinimum(1); + vpnSpin->setMaximum(100000); + vpnSpin->setValue(DEFAULT_VPN_THRESHOLD); + connect(vpnSpin, &QSpinBox::valueChanged, this, &TabAlts::onDisplayFilterChanged); + windowLabel = new QLabel; + monthsSpin = new QSpinBox; + monthsSpin->setMinimum(1); + monthsSpin->setMaximum(24); + monthsSpin->setValue(6); + monthsSpin->setKeyboardTracking(false); + connect(monthsSpin, &QSpinBox::valueChanged, this, &TabAlts::onSearchFilterChanged); + + auto *filterBar = new QHBoxLayout; + filterBar->addWidget(emailChip); + filterBar->addWidget(clientIdChip); + filterBar->addWidget(ipChip); + filterBar->addWidget(vpnLabel); + filterBar->addWidget(vpnSpin); + filterBar->addWidget(windowLabel); + filterBar->addWidget(monthsSpin); + filterBar->addStretch(); + + altsTable = new QTableWidget(0, COL_ALTS_COUNT); + configureAltTable(altsTable); + altsDelegate = + new AltTableDelegate(COL_ALTS_VIA, COL_ALTS_WARNS, COL_ALTS_BANS, COL_ALTS_SHARED, COL_ALTS_ACTIVE, altsTable); + altsTable->setItemDelegateForColumn(COL_ALTS_VIA, altsDelegate); + altsTable->setItemDelegateForColumn(COL_ALTS_WARNS, altsDelegate); + altsTable->setItemDelegateForColumn(COL_ALTS_BANS, altsDelegate); + altsTable->setItemDelegateForColumn(COL_ALTS_ACTIVE, altsDelegate); + connectTableActivation(altsTable, this, [this](const QModelIndex &index) { + if (!index.isValid()) { + return; + } + auto *userItem = altsTable->item(index.row(), COL_ALTS_USER); + if (userItem != nullptr && !userItem->text().isEmpty() && userItem->text() != currentUser) { + investigate(userItem->text()); + } + }); + connect(altsTable, &QTableWidget::itemSelectionChanged, this, &TabAlts::onAltSelected); + + detailNameValue = new QLabel; + detailCountsValue = new QLabel; + detailNotesEdit = new QTextEdit; + detailNotesEdit->setReadOnly(true); + // Compact cap so the warn/ban tables stay visible without scrolling the tab. + detailNotesEdit->setMaximumHeight(60); + warnsTable = new QTableWidget(0, COL_WARNS_COUNT); + configureAltTable(warnsTable); + bansTable = new QTableWidget(0, COL_BANS_COUNT); + configureAltTable(bansTable); + + auto *detailGrid = new QGridLayout; + detailNameLabel = new QLabel; + detailCountsLabel = new QLabel; + detailNotesLabel = new QLabel; + detailGrid->addWidget(detailNameLabel, 0, 0); + detailGrid->addWidget(detailNameValue, 0, 1); + detailGrid->addWidget(detailCountsLabel, 0, 2); + detailGrid->addWidget(detailCountsValue, 0, 3); + detailGrid->addWidget(detailNotesLabel, 1, 0); + detailGrid->addWidget(detailNotesEdit, 1, 1, 1, 3); + + detailsGroup = new QGroupBox; + auto *detailsLayout = new QVBoxLayout(detailsGroup); + detailsLayout->addLayout(detailGrid); + detailsLayout->addWidget(warnsTable, 1); + detailsLayout->addWidget(bansTable, 1); + investigateSelectedButton = new QPushButton; + investigateSelectedButton->setEnabled(false); + connect(investigateSelectedButton, &QPushButton::clicked, this, &TabAlts::investigateSelected); + detailsLayout->addWidget(investigateSelectedButton); + + auto *contentSplitter = new QSplitter(Qt::Horizontal); + contentSplitter->addWidget(altsTable); + contentSplitter->addWidget(detailsGroup); + contentSplitter->setStretchFactor(0, 3); + contentSplitter->setStretchFactor(1, 2); + detailsGroup->setMinimumWidth(260); + + auto *mainLayout = new QVBoxLayout(centralWidget); + mainLayout->addLayout(topBar); + mainLayout->addLayout(filterBar); + mainLayout->addWidget(statusLabel); + mainLayout->addWidget(trailLabel); + mainLayout->addWidget(contentSplitter, 1); + + retranslateUi(); + clearDetails(); + investigate(initialUser); +} + +void TabAlts::retranslateUi() +{ + searchEdit->setPlaceholderText(tr("User name")); + depthSpin->setPrefix(tr("Depth: ")); + depthSpin->setToolTip(tr("Search depth: 1 finds direct matches, higher values follow alts of alts.")); + investigateButton->setText(tr("Investigate")); + backButton->setText(tr("Back")); + backButton->setToolTip(tr("Back to the previously investigated user")); + if (currentUser.isEmpty()) { + statusLabel->setText(tr("Enter a user name to investigate.")); + } + + emailChip->setText(tr("Email")); + emailChip->setToolTip(tr("Show or hide email matches")); + clientIdChip->setText(tr("Client ID")); + clientIdChip->setToolTip(tr("Show or hide client ID matches")); + ipChip->setText(tr("IP")); + ipChip->setToolTip(tr("Show or hide IP address matches")); + vpnLabel->setText(tr("Hide IPs shared by more than")); + vpnSpin->setSuffix(tr(" users")); + windowLabel->setText(tr("Session window")); + monthsSpin->setSuffix(tr(" months")); + monthsSpin->setToolTip(tr("How far back session IPs and client IDs are considered.")); + + altsTable->setHorizontalHeaderLabels({tr("User"), tr("Via"), tr("Depth"), tr("Shared"), tr("IPs"), tr("eMail"), + tr("Client ID"), tr("Registered"), tr("Last login"), tr("Warns"), tr("Bans"), + tr("Active")}); + altsTable->setToolTip(tr("Double-click or press Enter on a row to investigate that account")); + warnsTable->setHorizontalHeaderLabels({tr("Warning time"), tr("Moderator"), tr("Reason")}); + bansTable->setHorizontalHeaderLabels({tr("Ban time"), tr("Moderator"), tr("Length"), tr("Reason")}); + + detailsGroup->setTitle(tr("Selected user")); + detailNameLabel->setText(tr("Name:")); + detailCountsLabel->setText(tr("Counts:")); + detailNotesLabel->setText(tr("Admin notes:")); + investigateSelectedButton->setText(tr("Investigate this user")); + investigateSelectedButton->setToolTip(tr("Open the selected account as a new investigation")); + + if (!lastAlts.isEmpty()) { + renderTable(); + } +} + +QString TabAlts::formatEpoch(quint64 ts) const +{ + return formatEpochTimestamp(ts, tr("Unknown")); +} + +void TabAlts::investigate(const QString &userName) +{ + if (userName.isEmpty() || userName == currentUser) { + return; + } + // Alt matches are symmetric, so revisiting an earlier account is a normal + // double-click. Truncate there instead of pushing it again, so the trail + // stays a single path. + if (backStack.contains(userName)) { + goToTrailEntry(userName); + return; + } + if (!currentUser.isEmpty()) { + backStack << currentUser; + backButton->setEnabled(true); + } + loadUser(userName); +} + +void TabAlts::goToTrailEntry(const QString &userName) +{ + const int index = backStack.indexOf(userName); + if (index < 0) { + investigate(userName); + return; + } + // Clicking an earlier crumb truncates the history there instead of pushing + // another entry, so the trail keeps a single path and Back still works. + backStack.erase(backStack.begin() + index, backStack.end()); + backButton->setEnabled(!backStack.isEmpty()); + loadUser(userName); +} + +void TabAlts::goBack() +{ + if (backStack.isEmpty()) { + return; + } + const QString previousUser = backStack.takeLast(); + backButton->setEnabled(!backStack.isEmpty()); + loadUser(previousUser); +} + +void TabAlts::loadUser(const QString &userName) +{ + currentUser = userName; + searchEdit->setText(userName); + updateTrail(); + investigateUser(); +} + +void TabAlts::updateTrail() +{ + QStringList trail = backStack; + trail << currentUser; + trail.removeAll(QString()); + QStringList links; + for (const QString &name : trail) { + const QString safeName = name.toHtmlEscaped(); + if (name == currentUser) { + links << QStringLiteral("") + safeName + QStringLiteral(""); + } else { + links << QStringLiteral("") + safeName + + QStringLiteral(""); + } + } + trailLabel->setText(links.join(QStringLiteral(" › "))); + trailLabel->setVisible(!links.isEmpty()); +} + +void TabAlts::investigateUser() +{ + const QString userName = searchEdit->text().simplified(); + if (userName.isEmpty()) { + return; + } + if (userName != currentUser && !currentUser.isEmpty()) { + backStack << currentUser; + backButton->setEnabled(true); + } + currentUser = userName; + updateTrail(); + reloadCurrentUser(); +} + +void TabAlts::reloadCurrentUser() +{ + lastAlts.clear(); + selectedUser.clear(); + clearDetails(); + altsTable->setRowCount(0); + statusLabel->setText(tr("Loading alts for %1...").arg(currentUser)); + requestAlts(currentUser, depthSpin->value()); +} + +void TabAlts::requestAlts(const QString &userName, int depth) +{ + Command_GetUserAlts cmd; + cmd.set_user_name(userName.toStdString()); + cmd.set_depth(static_cast(depth)); + cmd.set_include_email(emailChip->isChecked()); + cmd.set_include_clientid(clientIdChip->isChecked()); + cmd.set_include_ip(ipChip->isChecked()); + cmd.set_session_months(static_cast(monthsSpin->value())); + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, [this, userName](const Response &response) { + if (userName == currentUser) { + altsResponse(response); + } + }); + client->sendCommand(pend); +} + +void TabAlts::altsResponse(const Response &response) +{ + lastAlts.clear(); + if (response.response_code() != Response::RespOk) { + statusLabel->setText(tr("Could not load alts for %1.").arg(currentUser)); + return; + } + + const Response_UserAlts &resp = response.GetExtension(Response_UserAlts::ext); + // The server answers RespOk with no rows when the user does not exist. + if (resp.alts_size() == 0) { + statusLabel->setText(tr("User %1 not found.").arg(currentUser)); + return; + } + + for (int i = 0; i < resp.alts_size(); ++i) { + lastAlts << resp.alts(i); + } + renderTable(); +} + +bool TabAlts::rowVisible(const ServerInfo_UserAlt &alt) const +{ + const QString name = QString::fromStdString(alt.user_name()); + if (name == currentUser) { + return true; + } + if (!alt.has_match_reason() || alt.match_reason().empty()) { + return true; + } + const QString reason = QString::fromStdString(alt.match_reason()); + if (reason == QStringLiteral("email")) { + return emailChip->isChecked(); + } + if (reason.startsWith(QStringLiteral("clientid:"))) { + return clientIdChip->isChecked(); + } + if (reason.startsWith(QStringLiteral("ip:"))) { + return ipChip->isChecked() && + (!alt.has_shared_count() || alt.shared_count() <= static_cast(vpnSpin->value())); + } + return true; +} + +void TabAlts::renderTable() +{ + // Blocked so the programmatic refill neither wipes the details panel + // (via the selection-cleared path) nor refetches them on restore. + const QSignalBlocker blocker(altsTable); + altsTable->setRowCount(0); + + int emailCount = 0; + int clientIdCount = 0; + int ipCount = 0; + for (const ServerInfo_UserAlt &alt : lastAlts) { + const QString reason = QString::fromStdString(alt.match_reason()); + if (reason == QStringLiteral("email")) { + ++emailCount; + } else if (reason.startsWith(QStringLiteral("clientid:"))) { + ++clientIdCount; + } else if (reason.startsWith(QStringLiteral("ip:"))) { + ++ipCount; + } + } + // The server omits unchecked match types, so keep the last count while a + // chip is off instead of relabelling it "(0)". + if (emailChip->isChecked()) { + emailChip->setText(tr("Email (%1)").arg(emailCount)); + } + if (clientIdChip->isChecked()) { + clientIdChip->setText(tr("Client ID (%1)").arg(clientIdCount)); + } + if (ipChip->isChecked()) { + ipChip->setText(tr("IP (%1)").arg(ipCount)); + } + + QFont userFont = altsTable->font(); + userFont.setBold(true); + const QColor dimColor = palette().color(QPalette::Disabled, QPalette::Text); + + int hidden = 0; + int row = 0; + for (const ServerInfo_UserAlt &alt : lastAlts) { + if (!rowVisible(alt)) { + ++hidden; + continue; + } + altsTable->insertRow(row); + auto *userItem = new QTableWidgetItem(QString::fromStdString(alt.user_name())); + userItem->setFont(userFont); + altsTable->setItem(row, COL_ALTS_USER, userItem); + altsTable->setItem(row, COL_ALTS_VIA, + new QTableWidgetItem(alt.has_match_reason() && !alt.match_reason().empty() + ? QString::fromStdString(alt.match_reason()) + : QStringLiteral("-"))); + altsTable->setItem(row, COL_ALTS_DEPTH, + new QTableWidgetItem(alt.has_depth() ? QString::number(alt.depth()) : QStringLiteral("-"))); + altsTable->setItem( + row, COL_ALTS_SHARED, + new QTableWidgetItem(alt.has_shared_count() ? QString::number(alt.shared_count()) : QStringLiteral("-"))); + altsTable->setItem( + row, COL_ALTS_IPS, + new QTableWidgetItem(alt.has_ip_count() ? QString::number(alt.ip_count()) : QStringLiteral("-"))); + altsTable->setItem(row, COL_ALTS_EMAIL, new QTableWidgetItem(QString::fromStdString(alt.email()))); + altsTable->setItem(row, COL_ALTS_CLIENTID, new QTableWidgetItem(QString::fromStdString(alt.clientid()))); + altsTable->setItem(row, COL_ALTS_REGISTERED, new QTableWidgetItem(formatEpoch(alt.registration_time()))); + altsTable->setItem(row, COL_ALTS_LAST_LOGIN, new QTableWidgetItem(formatEpoch(alt.last_login()))); + altsTable->setItem(row, COL_ALTS_WARNS, new QTableWidgetItem(QString::number(alt.warn_count()))); + altsTable->setItem(row, COL_ALTS_BANS, new QTableWidgetItem(QString::number(alt.ban_count()))); + auto *activeItem = new QTableWidgetItem(alt.is_active() ? tr("yes") : tr("no")); + activeItem->setData(Qt::UserRole, alt.is_active()); + altsTable->setItem(row, COL_ALTS_ACTIVE, activeItem); + // shared_count already includes the investigated account, so anything + // above 2 means the address is shared beyond that pair. + if (QString::fromStdString(alt.match_reason()).startsWith(QStringLiteral("ip:")) && alt.has_shared_count() && + alt.shared_count() > 2) { + for (int col = 0; col < COL_ALTS_COUNT; ++col) { + if (col == COL_ALTS_VIA) { + continue; + } + if (auto *item = altsTable->item(row, col)) { + item->setForeground(dimColor); + } + } + } + ++row; + } + // The server appends the frontier user as a depth-0 seed row; it is the + // investigated user, not one of their related accounts. + int relatedCount = 0; + for (const ServerInfo_UserAlt &alt : lastAlts) { + if (!alt.has_depth() || alt.depth() > 0) { + ++relatedCount; + } + } + if (hidden == 0) { + statusLabel->setText(tr("%1 account(s) related to %2.").arg(relatedCount).arg(currentUser)); + } else { + statusLabel->setText(tr("%1 shown, %2 hidden by filters (%3 total) related to %4.") + .arg(row) + .arg(hidden) + .arg(relatedCount) + .arg(currentUser)); + } + + if (!selectedUser.isEmpty()) { + bool restored = false; + for (int restoreRow = 0; restoreRow < altsTable->rowCount(); ++restoreRow) { + auto *userItem = altsTable->item(restoreRow, COL_ALTS_USER); + if (userItem != nullptr && userItem->text() == selectedUser) { + altsTable->setCurrentItem(userItem, QItemSelectionModel::Select | QItemSelectionModel::Rows); + restored = true; + break; + } + } + if (!restored) { + selectedUser.clear(); + clearDetails(); + } + } +} + +void TabAlts::onSearchFilterChanged() +{ + if (currentUser.isEmpty()) { + return; + } + // A filter change reloads the user being investigated; it must not switch + // to a name that was typed into the search box but never submitted. + reloadCurrentUser(); +} + +void TabAlts::onDisplayFilterChanged() +{ + if (lastAlts.isEmpty()) { + return; + } + renderTable(); +} + +void TabAlts::investigateSelected() +{ + if (!selectedUser.isEmpty() && selectedUser != currentUser) { + investigate(selectedUser); + } +} + +void TabAlts::onAltSelected() +{ + const auto selected = altsTable->selectedItems(); + if (selected.isEmpty()) { + selectedUser.clear(); + clearDetails(); + return; + } + auto *userItem = altsTable->item(selected.first()->row(), COL_ALTS_USER); + if (userItem == nullptr || userItem->text().isEmpty() || userItem->text() == selectedUser) { + return; + } + selectedUser = userItem->text(); + requestDetails(selectedUser); +} + +void TabAlts::requestDetails(const QString &userName) +{ + detailNameValue->setText(userName); + detailCountsValue->setText(tr("Loading...")); + detailNotesEdit->clear(); + warnsTable->setRowCount(0); + bansTable->setRowCount(0); + investigateSelectedButton->setEnabled(userName != currentUser); + + Command_ReportUserInfo infoCmd; + infoCmd.set_user_name(userName.toStdString()); + PendingCommand *infoPend = client->prepareModeratorCommand(infoCmd); + connect(infoPend, &PendingCommand::finished, this, [this, userName](const Response &response) { + if (userName == selectedUser) { + userDetailsResponse(response); + } + }); + client->sendCommand(infoPend); + + Command_GetWarnHistory warnCmd; + warnCmd.set_user_name(userName.toStdString()); + PendingCommand *warnPend = client->prepareModeratorCommand(warnCmd); + connect(warnPend, &PendingCommand::finished, this, [this, userName](const Response &response) { + if (userName == selectedUser) { + warnHistoryResponse(response); + } + }); + client->sendCommand(warnPend); + + Command_GetBanHistory banCmd; + banCmd.set_user_name(userName.toStdString()); + PendingCommand *banPend = client->prepareModeratorCommand(banCmd); + connect(banPend, &PendingCommand::finished, this, [this, userName](const Response &response) { + if (userName == selectedUser) { + banHistoryResponse(response); + } + }); + client->sendCommand(banPend); +} + +void TabAlts::clearDetails() +{ + detailNameValue->clear(); + detailCountsValue->setText(tr("Select a row to inspect its history.")); + detailNotesEdit->clear(); + warnsTable->setRowCount(0); + bansTable->setRowCount(0); + investigateSelectedButton->setEnabled(false); +} + +void TabAlts::userDetailsResponse(const Response &response) +{ + if (response.response_code() != Response::RespOk) { + detailCountsValue->setText(tr("Error loading user info.")); + return; + } + + const Response_ReportUserInfo &resp = response.GetExtension(Response_ReportUserInfo::ext); + detailCountsValue->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()) { + detailNotesEdit->setPlainText(QString::fromStdString(resp.admin_notes())); + } else { + detailNotesEdit->setPlainText(tr("(none)")); + } +} + +void TabAlts::warnHistoryResponse(const Response &response) +{ + warnsTable->setRowCount(0); + if (response.response_code() != Response::RespOk) { + return; + } + + const Response_WarnHistory &resp = response.GetExtension(Response_WarnHistory::ext); + warnsTable->setRowCount(resp.warn_list_size()); + for (int i = 0; i < resp.warn_list_size(); ++i) { + const ServerInfo_Warning &warn = resp.warn_list(i); + warnsTable->setItem(i, COL_WARNS_TIME, new QTableWidgetItem(QString::fromStdString(warn.time_of()))); + warnsTable->setItem(i, COL_WARNS_MODERATOR, new QTableWidgetItem(QString::fromStdString(warn.admin_name()))); + warnsTable->setItem(i, COL_WARNS_REASON, new QTableWidgetItem(QString::fromStdString(warn.reason()))); + } +} + +void TabAlts::banHistoryResponse(const Response &response) +{ + bansTable->setRowCount(0); + if (response.response_code() != Response::RespOk) { + return; + } + + const Response_BanHistory &resp = response.GetExtension(Response_BanHistory::ext); + bansTable->setRowCount(resp.ban_list_size()); + for (int i = 0; i < resp.ban_list_size(); ++i) { + const ServerInfo_Ban &ban = resp.ban_list(i); + bansTable->setItem(i, COL_BANS_TIME, new QTableWidgetItem(QString::fromStdString(ban.ban_time()))); + bansTable->setItem(i, COL_BANS_MODERATOR, new QTableWidgetItem(QString::fromStdString(ban.admin_name()))); + bansTable->setItem(i, COL_BANS_LENGTH, new QTableWidgetItem(QString::fromStdString(ban.ban_length()))); + bansTable->setItem(i, COL_BANS_REASON, new QTableWidgetItem(QString::fromStdString(ban.ban_reason()))); + } +} diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.h b/cockatrice/src/interface/widgets/tabs/tab_alts.h new file mode 100644 index 000000000..7f6dd4f42 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.h @@ -0,0 +1,102 @@ +#ifndef TAB_ALTS_H +#define TAB_ALTS_H + +#include "tab.h" + +#include +#include +#include +#include +#include + +class AbstractClient; +class AltTableDelegate; +class QGroupBox; +class QLabel; +class QLineEdit; +class QPushButton; +class QSpinBox; +class QTableWidget; +class QTextEdit; +class Response; +class TabSupervisor; + +/** + * Staff alt-graph investigation tool. Shows accounts related to the + * investigated user (shared email, client id, or recent IP address), + * including transitive alts-of-alts, with per-row match evidence. + * Selecting a row loads that account's warnings, bans, and admin notes. + */ +class TabAlts : public Tab +{ + Q_OBJECT +public: + explicit TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const QString &initialUser = {}); + void retranslateUi() override; + [[nodiscard]] QString getTabText() const override + { + return tr("User Alts"); + } + void investigate(const QString &userName); + +private slots: + void investigateUser(); + void goBack(); + void investigateSelected(); + void altsResponse(const Response &response); + void onSearchFilterChanged(); + void onDisplayFilterChanged(); + void onAltSelected(); + void userDetailsResponse(const Response &response); + void warnHistoryResponse(const Response &response); + void banHistoryResponse(const Response &response); + +private: + void loadUser(const QString &userName); + void goToTrailEntry(const QString &userName); + void updateTrail(); + void reloadCurrentUser(); + void requestAlts(const QString &userName, int depth); + void requestDetails(const QString &userName); + void clearDetails(); + void renderTable(); + [[nodiscard]] bool rowVisible(const ServerInfo_UserAlt &alt) const; + [[nodiscard]] QString formatEpoch(quint64 ts) const; + + AbstractClient *client; + QString currentUser; + QString selectedUser; + QList backStack; + QList lastAlts; + + QLineEdit *searchEdit; + QSpinBox *depthSpin; + QPushButton *investigateButton; + QPushButton *backButton; + QPushButton *investigateSelectedButton; + QLabel *trailLabel; + QLabel *statusLabel; + QTableWidget *altsTable; + AltTableDelegate *altsDelegate; + + QPushButton *emailChip; + QPushButton *clientIdChip; + QPushButton *ipChip; + QLabel *vpnLabel; + QSpinBox *vpnSpin; + QLabel *windowLabel; + QSpinBox *monthsSpin; + + QLabel *detailNameValue; + QLabel *detailCountsValue; + QTextEdit *detailNotesEdit; + QTableWidget *warnsTable; + QTableWidget *bansTable; + + QGroupBox *detailsGroup; + QLabel *detailNameLabel; + QLabel *detailCountsLabel; + QLabel *detailNotesLabel; +}; + +#endif // TAB_ALTS_H diff --git a/cockatrice/src/interface/widgets/tabs/tab_game.cpp b/cockatrice/src/interface/widgets/tabs/tab_game.cpp index ac9edd1ba..5dba1fc51 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_game.cpp @@ -330,6 +330,9 @@ void TabGame::emitUserEvent() TabGame::~TabGame() { + if (aCardMenu && aCardMenu->menu() && aCardMenu->menu() != emptyCardMenu) { + delete aCardMenu->menu(); + } for (auto &player : game->getPlayerManager()->getPlayers()) { player->clear(); } @@ -570,8 +573,10 @@ void TabGame::adminLockChanged(bool lock) void TabGame::actGameInfo() { - DlgCreateGame dlg(game->getGameMetaInfo()->proto(), game->getGameMetaInfo()->getRoomGameTypes(), this); - dlg.exec(); + // Heap-allocated so that destroying this tab while the dialog is open cannot delete a stack object. + auto *dlg = new DlgCreateGame(game->getGameMetaInfo()->proto(), game->getGameMetaInfo()->getRoomGameTypes(), this); + dlg->setAttribute(Qt::WA_DeleteOnClose); + dlg->exec(); } void TabGame::actCopyGameLink() @@ -1156,10 +1161,16 @@ QString TabGame::getTabText() const /** * @param menu The menu to set. Pass in nullptr to set the menu to empty. + * + * The previous card menu is deleted here: QAction::setMenu() does not take ownership, so whoever installs the menu + * (this widget) has to dispose of the one it replaces. The empty placeholder menu is reused instead of deleted. */ void TabGame::setCardMenu(CardMenu *menu) { - if (!aCardMenu) { + if (!aCardMenu || !emptyCardMenu) { + if (menu) { + menu->deleteLater(); + } return; } @@ -1169,10 +1180,11 @@ void TabGame::setCardMenu(CardMenu *menu) gameView->viewport()->update(); } } - if (menu) { - aCardMenu->setMenu(menu); - } else { - aCardMenu->setMenu(new QMenu); + + QMenu *previousMenu = aCardMenu->menu(); + aCardMenu->setMenu(menu ? menu : emptyCardMenu); + if (previousMenu && previousMenu != emptyCardMenu && previousMenu != menu) { + previousMenu->deleteLater(); } } @@ -1256,7 +1268,8 @@ void TabGame::createMenuItems() gameMenu->addSeparator(); - aCardMenu = gameMenu->addMenu(new QMenu(this)); + emptyCardMenu = new QMenu(this); + aCardMenu = gameMenu->addMenu(emptyCardMenu); // Sync the new action with the same state the dock button already shows. updateInviteButtonState(); @@ -1288,6 +1301,7 @@ void TabGame::createReplayMenuItems() gameMenu->addAction(aLeaveGame); aCardMenu = nullptr; + emptyCardMenu = nullptr; addTabMenu(gameMenu); } diff --git a/cockatrice/src/interface/widgets/tabs/tab_game.h b/cockatrice/src/interface/widgets/tabs/tab_game.h index 142c02999..5c2af82b4 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.h +++ b/cockatrice/src/interface/widgets/tabs/tab_game.h @@ -101,6 +101,8 @@ private: QAction *aInviteToGame = nullptr; QList phaseActions; QAction *aCardMenu; + /** @brief Placeholder submenu shown when no card is selected; reused instead of reallocated. */ + QMenu *emptyCardMenu = nullptr; /** * @brief The actions associated with managing a QDockWidget diff --git a/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp b/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp index be1adeef8..8568b0d2e 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp @@ -1,11 +1,14 @@ #include "tab_moderation.h" +#include "../utility/epoch_format.h" +#include "../utility/table_activation.h" #include "abstract_client.h" #include "libcockatrice/protocol/pb/admin_commands.pb.h" #include "libcockatrice/protocol/pb/response.pb.h" #include "libcockatrice/protocol/pb/serverinfo_moderator_login.pb.h" #include "libcockatrice/protocol/pb/serverinfo_user_alt.pb.h" #include "libcockatrice/protocol/pb/serverinfo_user_session.pb.h" +#include "tab_supervisor.h" #include #include @@ -21,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -99,38 +103,67 @@ TabModeration::TabModeration(TabSupervisor *_tabSupervisor, AbstractClient *_cli topBar->addWidget(removeBannerButton); userInfoGroup = new QGroupBox; - userInfoNameLabel = new QLabel; userInfoNameValue = new QLabel; - userInfoRegisteredLabel = new QLabel; - userInfoRegisteredValue = new QLabel; - userInfoLastLoginLabel = new QLabel; - userInfoLastLoginValue = new QLabel; - userInfoStatusLabel = new QLabel; + QFont displayFont = userInfoNameValue->font(); + displayFont.setPointSize(displayFont.pointSize() + 6); + displayFont.setBold(true); + userInfoNameValue->setFont(displayFont); userInfoStatusValue = new QLabel; - userInfoCountsLabel = new QLabel; - userInfoCountsValue = new QLabel; + QFont statusFont = userInfoStatusValue->font(); + statusFont.setBold(true); + userInfoStatusValue->setFont(statusFont); + sublineLabel = new QLabel; + + auto makeCountCard = [](const QString &title, QLabel *&value) { + auto *card = new QGroupBox(title); + value = new QLabel(QStringLiteral("–"), card); + QFont cardFont = value->font(); + cardFont.setPointSize(cardFont.pointSize() + 6); + cardFont.setBold(true); + value->setFont(cardFont); + value->setAlignment(Qt::AlignCenter); + auto *cardLayout = new QVBoxLayout(card); + cardLayout->addWidget(value); + return card; + }; + cardReports = makeCountCard(QString(), cardReportsValue); + cardWarns = makeCountCard(QString(), cardWarnsValue); + cardBans = makeCountCard(QString(), cardBansValue); + + altsCard = new QGroupBox; + altsCountValue = new QLabel(QStringLiteral("–"), altsCard); + QFont altsFont = altsCountValue->font(); + altsFont.setPointSize(altsFont.pointSize() + 6); + altsFont.setBold(true); + altsCountValue->setFont(altsFont); + altsCountValue->setAlignment(Qt::AlignCenter); + openAltsButton = new QPushButton; + connect(openAltsButton, &QPushButton::clicked, this, &TabModeration::openAltsGraph); + auto *altsCardLayout = new QVBoxLayout(altsCard); + altsCardLayout->addWidget(altsCountValue); + altsCardLayout->addWidget(openAltsButton); + + auto *cardsRow = new QHBoxLayout; + cardsRow->addWidget(cardReports); + cardsRow->addWidget(cardWarns); + cardsRow->addWidget(cardBans); + cardsRow->addWidget(altsCard); + 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 *identityRow = new QHBoxLayout; + identityRow->addWidget(userInfoNameValue); + identityRow->addStretch(); + identityRow->addWidget(userInfoStatusValue); auto *infoLayout = new QVBoxLayout(userInfoGroup); - infoLayout->addLayout(infoGrid); + infoLayout->addLayout(identityRow); + infoLayout->addWidget(sublineLabel); + infoLayout->addLayout(cardsRow); + infoLayout->addWidget(userInfoNotesLabel); + infoLayout->addWidget(userInfoNotesEdit); auto configureTable = [](QTableWidget *table) { table->setSelectionBehavior(QAbstractItemView::SelectRows); @@ -142,19 +175,27 @@ TabModeration::TabModeration(TabSupervisor *_tabSupervisor, AbstractClient *_cli table->horizontalHeader()->setStretchLastSection(true); }; - altsGroup = new QGroupBox; altsTable = new QTableWidget(0, COL_ALTS_COUNT); configureTable(altsTable); - auto *altsLayout = new QVBoxLayout(altsGroup); + connectTableActivation(altsTable, this, [this](const QModelIndex &index) { + if (!index.isValid()) { + return; + } + auto *userItem = altsTable->item(index.row(), COL_ALTS_USER); + if (userItem != nullptr && !userItem->text().isEmpty()) { + getTabSupervisor()->openTabAlts(userItem->text()); + } + }); + auto *altsPage = new QWidget; + auto *altsLayout = new QVBoxLayout(altsPage); altsLayout->addWidget(altsTable); - sessionsGroup = new QGroupBox; sessionsTable = new QTableWidget(0, COL_SESSIONS_COUNT); configureTable(sessionsTable); - auto *sessionsLayout = new QVBoxLayout(sessionsGroup); + auto *sessionsPage = new QWidget; + auto *sessionsLayout = new QVBoxLayout(sessionsPage); sessionsLayout->addWidget(sessionsTable); - staffGroup = new QGroupBox; staffTable = new QTableWidget(0, COL_STAFF_COUNT); configureTable(staffTable); refreshStaffButton = new QPushButton; @@ -162,19 +203,21 @@ TabModeration::TabModeration(TabSupervisor *_tabSupervisor, AbstractClient *_cli auto *staffHeader = new QHBoxLayout; staffHeader->addStretch(); staffHeader->addWidget(refreshStaffButton); - auto *staffLayout = new QVBoxLayout(staffGroup); + auto *staffPage = new QWidget; + auto *staffLayout = new QVBoxLayout(staffPage); staffLayout->addWidget(staffTable); staffLayout->addLayout(staffHeader); + sectionTabs = new QTabWidget; + sectionTabs->addTab(altsPage, QString()); + sectionTabs->addTab(sessionsPage, QString()); + sectionTabs->addTab(staffPage, QString()); + auto *splitter = new QSplitter(Qt::Vertical); splitter->addWidget(userInfoGroup); - splitter->addWidget(altsGroup); - splitter->addWidget(sessionsGroup); - splitter->addWidget(staffGroup); + splitter->addWidget(sectionTabs); splitter->setStretchFactor(0, 1); splitter->setStretchFactor(1, 2); - splitter->setStretchFactor(2, 2); - splitter->setStretchFactor(3, 1); auto *mainLayout = new QVBoxLayout(centralWidget); mainLayout->addLayout(topBar); @@ -196,45 +239,56 @@ void TabModeration::retranslateUi() 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:")); + cardReports->setTitle(tr("Reports")); + cardWarns->setTitle(tr("Warnings")); + cardBans->setTitle(tr("Bans")); + altsCard->setTitle(tr("Alts")); + openAltsButton->setText(tr("Open graph")); + openAltsButton->setToolTip(tr("Open this user in the User Alts tab")); - altsGroup->setTitle(tr("Alts")); - sessionsGroup->setTitle(tr("Sessions")); - staffGroup->setTitle(tr("Staff Last Logins")); + sectionTabs->setTabText(0, tr("Alts")); + sectionTabs->setTabText(1, tr("Sessions")); + sectionTabs->setTabText(2, tr("Staff Last Logins")); + + if (cachedUserInfoValid) { + applyUserInfo(); + } + if (cachedAltsCount >= 0) { + applyAltsCount(); + } altsTable->setHorizontalHeaderLabels({tr("User"), tr("eMail"), tr("Client ID"), tr("Registered"), tr("Last login"), tr("Warns"), tr("Bans"), tr("Active")}); + altsTable->setToolTip(tr("Double-click or press Enter on a row to open it in the User Alts tab")); 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"); + return formatEpochTimestamp(ts, tr("Unknown")); } void TabModeration::clearUserData() { currentUser.clear(); + cachedUserInfoValid = false; + cachedAltsCount = -1; userInfoNameValue->clear(); - userInfoRegisteredValue->clear(); - userInfoLastLoginValue->clear(); userInfoStatusValue->clear(); - userInfoCountsValue->clear(); + sublineLabel->clear(); + cardReportsValue->setText(QStringLiteral("–")); + cardWarnsValue->setText(QStringLiteral("–")); + cardBansValue->setText(QStringLiteral("–")); + altsCountValue->setText(QStringLiteral("–")); userInfoNotesEdit->clear(); altsTable->setRowCount(0); sessionsTable->setRowCount(0); resetPasswordButton->setEnabled(false); removeAvatarButton->setEnabled(false); removeBannerButton->setEnabled(false); + openAltsButton->setEnabled(false); } void TabModeration::investigate(const QString &userName) @@ -253,9 +307,13 @@ void TabModeration::investigateUser() return; } currentUser = userName; + cachedUserInfoValid = false; + cachedAltsCount = -1; resetPasswordButton->setEnabled(true); removeAvatarButton->setEnabled(true); removeBannerButton->setEnabled(true); + openAltsButton->setEnabled(true); + altsCountValue->setText(QStringLiteral("…")); altsTable->setRowCount(0); sessionsTable->setRowCount(0); requestUserInfo(userName); @@ -263,13 +321,22 @@ void TabModeration::investigateUser() requestAlts(userName); } +void TabModeration::openAltsGraph() +{ + if (currentUser.isEmpty()) { + return; + } + getTabSupervisor()->openTabAlts(currentUser); +} + void TabModeration::requestUserInfo(const QString &userName) { userInfoNameValue->setText(userName); - userInfoRegisteredValue->setText(tr("Loading...")); - userInfoLastLoginValue->setText(tr("Loading...")); + sublineLabel->setText(tr("Loading...")); userInfoStatusValue->clear(); - userInfoCountsValue->clear(); + cardReportsValue->setText(QStringLiteral("…")); + cardWarnsValue->setText(QStringLiteral("…")); + cardBansValue->setText(QStringLiteral("…")); userInfoNotesEdit->clear(); Command_ReportUserInfo cmd; @@ -282,9 +349,12 @@ void TabModeration::requestUserInfo(const QString &userName) void TabModeration::userInfoResponse(const Response &response) { if (response.response_code() != Response::RespOk) { - userInfoRegisteredValue->clear(); - userInfoLastLoginValue->clear(); + cachedUserInfoValid = false; + sublineLabel->clear(); userInfoStatusValue->setText(tr("Error loading user info.")); + cardReportsValue->setText(QStringLiteral("–")); + cardWarnsValue->setText(QStringLiteral("–")); + cardBansValue->setText(QStringLiteral("–")); return; } @@ -293,20 +363,28 @@ void TabModeration::userInfoResponse(const Response &response) return; } - userInfoRegisteredValue->setText(formatEpoch(resp.registration_time())); - userInfoLastLoginValue->setText(formatEpoch(resp.last_login())); + cachedUserInfo.CopyFrom(resp); + cachedUserInfoValid = true; + applyUserInfo(); +} + +void TabModeration::applyUserInfo() +{ + const Response_ReportUserInfo &resp = cachedUserInfo; + sublineLabel->setText(tr("Registered %1 · Last login %2") + .arg(formatEpoch(resp.registration_time())) + .arg(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(", ")); + userInfoStatusValue->setText(statusParts.join(QStringLiteral(", "))); - userInfoCountsValue->setText(tr("Reports: %1 Bans: %2 Warnings: %3") - .arg(resp.total_reports()) - .arg(resp.total_bans()) - .arg(resp.total_warns())); + cardReportsValue->setText(QString::number(resp.total_reports())); + cardWarnsValue->setText(QString::number(resp.total_warns())); + cardBansValue->setText(QString::number(resp.total_bans())); if (resp.has_admin_notes() && !resp.admin_notes().empty()) { userInfoNotesEdit->setPlainText(QString::fromStdString(resp.admin_notes())); @@ -315,12 +393,21 @@ void TabModeration::userInfoResponse(const Response &response) } } +void TabModeration::applyAltsCount() +{ + altsCountValue->setText(tr("%1 related").arg(cachedAltsCount)); +} + 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); + connect(pend, &PendingCommand::finished, this, [this, userName](const Response &response) { + if (userName == currentUser) { + sessionsResponse(response); + } + }); client->sendCommand(pend); } @@ -352,7 +439,11 @@ 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); + connect(pend, &PendingCommand::finished, this, [this, userName](const Response &response) { + if (userName == currentUser) { + altsResponse(response); + } + }); client->sendCommand(pend); } @@ -360,10 +451,21 @@ void TabModeration::altsResponse(const Response &response) { altsTable->setRowCount(0); if (response.response_code() != Response::RespOk) { + cachedAltsCount = -1; + altsCountValue->setText(QStringLiteral("–")); return; } const Response_UserAlts &resp = response.GetExtension(Response_UserAlts::ext); + // The server always includes the frontier user as a depth-0 seed row. + int relatedCount = 0; + for (int i = 0; i < resp.alts_size(); ++i) { + if (!resp.alts(i).has_depth() || resp.alts(i).depth() > 0) { + ++relatedCount; + } + } + cachedAltsCount = relatedCount; + applyAltsCount(); altsTable->setRowCount(resp.alts_size()); for (int i = 0; i < resp.alts_size(); ++i) { const ServerInfo_UserAlt &alt = resp.alts(i); diff --git a/cockatrice/src/interface/widgets/tabs/tab_moderation.h b/cockatrice/src/interface/widgets/tabs/tab_moderation.h index ce23b5a7a..9ba123493 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_moderation.h +++ b/cockatrice/src/interface/widgets/tabs/tab_moderation.h @@ -5,6 +5,7 @@ #include #include +#include #include class AbstractClient; @@ -13,6 +14,7 @@ class QLabel; class QLineEdit; class QPushButton; class QTableWidget; +class QTabWidget; class QTextEdit; class Response; class TabSupervisor; @@ -36,6 +38,7 @@ public: private slots: void investigateUser(); + void openAltsGraph(); void userInfoResponse(const Response &response); void sessionsResponse(const Response &response); void altsResponse(const Response &response); @@ -53,10 +56,15 @@ private: void requestAlts(const QString &userName); void requestModeratorLogins(); void clearUserData(); + void applyUserInfo(); + void applyAltsCount(); [[nodiscard]] QString formatEpoch(quint64 ts) const; AbstractClient *client; QString currentUser; + Response_ReportUserInfo cachedUserInfo; + bool cachedUserInfoValid = false; + int cachedAltsCount = -1; QLineEdit *searchEdit; QPushButton *investigateButton; @@ -65,25 +73,25 @@ private: QPushButton *removeBannerButton; QGroupBox *userInfoGroup; - QLabel *userInfoNameLabel; QLabel *userInfoNameValue; - QLabel *userInfoRegisteredLabel; - QLabel *userInfoRegisteredValue; - QLabel *userInfoLastLoginLabel; - QLabel *userInfoLastLoginValue; - QLabel *userInfoStatusLabel; QLabel *userInfoStatusValue; - QLabel *userInfoCountsLabel; - QLabel *userInfoCountsValue; + QLabel *sublineLabel; + QGroupBox *cardReports; + QGroupBox *cardWarns; + QGroupBox *cardBans; + QGroupBox *altsCard; + QLabel *cardReportsValue; + QLabel *cardWarnsValue; + QLabel *cardBansValue; + QLabel *altsCountValue; QLabel *userInfoNotesLabel; QTextEdit *userInfoNotesEdit; - QGroupBox *altsGroup; + QTabWidget *sectionTabs; QTableWidget *altsTable; - QGroupBox *sessionsGroup; QTableWidget *sessionsTable; - QGroupBox *staffGroup; QTableWidget *staffTable; + QPushButton *openAltsButton; QPushButton *refreshStaffButton; }; diff --git a/cockatrice/src/interface/widgets/tabs/tab_report.cpp b/cockatrice/src/interface/widgets/tabs/tab_report.cpp index 7986286b3..d934a340b 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_report.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_report.cpp @@ -5,6 +5,7 @@ #include "../utility/report_utils.h" #include "abstract_client.h" #include "libcockatrice/protocol/pb/response.pb.h" +#include "report_status_delegate.h" #include "tab_supervisor.h" #include @@ -24,7 +25,7 @@ #include #include #include -#include +#include #include #include #include @@ -126,26 +127,43 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T connect(table, &QTableWidget::itemSelectionChanged, this, &TabReport::onSelectionChanged); table->setContextMenuPolicy(Qt::CustomContextMenu); connect(table, &QTableWidget::customContextMenuRequested, this, &TabReport::showTableContextMenu); + table->setItemDelegateForColumn(COL_STATUS, new ReportStatusDelegate(table)); userContextMenu = new UserContextMenu(tabSupervisor, this); connect(userContextMenu, &UserContextMenu::openMessageDialog, this, &TabReport::openMessageDialog); - descGroup = new QGroupBox; + reportHeaderName = new QLabel; + QFont displayFont = reportHeaderName->font(); + displayFont.setPointSize(displayFont.pointSize() + 6); + displayFont.setBold(true); + reportHeaderName->setFont(displayFont); + reportHeaderStatus = new QLabel; + QFont statusFont = reportHeaderStatus->font(); + statusFont.setBold(true); + reportHeaderStatus->setFont(statusFont); + reportHeaderSubline = new QLabel; + + auto *reportIdentityRow = new QHBoxLayout; + reportIdentityRow->addWidget(reportHeaderName); + reportIdentityRow->addStretch(); + reportIdentityRow->addWidget(reportHeaderStatus); + + auto *descPage = new QWidget; descriptionEdit = new QTextEdit; descriptionEdit->setReadOnly(true); - auto *descLayout = new QVBoxLayout(descGroup); + auto *descLayout = new QVBoxLayout(descPage); descLayout->setContentsMargins(4, 4, 4, 4); descLayout->addWidget(descriptionEdit); - chatGroup = new QGroupBox; + auto *chatPage = new QWidget; chatLogEdit = new QTextEdit; chatLogEdit->setReadOnly(true); applyChatLogFont(); connect(themeManager, &ThemeManager::fontsChanged, this, &TabReport::applyChatLogFont); - auto *chatLayout = new QVBoxLayout(chatGroup); + auto *chatLayout = new QVBoxLayout(chatPage); chatLayout->setContentsMargins(4, 4, 4, 4); chatLayout->addWidget(chatLogEdit); - commentsGroup = new QGroupBox; + auto *commentsPage = new QWidget; commentsEdit = new QTextEdit; commentsEdit->setReadOnly(true); auto *commentInputRow = new QHBoxLayout; @@ -156,12 +174,12 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T connect(commentInput, &QLineEdit::returnPressed, this, &TabReport::addComment); commentInputRow->addWidget(commentInput); commentInputRow->addWidget(commentButton); - auto *commentsLayout = new QVBoxLayout(commentsGroup); + auto *commentsLayout = new QVBoxLayout(commentsPage); commentsLayout->setContentsMargins(4, 4, 4, 4); commentsLayout->addWidget(commentsEdit); commentsLayout->addLayout(commentInputRow); - userContextGroup = new QGroupBox; + auto *userPage = new QWidget; userContextName = new QLabel; userContextAccountAge = new QLabel; userContextReports = new QLabel; @@ -180,7 +198,7 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T userContextWarnsLabel = new QLabel; userContextNotesLabel = new QLabel; userContextRecentReportsLabel = new QLabel; - auto *ucLayout = new QGridLayout(userContextGroup); + auto *ucLayout = new QGridLayout(userPage); ucLayout->setContentsMargins(8, 8, 8, 8); ucLayout->addWidget(userContextUserLabel, 0, 0); ucLayout->addWidget(userContextName, 0, 1, 1, 3); @@ -196,7 +214,6 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T 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); @@ -223,13 +240,11 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T } }); - 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); + detailTabs = new QTabWidget; + detailTabs->addTab(descPage, QString()); + detailTabs->addTab(chatPage, QString()); + detailTabs->addTab(commentsPage, QString()); + detailTabs->addTab(userPage, QString()); assignButton = new QPushButton; resolveButton = new QPushButton; @@ -261,12 +276,14 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T layout->addLayout(topBar); layout->addWidget(statsLabel); layout->addWidget(table, 2); - layout->addWidget(detailSplitter, 1); - layout->addWidget(userContextGroup); + layout->addLayout(reportIdentityRow); + layout->addWidget(reportHeaderSubline); + layout->addWidget(detailTabs, 2); layout->addWidget(statsGroup); layout->addLayout(actionBar); retranslateUi(); + clearReportHeader(); setActionsEnabled(false); refreshList(); @@ -293,18 +310,18 @@ void TabReport::retranslateUi() 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")); + detailTabs->setTabText(0, tr("Description")); + detailTabs->setTabText(1, tr("Chat Log Context")); + detailTabs->setTabToolTip(1, tr("Chat log attached by the reporter. It is captured from their client and may be " + "incomplete or edited.")); + detailTabs->setTabText(2, tr("Comments / Thread")); + detailTabs->setTabText(3, tr("Reported User")); 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:")); @@ -312,10 +329,18 @@ void TabReport::retranslateUi() 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()); + + if (headerReportId >= 0) { + for (const ServerInfo_Report &r : filteredReports) { + if (r.report_id() == headerReportId) { + updateReportHeader(r); + const QString status = QString::fromStdString(r.status()); + const bool canComment = (status == "open" || status == "assigned"); + commentInput->setPlaceholderText(canComment ? tr("Type a reply...") : tr("This report is closed.")); + break; + } + } + } statsGroup->setTitle(tr("Report Statistics")); @@ -408,7 +433,8 @@ void TabReport::reportListResponse(const Response &response) chatLogEdit->clear(); commentsEdit->clear(); setActionsEnabled(false); - userContextGroup->setVisible(false); + clearReportHeader(); + clearUserContext(); } } @@ -445,11 +471,14 @@ void TabReport::applyFilters() table->setSortingEnabled(false); table->setRowCount(filteredReports.size()); + QFont reportedFont = table->font(); + reportedFont.setBold(true); + 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); + COL_STATUS, COL_ASSIGNED, false); table->setItem(row, COL_REPORTER, new QTableWidgetItem(QString::fromStdString(r.reporter_name()))); @@ -458,12 +487,30 @@ void TabReport::applyFilters() table->setItem(row, COL_ROOM, new QTableWidgetItem(r.has_room_id() && r.room_id() > 0 ? QString::number(r.room_id()) : "")); + + if (auto *reportedItem = table->item(row, COL_REPORTED)) { + reportedItem->setFont(reportedFont); + } } table->setSortingEnabled(true); table->resizeColumnsToContents(); table->horizontalHeader()->setSectionResizeMode(COL_REPORTED, QHeaderView::Stretch); + if (headerReportId >= 0) { + bool stillVisible = false; + for (const ServerInfo_Report &r : filteredReports) { + if (r.report_id() == headerReportId) { + stillVisible = true; + break; + } + } + if (!stillVisible) { + clearReportHeader(); + clearUserContext(); + } + } + statusLabel->setText(tr("%1 report(s)").arg(filteredReports.size())); } @@ -490,6 +537,31 @@ void TabReport::updateStats() .arg(dismissed)); } +void TabReport::updateReportHeader(const ServerInfo_Report &report) +{ + reportHeaderName->setText(QString::fromStdString(report.reported_user_name())); + reportHeaderStatus->setText(report_utils::formatReportStatus(QString::fromStdString(report.status()))); + + QStringList parts; + parts << QString::fromStdString(report.category()); + parts << QDateTime::fromSecsSinceEpoch(report.report_time()).toLocalTime().toString("yyyy-MM-dd HH:mm"); + if (report.has_room_id() && report.room_id() > 0) { + parts << tr("Room %1").arg(report.room_id()); + } + if (report.game_id() > 0) { + parts << tr("Game %1").arg(report.game_id()); + } + reportHeaderSubline->setText(parts.join(QStringLiteral(" · "))); +} + +void TabReport::clearReportHeader() +{ + headerReportId = -1; + reportHeaderName->setText(tr("Select a report to inspect.")); + reportHeaderStatus->clear(); + reportHeaderSubline->clear(); +} + void TabReport::onSelectionChanged() { const int row = table->currentRow(); @@ -500,7 +572,8 @@ void TabReport::onSelectionChanged() commentInput->clear(); commentButton->setEnabled(false); setActionsEnabled(false); - userContextGroup->setVisible(false); + clearReportHeader(); + clearUserContext(); return; } @@ -509,6 +582,18 @@ void TabReport::onSelectionChanged() updateActionStates(); } +void TabReport::clearUserContext() +{ + lastRequestedUser.clear(); + userContextName->setText(tr("—")); + userContextAccountAge->clear(); + userContextReports->clear(); + userContextBans->clear(); + userContextWarns->clear(); + userContextNotes->setPlainText(tr("Select a report to inspect.")); + userContextRecentReports->clear(); +} + void TabReport::showTableContextMenu(const QPoint &pos) { QTableWidgetItem *clickedItem = table->itemAt(pos); @@ -546,12 +631,17 @@ void TabReport::loadReportDetails(int reportId) for (const ServerInfo_Report &r : filteredReports) { if (r.report_id() == reportId) { descriptionEdit->setPlainText(QString::fromStdString(r.description())); + updateReportHeader(r); + if (headerReportId != reportId) { + headerReportId = reportId; + detailTabs->setCurrentIndex(0); + } QString reportedUser = QString::fromStdString(r.reported_user_name()); if (!reportedUser.isEmpty()) { requestUserInfo(reportedUser); } else { - userContextGroup->setVisible(false); + clearUserContext(); } QString status = QString::fromStdString(r.status()); @@ -871,12 +961,11 @@ void TabReport::addCommentResponse(const Response &response) void TabReport::requestUserInfo(const QString &userName) { if (userName.isEmpty()) { - userContextGroup->setVisible(false); + clearUserContext(); return; } lastRequestedUser = userName; - userContextGroup->setVisible(true); userContextName->setText(userName); userContextAccountAge->setText(tr("Loading...")); userContextReports->clear(); diff --git a/cockatrice/src/interface/widgets/tabs/tab_report.h b/cockatrice/src/interface/widgets/tabs/tab_report.h index 43e778f4b..aea42648e 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_report.h +++ b/cockatrice/src/interface/widgets/tabs/tab_report.h @@ -16,8 +16,8 @@ class QGroupBox; class QLabel; class QLineEdit; class QPushButton; -class QSplitter; class QTableWidget; +class QTabWidget; class QTextEdit; class QTimer; class GameReplay; @@ -72,6 +72,9 @@ private: void applyFilters(); void updateStats(); void loadReportDetails(int reportId); + void updateReportHeader(const ServerInfo_Report &report); + void clearReportHeader(); + void clearUserContext(); AbstractClient *client; @@ -81,10 +84,10 @@ private: QPushButton *refreshButton; QLabel *statsLabel; QTableWidget *table; - QSplitter *detailSplitter; - QGroupBox *descGroup; - QGroupBox *chatGroup; - QGroupBox *commentsGroup; + QLabel *reportHeaderName; + QLabel *reportHeaderStatus; + QLabel *reportHeaderSubline; + QTabWidget *detailTabs; QTextEdit *descriptionEdit; QTextEdit *chatLogEdit; QTextEdit *commentsEdit; @@ -100,7 +103,6 @@ private: QTimer *refreshTimer; UserContextMenu *userContextMenu; - QGroupBox *userContextGroup; QLabel *userContextName; QLabel *userContextAccountAge; QLabel *userContextReports; @@ -125,6 +127,7 @@ private: QList allReports; QList filteredReports; + int headerReportId = -1; int detailsRequestedReportId = -1; int detailsRequestSeq = 0; int selectedReportIdBeforeRefresh = -1; diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index 45c320852..7647b8ca8 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -22,6 +22,7 @@ #include "tab.h" #include "tab_account.h" #include "tab_admin.h" +#include "tab_alts.h" #include "tab_card_art_rules.h" #include "tab_deck_editor.h" #include "tab_deck_storage.h" @@ -157,7 +158,7 @@ TabSupervisor::TabSupervisor(AbstractClient *_client, QMenu *tabsMenu, QWidget * : 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) + tabModeration(nullptr), tabAlts(nullptr), tabDeveloper(nullptr), isLocalGame(false) { setObjectName("tabSupervisor"); @@ -575,6 +576,7 @@ void TabSupervisor::startLocal(const QList &_clients) tabLog = nullptr; tabReport = nullptr; tabModeration = nullptr; + tabAlts = nullptr; tabDeveloper = nullptr; isLocalGame = true; userInfo = new ServerInfo_User; @@ -623,6 +625,9 @@ void TabSupervisor::stop() if (tabModeration) { tabModeration->close(); } + if (tabAlts) { + tabAlts->close(); + } if (tabCardArtRules) { tabCardArtRules->close(); } @@ -931,6 +936,21 @@ void TabSupervisor::openTabModeration(const QString &userName) aTabModeration->setChecked(true); } +void TabSupervisor::openTabAlts(const QString &userName) +{ + if (tabAlts) { + setCurrentWidget(tabAlts); + if (!userName.isEmpty()) { + tabAlts->investigate(userName); + } + return; + } + tabAlts = new TabAlts(this, client, userName); + myAddTab(tabAlts); + setCurrentWidget(tabAlts); + connect(tabAlts, &QObject::destroyed, this, [this] { tabAlts = nullptr; }); +} + void TabSupervisor::actTabDeveloper(bool checked) { if (checked && !tabDeveloper) { diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h index 9c7dc48d1..d529c5022 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h @@ -50,6 +50,7 @@ class TabAdmin; class TabMessage; class TabReport; class TabModeration; +class TabAlts; class TabAccount; class TabDeckEditor; class TabDeveloper; @@ -116,6 +117,7 @@ private: TabLog *tabLog; TabReport *tabReport; TabModeration *tabModeration; + TabAlts *tabAlts; TabDeveloper *tabDeveloper; QMap roomTabs; QMap gameTabs; @@ -209,6 +211,7 @@ public slots: /** Joins or spectates a game listed on a user's profile hover card. */ void joinGameFromUserCard(int gameId, int roomId, bool asSpectator); void openTabModeration(const QString &userName = {}); + void openTabAlts(const QString &userName = {}); void switchToFirstAvailableNetworkTab(); void maximizeMainWindow(); void actTabVisualDeckStorage(bool checked); diff --git a/cockatrice/src/interface/widgets/utility/epoch_format.h b/cockatrice/src/interface/widgets/utility/epoch_format.h new file mode 100644 index 000000000..977053997 --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/epoch_format.h @@ -0,0 +1,27 @@ +/** + * @file epoch_format.h + * @brief Shared timestamp formatting for the moderation tables. + */ + +#ifndef EPOCH_FORMAT_H +#define EPOCH_FORMAT_H + +#include +#include + +/** + * @brief Format a unix timestamp the way the moderation tabs show dates. + * + * @param ts Seconds since the epoch; 0 means "unknown". + * @param unknownText Label returned for 0, translated by the caller so the + * translation context stays with the tab. + */ +inline QString formatEpochTimestamp(quint64 ts, const QString &unknownText) +{ + if (ts == 0) { + return unknownText; + } + return QDateTime::fromSecsSinceEpoch(ts).toLocalTime().toString(QStringLiteral("yyyy-MM-dd HH:mm")); +} + +#endif // EPOCH_FORMAT_H diff --git a/cockatrice/src/interface/widgets/utility/pill_painter.cpp b/cockatrice/src/interface/widgets/utility/pill_painter.cpp new file mode 100644 index 000000000..6f317b951 --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/pill_painter.cpp @@ -0,0 +1,41 @@ +#include "pill_painter.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ +constexpr qreal PILL_ALPHA = 0.30; +} // namespace + +bool pill_painter::paint(QPainter *painter, + const QStyleOptionViewItem &option, + QPalette::ColorRole tintRole, + QPalette::ColorRole textRole, + const QString &text) +{ + const QRect pillRect = option.rect.adjusted(PAD_X / 2, PAD_Y / 2, -PAD_X / 2, -PAD_Y / 2); + if (pillRect.width() <= 0 || pillRect.height() <= 0) { + return false; + } + + if ((option.state & QStyle::State_Selected) == 0) { + painter->save(); + painter->setRenderHint(QPainter::Antialiasing, true); + QColor pill = option.palette.color(tintRole); + pill.setAlphaF(PILL_ALPHA); + painter->setPen(Qt::NoPen); + painter->setBrush(pill); + painter->drawRoundedRect(pillRect, pillRect.height() / 2.0, pillRect.height() / 2.0); + painter->restore(); + } + + QStyle *style = option.widget != nullptr ? option.widget->style() : QApplication::style(); + const QString elided = option.fontMetrics.elidedText(text, option.textElideMode, pillRect.width()); + style->drawItemText(painter, pillRect, Qt::AlignCenter, option.palette, true, elided, textRole); + return true; +} diff --git a/cockatrice/src/interface/widgets/utility/pill_painter.h b/cockatrice/src/interface/widgets/utility/pill_painter.h new file mode 100644 index 000000000..72e3fe0b4 --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/pill_painter.h @@ -0,0 +1,29 @@ +#ifndef PILL_PAINTER_H +#define PILL_PAINTER_H + +#include +#include + +class QPainter; +class QStyleOptionViewItem; + +namespace pill_painter +{ +constexpr int PAD_X = 10; +constexpr int PAD_Y = 4; + +/** + * Draws a translucent, palette-tinted rounded pill inside the option's rect and + * paints the text elided and centred on it, so badges follow the active theme. + * Returns false when the cell is too small to hold a pill, leaving the caller to + * fall back to default painting. + */ +bool paint(QPainter *painter, + const QStyleOptionViewItem &option, + QPalette::ColorRole tintRole, + QPalette::ColorRole textRole, + const QString &text); + +} // namespace pill_painter + +#endif // PILL_PAINTER_H diff --git a/cockatrice/src/interface/widgets/utility/report_utils.cpp b/cockatrice/src/interface/widgets/utility/report_utils.cpp index df2400b14..bdbc24f32 100644 --- a/cockatrice/src/interface/widgets/utility/report_utils.cpp +++ b/cockatrice/src/interface/widgets/utility/report_utils.cpp @@ -80,6 +80,23 @@ QString formatReportTime(qint64 secondsSinceEpoch) return dt.toString("yyyy-MM-dd hh:mm"); } +QString formatReportStatus(const QString &status) +{ + if (status == QStringLiteral("open")) { + return QObject::tr("Open"); + } + if (status == QStringLiteral("assigned")) { + return QObject::tr("Assigned"); + } + if (status == QStringLiteral("resolved")) { + return QObject::tr("Resolved"); + } + if (status == QStringLiteral("dismissed")) { + return QObject::tr("Dismissed"); + } + return status; +} + void fillReportTableRow(QTableWidget *table, int row, const ServerInfo_Report &report, @@ -89,7 +106,8 @@ void fillReportTableRow(QTableWidget *table, int categoryColumn, int gameIdColumn, int statusColumn, - int assignedColumn) + int assignedColumn, + bool colorizeStatus) { auto *idItem = new QTableWidgetItem(QString::number(report.report_id())); idItem->setData(Qt::UserRole, report.report_id()); @@ -102,8 +120,12 @@ void fillReportTableRow(QTableWidget *table, 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())); + const QString rawStatus = QString::fromStdString(report.status()); + auto *statusItem = new QTableWidgetItem(formatReportStatus(rawStatus)); + statusItem->setData(Qt::UserRole, rawStatus); + if (colorizeStatus) { + setReportStatusItemColor(statusItem, rawStatus); + } table->setItem(row, statusColumn, statusItem); table->setItem(row, assignedColumn, new QTableWidgetItem(QString::fromStdString(report.assigned_mod_name()))); diff --git a/cockatrice/src/interface/widgets/utility/report_utils.h b/cockatrice/src/interface/widgets/utility/report_utils.h index 32cc19eac..f81e01369 100644 --- a/cockatrice/src/interface/widgets/utility/report_utils.h +++ b/cockatrice/src/interface/widgets/utility/report_utils.h @@ -20,6 +20,7 @@ QFont reportLogFont(); QString formatReportCategory(const QString &category); QString formatReportTime(qint64 secondsSinceEpoch); +QString formatReportStatus(const QString &status); void fillReportTableRow(QTableWidget *table, int row, @@ -30,7 +31,8 @@ void fillReportTableRow(QTableWidget *table, int categoryColumn, int gameIdColumn, int statusColumn, - int assignedColumn); + int assignedColumn, + bool colorizeStatus = true); void renderReportDetails(QTextEdit *chatLogEdit, QTextEdit *commentsEdit, diff --git a/cockatrice/src/interface/widgets/utility/table_activation.cpp b/cockatrice/src/interface/widgets/utility/table_activation.cpp new file mode 100644 index 000000000..5633b9f58 --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/table_activation.cpp @@ -0,0 +1,28 @@ +#include "table_activation.h" + +#include +#include +#include + +namespace +{ +void addActivationShortcut(QTableWidget *table, + QObject *context, + int key, + const std::function &handler) +{ + auto *shortcut = new QShortcut(QKeySequence(key), table); + // The table's viewport takes focus, so match children as well. + shortcut->setContext(Qt::WidgetWithChildrenShortcut); + QObject::connect(shortcut, &QShortcut::activated, context, [table, handler] { handler(table->currentIndex()); }); +} +} // namespace + +void connectTableActivation(QTableWidget *table, + QObject *context, + const std::function &handler) +{ + QObject::connect(table, &QTableWidget::doubleClicked, context, handler); + addActivationShortcut(table, context, Qt::Key_Return, handler); + addActivationShortcut(table, context, Qt::Key_Enter, handler); +} diff --git a/cockatrice/src/interface/widgets/utility/table_activation.h b/cockatrice/src/interface/widgets/utility/table_activation.h new file mode 100644 index 000000000..f5038bdb0 --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/table_activation.h @@ -0,0 +1,32 @@ +/** + * @file table_activation.h + * @brief Row activation for tables that must not navigate on a single click. + */ + +#ifndef TABLE_ACTIVATION_H +#define TABLE_ACTIVATION_H + +#include + +class QModelIndex; +class QObject; +class QTableWidget; + +/** + * @brief Call @p handler on double-click or Enter, never on a single click. + * + * QAbstractItemView::activated fires on a single click on platforms where + * SH_ItemView_ActivateItemOnSingleClick is set, so navigation actions must not + * use it. This connects doubleClicked and installs Return/Enter shortcuts that + * forward the current index. + * + * @param table The table whose rows activate. + * @param context Object that owns the connections; the handler is disconnected + * with it. + * @param handler Receives the activated row's index, which may be invalid. + */ +void connectTableActivation(QTableWidget *table, + QObject *context, + const std::function &handler); + +#endif // TABLE_ACTIVATION_H diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.cpp index 464d62e4a..46330ac46 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.cpp @@ -15,9 +15,23 @@ #include #include +namespace +{ + +/** @brief Remove the filters that apply to the given color and attribute from the given model. */ +static void removeFilters(FilterTreeModel *filterModel, QChar color, CardFilter::Attr attr) +{ + for (const CardFilter *filter : filterModel->getFiltersOfType(attr)) { + if (filter->term() == color) { + filterModel->removeFilter(filter); + } + } +} +} // namespace + /** - * This widget provides a graphical control element for the CardFilter::Attr::AttrColor filters applied to the filter - * model. + * This widget provides a graphical control element for the CardFilter::Attr::AttrColor and + * CardFilter::Attr::AttrColorIdentity filters applied to the filter model. * @param parent The Qt Widget that this widget will parent to * @param _filterModel The filter model that this widget will manipulate */ @@ -59,11 +73,25 @@ void VisualDatabaseDisplayColorFilterWidget::retranslateUi() modeComboBox->blockSignals(true); modeComboBox->clear(); - modeComboBox->addItem(tr("Exact match"), QVariant::fromValue(FilterMode::ExactMatch)); modeComboBox->addItem(tr("Includes"), QVariant::fromValue(FilterMode::Includes)); + modeComboBox->addItem(tr("Excludes"), QVariant::fromValue(FilterMode::Excludes)); modeComboBox->addItem(tr("Include / Exclude"), QVariant::fromValue(FilterMode::IncludeExclude)); + modeComboBox->addItem(tr("Exact match"), QVariant::fromValue(FilterMode::ExactMatch)); + modeComboBox->addItem(tr("Color identity"), QVariant::fromValue(FilterMode::ColorIdentity)); + + modeComboBox->setItemData(0, tr("Cards containing any of the selected colors"), Qt::ToolTipRole); + modeComboBox->setItemData(1, tr("Cards containing none of the selected colors"), Qt::ToolTipRole); + modeComboBox->setItemData(2, tr("Cards containing any of the selected colors and none of the unselected colors"), + Qt::ToolTipRole); + modeComboBox->setItemData(3, tr("Cards being exactly of the selected colors"), Qt::ToolTipRole); + modeComboBox->setItemData(4, tr("Cards whose color identity fits within the selected colors"), Qt::ToolTipRole); + + if (currentMode == FilterMode::Custom) { + addCustomFilterModeItem(); + } modeComboBox->setToolTip(tr("How selected and unselected colors are combined in the filter")); + modeComboBox->setAccessibleName(tr("Color filter mode")); // Restore current mode const int index = modeComboBox->findData(QVariant::fromValue(currentMode)); @@ -74,6 +102,28 @@ void VisualDatabaseDisplayColorFilterWidget::retranslateUi() modeComboBox->blockSignals(false); } +void VisualDatabaseDisplayColorFilterWidget::setFilterMode(FilterMode filterMode) +{ + int index = modeComboBox->findData(QVariant::fromValue(filterMode)); + + if (index < 0 && filterMode == FilterMode::Custom) { + index = modeComboBox->count(); + addCustomFilterModeItem(); + } + + if (index >= 0) { + modeComboBox->setCurrentIndex(index); + } +} + +void VisualDatabaseDisplayColorFilterWidget::addCustomFilterModeItem() +{ + int customIndex = modeComboBox->count(); + modeComboBox->addItem(tr("Custom"), QVariant::fromValue(FilterMode::Custom)); + modeComboBox->setItemData(customIndex, tr("Cards matching an unknown combination of color filters"), + Qt::ToolTipRole); +} + void VisualDatabaseDisplayColorFilterWidget::handleColorToggled(QChar color, bool active) { if (active) { @@ -85,74 +135,24 @@ void VisualDatabaseDisplayColorFilterWidget::handleColorToggled(QChar color, boo void VisualDatabaseDisplayColorFilterWidget::addFilter(QChar color) { - QString colorString = color; - QString typeStr; + FilterModeInfo info = getFilterModeInfo(currentMode); - // Remove previous filters + removeFilters(filterModel, color, info.attr); - QList allColorFilters = filterModel->getFiltersOfType(CardFilter::Attr::AttrColor); - QList matchingFilters; - - for (const CardFilter *filter : allColorFilters) { - if (filter->term() == color) { - matchingFilters.append(filter); - } - } - - for (const CardFilter *filter : matchingFilters) { - filterModel->removeFilter(filter); - } - - // Add actual filter - - switch (currentMode) { - case FilterMode::ExactMatch: - filterModel->addFilter(new CardFilter(colorString, CardFilter::Type::TypeAnd, CardFilter::Attr::AttrColor)); - break; - - case FilterMode::Includes: - filterModel->addFilter(new CardFilter(colorString, CardFilter::Type::TypeOr, CardFilter::Attr::AttrColor)); - break; - - case FilterMode::IncludeExclude: - filterModel->addFilter(new CardFilter(colorString, CardFilter::Type::TypeOr, CardFilter::Attr::AttrColor)); - break; + if (info.includeType != CardFilter::Type::TypeEnd) { + filterModel->addFilter(new CardFilter(color, info.includeType, info.attr)); } } void VisualDatabaseDisplayColorFilterWidget::removeFilter(QChar color) { - QString colorString = color; + FilterModeInfo info = getFilterModeInfo(currentMode); - // Remove inclusion filters - QList allColorFilters = filterModel->getFiltersOfType(CardFilter::Attr::AttrColor); - QList matchingFilters; + removeFilters(filterModel, color, info.attr); - for (const CardFilter *filter : allColorFilters) { - if (filter->term() == color) { - matchingFilters.append(filter); - } - } - - for (const CardFilter *filter : matchingFilters) { - filterModel->removeFilter(filter); - } - - // Add exclusion filters if the mode demands it - switch (currentMode) { - case FilterMode::ExactMatch: - filterModel->addFilter( - new CardFilter(colorString, CardFilter::Type::TypeAndNot, CardFilter::Attr::AttrColor)); - break; - - case FilterMode::IncludeExclude: - filterModel->addFilter( - new CardFilter(colorString, CardFilter::Type::TypeAndNot, CardFilter::Attr::AttrColor)); - break; - - case FilterMode::Includes: - // No exclusion in Includes mode - break; + // Add exclusion filters if the mode demands it. + if (info.excludeType != CardFilter::Type::TypeEnd) { + filterModel->addFilter(new CardFilter(color, info.excludeType, info.attr)); } } @@ -169,6 +169,7 @@ void VisualDatabaseDisplayColorFilterWidget::updateFilterMode() filterModel->filterTree()->blockSignals(true); filterModel->clearFiltersOfType(CardFilter::Attr::AttrColor); + filterModel->clearFiltersOfType(CardFilter::Attr::AttrColorIdentity); const QList manaSymbolWidgets = findChildren(); @@ -211,52 +212,53 @@ QList VisualDatabaseDisplayColorFilterWidget::getActiveColors() void VisualDatabaseDisplayColorFilterWidget::syncWithFilterModel() { - QList allColorFilters = filterModel->getFiltersOfType(CardFilter::Attr::AttrColor); + QList filters; + filters.append(filterModel->getFiltersOfType(CardFilter::Attr::AttrColor)); + filters.append(filterModel->getFiltersOfType(CardFilter::Attr::AttrColorIdentity)); - QList manaSymbolWidgets = findChildren(); + // Re-derive the mode from the filters in the model, so that a loaded filter set selects the mode that + // produced it (or Custom when none could have). + currentMode = deriveFilterMode(filters, currentMode); - for (ManaSymbolWidget *manaSymbolWidget : manaSymbolWidgets) { + // Update the combobox. Add a Custom item only if we have a custom filter mode. + int customIndex = modeComboBox->findData(QVariant::fromValue(FilterMode::Custom)); + modeComboBox->blockSignals(true); + if (currentMode == FilterMode::Custom) { + if (customIndex < 0) { + customIndex = modeComboBox->count(); + addCustomFilterModeItem(); + } + modeComboBox->setCurrentIndex(customIndex); + } else { + if (customIndex >= 0) { + modeComboBox->removeItem(customIndex); + } + int index = modeComboBox->findData(QVariant::fromValue(currentMode)); + if (index >= 0) { + modeComboBox->setCurrentIndex(index); + } + } + modeComboBox->blockSignals(false); + + // Update the mana symbol widgets by looking for the current mode's include or exclude filters. + const FilterModeInfo modeInfo = getFilterModeInfo(currentMode); + + const bool useExclude = + modeInfo.includeType == CardFilter::Type::TypeEnd && modeInfo.excludeType != CardFilter::Type::TypeEnd; + const CardFilter::Type filterType = useExclude ? modeInfo.excludeType : modeInfo.includeType; + + filters = filterModel->getFiltersOfType(modeInfo.attr); + + for (ManaSymbolWidget *manaSymbolWidget : findChildren()) { bool found = false; - for (const CardFilter *filter : allColorFilters) { - if (manaSymbolWidget->getSymbolChar() == filter->term()) { - switch (currentMode) { - case FilterMode::ExactMatch: - switch (filter->type()) { - case CardFilter::Type::TypeAnd: - setManaSymbolActive(filter->term().at(0), true); - break; - default: - setManaSymbolActive(filter->term().at(0), false); - break; - } - break; - case FilterMode::Includes: - switch (filter->type()) { - case CardFilter::Type::TypeOr: - setManaSymbolActive(filter->term().at(0), true); - break; - default: - setManaSymbolActive(filter->term().at(0), false); - break; - } - break; - case FilterMode::IncludeExclude: - switch (filter->type()) { - case CardFilter::Type::TypeOr: - setManaSymbolActive(filter->term().at(0), true); - break; - default: - setManaSymbolActive(filter->term().at(0), false); - break; - } - break; - } + for (const CardFilter *filter : filters) { + if (filter->type() == filterType && manaSymbolWidget->getSymbolChar() == filter->term()) { found = true; + break; } } - if (!found) { - setManaSymbolActive(manaSymbolWidget->getSymbolChar(), false); - } + manaSymbolWidget->setColorActive(useExclude ? !found : found); + manaSymbolWidget->setDisabled(currentMode == FilterMode::Custom); } } diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.h b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.h index 184f1f96c..561c92d09 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.h +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.h @@ -10,8 +10,8 @@ #include #include #include -#include #include +#include #include #include @@ -38,15 +38,6 @@ private: int circleDiameter; }; -enum class FilterMode -{ - ExactMatch, // Only selected colors are included, all others are excluded. - Includes, // Include selected colors (OR condition). - IncludeExclude // Include selected colors (OR) and exclude unselected colors (AND NOT). -}; - -Q_DECLARE_METATYPE(FilterMode) - class VisualDatabaseDisplayColorFilterWidget : public QWidget { Q_OBJECT @@ -54,11 +45,13 @@ class VisualDatabaseDisplayColorFilterWidget : public QWidget public: explicit VisualDatabaseDisplayColorFilterWidget(QWidget *parent, FilterTreeModel *filterModel); void retranslateUi(); + void setFilterMode(FilterMode filterMode); signals: void filterModeChanged(FilterMode filterMode); private slots: + void addCustomFilterModeItem(); void handleColorToggled(QChar color, bool active); void addFilter(QChar color); void removeFilter(QChar color); 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 af5c74969..6d9b28619 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 @@ -160,6 +160,7 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, connect(clearFilterWidget, &QToolButton::clicked, this, [this] { filterModel->blockSignals(true); filterModel->filterTree()->blockSignals(true); + colorFilterWidget->setFilterMode(FilterMode::Includes); filterModel->clear(); filterModel->blockSignals(false); filterModel->filterTree()->blockSignals(false); 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 index 3611a8c1f..f9409f5f2 100644 --- 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 @@ -10,7 +10,7 @@ #include #include #include -#include +#include #include #include #include @@ -244,15 +244,15 @@ void VisualDeckStorageModel::startScan() // 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(); - + // The continuation is bound to this model as its context object: it runs on the UI thread, + // and Qt drops it if the model is destroyed first, so no result is delivered to a dead model. + QFuture scan = + QtConcurrent::run([currentDeckPath]() -> DeckScanResult { return scanDeckDirectory(currentDeckPath); }); + scan.then(this, [this, generation](const DeckScanResult &result) { 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()) { @@ -268,9 +268,6 @@ void VisualDeckStorageModel::startScan() beginLoad(row); } }); - - watcher->setFuture( - QtConcurrent::run([currentDeckPath]() -> DeckScanResult { return scanDeckDirectory(currentDeckPath); })); } void VisualDeckStorageModel::beginLoad(int row) @@ -286,32 +283,7 @@ void VisualDeckStorageModel::beginLoad(int row) 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 { + auto load = QtConcurrent::run([filePath, fmt]() -> std::optional { std::optional deck = DeckLoader::loadFromFile(filePath, fmt, false); if (!deck) { return std::nullopt; @@ -320,7 +292,28 @@ void VisualDeckStorageModel::beginLoad(int row) // keep the completion handler on the UI thread cheap. const QString colorIdentity = getDeckColorIdentity(deck->deckList, CardDatabaseManager::query()); return DeckLoadResult{std::move(*deck), QFileInfo(filePath).lastModified(), colorIdentity}; - })); + }); + + // Bound to this model as context: runs on the UI thread and is dropped if the model is destroyed first. + load.then(this, [this, filePath, generation](std::optional result) { + 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. + 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(); + }); } void VisualDeckStorageModel::schedulePendingLoadDrain() diff --git a/.github/CONTRIBUTING.md b/docs/CONTRIBUTING.md similarity index 100% rename from .github/CONTRIBUTING.md rename to docs/CONTRIBUTING.md diff --git a/doc/doxygen/DoxygenLayout.xml b/docs/doxygen/DoxygenLayout.xml similarity index 100% rename from doc/doxygen/DoxygenLayout.xml rename to docs/doxygen/DoxygenLayout.xml diff --git a/doc/doxygen/README.md b/docs/doxygen/README.md similarity index 80% rename from doc/doxygen/README.md rename to docs/doxygen/README.md index 230422303..ca4acc1fc 100644 --- a/doc/doxygen/README.md +++ b/docs/doxygen/README.md @@ -1,11 +1,11 @@ ## Building Doxygen Documentation We use [Doxygen](https://www.doxygen.nl) for our code documentation. -After installing the tool, you can build a HTML webpage into the `/docs` folder according to our [configuration](https://github.com/Cockatrice/Cockatrice/blob/master/Doxyfile): +After installing the tool, you can build a HTML webpage into the `/docs-output` folder according to our [configuration](https://github.com/Cockatrice/Cockatrice/blob/master/Doxyfile): ```bash doxygen Doxyfile ``` -Open the `index.html` file in the `/docs/html/` folder to browse the result locally. +Open the `index.html` file in the `/docs-output/html/` folder to browse the result locally. There is also an online version we host at the Cockatrice webpage: [cockatrice.github.io/docs](https://cockatrice.github.io/docs) Doxygen's own documenation can be found at [www.doxygen.nl/manual](https://www.doxygen.nl/manual). @@ -25,4 +25,4 @@ FULL_SIDEBAR = NO # Required for doxygen-awesome
-Cockatrice dedicated color adjustments are configured in the [cockatrice_docs_style.css](https://github.com/Cockatrice/Cockatrice/blob/master/doc/doxygen/css/cockatrice_docs_style.css) file that uses & overrides the theme definitions. +Cockatrice dedicated color adjustments are configured in the [cockatrice_docs_style.css](https://github.com/Cockatrice/Cockatrice/blob/master/docs/doxygen/css/cockatrice_docs_style.css) file that uses & overrides the theme definitions. diff --git a/doc/doxygen/css/cockatrice_docs_style.css b/docs/doxygen/css/cockatrice_docs_style.css similarity index 100% rename from doc/doxygen/css/cockatrice_docs_style.css rename to docs/doxygen/css/cockatrice_docs_style.css diff --git a/doc/doxygen/css/doxygen_style_old.css b/docs/doxygen/css/doxygen_style_old.css similarity index 100% rename from doc/doxygen/css/doxygen_style_old.css rename to docs/doxygen/css/doxygen_style_old.css diff --git a/doc/doxygen/css/hide_nav_sync.css b/docs/doxygen/css/hide_nav_sync.css similarity index 100% rename from doc/doxygen/css/hide_nav_sync.css rename to docs/doxygen/css/hide_nav_sync.css diff --git a/doc/doxygen/filters/proto2cpp.py b/docs/doxygen/filters/proto2cpp.py similarity index 100% rename from doc/doxygen/filters/proto2cpp.py rename to docs/doxygen/filters/proto2cpp.py diff --git a/doc/doxygen/groups/doc_groups.dox b/docs/doxygen/groups/doc_groups.dox similarity index 100% rename from doc/doxygen/groups/doc_groups.dox rename to docs/doxygen/groups/doc_groups.dox diff --git a/doc/doxygen/html/footer.html b/docs/doxygen/html/footer.html similarity index 100% rename from doc/doxygen/html/footer.html rename to docs/doxygen/html/footer.html diff --git a/doc/doxygen/html/header.html b/docs/doxygen/html/header.html similarity index 100% rename from doc/doxygen/html/header.html rename to docs/doxygen/html/header.html diff --git a/doc/doxygen/images/cockatrice_logo.svg b/docs/doxygen/images/cockatrice_logo.svg similarity index 100% rename from doc/doxygen/images/cockatrice_logo.svg rename to docs/doxygen/images/cockatrice_logo.svg diff --git a/doc/doxygen/images/screenshots/classic_database_display.png b/docs/doxygen/images/screenshots/classic_database_display.png similarity index 100% rename from doc/doxygen/images/screenshots/classic_database_display.png rename to docs/doxygen/images/screenshots/classic_database_display.png diff --git a/doc/doxygen/images/screenshots/classic_database_display_add_buttons.png b/docs/doxygen/images/screenshots/classic_database_display_add_buttons.png similarity index 100% rename from doc/doxygen/images/screenshots/classic_database_display_add_buttons.png rename to docs/doxygen/images/screenshots/classic_database_display_add_buttons.png diff --git a/doc/doxygen/images/screenshots/classic_deck_editor.png b/docs/doxygen/images/screenshots/classic_deck_editor.png similarity index 100% rename from doc/doxygen/images/screenshots/classic_deck_editor.png rename to docs/doxygen/images/screenshots/classic_deck_editor.png diff --git a/doc/doxygen/images/screenshots/deck_dock_deck_list.png b/docs/doxygen/images/screenshots/deck_dock_deck_list.png similarity index 100% rename from doc/doxygen/images/screenshots/deck_dock_deck_list.png rename to docs/doxygen/images/screenshots/deck_dock_deck_list.png diff --git a/doc/doxygen/images/screenshots/deck_dock_deck_list_buttons.png b/docs/doxygen/images/screenshots/deck_dock_deck_list_buttons.png similarity index 100% rename from doc/doxygen/images/screenshots/deck_dock_deck_list_buttons.png rename to docs/doxygen/images/screenshots/deck_dock_deck_list_buttons.png diff --git a/doc/doxygen/images/screenshots/deck_dock_deck_list_group_by.png b/docs/doxygen/images/screenshots/deck_dock_deck_list_group_by.png similarity index 100% rename from doc/doxygen/images/screenshots/deck_dock_deck_list_group_by.png rename to docs/doxygen/images/screenshots/deck_dock_deck_list_group_by.png diff --git a/doc/doxygen/images/screenshots/deckeditordeckdockwidget.png b/docs/doxygen/images/screenshots/deckeditordeckdockwidget.png similarity index 100% rename from doc/doxygen/images/screenshots/deckeditordeckdockwidget.png rename to docs/doxygen/images/screenshots/deckeditordeckdockwidget.png diff --git a/doc/doxygen/images/screenshots/printing_selector.png b/docs/doxygen/images/screenshots/printing_selector.png similarity index 100% rename from doc/doxygen/images/screenshots/printing_selector.png rename to docs/doxygen/images/screenshots/printing_selector.png diff --git a/doc/doxygen/images/screenshots/printing_selector_disable.png b/docs/doxygen/images/screenshots/printing_selector_disable.png similarity index 100% rename from doc/doxygen/images/screenshots/printing_selector_disable.png rename to docs/doxygen/images/screenshots/printing_selector_disable.png diff --git a/doc/doxygen/images/screenshots/printing_selector_enable.png b/docs/doxygen/images/screenshots/printing_selector_enable.png similarity index 100% rename from doc/doxygen/images/screenshots/printing_selector_enable.png rename to docs/doxygen/images/screenshots/printing_selector_enable.png diff --git a/doc/doxygen/images/screenshots/printing_selector_navigation.png b/docs/doxygen/images/screenshots/printing_selector_navigation.png similarity index 100% rename from doc/doxygen/images/screenshots/printing_selector_navigation.png rename to docs/doxygen/images/screenshots/printing_selector_navigation.png diff --git a/doc/doxygen/images/screenshots/printing_selector_options.png b/docs/doxygen/images/screenshots/printing_selector_options.png similarity index 100% rename from doc/doxygen/images/screenshots/printing_selector_options.png rename to docs/doxygen/images/screenshots/printing_selector_options.png diff --git a/doc/doxygen/images/screenshots/printing_selector_pre_providerid.png b/docs/doxygen/images/screenshots/printing_selector_pre_providerid.png similarity index 100% rename from doc/doxygen/images/screenshots/printing_selector_pre_providerid.png rename to docs/doxygen/images/screenshots/printing_selector_pre_providerid.png diff --git a/doc/doxygen/images/screenshots/release_channel_beta.png b/docs/doxygen/images/screenshots/release_channel_beta.png similarity index 100% rename from doc/doxygen/images/screenshots/release_channel_beta.png rename to docs/doxygen/images/screenshots/release_channel_beta.png diff --git a/doc/doxygen/images/screenshots/vde_deck_analytics.png b/docs/doxygen/images/screenshots/vde_deck_analytics.png similarity index 100% rename from doc/doxygen/images/screenshots/vde_deck_analytics.png rename to docs/doxygen/images/screenshots/vde_deck_analytics.png diff --git a/doc/doxygen/images/screenshots/vde_flat_layout_color_grouped.png b/docs/doxygen/images/screenshots/vde_flat_layout_color_grouped.png similarity index 100% rename from doc/doxygen/images/screenshots/vde_flat_layout_color_grouped.png rename to docs/doxygen/images/screenshots/vde_flat_layout_color_grouped.png diff --git a/doc/doxygen/images/screenshots/vde_flat_layout_type_grouped.png b/docs/doxygen/images/screenshots/vde_flat_layout_type_grouped.png similarity index 100% rename from doc/doxygen/images/screenshots/vde_flat_layout_type_grouped.png rename to docs/doxygen/images/screenshots/vde_flat_layout_type_grouped.png diff --git a/doc/doxygen/images/screenshots/vde_overlap_layout_type_grouped.png b/docs/doxygen/images/screenshots/vde_overlap_layout_type_grouped.png similarity index 100% rename from doc/doxygen/images/screenshots/vde_overlap_layout_type_grouped.png rename to docs/doxygen/images/screenshots/vde_overlap_layout_type_grouped.png diff --git a/doc/doxygen/images/screenshots/vde_sample_hand.png b/docs/doxygen/images/screenshots/vde_sample_hand.png similarity index 100% rename from doc/doxygen/images/screenshots/vde_sample_hand.png rename to docs/doxygen/images/screenshots/vde_sample_hand.png diff --git a/doc/doxygen/js/graph_toggle.js b/docs/doxygen/js/graph_toggle.js similarity index 100% rename from doc/doxygen/js/graph_toggle.js rename to docs/doxygen/js/graph_toggle.js diff --git a/doc/doxygen/pages/developer_documentation/card_database_schema_and_parsing.md b/docs/doxygen/pages/developer_documentation/card_database_schema_and_parsing.md similarity index 81% rename from doc/doxygen/pages/developer_documentation/card_database_schema_and_parsing.md rename to docs/doxygen/pages/developer_documentation/card_database_schema_and_parsing.md index 7b4bdafc3..8679b910b 100644 --- a/doc/doxygen/pages/developer_documentation/card_database_schema_and_parsing.md +++ b/docs/doxygen/pages/developer_documentation/card_database_schema_and_parsing.md @@ -7,8 +7,8 @@ The token file follows the schema of the card database (`tokens.xml`), too. Saved decks (`.xml`) use a different schema. -- [XSD Schema for `Card Databases`](https://github.com/Cockatrice/Cockatrice/blob/master/doc/carddatabase_v4/cards.xsd) v4 -- [XSD Schema for `Decks`](https://github.com/Cockatrice/Cockatrice/blob/master/doc/deck.xsd) v1 +- [XSD Schema for `Card Databases`](https://github.com/Cockatrice/Cockatrice/blob/master/docs/xsd/cards.xsd) v4 +- [XSD Schema for `Decks`](https://github.com/Cockatrice/Cockatrice/blob/master/docs/xsd/deck.xsd) v1 # Card Database Parsing diff --git a/doc/doxygen/pages/developer_documentation/displaying_cards.md b/docs/doxygen/pages/developer_documentation/displaying_cards.md similarity index 100% rename from doc/doxygen/pages/developer_documentation/displaying_cards.md rename to docs/doxygen/pages/developer_documentation/displaying_cards.md diff --git a/doc/doxygen/pages/developer_documentation/index.md b/docs/doxygen/pages/developer_documentation/index.md similarity index 100% rename from doc/doxygen/pages/developer_documentation/index.md rename to docs/doxygen/pages/developer_documentation/index.md diff --git a/doc/doxygen/pages/developer_documentation/loading_card_pictures.md b/docs/doxygen/pages/developer_documentation/loading_card_pictures.md similarity index 100% rename from doc/doxygen/pages/developer_documentation/loading_card_pictures.md rename to docs/doxygen/pages/developer_documentation/loading_card_pictures.md diff --git a/doc/doxygen/pages/developer_documentation/logging.md b/docs/doxygen/pages/developer_documentation/logging.md similarity index 100% rename from doc/doxygen/pages/developer_documentation/logging.md rename to docs/doxygen/pages/developer_documentation/logging.md diff --git a/doc/doxygen/pages/developer_documentation/network/client/game_event_handler.md b/docs/doxygen/pages/developer_documentation/network/client/game_event_handler.md similarity index 100% rename from doc/doxygen/pages/developer_documentation/network/client/game_event_handler.md rename to docs/doxygen/pages/developer_documentation/network/client/game_event_handler.md diff --git a/doc/doxygen/pages/developer_documentation/network/client/index.md b/docs/doxygen/pages/developer_documentation/network/client/index.md similarity index 100% rename from doc/doxygen/pages/developer_documentation/network/client/index.md rename to docs/doxygen/pages/developer_documentation/network/client/index.md diff --git a/doc/doxygen/pages/developer_documentation/network/client/player_event_handler.md b/docs/doxygen/pages/developer_documentation/network/client/player_event_handler.md similarity index 100% rename from doc/doxygen/pages/developer_documentation/network/client/player_event_handler.md rename to docs/doxygen/pages/developer_documentation/network/client/player_event_handler.md diff --git a/doc/doxygen/pages/developer_documentation/network/index.md b/docs/doxygen/pages/developer_documentation/network/index.md similarity index 100% rename from doc/doxygen/pages/developer_documentation/network/index.md rename to docs/doxygen/pages/developer_documentation/network/index.md diff --git a/doc/doxygen/pages/developer_documentation/network/protocol/index.md b/docs/doxygen/pages/developer_documentation/network/protocol/index.md similarity index 100% rename from doc/doxygen/pages/developer_documentation/network/protocol/index.md rename to docs/doxygen/pages/developer_documentation/network/protocol/index.md diff --git a/doc/doxygen/pages/developer_documentation/network/protocol/protocol_command_container.md b/docs/doxygen/pages/developer_documentation/network/protocol/protocol_command_container.md similarity index 100% rename from doc/doxygen/pages/developer_documentation/network/protocol/protocol_command_container.md rename to docs/doxygen/pages/developer_documentation/network/protocol/protocol_command_container.md diff --git a/doc/doxygen/pages/developer_documentation/network/protocol/protocol_game_command.md b/docs/doxygen/pages/developer_documentation/network/protocol/protocol_game_command.md similarity index 100% rename from doc/doxygen/pages/developer_documentation/network/protocol/protocol_game_command.md rename to docs/doxygen/pages/developer_documentation/network/protocol/protocol_game_command.md diff --git a/doc/doxygen/pages/developer_documentation/network/protocol/protocol_overview.md b/docs/doxygen/pages/developer_documentation/network/protocol/protocol_overview.md similarity index 100% rename from doc/doxygen/pages/developer_documentation/network/protocol/protocol_overview.md rename to docs/doxygen/pages/developer_documentation/network/protocol/protocol_overview.md diff --git a/doc/doxygen/pages/developer_documentation/network/protocol/protocol_response.md b/docs/doxygen/pages/developer_documentation/network/protocol/protocol_response.md similarity index 100% rename from doc/doxygen/pages/developer_documentation/network/protocol/protocol_response.md rename to docs/doxygen/pages/developer_documentation/network/protocol/protocol_response.md diff --git a/doc/doxygen/pages/developer_documentation/network/protocol/protocol_server_message.md b/docs/doxygen/pages/developer_documentation/network/protocol/protocol_server_message.md similarity index 100% rename from doc/doxygen/pages/developer_documentation/network/protocol/protocol_server_message.md rename to docs/doxygen/pages/developer_documentation/network/protocol/protocol_server_message.md diff --git a/doc/doxygen/pages/developer_documentation/primer_cards.md b/docs/doxygen/pages/developer_documentation/primer_cards.md similarity index 100% rename from doc/doxygen/pages/developer_documentation/primer_cards.md rename to docs/doxygen/pages/developer_documentation/primer_cards.md diff --git a/doc/doxygen/pages/developer_documentation/querying_the_card_database.md b/docs/doxygen/pages/developer_documentation/querying_the_card_database.md similarity index 100% rename from doc/doxygen/pages/developer_documentation/querying_the_card_database.md rename to docs/doxygen/pages/developer_documentation/querying_the_card_database.md diff --git a/doc/doxygen/pages/index.md b/docs/doxygen/pages/index.md similarity index 100% rename from doc/doxygen/pages/index.md rename to docs/doxygen/pages/index.md diff --git a/doc/doxygen/pages/user_documentation/card_pictures/custom_card_pictures.md b/docs/doxygen/pages/user_documentation/card_pictures/custom_card_pictures.md similarity index 100% rename from doc/doxygen/pages/user_documentation/card_pictures/custom_card_pictures.md rename to docs/doxygen/pages/user_documentation/card_pictures/custom_card_pictures.md diff --git a/doc/doxygen/pages/user_documentation/deck_management/creating_decks.md b/docs/doxygen/pages/user_documentation/deck_management/creating_decks.md similarity index 100% rename from doc/doxygen/pages/user_documentation/deck_management/creating_decks.md rename to docs/doxygen/pages/user_documentation/deck_management/creating_decks.md diff --git a/doc/doxygen/pages/user_documentation/deck_management/editing_decks.md b/docs/doxygen/pages/user_documentation/deck_management/editing_decks.md similarity index 100% rename from doc/doxygen/pages/user_documentation/deck_management/editing_decks.md rename to docs/doxygen/pages/user_documentation/deck_management/editing_decks.md diff --git a/doc/doxygen/pages/user_documentation/deck_management/editing_decks_classic.md b/docs/doxygen/pages/user_documentation/deck_management/editing_decks_classic.md similarity index 100% rename from doc/doxygen/pages/user_documentation/deck_management/editing_decks_classic.md rename to docs/doxygen/pages/user_documentation/deck_management/editing_decks_classic.md diff --git a/doc/doxygen/pages/user_documentation/deck_management/editing_decks_printings.md b/docs/doxygen/pages/user_documentation/deck_management/editing_decks_printings.md similarity index 100% rename from doc/doxygen/pages/user_documentation/deck_management/editing_decks_printings.md rename to docs/doxygen/pages/user_documentation/deck_management/editing_decks_printings.md diff --git a/doc/doxygen/pages/user_documentation/deck_management/editing_decks_visual.md b/docs/doxygen/pages/user_documentation/deck_management/editing_decks_visual.md similarity index 100% rename from doc/doxygen/pages/user_documentation/deck_management/editing_decks_visual.md rename to docs/doxygen/pages/user_documentation/deck_management/editing_decks_visual.md diff --git a/doc/doxygen/pages/user_documentation/deck_management/exporting_decks.md b/docs/doxygen/pages/user_documentation/deck_management/exporting_decks.md similarity index 100% rename from doc/doxygen/pages/user_documentation/deck_management/exporting_decks.md rename to docs/doxygen/pages/user_documentation/deck_management/exporting_decks.md diff --git a/doc/doxygen/pages/user_documentation/deck_management/importing_decks.md b/docs/doxygen/pages/user_documentation/deck_management/importing_decks.md similarity index 100% rename from doc/doxygen/pages/user_documentation/deck_management/importing_decks.md rename to docs/doxygen/pages/user_documentation/deck_management/importing_decks.md diff --git a/doc/doxygen/pages/user_documentation/deck_management/visual_database_display.md b/docs/doxygen/pages/user_documentation/deck_management/visual_database_display.md similarity index 100% rename from doc/doxygen/pages/user_documentation/deck_management/visual_database_display.md rename to docs/doxygen/pages/user_documentation/deck_management/visual_database_display.md diff --git a/docs/doxygen/pages/user_documentation/gameplay/revealing_cards.md b/docs/doxygen/pages/user_documentation/gameplay/revealing_cards.md new file mode 100644 index 000000000..5d47bd3b4 --- /dev/null +++ b/docs/doxygen/pages/user_documentation/gameplay/revealing_cards.md @@ -0,0 +1,11 @@ +@page revealing_cards Revealing Cards + +The **Hand** menu offers **Reveal a chosen number of random cards to...**. Choose **All players** or a specific player, then choose how many cards to reveal. The count dialog displays the chosen recipient. + +The server chooses distinct cards without replacement. Two copies of the same card are separate physical cards and can both be chosen. The operation does not reorder, move, or otherwise change the hand. + +When you choose a specific player, that player receives the selected card identities. Judges and spectators configured to see all hidden information retain their usual access. Ordinary participants other than the chosen player, including the hand's owner, receive a notice with the count and recipient, but do not learn which cards were chosen. Revealing to all players sends the selected identities to everyone, using the ordinary reveal window. + +If the hand becomes too small or the recipient leaves before the server processes the request, the server rejects the request and no cards are revealed. Cancelling the count dialog sends nothing. + +This operation requires a server that supports multiple random reveals. An older server rejects it without revealing any cards. Neither rejection is reported in the client; the reveal simply does not happen. The existing **Reveal random card to...** action and its shortcut remain available for single-card reveals. diff --git a/doc/doxygen/pages/user_documentation/index.md b/docs/doxygen/pages/user_documentation/index.md similarity index 91% rename from doc/doxygen/pages/user_documentation/index.md rename to docs/doxygen/pages/user_documentation/index.md index f4ecbe8a1..e95706727 100644 --- a/doc/doxygen/pages/user_documentation/index.md +++ b/docs/doxygen/pages/user_documentation/index.md @@ -7,6 +7,10 @@ - @subpage editing_decks - @subpage exporting_decks +## Gameplay + +- @subpage revealing_cards + ## Release Channels - @subpage beta_release diff --git a/doc/doxygen/pages/user_documentation/settings/beta.md b/docs/doxygen/pages/user_documentation/settings/beta.md similarity index 100% rename from doc/doxygen/pages/user_documentation/settings/beta.md rename to docs/doxygen/pages/user_documentation/settings/beta.md diff --git a/doc/doxygen/pages/user_documentation/settings/settings_folder.md b/docs/doxygen/pages/user_documentation/settings/settings_folder.md similarity index 100% rename from doc/doxygen/pages/user_documentation/settings/settings_folder.md rename to docs/doxygen/pages/user_documentation/settings/settings_folder.md diff --git a/doc/doxygen/pages/user_documentation/troubleshooting/enabling_debug_logs.md b/docs/doxygen/pages/user_documentation/troubleshooting/enabling_debug_logs.md similarity index 100% rename from doc/doxygen/pages/user_documentation/troubleshooting/enabling_debug_logs.md rename to docs/doxygen/pages/user_documentation/troubleshooting/enabling_debug_logs.md diff --git a/doc/doxygen/pages/user_documentation/troubleshooting/fixing_card_pictures.md b/docs/doxygen/pages/user_documentation/troubleshooting/fixing_card_pictures.md similarity index 100% rename from doc/doxygen/pages/user_documentation/troubleshooting/fixing_card_pictures.md rename to docs/doxygen/pages/user_documentation/troubleshooting/fixing_card_pictures.md diff --git a/doc/doxygen/theme b/docs/doxygen/theme similarity index 100% rename from doc/doxygen/theme rename to docs/doxygen/theme diff --git a/doc/carddatabase_v4/cards.xsd b/docs/xsd/cards.xsd similarity index 100% rename from doc/carddatabase_v4/cards.xsd rename to docs/xsd/cards.xsd diff --git a/doc/carddatabase_v3/cards.xsd b/docs/xsd/cards_v3.xsd similarity index 100% rename from doc/carddatabase_v3/cards.xsd rename to docs/xsd/cards_v3.xsd diff --git a/doc/deck.xsd b/docs/xsd/deck.xsd similarity index 100% rename from doc/deck.xsd rename to docs/xsd/deck.xsd diff --git a/gcovr.cfg b/gcovr.cfg index ac893b6a4..7b25dbc8d 100644 --- a/gcovr.cfg +++ b/gcovr.cfg @@ -32,7 +32,7 @@ exclude = ^tests/ # Vendored third-party code is not ours to cover and is never going to move the number. exclude = ^vcpkg/ -exclude = ^doc/doxygen/theme/ +exclude = ^docs/doxygen/theme/ exclude = ^libcockatrice_rng/libcockatrice/rng/sfmt/ exclude = ^libcockatrice_utility/libcockatrice/utility/peglib\.h$ exclude = ^oracle/src/lzma/ diff --git a/libcockatrice_card/libcockatrice/card/card_info.cpp b/libcockatrice_card/libcockatrice/card/card_info.cpp index 90bfd3f44..36f0cdeb8 100644 --- a/libcockatrice_card/libcockatrice/card/card_info.cpp +++ b/libcockatrice_card/libcockatrice/card/card_info.cpp @@ -316,6 +316,10 @@ void CardInfo::setColors(const QString &value) { setProperty(Mtg::Colors, value); } +QString CardInfo::getColorIdentity() const +{ + return getProperty(Mtg::ColorIdentity); +} QString CardInfo::getLoyalty() const { return getProperty(Mtg::Loyalty); diff --git a/libcockatrice_card/libcockatrice/card/card_info.h b/libcockatrice_card/libcockatrice/card/card_info.h index 5452ef98b..075b5b264 100644 --- a/libcockatrice_card/libcockatrice/card/card_info.h +++ b/libcockatrice_card/libcockatrice/card/card_info.h @@ -469,6 +469,7 @@ public: [[nodiscard]] QString getCmc() const; [[nodiscard]] QString getColors() const; void setColors(const QString &value); + [[nodiscard]] QString getColorIdentity() const; [[nodiscard]] QString getLoyalty() const; [[nodiscard]] QString getMainCardType() const; [[nodiscard]] QString getManaCost() const; 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 8d5735db7..99440628a 100644 --- a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp +++ b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp @@ -33,7 +33,7 @@ class ICardSetPriorityController; #define COCKATRICE_XML3_TAGNAME "cockatrice_carddatabase" #define COCKATRICE_XML3_TAGVER 3 #define COCKATRICE_XML3_SCHEMALOCATION \ - "https://raw.githubusercontent.com/Cockatrice/Cockatrice/master/doc/carddatabase_v3/cards.xsd" + "https://raw.githubusercontent.com/Cockatrice/Cockatrice/master/docs/xsd/cards_v3.xsd" CockatriceXml3Parser::CockatriceXml3Parser(ICardSetPriorityController *_cardSetPriorityController) : ICardDatabaseParser(_cardSetPriorityController) 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 3965b80f0..84fc27155 100644 --- a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp +++ b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp @@ -36,7 +36,7 @@ class ICardSetPriorityController; #define COCKATRICE_XML4_TAGNAME "cockatrice_carddatabase" #define COCKATRICE_XML4_TAGVER 4 #define COCKATRICE_XML4_SCHEMALOCATION \ - "https://raw.githubusercontent.com/Cockatrice/Cockatrice/master/doc/carddatabase_v4/cards.xsd" + "https://raw.githubusercontent.com/Cockatrice/Cockatrice/master/docs/xsd/cards.xsd" CockatriceXml4Parser::CockatriceXml4Parser(ICardSetPriorityController *_cardSetPriorityController) : ICardDatabaseParser(_cardSetPriorityController) diff --git a/libcockatrice_filters/CMakeLists.txt b/libcockatrice_filters/CMakeLists.txt index 070c04637..1b4303fc9 100644 --- a/libcockatrice_filters/CMakeLists.txt +++ b/libcockatrice_filters/CMakeLists.txt @@ -1,10 +1,10 @@ -set(HEADERS libcockatrice/filters/filter_card.h libcockatrice/filters/filter_string.h - libcockatrice/filters/filter_tree.h +set(HEADERS libcockatrice/filters/filter_card.h libcockatrice/filters/filter_mode.h + libcockatrice/filters/filter_string.h libcockatrice/filters/filter_tree.h ) add_library( - libcockatrice_filters STATIC ${HEADERS} libcockatrice/filters/filter_card.cpp libcockatrice/filters/filter_string.cpp - libcockatrice/filters/filter_tree.cpp + libcockatrice_filters STATIC ${HEADERS} libcockatrice/filters/filter_card.cpp libcockatrice/filters/filter_mode.cpp + libcockatrice/filters/filter_string.cpp libcockatrice/filters/filter_tree.cpp ) add_dependencies(libcockatrice_filters libcockatrice_card) diff --git a/libcockatrice_filters/libcockatrice/filters/filter_card.cpp b/libcockatrice_filters/libcockatrice/filters/filter_card.cpp index 26cddd53c..8af39c098 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_card.cpp +++ b/libcockatrice_filters/libcockatrice/filters/filter_card.cpp @@ -69,6 +69,8 @@ const QString CardFilter::attrName(Attr a) return tr("Type"); case AttrColor: return tr("Color"); + case AttrColorIdentity: + return tr("Color Identity"); case AttrText: return tr("Text"); case AttrSet: diff --git a/libcockatrice_filters/libcockatrice/filters/filter_card.h b/libcockatrice_filters/libcockatrice/filters/filter_card.h index dbeffd18f..bc613fc9c 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_card.h +++ b/libcockatrice_filters/libcockatrice/filters/filter_card.h @@ -32,6 +32,7 @@ public: { AttrCmc = 0, AttrColor, + AttrColorIdentity, AttrLoyalty, AttrManaCost, AttrName, diff --git a/libcockatrice_filters/libcockatrice/filters/filter_mode.cpp b/libcockatrice_filters/libcockatrice/filters/filter_mode.cpp new file mode 100644 index 000000000..356ccf608 --- /dev/null +++ b/libcockatrice_filters/libcockatrice/filters/filter_mode.cpp @@ -0,0 +1,77 @@ +/** + * @file filter_mode.cpp + * @ingroup Filters + */ + +#include "libcockatrice/filters/filter_mode.h" + +#include + +FilterModeInfo getFilterModeInfo(FilterMode filterMode) +{ + switch (filterMode) { + case FilterMode::Includes: + return {CardFilter::Type::TypeOr, CardFilter::Type::TypeEnd, CardFilter::Attr::AttrColor}; + case FilterMode::Excludes: + return {CardFilter::Type::TypeAndNot, CardFilter::Type::TypeEnd, CardFilter::Attr::AttrColor}; + case FilterMode::IncludeExclude: + return {CardFilter::Type::TypeOr, CardFilter::Type::TypeAndNot, CardFilter::Attr::AttrColor}; + case FilterMode::ExactMatch: + return {CardFilter::Type::TypeAnd, CardFilter::Type::TypeAndNot, CardFilter::Attr::AttrColor}; + case FilterMode::ColorIdentity: + return {CardFilter::Type::TypeEnd, CardFilter::Type::TypeAndNot, CardFilter::Attr::AttrColorIdentity}; + case FilterMode::Custom: + case FilterMode::End: + break; + } + return {CardFilter::Type::TypeEnd, CardFilter::Type::TypeEnd, CardFilter::Attr::AttrEnd}; +} + +FilterMode deriveFilterMode(const QList &filters, FilterMode currentMode) +{ + constexpr int modeCount = static_cast(FilterMode::End); + bool modeImpossible[modeCount] = {}; + const QString colors = "WUBRG"; + + for (int i = 0; i < modeCount; ++i) { + const FilterModeInfo modeInfo = getFilterModeInfo(static_cast(i)); + + // Remove the modes that cannot create one of the filters. + for (const CardFilter *filter : filters) { + if (filter->attr() != modeInfo.attr || + (filter->type() != modeInfo.includeType && filter->type() != modeInfo.excludeType)) { + modeImpossible[i] = true; + break; + } + } + + // Modes that have both an include and an exclude filter should always have a filter for each color. + if (modeInfo.includeType != CardFilter::Type::TypeEnd && modeInfo.excludeType != CardFilter::Type::TypeEnd && + !modeImpossible[i]) { + for (const QChar &color : colors) { + int count = 0; + for (const CardFilter *filter : filters) { + count += filter->term() == color; + } + if (count != 1) { + modeImpossible[i] = true; + break; + } + } + } + } + + // Keep the current mode if it can produce the filters, so that a live mode is never swapped for another. + // Switching to and from the custom mode is always possible. + if (!modeImpossible[static_cast(currentMode)] && currentMode != FilterMode::Custom) { + return currentMode; + } + + for (int i = 0; i < modeCount; ++i) { + if (!modeImpossible[i] || i == static_cast(FilterMode::Custom)) { + return static_cast(i); + } + } + + return FilterMode::Custom; +} diff --git a/libcockatrice_filters/libcockatrice/filters/filter_mode.h b/libcockatrice_filters/libcockatrice/filters/filter_mode.h new file mode 100644 index 000000000..f7662d662 --- /dev/null +++ b/libcockatrice_filters/libcockatrice/filters/filter_mode.h @@ -0,0 +1,62 @@ +/** + * @file filter_mode.h + * @ingroup Filters + */ + +#ifndef LIBCOCKATRICE_FILTERS_FILTER_MODE_H +#define LIBCOCKATRICE_FILTERS_FILTER_MODE_H + +#include "libcockatrice/filters/filter_card.h" + +#include +#include +#include + +/** + * @brief The way selected and unselected colors are combined into card filters. + */ +enum class FilterMode +{ + Includes = 0, // Include selected colors (OR condition). + Excludes, // Exclude selected colors (AND NOT). + IncludeExclude, // Include selected colors (OR) and exclude unselected colors (AND NOT). + ExactMatch, // Only selected colors are included, all others are excluded. + ColorIdentity, // Keep cards whose color identity is a subset of the selected colors (stored as AND NOT for each + // unselected color). + Custom, // The color filters do not match any of the above modes. Not included in the combo box. + End +}; + +Q_DECLARE_METATYPE(FilterMode) + +/** + * @brief The filters a single color contributes in a given mode. + */ +struct FilterModeInfo +{ + // When a color symbol is enabled, add filters of this type. Skipped if equal to CardFilter::Type::TypeEnd. + CardFilter::Type includeType; + + // When a color symbol is disabled, add filters of this type. Skipped if equal to CardFilter::Type::TypeEnd. + CardFilter::Type excludeType; + + // The attribute to filter for when adding the filters. + CardFilter::Attr attr; +}; + +/** @brief Return the filters the given mode contributes for one color. */ +FilterModeInfo getFilterModeInfo(FilterMode filterMode); + +/** + * @brief Determine the mode that produced the given color filters. + * + * Returns @p currentMode unchanged when that mode can still produce @p filters, so that a live mode is never + * replaced by an equally valid one. Otherwise returns the first mode that can produce them, falling back to + * FilterMode::Custom when none can. + * + * @param filters The filters on CardFilter::Attr::AttrColor and CardFilter::Attr::AttrColorIdentity. + * @param currentMode The mode currently selected in the UI. + */ +FilterMode deriveFilterMode(const QList &filters, FilterMode currentMode); + +#endif // LIBCOCKATRICE_FILTERS_FILTER_MODE_H diff --git a/libcockatrice_filters/libcockatrice/filters/filter_string.cpp b/libcockatrice_filters/libcockatrice/filters/filter_string.cpp index 081ada824..f7ca82344 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_string.cpp +++ b/libcockatrice_filters/libcockatrice/filters/filter_string.cpp @@ -409,7 +409,7 @@ static void setupParserRules() const bool identity = sv.tokens[0].empty() || sv.tokens[0][0] != 'i'; if (sv.tokens[1][0] == ':') { return [=](const CardData &x) { - const QString match = identity ? x->getColors() : x->getProperty("coloridentity"); + const QString match = identity ? x->getColors() : x->getColorIdentity(); if (parts.contains("m") && match.length() < 2) { return false; } @@ -427,7 +427,7 @@ static void setupParserRules() } return [=](const CardData &x) { - const QString match = identity ? x->getColors() : x->getProperty("colorIdentity"); + const QString match = identity ? x->getColors() : x->getColorIdentity(); if (parts.contains("m") && match.length() < 2) { return false; } @@ -506,6 +506,14 @@ FilterString::FilterString() _error = "Not initialized"; } +QString FilterString::error() const +{ + if (_errorColumn == 0) { + return _error; + } + return tr("Error at position %1: %2").arg(_errorColumn).arg(_error); +} + FilterString::FilterString(const QString &expr, const CardSearchLanguage &searchLanguage) { QByteArray ba = expr.simplified().toUtf8(); @@ -522,7 +530,8 @@ FilterString::FilterString(const QString &expr, const CardSearchLanguage &search searchLanguageContext = searchLanguage; search.set_logger([&](size_t /*ln*/, size_t col, const std::string &msg) { - _error = QString("Error at position %1: %2").arg(col).arg(QString::fromStdString(msg)); + _error = QString::fromStdString(msg); + _errorColumn = col; }); if (!search.parse(ba.data(), result)) { diff --git a/libcockatrice_filters/libcockatrice/filters/filter_string.h b/libcockatrice_filters/libcockatrice/filters/filter_string.h index 2abe95201..34e2bfb35 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_string.h +++ b/libcockatrice_filters/libcockatrice/filters/filter_string.h @@ -7,6 +7,7 @@ #ifndef FILTER_STRING_H #define FILTER_STRING_H +#include #include #include #include @@ -33,6 +34,8 @@ typedef AstBase Ast; class FilterString { + Q_DECLARE_TR_FUNCTIONS(FilterString) + public: FilterString(); explicit FilterString(const QString &exp, const CardSearchLanguage &searchLanguage = {}); @@ -45,18 +48,18 @@ public: return result(card); } - bool valid() + [[nodiscard]] bool valid() const { return _error.isEmpty(); } - QString error() - { - return _error; - } + [[nodiscard]] QString error() const; private: + // Untranslated parser message; error() adds the translated prefix, logging uses it as-is. QString _error; + // Column reported by the parser, or 0 when the message carries no position. + size_t _errorColumn = 0; Filter result; }; diff --git a/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp b/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp index f12f87181..817c7298d 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp +++ b/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp @@ -176,23 +176,24 @@ FilterItem::FilterItem(QString trm, FilterItemList *parent) : p(parent), term(st lowercaseTerm = term.toLower(); switch (attr()) { - case CardFilter::AttrColor: { - QString converted = term.trimmed(); + case CardFilter::AttrColor: + case CardFilter::AttrColorIdentity: { + QString converted = term.trimmed().toLower(); - converted.replace("green", "g", Qt::CaseInsensitive); - converted.replace("grn", "g", Qt::CaseInsensitive); - converted.replace("blue", "u", Qt::CaseInsensitive); - converted.replace("blu", "u", Qt::CaseInsensitive); - converted.replace("black", "b", Qt::CaseInsensitive); - converted.replace("blk", "b", Qt::CaseInsensitive); - converted.replace("red", "r", Qt::CaseInsensitive); - converted.replace("white", "w", Qt::CaseInsensitive); - converted.replace("wht", "w", Qt::CaseInsensitive); - converted.replace("colorless", "c", Qt::CaseInsensitive); - converted.replace("colourless", "c", Qt::CaseInsensitive); - converted.replace("none", "c", Qt::CaseInsensitive); + converted.replace("green", "g"); + converted.replace("grn", "g"); + converted.replace("blue", "u"); + converted.replace("blu", "u"); + converted.replace("black", "b"); + converted.replace("blk", "b"); + converted.replace("red", "r"); + converted.replace("white", "w"); + converted.replace("wht", "w"); + converted.replace("colorless", "c"); + converted.replace("colourless", "c"); + converted.replace("none", "c"); - converted.replace(QString(" "), QString(""), Qt::CaseInsensitive); + converted.replace(QString(" "), QString("")); convertedColorTerm = converted; break; @@ -358,12 +359,17 @@ bool FilterItem::acceptSubType(const CardInfoPtr &info) const return subType.contains(term, Qt::CaseInsensitive); } -bool FilterItem::acceptColor(const CardInfoPtr &info) const +/** + * @brief Return true when every letter of the given term appears in the given colors string. + * If the term is equal to "c", then only return true if the color string is empty. + * + * @param term The letters which should be included, or "c" + * @param colors The string to check for letters in + */ +static bool matchesColor(const QString &term, const QString &colors) { - const QString colors = info->getColors(); - // Colorless card filter - if (convertedColorTerm.toLower() == "c" && colors.length() < 1) { + if (term == "c" && colors.length() < 1) { return true; } @@ -372,13 +378,23 @@ bool FilterItem::acceptColor(const CardInfoPtr &info) const * then we should match all of them to the card's colors */ int match_count = 0; - for (const QChar &color : convertedColorTerm) { + for (const QChar &color : term) { if (colors.contains(color, Qt::CaseInsensitive)) { match_count++; } } - return match_count == convertedColorTerm.length(); + return match_count == term.length(); +} + +bool FilterItem::acceptColor(const CardInfoPtr &info) const +{ + return matchesColor(convertedColorTerm, info->getColors()); +} + +bool FilterItem::acceptColorIdentity(const CardInfoPtr &info) const +{ + return matchesColor(convertedColorTerm, info->getColorIdentity()); } bool FilterItem::acceptText(const CardInfoPtr &info, const CardSearchLanguage &searchLanguage) const @@ -540,6 +556,8 @@ bool FilterItem::acceptCardAttr(const CardInfoPtr &info, return acceptType(info); case CardFilter::AttrColor: return acceptColor(info); + case CardFilter::AttrColorIdentity: + return acceptColorIdentity(info); case CardFilter::AttrText: return acceptText(info, searchLanguage); case CardFilter::AttrSet: diff --git a/libcockatrice_filters/libcockatrice/filters/filter_tree.h b/libcockatrice_filters/libcockatrice/filters/filter_tree.h index 85a57304d..f1a5803e5 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_tree.h +++ b/libcockatrice_filters/libcockatrice/filters/filter_tree.h @@ -244,6 +244,7 @@ public: [[nodiscard]] bool acceptMainType(const CardInfoPtr &info) const; [[nodiscard]] bool acceptSubType(const CardInfoPtr &info) const; [[nodiscard]] bool acceptColor(const CardInfoPtr &info) const; + [[nodiscard]] bool acceptColorIdentity(const CardInfoPtr &info) const; [[nodiscard]] bool acceptText(const CardInfoPtr &info, const CardSearchLanguage &searchLanguage) const; [[nodiscard]] bool acceptSet(const CardInfoPtr &info) const; [[nodiscard]] bool acceptManaCost(const CardInfoPtr &info) const; diff --git a/libcockatrice_game/libcockatrice/game/player/player_actions.cpp b/libcockatrice_game/libcockatrice/game/player/player_actions.cpp index 90823fec6..1e854cbd4 100644 --- a/libcockatrice_game/libcockatrice/game/player/player_actions.cpp +++ b/libcockatrice_game/libcockatrice/game/player/player_actions.cpp @@ -1756,6 +1756,17 @@ void PlayerActions::actRevealRandomHandCard(int revealToPlayerId) sendGameCommand(cmd); } +void PlayerActions::actRevealRandomHandCards(int revealToPlayerId, int amount) +{ + Command_RevealRandomCards cmd; + if (revealToPlayerId != -1) { + cmd.set_player_id(revealToPlayerId); + } + cmd.set_zone_name(ZoneNames::HAND); + cmd.set_number_of_cards(amount); + sendGameCommand(cmd); +} + void PlayerActions::actRevealLibrary(int revealToPlayerId) { Command_RevealCards cmd; diff --git a/libcockatrice_game/libcockatrice/game/player/player_actions.h b/libcockatrice_game/libcockatrice/game/player/player_actions.h index 7ed52b9a1..81d18315c 100644 --- a/libcockatrice_game/libcockatrice/game/player/player_actions.h +++ b/libcockatrice_game/libcockatrice/game/player/player_actions.h @@ -279,6 +279,7 @@ public slots: void actReveal(QList selectedCards, QAction *action); void actRevealHand(int revealToPlayerId); void actRevealRandomHandCard(int revealToPlayerId); + void actRevealRandomHandCards(int revealToPlayerId, int amount); void actRevealLibrary(int revealToPlayerId); void actSortHand(); diff --git a/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.cpp b/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.cpp index db1fa32c3..f5fcd8786 100644 --- a/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.cpp +++ b/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.cpp @@ -7,6 +7,7 @@ #include #include #include +#include class PlayerLogic; class QObject; @@ -36,6 +37,11 @@ bool ZoneViewZoneLogic::getCloseEmptyCardView() const return interfaceSettings ? interfaceSettings->getCloseEmptyCardView() : true; } +bool ZoneViewZoneLogic::isLibraryEdgeView() const +{ + return origZone != nullptr && numberCards > 0 && origZone->getName() == ZoneNames::DECK; +} + /** * Checks if inserting a card at the given position requires an actual new card to be created and added to the view. * Also does any cardId updates that would be required if a card is inserted in that position. diff --git a/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.h b/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.h index a29e543a3..ee8156058 100644 --- a/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.h +++ b/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.h @@ -82,6 +82,27 @@ public: return isReversed; } + /** + * @brief Whether this view is a limited look at one end of a library. + * + * Only scry/surveil-style views of the top or bottom cards of a deck + * qualify. Full-zone views, revealed-card views and views of other zones + * do not, because their first and last cards have no library edge meaning. + */ + [[nodiscard]] bool isLibraryEdgeView() const; + + /** @brief Whether the first card in the view is the top card of the library. */ + [[nodiscard]] bool showsTopOfLibrary() const + { + return isLibraryEdgeView() && !isReversed; + } + + /** @brief Whether the last card in the view is the bottom card of the library. */ + [[nodiscard]] bool showsBottomOfLibrary() const + { + return isLibraryEdgeView() && isReversed; + } + CardZoneLogic *getOriginalZone() const { return origZone; 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 dcd14d14c..f7739bc85 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 @@ -405,6 +405,13 @@ Response::ResponseCode Server_AbstractParticipant::cmdRevealCards(const Command_ return Response::RespFunctionNotAllowed; } +Response::ResponseCode Server_AbstractParticipant::cmdRevealRandomCards(const Command_RevealRandomCards & /*cmd*/, + ResponseContainer & /*rc*/, + GameEventStorage & /*ges*/) +{ + return Response::RespFunctionNotAllowed; +} + Response::ResponseCode Server_AbstractParticipant::cmdChangeZoneProperties(const Command_ChangeZoneProperties & /*cmd*/, ResponseContainer & /*rc*/, GameEventStorage & /*ges*/) @@ -544,6 +551,9 @@ Server_AbstractParticipant::processGameCommand(const GameCommand &command, Respo case GameCommand::REVEAL_CARDS: return cmdRevealCards(command.GetExtension(Command_RevealCards::ext), rc, ges); break; + case GameCommand::REVEAL_RANDOM_CARDS: + return cmdRevealRandomCards(command.GetExtension(Command_RevealRandomCards::ext), rc, ges); + break; case GameCommand::MOVE_CARD: return cmdMoveCard(command.GetExtension(Command_MoveCard::ext), rc, ges); break; 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 316b9727c..6b2a93e48 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 @@ -46,6 +46,7 @@ class Command_NextTurn; class Command_SetActivePhase; class Command_DumpZone; class Command_RevealCards; +class Command_RevealRandomCards; class Command_ReverseTurn; class Command_MoveCard; class Command_SetSideboardPlan; @@ -176,6 +177,8 @@ public: virtual Response::ResponseCode cmdRevealCards(const Command_RevealCards &cmd, ResponseContainer &rc, GameEventStorage &ges); virtual Response::ResponseCode + cmdRevealRandomCards(const Command_RevealRandomCards &cmd, ResponseContainer &rc, GameEventStorage &ges); + virtual Response::ResponseCode cmdReverseTurn(const Command_ReverseTurn & /*cmd*/, ResponseContainer & /*rc*/, GameEventStorage &ges); virtual Response::ResponseCode cmdSetPlayerOrder(const Command_SetPlayerOrder &cmd, ResponseContainer &rc, GameEventStorage &ges); 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 b5a620798..b9617b547 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 @@ -1494,8 +1494,10 @@ Server_AbstractPlayer::cmdDumpZone(const Command_DumpZone &cmd, ResponseContaine return Response::RespOk; } -Response::ResponseCode -Server_AbstractPlayer::cmdRevealCards(const Command_RevealCards &cmd, ResponseContainer & /*rc*/, GameEventStorage &ges) +Response::ResponseCode Server_AbstractPlayer::resolveRevealZone(const std::string &zoneName, + bool hasRecipient, + int recipientId, + Server_CardZone *&zone) { if (!game->getGameStarted()) { return Response::RespGameNotStarted; @@ -1504,17 +1506,61 @@ Server_AbstractPlayer::cmdRevealCards(const Command_RevealCards &cmd, ResponseCo return Response::RespContextError; } - if (cmd.has_player_id()) { - Server_AbstractPlayer *otherPlayer = game->getPlayer(cmd.player_id()); - if (!otherPlayer) { - return Response::RespNameNotFound; - } + if (hasRecipient && !game->getPlayer(recipientId)) { + return Response::RespNameNotFound; } - Server_CardZone *zone = zones.value(nameFromStdString(cmd.zone_name())); + + zone = zones.value(nameFromStdString(zoneName)); if (!zone) { return Response::RespNameNotFound; } + return Response::RespOk; +} + +Response::ResponseCode Server_AbstractPlayer::cmdRevealRandomCards(const Command_RevealRandomCards &cmd, + ResponseContainer & /*rc*/, + GameEventStorage &ges) +{ + if (cmd.number_of_cards() <= 0) { + return Response::RespInvalidData; + } + + Server_CardZone *zone = nullptr; + const Response::ResponseCode result = + resolveRevealZone(cmd.zone_name(), cmd.has_player_id(), cmd.player_id(), zone); + if (result != Response::RespOk) { + return result; + } + + auto candidates = zone->getCards(); + if (cmd.number_of_cards() > candidates.size()) { + return Response::RespContextError; + } + + // Partial Fisher-Yates on a copy: each physical card can appear only once, + // and the hand's order and contents stay unchanged. + QList cardsToReveal; + for (int i = 0; i < cmd.number_of_cards(); ++i) { + const int selected = rng->rand(i, candidates.size() - 1); + candidates.swapItemsAt(i, selected); + cardsToReveal.append(candidates.at(i)); + } + + // -2 in the public event tells other participants only how many cards were revealed. + return revealCards(zone, cardsToReveal, {-2}, cmd.has_player_id(), cmd.player_id(), false, ges); +} + +Response::ResponseCode +Server_AbstractPlayer::cmdRevealCards(const Command_RevealCards &cmd, ResponseContainer & /*rc*/, GameEventStorage &ges) +{ + Server_CardZone *zone = nullptr; + const Response::ResponseCode result = + resolveRevealZone(cmd.zone_name(), cmd.has_player_id(), cmd.player_id(), zone); + if (result != Response::RespOk) { + return result; + } + QList cardsToReveal; if (cmd.top_cards() != -1) { for (int i = 0; i < cmd.top_cards(); i++) { @@ -1547,15 +1593,28 @@ Server_AbstractPlayer::cmdRevealCards(const Command_RevealCards &cmd, ResponseCo } } + const QList cardIdsForOthers(cmd.card_id().begin(), cmd.card_id().end()); + return revealCards(zone, cardsToReveal, cardIdsForOthers, cmd.has_player_id(), cmd.player_id(), + cmd.grant_write_access(), ges); +} + +Response::ResponseCode Server_AbstractPlayer::revealCards(Server_CardZone *zone, + const QList &cardsToReveal, + const QList &cardIdsForOthers, + bool hasRecipient, + int recipientId, + bool grantWriteAccess, + GameEventStorage &ges) +{ Event_RevealCards eventOthers; - eventOthers.set_grant_write_access(cmd.grant_write_access()); + eventOthers.set_grant_write_access(grantWriteAccess); eventOthers.set_zone_name(zone->getName().toStdString()); eventOthers.set_number_of_cards(cardsToReveal.size()); - for (auto cardId : cmd.card_id()) { + for (int cardId : cardIdsForOthers) { eventOthers.add_card_id(cardId); } - if (cmd.has_player_id()) { - eventOthers.set_other_player_id(cmd.player_id()); + if (hasRecipient) { + eventOthers.set_other_player_id(recipientId); } Event_RevealCards eventPrivate(eventOthers); @@ -1592,19 +1651,19 @@ Server_AbstractPlayer::cmdRevealCards(const Command_RevealCards &cmd, ResponseCo } } - if (cmd.has_player_id()) { - if (cmd.grant_write_access()) { - zone->addWritePermission(cmd.player_id()); + if (hasRecipient) { + if (grantWriteAccess) { + zone->addWritePermission(recipientId); } if (isJudge()) { ges.setOverwriteOwnership(true); } - ges.enqueueGameEvent(eventPrivate, playerId, GameEventStorageItem::SendToPrivate, cmd.player_id()); + ges.enqueueGameEvent(eventPrivate, playerId, GameEventStorageItem::SendToPrivate, recipientId); ges.enqueueGameEvent(eventOthers, playerId, GameEventStorageItem::SendToOthers); } else { - if (cmd.grant_write_access()) { + if (grantWriteAccess) { const QList &participantIds = game->getParticipants().keys(); for (int anyParticipantId : participantIds) { zone->addWritePermission(anyParticipantId); 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 ae20955bf..c92c4c3dd 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 @@ -32,6 +32,18 @@ private: void sendCreateTokenEvents(Server_CardZone *zone, Server_Card *card, int xCoord, int yCoord, GameEventStorage &ges); void getPlayerProperties(ServerInfo_PlayerProperties &result) override; + /// Validates the preconditions shared by every reveal and resolves the source zone. + Response::ResponseCode + resolveRevealZone(const std::string &zoneName, bool hasRecipient, int recipientId, Server_CardZone *&zone); + /// Builds the private and public reveal events for an already chosen set of cards and enqueues them. + Response::ResponseCode revealCards(Server_CardZone *zone, + const QList &cardsToReveal, + const QList &cardIdsForOthers, + bool hasRecipient, + int recipientId, + bool grantWriteAccess, + GameEventStorage &ges); + protected: bool conceded; DeckList *deck; @@ -163,6 +175,8 @@ public: cmdDumpZone(const Command_DumpZone &cmd, ResponseContainer &rc, GameEventStorage &ges) override; virtual Response::ResponseCode cmdRevealCards(const Command_RevealCards &cmd, ResponseContainer &rc, GameEventStorage &ges) override; + Response::ResponseCode + cmdRevealRandomCards(const Command_RevealRandomCards &cmd, ResponseContainer &rc, GameEventStorage &ges) override; virtual Response::ResponseCode cmdChangeZoneProperties(const Command_ChangeZoneProperties &cmd, ResponseContainer &rc, GameEventStorage &ges) override; diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_reveal_cards.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_reveal_cards.proto index 82216911c..6bca5a69e 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/command_reveal_cards.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_reveal_cards.proto @@ -10,3 +10,13 @@ message Command_RevealCards { optional bool grant_write_access = 4; optional sint32 top_cards = 5 [default = -1]; } + +// Separate extension so older servers reject the operation without revealing any cards. +message Command_RevealRandomCards { + extend GameCommand { + optional Command_RevealRandomCards ext = 1036; + } + optional string zone_name = 1; + optional sint32 number_of_cards = 2; + optional sint32 player_id = 3 [default = -1]; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/game_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/game_commands.proto index eb399ffb9..10ceb33ba 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/game_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/game_commands.proto @@ -185,6 +185,11 @@ message GameCommand { /// Server: Server_AbstractParticipant::cmdSetPlayerOrder /// Client: reflected via Event_PlayerOrderChanged SET_PLAYER_ORDER = 1206; + + /// Reveal a distinct random sample without exposing identities to other recipients. + /// Server: Server_AbstractPlayer::cmdRevealCards + /// Client: PlayerEventHandler::eventRevealCards + REVEAL_RANDOM_CARDS = 1036; } extensions 100 to max; diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto index 04cb31d98..d03f93667 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto @@ -169,6 +169,11 @@ message Command_GetUserAlts { optional Command_GetUserAlts ext = 1014; } optional string user_name = 1; + optional uint32 depth = 2 [default = 1]; // BFS depth: 1 = direct matches only, up to 3 for transitive alts-of-alts + optional bool include_email = 3 [default = true]; // match and expand over registration email + optional bool include_clientid = 4 [default = true]; // match and expand over current and historic client ids + optional bool include_ip = 5 [default = true]; // match and expand over recent session IPs + optional uint32 session_months = 6 [default = 6]; // session lookback window in months, 1-24 } message Command_GetModeratorLastLogins { diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user_alt.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user_alt.proto index 191dd5063..c9ec9aa9a 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user_alt.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user_alt.proto @@ -13,4 +13,9 @@ message ServerInfo_UserAlt { 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 + optional string match_reason = 9; // why flagged, e.g. "email", "clientid:abc", "ip:1.2.3.4"; empty = seed/legacy + optional uint32 depth = 10; // BFS distance from investigated account; 1 = direct, 2+ = alt-of-alt + optional uint32 shared_count = + 11; // distinct users sharing the matched identifier; high values suggest VPN/shared network + optional uint32 ip_count = 12; // distinct recent session IPs of this account in the search window } diff --git a/servatrice/check_schema_version.sh b/servatrice/check_schema_version.sh index c4aadf356..8b645b5d2 100755 --- a/servatrice/check_schema_version.sh +++ b/servatrice/check_schema_version.sh @@ -10,7 +10,7 @@ declare -i schema_ver="${version_line%%)*}" latest_migration="$(ls -1 servatrice/migrations/ | tail -n1)" xtoysql="${latest_migration#servatrice_}" xtoy="${xtoysql%.sql}" -declare -i old_ver="10#${xtoy%_to_*}" #declare as integer with base 10, numbers with a leading 0 are normally interpreted as base 16 +declare -i old_ver="10#${xtoy%_to_*}" # declare as integer with base 10, numbers with a leading 0 are normally interpreted as base 16 declare -i new_ver="10#${xtoy#*_to_}" if ((old_ver >= new_ver)); then diff --git a/servatrice/src/servatrice_database_interface.cpp b/servatrice/src/servatrice_database_interface.cpp index a4f184e38..1327c85d5 100644 --- a/servatrice/src/servatrice_database_interface.cpp +++ b/servatrice/src/servatrice_database_interface.cpp @@ -34,6 +34,7 @@ #include #include #include +#include #include #include #include @@ -1977,7 +1978,72 @@ QList Servatrice_DatabaseInterface::getUserSessions(cons return results; } -QList Servatrice_DatabaseInterface::getUserAlts(const QString &userName) +namespace +{ +constexpr int MAX_USER_ALT_DEPTH = 3; +constexpr int MAX_USER_ALTS_PER_LEVEL = 200; +// Bounds per-level work and the size of each dynamic IN-list. +// Identifier-heavy frontiers, e.g. VPN users with hundreds of recent IPs, are +// truncated: this tool is deliberately a first-pass sweep, not an audit. +constexpr int MAX_USER_ALT_IDENTIFIERS = 200; +constexpr int MIN_USER_ALT_MONTHS = 1; +constexpr int MAX_USER_ALT_MONTHS = 24; + +QString altInClause(const QString &prefix, const QList &values) +{ + QStringList placeholders; + placeholders.reserve(values.size()); + for (int i = 0; i < values.size(); ++i) { + placeholders << prefix + QString::number(i); + } + return QStringLiteral("(") + placeholders.join(QStringLiteral(",")) + QStringLiteral(")"); +} + +void altBindValues(QSqlQuery *query, const QString &prefix, const QList &values) +{ + for (int i = 0; i < values.size(); ++i) { + query->bindValue(prefix + QString::number(i), values.at(i)); + } +} + +struct AltCandidate +{ + QString name; + QString email; + QString clientId; + qint64 registrationTime = 0; + qint64 lastLogin = 0; + bool hasLastLogin = false; + int warnCount = 0; + int banCount = 0; + bool isActive = false; +}; + +// Keeps the first-seen order: callers add the strongest evidence first so the +// cap below drops the least relevant identifiers rather than an arbitrary slice. +void appendIdentifier(QList &list, QSet &seen, const QString &value) +{ + if (!value.isEmpty() && !seen.contains(value)) { + seen.insert(value); + list << value; + } +} + +QList cappedIdentifiers(const QList &values) +{ + if (values.size() <= MAX_USER_ALT_IDENTIFIERS) { + return values; + } + return values.mid(0, MAX_USER_ALT_IDENTIFIERS); +} +} // namespace + +QList Servatrice_DatabaseInterface::getUserAlts(const QString &userName, + int depth, + bool useEmail, + bool useClientId, + bool useIp, + int sessionMonths) { QList results; @@ -1985,63 +2051,394 @@ QList Servatrice_DatabaseInterface::getUserAlts(const QStrin return results; } - // Seed account identifiers used to find related accounts - QSqlQuery *seedQuery = prepareQuery("SELECT email, clientid FROM {prefix}_users WHERE name = :user_name"); + if (depth < 1) { + depth = 1; + } else if (depth > MAX_USER_ALT_DEPTH) { + depth = MAX_USER_ALT_DEPTH; + } + if (sessionMonths < MIN_USER_ALT_MONTHS) { + sessionMonths = MIN_USER_ALT_MONTHS; + } else if (sessionMonths > MAX_USER_ALT_MONTHS) { + sessionMonths = MAX_USER_ALT_MONTHS; + } + + // Interpolated, not bound: placeholders are not allowed in INTERVAL + // expressions, and the clamped integer cannot inject SQL. + const QString sessionWindow = QStringLiteral(" AND start_time >= DATE_SUB(NOW(), INTERVAL ") + + QString::number(sessionMonths) + QStringLiteral(" MONTH)"); + + // Seed must exist; its row anchors the result at depth 0. + QSqlQuery *seedQuery = prepareQuery("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"); 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()) { + auto appendAlt = [&results](const AltCandidate &candidate, const QString &matchReason, int matchDepth, + int sharedCount) { 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_user_name(candidate.name.toStdString()); + altDetails.set_email(candidate.email.toStdString()); + altDetails.set_clientid(candidate.clientId.toStdString()); + altDetails.set_registration_time(candidate.registrationTime); + if (candidate.hasLastLogin) { + altDetails.set_last_login(candidate.lastLogin); } - altDetails.set_warn_count(query->value(5).toInt()); - altDetails.set_ban_count(query->value(6).toInt()); - altDetails.set_is_active(query->value(7).toBool()); + altDetails.set_warn_count(candidate.warnCount); + altDetails.set_ban_count(candidate.banCount); + altDetails.set_is_active(candidate.isActive); + altDetails.set_match_reason(matchReason.toStdString()); + altDetails.set_depth(static_cast(matchDepth)); + altDetails.set_shared_count(static_cast(sharedCount)); results << altDetails; + }; + + AltCandidate seed{.name = seedQuery->value(0).toString(), + .email = seedQuery->value(1).toString(), + .clientId = seedQuery->value(2).toString(), + .registrationTime = seedQuery->value(3).toLongLong(), + .lastLogin = seedQuery->value(4).toLongLong(), + .hasLastLogin = !seedQuery->value(4).isNull(), + .warnCount = seedQuery->value(5).toInt(), + .banCount = seedQuery->value(6).toInt(), + .isActive = seedQuery->value(7).toBool()}; + appendAlt(seed, QStringLiteral("self"), 0, 1); + + // Identifier lookups are case-insensitive in SQL, so the seed must be keyed by + // its stored casing: otherwise the level-1 query re-lists it as an alt. + QSet visited{seed.name}; + QList frontier{seed.name}; + + // Dynamic IN-lists bypass prepareQuery(): it caches by query text, so every + // distinct list size would leave a prepared statement in the long-lived cache + // and can exhaust MySQL's max_prepared_stmt_count. + const QString dbPrefix = server->getDbPrefix(); + auto prepareDynamicQuery = [&dbPrefix](QSqlQuery &query, const QString &queryText) { + QString text = queryText; + text.replace(QStringLiteral("{prefix}"), dbPrefix); + query.prepare(text); + }; + + for (int level = 1; level <= depth && !frontier.isEmpty(); ++level) { + const QString frontierClause = altInClause(QStringLiteral(":alt_name"), frontier); + + // Current identifiers of the frontier: registration email + client id. + // Kept strongest-first so cappedIdentifiers() drops the least useful ones. + QList emails; + QList clientIds; + QList ips; + QSet emailKeys; + QSet clientIdKeys; + QSet ipKeys; + + if (useEmail || useClientId) { + QSqlQuery frontierQuery(sqlDatabase); + prepareDynamicQuery(frontierQuery, + QStringLiteral("SELECT email, clientid FROM {prefix}_users WHERE name IN ") + + frontierClause); + altBindValues(&frontierQuery, QStringLiteral(":alt_name"), frontier); + if (execSqlQuery(&frontierQuery)) { + while (frontierQuery.next()) { + // Email comparison is case-insensitive in SQL, so normalise the + // keys too or the membership tests below miss real matches. + if (useEmail) { + appendIdentifier(emails, emailKeys, frontierQuery.value(0).toString().toLower()); + } + if (useClientId) { + appendIdentifier(clientIds, clientIdKeys, frontierQuery.value(1).toString()); + } + } + } + } + + // Recent session identifiers: IPs plus historic client ids, most recent + // first so a capped list keeps what the account actually used lately. + if (useIp || useClientId) { + QSqlQuery sessionQuery(sqlDatabase); + prepareDynamicQuery(sessionQuery, + QStringLiteral("SELECT ip_address, clientid, MAX(start_time) AS last_seen " + "FROM {prefix}_sessions WHERE user_name IN ") + + frontierClause + sessionWindow + + QStringLiteral(" GROUP BY ip_address, clientid " + "ORDER BY last_seen DESC")); + altBindValues(&sessionQuery, QStringLiteral(":alt_name"), frontier); + if (execSqlQuery(&sessionQuery)) { + while (sessionQuery.next()) { + if (useIp) { + appendIdentifier(ips, ipKeys, sessionQuery.value(0).toString()); + } + if (useClientId) { + appendIdentifier(clientIds, clientIdKeys, sessionQuery.value(1).toString()); + } + } + } + } + + if (emails.isEmpty() && clientIds.isEmpty() && ips.isEmpty()) { + break; + } + + // Accounts sharing any frontier identifier. Historic client ids are matched + // through past sessions so identifier changes do not hide older alts. + QString candidateString = + QStringLiteral("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 "); + QStringList conditions; + const QList emailList = cappedIdentifiers(emails); + const QList clientIdList = cappedIdentifiers(clientIds); + const QList ipList = cappedIdentifiers(ips); + if (!emailList.isEmpty()) { + conditions << QStringLiteral("u.email IN ") + altInClause(QStringLiteral(":alt_email"), emailList); + } + if (!clientIdList.isEmpty()) { + conditions << QStringLiteral("u.clientid IN ") + altInClause(QStringLiteral(":alt_cid"), clientIdList); + } + if (!ipList.isEmpty()) { + conditions << QStringLiteral("u.name IN (SELECT DISTINCT s.user_name FROM {prefix}_sessions s WHERE " + "s.ip_address IN ") + + altInClause(QStringLiteral(":alt_ip"), ipList) + sessionWindow + QStringLiteral(")"); + } + if (!clientIdList.isEmpty()) { + conditions << QStringLiteral("u.name IN (SELECT DISTINCT s.user_name FROM {prefix}_sessions s WHERE " + "s.clientid IN ") + + altInClause(QStringLiteral(":alt_hcid"), clientIdList) + sessionWindow + + QStringLiteral(")"); + } + candidateString += conditions.join(QStringLiteral(" OR ")); + // Already-visited accounts match their own identifiers at every level and + // are dropped below, so widen the row limit by that count: otherwise a + // second level can return only visited rows and no new candidates. + candidateString += + QStringLiteral(" ORDER BY u.name LIMIT ") + QString::number(MAX_USER_ALTS_PER_LEVEL + visited.size()); + + QSqlQuery candidateQuery(sqlDatabase); + prepareDynamicQuery(candidateQuery, candidateString); + altBindValues(&candidateQuery, QStringLiteral(":alt_email"), emailList); + altBindValues(&candidateQuery, QStringLiteral(":alt_cid"), clientIdList); + altBindValues(&candidateQuery, QStringLiteral(":alt_ip"), ipList); + altBindValues(&candidateQuery, QStringLiteral(":alt_hcid"), clientIdList); + if (!execSqlQuery(&candidateQuery)) { + qCWarning(DatabaseInterfaceLog) << "Failed to collect user alt information: SQL Error"; + return results; + } + + QList candidates; + while (candidateQuery.next()) { + AltCandidate candidate; + candidate.name = candidateQuery.value(0).toString(); + if (visited.contains(candidate.name)) { + continue; + } + candidate.email = candidateQuery.value(1).toString(); + candidate.clientId = candidateQuery.value(2).toString(); + candidate.registrationTime = candidateQuery.value(3).toLongLong(); + candidate.hasLastLogin = !candidateQuery.value(4).isNull(); + candidate.lastLogin = candidateQuery.value(4).toLongLong(); + candidate.warnCount = candidateQuery.value(5).toInt(); + candidate.banCount = candidateQuery.value(6).toInt(); + candidate.isActive = candidateQuery.value(7).toBool(); + candidates << candidate; + if (candidates.size() >= MAX_USER_ALTS_PER_LEVEL) { + break; + } + } + + // Resolve session-level evidence (shared IP or historic client id) for + // candidates that do not match on current email or client id. + QMap candidateIp; + QMap candidateHistoricClientId; + QList unexplained; + for (const AltCandidate &candidate : candidates) { + const bool emailMatch = !candidate.email.isEmpty() && emailKeys.contains(candidate.email.toLower()); + const bool clientIdMatch = !candidate.clientId.isEmpty() && clientIdKeys.contains(candidate.clientId); + if (!emailMatch && !clientIdMatch) { + unexplained << candidate.name; + } + } + if (!unexplained.isEmpty() && (!ipKeys.isEmpty() || !clientIdKeys.isEmpty())) { + QString evidenceString = + QStringLiteral("SELECT DISTINCT user_name, ip_address, clientid FROM {prefix}_sessions WHERE " + "user_name IN ") + + altInClause(QStringLiteral(":alt_ev"), unexplained) + QStringLiteral(" AND ("); + QStringList evidenceConditions; + if (!ipList.isEmpty()) { + evidenceConditions << QStringLiteral("ip_address IN ") + + altInClause(QStringLiteral(":alt_eip"), ipList); + } + if (!clientIdList.isEmpty()) { + evidenceConditions << QStringLiteral("clientid IN ") + + altInClause(QStringLiteral(":alt_ecid"), clientIdList); + } + evidenceString += evidenceConditions.join(QStringLiteral(" OR ")); + evidenceString += QStringLiteral(")") + sessionWindow; + + QSqlQuery evidenceQuery(sqlDatabase); + prepareDynamicQuery(evidenceQuery, evidenceString); + altBindValues(&evidenceQuery, QStringLiteral(":alt_ev"), unexplained); + altBindValues(&evidenceQuery, QStringLiteral(":alt_eip"), ipList); + altBindValues(&evidenceQuery, QStringLiteral(":alt_ecid"), clientIdList); + if (execSqlQuery(&evidenceQuery)) { + while (evidenceQuery.next()) { + const QString name = evidenceQuery.value(0).toString(); + const QString ip = evidenceQuery.value(1).toString(); + const QString clientId = evidenceQuery.value(2).toString(); + if (!ip.isEmpty() && ipKeys.contains(ip) && !candidateIp.contains(name)) { + candidateIp.insert(name, ip); + } + if (!clientId.isEmpty() && clientIdKeys.contains(clientId) && + !candidateHistoricClientId.contains(name)) { + candidateHistoricClientId.insert(name, clientId); + } + } + } + } + + // Fan-out counts so moderators can spot VPN/shared-network false positives. + QMap emailCounts; + QMap clientIdCounts; + QMap historicClientIdCounts; + QMap ipCounts; + QSet neededEmails; + QSet neededClientIds; + QSet neededHistoricClientIds; + QSet neededIps; + for (const AltCandidate &candidate : candidates) { + if (!candidate.email.isEmpty() && emailKeys.contains(candidate.email.toLower())) { + neededEmails << candidate.email.toLower(); + } else if (!candidate.clientId.isEmpty() && clientIdKeys.contains(candidate.clientId)) { + neededClientIds << candidate.clientId; + } else if (candidateHistoricClientId.contains(candidate.name)) { + neededHistoricClientIds << candidateHistoricClientId.value(candidate.name); + } else if (candidateIp.contains(candidate.name)) { + neededIps << candidateIp.value(candidate.name); + } + } + if (!neededEmails.isEmpty()) { + const QList neededEmailList = neededEmails.values(); + QSqlQuery countQuery(sqlDatabase); + prepareDynamicQuery(countQuery, QStringLiteral("SELECT email, COUNT(*) FROM {prefix}_users WHERE " + "email IN ") + + altInClause(QStringLiteral(":alt_ce"), neededEmailList) + + QStringLiteral(" GROUP BY email")); + altBindValues(&countQuery, QStringLiteral(":alt_ce"), neededEmailList); + if (execSqlQuery(&countQuery)) { + while (countQuery.next()) { + emailCounts.insert(countQuery.value(0).toString().toLower(), countQuery.value(1).toInt()); + } + } + } + if (!neededClientIds.isEmpty()) { + const QList neededClientIdList = neededClientIds.values(); + QSqlQuery countQuery(sqlDatabase); + prepareDynamicQuery(countQuery, QStringLiteral("SELECT clientid, COUNT(*) FROM {prefix}_users WHERE " + "clientid IN ") + + altInClause(QStringLiteral(":alt_cc"), neededClientIdList) + + QStringLiteral(" GROUP BY clientid")); + altBindValues(&countQuery, QStringLiteral(":alt_cc"), neededClientIdList); + if (execSqlQuery(&countQuery)) { + while (countQuery.next()) { + clientIdCounts.insert(countQuery.value(0).toString(), countQuery.value(1).toInt()); + } + } + } + // Historic client ids are matched through sessions, so their fan-out must + // come from _sessions too: _users only knows each account's current id. + if (!neededHistoricClientIds.isEmpty()) { + const QList neededHistoricClientIdList = neededHistoricClientIds.values(); + QSqlQuery countQuery(sqlDatabase); + prepareDynamicQuery(countQuery, + QStringLiteral("SELECT clientid, COUNT(DISTINCT user_name) FROM {prefix}_sessions " + "WHERE clientid IN ") + + altInClause(QStringLiteral(":alt_hcc"), neededHistoricClientIdList) + + QStringLiteral(" AND start_time >= DATE_SUB(NOW(), INTERVAL 6 MONTH) " + "GROUP BY clientid")); + altBindValues(&countQuery, QStringLiteral(":alt_hcc"), neededHistoricClientIdList); + if (execSqlQuery(&countQuery)) { + while (countQuery.next()) { + historicClientIdCounts.insert(countQuery.value(0).toString(), countQuery.value(1).toInt()); + } + } + } + if (!neededIps.isEmpty()) { + const QList neededIpList = neededIps.values(); + QSqlQuery countQuery(sqlDatabase); + prepareDynamicQuery(countQuery, + QStringLiteral("SELECT ip_address, COUNT(DISTINCT user_name) FROM {prefix}_sessions " + "WHERE ip_address IN ") + + altInClause(QStringLiteral(":alt_ci"), neededIpList) + sessionWindow + + QStringLiteral(" GROUP BY ip_address")); + altBindValues(&countQuery, QStringLiteral(":alt_ci"), neededIpList); + if (execSqlQuery(&countQuery)) { + while (countQuery.next()) { + ipCounts.insert(countQuery.value(0).toString(), countQuery.value(1).toInt()); + } + } + } + + QList nextFrontier; + for (const AltCandidate &candidate : candidates) { + QString matchReason; + int sharedCount = 1; + if (!candidate.email.isEmpty() && emailKeys.contains(candidate.email.toLower())) { + matchReason = QStringLiteral("email"); + sharedCount = qMax(1, emailCounts.value(candidate.email.toLower(), 1)); + } else if (!candidate.clientId.isEmpty() && clientIdKeys.contains(candidate.clientId)) { + matchReason = QStringLiteral("clientid:") + candidate.clientId; + sharedCount = qMax(1, clientIdCounts.value(candidate.clientId, 1)); + } else if (candidateHistoricClientId.contains(candidate.name)) { + const QString historicId = candidateHistoricClientId.value(candidate.name); + matchReason = QStringLiteral("clientid:") + historicId; + sharedCount = qMax(1, historicClientIdCounts.value(historicId, 1)); + } else if (candidateIp.contains(candidate.name)) { + const QString ip = candidateIp.value(candidate.name); + matchReason = QStringLiteral("ip:") + ip; + sharedCount = qMax(1, ipCounts.value(ip, 1)); + } else { + continue; + } + appendAlt(candidate, matchReason, level, sharedCount); + visited.insert(candidate.name); + nextFrontier << candidate.name; + } + frontier = nextFrontier; + } + + // Distinct recent IP counts per account: cheap group signal without + // shipping full address histories to the client. + if (!results.isEmpty()) { + QList resultNames; + resultNames.reserve(results.size()); + for (const ServerInfo_UserAlt &alt : results) { + resultNames << QString::fromStdString(alt.user_name()); + } + QSqlQuery *ipCountQuery = prepareQuery( + QStringLiteral("SELECT user_name, COUNT(DISTINCT ip_address) FROM {prefix}_sessions WHERE user_name IN ") + + altInClause(QStringLiteral(":alt_ic"), resultNames) + sessionWindow + + QStringLiteral(" GROUP BY user_name")); + altBindValues(ipCountQuery, QStringLiteral(":alt_ic"), resultNames); + QMap ipCountsByUser; + if (execSqlQuery(ipCountQuery)) { + while (ipCountQuery->next()) { + ipCountsByUser.insert(ipCountQuery->value(0).toString(), ipCountQuery->value(1).toInt()); + } + } + for (ServerInfo_UserAlt &alt : results) { + alt.set_ip_count(static_cast( + ipCountsByUser.value(QString::fromStdString(alt.user_name()), 0))); + } } return results; diff --git a/servatrice/src/servatrice_database_interface.h b/servatrice/src/servatrice_database_interface.h index eb74063ac..c3cc57ab2 100644 --- a/servatrice/src/servatrice_database_interface.h +++ b/servatrice/src/servatrice_database_interface.h @@ -224,7 +224,12 @@ public: const QString &beforeTime = QString(), bool *hasMore = nullptr); QList getUserSessions(const QString &userName, int limit); - QList getUserAlts(const QString &userName); + QList getUserAlts(const QString &userName, + int depth = 1, + bool useEmail = true, + bool useClientId = true, + bool useIp = true, + int sessionMonths = 6); QList getModeratorLastLogins(); // Uptime snapshot as recorded by Servatrice::statusUpdate() into the diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index c38d790b1..c32ccad49 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -2595,7 +2595,10 @@ Response::ResponseCode AbstractServerSocketInterface::cmdGetUserAlts(const Comma } Response_UserAlts *re = new Response_UserAlts; - const QList alts = sqlInterface->getUserAlts(userName); + const int depth = cmd.has_depth() ? static_cast(cmd.depth()) : 1; + const int sessionMonths = cmd.has_session_months() ? static_cast(cmd.session_months()) : 6; + const QList alts = sqlInterface->getUserAlts( + userName, depth, cmd.include_email(), cmd.include_clientid(), cmd.include_ip(), sessionMonths); for (const ServerInfo_UserAlt &alt : alts) { re->add_alts()->CopyFrom(alt); } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index fedadfd1a..97a9beb4b 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -301,6 +301,7 @@ cockatrice_add_test( NAME server_card_counter_test SOURCES + ${VERSION_STRING_CPP} server_card_counter_test.cpp LIBS libcockatrice_network @@ -311,6 +312,7 @@ cockatrice_add_test( NAME server_dump_zone_test SOURCES + ${VERSION_STRING_CPP} server_dump_zone_test.cpp LIBS libcockatrice_rng @@ -322,6 +324,7 @@ cockatrice_add_test( NAME server_counter_test SOURCES + ${VERSION_STRING_CPP} server_counter_test.cpp LIBS libcockatrice_network @@ -333,6 +336,7 @@ cockatrice_add_test( server_developer_role_test NO_SANITIZE SOURCES + ${VERSION_STRING_CPP} server_developer_role_test.cpp LIBS libcockatrice_network @@ -345,6 +349,7 @@ cockatrice_add_test( server_moderator_role_test NO_SANITIZE SOURCES + ${VERSION_STRING_CPP} server_moderator_role_test.cpp LIBS libcockatrice_network @@ -352,6 +357,17 @@ cockatrice_add_test( LABELS server ) +cockatrice_add_test( + NAME + random_reveal_test + SOURCES + random_reveal_test.cpp + LIBS + libcockatrice_network_server_remote + libcockatrice_rng + LABELS + server +) cockatrice_add_test( NAME server_game_join_test @@ -391,6 +407,7 @@ cockatrice_add_test( NAME local_client_lifetime_test SOURCES + ${VERSION_STRING_CPP} local_client_lifetime_test.cpp LIBS libcockatrice_network @@ -412,6 +429,7 @@ cockatrice_add_test( NAME latency_tracker_test SOURCES + ${VERSION_STRING_CPP} latency_tracker_test.cpp LIBS libcockatrice_network @@ -456,6 +474,31 @@ cockatrice_add_test( INCLUDE_DIRS ${CMAKE_SOURCE_DIR}/cockatrice/src ) +cockatrice_add_test( + NAME + game_creation_options_test + SOURCES + ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/widgets/game_hosting/game_creation_options.cpp + game_creation_options_test.cpp + LIBS + libcockatrice_interfaces + libcockatrice_protocol + INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/cockatrice/src +) +cockatrice_add_test( + NAME + create_game_form_widget_test + SOURCES + ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/widgets/game_hosting/create_game_form_widget.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/widgets/game_hosting/game_creation_options.cpp + create_game_form_widget_test.cpp + LIBS + libcockatrice_interfaces + libcockatrice_protocol + INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/cockatrice/src +) cockatrice_add_test( NAME deck_hash_performance_test diff --git a/tests/carddatabase/CMakeLists.txt b/tests/carddatabase/CMakeLists.txt index 7d83640a1..012081fd2 100644 --- a/tests/carddatabase/CMakeLists.txt +++ b/tests/carddatabase/CMakeLists.txt @@ -21,10 +21,12 @@ cockatrice_add_test( ) # ------------------------ -# Filter String Test -# (guard must match the condition for libcockatrice_filters in the root CMakeLists.txt) +# Guard to match condition for libcockatrice_filters in the root CMakeLists.txt # ------------------------ if(WITH_ORACLE OR WITH_CLIENT) + # ------------------------ + # Filter String Test + # ------------------------ cockatrice_add_test( NAME filter_string_test @@ -36,6 +38,34 @@ if(WITH_ORACLE OR WITH_CLIENT) libcockatrice_filters ) + # ------------------------ + # Filter Tree Test + # ------------------------ + cockatrice_add_test( + NAME + filter_tree_test + SOURCES + ${VERSION_STRING_CPP} + filter_tree_test.cpp + mocks.cpp + LIBS + libcockatrice_filters + ) + + # ------------------------ + # Filter Mode Test + # ------------------------ + cockatrice_add_test( + NAME + filter_mode_test + SOURCES + ${VERSION_STRING_CPP} + filter_mode_test.cpp + mocks.cpp + LIBS + libcockatrice_filters + ) + # ------------------------ # Visual Display Model Test # ------------------------ diff --git a/tests/carddatabase/carddatabase_test.cpp b/tests/carddatabase/carddatabase_test.cpp index b54205f30..350d9be7e 100644 --- a/tests/carddatabase/carddatabase_test.cpp +++ b/tests/carddatabase/carddatabase_test.cpp @@ -1,3 +1,4 @@ +#include "../support/scoped_file_size_limit.h" #include "libcockatrice/card/database/card_database.h" #include "libcockatrice/card/database/card_database_loader.h" #include "libcockatrice/card/database/card_database_querier.h" @@ -36,12 +37,6 @@ #include #include -#ifdef Q_OS_UNIX -#include -#include -#include -#endif - namespace { @@ -247,21 +242,14 @@ TEST(CardDatabaseTest, Xml4ShortWriteKeepsInstalledFile) ASSERT_EQ(installed.write("installed database"), 18); installed.close(); - // A real filesystem write failure, isolated in the death-test child so the - // process-wide file limit never affects other cases or the parent runner. - ASSERT_EXIT( - { - std::signal(SIGXFSZ, SIG_IGN); - struct rlimit limit; - limit.rlim_cur = 128; - limit.rlim_max = 128; - if (setrlimit(RLIMIT_FSIZE, &limit) != 0) { - _exit(2); - } - CockatriceXml4Parser writer(&controller); - _exit(writer.saveToFile({}, sets, cards, fileName) ? 1 : 0); - }, - ::testing::ExitedWithCode(0), ""); + // A real filesystem write failure: the soft file-size cap makes the writer's own + // flush fail without filling a disk. The cap is scoped rather than forked, because a + // death-test child under RLIMIT_FSIZE is killed by SIGSEGV on macOS. + test_support::ScopedFileSizeLimit fileSizeCap(128); + ASSERT_TRUE(fileSizeCap.isValid()); + + CockatriceXml4Parser writer(&controller); + EXPECT_FALSE(writer.saveToFile({}, sets, cards, fileName)); ASSERT_TRUE(installed.open(QIODevice::ReadOnly)); EXPECT_EQ(installed.readAll(), "installed database"); diff --git a/tests/carddatabase/filter_mode_test.cpp b/tests/carddatabase/filter_mode_test.cpp new file mode 100644 index 000000000..3fec7e25d --- /dev/null +++ b/tests/carddatabase/filter_mode_test.cpp @@ -0,0 +1,147 @@ +#include "libcockatrice/filters/filter_mode.h" + +#include + +class FilterModeTest : public ::testing::Test +{ +protected: + ~FilterModeTest() override + { + for (CardFilter *filter : owned) { + delete filter; + } + } + + /** @brief Create a filter owned by the fixture and append it to the set under test. */ + const CardFilter * + add(const QString &term, CardFilter::Type type, CardFilter::Attr attr = CardFilter::Attr::AttrColor) + { + owned.append(new CardFilter(term, type, attr)); + filters.append(owned.last()); + return owned.last(); + } + + QList owned; + QList filters; +}; + +TEST(FilterModeInfoTest, EachModeDescribesItsOwnFilters) +{ + EXPECT_EQ(getFilterModeInfo(FilterMode::Includes).includeType, CardFilter::Type::TypeOr); + EXPECT_EQ(getFilterModeInfo(FilterMode::Includes).excludeType, CardFilter::Type::TypeEnd); + EXPECT_EQ(getFilterModeInfo(FilterMode::Includes).attr, CardFilter::Attr::AttrColor); + + EXPECT_EQ(getFilterModeInfo(FilterMode::Excludes).includeType, CardFilter::Type::TypeAndNot); + EXPECT_EQ(getFilterModeInfo(FilterMode::Excludes).excludeType, CardFilter::Type::TypeEnd); + + EXPECT_EQ(getFilterModeInfo(FilterMode::IncludeExclude).includeType, CardFilter::Type::TypeOr); + EXPECT_EQ(getFilterModeInfo(FilterMode::IncludeExclude).excludeType, CardFilter::Type::TypeAndNot); + + EXPECT_EQ(getFilterModeInfo(FilterMode::ExactMatch).includeType, CardFilter::Type::TypeAnd); + EXPECT_EQ(getFilterModeInfo(FilterMode::ExactMatch).excludeType, CardFilter::Type::TypeAndNot); + + // Color identity only excludes: the selected colors must be the identity, so nothing is included positively. + EXPECT_EQ(getFilterModeInfo(FilterMode::ColorIdentity).includeType, CardFilter::Type::TypeEnd); + EXPECT_EQ(getFilterModeInfo(FilterMode::ColorIdentity).excludeType, CardFilter::Type::TypeAndNot); + EXPECT_EQ(getFilterModeInfo(FilterMode::ColorIdentity).attr, CardFilter::Attr::AttrColorIdentity); + + EXPECT_EQ(getFilterModeInfo(FilterMode::Custom).attr, CardFilter::Attr::AttrEnd); +} + +TEST_F(FilterModeTest, EmptyFilterSetKeepsACompatibleMode) +{ + // Includes and Color identity both allow an empty set, so the live mode is not swapped for another. + EXPECT_EQ(deriveFilterMode(filters, FilterMode::Includes), FilterMode::Includes); + EXPECT_EQ(deriveFilterMode(filters, FilterMode::ColorIdentity), FilterMode::ColorIdentity); +} + +// Include / Exclude and Exact match always hold one filter per color, so an empty set cannot have come from +// them and falls back to the first mode that can describe it. +TEST_F(FilterModeTest, EmptyFilterSetFallsBackWhenTheModeNeedsOneFilterPerColor) +{ + EXPECT_EQ(deriveFilterMode(filters, FilterMode::IncludeExclude), FilterMode::Includes); + EXPECT_EQ(deriveFilterMode(filters, FilterMode::ExactMatch), FilterMode::Includes); +} + +TEST_F(FilterModeTest, IncludesFromOrFilters) +{ + add("W", CardFilter::Type::TypeOr); + + EXPECT_EQ(deriveFilterMode(filters, FilterMode::Includes), FilterMode::Includes); + // A single OR filter cannot come from Excludes, so the first mode that can produce it is selected. + EXPECT_EQ(deriveFilterMode(filters, FilterMode::Excludes), FilterMode::Includes); +} + +TEST_F(FilterModeTest, ExcludesFromAndNotFilters) +{ + add("W", CardFilter::Type::TypeAndNot); + + EXPECT_EQ(deriveFilterMode(filters, FilterMode::Excludes), FilterMode::Excludes); + // Includes needs an OR or an end filter, so an AND NOT filter selects Excludes. + EXPECT_EQ(deriveFilterMode(filters, FilterMode::Includes), FilterMode::Excludes); +} + +TEST_F(FilterModeTest, IncludeExcludeNeedsOneFilterPerColor) +{ + add("W", CardFilter::Type::TypeOr); + for (const QChar color : QString("UBRG")) { + add(QString(color), CardFilter::Type::TypeAndNot); + } + + EXPECT_EQ(deriveFilterMode(filters, FilterMode::IncludeExclude), FilterMode::IncludeExclude); +} + +TEST_F(FilterModeTest, IncludeExcludeRejectsAnIncompleteColorSet) +{ + add("W", CardFilter::Type::TypeOr); + add("U", CardFilter::Type::TypeAndNot); + + EXPECT_EQ(deriveFilterMode(filters, FilterMode::IncludeExclude), FilterMode::Custom); +} + +TEST_F(FilterModeTest, ExactMatchFromAndAndAndNotFilters) +{ + add("W", CardFilter::Type::TypeAnd); + for (const QChar color : QString("UBRG")) { + add(QString(color), CardFilter::Type::TypeAndNot); + } + + EXPECT_EQ(deriveFilterMode(filters, FilterMode::ExactMatch), FilterMode::ExactMatch); +} + +TEST_F(FilterModeTest, ColorIdentityFromIdentityAndNotFilters) +{ + for (const QChar color : QString("BRG")) { + add(QString(color), CardFilter::Type::TypeAndNot, CardFilter::Attr::AttrColorIdentity); + } + + EXPECT_EQ(deriveFilterMode(filters, FilterMode::ColorIdentity), FilterMode::ColorIdentity); +} + +TEST_F(FilterModeTest, MixedAttributesAreCustom) +{ + add("W", CardFilter::Type::TypeOr, CardFilter::Attr::AttrColor); + add("U", CardFilter::Type::TypeAndNot, CardFilter::Attr::AttrColorIdentity); + + EXPECT_EQ(deriveFilterMode(filters, FilterMode::Includes), FilterMode::Custom); +} + +TEST_F(FilterModeTest, CustomLeavesAsSoonAsAModeCanExplainTheFilters) +{ + add("W", CardFilter::Type::TypeOr); + + EXPECT_EQ(deriveFilterMode(filters, FilterMode::Custom), FilterMode::Includes); +} + +// Exact match with no color selected stores one AND NOT filter per color. That is a legitimate Exact match +// state, so the live mode is kept; a mode that cannot produce the set re-derives to Excludes, which produces +// the identical filters. +TEST_F(FilterModeTest, FullyExcludedColorSetIsExactMatchOrExcludes) +{ + for (const QChar color : QString("WUBRG")) { + add(QString(color), CardFilter::Type::TypeAndNot); + } + + EXPECT_EQ(deriveFilterMode(filters, FilterMode::ExactMatch), FilterMode::ExactMatch); + EXPECT_EQ(deriveFilterMode(filters, FilterMode::Includes), FilterMode::Excludes); +} diff --git a/tests/carddatabase/filter_string_test.cpp b/tests/carddatabase/filter_string_test.cpp index e561db590..88058278f 100644 --- a/tests/carddatabase/filter_string_test.cpp +++ b/tests/carddatabase/filter_string_test.cpp @@ -123,6 +123,25 @@ CardInfoPtr greenIdentityCard() return CardInfo::newInstance("Identity Cat", "", false, {{"colors", "G"}, {"coloridentity", "G"}}, {}, {}, {}, {}); } +// A card whose color identity differs from its colors, so that a query reading the wrong one is visible. +CardInfoPtr splitIdentityCard() +{ + return CardInfo::newInstance("Split Identity", "", false, {{"colors", "G"}, {"coloridentity", "W"}}, {}, {}, {}, + {}); +} + +// "ci!" (exactly these color identities) used to read the camelCase "colorIdentity" property instead of the +// lowercase "coloridentity" key, so it matched the empty string and rejected every card. +TEST_F(CardQuery, ColorIdentityExactReadsTheColorIdentityProperty) +{ + const CardData card = splitIdentityCard(); + ASSERT_TRUE(FilterString("ci!w").check(card)); + ASSERT_FALSE(FilterString("ci!g").check(card)); + // "c!" still reads colors, not identity. + ASSERT_TRUE(FilterString("c!g").check(card)); + ASSERT_FALSE(FilterString("c!w").check(card)); +} + // Whitespace belongs around the separator, never inside an operator token: "c i: g" must not // be read as the color-identity operator "ci" (it is a generic "c" AND a field query on "i"). TEST_F(CardQuery, ColorIdentityOperatorIsNotSplitByWhitespace) @@ -260,6 +279,22 @@ TEST_F(SetQuery, ComparisonMatchesByReleaseDate) EXPECT_FALSE(FilterString("set +#include +#include + +namespace +{ +class FilterTreeTest : public ::testing::Test +{ +protected: + void SetUp() override + { + language = CardSearchLanguage{}; + + island = CardInfo::newInstance("Island", "", false, {{"colors", ""}, {"coloridentity", "U"}}, {}, {}, {}, {}); + artifact = + CardInfo::newInstance("Artifact", "", false, {{"colors", ""}, {"coloridentity", ""}}, {}, {}, {}, {}); + truth = CardInfo::newInstance("Truth", "", false, {{"colors", "U"}, {"coloridentity", "U"}}, {}, {}, {}, {}); + notDead = + CardInfo::newInstance("Not Dead", "", false, {{"colors", "B"}, {"coloridentity", "B"}}, {}, {}, {}, {}); + wallOfDenial = CardInfo::newInstance("Wall of Denial", "", false, {{"colors", "WU"}, {"coloridentity", "WU"}}, + {}, {}, {}, {}); + maelstromAngel = CardInfo::newInstance("Maelstrom Archangel", "", false, + {{"colors", "WUBRG"}, {"coloridentity", "WUBRG"}}, {}, {}, {}, {}); + } + + CardSearchLanguage language; + + CardData island; // c/u + CardData artifact; // c + CardData truth; // u + CardData notDead; // b + CardData wallOfDenial; // wu + CardData maelstromAngel; // wubrg +}; +} // namespace + +// ===================================================================================================================== +// acceptColor +// ===================================================================================================================== + +TEST_F(FilterTreeTest, Color) +{ + FilterTree filterTree; + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "wu"); + ASSERT_FALSE(filterTree.acceptsCard(island, language)); + ASSERT_FALSE(filterTree.acceptsCard(artifact, language)); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_FALSE(filterTree.acceptsCard(notDead, language)); + ASSERT_TRUE(filterTree.acceptsCard(wallOfDenial, language)); + ASSERT_TRUE(filterTree.acceptsCard(maelstromAngel, language)); +} + +TEST_F(FilterTreeTest, ColorColorless) +{ + FilterTree filterTree; + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "c"); + ASSERT_TRUE(filterTree.acceptsCard(island, language)); + ASSERT_TRUE(filterTree.acceptsCard(artifact, language)); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_FALSE(filterTree.acceptsCard(notDead, language)); + ASSERT_FALSE(filterTree.acceptsCard(wallOfDenial, language)); + ASSERT_FALSE(filterTree.acceptsCard(maelstromAngel, language)); +} + +TEST_F(FilterTreeTest, ColorNot) +{ + FilterTree filterTree; + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAndNot, "b"); + ASSERT_TRUE(filterTree.acceptsCard(island, language)); + ASSERT_TRUE(filterTree.acceptsCard(artifact, language)); + ASSERT_TRUE(filterTree.acceptsCard(truth, language)); + ASSERT_FALSE(filterTree.acceptsCard(notDead, language)); + ASSERT_TRUE(filterTree.acceptsCard(wallOfDenial, language)); + ASSERT_FALSE(filterTree.acceptsCard(maelstromAngel, language)); +} + +TEST_F(FilterTreeTest, ColorAliases) +{ + FilterTree filterTree; + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "green"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(maelstromAngel, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "grn"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(maelstromAngel, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "blue"); + ASSERT_TRUE(filterTree.acceptsCard(truth, language)); + ASSERT_FALSE(filterTree.acceptsCard(notDead, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "blu"); + ASSERT_TRUE(filterTree.acceptsCard(truth, language)); + ASSERT_FALSE(filterTree.acceptsCard(notDead, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "black"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(notDead, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "blk"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(notDead, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "red"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(maelstromAngel, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "white"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(wallOfDenial, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "wht"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(wallOfDenial, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "colorless"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(artifact, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "colourless"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(artifact, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "none"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(artifact, language)); + filterTree.clear(); +} + +// ===================================================================================================================== +// acceptColorIdentity +// ===================================================================================================================== + +TEST_F(FilterTreeTest, ColorIdentity) +{ + FilterTree filterTree; + filterTree.termNode(CardFilter::Attr::AttrColorIdentity, CardFilter::Type::TypeAnd, "wu"); + ASSERT_FALSE(filterTree.acceptsCard(island, language)); + ASSERT_FALSE(filterTree.acceptsCard(artifact, language)); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_FALSE(filterTree.acceptsCard(notDead, language)); + ASSERT_TRUE(filterTree.acceptsCard(wallOfDenial, language)); + ASSERT_TRUE(filterTree.acceptsCard(maelstromAngel, language)); +} + +TEST_F(FilterTreeTest, ColorIdentityColorless) +{ + FilterTree filterTree; + filterTree.termNode(CardFilter::Attr::AttrColorIdentity, CardFilter::Type::TypeAnd, "c"); + ASSERT_FALSE(filterTree.acceptsCard(island, language)); + ASSERT_TRUE(filterTree.acceptsCard(artifact, language)); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_FALSE(filterTree.acceptsCard(notDead, language)); + ASSERT_FALSE(filterTree.acceptsCard(wallOfDenial, language)); + ASSERT_FALSE(filterTree.acceptsCard(maelstromAngel, language)); +} + +TEST_F(FilterTreeTest, ColorIdentityNot) +{ + FilterTree filterTree; + filterTree.termNode(CardFilter::Attr::AttrColorIdentity, CardFilter::Type::TypeAndNot, "b"); + ASSERT_TRUE(filterTree.acceptsCard(island, language)); + ASSERT_TRUE(filterTree.acceptsCard(artifact, language)); + ASSERT_TRUE(filterTree.acceptsCard(truth, language)); + ASSERT_FALSE(filterTree.acceptsCard(notDead, language)); + ASSERT_TRUE(filterTree.acceptsCard(wallOfDenial, language)); + ASSERT_FALSE(filterTree.acceptsCard(maelstromAngel, language)); +} + +TEST_F(FilterTreeTest, ColorIdentityMultiple) +{ + // Simulate a WU commander. + FilterTree filterTree; + filterTree.termNode(CardFilter::Attr::AttrColorIdentity, CardFilter::Type::TypeAndNot, "b"); + filterTree.termNode(CardFilter::Attr::AttrColorIdentity, CardFilter::Type::TypeAndNot, "r"); + filterTree.termNode(CardFilter::Attr::AttrColorIdentity, CardFilter::Type::TypeAndNot, "g"); + ASSERT_TRUE(filterTree.acceptsCard(island, language)); + ASSERT_TRUE(filterTree.acceptsCard(artifact, language)); + ASSERT_TRUE(filterTree.acceptsCard(truth, language)); + ASSERT_FALSE(filterTree.acceptsCard(notDead, language)); + ASSERT_TRUE(filterTree.acceptsCard(wallOfDenial, language)); + ASSERT_FALSE(filterTree.acceptsCard(maelstromAngel, language)); +} + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/client_login_status_test.cpp b/tests/client_login_status_test.cpp index d8ea63550..a2e073973 100644 --- a/tests/client_login_status_test.cpp +++ b/tests/client_login_status_test.cpp @@ -13,6 +13,10 @@ #include #include #include +#include + +// Referenced by other translation units in the server_remote library. +RNG_Abstract *rng = nullptr; namespace { diff --git a/tests/create_game_form_widget_test.cpp b/tests/create_game_form_widget_test.cpp new file mode 100644 index 000000000..f919e3443 --- /dev/null +++ b/tests/create_game_form_widget_test.cpp @@ -0,0 +1,132 @@ +#include "interface/widgets/game_hosting/create_game_form_widget.h" + +#include +#include +#include + +namespace +{ +const QMap gameTypes{{1, "Draft"}, {2, "Commander"}}; + +void expectSameOptions(const GameCreationOptions &actual, const GameCreationOptions &expected) +{ + EXPECT_EQ(actual.description, expected.description); + EXPECT_EQ(actual.password, expected.password); + EXPECT_EQ(actual.maxPlayers, expected.maxPlayers); + EXPECT_EQ(actual.onlyBuddies, expected.onlyBuddies); + EXPECT_EQ(actual.onlyRegistered, expected.onlyRegistered); + EXPECT_EQ(actual.spectatorsAllowed, expected.spectatorsAllowed); + EXPECT_EQ(actual.spectatorsNeedPassword, expected.spectatorsNeedPassword); + EXPECT_EQ(actual.spectatorsCanTalk, expected.spectatorsCanTalk); + EXPECT_EQ(actual.spectatorsSeeEverything, expected.spectatorsSeeEverything); + EXPECT_EQ(actual.joinAsJudge, expected.joinAsJudge); + EXPECT_EQ(actual.joinAsSpectator, expected.joinAsSpectator); + EXPECT_EQ(actual.startingLifeTotal, expected.startingLifeTotal); + EXPECT_EQ(actual.shareDecklistsOnLoad, expected.shareDecklistsOnLoad); + EXPECT_EQ(actual.shufflePlayers, expected.shufflePlayers); + EXPECT_EQ(actual.gameTypeIds, expected.gameTypeIds); +} +} // namespace + +TEST(CreateGameFormWidget, RegisteredHostDefaultsToRegisteredOnly) +{ + const CreateGameFormWidget form(gameTypes, true, false); + + const GameCreationOptions options = form.options(); + + EXPECT_TRUE(options.onlyRegistered); + EXPECT_FALSE(options.onlyBuddies); + EXPECT_EQ(options.maxPlayers, 2); + EXPECT_TRUE(options.spectatorsAllowed); + EXPECT_EQ(options.startingLifeTotal, 20); + EXPECT_TRUE(options.gameTypeIds.isEmpty()); +} + +TEST(CreateGameFormWidget, UnregisteredHostDefaultsToOpenJoin) +{ + const CreateGameFormWidget form(gameTypes, false, false); + + const GameCreationOptions options = form.options(); + + EXPECT_FALSE(options.onlyRegistered); + EXPECT_FALSE(options.onlyBuddies); +} + +TEST(CreateGameFormWidget, SetOptionsRoundTripsEveryField) +{ + CreateGameFormWidget form(gameTypes, true, true); + + GameCreationOptions expected; + expected.description = "Some game"; + expected.password = "hunter2"; + expected.maxPlayers = 5; + expected.onlyBuddies = true; + expected.onlyRegistered = false; + expected.spectatorsAllowed = false; + expected.spectatorsNeedPassword = true; + expected.spectatorsCanTalk = true; + expected.spectatorsSeeEverything = true; + expected.joinAsJudge = true; + expected.joinAsSpectator = true; + expected.startingLifeTotal = 40; + expected.shareDecklistsOnLoad = true; + expected.shufflePlayers = true; + expected.gameTypeIds = {2}; + + form.setOptions(expected); + + expectSameOptions(form.options(), expected); +} + +TEST(CreateGameFormWidget, ResetRestoresDefaultsAndClearsTheGameType) +{ + CreateGameFormWidget form(gameTypes, true, false); + GameCreationOptions configured; + configured.description = "Configured"; + configured.maxPlayers = 8; + configured.onlyBuddies = true; + configured.joinAsJudge = true; + configured.gameTypeIds = {2}; + form.setOptions(configured); + + form.resetToDefaults(); + + const GameCreationOptions options = form.options(); + EXPECT_TRUE(options.description.isEmpty()); + EXPECT_EQ(options.maxPlayers, 2); + EXPECT_FALSE(options.onlyBuddies); + EXPECT_TRUE(options.onlyRegistered); + EXPECT_TRUE(options.spectatorsAllowed); + EXPECT_EQ(options.startingLifeTotal, 20); + EXPECT_FALSE(options.joinAsJudge); + EXPECT_TRUE(options.gameTypeIds.isEmpty()); +} + +TEST(CreateGameFormWidget, RememberSettingsFlagRoundTrips) +{ + CreateGameFormWidget form(gameTypes, true, false); + + EXPECT_FALSE(form.isRememberSettingsChecked()); + + form.setRememberSettingsChecked(true); + + EXPECT_TRUE(form.isRememberSettingsChecked()); +} + +TEST(CreateGameFormWidget, SelectedGameTypesSettingStringUsesTheLegacyEncoding) +{ + CreateGameFormWidget form(gameTypes, true, false); + GameCreationOptions configured; + configured.gameTypeIds = {1}; + form.setOptions(configured); + + EXPECT_EQ(form.selectedGameTypesSettingString(), "Draft, "); +} + +int main(int argc, char **argv) +{ + qputenv("QT_QPA_PLATFORM", "offscreen"); + QApplication app(argc, argv); + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/game/card_zone_logic_test.cpp b/tests/game/card_zone_logic_test.cpp index 7f0c39b47..e308d95a6 100644 --- a/tests/game/card_zone_logic_test.cpp +++ b/tests/game/card_zone_logic_test.cpp @@ -5,6 +5,7 @@ #include #include #include +#include namespace { @@ -105,3 +106,30 @@ TEST(CardZoneLogicTest, ReorganizeSignalFiresOnAdd) EXPECT_EQ(reorganizeSpy.count(), 1); } + +TEST(CardZoneLogicTest, LibraryEdgeViewIsLimitedToOneEndOfTheLibrary) +{ + HandZoneLogic deck(nullptr, QString::fromLatin1(ZoneNames::DECK), false, true, true); + HandZoneLogic grave(nullptr, QString::fromLatin1(ZoneNames::GRAVE), false, true, true); + + ZoneViewZoneLogic fullDeckView(nullptr, &deck, -1, false, false, false); + EXPECT_FALSE(fullDeckView.isLibraryEdgeView()); + EXPECT_FALSE(fullDeckView.showsTopOfLibrary()); + EXPECT_FALSE(fullDeckView.showsBottomOfLibrary()); + + ZoneViewZoneLogic emptyDeckView(nullptr, &deck, 0, false, false, false); + EXPECT_FALSE(emptyDeckView.isLibraryEdgeView()); + + ZoneViewZoneLogic topView(nullptr, &deck, 3, false, false, false); + EXPECT_TRUE(topView.isLibraryEdgeView()); + EXPECT_TRUE(topView.showsTopOfLibrary()); + EXPECT_FALSE(topView.showsBottomOfLibrary()); + + ZoneViewZoneLogic bottomView(nullptr, &deck, 3, false, false, true); + EXPECT_TRUE(bottomView.isLibraryEdgeView()); + EXPECT_FALSE(bottomView.showsTopOfLibrary()); + EXPECT_TRUE(bottomView.showsBottomOfLibrary()); + + ZoneViewZoneLogic graveView(nullptr, &grave, 3, false, false, false); + EXPECT_FALSE(graveView.isLibraryEdgeView()); +} diff --git a/tests/game_creation_options_test.cpp b/tests/game_creation_options_test.cpp new file mode 100644 index 000000000..e4a7996cb --- /dev/null +++ b/tests/game_creation_options_test.cpp @@ -0,0 +1,310 @@ +#include "interface/widgets/game_hosting/game_creation_options.h" + +#include +#include +#include +#include + +namespace +{ +/** + * @brief The remembered settings a test hands to fromSettings. + * + * The fake cannot itself be an aggregate (it derives from a polymorphic interface), so the + * remembered values live in a GameCreationOptions member and the getters read them from + * there. That keeps the test data in one designated initializer. + */ +class FakeGameSettings : public IGameSettingsProvider +{ +public: + explicit FakeGameSettings(const GameCreationOptions &_options = {}) : options(_options) + { + } + + [[nodiscard]] QString getGameDescription() const override + { + return options.description; + } + [[nodiscard]] int getMaxPlayers() const override + { + return options.maxPlayers; + } + [[nodiscard]] QString getGameTypes() const override + { + return gameTypes; + } + [[nodiscard]] bool getOnlyBuddies() const override + { + return options.onlyBuddies; + } + [[nodiscard]] bool getOnlyRegistered() const override + { + return options.onlyRegistered; + } + [[nodiscard]] bool getSpectatorsAllowed() const override + { + return options.spectatorsAllowed; + } + [[nodiscard]] bool getSpectatorsNeedPassword() const override + { + return options.spectatorsNeedPassword; + } + [[nodiscard]] bool getSpectatorsCanTalk() const override + { + return options.spectatorsCanTalk; + } + [[nodiscard]] bool getSpectatorsCanSeeEverything() const override + { + return options.spectatorsSeeEverything; + } + [[nodiscard]] bool getCreateGameAsSpectator() const override + { + return options.joinAsSpectator; + } + [[nodiscard]] int getDefaultStartingLifeTotal() const override + { + return options.startingLifeTotal; + } + [[nodiscard]] bool getShareDecklistsOnLoad() const override + { + return options.shareDecklistsOnLoad; + } + [[nodiscard]] bool getShufflePlayers() const override + { + return options.shufflePlayers; + } + [[nodiscard]] bool getRememberGameSettings() const override + { + return false; + } + [[nodiscard]] bool getLocalGameRememberSettings() const override + { + return false; + } + [[nodiscard]] int getLocalGameMaxPlayers() const override + { + return 0; + } + [[nodiscard]] int getLocalGameStartingLifeTotal() const override + { + return 0; + } + + GameCreationOptions options; + /// The legacy "Name, " list; it has no GameCreationOptions counterpart. + QString gameTypes; +}; +} // namespace + +TEST(GameCreationOptions, DefaultsMatchTheDialog) +{ + const GameCreationOptions options; + + EXPECT_TRUE(options.description.isEmpty()); + EXPECT_TRUE(options.password.isEmpty()); + EXPECT_EQ(options.maxPlayers, 2); + EXPECT_FALSE(options.onlyBuddies); + EXPECT_FALSE(options.onlyRegistered); + EXPECT_TRUE(options.spectatorsAllowed); + EXPECT_FALSE(options.spectatorsNeedPassword); + EXPECT_FALSE(options.spectatorsCanTalk); + EXPECT_FALSE(options.spectatorsSeeEverything); + EXPECT_FALSE(options.joinAsJudge); + EXPECT_FALSE(options.joinAsSpectator); + EXPECT_EQ(options.startingLifeTotal, 20); + EXPECT_FALSE(options.shareDecklistsOnLoad); + EXPECT_FALSE(options.shufflePlayers); + EXPECT_TRUE(options.gameTypeIds.isEmpty()); +} + +TEST(GameCreationOptions, EveryFieldMapsToTheCommand) +{ + GameCreationOptions options; + options.description = "Some game"; + options.password = "hunter2"; + options.maxPlayers = 4; + options.onlyBuddies = true; + options.onlyRegistered = true; + options.spectatorsAllowed = false; + options.spectatorsNeedPassword = true; + options.spectatorsCanTalk = true; + options.spectatorsSeeEverything = true; + options.joinAsJudge = true; + options.joinAsSpectator = true; + options.startingLifeTotal = 40; + options.shareDecklistsOnLoad = true; + options.shufflePlayers = true; + + const Command_CreateGame cmd = options.toCreateGameCommand(); + + EXPECT_EQ(cmd.description(), "Some game"); + EXPECT_EQ(cmd.password(), "hunter2"); + EXPECT_EQ(cmd.max_players(), 4); + EXPECT_TRUE(cmd.only_buddies()); + EXPECT_TRUE(cmd.only_registered()); + EXPECT_FALSE(cmd.spectators_allowed()); + EXPECT_TRUE(cmd.spectators_need_password()); + EXPECT_TRUE(cmd.spectators_can_talk()); + EXPECT_TRUE(cmd.spectators_see_everything()); + EXPECT_TRUE(cmd.join_as_judge()); + EXPECT_TRUE(cmd.join_as_spectator()); + EXPECT_EQ(cmd.starting_life_total(), 40); + EXPECT_TRUE(cmd.share_decklists_on_load()); + EXPECT_TRUE(cmd.shuffle_players()); +} + +TEST(GameCreationOptions, GameTypeIdsKeepTheirOrder) +{ + GameCreationOptions options; + options.gameTypeIds = {7, 2, 5}; + + const Command_CreateGame cmd = options.toCreateGameCommand(); + + ASSERT_EQ(cmd.game_type_ids_size(), 3); + EXPECT_EQ(cmd.game_type_ids(0), 7); + EXPECT_EQ(cmd.game_type_ids(1), 2); + EXPECT_EQ(cmd.game_type_ids(2), 5); +} + +TEST(GameCreationOptions, EmptyGameTypeIdsProduceAnEmptyRepeatedField) +{ + GameCreationOptions options; + + const Command_CreateGame cmd = options.toCreateGameCommand(); + + EXPECT_EQ(cmd.game_type_ids_size(), 0); +} + +TEST(GameCreationOptions, DescriptionWhitespaceIsSimplified) +{ + GameCreationOptions options; + options.description = " a b "; + + const Command_CreateGame cmd = options.toCreateGameCommand(); + + EXPECT_EQ(cmd.description(), "a b"); +} + +TEST(GameCreationOptions, FromSettingsCopiesEveryRememberedField) +{ + const FakeGameSettings settings{GameCreationOptions{.description = "Remembered game", + .password = {}, + .maxPlayers = 6, + .onlyBuddies = true, + .onlyRegistered = true, + .spectatorsAllowed = false, + .spectatorsNeedPassword = true, + .spectatorsCanTalk = true, + .spectatorsSeeEverything = true, + .joinAsJudge = false, + .joinAsSpectator = true, + .startingLifeTotal = 40, + .shareDecklistsOnLoad = true, + .shufflePlayers = true, + .gameTypeIds = {}}}; + + const GameCreationOptions options = GameCreationOptions::fromSettings(settings, {}, true); + + EXPECT_EQ(options.description, "Remembered game"); + EXPECT_EQ(options.maxPlayers, 6); + EXPECT_TRUE(options.onlyBuddies); + EXPECT_TRUE(options.onlyRegistered); + EXPECT_FALSE(options.spectatorsAllowed); + EXPECT_TRUE(options.spectatorsNeedPassword); + EXPECT_TRUE(options.spectatorsCanTalk); + EXPECT_TRUE(options.spectatorsSeeEverything); + EXPECT_TRUE(options.joinAsSpectator); + EXPECT_EQ(options.startingLifeTotal, 40); + EXPECT_TRUE(options.shareDecklistsOnLoad); + EXPECT_TRUE(options.shufflePlayers); +} + +TEST(GameCreationOptions, FromSettingsDropsJoinRestrictionsForUnregisteredHosts) +{ + FakeGameSettings settings; + settings.options.onlyBuddies = true; + settings.options.onlyRegistered = true; + + const GameCreationOptions options = GameCreationOptions::fromSettings(settings, {}, false); + + EXPECT_FALSE(options.onlyBuddies); + EXPECT_FALSE(options.onlyRegistered); +} + +TEST(GameCreationOptions, FromSettingsMatchesGameTypesByTheLegacyNameList) +{ + FakeGameSettings settings; + settings.gameTypes = "Draft, Commander, "; + const QMap gameTypes{{1, "Draft"}, {2, "Commander"}, {3, "Standard"}}; + + const GameCreationOptions options = GameCreationOptions::fromSettings(settings, gameTypes, true); + + EXPECT_EQ(options.gameTypeIds, QList({1, 2})); +} + +TEST(GameCreationOptions, FromSettingsDoesNotMatchGameTypesWithoutTheLegacySeparator) +{ + FakeGameSettings settings; + settings.gameTypes = "Draft Commander"; + const QMap gameTypes{{1, "Draft"}, {2, "Commander"}}; + + const GameCreationOptions options = GameCreationOptions::fromSettings(settings, gameTypes, true); + + EXPECT_TRUE(options.gameTypeIds.isEmpty()); +} + +TEST(GameCreationOptions, FromGameInfoCopiesTheVisibleFields) +{ + ServerInfo_Game gameInfo; + gameInfo.set_description("Some game"); + gameInfo.set_max_players(4); + gameInfo.set_only_buddies(true); + gameInfo.set_only_registered(true); + gameInfo.set_spectators_allowed(false); + gameInfo.set_spectators_need_password(true); + gameInfo.set_spectators_can_chat(true); + gameInfo.set_spectators_omniscient(true); + gameInfo.set_share_decklists_on_load(true); + gameInfo.set_shuffle_players(true); + gameInfo.add_game_types(2); + + const QMap gameTypes{{1, "Draft"}, {2, "Commander"}}; + const GameCreationOptions options = GameCreationOptions::fromGameInfo(gameInfo, gameTypes); + + EXPECT_EQ(options.description, "Some game"); + EXPECT_EQ(options.maxPlayers, 4); + EXPECT_TRUE(options.onlyBuddies); + EXPECT_TRUE(options.onlyRegistered); + EXPECT_FALSE(options.spectatorsAllowed); + EXPECT_TRUE(options.spectatorsNeedPassword); + EXPECT_TRUE(options.spectatorsCanTalk); + EXPECT_TRUE(options.spectatorsSeeEverything); + EXPECT_TRUE(options.shareDecklistsOnLoad); + EXPECT_TRUE(options.shufflePlayers); + EXPECT_EQ(options.gameTypeIds, QList({2})); +} + +TEST(GameCreationOptions, FromGameInfoLeavesUnshownFieldsAtDefaults) +{ + ServerInfo_Game gameInfo; + gameInfo.set_max_players(3); + + const GameCreationOptions options = GameCreationOptions::fromGameInfo(gameInfo, {}); + + EXPECT_TRUE(options.password.isEmpty()); + EXPECT_EQ(options.startingLifeTotal, 20); + EXPECT_FALSE(options.joinAsJudge); + EXPECT_FALSE(options.joinAsSpectator); +} + +TEST(GameCreationOptions, FromGameInfoIgnoresUnknownGameTypeIds) +{ + ServerInfo_Game gameInfo; + gameInfo.add_game_types(7); + gameInfo.add_game_types(9); + + const QMap gameTypes{{1, "Draft"}, {7, "Commander"}}; + const GameCreationOptions options = GameCreationOptions::fromGameInfo(gameInfo, gameTypes); + + EXPECT_EQ(options.gameTypeIds, QList({7})); +} diff --git a/tests/latency_tracker_test.cpp b/tests/latency_tracker_test.cpp index a77051799..a8ecf9e8d 100644 --- a/tests/latency_tracker_test.cpp +++ b/tests/latency_tracker_test.cpp @@ -3,8 +3,12 @@ #include #include #include +#include #include +// Referenced by other translation units in the server_remote library. +RNG_Abstract *rng = nullptr; + TEST(LatencyTrackerTest, EmptyTrackerYieldsZeroedStats) { LatencyTracker tracker; diff --git a/tests/loader_local_matching_test.cpp b/tests/loader_local_matching_test.cpp index 7632e60d4..ef6018790 100644 --- a/tests/loader_local_matching_test.cpp +++ b/tests/loader_local_matching_test.cpp @@ -23,10 +23,14 @@ #include #include #include +#include #include #include #include +// Referenced by other translation units in the server_remote library. +RNG_Abstract *rng = nullptr; + namespace { diff --git a/tests/oracle/oracle_importer_test.cpp b/tests/oracle/oracle_importer_test.cpp index 259bae087..1b4e2d915 100644 --- a/tests/oracle/oracle_importer_test.cpp +++ b/tests/oracle/oracle_importer_test.cpp @@ -1,6 +1,7 @@ #include "../../oracle/src/database_writer.h" #include "../../oracle/src/oracleimporter.h" #include "../../oracle/src/raw_json_scanner.h" +#include "../support/scoped_file_size_limit.h" #include "libcockatrice/card/card_info.h" #include "gtest/gtest.h" @@ -33,12 +34,6 @@ #include #include -#ifdef Q_OS_UNIX -#include -#include -#include -#endif - class OracleImporterTest : public ::testing::Test { protected: @@ -1336,20 +1331,16 @@ TEST(OracleDatabaseWriter, ShortWriteKeepsPreviouslyInstalledDatabase) original.close(); const QByteArray data = ""; - // A real filesystem write failure, isolated in the death-test child so the - // process-wide file limit never affects other cases or the parent runner. - ASSERT_EXIT( - { - std::signal(SIGXFSZ, SIG_IGN); - struct rlimit limit; - limit.rlim_cur = 128; - limit.rlim_max = 128; - if (setrlimit(RLIMIT_FSIZE, &limit) != 0) { - _exit(2); - } - _exit(OracleDatabase::saveXml(path, data) ? 1 : 0); - }, - ::testing::ExitedWithCode(0), ""); + // A real filesystem write failure: the soft file-size cap makes the writer's own + // flush fail without filling a disk. The cap is scoped rather than forked, because a + // death-test child under RLIMIT_FSIZE is killed by SIGSEGV on macOS. + test_support::ScopedFileSizeLimit fileSizeCap(128); + ASSERT_TRUE(fileSizeCap.isValid()); + + QString error; + EXPECT_FALSE(OracleDatabase::saveXml(path, data, &error)); + EXPECT_FALSE(error.isEmpty()); + ASSERT_TRUE(original.open(QIODevice::ReadOnly)); EXPECT_EQ(original.readAll(), "installed database"); } diff --git a/tests/random_reveal_test.cpp b/tests/random_reveal_test.cpp new file mode 100644 index 000000000..62defe183 --- /dev/null +++ b/tests/random_reveal_test.cpp @@ -0,0 +1,151 @@ +#include "game/game_config.h" +#include "game/server_abstract_player.h" +#include "game/server_card.h" +#include "game/server_cardzone.h" +#include "game/server_game.h" +#include "movecard_tests/server_test_helpers.h" +#include "server_abstractuserinterface.h" +#include "server_response_containers.h" +#include "server_room.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +class FirstRng : public RNG_Abstract +{ + unsigned int rand(int min, int) override + { + return min; + } +} firstRng; + +class User : public Server_AbstractUserInterface +{ +public: + User(Server *server, const char *name) : Server_AbstractUserInterface(server) + { + ServerInfo_User info; + info.set_name(name); + setUserInfo(info); + } + int getLastCommandTime() const override + { + return 0; + } + bool addSaidMessageSize(int) override + { + return true; + } + void sendProtocolItem(const Response &) override + { + } + void sendProtocolItem(const SessionEvent &) override + { + } + void sendProtocolItem(const RoomEvent &) override + { + } + void sendProtocolItem(const GameEventContainer &) override + { + } +}; + +class RandomReveal : public ::testing::Test +{ +protected: + FakeServer server; + User owner{&server, "Owner"}, target{&server, "Target"}; + Server_Room room{0, 0, "", "", "", "", false, "", {}, &server}; + std::unique_ptr game; + Server_AbstractPlayer *player; + Server_CardZone *hand; + ResponseContainer response{1}; + + void SetUp() override + { + GameConfig config; + config.creatorInfo = *owner.getUserInfo(); + config.gameId = 1; + config.maxPlayers = 2; + config.shareDecklistsOnLoad = false; + game = std::make_unique(config, &room); + game->addPlayer(&owner, response, {}); + game->addPlayer(&target, response, {}); + for (auto *participant : game->getPlayers()) { + GameCommand command; + command.MutableExtension(Command_DeckSelect::ext) + ->set_deck(DeckList().writeToString_Native().toStdString()); + GameEventStorage events; + ASSERT_EQ(participant->processGameCommand(command, response, events), Response::RespOk); + participant->setReadyStart(true); + } + game->startGameIfReady(false); + QCoreApplication::processEvents(); + ASSERT_TRUE(game->getGameStarted()); + player = game->getPlayer(0); + hand = player->getZones().value(ZoneNames::HAND); + for (int i = 0; i < 4; ++i) { + hand->insertCard( + new Server_Card({.name = "Twin", .providerId = QString::number(i)}, player->newCardId(), i, 0), -1, 0); + } + } + + Response::ResponseCode reveal(int count, GameEventStorage &events) + { + GameCommand command; + auto *request = command.MutableExtension(Command_RevealRandomCards::ext); + request->set_zone_name(ZoneNames::HAND); + request->set_player_id(1); + request->set_number_of_cards(count); + return player->processGameCommand(command, response, events); + } +}; + +TEST_F(RandomReveal, DistinctPhysicalCardsArePrivateAndLeaveTheHandUnchanged) +{ + const auto original = hand->getCards(); + GameEventStorage events; + ASSERT_EQ(reveal(2, events), Response::RespOk); + ASSERT_EQ(events.getGameEventList().size(), 2); + EXPECT_EQ(events.getPrivatePlayerId(), 1); + const auto *privateEvent = events.getGameEventList().at(0); + EXPECT_EQ(privateEvent->getRecipients(), GameEventStorageItem::SendToPrivate); + const auto &cards = privateEvent->getGameEvent().GetExtension(Event_RevealCards::ext); + ASSERT_EQ(cards.cards_size(), 2); + EXPECT_NE(cards.cards(0).id(), cards.cards(1).id()); + const auto *publicEvent = events.getGameEventList().at(1); + EXPECT_EQ(publicEvent->getRecipients(), GameEventStorageItem::SendToOthers); + const auto ¬ice = publicEvent->getGameEvent().GetExtension(Event_RevealCards::ext); + EXPECT_EQ(notice.cards_size(), 0); + EXPECT_EQ(notice.number_of_cards(), 2); + EXPECT_EQ(hand->getCards(), original); +} + +TEST_F(RandomReveal, InvalidCountsDoNotQueueAnyReveal) +{ + for (int count : {0, -1, 5}) { + GameEventStorage events; + EXPECT_NE(reveal(count, events), Response::RespOk); + EXPECT_TRUE(events.getGameEventList().isEmpty()); + } +} +} // namespace + +RNG_Abstract *rng = &firstRng; + +int main(int argc, char **argv) +{ + QCoreApplication application(argc, argv); + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/server_counter_test.cpp b/tests/server_counter_test.cpp index 492aecf66..f894abdbf 100644 --- a/tests/server_counter_test.cpp +++ b/tests/server_counter_test.cpp @@ -8,9 +8,13 @@ #include #include #include +#include #include #include +// Referenced by other translation units in the server_remote library. +RNG_Abstract *rng = nullptr; + TEST(ServerCounter, IncrementDoesNotOverflow) { Server_Counter c(1, "test", color(), 10, std::numeric_limits::max()); diff --git a/tests/server_dump_zone_test.cpp b/tests/server_dump_zone_test.cpp index 71c410655..e3284efbb 100644 --- a/tests/server_dump_zone_test.cpp +++ b/tests/server_dump_zone_test.cpp @@ -7,8 +7,12 @@ #include #include #include +#include #include +// Referenced by other translation units in the server_remote library. +RNG_Abstract *rng = nullptr; + namespace { /// Indices a dump request resolves to, as the handler would walk them. diff --git a/tests/spoiler_file_test.cpp b/tests/spoiler_file_test.cpp index 9944f95bd..ecdc3285f 100644 --- a/tests/spoiler_file_test.cpp +++ b/tests/spoiler_file_test.cpp @@ -1,4 +1,5 @@ #include "client/network/update/card_spoiler/spoiler_file.h" +#include "support/scoped_file_size_limit.h" #include #include @@ -6,12 +7,6 @@ #include #include -#ifdef Q_OS_UNIX -#include -#include -#include -#endif - namespace { class SpoilerFileTest : public ::testing::Test @@ -123,25 +118,16 @@ TEST_F(SpoilerFileTest, FileInParentPathReportsFailureWithoutModifyingIt) #ifdef Q_OS_UNIX TEST_F(SpoilerFileTest, ShortWriteLeavesExistingFileIntact) { - // Limit only the child process: a real short write exercises QSaveFile's - // buffered flush failure without depending on disk capacity or file ownership. const QByteArray changed = ""; - EXPECT_EXIT( - { - struct rlimit limit; - if (getrlimit(RLIMIT_FSIZE, &limit) != 0) { - _exit(2); - } - limit.rlim_cur = 128; - if (setrlimit(RLIMIT_FSIZE, &limit) != 0) { - _exit(3); - } - std::signal(SIGXFSZ, SIG_IGN); - const auto result = save(changed); - _exit(result == SpoilerFile::SaveResult::Failed && !error.isEmpty() ? 0 : 1); - }, - ::testing::ExitedWithCode(0), ""); + // A real short write exercises QSaveFile's buffered flush failure without depending + // on disk capacity. The cap is scoped rather than forked, because a death-test child + // under RLIMIT_FSIZE is killed by SIGSEGV on macOS. + test_support::ScopedFileSizeLimit fileSizeCap(128); + ASSERT_TRUE(fileSizeCap.isValid()); + + EXPECT_EQ(save(changed), SpoilerFile::SaveResult::Failed); + EXPECT_FALSE(error.isEmpty()); EXPECT_EQ(read(), original); EXPECT_EQ(QDir(directory.path()).entryList(QDir::Files), QStringList{"spoiler.xml"}); } diff --git a/tests/support/scoped_file_size_limit.h b/tests/support/scoped_file_size_limit.h new file mode 100644 index 000000000..1c1f3d939 --- /dev/null +++ b/tests/support/scoped_file_size_limit.h @@ -0,0 +1,83 @@ +#ifndef COCKATRICE_TEST_SCOPED_FILE_SIZE_LIMIT_H +#define COCKATRICE_TEST_SCOPED_FILE_SIZE_LIMIT_H + +#include + +#ifdef Q_OS_UNIX + +#include +#include +#include + +namespace test_support +{ +/** + * @brief Caps how large a single file may grow, so a writer's short-write path is reachable. + * + * Lowering RLIMIT_FSIZE makes the next write past the cap fail with EFBIG instead of + * filling a filesystem, which is the only way to fail an install step mid-write without + * depending on free disk space or file ownership. Only the soft limit is lowered, so the + * destructor can hand the process back exactly as it found it: ctest runs one case per + * process, but a whole test binary must not carry the cap into its neighbours. + * + * Do not use this inside a gtest death test. A forked child under RLIMIT_FSIZE is killed + * by SIGSEGV on the macOS runners, while the same case passes on Linux, so the fork is + * what breaks - not the writer. The guard alone isolates the failure just as well. + */ +class ScopedFileSizeLimit +{ +public: + explicit ScopedFileSizeLimit(rlim_t maxBytes) + { + if (getrlimit(RLIMIT_FSIZE, &previousLimit) != 0) { + return; + } + + struct sigaction ignore = {}; + ignore.sa_handler = SIG_IGN; + sigemptyset(&ignore.sa_mask); + if (sigaction(SIGXFSZ, &ignore, &previousAction) != 0) { + return; + } + actionSaved = true; + + struct rlimit capped = previousLimit; + capped.rlim_cur = std::min(maxBytes, previousLimit.rlim_max); + if (setrlimit(RLIMIT_FSIZE, &capped) != 0) { + return; + } + active = true; + } + + ~ScopedFileSizeLimit() + { + if (active) { + setrlimit(RLIMIT_FSIZE, &previousLimit); + } + if (actionSaved) { + sigaction(SIGXFSZ, &previousAction, nullptr); + } + } + + ScopedFileSizeLimit(const ScopedFileSizeLimit &) = delete; + ScopedFileSizeLimit &operator=(const ScopedFileSizeLimit &) = delete; + + /** + * @brief Whether the cap is in force; a test that needs the failure must assert this. + */ + [[nodiscard]] bool isValid() const + { + return active; + } + +private: + bool active = false; + bool actionSaved = false; + struct rlimit previousLimit = {}; + struct sigaction previousAction = {}; +}; +} // namespace test_support + +#endif // Q_OS_UNIX + +#endif // COCKATRICE_TEST_SCOPED_FILE_SIZE_LIMIT_H diff --git a/tests/theme_migration/theme_migration_test.cpp b/tests/theme_migration/theme_migration_test.cpp index d77c2f62d..a953c9f0b 100644 --- a/tests/theme_migration/theme_migration_test.cpp +++ b/tests/theme_migration/theme_migration_test.cpp @@ -9,12 +9,16 @@ #include #include #include +#include #include // Owned by main.cpp in the real application; pixel_map_generator.cpp references // it, so the test binary needs a definition even though it never renders. ThemeManager *themeManager = nullptr; +// Referenced by other translation units in the server_remote library. +RNG_Abstract *rng = nullptr; + namespace { const QString LEGACY = QStringLiteral("Default");