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/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 4ac28d3a5..861b28fbb 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 @@ -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,13 +115,35 @@ 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 @@ -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,11 +326,13 @@ 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 @@ -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 @@ -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,14 +730,21 @@ 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 @@ -624,18 +759,31 @@ jobs: 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 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 @@ -654,7 +802,7 @@ jobs: id: restore_qt uses: actions/cache/restore@v6 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 @@ -663,6 +811,7 @@ jobs: if: matrix.os == 'macOS' && steps.restore_qt.outputs.cache-hit != 'true' uses: jurplel/install-qt-action@v4 with: + archives: ${{ env.QT_ARCHIVES }} cache: false # cache-key-prefix: Qt dir: ${{ github.workspace }} # thinning script depends on this location @@ -686,6 +835,7 @@ jobs: 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" @@ -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 @@ -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,8 +1136,8 @@ 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' + - name: "Cache updated compiler cache (ccache)" + if: matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true' && github.ref == 'refs/heads/master' uses: actions/cache/save@v6 with: key: ${{ steps.restore_ccache.outputs.cache-primary-key }} @@ -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..f42f26e6a 100644 --- a/.github/workflows/desktop-lint.yml +++ b/.github/workflows/desktop-lint.yml @@ -12,7 +12,7 @@ on: - '!.ci/**' - '!.github/**' - '!.tx/**' - - '!doc/**' + - '!docs/**' - '.ci/lint_cpp.sh' - '.github/workflows/desktop-lint.yml' - '.clang-format' diff --git a/.github/workflows/documentation-build.yml b/.github/workflows/documentation-build.yml index 91ee14e3e..b2f85e267 100644 --- a/.github/workflows/documentation-build.yml +++ b/.github/workflows/documentation-build.yml @@ -6,7 +6,7 @@ permissions: on: pull_request: paths: - - 'doc/doxygen/**' + - 'docs/**' - '.github/workflows/documentation-build.yml' - 'Doxyfile' release: @@ -71,7 +71,7 @@ jobs: 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/.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..984784161 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -291,9 +291,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 +386,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/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/card_picture_loader/card_picture_loader_worker.cpp b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp index 3ab5eb684..54d2e5cf5 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp @@ -176,7 +176,7 @@ QNetworkReply *CardPictureLoaderWorker::makeRequest(const QUrl &url, CardPicture QNetworkRequest req(url); req.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING)); - req.setRawHeader("Accept", "image/avif,image/webp,image/apng,image/,/*;q=0.8"); + req.setRawHeader("Accept", "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"); // Cached entries are served straight from the disk cache even when picture downloads are // enabled: re-fetching an already-cached image would burn the rate limit for nothing. Only a 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/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/doc/doxygen/pages/user_documentation/gameplay/revealing_cards.md b/docs/doxygen/pages/user_documentation/gameplay/revealing_cards.md similarity index 100% rename from doc/doxygen/pages/user_documentation/gameplay/revealing_cards.md rename to docs/doxygen/pages/user_documentation/gameplay/revealing_cards.md diff --git a/doc/doxygen/pages/user_documentation/index.md b/docs/doxygen/pages/user_documentation/index.md similarity index 100% rename from doc/doxygen/pages/user_documentation/index.md rename to docs/doxygen/pages/user_documentation/index.md 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/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_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/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/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/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/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/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