diff --git a/.ci/Debian12/Dockerfile b/.ci/Debian12/Dockerfile index 14c6f018c..4da910dc0 100644 --- a/.ci/Debian12/Dockerfile +++ b/.ci/Debian12/Dockerfile @@ -3,11 +3,13 @@ FROM debian:12 RUN apt-get update && \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ build-essential \ + ca-certificates \ ccache \ cmake \ file \ g++ \ git \ + libcurl4-openssl-dev \ libgl-dev \ liblzma-dev \ libmariadb-dev-compat \ diff --git a/.ci/Debian13/Dockerfile b/.ci/Debian13/Dockerfile index b597307c5..94f711398 100644 --- a/.ci/Debian13/Dockerfile +++ b/.ci/Debian13/Dockerfile @@ -9,6 +9,7 @@ RUN apt-get update && \ file \ g++ \ git \ + libcurl4-openssl-dev \ libgl-dev \ liblzma-dev \ libmariadb-dev-compat \ diff --git a/.ci/Fedora43/Dockerfile b/.ci/Fedora43/Dockerfile index 463da5a51..8aa345651 100644 --- a/.ci/Fedora43/Dockerfile +++ b/.ci/Fedora43/Dockerfile @@ -1,10 +1,12 @@ FROM fedora:43 RUN dnf install -y \ + ca-certificates \ ccache \ cmake \ gcc-c++ \ git \ + libcurl-devel \ mariadb-devel \ ninja-build \ openssl-devel \ diff --git a/.ci/Fedora44/Dockerfile b/.ci/Fedora44/Dockerfile index 62238e760..15204fa1a 100644 --- a/.ci/Fedora44/Dockerfile +++ b/.ci/Fedora44/Dockerfile @@ -1,10 +1,12 @@ FROM fedora:44 RUN dnf install -y \ + ca-certificates \ ccache \ cmake \ gcc-c++ \ git \ + libcurl-devel \ mariadb-devel \ ninja-build \ openssl-devel \ diff --git a/.ci/Ubuntu24.04/Dockerfile b/.ci/Ubuntu24.04/Dockerfile index b9fb2b701..43497dd3b 100644 --- a/.ci/Ubuntu24.04/Dockerfile +++ b/.ci/Ubuntu24.04/Dockerfile @@ -3,11 +3,13 @@ FROM ubuntu:24.04 RUN apt-get update && \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ build-essential \ + ca-certificates \ ccache \ cmake \ file \ g++ \ git \ + libcurl4-openssl-dev \ libgl-dev \ liblzma-dev \ libmariadb-dev-compat \ diff --git a/.ci/Ubuntu26.04/Dockerfile b/.ci/Ubuntu26.04/Dockerfile index cc6528858..4dc861ae9 100644 --- a/.ci/Ubuntu26.04/Dockerfile +++ b/.ci/Ubuntu26.04/Dockerfile @@ -10,6 +10,7 @@ RUN apt-get update && \ g++ \ gcovr \ git \ + libcurl4-openssl-dev \ libgl-dev \ liblzma-dev \ libmariadb-dev-compat \ diff --git a/.ci/compile.sh b/.ci/compile.sh index 6789b9817..bf5a169af 100755 --- a/.ci/compile.sh +++ b/.ci/compile.sh @@ -200,6 +200,20 @@ if [[ $USE_VCPKG ]]; then flags+=("-DUSE_VCPKG=1") flags+=("-DVCPKG_INSTALL_OPTIONS=--x-abi-tools-use-exact-versions") fi +if [[ ${WITH_CRASH_REPORTING:-} == "ON" ]]; then + # Release CI opts into the sentry-native SDK (default is OFF so from-source, distro and + # offline package builds never fetch it). Debug builds stay SDK-free so crashes from + # developer builds never reach Sentry. + flags+=("-DWITH_CRASH_REPORTING=ON") +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 + # CI builds, which never receive the secret) keeps today's mock fallback via the + # compiled-in default. CI passes the value from the SENTRY_DSN secret on tag + # Release builds only, so pre-release test artifacts never report to Sentry. + flags+=("-DCOCKATRICE_SENTRY_DSN=$COCKATRICE_SENTRY_DSN") +fi # Add cmake --build flags buildflags=(--config "$BUILDTYPE") @@ -445,6 +459,206 @@ if [[ $MAKE_COVERAGE ]]; then echo "::endgroup::" fi +if [[ $RUNNER_OS == Windows && ! $MAKE_NO_CLIENT ]]; 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 + # build/build. Both paths are relative to the dir the probe was built into. + probe="./installer-holder-probe.exe" + if [[ ! -f $probe ]]; then + echo "::error file=$0::installer-holder-probe.exe was not built" + exit 1 + fi + # The probe exists to name a holder that a process-and-module scan cannot see: one + # that has a file open without having loaded it. Holding a temporary DLL with + # FileShare.None is exactly that case, so this asserts the reason the probe was + # added instead of only that the Restart Manager answers at all. It needs no NSIS + # and no packaging, so it runs on every Windows build that gets this far. + # The holder is a process of its own: the probe never names the process that started it, + # which is what keeps an in-place uninstaller from being named as a holder of the + # directory it is emptying. The same DLL held by the script that starts the probe + # therefore has to come back as nothing, and that is the second case below. + probe_check="./holder-probe-check.ps1" + cat >"$probe_check" <<'POWERSHELL' +$ErrorActionPreference = "Stop" +$dir = Join-Path ([IO.Path]::GetTempPath()) "cockatrice-holder-probe-$PID" +Remove-Item -Recurse -Force $dir -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force $dir | Out-Null +$probe = (Resolve-Path -LiteralPath $env:PROBE_EXE).Path + +function Invoke-Probe([string] $guardDir) { + $start = New-Object System.Diagnostics.ProcessStartInfo + $start.FileName = $probe + $start.UseShellExecute = $false + $start.RedirectStandardOutput = $true + $start.EnvironmentVariables["COCKATRICE_GUARD_DIR"] = $guardDir + $process = [System.Diagnostics.Process]::Start($start) + $read = $process.StandardOutput.ReadToEndAsync() + if (-not $process.WaitForExit(20000)) { + try { $process.Kill() } catch { } + throw "the holder probe did not finish within 20 s for $guardDir" + } + $output = $read.Result + $code = $process.ExitCode + $process.Dispose() + return @{ Code = $code; Output = $output } +} + +function Wait-UntilLocked([string] $path) { + $deadline = (Get-Date).AddSeconds(15) + while ((Get-Date) -lt $deadline) { + try { + $open = [IO.File]::Open($path, "Open", "ReadWrite", "None") + $open.Dispose() + } catch { + return + } + Start-Sleep -Milliseconds 100 + } + throw "the holder never locked $path" +} + +try { + $file = Join-Path $dir "locked.dll" + [IO.File]::WriteAllText($file, "x") + + # The holder is a process of its own, not this script: the probe never names the process + # that started it (the case below asserts that), so a file held here would be left out on + # purpose and the check would pass for the wrong reason. + $holderScript = Join-Path $dir "hold.ps1" + Set-Content -LiteralPath $holderScript -Value @' +param([string] $Path) +$handle = [IO.File]::Open($Path, "Open", "ReadWrite", "None") +Start-Sleep -Seconds 120 +'@ + $powershell = Join-Path $env:SystemRoot "System32\WindowsPowerShell\v1.0\powershell.exe" + $holder = Start-Process -FilePath $powershell -PassThru -ArgumentList @( + "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "`"$holderScript`"", "-Path", "`"$file`"") + try { + Wait-UntilLocked $file + $locked = Invoke-Probe $dir + if ($locked.Code -ne 1) { throw "expected exit 1 for a locked file, got $($locked.Code) : $($locked.Output)" } + if ($locked.Output -notmatch "pid\s+$($holder.Id)\b") { + throw "the lookup did not name the process holding the file: $($locked.Output)" + } + } finally { + try { $holder.Kill() } catch { } + try { $holder.WaitForExit(5000) | Out-Null } catch { } + } + + # The probe is started by the installer, or by an uninstaller that runs in place from the + # directory it is emptying, so the asking process holds files there itself. Naming it would + # ask the user to close the program that is asking - a question neither the close nor the + # end offer can answer, so the guard repeated it until the update was cancelled. A holder + # that is only the asking process has to come back as nothing. + $handle = [IO.File]::Open($file, "Open", "ReadWrite", "None") + try { + $self = Invoke-Probe $dir + } finally { + $handle.Dispose() + } + if ($self.Code -ne 0) { throw "expected exit 0 when the asking process is the only holder, got $($self.Code) : $($self.Output)" } + if ($self.Output -match "pid\s+$PID\b") { throw "the lookup named the process that started it: $($self.Output)" } + + # A payload executable that refuses to be replaced is running, whether or not this probe + # may name it: the Restart Manager does not report every mapping, and the image path of a + # process under another account cannot be read from here, so both process lookups can + # come back empty while the program is still there. Held here by the asking process, so + # that it cannot be named - the answer has to be "could not name it", never "nothing is + # holding it", which is what sent an update into an uninstall that could only fail + # halfway. Exit 3 is the code the guard answers by asking the user to close it and retry. + $image = Join-Path $dir "oracle.exe" + [IO.File]::WriteAllText($image, "x") + $handle = [IO.File]::Open($image, "Open", "ReadWrite", "None") + try { + $unnameable = Invoke-Probe $dir + } finally { + $handle.Dispose() + } + if ($unnameable.Code -ne 3) { + throw "expected exit 3 for a payload executable that is in use but cannot be named, got $($unnameable.Code) : $($unnameable.Output)" + } + if ($unnameable.Output -notmatch "oracle\.exe is in use") { + throw "the answer did not name the executable it could not replace: $($unnameable.Output)" + } + + $empty = Join-Path $dir "empty" + New-Item -ItemType Directory -Force $empty | Out-Null + $nothing = Invoke-Probe $empty + if ($nothing.Code -ne 0) { throw "expected exit 0 for a directory with nothing open, got $($nothing.Code) : $($nothing.Output)" } + # A process running from the directory is a holder even when it has no file open: an + # instance holds its payload as mapped images, not as handles, and that is the state + # the guard used to miss - it passed, the purge failed, and the install ended in a + # warning with no offer to close and try again. The image is a copy of a native + # Windows program, so it starts without its usual directory next to it, and it carries + # one of the payload's own names because that is what the lookup names. + $copy = Join-Path $dir "cockatrice.exe" + Copy-Item -LiteralPath (Join-Path $env:SystemRoot "System32\cmd.exe") -Destination $copy + $running = Start-Process -FilePath $copy -ArgumentList "/c", "ping -n 20 127.0.0.1 >nul" -PassThru + try { + Start-Sleep -Milliseconds 1500 + $byImage = Invoke-Probe $dir + } finally { + try { $running.Kill() } catch { } + # Wait for the image to be released, or the cleanup below leaves the copy behind. + try { $running.WaitForExit(5000) | Out-Null } catch { } + } + if ($byImage.Code -ne 1) { throw "expected exit 1 for a process running from the directory, got $($byImage.Code) : $($byImage.Output)" } + if ($byImage.Output -notmatch "cockatrice\.exe \(pid\s+$($running.Id)") { + throw "the lookup did not name the process running from the directory: $($byImage.Output)" + } + + # A program in a folder below the directory is not an instance of the installation, even + # when it carries a payload program's name: the install directory can be a folder the + # user keeps other applications in, each in its own subfolder, and naming one of those + # asked for a close the probe never sends and an end it refuses to perform, so the guard + # could only abort the update. A program that really does hold a payload file is still + # named, by the Restart Manager lookup exercised above. + $nested = Join-Path $dir "other" + New-Item -ItemType Directory -Force $nested | Out-Null + $nestedImage = Join-Path $nested "cockatrice.exe" + Copy-Item -LiteralPath (Join-Path $env:SystemRoot "System32\cmd.exe") -Destination $nestedImage + $nestedRunning = Start-Process -FilePath $nestedImage -ArgumentList "/c", "ping -n 20 127.0.0.1 >nul" -PassThru + try { + Start-Sleep -Milliseconds 1500 + $inSubfolder = Invoke-Probe $dir + } finally { + try { $nestedRunning.Kill() } catch { } + try { $nestedRunning.WaitForExit(5000) | Out-Null } catch { } + } + if ($inSubfolder.Code -ne 0) { + throw "expected exit 0 for a program in a folder below the directory, got $($inSubfolder.Code) : $($inSubfolder.Output)" + } + + # A program the user keeps in the directory itself is not a payload program either: the + # guard ends only what the payload installs, and naming anything else asks for an answer + # that cannot free a single file. + $foreign = Join-Path $dir "tool.exe" + Copy-Item -LiteralPath (Join-Path $env:SystemRoot "System32\cmd.exe") -Destination $foreign + $foreignRunning = Start-Process -FilePath $foreign -ArgumentList "/c", "ping -n 20 127.0.0.1 >nul" -PassThru + try { + Start-Sleep -Milliseconds 1500 + $ignored = Invoke-Probe $dir + } finally { + try { $foreignRunning.Kill() } catch { } + try { $foreignRunning.WaitForExit(5000) | Out-Null } catch { } + } + if ($ignored.Code -ne 0) { + throw "expected exit 0 for a program the payload does not install, got $($ignored.Code) : $($ignored.Output)" + } + Write-Host "holder lookup named pid $($holder.Id) for a file another process holds, named nothing for a file only the asking process holds, reported a payload executable it could not name as in use instead of free, named nothing for an empty directory, named pid $($running.Id) for a payload program running from the directory, and named nothing for a payload program in a folder below it or for a program the payload does not install" +} finally { + Remove-Item -Recurse -Force $dir -ErrorAction SilentlyContinue +} +POWERSHELL + if ! PROBE_EXE="$probe" powershell -NoProfile -File "$probe_check"; then + rm -f "$probe_check" + exit 1 + fi + rm -f "$probe_check" + echo "::endgroup::" +fi + if [[ $MAKE_INSTALL ]]; then echo "::group::Install" cmake --build . --target install --config "$BUILDTYPE" diff --git a/.ci/docker.sh b/.ci/docker.sh index 412ad6a1d..64ea6baa7 100644 --- a/.ci/docker.sh +++ b/.ci/docker.sh @@ -175,6 +175,12 @@ function RUN () args+=(--mount "type=bind,source=$GITHUB_OUTPUT,target=/gh_output") args+=(--env "GITHUB_OUTPUT=/gh_output") fi + # The container does not inherit host env vars: without this, WITH_CRASH_REPORTING=ON set in + # the workflow never reaches compile.sh inside the container, so the Linux packages silently + # build with crash reporting OFF while the libcurl packages in the Dockerfiles go unused. + if [[ ${WITH_CRASH_REPORTING:-} ]]; then + args+=(--env "WITH_CRASH_REPORTING=${WITH_CRASH_REPORTING}") + fi # shellcheck disable=2086 docker run "${args[@]}" $RUN_ARGS "$IMAGE_NAME" bash "$BUILD_SCRIPT" $RUN_OPTS "$@" return $? diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 75fbc59f1..75494dc3e 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -60,8 +60,10 @@ jobs: run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ + ca-certificates \ cmake \ g++ \ + libcurl4-openssl-dev \ libgl-dev \ liblzma-dev \ libmariadb-dev-compat \ diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 0b9502fe3..54068f5a0 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -46,6 +46,11 @@ jobs: outputs: tag: ${{ steps.configure.outputs.tag }} sha: ${{ steps.configure.outputs.sha }} + # MAJ.MIN.PATCH[-beta[.N]] of a tag build; empty otherwise. Mirrors what + # cmake/getversion.cmake derives as PROJECT_VERSION, which the client bakes as its + # Sentry release (cmake/CrashReporting.cmake) — symbol uploads must use the same + # string so stack traces de-symbolicate against this build's binaries. + sentry_release: ${{ steps.configure.outputs.sentry_release }} steps: - name: "Configure" @@ -56,6 +61,9 @@ jobs: run: | if [[ "$GITHUB_REF_TYPE" == 'tag' ]]; then # release echo "tag=$GITHUB_REF_NAME" >> "$GITHUB_OUTPUT" + # Tag format is YYYY-MM-DD-{Release|Development}-MAJ.MIN.PATCH[-beta[.N]]; + # PROJECT_VERSION is the tail after the type (see cmake/getversion.cmake). + echo "sentry_release=$(sed -E 's/^[0-9]{4}-[0-9]{2}-[0-9]{2}-(Release|Development)-//' <<< "$GITHUB_REF_NAME")" >> "$GITHUB_OUTPUT" fi echo "sha=$RESOLVED_SHA" >> "$GITHUB_OUTPUT" @@ -239,6 +247,12 @@ jobs: SUFFIX: '-${{ matrix.distro }}${{ matrix.version }}' package: '${{ matrix.package }}' server_only: '${{ matrix.server_only }}' + WITH_CRASH_REPORTING: 'ON' + # Tag builds only: artifacts from master pushes and PR branches must not report to + # Sentry. Forks get no secrets on pull_request, so they keep the mock fallback either way. + # configure.outputs.tag is set for ANY tag, so beta (Development) tags get the DSN + # and the symbol upload below just like full releases. + COCKATRICE_SENTRY_DSN: ${{ needs.configure.outputs.tag != null && secrets.SENTRY_DSN || '' }} run: | source .ci/docker.sh args=() @@ -247,9 +261,58 @@ jobs: args+=(--ccache "$CCACHE_SIZE") args+=(--cmake-generator "$CMAKE_GENERATOR") args+=(--suffix "$SUFFIX") + # The container inherits no environment, so the DSN rides in explicitly. -e without + # =value passes it through (possibly empty); compile.sh only consumes it for Release + # configures, and empty keeps the mock fallback. + export RUN_ARGS="${RUN_ARGS:-} -e COCKATRICE_SENTRY_DSN" RUN --server --release --package "$package" "${args[@]}" + - name: "Install sentry-cli" + if: matrix.package != 'skip' && needs.configure.outputs.tag != null + # Downloads from Sentry's release registry: a Sentry outage or a broken install under + # Git Bash must not take down the release assets below. + continue-on-error: true + shell: bash + env: + SENTRY_CLI_VERSION: '3.8.0' + run: | + export INSTALL_DIR="$GITHUB_WORKSPACE/.sentry-cli" + curl -sL https://sentry.io/get-cli/ | bash + echo "$GITHUB_WORKSPACE/.sentry-cli" >> "$GITHUB_PATH" + + - name: "Upload debug symbols to Sentry" + # Like the DSN above, this runs for every tag — beta (Development) tags included. + if: matrix.package != 'skip' && needs.configure.outputs.tag != null + # A Sentry outage or rotated token must not take down the release assets below. + continue-on-error: true + shell: bash + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + run: | + paths=() + # The binaries only, not the directories: a directory makes sentry-cli walk the + # whole build tree, where it finds (and uploads) object files and other fragments + # that can never match a stack frame. The packaged copy is split and stripped by + # cpack, but keeps this binary's GNU build-id, which is how Sentry matches it. + for app in cockatrice oracle servatrice; do + binary="build/$app/$app" + [[ -f $binary ]] && paths+=("$binary") + done + if [[ ${#paths[@]} == 0 ]]; then + # Fail loudly instead of skipping: see the Windows/macOS leg below — this step + # is the only way symbols reach Sentry and `continue-on-error` keeps the job + # green either way, so a silent skip looks exactly like a successful upload. + echo "::error::No debug symbol files found; nothing would be uploaded" + exit 1 + fi + # Project slug on Sentry; must match the project the baked DSN points at. + # No --release here: `debug-files upload` has no such option (sentry-cli errors on + # the unknown flag and uploads nothing), and debug information files are matched to + # events by debug ID via the event's debug_meta, not by release. The release grouping + # comes from the client event itself (COCKATRICE_SENTRY_RELEASE). + sentry-cli debug-files upload --org cockatrice --project cockatrice-desktop "${paths[@]}" + # Delete used cache to emulate a ccache update. See https://github.com/actions/cache/issues/342 - name: "Delete remote compiler cache (ccache)" if: github.ref == 'refs/heads/master' && steps.restore_ccache.outputs.cache-hit @@ -521,11 +584,17 @@ jobs: # job. A scope nobody has written to yet therefore leaves the directory missing # and fails the job, which is exactly the state every scope starts in. Make the # directory up front so an empty scope is a cache miss instead of an error. + # always() covers the cache step itself failing: the action's post save still runs + # then, and it should report the real error instead of ENOENT. It cannot rescue a + # failure before the cache step, since a post step only runs for an action whose + # main step did. The fallback keeps this guard from failing when the cache step was + # skipped and left no path output. - name: "Create vcpkg cache directory" + if: always() shell: bash env: CACHE_DIR: '${{ steps.vcpkg-cache.outputs.path }}' - run: mkdir -p "$CACHE_DIR" + run: mkdir -p "${CACHE_DIR:-$GITHUB_WORKSPACE/.vcpkg-cache}" # Uses environment variables, see compile.sh for more details - name: "Build Cockatrice" @@ -535,6 +604,11 @@ jobs: BUILDTYPE: '${{ matrix.type }}' CCACHE_EVICTION_AGE: ${{ matrix.ccache_eviction_age }} CMAKE_GENERATOR: ${{ matrix.cmake_generator }} + # Tag builds only: artifacts from master pushes and PR branches must not report to + # Sentry. Forks get no secrets on pull_request, so they keep the mock fallback either way. + # configure.outputs.tag is set for ANY tag, so beta (Development) tags get the DSN + # and the symbol upload below just like full releases. + COCKATRICE_SENTRY_DSN: ${{ needs.configure.outputs.tag != null && secrets.SENTRY_DSN || '' }} DEVELOPER_DIR: '/Applications/Xcode_${{ matrix.xcode }}.app/Contents/Developer' GITHUB_TOKEN: ${{ github.token }} # needed for vcpkg dependency graph updates, see VCPKG_FEATURE_FLAGS MACOS_CERTIFICATE: ${{ secrets.PROD_MACOS_CERTIFICATE }} @@ -558,8 +632,125 @@ jobs: VCPKG_DISABLE_METRICS: 1 VCPKG_FEATURE_FLAGS: dependencygraph VCPKG_TARGET_MACOS_VERSION: ${{ matrix.vcpkg_deployment_target }} + # Release legs compile the sentry-native SDK; Debug legs stay SDK-free. + WITH_CRASH_REPORTING: ${{ matrix.type == 'Release' && 'ON' || '' }} run: .ci/compile.sh --server --test --vcpkg + - 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 + # failure here must not block the release upload below. + continue-on-error: true + shell: bash + env: + SENTRY_CLI_VERSION: '3.8.0' + run: | + export INSTALL_DIR="$GITHUB_WORKSPACE/.sentry-cli" + curl -sL https://sentry.io/get-cli/ | bash + echo "$GITHUB_WORKSPACE/.sentry-cli" >> "$GITHUB_PATH" + + - name: "Upload debug symbols to Sentry" + # Like the DSN above, this runs for every tag — beta (Development) tags included. + if: needs.configure.outputs.tag != null && matrix.make_package == '1' + # A Sentry outage or rotated token must not take down the release assets below. + continue-on-error: true + shell: bash + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + run: | + paths=() + if [[ '${{ matrix.os }}' == 'Windows' ]]; then + # Top-level CMakeLists.txt links /DEBUG even in release, so the linker PDBs + # land next to the binaries. Ninja is single-config, so that is + # build// and NOT build//Release/ — the same globs the symbol + # artifact step below uses. + # + # The executables are uploaded as well, in the same invocation. On 64-bit + # Windows the Call Frame Information (.pdata/.xdata) lives in the PE and is + # not copied into the PDB, so a PDB on its own leaves Sentry reporting + # "Stack Unwinding: Missing" for the image. sentry-cli also needs to see the + # PE and its PDB together: only then does it associate the PE's code + # identifier with the PDB's debug identifier. + shopt -s nullglob + for target in cockatrice oracle servatrice; do + paths+=("build/$target"/*.exe "build/$target"/*.pdb) + done + shopt -u nullglob + + # Qt's own runtime DLLs, taken from required-runtime.txt — the manifest + # .ci/compile.sh asserts the installer against — so the set uploaded is + # the set an install actually carries. The PE is the only carrier of + # unwind information for a 64-bit image, and sending it in the same + # invocation as a PDB is what lets sentry-cli associate the PE's code + # identifier with the PDB's debug identifier. Qt ships no PDBs with the + # SDK, so Qt frames resolve through the export table: names, no lines. + qt_dir= + qt_core_dir= + # Existence guards matter: the step runs under `bash -eo pipefail`, so a + # redirection-less sed over a missing file would abort the step outright + # and continue-on-error would hide it. + if [[ -f build/CMakeCache.txt ]]; then + qt_core_dir=$(sed -n 's/^Qt6Core_DIR:PATH=//p' build/CMakeCache.txt | head -1) + fi + if [[ -n $qt_core_dir ]]; then + qt_dir=$(dirname "$(dirname "$(dirname "$qt_core_dir")")") + fi + if [[ -z $qt_dir || ! -d $qt_dir ]]; then + # Warnings, not failures: the upload of our own binaries is what this + # step is for, and continue-on-error already means an upload failure + # cannot be told apart from a successful one. + echo "::warning::Qt runtime directory not resolved from build/CMakeCache.txt; Qt images get no unwind information" + elif [[ ! -f build/required-runtime.txt ]]; then + echo "::warning::build/required-runtime.txt is missing; cannot tell which Qt DLLs ship" + else + while IFS= read -r runtime; do + name=${runtime##*/} + if [[ $runtime == */* ]]; then + # A Qt plugin: the install root keeps it as plugins//. + # `|| true`: an unmatched glob makes ls exit non-zero, and pipefail + # turns that into a step abort. + runtime_path=$(ls "$qt_dir/plugins"/*/"$name" 2>/dev/null | head -1) || true + else + runtime_path="$qt_dir/bin/$name" + fi + if [[ -f ${runtime_path:-} ]]; then + paths+=("$runtime_path") + else + echo "::warning::shipped Qt runtime $runtime not found under $qt_dir" + fi + done < <(grep -E '^(Qt6[^/]*\.dll|Plugins/)' build/required-runtime.txt) + fi + else + # Release builds carry DWARF (-g, kept by CrashReporting.cmake); macOS keeps its + # debug info in dSYM bundles, so materialize them before uploading. + for app in cockatrice oracle servatrice; do + binary="build/$app/$app.app/Contents/MacOS/$app" + if [[ -f $binary ]]; then + if [[ '${{ matrix.os }}' == 'macOS' ]]; then + dsymutil "$binary" -o "$binary.dSYM" + paths+=("$binary.dSYM") + # The dSYM holds the debug information, but the unwind information + # Sentry needs to walk a minidump stack lives in the Mach-O itself. + paths+=("$binary") + else + paths+=("$binary") + fi + fi + done + fi + if [[ ${#paths[@]} == 0 ]]; then + # Fail loudly instead of skipping: this step is the only way symbols reach + # Sentry, and `continue-on-error` keeps the job green either way, so a + # silent skip is indistinguishable from a successful upload. It also hid a + # wrong Windows path (build//Release, which Ninja never creates). + echo "::error::No debug symbol files found; nothing would be uploaded" + exit 1 + fi + # Project slug on Sentry; must match the project the baked DSN points at. + # No --release: see the Linux leg above — the flag does not exist on + # `debug-files upload` and DIFs are matched by debug ID, not release. + sentry-cli debug-files upload --org cockatrice --project cockatrice-desktop "${paths[@]}" + # CPack reports a failed makensis run as "Problem running NSIS command" # plus a path to NSISOutput.log, and that log lives only on the runner, so # the actual compile error is otherwise nowhere in the workflow output. @@ -703,19 +894,6 @@ jobs: if-no-files-found: error path: ${{ steps.build.outputs.path }} - # Ninja is single-config: binaries and their PDBs land directly in the - # target directories instead of a Release subdirectory. - - name: "[Windows] Upload PDBs (Program Databases)" - if: matrix.os == 'Windows' && github.ref_type != 'tag' - uses: actions/upload-artifact@v7 - with: - if-no-files-found: error - name: ${{ steps.build.outputs.name }}-PDBs - path: | - build/cockatrice/*.pdb - build/oracle/*.pdb - build/servatrice/*.pdb - - name: "Upload to release" if: needs.configure.outputs.tag != null && matrix.make_package == '1' id: upload_release @@ -727,6 +905,33 @@ jobs: tag_name: ${{ needs.configure.outputs.tag }} run: gh release upload "$tag_name" "$asset_path#$asset_name" + # Ninja is single-config: binaries and their PDBs land directly in the + # target directories instead of a Release subdirectory. + # + # Archived for tag builds only, and ordered after the release upload so + # a missing PDB can never block a release from being published. On a tag + # build this artifact is the only durable copy of the symbols: the Sentry + # upload is continue-on-error, so without it a failed upload would lose + # the ability to symbolicate that release permanently. Other builds upload + # no symbols at all, so archiving them there would only cost storage. + # + # The executables travel with their PDBs: re-uploading the pair together is + # what lets sentry-cli associate the PE's code identifier with the PDB's + # debug identifier, and the PE is what carries the unwind information. + - name: "[Windows] Upload debug symbols (exe + pdb)" + if: matrix.os == 'Windows' && needs.configure.outputs.tag != null + uses: actions/upload-artifact@v7 + with: + if-no-files-found: error + name: ${{ steps.build.outputs.name }}-Symbols + path: | + build/cockatrice/*.exe + build/cockatrice/*.pdb + build/oracle/*.exe + build/oracle/*.pdb + build/servatrice/*.exe + build/servatrice/*.pdb + - name: "Attest binary provenance" if: steps.upload_release.outcome == 'success' id: attestation diff --git a/CMakeLists.txt b/CMakeLists.txt index 87f1c746a..d9acabc5e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -220,6 +220,7 @@ endif() # when ENABLE_SANITIZERS or ENABLE_COVERAGE is on. include(Sanitizers) include(Coverage) +include(CrashReporting) # GNU systems need to define the Mersenne exponent for the RNG to compile w/o warning if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") @@ -249,6 +250,15 @@ message(STATUS "Update Translations: ${UPDATE_TRANSLATIONS}") include(FindQtRuntime) +# Reject APIs that were deprecated before the oldest Qt release we support +# (6.4). Qt 6.5 renamed the switch, so use whichever spelling the found +# version understands while still compiling everywhere. +if(Qt6_VERSION VERSION_GREATER_EQUAL 6.5) + add_compile_definitions(QT_DISABLE_DEPRECATED_UP_TO=0x060400) +else() + add_compile_definitions(QT_DISABLE_DEPRECATED_BEFORE=0x060400) +endif() + set(CMAKE_AUTOMOC TRUE) # Find other needed libraries @@ -336,6 +346,15 @@ elseif(WIN32) # Configure file with custom definitions for NSIS. configure_file("${COCKATRICE_CMAKE_PATH}/NSIS.definitions.nsh.in" "${PROJECT_BINARY_DIR}/NSIS.definitions.nsh") + # Installer-log sender for the AbortInstall Sentry hook (values parsed out of COCKATRICE_SENTRY_DSN in + # cmake/CrashReporting.cmake). Generated only when a DSN was parsed: cmake/NSIS.template.in embeds it under + # the same CPACK_SENTRY_ENABLED check, so a build without reporting never references or carries the helper. + if(CPACK_SENTRY_ENABLED) + configure_file( + "${COCKATRICE_CMAKE_PATH}/SendInstallerLog.ps1.in" "${PROJECT_BINARY_DIR}/SendInstallerLog.ps1" @ONLY + ) + endif() + # Every runtime DLL a Windows install has to contain, as paths relative to the # install root. .ci/compile.sh reads this and fails the build when the packaged # installer is missing one of them, or carries a runtime it does not know about. @@ -410,6 +429,9 @@ if(WITH_ORACLE OR WITH_CLIENT) add_subdirectory(${CMAKE_SOURCE_DIR}/libcockatrice_models ${CMAKE_BINARY_DIR}/libcockatrice_models) add_subdirectory(${CMAKE_SOURCE_DIR}/libcockatrice_filters ${CMAKE_BINARY_DIR}/libcockatrice_filters) endif() +if(WITH_CLIENT) + add_subdirectory(${CMAKE_SOURCE_DIR}/libcockatrice_game ${CMAKE_BINARY_DIR}/libcockatrice_game) +endif() if(WITH_SERVER) add_subdirectory(servatrice) @@ -421,6 +443,13 @@ if(WITH_CLIENT) set(CPACK_INSTALL_CMAKE_PROJECTS "Cockatrice;Cockatrice;ALL;/" ${CPACK_INSTALL_CMAKE_PROJECTS}) endif() +if(WIN32 AND WITH_CLIENT) + # Runs from the installer's $PLUGINSDIR when a purge fails, and is never installed + # (see LogProcessesUsingInstallDir in cmake/NSIS.template.in), so it has no install + # rules and is not part of the client's staged tree. + add_subdirectory(${CMAKE_SOURCE_DIR}/tools/installer-holder-probe ${CMAKE_BINARY_DIR}/tools/installer-holder-probe) +endif() + if(WITH_ORACLE) add_subdirectory(oracle) set(CPACK_INSTALL_CMAKE_PROJECTS "Oracle;Oracle;ALL;/" ${CPACK_INSTALL_CMAKE_PROJECTS}) @@ -433,5 +462,5 @@ endif() if(Qt6_FOUND AND Qt6_VERSION_MINOR GREATER_EQUAL 3) # Qt6.3+ requires project finalization to support translations - qt6_finalize_project() + qt_finalize_project() endif() diff --git a/README.md b/README.md index f2a170868..fa9272c0f 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,10 @@ ---

-To get started with Cockatrice ⇢ [view our webpage](https://cockatrice.github.io/)
-To get support, or suggest changes to the app ⇢ [file an issue](https://github.com/Cockatrice/Cockatrice/issues) ([How?](https://github.com/Cockatrice/Cockatrice/wiki/How-to-Create-a-GitHub-Ticket-Regarding-Cockatrice)) -To help with development ⇢ learn [how to contribute](#contribute-) +To get started with Cockatrice ⇢ [view our webpage](https://cockatrice.github.io/) +To get support, or suggest changes to the app ⇢ [file an issue](https://github.com/Cockatrice/Cockatrice/issues) ([How?](https://github.com/Cockatrice/Cockatrice/wiki/How-to-Create-a-GitHub-Ticket-Regarding-Cockatrice))
+To learn about the app and its code ⇢ [read our code documentation](https://cockatrice.github.io/docs/) +To help with development ⇢ learn [how to contribute](#contribute)


@@ -83,7 +84,7 @@ Join our [Discord community](https://discord.gg/3Z9yzmA) to connect with other p ### Code -To contribute code to the project, please review our [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/.github/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. @@ -95,7 +96,7 @@ We'll happily advice on how best to implement a feature, or we can show you wher You can also have a look at our `Todo List` in our [Code Documentation](https://cockatrice.github.io/docs) or search the repo for [`\todo` comments](https://github.com/search?q=repo%3ACockatrice%2FCockatrice%20%5Ctodo&type=code). -### Documentation [![CI Docs](https://github.com/Cockatrice/Cockatrice/actions/workflows/documentation-build.yml/badge.svg?event=push)](https://github.com/Cockatrice/Cockatrice/actions/workflows/documentation-build.yml?query=event%3Apush) +### Documentation [![CI Docs](https://github.com/Cockatrice/Cockatrice/actions/workflows/documentation-build.yml/badge.svg?event=push)](https://github.com/Cockatrice/Cockatrice/actions/workflows/documentation-build.yml?query=event%3Arelease+event%3Aworkflow_dispatch) There are various places where useful information for different needs are maintained: - [Official Code Documentation](https://cockatrice.github.io/docs/) @@ -104,7 +105,8 @@ There are various places where useful information for different needs are mainta - [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) -Cockatrice tries to use the [Google Developer Documentation Style Guide](https://developers.google.com/style/) to ensure consistent documentation. We encourage you to improve the documentation by suggesting edits based on this guide. +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. ### Translation [![Transifex Project](https://img.shields.io/badge/translate-on%20transifex-brightgreen)](https://explore.transifex.com/cockatrice/cockatrice/) diff --git a/cmake/CrashReporting.cmake b/cmake/CrashReporting.cmake new file mode 100644 index 000000000..6f9cf4849 --- /dev/null +++ b/cmake/CrashReporting.cmake @@ -0,0 +1,212 @@ +# Crash reporting via sentry-native (Sentry protocol, MIT). +# Included once from the root CMakeLists.txt, right after the sanitizer/coverage helpers. +# +# Inputs: +# WITH_CRASH_REPORTING -- build the client with the sentry-native SDK (default OFF, our release +# CI passes ON; packagers and from-source builds stay SDK-free until this +# ships something real, so offline package builds never fetch the SDK) +# COCKATRICE_SENTRY_DSN -- Sentry DSN baked into the build (default empty = SDK init skipped at runtime) +# COCKATRICE_SENTRY_TAG -- sentry-native release tag to vendor via FetchContent +# ENABLE_SANITIZERS -- forces the sentry backend to "none" (crashpad vs ASan conflict) +# Defines: cockatrice_target_enable_crash_reporting() + +option(WITH_CRASH_REPORTING "Build with crash reporting via sentry-native" OFF) +set(COCKATRICE_SENTRY_DSN + "" + CACHE STRING "Sentry DSN baked into the client; empty disables crash-report upload at runtime" +) +# Pinned stable release; tag verified at https://github.com/getsentry/sentry-native/tags. +set(COCKATRICE_SENTRY_TAG + "0.17.3" + CACHE STRING "sentry-native release tag to vendor via FetchContent" +) + +# Wire the SDK (or the no-op fallback) into a client target. Always callable: with the +# option OFF the compile definitions below make crash_reporter.cpp compile to no-ops. +function(cockatrice_target_enable_crash_reporting _target) + if(WITH_CRASH_REPORTING AND TARGET sentry::sentry) + target_link_libraries(${_target} PRIVATE sentry::sentry) + target_compile_definitions(${_target} PRIVATE COCKATRICE_WITH_CRASH_REPORTING=1) + else() + target_compile_definitions(${_target} PRIVATE COCKATRICE_WITH_CRASH_REPORTING=0) + endif() + # Always baked in (possibly empty): main() prefers the COCKATRICE_SENTRY_DSN env var at runtime. + target_compile_definitions(${_target} PRIVATE "COCKATRICE_SENTRY_DSN=\"${COCKATRICE_SENTRY_DSN}\"") + # Sentry release for events from this target: PROJECT_VERSION (getversion.cmake ran before this + # file was included). Deliberately not PROJECT_VERSION_FRIENDLY / VERSION_STRING — that carries + # a build date, so every build of the same version would land in a different Sentry release. + # PROJECT_VERSION matches what the installer reports in its own log and what CPack derives the + # package version from, so client and installer events group under one release. + target_compile_definitions(${_target} PRIVATE "COCKATRICE_SENTRY_RELEASE=\"${PROJECT_VERSION}\"") +endfunction() + +if(WITH_CRASH_REPORTING AND WITH_CLIENT) + include(FetchContent) + + if(ENABLE_SANITIZERS) + # The crash handler cannot run under ASan/UBSan, so there is no backend to capture with. + set(_sentry_backend "none") + else() + # Breakpad on every OS (increment 3 probe, 2026-10-14): the crashpad sources + # do vendor friction-free (FetchContent inits all submodules recursively by + # default, verified populated in a local build tree), but the crashpad backend + # would need its out-of-process crashpad_handler binary installed next to the + # exe, into the macOS bundle, and through NSIS, plus handler-path wiring and + # notarization care. Breakpad is in-process, needs no extra binary and no + # packaging on any OS sentry-native builds, so simplicity wins. + set(_sentry_backend "breakpad") + endif() + # FORCE so toggling ENABLE_SANITIZERS on a reused build dir cannot leave a stale backend behind. + set(SENTRY_BACKEND + "${_sentry_backend}" + CACHE STRING "Crash handler backend for sentry-native" FORCE + ) + unset(_sentry_backend) + + if(NOT MSVC) + # Symbol upload needs debug info in Release binaries: the top-level flags build GCC + # Release as -s -O2 (stripped, no DWARF) and Clang/Apple Release as plain -O2 (no DWARF), + # both of which upload nothing useful. Keep -g and drop the strip flag while the SDK is + # on; the OFF default leaves local, distro and Debug builds untouched. MSVC already + # links /DEBUG with PDBs in release (see top-level CMakeLists.txt), so it needs nothing. + string(REGEX REPLACE "(^| )-s( |$)" "\\1\\2" CMAKE_CXX_FLAGS_RELEASE "${CMAKE_CXX_FLAGS_RELEASE}") + if(NOT CMAKE_CXX_FLAGS_RELEASE MATCHES "(^| )-g( |$)") + set(CMAKE_CXX_FLAGS_RELEASE "${CMAKE_CXX_FLAGS_RELEASE} -g") + endif() + endif() + + # No Qt integration: it runs find_package(Qt6) inside the vendored build, which + # cannot see our thin/aqt Qt layouts (macOS CI) under foreign toolchains, and its + # breadcrumbs could carry unredacted log lines into native crash events that bypass + # before_send. Our attached redacted log preview already carries the log context. + set(SENTRY_INTEGRATION_QT + OFF + CACHE BOOL "Qt log-message breadcrumbs for sentry-native (off: discovery + secret surface)" FORCE + ) + # The transport stays at the SDK default (curl on Apple/Linux, winhttp on Windows). + set(SENTRY_BUILD_TESTS + OFF + CACHE BOOL "Build sentry-native tests" FORCE + ) + set(SENTRY_BUILD_EXAMPLES + OFF + CACHE BOOL "Build sentry-native examples" FORCE + ) + + fetchcontent_declare( + sentry + GIT_REPOSITORY https://github.com/getsentry/sentry-native.git + GIT_TAG ${COCKATRICE_SENTRY_TAG} + GIT_SHALLOW TRUE + ) + fetchcontent_makeavailable(sentry) + + # Vendored sentry-native (and its breakpad/crashpad trees) warn under our -Wall -Wextra + # (e.g. breakpad's -Wmissing-field-initializers), and WARNING_AS_ERROR would fail the build + # on code we cannot fix. Those findings are not actionable for us, so opt the whole vendored + # tree out of warnings-as-errors while keeping the warnings themselves visible; our own + # targets keep -Werror. Interface libraries compile nothing, so they are skipped outright: + # an INTERFACE -Wno-error would propagate to our consumers and weaken their build. + function(_cockatrice_sentry_opt_out_of_werror _dir) + get_property( + _sentry_targets + DIRECTORY "${_dir}" + PROPERTY BUILDSYSTEM_TARGETS + ) + foreach(_sentry_target IN LISTS _sentry_targets) + get_target_property(_sentry_type ${_sentry_target} TYPE) + if(_sentry_type STREQUAL "INTERFACE_LIBRARY") + continue() + elseif(_sentry_type MATCHES "LIBRARY|EXECUTABLE") + get_target_property(_sentry_imported ${_sentry_target} IMPORTED) + if(NOT _sentry_imported) + target_compile_options(${_sentry_target} PRIVATE -Wno-error) + endif() + endif() + endforeach() + get_property( + _sentry_subdirs + DIRECTORY "${_dir}" + PROPERTY SUBDIRECTORIES + ) + foreach(_sentry_subdir IN LISTS _sentry_subdirs) + _cockatrice_sentry_opt_out_of_werror("${_sentry_subdir}") + endforeach() + endfunction() + if(NOT MSVC) + _cockatrice_sentry_opt_out_of_werror("${sentry_SOURCE_DIR}") + endif() + + message(STATUS "Crash reporting: sentry-native ${COCKATRICE_SENTRY_TAG} (backend: ${SENTRY_BACKEND})") +else() + message(STATUS "Crash reporting: SDK not vendored " + "(WITH_CRASH_REPORTING=${WITH_CRASH_REPORTING}, WITH_CLIENT=${WITH_CLIENT})" + ) +endif() + +# Packaged release binaries must not ship the DWARF that the symbol upload needs: it is +# 80-90% of an unstripped binary and nobody downloads a 500 MB installer to get it. The +# build tree keeps -g (CI uploads symbols from there); the *packaged copy* is split at +# install time by a CPack install script, which extracts the debug info next to the staging +# dir and strips the binary. Only ELF (Linux/BSD) needs this: Windows links /DEBUG with +# separate PDBs, and macOS keeps its dSYM extracted from the bundle. +option(COCKATRICE_SPLIT_DEBUG_SYMBOLS "Strip DWARF out of packaged release binaries" ON) +set(COCKATRICE_DEBUG_SYMBOL_DIR + "${CMAKE_BINARY_DIR}/debug-symbols" + CACHE PATH "Where the packaged binaries' split debug information is written (kept out of the package)" +) +if(WITH_CRASH_REPORTING + AND WITH_CLIENT + AND COCKATRICE_SPLIT_DEBUG_SYMBOLS + AND CMAKE_BUILD_TYPE STREQUAL "Release" + AND UNIX + AND NOT APPLE +) + find_program(COCKATRICE_OBJCOPY NAMES objcopy) + find_program(COCKATRICE_STRIP_TOOL NAMES strip) + if(COCKATRICE_OBJCOPY AND COCKATRICE_STRIP_TOOL) + configure_file( + "${CMAKE_CURRENT_LIST_DIR}/SplitDebugSymbols.cmake.in" "${CMAKE_BINARY_DIR}/cmake/SplitDebugSymbols.cmake" @ONLY + ) + # PRE_BUILD runs after CPack stages the install tree and before the package is produced. + # CPACK_INSTALL_SCRIPTS would run too early (staging is still empty); the script walks + # CPACK_TEMPORARY_DIRECTORY, never CMAKE_INSTALL_PREFIX, and refuses to run otherwise. + list(APPEND CPACK_PRE_BUILD_SCRIPTS "${CMAKE_BINARY_DIR}/cmake/SplitDebugSymbols.cmake") + message(STATUS "Crash reporting: packaged binaries stripped, debug info split to ${COCKATRICE_DEBUG_SYMBOL_DIR}") + else() + message(WARNING "COCKATRICE_SPLIT_DEBUG_SYMBOLS is ON but objcopy/strip were not found; " + "packaged release binaries will keep their debug information" + ) + endif() +endif() + +# Installer failure reporting (increment 4): parse COCKATRICE_SENTRY_DSN into the parts the Windows installer +# needs. These are CPACK_* so CPack snapshots them at include(CPack) time and substitutes them into +# cmake/NSIS.template.in at package time; on non-Windows they are inert (only the NSIS template consumes them). +# Set from COCKATRICE_SENTRY_DSN on every configure, so a changed or removed DSN can never leave a stale value +# behind. Plain variables, not CACHE: they only need to be in scope when include(CPack) later snapshots every +# CPACK_* variable into CPackConfig.cmake. +set(CPACK_SENTRY_ENABLED "0") +set(CPACK_SENTRY_KEY "") +set(CPACK_SENTRY_ENVELOPE_URL "") +set(CPACK_SENTRY_DSN_FULL "") +# Deliberately PROJECT_VERSION: that is what the client bakes as its Sentry release and what CI passes to +# `sentry-cli debug-files upload`, so installer and client events group under one release. It is not the +# CPACK_PACKAGE_VERSION the installer logs in its own log line — on Development tags PROJECT_VERSION carries a +# "-

Run Oracle now?

"); - const QMessageBox::StandardButton answer = QMessageBox::question( - this, tr("Card text & images language changed"), message, QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes); - // The answer only controls whether Oracle starts right away; the caches stay // cleared so the next import or launch rebuilds them in the new language. - if (answer == QMessageBox::Yes) { + if (QtUtils::confirm(this, tr("Card text & images language changed"), message, QMessageBox::Yes)) { emit cardDatabaseUpdateRequested(); } } @@ -556,7 +568,7 @@ void GeneralSettingsPage::retranslateUi() const auto &settings = SettingsCache::instance(); languageGroupBox->setTitle(tr("Language settings")); - languageLabel.setText(tr("Language:")); + languageLabel.setText(tr("Interface language:")); cardLanguageBox.setItemText(0, tr("English")); cardLanguageLabel.setText(tr("Card text & images language:")); cardLanguageNoteLabel.setText( @@ -569,13 +581,19 @@ void GeneralSettingsPage::retranslateUi() tr("English and selected card language")); advertiseTranslationPageLabel.setText( QString("%2").arg(WIKI_TRANSLATION_FAQ).arg(tr("How to help with translations"))); - versionGroupBox->setTitle(tr("Version settings")); updateReleaseChannelLabel.setText(tr("Update channel")); startupUpdateCheckCheckBox.setText(tr("Check for client updates on startup")); updateNotificationCheckBox.setText(tr("Notify if a feature supported by the server is missing in my client")); newVersionOracleCheckBox.setText(tr("Automatically run Oracle when running a new version of Cockatrice")); + crashReportingGroupBox->setTitle(tr("Crash reporting")); + crashReportingLabel.setText(tr("Send crash reports:")); + crashReportingBox.setItemText(static_cast(PersonalSettings::CrashReportingMode::Ask), tr("Ask every time")); + crashReportingBox.setItemText(static_cast(PersonalSettings::CrashReportingMode::AlwaysSend), + tr("Always send")); + crashReportingBox.setItemText(static_cast(PersonalSettings::CrashReportingMode::NeverSend), tr("Never send")); + // We can't change the strings after they're put into the QComboBox, so this is our workaround int oldIndex = updateReleaseChannelBox.currentIndex(); updateReleaseChannelBox.clear(); diff --git a/cockatrice/src/interface/widgets/settings_page/general_settings_page.h b/cockatrice/src/interface/widgets/settings_page/general_settings_page.h index fcf1c78d0..9451db434 100644 --- a/cockatrice/src/interface/widgets/settings_page/general_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/general_settings_page.h @@ -26,7 +26,6 @@ public: void retranslateUi() override; static QStringList findQmFiles(); - static QString languageName(const QString &lang); signals: /// Request to re-import the card database with the newly selected card language @@ -49,6 +48,7 @@ private slots: private: QGroupBox *languageGroupBox; QGroupBox *versionGroupBox; + QGroupBox *crashReportingGroupBox; QGroupBox *cardDatabaseGroupBox; QGroupBox *startupGroupBox; QGroupBox *pathsGroupBox; @@ -70,6 +70,9 @@ private: QCheckBox updateNotificationCheckBox; QCheckBox newVersionOracleCheckBox; + QLabel crashReportingLabel; + QComboBox crashReportingBox; + QLabel startupCardUpdateCheckBehaviorLabel; QComboBox startupCardUpdateCheckBehaviorSelector; QLabel cardUpdateCheckIntervalLabel; diff --git a/cockatrice/src/interface/widgets/settings_page/shortcut_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/shortcut_settings_page.cpp index ea7ed745c..a77c0c2c3 100644 --- a/cockatrice/src/interface/widgets/settings_page/shortcut_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/shortcut_settings_page.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include ShortcutSettingsPage::ShortcutSettingsPage() @@ -112,16 +113,15 @@ void ShortcutSettingsPage::currentItemChanged(const QString &key) void ShortcutSettingsPage::resetShortcuts() { - if (QMessageBox::question(this, tr("Restore all default shortcuts"), - tr("Do you really want to restore all default shortcuts?")) == QMessageBox::Yes) { + if (QtUtils::confirm(this, tr("Restore all default shortcuts"), + tr("Do you really want to restore all default shortcuts?"))) { SettingsCache::instance().shortcuts().resetAllShortcuts(); } } void ShortcutSettingsPage::clearShortcuts() { - if (QMessageBox::question(this, tr("Clear all default shortcuts"), - tr("Do you really want to clear all shortcuts?")) == QMessageBox::Yes) { + if (QtUtils::confirm(this, tr("Clear all default shortcuts"), tr("Do you really want to clear all shortcuts?"))) { SettingsCache::instance().shortcuts().clearAllShortcuts(); } } diff --git a/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.cpp index 0a5f57e1f..44ce01aba 100644 --- a/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include enum visualDeckStoragePromptForConversionIndex @@ -248,8 +249,7 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage() updateCommanderSpellbookUiState(); - auto *labelLayout = new QHBoxLayout; - labelLayout->setContentsMargins(0, 0, 0, 0); + auto *labelLayout = QtUtils::makeLayout(); labelLayout->addWidget(&commanderSpellbookIntegrationUseOfficialBracketNamesLabel); labelLayout->addWidget(&commanderSpellbookIntegrationUseOfficialBracketNamesExplainer); diff --git a/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.cpp b/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.cpp index 4044f03db..1e80b2715 100644 --- a/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.cpp +++ b/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.cpp @@ -11,9 +11,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" -#include "../../deck_loader/deck_file_format.h" #include "../../deck_loader/deck_loader.h" -#include "../../deck_loader/loaded_deck.h" #include "../cards/additional_info/deck_color_identity.h" #include "../cards/card_info_frame_widget.h" #include "../client/network/interfaces/deck_stats_interface.h" @@ -55,6 +53,8 @@ #include #include #include +#include +#include #include #include #include diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_display_widget.cpp index 555ec7ec5..46d2466c6 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_display_widget.cpp @@ -2,7 +2,6 @@ #include "../../../../../../client/settings/cache_settings.h" #include "../../../../../deck_loader/card_node_function.h" -#include "../../../../../deck_loader/loaded_deck.h" #include "../../../../cards/deck_card_zone_display_widget.h" #include "../../../../visual_deck_editor/visual_deck_display_options_widget.h" #include "../../../../visual_deck_editor/visual_deck_editor_widget.h" @@ -30,7 +29,9 @@ #include #include #include +#include #include +#include #include #include @@ -66,10 +67,7 @@ ArchidektApiResponseDeckDisplayWidget::ArchidektApiResponseDeckDisplayWidget(QWi connect(displayOptionsWidget, &VisualDeckDisplayOptionsWidget::groupCriteriaChanged, this, &ArchidektApiResponseDeckDisplayWidget::onGroupCriteriaChange); - scrollArea = new QScrollArea(this); - scrollArea->setWidgetResizable(true); - scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + scrollArea = QtUtils::makeScrollArea(this); layout->addWidget(scrollArea); @@ -240,7 +238,5 @@ void ArchidektApiResponseDeckDisplayWidget::constructZoneWidgetsFromDeckListMode void ArchidektApiResponseDeckDisplayWidget::resizeEvent(QResizeEvent *event) { QWidget::resizeEvent(event); - layout->invalidate(); - layout->activate(); - layout->update(); + QtUtils::relayout(layout); } diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_entry_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_entry_display_widget.cpp index b5e28fcf5..b22dac8de 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_entry_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_entry_display_widget.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #include #include @@ -241,9 +242,7 @@ void ArchidektApiResponseDeckEntryDisplayWidget::updateScaledPreview() void ArchidektApiResponseDeckEntryDisplayWidget::resizeEvent(QResizeEvent *event) { QWidget::resizeEvent(event); - layout->invalidate(); - layout->activate(); - layout->update(); + QtUtils::relayout(layout); } void ArchidektApiResponseDeckEntryDisplayWidget::actRequestNavigationToDeck() diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_listings_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_listings_display_widget.cpp index 9c4ea59ab..f8bf3c0dc 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_listings_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_listings_display_widget.cpp @@ -11,6 +11,8 @@ #include #include #include +#include +#include #include ArchidektApiResponseDeckListingsDisplayWidget::ArchidektApiResponseDeckListingsDisplayWidget( @@ -19,16 +21,14 @@ ArchidektApiResponseDeckListingsDisplayWidget::ArchidektApiResponseDeckListingsD CardSizeWidget *_cardSizeSlider) : QWidget(parent), cardSizeSlider(_cardSizeSlider) { - layout = new QHBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); + layout = QtUtils::makeLayout(this); setLayout(layout); flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAsNeeded); cardSizeSlider->enableCtrlScrollResize(flowWidget); - imageNetworkManager = new QNetworkAccessManager(this); - imageNetworkManager->setTransferTimeout(); // Use Qt's default timeout + imageNetworkManager = NetworkManagerFactory::create(this); imageNetworkManager->setRedirectPolicy(QNetworkRequest::ManualRedirectPolicy); // Add widgets for deck listings @@ -64,7 +64,5 @@ void ArchidektApiResponseDeckListingsDisplayWidget::append(const ArchidektDeckLi void ArchidektApiResponseDeckListingsDisplayWidget::resizeEvent(QResizeEvent *event) { QWidget::resizeEvent(event); - layout->invalidate(); - layout->activate(); - layout->update(); + QtUtils::relayout(layout); } diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp index 02badf1aa..7f7a2d3fd 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp @@ -48,7 +48,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -57,8 +59,7 @@ TabArchidekt::TabArchidekt(TabSupervisor *_tabSupervisor) : Tab(_tabSupervisor), currentPage(1), isLoadingMore(false), isListMode(true) { // Initialize network - networkManager = new QNetworkAccessManager(this); - networkManager->setTransferTimeout(); + networkManager = NetworkManagerFactory::create(this); networkManager->setRedirectPolicy(QNetworkRequest::ManualRedirectPolicy); connect(networkManager, &QNetworkAccessManager::finished, this, &TabArchidekt::processApiJson); @@ -80,9 +81,7 @@ void TabArchidekt::initializeUi() { // Main container container = new QWidget(this); - mainLayout = new QVBoxLayout(container); - mainLayout->setContentsMargins(0, 0, 0, 0); - mainLayout->setSpacing(0); + mainLayout = QtUtils::makeLayout(container, 0); // Primary toolbar (most important filters) primaryToolbar = new QWidget(container); @@ -105,9 +104,7 @@ void TabArchidekt::initializeUi() // Color filter (inline) QWidget *colorWidget = new QWidget(primaryToolbar); - QHBoxLayout *colorLayout = new QHBoxLayout(colorWidget); - colorLayout->setContentsMargins(0, 0, 0, 0); - colorLayout->setSpacing(2); + QHBoxLayout *colorLayout = QtUtils::makeLayout(colorWidget, 2); QString colorIdentity = "WUBRG"; for (const QChar &color : colorIdentity) { @@ -186,15 +183,10 @@ void TabArchidekt::initializeUi() secondaryToolbarLayout->setSpacing(6); // Scrollable results area - scrollArea = new QScrollArea(container); - scrollArea->setWidgetResizable(true); - scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + scrollArea = QtUtils::makeScrollArea(container); resultsContainer = new QWidget(); - resultsLayout = new QVBoxLayout(resultsContainer); - resultsLayout->setContentsMargins(0, 0, 0, 0); - resultsLayout->setSpacing(0); + resultsLayout = QtUtils::makeLayout(resultsContainer, 0); scrollArea->setWidget(resultsContainer); @@ -228,9 +220,9 @@ void TabArchidekt::setupFilterWidgets() // Advanced filters (in secondary toolbar) // EDH Bracket - auto *bracketLabel = new QLabel(tr("Bracket:"), secondaryToolbar); + bracketLabel = new QLabel(secondaryToolbar); edhBracketCombo = new QComboBox(secondaryToolbar); - edhBracketCombo->addItem(tr("Any")); + edhBracketCombo->addItem(QString()); edhBracketCombo->addItems({"1", "2", "3", "4", "5"}); edhBracketCombo->setSizeAdjustPolicy(QComboBox::AdjustToContents); @@ -284,7 +276,6 @@ void TabArchidekt::setupFilterWidgets() sizeLayout->setContentsMargins(4, 4, 4, 4); minDeckSizeSpin = new QSpinBox(sizeContainer); - minDeckSizeSpin->setSpecialValueText(tr("Any")); minDeckSizeSpin->setRange(0, 200); minDeckSizeSpin->setValue(0); @@ -292,7 +283,8 @@ void TabArchidekt::setupFilterWidgets() minDeckSizeLogicCombo->addItems({"Exact", "≥", "≤"}); minDeckSizeLogicCombo->setCurrentIndex(1); - sizeLayout->addWidget(new QLabel(tr("Cards:"), sizeContainer)); + cardsLabel = new QLabel(sizeContainer); + sizeLayout->addWidget(cardsLabel); sizeLayout->addWidget(minDeckSizeSpin); sizeLayout->addWidget(minDeckSizeLogicCombo); @@ -349,9 +341,9 @@ void TabArchidekt::connectSignals() }); // Format filters trigger search - connect(edhBracketCombo, &QComboBox::currentTextChanged, this, &TabArchidekt::doSearch); + connect(edhBracketCombo, &QComboBox::currentIndexChanged, this, &TabArchidekt::doSearch); connect(minDeckSizeSpin, qOverload(&QSpinBox::valueChanged), this, &TabArchidekt::doSearch); - connect(minDeckSizeLogicCombo, &QComboBox::currentTextChanged, this, &TabArchidekt::doSearch); + connect(minDeckSizeLogicCombo, &QComboBox::currentIndexChanged, this, &TabArchidekt::doSearch); // Allow Enter key in text fields to trigger search connect(nameField, &QLineEdit::returnPressed, this, &TabArchidekt::doSearchImmediate); @@ -401,8 +393,15 @@ void TabArchidekt::retranslateUi() commandersField->setPlaceholderText(tr("Commander...")); deckTagNameField->setPlaceholderText(tr("Tag...")); + bracketLabel->setText(tr("Bracket:")); + // Index 0 is the "Any" placeholder added in setupFilterWidgets() + edhBracketCombo->setItemText(0, tr("Any")); + formatButton->setButtonText(tr("Formats")); deckSizeButton->setButtonText(tr("Deck Size")); + cardsLabel->setText(tr("Cards:")); + minDeckSizeSpin->setSpecialValueText(tr("Any")); + minDeckSizeLogicCombo->setItemText(0, tr("Exact")); searchButton->setText(tr("Search")); @@ -492,13 +491,17 @@ QString TabArchidekt::buildSearchUrl() query.addQueryItem("size", QString::number(minDeckSizeSpin->value())); QString logic = "GTE"; - QString selected = minDeckSizeLogicCombo->currentText(); - if (selected == "≥") { - logic = "GTE"; - } else if (selected == "≤") { - logic = "LTE"; - } else { - logic = ""; + // Index order matches setupFilterWidgets(): 0 = Exact, 1 = ≥, 2 = ≤ + switch (minDeckSizeLogicCombo->currentIndex()) { + case 1: + logic = "GTE"; + break; + case 2: + logic = "LTE"; + break; + default: + logic = ""; + break; } if (!logic.isEmpty()) { @@ -599,11 +602,7 @@ void TabArchidekt::processTopDecksResponse(QJsonObject reply) // New search → clear everything if (currentPage == 1) { - QLayoutItem *item; - while ((item = resultsLayout->takeAt(0)) != nullptr) { - delete item->widget(); - delete item; - } + QtUtils::clearLayoutRec(resultsLayout, QtUtils::DeletePolicy::Immediate); listingsWidget = new ArchidektApiResponseDeckListingsDisplayWidget(resultsContainer, deckData, cardSizeSlider); @@ -629,11 +628,7 @@ void TabArchidekt::processDeckResponse(QJsonObject reply) isListMode = false; // Clear existing results for single deck view - QLayoutItem *item; - while ((item = resultsLayout->takeAt(0)) != nullptr) { - delete item->widget(); - delete item; - } + QtUtils::clearLayoutRec(resultsLayout, QtUtils::DeletePolicy::Immediate); auto display = new ArchidektApiResponseDeckDisplayWidget(resultsContainer, deckData, cardSizeSlider); connect(display, &ArchidektApiResponseDeckDisplayWidget::requestNavigation, this, &TabArchidekt::actNavigatePage); diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.h b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.h index 015b8590b..c74380b63 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.h +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.h @@ -287,9 +287,11 @@ private: SettingsButtonWidget *formatButton; ///< Collapsible button for format filters QVector formatChecks; ///< Individual checkboxes for each format + QLabel *bracketLabel; ///< Label for EDH bracket selection QComboBox *edhBracketCombo; ///< Dropdown for EDH bracket selection SettingsButtonWidget *deckSizeButton; ///< Collapsible button for deck size filter + QLabel *cardsLabel; ///< Label for deck size selection QSpinBox *minDeckSizeSpin; ///< Spinner to select minimum deck size QComboBox *minDeckSizeLogicCombo; ///< Combo box for size comparison logic @@ -300,4 +302,4 @@ private: CardInfoPtr cardToQuery; ///< Optional pre-selected card for initial filtering }; -#endif // COCKATRICE_TAB_ARCHIDEKT_H \ No newline at end of file +#endif // COCKATRICE_TAB_ARCHIDEKT_H diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp index 06a76dd19..5279165af 100644 --- a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include template class QSharedPointer; @@ -44,8 +45,7 @@ CommanderBracketWidget::CommanderBracketWidget(QWidget *parent) : QWidget(parent connect(bracketRefreshButton, &QToolButton::clicked, this, &CommanderBracketWidget::requestBracketEstimate); - auto *layout = new QGridLayout(this); - layout->setContentsMargins(0, 0, 0, 0); + auto *layout = QtUtils::makeLayout(this); layout->addWidget(bracketLabel, 0, 0); auto *bracketRow = new QHBoxLayout; @@ -110,8 +110,7 @@ bool CommanderBracketWidget::promptCommanderSpellbookIntegration() // Create label + explainer button auto *labelWidget = new QWidget(&dialog); - auto *labelLayout = new QHBoxLayout(labelWidget); - labelLayout->setContentsMargins(0, 0, 0, 0); + auto *labelLayout = QtUtils::makeLayout(labelWidget); auto *namingLabel = new QLabel(tr("Bracket naming:"), labelWidget); auto *explainerButton = new QToolButton(labelWidget); diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.cpp b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.cpp index 22200ffa2..407f53e99 100644 --- a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.cpp @@ -12,12 +12,13 @@ #include #include #include +#include #include static const QUrl COMMANDER_BRACKET_JSON_URL(QStringLiteral("https://cockatrice.github.io/commander-brackets.json")); HandleCommanderBrackets::HandleCommanderBrackets(QObject *parent) - : QObject(parent), nam(new QNetworkAccessManager(this)), reply(nullptr) + : QObject(parent), nam(NetworkManagerFactory::create(this)), reply(nullptr) { } diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.cpp index 0728b3154..b2ddace01 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include EdhrecApiResponseCardDetailsDisplayWidget::EdhrecApiResponseCardDetailsDisplayWidget( @@ -47,15 +48,7 @@ EdhrecApiResponseCardDetailsDisplayWidget::EdhrecApiResponseCardDetailsDisplayWi layout->addWidget(backgroundPlateWidget); - QWidget *currentParent = parentWidget(); - TabEdhRecMain *parentTab = nullptr; - - while (currentParent) { - if ((parentTab = qobject_cast(currentParent))) { - break; - } - currentParent = currentParent->parentWidget(); - } + auto *parentTab = QtUtils::findParentOfType(this); if (parentTab) { cardPictureWidget->setScaleFactor(parentTab->getCardSizeSlider()->getSlider()->value()); diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_api_response_commander_details_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_api_response_commander_details_display_widget.cpp index 3da050edd..2ff22f2b3 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_api_response_commander_details_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_api_response_commander_details_display_widget.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include EdhrecCommanderResponseCommanderDetailsDisplayWidget::EdhrecCommanderResponseCommanderDetailsDisplayWidget( @@ -33,15 +34,7 @@ EdhrecCommanderResponseCommanderDetailsDisplayWidget::EdhrecCommanderResponseCom commanderPicture = new CardInfoPictureWidget(this); commanderPicture->setCard(CardDatabaseManager::query()->getCard({commanderDetails.getName()})); - QWidget *currentParent = parentWidget(); - TabEdhRecMain *parentTab = nullptr; - - while (currentParent) { - if ((parentTab = qobject_cast(currentParent))) { - break; - } - currentParent = currentParent->parentWidget(); - } + auto *parentTab = QtUtils::findParentOfType(this); if (parentTab) { connect(parentTab->getCardSizeSlider()->getSlider(), &QSlider::valueChanged, commanderPicture, diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_display_widget.cpp index 111d91b91..6dc987b76 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_display_widget.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include @@ -61,10 +62,7 @@ EdhrecCommanderApiResponseDisplayWidget::EdhrecCommanderApiResponseDisplayWidget } // Create a QScrollArea to hold the card display widgets - scrollArea = new QScrollArea(splitter); - scrollArea->setWidgetResizable(true); - scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); - scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + scrollArea = QtUtils::makeScrollArea(splitter); // Set the cardDisplayLayout inside the scroll area auto scrollWidget = new QWidget(scrollArea); @@ -109,9 +107,7 @@ void EdhrecCommanderApiResponseDisplayWidget::resizeEvent(QResizeEvent *event) { QWidget::resizeEvent(event); qDebug() << event->size(); - layout->invalidate(); - layout->activate(); - layout->update(); + QtUtils::relayout(layout); if (scrollArea && scrollArea->widget()) { scrollArea->widget()->resize(event->size()); } diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_navigation_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_navigation_widget.cpp index fd02c4261..220dcfbda 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_navigation_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_navigation_widget.cpp @@ -7,6 +7,7 @@ #include #include #include +#include EdhrecCommanderApiResponseNavigationWidget::EdhrecCommanderApiResponseNavigationWidget( QWidget *parent, @@ -40,15 +41,7 @@ EdhrecCommanderApiResponseNavigationWidget::EdhrecCommanderApiResponseNavigation layout->addWidget(bracketNavigationWidget, 1, 0, 1, 2); layout->addWidget(budgetNavigationWidget, 2, 0, 1, 2); - QWidget *currentParent = parentWidget(); - TabEdhRecMain *parentTab = nullptr; - - while (currentParent) { - if ((parentTab = qobject_cast(currentParent))) { - break; - } - currentParent = currentParent->parentWidget(); - } + auto *parentTab = QtUtils::findParentOfType(this); if (parentTab) { connect(comboPushButton, &QPushButton::clicked, this, diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_cards/edhrec_top_cards_api_response_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_cards/edhrec_top_cards_api_response_display_widget.cpp index 6f32db102..680926ef4 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_cards/edhrec_top_cards_api_response_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_cards/edhrec_top_cards_api_response_display_widget.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include EdhrecTopCardsApiResponseDisplayWidget::EdhrecTopCardsApiResponseDisplayWidget(QWidget *parent, @@ -29,10 +30,7 @@ EdhrecTopCardsApiResponseDisplayWidget::EdhrecTopCardsApiResponseDisplayWidget(Q } // Create a QScrollArea to hold the card display widgets - scrollArea = new QScrollArea(this); - scrollArea->setWidgetResizable(true); - scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); - scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + scrollArea = QtUtils::makeScrollArea(this); // Set the cardDisplayLayout inside the scroll area auto scrollWidget = new QWidget(scrollArea); @@ -45,9 +43,7 @@ EdhrecTopCardsApiResponseDisplayWidget::EdhrecTopCardsApiResponseDisplayWidget(Q void EdhrecTopCardsApiResponseDisplayWidget::resizeEvent(QResizeEvent *event) { QWidget::resizeEvent(event); - layout->invalidate(); - layout->activate(); - layout->update(); + QtUtils::relayout(layout); if (scrollArea && scrollArea->widget()) { scrollArea->widget()->resize(event->size()); } diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_commander/edhrec_top_commanders_api_response_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_commander/edhrec_top_commanders_api_response_display_widget.cpp index 4ab333fd4..105386597 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_commander/edhrec_top_commanders_api_response_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_commander/edhrec_top_commanders_api_response_display_widget.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include @@ -33,10 +34,7 @@ EdhrecTopCommandersApiResponseDisplayWidget::EdhrecTopCommandersApiResponseDispl } // Create a QScrollArea to hold the card display widgets - scrollArea = new QScrollArea(this); - scrollArea->setWidgetResizable(true); - scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); - scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + scrollArea = QtUtils::makeScrollArea(this); // Set the cardDisplayLayout inside the scroll area auto scrollWidget = new QWidget(scrollArea); @@ -50,9 +48,7 @@ void EdhrecTopCommandersApiResponseDisplayWidget::resizeEvent(QResizeEvent *even { QWidget::resizeEvent(event); qDebug() << event->size(); - layout->invalidate(); - layout->activate(); - layout->update(); + QtUtils::relayout(layout); if (scrollArea && scrollArea->widget()) { scrollArea->widget()->resize(event->size()); } diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_tags/edhrec_top_tags_api_response_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_tags/edhrec_top_tags_api_response_display_widget.cpp index 9c7ce8d06..ae8686f3a 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_tags/edhrec_top_tags_api_response_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_tags/edhrec_top_tags_api_response_display_widget.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include EdhrecTopTagsApiResponseDisplayWidget::EdhrecTopTagsApiResponseDisplayWidget(QWidget *parent, @@ -29,10 +30,7 @@ EdhrecTopTagsApiResponseDisplayWidget::EdhrecTopTagsApiResponseDisplayWidget(QWi } // Create a QScrollArea to hold the card display widgets - scrollArea = new QScrollArea(this); - scrollArea->setWidgetResizable(true); - scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); - scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + scrollArea = QtUtils::makeScrollArea(this); // Set the cardDisplayLayout inside the scroll area auto scrollWidget = new QWidget(scrollArea); @@ -45,9 +43,7 @@ EdhrecTopTagsApiResponseDisplayWidget::EdhrecTopTagsApiResponseDisplayWidget(QWi void EdhrecTopTagsApiResponseDisplayWidget::resizeEvent(QResizeEvent *event) { QWidget::resizeEvent(event); - layout->invalidate(); - layout->activate(); - layout->update(); + QtUtils::relayout(layout); if (scrollArea && scrollArea->widget()) { scrollArea->widget()->resize(event->size()); } diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec.cpp index 3aa395126..9dd0cca03 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec.cpp @@ -18,14 +18,14 @@ #include #include #include +#include #include class TabSupervisor; TabEdhRec::TabEdhRec(TabSupervisor *_tabSupervisor) : Tab(_tabSupervisor) { - networkManager = new QNetworkAccessManager(this); - networkManager->setTransferTimeout(); // Use Qt's default timeout + networkManager = NetworkManagerFactory::create(this); networkManager->setRedirectPolicy(QNetworkRequest::ManualRedirectPolicy); connect(networkManager, &QNetworkAccessManager::finished, this, &TabEdhRec::processApiJson); } diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec_main.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec_main.cpp index ca575dc02..c8822930e 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec_main.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec_main.cpp @@ -1,7 +1,6 @@ #include "tab_edhrec_main.h" #include "../../../../../client/settings/cache_settings.h" -#include "../../../../deck_loader/loaded_deck.h" #include "../../../cards/card_size_widget.h" #include "../../../quick_settings/settings_button_widget.h" #include "../../../utility/completer_utils.h" @@ -28,10 +27,12 @@ #include #include #include +#include #include #include #include #include +#include #include #include @@ -44,8 +45,7 @@ static bool canBeCommander(const CardInfoPtr &cardInfo) TabEdhRecMain::TabEdhRecMain(TabSupervisor *_tabSupervisor) : Tab(_tabSupervisor) { - networkManager = new QNetworkAccessManager(this); - networkManager->setTransferTimeout(); // Use Qt's default timeout + networkManager = NetworkManagerFactory::create(this); networkManager->setRedirectPolicy(QNetworkRequest::ManualRedirectPolicy); connect(networkManager, SIGNAL(finished(QNetworkReply *)), this, SLOT(processApiJson(QNetworkReply *))); diff --git a/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.cpp b/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.cpp index 1fc2854ce..f18aa82b9 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -29,7 +30,9 @@ #include #include #include +#include #include +#include #include class QObject; @@ -172,6 +175,7 @@ void TabCardArtRules::setupUi() addBtn = new QPushButton; removeBtn = new QPushButton; refreshBtn = new QPushButton; + clearBannedArtBtn = new QPushButton; modeBox->addItems({"ALLOW", "DENY"}); @@ -188,10 +192,14 @@ void TabCardArtRules::setupUi() form->addRow(tr("Mode:"), modeBox); form->addRow(tr("Reason:"), reasonEdit); + // The bulk clear acts on every user, so keep it visibly apart from the + // rules this tab edits. auto *buttons = new QHBoxLayout; buttons->addWidget(addBtn); buttons->addWidget(removeBtn); buttons->addWidget(refreshBtn); + buttons->addStretch(); + buttons->addWidget(clearBannedArtBtn); auto *layout = new QVBoxLayout; layout->addLayout(form); @@ -207,6 +215,8 @@ void TabCardArtRules::setupUi() connect(refreshBtn, &QPushButton::clicked, this, &TabCardArtRules::refresh); + connect(clearBannedArtBtn, &QPushButton::clicked, this, &TabCardArtRules::clearBlacklistedBanners); + retranslateUi(); } @@ -263,6 +273,7 @@ void TabCardArtRules::retranslateUi() addBtn->setText(tr("Add rule")); removeBtn->setText(tr("Remove rule")); refreshBtn->setText(tr("Refresh")); + clearBannedArtBtn->setText(tr("Clear blacklisted banners")); } void TabCardArtRules::refresh() @@ -299,4 +310,47 @@ void TabCardArtRules::removeSelected() client->sendCommand(client->prepareModeratorCommand(cmd)); refresh(); -} \ No newline at end of file +} + +void TabCardArtRules::clearBlacklistedBanners() +{ + if (!QtUtils::confirm(this, tr("Clear blacklisted banners"), + tr("Remove the banner card from every user whose current banner matches a DENY rule?\n\n" + "Users who are online keep showing their banner until they log in again."), + QMessageBox::No)) { + return; + } + + Command_ClearBlacklistedBanners cmd; + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabCardArtRules::clearBlacklistedBannersResponse); + client->sendCommand(pend); +} + +void TabCardArtRules::clearBlacklistedBannersResponse(const Response &r) +{ + switch (r.response_code()) { + case Response::RespOk: + break; + case Response::RespFunctionNotAllowed: + QMessageBox::warning(this, tr("Not supported"), + tr("This server does not support clearing blacklisted banners. It may be running an " + "older version.")); + return; + case Response::RespInternalError: + QMessageBox::critical(this, tr("Error"), + tr("The server could not clear blacklisted banners because of a database error.")); + return; + default: + QMessageBox::critical(this, tr("Error"), + tr("The server rejected the request: %1") + .arg(QString::fromStdString(Response_ResponseCode_Name(r.response_code())))); + return; + } + + const auto &resp = r.GetExtension(Response_ClearBlacklistedBanners::ext); + QMessageBox::information(this, tr("Banners cleared"), + tr("%n banner card(s) cleared.\n\n" + "Users who are online keep showing their banner until they log in again.", + "number of banners cleared", resp.cleared_count())); +} diff --git a/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.h b/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.h index f34300179..1e0155ab7 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.h +++ b/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.h @@ -78,6 +78,8 @@ private slots: void addRule(); void removeSelected(); void refresh(); + void clearBlacklistedBanners(); + void clearBlacklistedBannersResponse(const Response &r); private: AbstractClient *client; @@ -97,6 +99,7 @@ private: QPushButton *addBtn; QPushButton *removeBtn; QPushButton *refreshBtn; + QPushButton *clearBannedArtBtn; QTableView *table; CardArtRulesModel *tableModel; diff --git a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp index 44d04bd24..3dfdc956b 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp @@ -1,9 +1,7 @@ #include "tab_deck_storage.h" #include "../../../client/settings/cache_settings.h" -#include "../../deck_loader/deck_file_format.h" #include "../../deck_loader/deck_loader.h" -#include "../../deck_loader/loaded_deck.h" #include "../../pixel_map_generator.h" #include "../cards/additional_info/deck_color_identity.h" #include "../deck_share/deck_share_utils.h" @@ -20,7 +18,6 @@ #include #include #include -#include #include #include #include @@ -45,13 +42,14 @@ #include #include #include -#include #include #include #include #include #include +#include #include +#include #include #include #include @@ -65,6 +63,7 @@ #include #include #include +#include #include #include #include @@ -524,7 +523,7 @@ void TabDeckStorage::actDeleteLocalDeck() void TabDeckStorage::actOpenDecksFolder() { QString dir = localDirModel->rootPath(); - QDesktopServices::openUrl(QUrl::fromLocalFile(dir)); + QtUtils::openPathInFileManager(dir); } void TabDeckStorage::actRemoteDoubleClick(const QModelIndex &curRight) diff --git a/cockatrice/src/interface/widgets/tabs/tab_game.cpp b/cockatrice/src/interface/widgets/tabs/tab_game.cpp index 4cf5919ba..9e724727f 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_game.cpp @@ -2,16 +2,10 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" -#include "../../../game/abstract_game.h" -#include "../../../game/game_event_handler.h" -#include "../../../game/game_meta_info.h" -#include "../../../game/game_state.h" -#include "../../../game/player/player_info.h" -#include "../../../game/player/player_manager.h" +#include "../../../database/interface/settings_card_preference_provider.h" #include "../../../game_graphics/board/abstract_card_item.h" -#include "../game/game.h" -#include "../game/player/player_logic.h" -#include "../game/replay.h" +#include "../game_graphics/board/arrow_item.h" +#include "../game_graphics/board/card_item.h" #include "../game_graphics/deckview/deck_view_container.h" #include "../game_graphics/deckview/tabbed_deck_view_container.h" #include "../game_graphics/game_scene.h" @@ -71,6 +65,15 @@ #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include #include #include @@ -78,6 +81,7 @@ #include #include #include +#include #include #include #include @@ -96,7 +100,8 @@ TabGame::TabGame(TabSupervisor *_tabSupervisor, GameReplay *_replay) : Tab(_tabSupervisor), sayLabel(nullptr), sayEdit(nullptr) { // THIS CTOR IS USED ON REPLAY - game = new Replay(this, _replay, tabSupervisor->getIsLocalGame()); + game = new Replay(this, _replay, tabSupervisor->getIsLocalGame(), &SettingsCache::instance().userInterface(), + new SettingsCardPreferenceProvider(this)); createCardInfoDock(true); createPlayerListDock(true); @@ -140,7 +145,8 @@ TabGame::TabGame(TabSupervisor *_tabSupervisor, : Tab(_tabSupervisor), userListProxy(_tabSupervisor->getUserListManager()) { // THIS CTOR IS USED ON GAMES - game = new Game(this, tabSupervisor->getIsLocalGame(), _clients, event, _roomGameTypes); + game = new Game(this, tabSupervisor->getIsLocalGame(), _clients, event, _roomGameTypes, + &SettingsCache::instance().userInterface(), new SettingsCardPreferenceProvider(this)); createCardInfoDock(); createPlayerListDock(); @@ -605,15 +611,14 @@ void TabGame::actConcede() return; } if (!player->getConceded()) { - if (QMessageBox::question(this, tr("Concede"), tr("Are you sure you want to concede this game?"), - QMessageBox::Yes | QMessageBox::No, QMessageBox::No) != QMessageBox::Yes) { + if (!QtUtils::confirm(this, tr("Concede"), tr("Are you sure you want to concede this game?"), + QMessageBox::No)) { return; } emit game->getPlayerManager()->activeLocalPlayerConceded(); } else { - if (QMessageBox::question(this, tr("Unconcede"), - tr("You have already conceded. Do you want to return to this game?"), - QMessageBox::Yes | QMessageBox::No, QMessageBox::No) != QMessageBox::Yes) { + if (!QtUtils::confirm(this, tr("Unconcede"), + tr("You have already conceded. Do you want to return to this game?"), QMessageBox::No)) { return; } emit game->getPlayerManager()->activeLocalPlayerUnconceded(); @@ -630,8 +635,8 @@ bool TabGame::leaveGame() if (!game->getGameState()->isGameClosed()) { if (!game->getPlayerManager()->isSpectator()) { tabSupervisor->setCurrentWidget(this); - if (QMessageBox::question(this, tr("Leave game"), tr("Are you sure you want to leave this game?"), - QMessageBox::Yes | QMessageBox::No, QMessageBox::No) != QMessageBox::Yes) { + if (!QtUtils::confirm(this, tr("Leave game"), tr("Are you sure you want to leave this game?"), + QMessageBox::No)) { return false; } } @@ -1276,8 +1281,14 @@ void TabGame::createPlayAreaWidget(bool bReplay) { phasesToolbar = new PhasesToolbar; if (!bReplay) { - connect(phasesToolbar, &PhasesToolbar::sendGameCommand, game->getGameEventHandler(), - qOverload(&GameEventHandler::sendGameCommand)); + connect(phasesToolbar, &PhasesToolbar::setActivePhaseRequested, game->getGameEventHandler(), + &GameEventHandler::handleActivePhaseChanged); + connect(phasesToolbar, &PhasesToolbar::nextTurnRequested, game->getGameEventHandler(), + &GameEventHandler::handleNextTurn); + connect(phasesToolbar, &PhasesToolbar::untapAllRequested, game->getGameEventHandler(), + &GameEventHandler::handleUntapAll); + connect(phasesToolbar, &PhasesToolbar::drawCardRequested, game->getGameEventHandler(), + &GameEventHandler::handleDrawCard); } scene = new GameScene(phasesToolbar, this); connect(game->getPlayerManager(), &PlayerManager::playerConceded, scene, &GameScene::rearrange); @@ -1287,8 +1298,7 @@ void TabGame::createPlayAreaWidget(bool bReplay) connect(game->getGameEventHandler(), &GameEventHandler::arrowDeleted, scene, &GameScene::deleteArrow); gameView = new GameView(scene); - auto gamePlayAreaVBox = new QVBoxLayout; - gamePlayAreaVBox->setContentsMargins(0, 0, 0, 0); + auto gamePlayAreaVBox = QtUtils::makeLayout(); gamePlayAreaVBox->addWidget(gameView); gamePlayAreaWidget = new QWidget; @@ -1320,8 +1330,7 @@ void TabGame::createDeckViewContainerWidget(bool bReplay) deckViewContainerWidget = new QWidget(); deckViewContainerWidget->setObjectName("deckViewContainerWidget"); - deckViewContainerLayout = new QVBoxLayout; - deckViewContainerLayout->setContentsMargins(0, 0, 0, 0); + deckViewContainerLayout = QtUtils::makeLayout(); deckViewContainerWidget->setLayout(deckViewContainerLayout); } @@ -1336,8 +1345,7 @@ void TabGame::createCardInfoDock(bool bReplay) cardInfoFrameWidget = new CardInfoFrameWidget(); auto cardHInfoLayout = new QHBoxLayout; - auto cardVInfoLayout = new QVBoxLayout; - cardVInfoLayout->setContentsMargins(0, 0, 0, 0); + auto cardVInfoLayout = QtUtils::makeLayout(); cardVInfoLayout->addWidget(cardInfoFrameWidget); cardVInfoLayout->addLayout(cardHInfoLayout); @@ -1364,9 +1372,7 @@ void TabGame::createPlayerListDock(bool bReplay) playerListWidget->setFocusPolicy(Qt::NoFocus); auto *playerListBox = new QWidget(this); - auto *vbox = new QVBoxLayout(playerListBox); - vbox->setContentsMargins(0, 0, 0, 0); - vbox->setSpacing(0); + auto *vbox = QtUtils::makeLayout(playerListBox, 0); vbox->addWidget(playerListWidget); @@ -1391,8 +1397,7 @@ void TabGame::createPlayerListDock(bool bReplay) void TabGame::createMessageDock(bool bReplay) { - auto messageLogLayout = new QVBoxLayout; - messageLogLayout->setContentsMargins(0, 0, 0, 0); + auto messageLogLayout = QtUtils::makeLayout(); // clock if (!bReplay) { diff --git a/cockatrice/src/interface/widgets/tabs/tab_home.cpp b/cockatrice/src/interface/widgets/tabs/tab_home.cpp index 1358645d0..af9994fdd 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_home.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_home.cpp @@ -12,4 +12,5 @@ TabHome::TabHome(TabSupervisor *_tabSupervisor, AbstractClient *_client) : Tab(_ void TabHome::retranslateUi() { + homeWidget->retranslateUi(); } diff --git a/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp b/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp index 09a640540..be1adeef8 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp @@ -30,12 +30,14 @@ #include #include #include +#include #include #include #include #include #include #include +#include #include #include @@ -85,12 +87,16 @@ TabModeration::TabModeration(TabSupervisor *_tabSupervisor, AbstractClient *_cli removeAvatarButton = new QPushButton; connect(removeAvatarButton, &QPushButton::clicked, this, &TabModeration::removeAvatar); + removeBannerButton = new QPushButton; + connect(removeBannerButton, &QPushButton::clicked, this, &TabModeration::removeBanner); + auto *topBar = new QHBoxLayout; topBar->addWidget(searchEdit); topBar->addWidget(investigateButton); topBar->addStretch(); topBar->addWidget(resetPasswordButton); topBar->addWidget(removeAvatarButton); + topBar->addWidget(removeBannerButton); userInfoGroup = new QGroupBox; userInfoNameLabel = new QLabel; @@ -186,6 +192,7 @@ void TabModeration::retranslateUi() investigateButton->setText(tr("Investigate")); resetPasswordButton->setText(tr("Reset Password")); removeAvatarButton->setText(tr("Remove Avatar")); + removeBannerButton->setText(tr("Remove Banner")); refreshStaffButton->setText(tr("Refresh")); userInfoGroup->setTitle(tr("User Info")); @@ -227,6 +234,7 @@ void TabModeration::clearUserData() sessionsTable->setRowCount(0); resetPasswordButton->setEnabled(false); removeAvatarButton->setEnabled(false); + removeBannerButton->setEnabled(false); } void TabModeration::investigate(const QString &userName) @@ -247,6 +255,7 @@ void TabModeration::investigateUser() currentUser = userName; resetPasswordButton->setEnabled(true); removeAvatarButton->setEnabled(true); + removeBannerButton->setEnabled(true); altsTable->setRowCount(0); sessionsTable->setRowCount(0); requestUserInfo(userName); @@ -414,13 +423,11 @@ void TabModeration::resetPassword() return; } - QMessageBox::StandardButton choice = - QMessageBox::warning(this, tr("Reset Password"), - tr("Reset the password of %1? A temporary password will be generated and shown to you. " - "The user must change it on their first login.") - .arg(currentUser), - QMessageBox::Ok | QMessageBox::Cancel, QMessageBox::Cancel); - if (choice != QMessageBox::Ok) { + if (!QtUtils::confirm(this, tr("Reset Password"), + tr("Reset the password of %1? A temporary password will be generated and shown to you. " + "The user must change it on their first login.") + .arg(currentUser), + QMessageBox::No)) { return; } @@ -451,11 +458,9 @@ void TabModeration::removeAvatar() return; } - QMessageBox::StandardButton choice = - QMessageBox::warning(this, tr("Remove Avatar"), - tr("Remove the avatar of %1? The user will have to upload a new one.").arg(currentUser), - QMessageBox::Ok | QMessageBox::Cancel, QMessageBox::Cancel); - if (choice != QMessageBox::Ok) { + if (!QtUtils::confirm(this, tr("Remove Avatar"), + tr("Remove the avatar of %1? The user will have to upload a new one.").arg(currentUser), + QMessageBox::No)) { return; } @@ -477,3 +482,67 @@ void TabModeration::removeAvatarResponse(const Response &response) QMessageBox::information(this, tr("Avatar removed"), tr("The avatar of %1 has been removed.").arg(QString::fromStdString(resp.user_name()))); } + +void TabModeration::removeBanner() +{ + if (currentUser.isEmpty()) { + return; + } + + if (!QtUtils::confirm(this, tr("Remove Banner"), + tr("Remove the banner card of %1? The user will have to choose a new one.\n\n" + "Other players stop seeing the banner the next time %1 logs in.") + .arg(currentUser), + QMessageBox::No)) { + return; + } + + Command_RemoveUserBanner cmd; + cmd.set_user_name(currentUser.toStdString()); + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabModeration::removeBannerResponse); + client->sendCommand(pend); +} + +void TabModeration::removeBannerResponse(const Response &response) +{ + const QString target = currentUser.isEmpty() ? tr("this user") : currentUser; + + switch (response.response_code()) { + case Response::RespOk: + break; + case Response::RespNameNotFound: + QMessageBox::warning(this, tr("User not found"), + tr("The server does not know a user named %1.").arg(target)); + return; + case Response::RespFunctionNotAllowed: + QMessageBox::warning(this, tr("Not supported"), + tr("This server does not support removing banner cards. It may be running an older " + "version.")); + return; + case Response::RespInternalError: + QMessageBox::critical( + this, tr("Error"), + tr("The server could not clear the banner card of %1 because of a database error.").arg(target)); + return; + default: + QMessageBox::critical( + this, tr("Error"), + tr("The server rejected the request for %1: %2") + .arg(target, QString::fromStdString(Response_ResponseCode_Name(response.response_code())))); + return; + } + + const Response_RemoveUserBanner &resp = response.GetExtension(Response_RemoveUserBanner::ext); + const QString removedUser = resp.user_name().empty() ? target : QString::fromStdString(resp.user_name()); + if (!resp.had_banner()) { + QMessageBox::information(this, tr("No banner set"), + tr("%1 has no banner card set, so there was nothing to remove.").arg(removedUser)); + return; + } + + QMessageBox::information(this, tr("Banner removed"), + tr("The banner card of %1 has been removed.\n\n" + "Other players stop seeing it the next time %1 logs in.") + .arg(removedUser)); +} diff --git a/cockatrice/src/interface/widgets/tabs/tab_moderation.h b/cockatrice/src/interface/widgets/tabs/tab_moderation.h index e352e90bb..ce23b5a7a 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_moderation.h +++ b/cockatrice/src/interface/widgets/tabs/tab_moderation.h @@ -20,7 +20,7 @@ class TabSupervisor; /** * Staff investigation tool. Lets moderators look up a user's account data, * alternate accounts, login sessions, and staff login activity, and offers - * the password-reset and remove-avatar actions. + * the password-reset, remove-avatar, and remove-banner actions. */ class TabModeration : public Tab { @@ -44,6 +44,8 @@ private slots: void resetPasswordResponse(const Response &response); void removeAvatar(); void removeAvatarResponse(const Response &response); + void removeBanner(); + void removeBannerResponse(const Response &response); private: void requestUserInfo(const QString &userName); @@ -60,6 +62,7 @@ private: QPushButton *investigateButton; QPushButton *resetPasswordButton; QPushButton *removeAvatarButton; + QPushButton *removeBannerButton; QGroupBox *userInfoGroup; QLabel *userInfoNameLabel; diff --git a/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp b/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp index 6bf3f6be7..ce37ce440 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp @@ -2,7 +2,6 @@ #include "../../../client/settings/cache_settings.h" #include "../../deck_loader/deck_loader.h" -#include "../../deck_loader/loaded_deck.h" #include "../cards/card_size_widget.h" #include "../general/layout_containers/flow_widget.h" #include "../visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.h" @@ -28,12 +27,14 @@ #include #include #include +#include #include #include #include #include #include #include +#include #include #include @@ -44,9 +45,7 @@ TabPublicDecks::TabPublicDecks(TabSupervisor *_tabSupervisor, AbstractClient *_c cardSize = SettingsCache::instance().cardsDisplay().getVisualDeckStorageCardSize(); titleLabel = new QLabel(tr("Public decks of %1").arg(userName.toHtmlEscaped()), this); - QFont titleFont = titleLabel->font(); - titleFont.setBold(true); - titleLabel->setFont(titleFont); + titleLabel->setFont(QtUtils::boldFont(titleLabel->font())); auto *headerLayout = new QHBoxLayout; headerLayout->addWidget(titleLabel); diff --git a/cockatrice/src/interface/widgets/tabs/tab_replays.cpp b/cockatrice/src/interface/widgets/tabs/tab_replays.cpp index 52bc4cd8f..d37bff3d8 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_replays.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_replays.cpp @@ -16,7 +16,6 @@ #include #include #include -#include #include #include #include @@ -45,7 +44,6 @@ #include #include #include -#include #include #include #include @@ -61,6 +59,7 @@ #include #include #include +#include #include #include #include @@ -400,7 +399,7 @@ void TabReplays::actDeleteLocalReplay() void TabReplays::actOpenReplaysFolder() { QString dir = localDirModel->rootPath(); - QDesktopServices::openUrl(QUrl::fromLocalFile(dir)); + QtUtils::openPathInFileManager(dir); } void TabReplays::actRemoteDoubleClick(const QModelIndex &curRight) diff --git a/cockatrice/src/interface/widgets/tabs/tab_report.cpp b/cockatrice/src/interface/widgets/tabs/tab_report.cpp index 9018e2477..988c3e7d9 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_report.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_report.cpp @@ -12,8 +12,6 @@ #include #include #include -#include -#include #include #include #include @@ -52,6 +50,7 @@ #include #include #include +#include #include #include @@ -138,13 +137,7 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T chatGroup = new QGroupBox; chatLogEdit = new QTextEdit; chatLogEdit->setReadOnly(true); - QFont monoFont("monospace"); - monoFont.setStyleHint(QFont::Monospace); - const int systemPointSize = QFontDatabase::systemFont(QFontDatabase::GeneralFont).pointSize(); - if (systemPointSize > 0) { - monoFont.setPointSize(systemPointSize); - } - chatLogEdit->setFont(monoFont); + chatLogEdit->setFont(QtUtils::monospaceFont()); auto *chatLayout = new QVBoxLayout(chatGroup); chatLayout->setContentsMargins(4, 4, 4, 4); chatLayout->addWidget(chatLogEdit); @@ -217,8 +210,7 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T sgLayout->addWidget(statsTrendLabel); sgLayout->addWidget(statsCategoriesLabel); sgLayout->addWidget(statsDetailText); - auto *statsGroupLayout = new QVBoxLayout(statsGroup); - statsGroupLayout->setContentsMargins(0, 0, 0, 0); + auto *statsGroupLayout = QtUtils::makeLayout(statsGroup); statsGroupLayout->addWidget(statsContent); statsGroup->setChecked(true); connect(statsGroup, &QGroupBox::toggled, this, [this, statsContent](bool checked) { diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index 2352d7d7b..3316232a4 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -2,10 +2,6 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" -#include "../../../game/abstract_game.h" -#include "../../../game/game_event_handler.h" -#include "../../../game/game_meta_info.h" -#include "../../deck_loader/loaded_deck.h" #include "../../intents/contexts/context_connect_to_server.h" #include "../../intents/contexts/context_join_game.h" #include "../../intents/contexts/context_join_room.h" @@ -51,8 +47,6 @@ #include #include #include -#include -#include #include #include #include @@ -65,6 +59,10 @@ #include #include #include +#include +#include +#include +#include #include #include #include @@ -79,6 +77,7 @@ #include #include #include +#include #include #include #include @@ -291,48 +290,11 @@ void TabSupervisor::retranslateUi() aTabDeveloper->setText(tr("Developer")); // tabs - QList tabs; - tabs.append(tabServer); - tabs.append(tabReplays); - tabs.append(tabDeckStorage); - tabs.append(tabAdmin); - tabs.append(tabAccount); - tabs.append(tabLog); - tabs.append(tabReport); - tabs.append(tabModeration); - tabs.append(tabCardArtRules); - tabs.append(tabDeveloper); - QMapIterator roomIterator(roomTabs); - while (roomIterator.hasNext()) { - tabs.append(roomIterator.next().value()); - } - QMapIterator gameIterator(gameTabs); - while (gameIterator.hasNext()) { - tabs.append(gameIterator.next().value()); - } - QMapIterator publicDecksIterator(publicDecksTabs); - while (publicDecksIterator.hasNext()) { - tabs.append(publicDecksIterator.next().value()); - } - QListIterator replayIterator(replayTabs); - while (replayIterator.hasNext()) { - tabs.append(replayIterator.next()); - } - QListIterator deckEditorIterator(deckEditorTabs); - while (deckEditorIterator.hasNext()) { - tabs.append(deckEditorIterator.next()); - } - QMapIterator messageIterator(messageTabs); - while (messageIterator.hasNext()) { - tabs.append(messageIterator.next().value()); - } - - for (auto &tab : tabs) { - if (tab) { - int idx = indexOf(tab); + for (int i = 0; i < count(); ++i) { + if (auto *tab = qobject_cast(widget(i))) { QString tabText = tab->getTabText(); - setTabText(idx, sanitizeTabName(tabText)); - setTabToolTip(idx, sanitizeHtml(tabText)); + setTabText(i, sanitizeTabName(tabText)); + setTabToolTip(i, sanitizeHtml(tabText)); tab->retranslateUi(); } } @@ -362,9 +324,8 @@ void TabSupervisor::closeEvent(QCloseEvent *event) QTabWidget::closeEvent(event); if (getGameCount()) { - if (QMessageBox::question(this, tr("Are you sure?"), - tr("There are still open games. Are you sure you want to quit?"), - QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::No) { + if (!QtUtils::confirm(this, tr("Are you sure?"), + tr("There are still open games. Are you sure you want to quit?"), QMessageBox::No)) { event->ignore(); return; } diff --git a/cockatrice/src/interface/widgets/tabs/tab_visual_database_display.cpp b/cockatrice/src/interface/widgets/tabs/tab_visual_database_display.cpp index f4b007072..26d29463b 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_visual_database_display.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_visual_database_display.cpp @@ -23,9 +23,10 @@ TabVisualDatabaseDisplay::TabVisualDatabaseDisplay(TabSupervisor *_tabSupervisor void TabVisualDatabaseDisplay::retranslateUi() { + visualDatabaseDisplayWidget->retranslateUi(); } void TabVisualDatabaseDisplay::openEdhrecTab(const CardInfoPtr &info, bool isCommander) const { getTabSupervisor()->addEdhrecTab(info, isCommander); -} \ No newline at end of file +} diff --git a/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.cpp b/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.cpp index be7255dc3..bf4e15aa6 100644 --- a/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.cpp +++ b/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.cpp @@ -2,7 +2,6 @@ #include "../../../../client/settings/cache_settings.h" #include "../../../../client/settings/shortcuts_settings.h" -#include "../../../deck_loader/loaded_deck.h" #include "../../deck_editor/deck_editor_card_database_dock_widget.h" #include "../../deck_editor/deck_editor_card_info_dock_widget.h" #include "../../deck_editor/deck_editor_deck_dock_widget.h" @@ -44,6 +43,7 @@ #include #include #include +#include #include #include #include @@ -61,8 +61,8 @@ TabDeckEditorVisual::TabDeckEditorVisual(TabSupervisor *_tabSupervisor) : Abstra { setObjectName("TabDeckEditorVisual"); - createCentralFrame(); createMenus(); + createCentralFrame(); installEventFilter(this); @@ -105,7 +105,6 @@ void TabDeckEditorVisual::createCentralFrame() centralFrame->addWidget(tabContainer); setCentralWidget(centralWidget); - setDockOptions(QMainWindow::AnimatedDocks | QMainWindow::AllowNestedDocks | QMainWindow::AllowTabbedDocks); } /** @brief Updates the visual deck, analytics, and sample hand after a deck change. */ diff --git a/cockatrice/src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.cpp b/cockatrice/src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.cpp index cb6dfab9c..ece9881b4 100644 --- a/cockatrice/src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.cpp +++ b/cockatrice/src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.cpp @@ -1,9 +1,7 @@ #include "tab_deck_storage_visual.h" #include "../../../../client/settings/cache_settings.h" -#include "../../../deck_loader/deck_file_format.h" #include "../../../deck_loader/deck_loader.h" -#include "../../../deck_loader/loaded_deck.h" #include "../../cards/additional_info/deck_color_identity.h" #include "../../deck_share/deck_share_utils.h" #include "../../deck_share/share_bar_widget.h" @@ -17,12 +15,15 @@ #include #include #include +#include #include +#include #include #include #include #include #include +#include #include #include @@ -46,9 +47,7 @@ TabDeckStorageVisual::TabDeckStorageVisual(TabSupervisor *_tabSupervisor, Abstra }); auto *widget = new QWidget(this); - auto *layout = new QVBoxLayout(widget); - layout->setContentsMargins(0, 0, 0, 0); - layout->setSpacing(0); + auto *layout = QtUtils::makeLayout(widget, 0); widget->setLayout(layout); this->setCentralWidget(widget); layout->addWidget(visualDeckStorageWidget); diff --git a/cockatrice/src/interface/widgets/utility/card_completer_delegate.cpp b/cockatrice/src/interface/widgets/utility/card_completer_delegate.cpp index af07590a8..a629df43c 100644 --- a/cockatrice/src/interface/widgets/utility/card_completer_delegate.cpp +++ b/cockatrice/src/interface/widgets/utility/card_completer_delegate.cpp @@ -34,6 +34,7 @@ #include #include #include +#include #include class QObject; @@ -181,9 +182,8 @@ void CardCompleterDelegate::drawManaSymbol(QPainter *p, QPoint centre, const QSt p->setBrush(Qt::NoBrush); p->drawEllipse(pip.adjusted(1, 1, -1, -1)); - QFont f = p->font(); + QFont f = QtUtils::boldFont(p->font()); f.setPixelSize(qMax(radius - 1, 7)); - f.setBold(true); p->setFont(f); p->setPen(mc.text); diff --git a/cockatrice/src/interface/widgets/utility/card_completer_styler.cpp b/cockatrice/src/interface/widgets/utility/card_completer_styler.cpp index 5b33303a0..8f1941412 100644 --- a/cockatrice/src/interface/widgets/utility/card_completer_styler.cpp +++ b/cockatrice/src/interface/widgets/utility/card_completer_styler.cpp @@ -31,6 +31,7 @@ #include #include #include +#include #include template class QSharedPointer; @@ -346,12 +347,7 @@ void CardCompleterStyler::showPreview() preview->show(); preview->raise(); - auto *fade = new QPropertyAnimation(preview, "windowOpacity", preview); - - fade->setDuration(FadeDuration); - fade->setStartValue(preview->windowOpacity()); - fade->setEndValue(1.0); - fade->start(QAbstractAnimation::DeleteWhenStopped); + QtUtils::fadeWindow(preview, preview->windowOpacity(), 1.0, FadeDuration); } // --------------------------------------------------------------------------- diff --git a/cockatrice/src/interface/widgets/utility/sequence_edit.cpp b/cockatrice/src/interface/widgets/utility/sequence_edit.cpp index e92287c9a..44e6581e9 100644 --- a/cockatrice/src/interface/widgets/utility/sequence_edit.cpp +++ b/cockatrice/src/interface/widgets/utility/sequence_edit.cpp @@ -15,6 +15,7 @@ #include #include #include +#include SequenceEdit::SequenceEdit(const QString &_shortcutName, QWidget *parent) : QWidget(parent) { @@ -26,9 +27,7 @@ SequenceEdit::SequenceEdit(const QString &_shortcutName, QWidget *parent) : QWid clearButton->setIcon(themePixmap(QStringLiteral("icons/clearsearch"))); defaultButton->setIcon(themePixmap(QStringLiteral("icons/update"))); - auto *layout = new QHBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); - layout->setSpacing(1); + auto *layout = QtUtils::makeLayout(this, 1); layout->addWidget(lineEdit); layout->addWidget(clearButton); layout->addWidget(defaultButton); 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 90cfd9cd8..1b4af8123 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 @@ -11,7 +11,6 @@ #include #include #include -#include #include #include #include @@ -49,7 +48,7 @@ VisualDatabaseDisplayColorFilterWidget::VisualDatabaseDisplayColorFilterWidget(Q &VisualDatabaseDisplayColorFilterWidget::updateFilterMode); connect(filterModel, &FilterTreeModel::layoutChanged, this, - [this]() { QTimer::singleShot(100, this, &VisualDatabaseDisplayColorFilterWidget::syncWithFilterModel); }); + [this]() { syncDebouncer.call([this] { syncWithFilterModel(); }); }); // Call retranslateUi to set the initial text retranslateUi(); 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 c1c8e173f..90755a2ab 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 @@ -12,6 +12,7 @@ #include #include #include +#include #include class FilterTreeModel; @@ -73,6 +74,7 @@ private: QHBoxLayout *layout; QComboBox *modeComboBox; FilterMode currentMode = FilterMode::Includes; // Default mode + QtUtils::Debouncer syncDebouncer{100}; }; #endif // VISUAL_DATABASE_DISPLAY_COLOR_FILTER_WIDGET_H diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp index 3e9d9e525..5063cce14 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp @@ -29,6 +29,7 @@ #include #include #include +#include #include class DeckListModel; @@ -110,14 +111,10 @@ VisualDatabaseDisplayFilterToolbarWidget::VisualDatabaseDisplayFilterToolbarWidg void VisualDatabaseDisplayFilterToolbarWidget::initialize() { - auto sortLayout = new QHBoxLayout(sortGroupBox); - sortLayout->setContentsMargins(0, 0, 0, 0); - sortLayout->setSpacing(0); + auto sortLayout = QtUtils::makeLayout(sortGroupBox, 0); sortLayout->setAlignment(Qt::AlignLeft); - auto filterLayout = new QHBoxLayout(filterGroupBox); - filterLayout->setContentsMargins(0, 0, 0, 0); - filterLayout->setSpacing(2); + auto filterLayout = QtUtils::makeLayout(filterGroupBox, 2); filterLayout->setAlignment(Qt::AlignLeft); // create settings widgets diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_format_legality_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_format_legality_filter_widget.cpp index a5725ff3f..e0734fbb8 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_format_legality_filter_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_format_legality_filter_widget.cpp @@ -16,12 +16,12 @@ #include #include #include -#include #include #include #include #include #include +#include #include #include @@ -48,8 +48,7 @@ VisualDatabaseDisplayFormatLegalityFilterWidget::VisualDatabaseDisplayFormatLega layout->addWidget(flowWidget); // Create a container for the threshold control - auto *thresholdLayout = new QHBoxLayout(); - thresholdLayout->setContentsMargins(0, 0, 0, 0); + auto *thresholdLayout = QtUtils::makeLayout(); thresholdLabel = new QLabel(this); thresholdLayout->addWidget(thresholdLabel); @@ -72,9 +71,8 @@ VisualDatabaseDisplayFormatLegalityFilterWidget::VisualDatabaseDisplayFormatLega layout->addWidget(toggleButton); connect(toggleButton, &QPushButton::clicked, this, &VisualDatabaseDisplayFormatLegalityFilterWidget::updateFilterMode); - connect(filterModel, &FilterTreeModel::layoutChanged, this, [this]() { - QTimer::singleShot(100, this, &VisualDatabaseDisplayFormatLegalityFilterWidget::syncWithFilterModel); - }); + connect(filterModel, &FilterTreeModel::layoutChanged, this, + [this]() { syncDebouncer.call([this] { syncWithFilterModel(); }); }); createFormatButtons(); // Populate buttons initially updateFilterMode(); // Initialize toggle button text diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_format_legality_filter_widget.h b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_format_legality_filter_widget.h index e94593e67..6cc55062e 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_format_legality_filter_widget.h +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_format_legality_filter_widget.h @@ -4,6 +4,7 @@ #include #include #include +#include #include class FilterTreeModel; @@ -42,6 +43,7 @@ private: QMap formatButtons; // Store toggle buttons bool exactMatchMode = true; // Toggle between "Exact Match" and "Includes" + QtUtils::Debouncer syncDebouncer{100}; }; #endif // COCKATRICE_VISUAL_DATABASE_DISPLAY_FORMAT_LEGALITY_FILTER_WIDGET_H diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_main_type_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_main_type_filter_widget.cpp index eff0fe10b..018841b30 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_main_type_filter_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_main_type_filter_widget.cpp @@ -16,11 +16,11 @@ #include #include #include -#include #include #include #include #include +#include #include VisualDatabaseDisplayMainTypeFilterWidget::VisualDatabaseDisplayMainTypeFilterWidget(QWidget *parent, @@ -42,8 +42,7 @@ VisualDatabaseDisplayMainTypeFilterWidget::VisualDatabaseDisplayMainTypeFilterWi layout->addWidget(flowWidget); // Create a container for the threshold control - auto *thresholdLayout = new QHBoxLayout(); - thresholdLayout->setContentsMargins(0, 0, 0, 0); + auto *thresholdLayout = QtUtils::makeLayout(); thresholdLabel = new QLabel(this); thresholdLayout->addWidget(thresholdLabel); @@ -65,9 +64,8 @@ VisualDatabaseDisplayMainTypeFilterWidget::VisualDatabaseDisplayMainTypeFilterWi toggleButton = new QPushButton(this); layout->addWidget(toggleButton); connect(toggleButton, &QPushButton::clicked, this, &VisualDatabaseDisplayMainTypeFilterWidget::updateFilterMode); - connect(filterModel, &FilterTreeModel::layoutChanged, this, [this]() { - QTimer::singleShot(100, this, &VisualDatabaseDisplayMainTypeFilterWidget::syncWithFilterModel); - }); + connect(filterModel, &FilterTreeModel::layoutChanged, this, + [this]() { syncDebouncer.call([this] { syncWithFilterModel(); }); }); createMainTypeButtons(); // Populate buttons initially updateFilterMode(); // Initialize toggle button text diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_main_type_filter_widget.h b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_main_type_filter_widget.h index b5a6f332c..1e2fa2b39 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_main_type_filter_widget.h +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_main_type_filter_widget.h @@ -10,6 +10,7 @@ #include #include #include +#include #include class FilterTreeModel; @@ -48,6 +49,7 @@ private: QMap typeButtons; // Store toggle buttons bool exactMatchMode = true; // Toggle between "Exact Match" and "Includes" + QtUtils::Debouncer syncDebouncer{100}; }; #endif // VISUAL_DATABASE_DISPLAY_MAIN_TYPE_FILTER_WIDGET_H diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_name_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_name_filter_widget.cpp index d3e497b21..e335cd4c0 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_name_filter_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_name_filter_widget.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -61,7 +62,7 @@ VisualDatabaseDisplayNameFilterWidget::VisualDatabaseDisplayNameFilterWidget(QWi &VisualDatabaseDisplayNameFilterWidget::actLoadFromClipboard); connect(filterModel, &FilterTreeModel::layoutChanged, this, - [this]() { QTimer::singleShot(100, this, &VisualDatabaseDisplayNameFilterWidget::syncWithFilterModel); }); + [this]() { syncDebouncer.call([this] { syncWithFilterModel(); }); }); retranslateUi(); } @@ -139,20 +140,19 @@ void VisualDatabaseDisplayNameFilterWidget::updateFilterModel() emit filterModel->layoutAboutToBeChanged(); filterModel->clearFiltersOfType(CardFilter::Attr::AttrNameExact); - filterModel->blockSignals(true); - filterModel->filterTree()->blockSignals(true); + { + // Scope the blockers so both models are unblocked before the explicit + // change notifications below. + const QSignalBlocker modelBlocker(filterModel); + const QSignalBlocker treeBlocker(filterModel->filterTree()); - for (const auto &name : activeFilters.keys()) { - QString nameString = name; - filterModel->addFilter(new CardFilter(nameString, CardFilter::Type::TypeOr, CardFilter::Attr::AttrNameExact)); + for (const auto &name : activeFilters.keys()) { + QString nameString = name; + filterModel->addFilter( + new CardFilter(nameString, CardFilter::Type::TypeOr, CardFilter::Attr::AttrNameExact)); + } } - filterModel->blockSignals(false); - filterModel->filterTree()->blockSignals(false); - - filterModel->blockSignals(false); - filterModel->filterTree()->blockSignals(false); - emit filterModel->filterTree()->changed(); emit filterModel->layoutChanged(); } diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_name_filter_widget.h b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_name_filter_widget.h index 1629cb93b..ba2684e05 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_name_filter_widget.h +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_name_filter_widget.h @@ -10,6 +10,7 @@ #include #include #include +#include #include class DeckListModel; @@ -46,6 +47,7 @@ private: QPushButton *loadFromClipboardButton; QMap activeFilters; // Store active name filter buttons + QtUtils::Debouncer syncDebouncer{100}; private slots: void actLoadFromDeck(); diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_set_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_set_filter_widget.cpp index 3d76fb78a..fe583361a 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_set_filter_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_set_filter_widget.cpp @@ -22,7 +22,6 @@ #include #include #include -#include #include #include #include @@ -30,13 +29,13 @@ #include #include #include +#include #include VisualDatabaseDisplayRecentSetFilterSettingsWidget::VisualDatabaseDisplayRecentSetFilterSettingsWidget(QWidget *parent) : QWidget(parent) { - layout = new QHBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); + layout = QtUtils::makeLayout(this); setLayout(layout); @@ -101,7 +100,7 @@ VisualDatabaseDisplaySetFilterWidget::VisualDatabaseDisplaySetFilterWidget(QWidg layout->addWidget(toggleButton); connect(toggleButton, &QPushButton::clicked, this, &VisualDatabaseDisplaySetFilterWidget::updateFilterMode); connect(filterModel, &FilterTreeModel::layoutChanged, this, - [this]() { QTimer::singleShot(100, this, &VisualDatabaseDisplaySetFilterWidget::syncWithFilterModel); }); + [this]() { syncDebouncer.call([this] { syncWithFilterModel(); }); }); createSetButtons(); // Populate buttons initially updateFilterMode(); @@ -304,5 +303,5 @@ void VisualDatabaseDisplaySetFilterWidget::updateFilterMode() // Reconnect the layoutChanged -> sync lambda connect(filterModel, &FilterTreeModel::layoutChanged, this, - [this]() { QTimer::singleShot(100, this, &VisualDatabaseDisplaySetFilterWidget::syncWithFilterModel); }); + [this]() { syncDebouncer.call([this] { syncWithFilterModel(); }); }); } diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_set_filter_widget.h b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_set_filter_widget.h index 468e97e0d..4874df79b 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_set_filter_widget.h +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_set_filter_widget.h @@ -10,6 +10,7 @@ #include #include #include +#include #include class FilterTreeModel; @@ -64,6 +65,7 @@ private: QMap activeSets; // Track active set filters bool exactMatchMode = true; // Toggle between "Exact Match" and "Includes" + QtUtils::Debouncer syncDebouncer{100}; }; #endif // VISUAL_DATABASE_DISPLAY_SET_FILTER_WIDGET_H diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_sub_type_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_sub_type_filter_widget.cpp index 705bb8efa..68a1edf11 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_sub_type_filter_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_sub_type_filter_widget.cpp @@ -17,11 +17,11 @@ #include #include #include -#include #include #include #include #include +#include #include VisualDatabaseDisplaySubTypeFilterWidget::VisualDatabaseDisplaySubTypeFilterWidget(QWidget *parent, @@ -47,8 +47,7 @@ VisualDatabaseDisplaySubTypeFilterWidget::VisualDatabaseDisplaySubTypeFilterWidg layout->addWidget(flowWidget); // Create a container for the threshold control - auto *thresholdLayout = new QHBoxLayout(); - thresholdLayout->setContentsMargins(0, 0, 0, 0); + auto *thresholdLayout = QtUtils::makeLayout(); thresholdLabel = new QLabel(this); thresholdLayout->addWidget(thresholdLabel); @@ -70,9 +69,8 @@ VisualDatabaseDisplaySubTypeFilterWidget::VisualDatabaseDisplaySubTypeFilterWidg toggleButton = new QPushButton(this); layout->addWidget(toggleButton); connect(toggleButton, &QPushButton::clicked, this, &VisualDatabaseDisplaySubTypeFilterWidget::updateFilterMode); - connect(filterModel, &FilterTreeModel::layoutChanged, this, [this]() { - QTimer::singleShot(100, this, &VisualDatabaseDisplaySubTypeFilterWidget::syncWithFilterModel); - }); + connect(filterModel, &FilterTreeModel::layoutChanged, this, + [this]() { syncDebouncer.call([this] { syncWithFilterModel(); }); }); createSubTypeButtons(); // Populate buttons initially updateFilterMode(); // Initialize the toggle button text diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_sub_type_filter_widget.h b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_sub_type_filter_widget.h index 7929063fd..8bef870ed 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_sub_type_filter_widget.h +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_sub_type_filter_widget.h @@ -10,6 +10,7 @@ #include #include #include +#include #include class FilterTreeModel; @@ -50,6 +51,7 @@ private: QMap typeButtons; // Store toggle buttons bool exactMatchMode = true; // Toggle between "Exact Match" and "Includes" + QtUtils::Debouncer syncDebouncer{100}; }; #endif // VISUAL_DATABASE_DISPLAY_SUB_TYPE_FILTER_WIDGET_H 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 599ac97b4..5a1097b0d 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 @@ -54,6 +54,7 @@ #include #include #include +#include #include class QMouseEvent; @@ -92,10 +93,8 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, // Set up main layout and widgets setMinimumSize(0, 0); setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); - mainLayout = new QVBoxLayout(this); + mainLayout = QtUtils::makeLayout(this, 0, 1); setLayout(mainLayout); - mainLayout->setSpacing(1); - mainLayout->setContentsMargins(0, 0, 0, 0); flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarPolicy::ScrollBarAsNeeded); cardSizeWidget = new CardSizeWidget(this, flowWidget, @@ -117,7 +116,7 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, setFocusProxy(searchEdit); setFocusPolicy(Qt::ClickFocus); - displayModeButton = new QPushButton(tr("Visual"), this); + displayModeButton = new QPushButton(this); displayModeButton->setCheckable(true); // Toggle button connect(displayModeButton, &QPushButton::toggled, this, &VisualDatabaseDisplayWidget::onDisplayModeChanged); @@ -263,6 +262,10 @@ void VisualDatabaseDisplayWidget::retranslateUi() { databaseLoadIndicator->setText(tr("Loading database ...")); clearFilterWidget->setToolTip(tr("Clear all filters")); + searchEdit->setPlaceholderText(tr("Search by card name (or search expressions)")); + updateDisplayModeButton(); + colorFilterWidget->retranslateUi(); + filterContainer->retranslateUi(); } void VisualDatabaseDisplayWidget::highlightAllSearchEdit() @@ -270,6 +273,11 @@ void VisualDatabaseDisplayWidget::highlightAllSearchEdit() searchEdit->setSelection(0, searchEdit->text().length()); } +void VisualDatabaseDisplayWidget::updateDisplayModeButton() +{ + displayModeButton->setText(displayModeButton->isChecked() ? tr("Table") : tr("Visual")); +} + void VisualDatabaseDisplayWidget::resizeEvent(QResizeEvent *event) { QWidget::resizeEvent(event); @@ -278,16 +286,15 @@ void VisualDatabaseDisplayWidget::resizeEvent(QResizeEvent *event) void VisualDatabaseDisplayWidget::onDisplayModeChanged(bool checked) { + updateDisplayModeButton(); if (checked) { // Table mode - displayModeButton->setText(tr("Table")); flowWidget->setVisible(false); cardSizeWidget->setVisible(false); databaseView->setItemDelegate(new QStyledItemDelegate(databaseView)); databaseView->setVisible(true); } else { // Visual mode - displayModeButton->setText(tr("Visual")); flowWidget->setVisible(true); cardSizeWidget->setVisible(true); databaseView->setVisible(false); @@ -432,10 +439,11 @@ void VisualDatabaseDisplayWidget::loadPage(int start, int end) if (CardInfoPtr info = CardDatabaseManager::query()->getCardInfo(name.toString())) { if (!setFilters.empty()) { - SetToPrintingsMap setMap = info->getSets(); + const auto &setMap = info->getSets(); for (const CardFilter *setFilter : setFilters) { - if (setMap.contains(setFilter->term())) { - for (PrintingInfo printing : setMap[setFilter->term()]) { + const auto it = setMap.constFind(setFilter->term()); + if (it != setMap.constEnd()) { + for (const PrintingInfo &printing : it.value()) { addCardToDisplay(ExactCard(info, printing)); } } diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.h b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.h index 9771e55c1..15adca214 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.h +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.h @@ -145,6 +145,7 @@ private: void initializeFilters(); void highlightAllSearchEdit(); + void updateDisplayModeButton(); bool nearEndOfPage() const; protected: diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_filter_display_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_filter_display_widget.cpp index 0f6ac51ec..d13db1273 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_filter_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_filter_display_widget.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include FilterDisplayWidget::FilterDisplayWidget(QWidget *parent, const QString &filename, FilterTreeModel *_filterModel) @@ -69,10 +70,8 @@ void FilterDisplayWidget::loadFilter() void FilterDisplayWidget::deleteFilter() { // Show confirmation dialog before deleting the filter - QMessageBox::StandardButton reply = QMessageBox::question( - this, tr("Confirm Delete"), tr("Are you sure you want to delete the filter '%1'?").arg(filterFilename), - QMessageBox::Yes | QMessageBox::No); - if (reply == QMessageBox::Yes) { + if (QtUtils::confirm(this, tr("Confirm Delete"), + tr("Are you sure you want to delete the filter '%1'?").arg(filterFilename))) { // If confirmed, delete the filter QString filePath = SettingsCache::instance().paths().getFiltersPath() + QDir::separator() + filterFilename; QFile file(filePath); diff --git a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp index 25ba41aa9..63ef07835 100644 --- a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -25,9 +26,7 @@ VisualDeckDisplayOptionsWidget::VisualDeckDisplayOptionsWidget(QWidget *parent) : QWidget(parent) { - groupAndSortLayout = new QHBoxLayout(this); - groupAndSortLayout->setContentsMargins(0, 0, 0, 0); - groupAndSortLayout->setSpacing(3); + groupAndSortLayout = QtUtils::makeLayout(this, 3); groupAndSortLayout->setAlignment(Qt::AlignLeft); this->setLayout(groupAndSortLayout); @@ -110,12 +109,33 @@ void VisualDeckDisplayOptionsWidget::retranslateUi() sortByLabel->setText(tr("Sort by:")); sortLabel->setText(tr("Click and drag to change the sort order within the groups")); sortCriteriaButton->setToolTip(tr("Configure how cards are sorted within their groups")); - displayTypeButton->setButtonText(tr("Toggle Layout: Overlap")); - displayTypeButton->setButtonIcon(themePixmap(QStringLiteral("icons/scales"))); + updateDisplayTypeButton(); displayTypeButton->setToolTip( tr("Change how cards are displayed within zones (i.e. overlapped or fully visible.)")); } +void VisualDeckDisplayOptionsWidget::updateDisplayTypeButton() +{ + switch (currentDisplayType) { + case DisplayType::Flat: + displayTypeButton->setButtonText(tr("Toggle Layout: Flat")); + displayTypeButton->setButtonIcon(themePixmap(QStringLiteral("icons/scroll"))); + break; + case DisplayType::Overlap: + displayTypeButton->setButtonText(tr("Toggle Layout: Overlap")); + displayTypeButton->setButtonIcon(themePixmap(QStringLiteral("icons/scales"))); + break; + } +} + +void VisualDeckDisplayOptionsWidget::changeEvent(QEvent *event) +{ + if (event->type() == QEvent::LanguageChange) { + retranslateUi(); + } + QWidget::changeEvent(event); +} + void VisualDeckDisplayOptionsWidget::onSortCriteriaChange() { QStringList selectedCriteria; @@ -132,17 +152,8 @@ void VisualDeckDisplayOptionsWidget::updateDisplayType() // Toggle the display type currentDisplayType = (currentDisplayType == DisplayType::Overlap) ? DisplayType::Flat : DisplayType::Overlap; - // Update UI and emit signal - switch (currentDisplayType) { - case DisplayType::Flat: - displayTypeButton->setButtonText(tr("Toggle Layout: Flat")); - displayTypeButton->setButtonIcon(themePixmap(QStringLiteral("icons/scroll"))); - break; - case DisplayType::Overlap: - displayTypeButton->setButtonText(tr("Toggle Layout: Overlap")); - displayTypeButton->setButtonIcon(themePixmap(QStringLiteral("icons/scales"))); - break; - } + // Update only the toggle button, not every string in the widget + updateDisplayTypeButton(); emit displayTypeChanged(currentDisplayType); } diff --git a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.h b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.h index 96600c587..c40b8ffeb 100644 --- a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.h @@ -11,6 +11,7 @@ class CompactPushButton; class QComboBox; +class QEvent; class QHBoxLayout; class QLabel; class QListWidget; @@ -57,6 +58,7 @@ public slots: * @brief Updates all UI text for retranslation/localization. * * Called when the application language changes. + * Also refreshes the layout toggle button text for the current display type. */ void retranslateUi(); void updateCompactMode(bool mode); @@ -94,6 +96,10 @@ public: return activeSortCriteria; } +protected: + /** @brief Retranslates on QEvent::LanguageChange. */ + void changeEvent(QEvent *event) override; + private slots: /** * @brief Slot triggered whenever the sort list is reordered. @@ -110,6 +116,8 @@ private slots: void updateDisplayType(); private: + /** @brief Updates the layout toggle button's text and icon for the current display type. */ + void updateDisplayTypeButton(); /** @brief Layout for grouping and sorting UI elements. */ QHBoxLayout *groupAndSortLayout; diff --git a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_sample_hand_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_sample_hand_widget.cpp index 1554dceb8..91ad63433 100644 --- a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_sample_hand_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_sample_hand_widget.cpp @@ -25,6 +25,7 @@ #include #include #include +#include #include #include #include @@ -41,9 +42,7 @@ VisualDeckEditorSampleHandWidget::VisualDeckEditorSampleHandWidget(QWidget *pare layout->setSpacing(0); setLayout(layout); - auto upperLayout = new QVBoxLayout(); - upperLayout->setContentsMargins(0, 0, 0, 0); - upperLayout->setSpacing(0); + auto upperLayout = QtUtils::makeLayout(nullptr, 0); resetAndHandSizeContainerWidget = new QWidget(this); resetAndHandSizeLayout = new QHBoxLayout(resetAndHandSizeContainerWidget); diff --git a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.cpp index 74b940586..506ffa2b4 100644 --- a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.cpp @@ -42,6 +42,7 @@ #include #include #include +#include #include VisualDeckEditorWidget::VisualDeckEditorWidget(QWidget *parent, @@ -188,14 +189,9 @@ void VisualDeckEditorWidget::initializeDisplayOptionsAndSearchWidget() void VisualDeckEditorWidget::initializeScrollAreaAndZoneContainer() { - scrollArea = new QScrollArea(this); - scrollArea->setWidgetResizable(true); + scrollArea = QtUtils::makeScrollArea(this); scrollArea->setMinimumSize(0, 0); - // Set scrollbar policies - scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); - zoneContainer = new QWidget(scrollArea); zoneContainer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); zoneContainer->setObjectName("zoneContainer"); diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.cpp index dc619e9fa..cebe92495 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.cpp @@ -1,9 +1,7 @@ #include "deck_preview_deck_tags_display_widget.h" #include "../../../../client/settings/cache_settings.h" -#include "../../../deck_loader/deck_file_format.h" #include "../../../deck_loader/deck_loader.h" -#include "../../../deck_loader/loaded_deck.h" #include "../../general/layout_containers/flow_widget.h" #include "deck_preview_tag_addition_widget.h" #include "deck_preview_tag_dialog.h" @@ -14,8 +12,12 @@ #include #include #include +#include #include +#include +#include #include +#include #include #include @@ -26,8 +28,7 @@ DeckPreviewDeckTagsDisplayWidget::DeckPreviewDeckTagsDisplayWidget(QWidget *_par setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum); // Create layout - auto *layout = new QHBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); + auto *layout = QtUtils::makeLayout(this); setFixedHeight(100); @@ -117,8 +118,13 @@ void DeckPreviewDeckTagsDisplayWidget::openTagEditDlg() void DeckPreviewDeckTagsDisplayWidget::execTagDialog(const QStringList &knownTags) { + QPointer self(this); DeckPreviewTagDialog dialog(knownTags, currentTags); if (dialog.exec() == QDialog::Accepted) { + // Guard against the widget being destroyed during dialog.exec()'s nested event loop. + if (!self) { + return; + } QStringList updatedTags = dialog.getActiveTags(); if (updatedTags != currentTags) { setTags(updatedTags); diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_item_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_item_widget.cpp index 7796f893b..8850060ea 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_item_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_item_widget.cpp @@ -2,13 +2,12 @@ #include #include +#include DeckPreviewTagItemWidget::DeckPreviewTagItemWidget(const QString &tagName, bool isChecked, QWidget *parent) : QWidget(parent), checkBox_(new QCheckBox(this)) { - auto *layout = new QHBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); - layout->setSpacing(5); + auto *layout = QtUtils::makeLayout(this, 5); checkBox_->setText(tagName); // Set the tag name as the checkbox label checkBox_->setChecked(isChecked); // Set the initial state of the checkbox diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.cpp index 220c55788..f2002d4e4 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.cpp @@ -3,7 +3,6 @@ #include "../../../../client/settings/cache_settings.h" #include "../../../../interface/widgets/dialogs/dlg_convert_deck_to_cod_format.h" #include "../../../deck_loader/deck_loader.h" -#include "../../../deck_loader/loaded_deck.h" #include "../../cards/additional_info/color_identity_widget.h" #include "../../cards/deck_preview_card_picture_widget.h" #include "../visual_deck_storage_model.h" @@ -45,6 +44,7 @@ #include #include #include +#include #include #include #include diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.h index cdeed87c7..f3fc982ac 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.h @@ -16,6 +16,7 @@ #include #include #include +#include #include class QFrame; @@ -131,13 +132,9 @@ protected: // If popup is not open, forward event to parent scroll area if (!combo->view()->isVisible()) { // Try to find a scrollable parent and manually send the event - QWidget *parent = combo->parentWidget(); - while (parent) { - if (auto *scroll = qobject_cast(parent)) { - QApplication::sendEvent(scroll->viewport(), event); - return true; // Mark event as handled - } - parent = parent->parentWidget(); + if (auto *scroll = QtUtils::findParentOfType(combo)) { + QApplication::sendEvent(scroll->viewport(), event); + return true; // Mark event as handled } // If no scrollable parent found, just block return true; diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/public_deck_preview_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/public_deck_preview_widget.cpp index 361747423..11a9da66f 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/public_deck_preview_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/public_deck_preview_widget.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include PublicDeckPreviewWidget::PublicDeckPreviewWidget(QWidget *parent, const RemotePublicDecksModel::DeckEntry &entry) @@ -36,8 +37,7 @@ PublicDeckPreviewWidget::PublicDeckPreviewWidget(QWidget *parent, const RemotePu tagsFlowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAsNeeded); tagsFlowWidget->setSpacing(3, 3); - auto *layout = new QVBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); + auto *layout = QtUtils::makeLayout(this); layout->addWidget(bannerCardDisplayWidget); layout->addWidget(uploadTimeLabel); layout->addWidget(colorIdentityWidget); 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 e84631e52..3611a8c1f 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 @@ -1,8 +1,6 @@ #include "visual_deck_storage_model.h" -#include "../../deck_loader/deck_file_format.h" #include "../../deck_loader/deck_loader.h" -#include "../../deck_loader/loaded_deck.h" #include "../cards/additional_info/deck_color_identity.h" #include "libcockatrice/deck_list/deck_list.h" @@ -21,6 +19,8 @@ #include #include #include +#include +#include #include #include #include diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.h b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.h index 57eaebacf..f9af6f318 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.h @@ -13,7 +13,6 @@ #ifndef VISUAL_DECK_STORAGE_MODEL_H #define VISUAL_DECK_STORAGE_MODEL_H -#include "../../deck_loader/loaded_deck.h" #include "libcockatrice/utility/card_ref.h" #include @@ -24,6 +23,9 @@ #include #include #include +#include +#include +#include #include #include diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp index 2c03666c1..f2669f55d 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp @@ -9,6 +9,7 @@ #include #include #include +#include /** * @brief Constructs a search bar for filtering decks in the Visual Deck Storage. @@ -20,8 +21,7 @@ */ VisualDeckStorageSearchWidget::VisualDeckStorageSearchWidget(QWidget *parent) : QWidget(parent) { - layout = new QHBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); + layout = QtUtils::makeLayout(this); setLayout(layout); searchBar = new QLineEdit(this); diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.cpp index abe3327af..c93e496ec 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.cpp @@ -1,7 +1,6 @@ #include "visual_deck_storage_sort_filter_proxy_model.h" #include "../../../client/settings/cache_settings.h" -#include "../../deck_loader/loaded_deck.h" #include "../../filters/deck_filter_string.h" #include "visual_deck_storage_model.h" @@ -16,6 +15,7 @@ #include #include #include +#include #include VisualDeckStorageSortFilterProxyModel::VisualDeckStorageSortFilterProxyModel(QObject *parent) diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_widget.cpp index f798a72a9..e4285e360 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_widget.cpp @@ -7,11 +7,11 @@ #include #include #include +#include VisualDeckStorageSortWidget::VisualDeckStorageSortWidget(VisualDeckStorageWidget *parent) : QWidget(parent) { - layout = new QHBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); + layout = QtUtils::makeLayout(this); setLayout(layout); // Initialize the ComboBox diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp index 51e9c70e7..4e1d6293f 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp @@ -33,6 +33,7 @@ #include #include #include +#include #include VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(parent) @@ -98,10 +99,7 @@ VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(pare &VisualDeckStorageWidget::updateSelectionAnimationEnabled); // deck area - scrollArea = new QScrollArea(this); - scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); - scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - scrollArea->setWidgetResizable(true); + scrollArea = QtUtils::makeScrollArea(this); // putting everything together layout->addWidget(searchAndSortContainer); @@ -186,7 +184,9 @@ void VisualDeckStorageWidget::resizeEvent(QResizeEvent *event) void VisualDeckStorageWidget::retranslateUi() { - databaseLoadIndicator->setText(tr("Loading database ...")); + if (databaseLoadIndicator) { + databaseLoadIndicator->setText(tr("Loading database ...")); + } refreshButton->setToolTip(tr("Refresh loaded files")); shareButton->setToolTip(tr("Select decks to share")); @@ -281,6 +281,14 @@ void VisualDeckStorageWidget::createRootFolderWidget() folderWidget = new VisualDeckStorageFolderDisplayWidget(this, this, QString(), false, quickSettingsWidget->getShowFolders()); + // QScrollArea::setWidget() deletes the widget it currently holds. If that is still the + // database load indicator, take it back first so the label stays alive for retranslateUi(). + if (databaseLoadIndicator && scrollArea->widget() == databaseLoadIndicator) { + scrollArea->takeWidget(); + databaseLoadIndicator->setParent(this); + databaseLoadIndicator->setVisible(false); + } + scrollArea->setWidget(folderWidget); // this automatically destroys the old folderWidget scrollArea->widget()->setMaximumWidth(scrollArea->viewport()->width()); scrollArea->widget()->adjustSize(); diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.h index b512dd87c..19d16f107 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.h @@ -6,6 +6,7 @@ #ifndef VISUAL_DECK_STORAGE_WIDGET_H #define VISUAL_DECK_STORAGE_WIDGET_H +#include #include #include #include @@ -87,7 +88,7 @@ private: QVBoxLayout *layout; QWidget *searchAndSortContainer; QHBoxLayout *searchAndSortLayout; - QLabel *databaseLoadIndicator; + QPointer databaseLoadIndicator; VisualDeckStorageSortWidget *sortWidget; VisualDeckStorageSearchWidget *searchWidget; DeckPreviewColorIdentityFilterWidget *deckPreviewColorIdentityFilterWidget; diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index 636cee281..f8ab5d3f6 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -62,7 +62,6 @@ #include #include #include -#include #include #include #include @@ -112,6 +111,7 @@ #include #include #include +#include #include #include #include @@ -341,7 +341,7 @@ void MainWindow::actViewLog() void MainWindow::actOpenSettingsFolder() { QString dir = SettingsCache::instance().getSettingsPath(); - QDesktopServices::openUrl(QUrl::fromLocalFile(dir)); + QtUtils::openPathInFileManager(dir); } void MainWindow::retranslateUi() @@ -923,11 +923,28 @@ void MainWindow::actShow() void MainWindow::closeEvent(QCloseEvent *event) { - // workaround Qt bug where closeEvent gets called twice - if (bClosingDown) { - return; + switch (shutdownState) { + case ShutdownState::Accepted: + // A duplicate close for a shutdown that already went through (Qt delivers the event + // twice on some platforms). Accepting it again keeps the quit that is already under + // way; ignoring it would cancel it. + event->accept(); + return; + case ShutdownState::Confirming: + // A second close request arrived while one of our own confirmations is up. Another + // program asking us to close - an installer replacing our files, for one - looks + // exactly like this, and accepting it would be destructive: close() hides this + // window, hides the dialog with it, and leaves the process running inside the nested + // loop that dialog opened. Nothing can reach that loop afterwards, so the process + // stays alive with no window and every later close request is accepted into the same + // dead end. Refuse this one and leave the question in front of the user; the shutdown + // that is already running finishes as soon as they answer it. + event->ignore(); + return; + case ShutdownState::Idle: + break; } - bClosingDown = true; + shutdownState = ShutdownState::Confirming; if (cardUpdateProcess && cardUpdateProcess->state() != QProcess::NotRunning) { const QString runningFor = cardUpdateStatusBar ? cardUpdateStatusBar->statusText() : QString(); @@ -937,22 +954,22 @@ void MainWindow::closeEvent(QCloseEvent *event) : tr("A card database update is still running: %1.\nQuitting now will cancel it.\n" "Are you sure you want to quit?") .arg(runningFor); - if (QMessageBox::question(this, tr("Are you sure?"), message, QMessageBox::Yes | QMessageBox::No, - QMessageBox::No) == QMessageBox::No) { + if (!QtUtils::confirm(this, tr("Are you sure?"), message, QMessageBox::No)) { event->ignore(); - bClosingDown = false; + shutdownState = ShutdownState::Idle; return; } } if (!tabSupervisor->close()) { event->ignore(); - bClosingDown = false; + shutdownState = ShutdownState::Idle; return; } tip->close(); event->accept(); + shutdownState = ShutdownState::Accepted; SettingsCache::instance().layouts().setMainWindowGeometry(saveGeometry()); tabSupervisor->deleteLater(); } @@ -963,12 +980,36 @@ bool MainWindow::closeForUpdate() // closeEvent() is blocked on a user prompt). close() would hit the re-entrancy guard and return // true without any shutdown having happened, so report faithfully instead: the caller must keep // the process alive until the user resolves whatever is blocking the close. - if (bClosingDown) { + if (shutdownState != ShutdownState::Idle) { return false; } return close(); } +void MainWindow::requestInstallerClose() +{ + // The same shutdown the in-app updater asks for: close the window, flush settings and let + // the tabs answer their own prompts. Never a forced quit - ending a process that has not + // agreed is the installer's separate force-close answer, not this one. + if (closeForUpdate()) { + QTimer::singleShot(0, qApp, [] { QCoreApplication::exit(0); }); + return; + } + // A confirmation is already up, or a shutdown is in progress. Bring it forward: an + // installer prompt behind a hidden confirmation is the deadlock this request exists to + // break, and the user has to be able to answer it. showWindowIfHidden() also clears + // Qt::WindowMinimized, which raise() alone does not: a minimized window can keep the + // confirmation behind the installer. + showWindowIfHidden(); + if (QWidget *modal = QApplication::activeModalWidget()) { + modal->raise(); + modal->activateWindow(); + } + raise(); + activateWindow(); + QApplication::alert(this, 0); +} + void MainWindow::changeEvent(QEvent *event) { if (event->type() == QEvent::LanguageChange) { @@ -1491,13 +1532,13 @@ void MainWindow::refreshShortcuts() void MainWindow::actOpenCustomFolder() { QString dir = SettingsCache::instance().paths().getCustomPicsPath(); - QDesktopServices::openUrl(QUrl::fromLocalFile(dir)); + QtUtils::openPathInFileManager(dir); } void MainWindow::actOpenCustomsetsFolder() { QString dir = SettingsCache::instance().getCustomCardDatabasePath(); - QDesktopServices::openUrl(QUrl::fromLocalFile(dir)); + QtUtils::openPathInFileManager(dir); } void MainWindow::actAddCustomSet() diff --git a/cockatrice/src/interface/window_main.h b/cockatrice/src/interface/window_main.h index bcf709cb1..9e342e4b0 100644 --- a/cockatrice/src/interface/window_main.h +++ b/cockatrice/src/interface/window_main.h @@ -194,7 +194,17 @@ private: LagMonitor lagMonitor; ///< watches the main thread for event loop stalls LatencyStatusWidget *latencyStatus = nullptr; ///< status bar widget with live round-trip stats and history graph bool bHasActivated, askedForDbUpdater; - bool bClosingDown = false; ///< guards closeEvent() against re-entrancy + /** + * How far a shutdown that was asked for has got. A close request that arrives while one is + * already asking the user something must not be accepted - see closeEvent(). + */ + enum class ShutdownState + { + Idle, ///< no shutdown has been asked for + Confirming, ///< asking about a card database update, open games or an unsaved deck + Accepted, ///< the close was accepted and the window is hiding + }; + ShutdownState shutdownState = ShutdownState::Idle; bool skipStartupAutoConnect = false; bool startupAutoConnectAttempted = false; bool firstRunWizardActive = false; @@ -245,6 +255,17 @@ public: */ bool closeForUpdate(); + /** + * @brief Closes this instance because the installer asked it to over its local socket. + * + * The request reaches an instance hidden in the notification area or sitting on its own + * confirmation, which is exactly where a WM_CLOSE from the installer does not. The close + * is still the ordinary one: a clean client exits, one with unsaved work raises the same + * confirmation the close button does, and a confirmation already up is brought to the + * front instead of being left behind the installer. + */ + void requestInstallerClose(); + protected: void closeEvent(QCloseEvent *event) override; void changeEvent(QEvent *event) override; diff --git a/cockatrice/src/main.cpp b/cockatrice/src/main.cpp index 3f63efc3b..829a8ac42 100644 --- a/cockatrice/src/main.cpp +++ b/cockatrice/src/main.cpp @@ -20,16 +20,19 @@ #include "main.h" +#include "client/crash/crash_reporter.h" #include "client/network/update/card_spoiler/spoiler_background_updater.h" #include "client/settings/cache_settings.h" #include "client/sound_engine.h" #include "client/url_scheme_event_filter.h" #include "database/interface/settings_card_preference_provider.h" +#include "installer_close_server.h" #include "interface/intents/intent.h" #include "interface/intents/intent_open_local_deck.h" #include "interface/logger.h" #include "interface/pixel_map_generator.h" #include "interface/theme_manager.h" +#include "interface/widgets/dialogs/dlg_crash_report.h" #include "interface/window_main.h" #include "libcockatrice/card/database/card_database.h" #include "libcockatrice/rng/rng_abstract.h" @@ -103,60 +106,6 @@ static void CockatriceLogger(QtMsgType type, const QMessageLogContext &ctx, cons Logger::getInstance().log(type, ctx, logMessage); } -#ifdef Q_OS_WIN -// clang-format off -#include -#include -#include -#include -#include - -#pragma comment(lib, "DbgHelp.lib") // Link the DbgHelp library -// clang-format on - -LONG WINAPI CockatriceUnhandledExceptionFilter(EXCEPTION_POINTERS *exceptionPointers) -{ - std::filesystem::path path; - - // Find %LOCALAPPDATA% (or cheat at finding it) - wchar_t *localAppDataFolder; - if (SHGetKnownFolderPath(FOLDERID_LocalAppData, KF_FLAG_CREATE, NULL, &localAppDataFolder) != S_OK) { - path = std::filesystem::temp_directory_path().parent_path().parent_path(); - } else { - path = std::filesystem::path(localAppDataFolder); - } - - // Plan on writing crash files into %LOCALAPPDATA%/CrashDumps/Cockatrice/cockatrice.crash.*.dmp - path /= "CrashDumps"; - path /= "Cockatrice"; - if (!std::filesystem::exists(path)) { - std::filesystem::create_directories(path); - } - path /= "cockatrice.crash." + std::to_string(std::time(0)) + ".dmp"; - - // Create and write crash files -#ifdef UNICODE - HANDLE hDumpFile = - CreateFile(path.wstring().c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); -#else - HANDLE hDumpFile = - CreateFile(path.string().c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); -#endif - - MINIDUMP_EXCEPTION_INFORMATION mei; - mei.ExceptionPointers = exceptionPointers; - mei.ThreadId = GetCurrentThreadId(); - mei.ClientPointers = 1; - - MiniDumpWriteDump(GetCurrentProcess(), GetCurrentProcessId(), hDumpFile, MiniDumpWithFullMemory, &mei, nullptr, - nullptr); - - CloseHandle(hDumpFile); - - return EXCEPTION_EXECUTE_HANDLER; -} -#endif - void installNewTranslator() { QString lang = SettingsCache::instance().personal().getLang(); @@ -197,11 +146,52 @@ static QString redactActivationUrl(const QString &url) return parsed.scheme() + "://" + parsed.host(); } +/** + * @brief Run the startup crash-report flow for an unclean exit (or --simulate-crash). + * + * With a configured DSN the SDK owns capture and upload (consent-gated); with an + * empty DSN the flow falls back to a local mock file. AlwaysSend uploads silently + * (the log was folded into the pending native envelope(s) at startup); Ask offers + * the dialog; NeverSend skips silently. + * + * @param attachedEnvelopes number of pending native envelopes that already carry the + * redacted log, so an AlwaysSend run only sends a standalone + * log event when no crash event existed. + */ +static void runCrashReportFlow(CrashReporter &crashReporter, QWidget *parent, int attachedEnvelopes) +{ + const PersonalSettings::CrashReportingMode crashMode = SettingsCache::instance().personal().getCrashReportingMode(); + const bool crashReady = crashReporter.isReady(); + if (crashMode == PersonalSettings::CrashReportingMode::AlwaysSend) { + if (crashReady) { + // Silent path: the redacted log was folded into the pending native crash + // envelope(s) at startup and consent already released them. Only when no + // pending crash event existed (e.g. --simulate-crash) does a standalone + // log event still need to go out here. + if (attachedEnvelopes == 0) { + const QString crashPreview = DlgCrashReport::buildRedactedPreview(); + if (!crashReporter.sendReport(crashPreview)) { + const QString savedPath = DlgCrashReport::writeMockReport(crashPreview); + qCWarning(MainLog) << "Failed to send crash report, saved locally to" << savedPath; + } + } + } else { + DlgCrashReport::writeMockReport(DlgCrashReport::buildRedactedPreview()); + } + } else if (crashMode == PersonalSettings::CrashReportingMode::Ask) { + // Real mode: Send gives consent + captures the event, Don't send revokes + // consent; the dialog owns both via the reporter. + DlgCrashReport crashDialog(parent, crashReady, crashReady ? &crashReporter : nullptr); + crashDialog.exec(); + } + // NeverSend: skip silently (no dialog, nothing written or sent). +} + int main(int argc, char *argv[]) { -#ifdef Q_OS_WIN - SetUnhandledExceptionFilter(CockatriceUnhandledExceptionFilter); -#endif + // No custom unhandled-exception filter: sentry owns native crash capture on all + // platforms. (The legacy MiniDumpWithFullMemory writer was removed as a privacy + // liability; unclean exits are detected via CrashReporter::beginSession below.) // Logging setup #ifdef Q_OS_APPLE @@ -259,6 +249,82 @@ int main(int argc, char *argv[]) qApp->setAttribute(Qt::AA_DontShowIconsInMenus, true); #endif + // Persistent session log: keeps this run's logs on disk so the next-startup crash dialog can + // attach the crashed session's tail (the in-memory buffer is lost on crash). Opened in Append + // mode with a per-launch session marker; read back via Logger::getPreviousSessionTail(). + // Declared before crash-reporter init: the startup attach below needs the log tail while the + // file still holds the crashed session's lines. + const QString persistentLogPath = + SettingsCache::instance().getSettingsPath() + QStringLiteral("logs/cockatrice.log"); + Logger::getInstance().setPersistentLogPath(persistentLogPath); + + // Crash reporting: init the SDK early so a crash anywhere below is captured into the + // local database. Uploads stay consent-gated (require_user_consent, armed in CrashReporter::init): + // AlwaysSend gives consent here so cached envelopes flush at startup; any other mode starts + // unconsented and the dialog below drives give/revoke. An empty DSN skips the SDK entirely and + // the mock fallback further below behaves exactly as before. + CrashReporter crashReporter; + const QString crashDatabasePath = SettingsCache::instance().getSettingsPath() + QStringLiteral("sentry"); + // Envelopes that received the redacted log at startup (AlwaysSend path); 0 means the log was + // NOT folded into a pending crash event and a standalone log event is still needed below. + int crashLogAttachedEnvelopes = 0; + { + QString crashDsn = QString::fromUtf8(qgetenv("COCKATRICE_SENTRY_DSN")); + const bool dsnFromEnvironment = !crashDsn.isEmpty(); + if (!dsnFromEnvironment) { + crashDsn = QString::fromUtf8(COCKATRICE_SENTRY_DSN); + } + if (crashDsn.isEmpty()) { + // Nothing is armed without a DSN, so no crash is ever captured and nothing is ever sent. + // It is the one disabled configuration a tester cannot see from the UI, so say it here. + qCWarning(CrashReportLog) << "crash reporting disabled: no DSN configured (COCKATRICE_SENTRY_DSN is " + "not set and this build has no DSN baked in)"; + } else if (!CrashReporter::isDsnWellFormed(crashDsn)) { + // Same rule the installer applies (cmake/CrashReporting.cmake): a malformed DSN disables + // reporting rather than silently falling back to the project baked into the build, which + // would upload where the tester never chose. The SDK would otherwise refuse to build a + // request at all and report nothing about it. + qCWarning(CrashReportLog) << "crash reporting disabled:" + << (dsnFromEnvironment ? "COCKATRICE_SENTRY_DSN is set but malformed" + : "the DSN baked into this build is malformed") + << "(expected ://@/)"; + crashDsn.clear(); + } else if (dsnFromEnvironment) { + // Destination and mode are logged by CrashReporter::init() once it resolves them. + qCInfo(CrashReportLog) << "using COCKATRICE_SENTRY_DSN from the environment"; + } + const PersonalSettings::CrashReportingMode crashMode = + SettingsCache::instance().personal().getCrashReportingMode(); + // AlwaysSend folds the crashed session's redacted log into the pending crash + // envelope(s) BEFORE init: sentry_init replays the crashed run's *.run + // envelopes immediately when consent is already persisted, so attaching any + // later would be too late. Consent given below then releases them, and dump, + // stack and log upload as a single Sentry event. Ask mode attaches at the + // dialog instead — its cache-held envelopes stay untouched until giveConsent(). + if (crashMode == PersonalSettings::CrashReportingMode::AlwaysSend && + CrashReporter::isReportingEnabled(crashDsn, crashMode)) { + crashLogAttachedEnvelopes = CrashReporter::attachToPendingCrashEnvelopes( + crashDatabasePath, DlgCrashReport::buildRedactedPreview(), QString()); + } + crashReporter.init(crashDsn, QString::fromUtf8(COCKATRICE_SENTRY_RELEASE), crashDatabasePath, crashMode); + if (crashReporter.isReady() && crashMode == PersonalSettings::CrashReportingMode::AlwaysSend) { + // Silent opt-in: cached native crash envelopes flush automatically once consent is given. + crashReporter.giveConsent(); + } + // Mid-session mode changes must reach the SDK: without this, switching from AlwaysSend to + // NeverSend (or Ask) leaves consent given until restart, so a later crash in this session + // would still upload after the user opted out. crashReporter outlives main(), so capturing + // it here is safe. + QObject::connect(&SettingsCache::instance().personal(), &PersonalSettings::crashReportingModeChanged, + [&crashReporter](PersonalSettings::CrashReportingMode mode) { + if (mode == PersonalSettings::CrashReportingMode::AlwaysSend) { + crashReporter.giveConsent(); + } else { + crashReporter.revokeConsent(); + } + }); + } + // Translations #ifdef Q_OS_MAC translationPath = qApp->applicationDirPath() + "/../Resources/translations"; @@ -276,7 +342,10 @@ int main(int argc, char *argv[]) parser.addOptions( {{{"c", "connect"}, QCoreApplication::translate("main", "Connect on startup"), "user:pass@host:port"}, - {{"d", "debug-output"}, QCoreApplication::translate("main", "Debug to file")}}); + {{"d", "debug-output"}, QCoreApplication::translate("main", "Debug to file")}, + {"simulate-crash", + QCoreApplication::translate("main", + "Show the crash-report dialog (saves a mock file when no DSN is configured)")}}); parser.process(app); @@ -320,6 +389,16 @@ int main(int argc, char *argv[]) } } + // Unclean-exit detection (cross-platform QLockFile session tracking, replacing the + // legacy Windows-only .dmp scan): a leftover session lock means the previous run died + // without reaching endSession(). Our own lock is held until the event loop finishes. + // Deliberately placed after parser.process() (--help/--version exit inside it and must + // not leak a lock) and after the instance hand-off (a handed-off secondary exits + // immediately and must never take a lock). Note this counts any death without + // endSession() as a crash, including SIGTERM at logout, Task Manager kills and + // session-end shutdowns; in AlwaysSend those send a report too. + const bool uncleanExit = crashReporter.beginSession(crashDatabasePath); + rng = new RNG_SFMT(CryptoUtil::randomUInt64()); themeManager = new ThemeManager; soundEngine = new SoundEngine; @@ -353,6 +432,15 @@ int main(int argc, char *argv[]) MainWindow ui; + // The installer asks a running instance to close over this socket, which reaches a + // hidden window and one sitting on its own confirmation. Failure is not fatal: the + // installer falls back to WM_CLOSE for clients that have no socket. + InstallerCloseServer installerClose; + if (installerClose.listen()) { + QObject::connect(&installerClose, &InstallerCloseServer::closeRequested, &ui, + &MainWindow::requestInstallerClose); + } + // A URL launch must own the connection: the intent chain triggered by the // URL connects to the server named in the URL, so the window's own startup // auto-connect must not race against it (two connectToServer calls tear @@ -413,6 +501,13 @@ int main(int argc, char *argv[]) ui.show(); qCInfo(MainLog) << "ui.show() finished"; + // Crash-report flow: the trigger is --simulate-crash or an unclean previous exit + // (leftover session lock; the old %LOCALAPPDATA%/CrashDumps .dmp scan is gone with + // the exception filter). See runCrashReportFlow() for the per-mode handling. + if (parser.isSet("simulate-crash") || uncleanExit) { + runCrashReportFlow(crashReporter, &ui, crashLogAttachedEnvelopes); + } + // force shortcuts to be shown/hidden in right-click menus, regardless of system defaults qApp->setAttribute(Qt::AA_DontShowShortcutsInContextMenus, !SettingsCache::instance().userInterface().getShowShortcuts()); @@ -444,5 +539,10 @@ int main(int argc, char *argv[]) UserLevelPixmapGenerator::clear(); ManaSymbolPixmapGenerator::clear(); + // Released last: crashes anywhere in the teardown above (a common place for Qt apps to + // crash) are still captured by the SDK and detected as unclean exits on the next launch. + crashReporter.shutdown(); + crashReporter.endSession(); + return ret; } diff --git a/format.sh b/format.sh index 9e3a6069b..b4c78543b 100755 --- a/format.sh +++ b/format.sh @@ -19,7 +19,8 @@ libcockatrice_* \ "oracle/src" \ "servatrice/src" \ "cmake/pch" \ -"tests") +"tests" \ +"tools/installer-holder-probe") exclude=("libcockatrice_rng/libcockatrice/rng/sfmt/" \ "libcockatrice_utility/libcockatrice/utility/peglib.h" \ "oracle/src/lzma/" \ diff --git a/libcockatrice_card/CMakeLists.txt b/libcockatrice_card/CMakeLists.txt index b3583522c..e2d5029ed 100644 --- a/libcockatrice_card/CMakeLists.txt +++ b/libcockatrice_card/CMakeLists.txt @@ -21,11 +21,9 @@ set(HEADERS libcockatrice/card/relation/card_relation.h ) -qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) - add_library( libcockatrice_card STATIC - ${MOC_SOURCES} + ${HEADERS} libcockatrice/card/card_info.cpp libcockatrice/card/card_info_comparator.cpp libcockatrice/card/card_localization.cpp diff --git a/libcockatrice_card/libcockatrice/card/card_info.cpp b/libcockatrice_card/libcockatrice/card/card_info.cpp index 3aaa4c5bc..36f0cdeb8 100644 --- a/libcockatrice_card/libcockatrice/card/card_info.cpp +++ b/libcockatrice_card/libcockatrice/card/card_info.cpp @@ -6,6 +6,7 @@ #include "relation/card_relation.h" #include "set/card_set.h" +#include #include #include #include @@ -19,6 +20,18 @@ const QHash &CardInfo::getPropertiesHash() const return properties.getProperties(); } +void CardInfo::notifyChanged() +{ + const CardInfoPtr self = sharedFromThis(); + Q_ASSERT_X(self, "CardInfo::notifyChanged", "every CardInfo is owned by a QSharedPointer"); + if (!self) { + qCWarning(CardInfoLog) << "notifyChanged() on an unowned CardInfo" << name << "- observers stay stale"; + return; + } + + emit cardInfoChanged(self); +} + void CardInfo::setProperty(const QString &_name, const QString &_value) { bool changed = properties.insert(_name, _value); @@ -26,7 +39,7 @@ void CardInfo::setProperty(const QString &_name, const QString &_value) return; } - emit cardInfoChanged(smartThis); + notifyChanged(); } CardInfo::CardInfo(const QString &_name, @@ -67,10 +80,16 @@ CardInfo::CardInfo(const QString &_name, reverseRelatedCards(_reverseRelatedCards), setsToPrintings(std::move(_sets)), uiAttributes(_uiAttributes), altNames(std::move(_altNames)) { - // propertiesBlob is materialized lazily on first query; simpleName and - // altNames are supplied by the caller (binary cache). Only the set-name - // display list still depends on the current enabled-set state. + // propertiesBlob is materialized lazily on first query. The binary cache supplies simpleName + // and altNames to skip the Unicode normalization and the printing scan; the XML parsers leave + // them empty and get the same derived state the property-hash constructor computes. + if (simpleName.isEmpty()) { + simpleName = simplifyName(name); + } refreshCachedSetNames(); + if (altNames.isEmpty()) { + refreshCachedAltNames(); + } } CardInfoPtr CardInfo::newInstance(const QString &_name) @@ -91,7 +110,6 @@ CardInfoPtr CardInfo::newInstance(const QString &_name, { CardInfoPtr ptr(new CardInfo(_name, _text, _isToken, _properties, _relatedCards, _reverseRelatedCards, _sets, _uiAttributes, std::move(_localizedNames), std::move(_localizedTexts))); - ptr->setSmartPointer(ptr); for (const auto &printings : _sets) { for (const PrintingInfo &printing : printings) { @@ -120,7 +138,6 @@ CardInfoPtr CardInfo::newInstance(const QString &_name, CardInfoPtr ptr(new CardInfo(_name, _text, _isToken, std::move(_propertiesBlob), _relatedCards, _reverseRelatedCards, _sets, _uiAttributes, std::move(_simpleName), std::move(_altNames), std::move(_localizedNames), std::move(_localizedTexts))); - ptr->setSmartPointer(ptr); if (_appendToSets) { for (const auto &printings : _sets) { @@ -163,8 +180,15 @@ bool CardInfo::isLegalInFormat(const QString &format) const void CardInfo::addToSet(const CardSetPtr &_set, const PrintingInfo &_info) { - if (!_set->contains(smartThis)) { - _set->append(smartThis); + const CardInfoPtr self = sharedFromThis(); + Q_ASSERT_X(self, "CardInfo::addToSet", "every CardInfo is owned by a QSharedPointer"); + if (!self) { + qCWarning(CardInfoLog) << "addToSet() on an unowned CardInfo" << name << "- the set will not list it"; + return; + } + + if (!_set->contains(self)) { + _set->append(self); } if (!setsToPrintings[_set->getShortName()].contains(_info)) { setsToPrintings[_set->getShortName()].append(_info); @@ -182,7 +206,7 @@ void CardInfo::combineLegalities(const QHash &props) properties.insert(it.key(), it.value()); } } - emit cardInfoChanged(smartThis); + notifyChanged(); } void CardInfo::refreshCachedSets() @@ -245,7 +269,7 @@ QString CardInfo::simplifyName(const QString &name) QChar CardInfo::getColorChar() const { - QString colors = getColors(); + const QString colors = getColors(); switch (colors.size()) { case 0: return QChar(); @@ -264,6 +288,13 @@ void CardInfo::resetReverseRelatedCards2Me() reverseRelatedCardsToMe = QList(); } +void CardInfo::clearDirectRelations() +{ + relatedCards = QList(); + reverseRelatedCards = QList(); + reverseRelatedCardsToMe = QList(); +} + // Back-compatibility methods. Remove ASAP QString CardInfo::getCardType() const { diff --git a/libcockatrice_card/libcockatrice/card/card_info.h b/libcockatrice_card/libcockatrice/card/card_info.h index 1670e245d..075b5b264 100644 --- a/libcockatrice_card/libcockatrice/card/card_info.h +++ b/libcockatrice_card/libcockatrice/card/card_info.h @@ -7,6 +7,7 @@ #include #include +#include #include #include #include @@ -50,7 +51,7 @@ Q_DECLARE_METATYPE(CardInfoPtr) * Each CardInfo may belong to multiple sets through its printings, and can * be related to other cards through defined relationships. */ -class CardInfo : public QObject +class CardInfo : public QObject, public QEnableSharedFromThis { Q_OBJECT @@ -74,11 +75,10 @@ private: * @anchor PrivateCardProperties */ ///@{ - CardInfoPtr smartThis; ///< Smart pointer to self for safe cross-references. - QString name; ///< Full name of the card. - QString simpleName; ///< Simplified name for fuzzy matching. - QString text; ///< Text description or rules text of the card. - bool isToken; ///< Whether this card is a token or not. + QString name; ///< Full name of the card. + QString simpleName; ///< Simplified name for fuzzy matching. + QString text; ///< Text description or rules text of the card. + bool isToken; ///< Whether this card is a token or not. QMap localizedNames; ///< Localized card names, keyed by language code. QMap localizedTexts; ///< Localized rules text, keyed by language code. @@ -121,14 +121,12 @@ public: QMap _localizedTexts = {}); /** - * @brief Constructs a CardInfo from a cache snapshot with precomputed derived - * state. + * @brief Constructs a CardInfo from a serialized properties blob. * - * Used by the binary cache reader to skip recomputing @p _simpleName and - * @p _altNames (which otherwise require a Unicode normalization and a full - * printing scan). Properties are supplied as a pre-serialized @p _propertiesBlob - * so the QHash is not built at load time (it is materialized on first - * query). + * Properties stay in @p _propertiesBlob so the QHash is not + * built at load time (it is materialized on first query). @p _simpleName and + * @p _altNames are filled in when empty; the binary cache supplies them to + * skip the Unicode normalization and the printing scan. * * @param _name The card name. * @param _text Rules text or description of the card. @@ -138,8 +136,8 @@ public: * @param _reverseRelatedCards Reverse relationships. * @param _sets Printing information per set. * @param _uiAttributes Attributes that affect display and game logic. - * @param _simpleName Precomputed simplified name. - * @param _altNames Precomputed alternate names. + * @param _simpleName Precomputed simplified name; derived from @p _name when empty. + * @param _altNames Precomputed alternate names; derived from the printings when empty. * @param _localizedNames Localized card names, keyed by language code. * @param _localizedTexts Localized rules text, keyed by language code. */ @@ -164,13 +162,14 @@ public: * @param other Another CardInfo to copy. */ CardInfo(const CardInfo &other) - : QObject(other.parent()), name(other.name), simpleName(other.simpleName), text(other.text), - isToken(other.isToken), localizedNames(other.localizedNames), localizedTexts(other.localizedTexts), - properties(other.properties), relatedCards(other.relatedCards), + : QObject(other.parent()), QEnableSharedFromThis(), name(other.name), simpleName(other.simpleName), + text(other.text), isToken(other.isToken), localizedNames(other.localizedNames), + localizedTexts(other.localizedTexts), properties(other.properties), relatedCards(other.relatedCards), reverseRelatedCards(other.reverseRelatedCards), reverseRelatedCardsToMe(other.reverseRelatedCardsToMe), setsToPrintings(other.setsToPrintings), uiAttributes(other.uiAttributes), setsNames(other.setsNames), altNames(other.altNames) { + // The copy deliberately starts unowned: the QSharedPointer that wraps it registers the self reference. } /** @@ -210,20 +209,26 @@ public: QMap _localizedTexts = {}); /** - * @brief Creates a new instance from a cache snapshot with precomputed - * derived state. + * @brief Creates a new instance from a serialized properties blob. + * + * The properties stay in blob form, so the QHash is not + * built at load time (it is materialized on first query). The derived state + * is required so it is never recomputed here, and the overload cannot be a + * candidate for a shorter call that passes a property hash instead of a blob. * * @param _name Name of the card. * @param _text Rules text or description. * @param _isToken Token flag. - * @param _propertiesBlob Pre-serialized properties blob (as written by the cache). + * @param _propertiesBlob Serialized properties blob. * @param _relatedCards Forward relationships. * @param _reverseRelatedCards Reverse relationships. * @param _sets Printing information per set. * @param _uiAttributes Attributes that affect display and game logic. - * @param _simpleName Precomputed simplified name. - * @param _altNames Precomputed alternate names. - * @param _appendToSets When true (default), the card is appended to each of + * @param _simpleName Precomputed simplified name; pass {} to derive it from + * @p _name. + * @param _altNames Precomputed alternate names; pass {} to derive them from + * the printings. + * @param _appendToSets When true, the card is appended to each of * its CardSets. Pass false when building cards in parallel so the * (non-thread-safe) set membership is populated in a later * single-threaded pass. @@ -241,34 +246,20 @@ public: UiAttributes _uiAttributes, QString _simpleName, QSet _altNames, - bool _appendToSets = true, + bool _appendToSets, QMap _localizedNames = {}, QMap _localizedTexts = {}); /** * @brief Clones the current CardInfo instance. * - * Uses the copy constructor and ensures the smart pointer is properly set. + * Uses the copy constructor; the copy is managed by its own shared pointer. * * @return Shared pointer to the cloned CardInfo. */ [[nodiscard]] CardInfoPtr clone() const { - auto newCardInfo = CardInfoPtr(new CardInfo(*this)); - newCardInfo->setSmartPointer(newCardInfo); // Set the smart pointer for the new instance - return newCardInfo; - } - - /** - * @brief Sets the internal smart pointer to self. - * - * Used internally to allow safe cross-references among CardInfo and CardSet. - * - * @param _ptr Shared pointer pointing to this instance. - */ - void setSmartPointer(CardInfoPtr _ptr) - { - smartThis = std::move(_ptr); + return CardInfoPtr(new CardInfo(*this)); } /** @name Basic Properties Accessors */ //@{ @@ -280,7 +271,7 @@ public: { return simpleName; } - const QSet &getAltNames() + const QSet &getAltNames() const { return altNames; } @@ -291,7 +282,7 @@ public: void setText(const QString &_text) { text = _text; - emit cardInfoChanged(smartThis); + notifyChanged(); } /** @@ -354,7 +345,7 @@ public: return; } localizedNames.insert(lang, _localizedName); - emit cardInfoChanged(smartThis); + notifyChanged(); } /** @@ -369,7 +360,7 @@ public: return; } localizedTexts.insert(lang, _localizedText); - emit cardInfoChanged(smartThis); + notifyChanged(); } /** @@ -389,18 +380,34 @@ public: } [[nodiscard]] QStringList getProperties() const { - return getPropertiesHash().keys(); + return properties.keys(); } + + /** + * @brief Returns the properties in their serialized blob form. + * + * Cheap while the properties were never materialized (the cache and the XML parsers pass the blob through). + */ + [[nodiscard]] QByteArray getPropertiesBlob() const + { + return properties.serialized(); + } + + /** + * @brief Returns the materialized property hash, forcing deserialization of the blob. + * + * Prefer getProperty(), hasProperty() or getPropertiesBlob(), which avoid building the hash. + */ [[nodiscard]] const QHash &getPropertiesHash() const; [[nodiscard]] QString getProperty(const QString &propertyName) const { - return getPropertiesHash().value(propertyName); + return properties.value(propertyName); } void setProperty(const QString &_name, const QString &_value); [[nodiscard]] bool hasProperty(const QString &propertyName) const { - return getPropertiesHash().contains(propertyName); + return properties.contains(propertyName); } [[nodiscard]] const SetToPrintingsMap &getSets() const { @@ -433,6 +440,14 @@ public: return result; } void resetReverseRelatedCards2Me(); + /** + * @brief Clears the raw relation lists without deleting the CardRelation objects. + * + * Callers that schedule the relations for deletion (e.g. CardDatabase::removeCard()) + * must use this to drop the now-dangling pointers from this CardInfo, which may + * outlive them through external shared pointers. + */ + void clearDirectRelations(); void addReverseRelatedCards2Me(CardRelation *cardRelation) { reverseRelatedCardsToMe.append(cardRelation); @@ -525,6 +540,14 @@ public: static QString simplifyName(const QString &name); private: + /** + * @brief Emits cardInfoChanged() with a strong reference to this card. + * + * A CardInfo is always owned by a QSharedPointer, so the self reference is always available; a released + * build that somehow finds it missing warns rather than emitting a null card. + */ + void notifyChanged(); + /** * @brief Refreshes the cached, human-readable list of set names. * diff --git a/libcockatrice_card/libcockatrice/card/database/card_database.cpp b/libcockatrice_card/libcockatrice/card/database/card_database.cpp index 7b0d5b2f9..ed097ef3b 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database.cpp +++ b/libcockatrice_card/libcockatrice/card/database/card_database.cpp @@ -53,13 +53,26 @@ CardDatabase::~CardDatabase() clear(); } +void CardDatabase::detachSetMembership() +{ + for (const auto &set : sets) { + if (set) { + set->clear(); + } + } +} + void CardDatabase::clear() { QMutexLocker locker(clearDatabaseMutex); + // The sets hold strong card references, so empty them before dropping the cards: this breaks the + // CardInfo <-> CardSet cycle and spares the per-card detach over the whole database. + detachSetMembership(); + for (const auto &card : cards.values()) { if (card) { - removeCard(card); + unindexCard(card); } } @@ -156,6 +169,23 @@ void CardDatabase::removeCard(const CardInfoPtr &card) return; } + // The set holds a strong reference back to the card, so detach it: leaving it there keeps both + // objects alive after the database drops the card. newInstance() appends through the first + // printing of every set, so detaching through that same printing mirrors the append. + for (const auto &printings : card->getSets()) { + if (printings.isEmpty()) { + continue; + } + if (const CardSetPtr set = printings.first().getSet()) { + set->removeAll(card); + } + } + + unindexCard(card); +} + +void CardDatabase::unindexCard(const CardInfoPtr &card) +{ for (auto *cardRelation : card->getRelatedCards()) { cardRelation->deleteLater(); } @@ -168,6 +198,10 @@ void CardDatabase::removeCard(const CardInfoPtr &card) cardRelation->deleteLater(); } + // The relations are only scheduled for deletion; the CardInfo may outlive + // them through an external shared pointer, so drop the raw pointers now. + card->clearDirectRelations(); + QMutexLocker locker(removeCardMutex); cards.remove(card->getName()); simpleNameCards.remove(card->getSimpleName()); @@ -271,8 +305,15 @@ void CardDatabase::swapInDatabaseData(CardDatabaseData data) for (auto *rel : card->getReverseRelatedCards2Me()) { rel->deleteLater(); } + // Replacement cards take over the database; old CardInfo objects may + // still be referenced by the UI, so clear their dangling relations. + card->clearDirectRelations(); } + // Break the CardSet <-> CardInfo cycle before replacing the containers, otherwise the previous + // snapshot survives the reload through the sets' strong card lists. + detachSetMembership(); + cards = std::move(data.cards); simpleNameCards = std::move(data.simpleNameCards); sets = std::move(data.sets); diff --git a/libcockatrice_card/libcockatrice/card/database/card_database.h b/libcockatrice_card/libcockatrice/card/database/card_database.h index 9f0d67b67..5c1cb43c1 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database.h +++ b/libcockatrice_card/libcockatrice/card/database/card_database.h @@ -74,6 +74,23 @@ private: */ void refreshCachedReverseRelatedCards(CardNameMap &cardMap); + /** + * @brief Empties every set, dropping the strong card references they hold. + * + * A card keeps a CardSetPtr through its printings while its set keeps a strong CardInfoPtr, so the + * two form a reference cycle. Dropping a whole snapshot has to break that cycle once, up front, + * instead of relying on the per-card detach in removeCard(). + */ + void detachSetMembership(); + + /** + * @brief Drops a card from the database indexes and notifies observers. + * + * Callers are responsible for detaching the card from its sets first. + * @param card Pointer to the card to unindex. + */ + void unindexCard(const CardInfoPtr &card); + /** @brief Mutexes for thread safety. */ QBasicMutex *clearDatabaseMutex = new QBasicMutex(), *addCardMutex = new QBasicMutex(), *removeCardMutex = new QBasicMutex(); diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_cache.cpp b/libcockatrice_card/libcockatrice/card/database/card_database_cache.cpp index 4ab4d1fca..611b24ad8 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database_cache.cpp +++ b/libcockatrice_card/libcockatrice/card/database/card_database_cache.cpp @@ -53,13 +53,10 @@ QString readString(QDataStream &in) // Stores a QHash as a single pre-serialized blob. The reader keeps the // blob as-is and materializes the QHash lazily on first query, which is // what removes the allocation storm from database load (see card_info.cpp / -// printing_info.cpp). -void writeHashBlob(QDataStream &out, const QHash &h) +// printing_info.cpp). Writing the already-serialized blob also keeps cache writes from +// materializing every card's property hash. +void writeBlob(QDataStream &out, const QByteArray &blob) { - QByteArray blob; - QDataStream blobOut(&blob, QIODevice::WriteOnly); - blobOut.setVersion(QDataStream::Qt_6_4); - blobOut << h; out << static_cast(blob.size()); out.writeRawData(blob.constData(), blob.size()); } @@ -156,7 +153,7 @@ CardRelation *readRelation(QDataStream &in) void writePrinting(QDataStream &out, const PrintingInfo &p) { writeString(out, p.getSet() ? p.getSet()->getShortName() : QString()); - writeHashBlob(out, p.getPropertiesHash()); + writeBlob(out, p.getPropertiesBlob()); } PrintingInfo readPrinting(QDataStream &in, const SetNameMap &sets) @@ -198,7 +195,7 @@ void writeCard(QDataStream &out, const CardInfoPtr &card) writeString(out, card->getName()); writeString(out, card->getText()); out << card->getIsToken(); - writeHashBlob(out, card->getPropertiesHash()); + writeBlob(out, card->getPropertiesBlob()); CardInfo::UiAttributes ui = card->getUiAttributes(); out << ui.cipt; @@ -210,7 +207,7 @@ void writeCard(QDataStream &out, const CardInfoPtr &card) // per-printing alt-name scan entirely. writeString(out, card->getSimpleName()); { - const QSet altNames = card->getAltNames(); + const QSet &altNames = card->getAltNames(); out << static_cast(altNames.size()); for (const QString &alt : altNames) { writeString(out, alt); @@ -218,7 +215,7 @@ void writeCard(QDataStream &out, const CardInfoPtr &card) } // setsToPrintings - const SetToPrintingsMap sets = card->getSets(); + const auto &sets = card->getSets(); out << static_cast(sets.size()); for (auto it = sets.constBegin(); it != sets.constEnd(); ++it) { writeString(out, it.key()); diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_querier.cpp b/libcockatrice_card/libcockatrice/card/database/card_database_querier.cpp index 2fad5212d..8df0d2e20 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database_querier.cpp +++ b/libcockatrice_card/libcockatrice/card/database/card_database_querier.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include CardDatabaseQuerier::CardDatabaseQuerier(QObject *_parent, @@ -52,6 +53,7 @@ CardInfoPtr CardDatabaseQuerier::getCardInfo(const QString &cardName) const QList CardDatabaseQuerier::getCardInfos(const QStringList &cardNames) const { QList cardInfos; + cardInfos.reserve(cardNames.size()); for (const QString &cardName : cardNames) { CardInfoPtr ptr = db->cards.value(cardName); if (ptr) { @@ -90,6 +92,7 @@ CardInfoPtr CardDatabaseQuerier::lookupCardByName(const QString &name) const QList CardDatabaseQuerier::getCards(const QList &cardRefs) const { QList cards; + cards.reserve(cardRefs.size()); for (const auto &cardRef : cardRefs) { ExactCard card = getCard(cardRef); if (card) { @@ -133,6 +136,10 @@ ExactCard CardDatabaseQuerier::getCard(const CardRef &cardRef) const ExactCard CardDatabaseQuerier::guessCard(const CardRef &cardRef) const { auto card = lookupCardByName(cardRef.name); + if (!card) { + return {}; + } + auto printing = cardRef.providerId.isEmpty() ? getPreferredPrinting(card) : findPrintingWithId(card, cardRef.providerId); @@ -145,10 +152,10 @@ ExactCard CardDatabaseQuerier::getRandomCard() const return {}; } - const auto keys = db->cards.keys(); - int randomIndex = QRandomGenerator::global()->bounded(keys.size()); - const QString &randomKey = keys.at(randomIndex); - CardInfoPtr randomCard = getCardInfo(randomKey); + const auto randomIndex = QRandomGenerator::global()->bounded(db->cards.size()); + auto it = db->cards.constBegin(); + std::advance(it, randomIndex); + CardInfoPtr randomCard = it.value(); return ExactCard{randomCard, getPreferredPrinting(randomCard)}; } @@ -173,10 +180,14 @@ ExactCard CardDatabaseQuerier::getCardFromSameSet(const QString &cardName, const * * @param cardInfo The CardInfo to search * @param providerId The uuid to look for - * @return The PrintingInfo, or a default-constructed PrintingInfo if not found. + * @return The PrintingInfo, or a default-constructed PrintingInfo if not found or if cardInfo is null. */ PrintingInfo CardDatabaseQuerier::findPrintingWithId(const CardInfoPtr &cardInfo, const QString &providerId) const { + if (!cardInfo) { + return PrintingInfo(); + } + for (const auto &printings : cardInfo->getSets()) { for (const auto &printing : printings) { if (printing.getUuid() == providerId) { @@ -207,20 +218,23 @@ PrintingInfo CardDatabaseQuerier::getSpecificPrinting(const QString &cardName, return PrintingInfo(nullptr); } - SetToPrintingsMap setMap = cardInfo->getSets(); + const auto &setMap = cardInfo->getSets(); if (setMap.empty()) { return PrintingInfo(nullptr); } for (const auto &printings : setMap) { - for (auto &cardInfoForSet : printings) { + for (const auto &cardInfoForSet : printings) { + const CardSetPtr &set = cardInfoForSet.getSet(); + if (!set) { + continue; + } if (!collectorNumber.isEmpty()) { - if (cardInfoForSet.getSet()->getShortName() == setShortName && - cardInfoForSet.getProperty("num") == collectorNumber) { + if (set->getShortName() == setShortName && cardInfoForSet.getProperty("num") == collectorNumber) { return cardInfoForSet; } } else { - if (cardInfoForSet.getSet()->getShortName() == setShortName) { + if (set->getShortName() == setShortName) { return cardInfoForSet; } } @@ -276,10 +290,10 @@ PrintingInfo CardDatabaseQuerier::getPreferredPrinting(const CardInfoPtr &cardIn const auto &pinnedPrintingProviderId = prefs->getCardPreferenceOverride(cardInfo->getName()); if (!pinnedPrintingProviderId.isEmpty()) { - return getSpecificPrinting({cardInfo->getName(), pinnedPrintingProviderId}); + return findPrintingWithId(cardInfo, pinnedPrintingProviderId); } - SetToPrintingsMap setMap = cardInfo->getSets(); + const auto &setMap = cardInfo->getSets(); if (setMap.empty()) { return PrintingInfo(nullptr); } @@ -289,8 +303,8 @@ PrintingInfo CardDatabaseQuerier::getPreferredPrinting(const CardInfoPtr &cardIn SetPriorityComparator comparator; for (const auto &printings : setMap) { - for (auto &printing : printings) { - CardSetPtr currentSet = printing.getSet(); + for (const auto &printing : printings) { + const CardSetPtr ¤tSet = printing.getSet(); if (!preferredSet || comparator(currentSet, preferredSet)) { preferredSet = currentSet; preferredPrinting = printing; @@ -307,17 +321,18 @@ PrintingInfo CardDatabaseQuerier::getPreferredPrinting(const CardInfoPtr &cardIn QString CardDatabaseQuerier::getPreferredPrintingProviderId(const QString &cardName) const { - PrintingInfo preferredPrinting = getPreferredPrinting(cardName); - QString uuid = preferredPrinting.getUuid(); + CardInfoPtr cardInfo = getCardInfo(cardName); + if (!cardInfo) { + return cardName; + } + + PrintingInfo preferredPrinting = getPreferredPrinting(cardInfo); + const QString &uuid = preferredPrinting.getUuid(); if (!uuid.isEmpty()) { return uuid; } - CardInfoPtr defaultCardInfo = getCardInfo(cardName); - if (defaultCardInfo.isNull()) { - return cardName; - } - return defaultCardInfo->getName(); + return cardInfo->getName(); } QStringList CardDatabaseQuerier::getAllMainCardTypes() const @@ -331,8 +346,8 @@ QMap CardDatabaseQuerier::getAllMainCardTypesWithCount() const // produces an empty map, so the cache is only ever empty when it needs a // (trivially cheap) rebuild. if (mainCardTypeCountsCache.isEmpty()) { - for (const auto &card : db->cards.values()) { - QString type = card->getMainCardType(); + for (const auto &card : db->cards) { + const QString type = card->getMainCardType(); mainCardTypeCountsCache[type]++; } } @@ -343,13 +358,13 @@ QMap CardDatabaseQuerier::getAllMainCardTypesWithCount() const QMap CardDatabaseQuerier::getAllSubCardTypesWithCount() const { if (subCardTypeCountsCache.isEmpty()) { - for (const auto &card : db->cards.values()) { - QString type = card->getCardType(); + for (const auto &card : db->cards) { + const QString type = card->getCardType(); - QStringList parts = type.split(" — "); + const QStringList parts = type.split(" — "); if (parts.size() > 1) { // Ensure there are subtypes - QStringList subtypes = parts[1].split(" ", Qt::SkipEmptyParts); + const QStringList subtypes = parts[1].split(" ", Qt::SkipEmptyParts); for (const QString &subtype : subtypes) { subCardTypeCountsCache[subtype]++; @@ -369,13 +384,13 @@ FormatRulesPtr CardDatabaseQuerier::getFormat(const QString &formatName) const QMap CardDatabaseQuerier::getAllFormatsWithCount() const { if (formatsCountCache.isEmpty()) { - for (const auto &card : db->cards.values()) { - QStringList allProps = card->getProperties(); + static const QString formatPrefix = QStringLiteral("format-"); - for (const QString &prop : allProps) { - if (prop.startsWith("format-")) { - QString formatName = prop.mid(QStringLiteral("format-").size()); - formatsCountCache[formatName]++; + for (const auto &card : db->cards) { + const auto &properties = card->getPropertiesHash(); + for (auto it = properties.constBegin(); it != properties.constEnd(); ++it) { + if (it.key().startsWith(formatPrefix)) { + formatsCountCache[it.key().mid(formatPrefix.size())]++; } } } 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 b17eb8b5e..d40c1c063 100644 --- a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp +++ b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp @@ -268,7 +268,7 @@ void CockatriceXml3Parser::loadCardsFromXml(QXmlStreamReader &xml) if (attrs.hasAttribute("rarity")) { printingProps.insert("rarity", attrs.value("rarity").toString()); } - PrintingInfo setInfo(set, LazyPropertiesHash(printingProps)); + PrintingInfo setInfo(set, LazyPropertiesHash::serializedFrom(printingProps)); _sets[setName].append(setInfo); } // related cards @@ -325,8 +325,9 @@ void CockatriceXml3Parser::loadCardsFromXml(QXmlStreamReader &xml) .landscapeOrientation = landscapeOrientation, .tableRow = tableRow, .upsideDownArt = upsideDown}; - CardInfoPtr newCard = CardInfo::newInstance(name, text, isToken, properties, relatedCards, - reverseRelatedCards, _sets, attributes); + CardInfoPtr newCard = + CardInfo::newInstance(name, text, isToken, LazyPropertiesHash::serialize(properties), relatedCards, + reverseRelatedCards, _sets, attributes, {}, {}, true); if (targetData) { if (auto existing = targetData->cards.value(name)) { for (const auto &printings : newCard->getSets()) { @@ -401,7 +402,7 @@ static QXmlStreamWriter &operator<<(QXmlStreamWriter &xml, const CardInfoPtr &in } // sets - const SetToPrintingsMap setMap = info->getSets(); + const auto &setMap = info->getSets(); for (const auto &printings : setMap) { for (const PrintingInfo &set : printings) { xml.writeStartElement("set"); 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 4d975e533..3ee1e808e 100644 --- a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp +++ b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp @@ -383,7 +383,7 @@ void CockatriceXml4Parser::loadCardsFromXml(QXmlStreamReader &xml) } printingProps.insert(attrName, attr.value().toString()); } - PrintingInfo printingInfo(set, LazyPropertiesHash(printingProps)); + PrintingInfo printingInfo(set, LazyPropertiesHash::serializedFrom(printingProps)); _sets[setName].append(printingInfo); } // related cards @@ -450,9 +450,9 @@ void CockatriceXml4Parser::loadCardsFromXml(QXmlStreamReader &xml) .landscapeOrientation = landscapeOrientation, .tableRow = tableRow, .upsideDownArt = upsideDown}; - CardInfoPtr newCard = - CardInfo::newInstance(name, text, isToken, properties, relatedCards, reverseRelatedCards, _sets, - attributes, std::move(localizedNames), std::move(localizedTexts)); + CardInfoPtr newCard = CardInfo::newInstance(name, text, isToken, LazyPropertiesHash::serialize(properties), + relatedCards, reverseRelatedCards, _sets, attributes, {}, {}, + true, std::move(localizedNames), std::move(localizedTexts)); if (targetData) { // Mirror CardDatabase::addCard: if a card with this name already // exists, merge the new printings into it instead of replacing. diff --git a/libcockatrice_card/libcockatrice/card/lazy_properties_hash.cpp b/libcockatrice_card/libcockatrice/card/lazy_properties_hash.cpp index a12de469b..3798bcd62 100644 --- a/libcockatrice_card/libcockatrice/card/lazy_properties_hash.cpp +++ b/libcockatrice_card/libcockatrice/card/lazy_properties_hash.cpp @@ -1,8 +1,168 @@ #include "lazy_properties_hash.h" #include +#include #include -#include + +namespace +{ +constexpr quint32 NULL_STRING_LENGTH = 0xFFFFFFFFu; + +/** + * @brief Reads a QDataStream-serialized QString length at @p pos, advancing past the length field. + * + * QDataStream writes a QString as a big-endian quint32 byte length followed by UTF-16 code units (also big-endian), + * or 0xFFFFFFFF for a null string. Parsing at the byte level lets us skip and compare properties without + * constructing a QString for every entry. + */ +bool readBlobLength(const QByteArray &blob, int &pos, quint32 &length) +{ + if (pos < 0 || static_cast(pos) + 4 > blob.size()) { + return false; + } + + const auto *bytes = reinterpret_cast(blob.constData()) + pos; + length = (quint32(bytes[0]) << 24) | (quint32(bytes[1]) << 16) | (quint32(bytes[2]) << 8) | quint32(bytes[3]); + pos += 4; + + if (length == NULL_STRING_LENGTH) { + length = 0; + return true; + } + + if (length > static_cast(blob.size() - pos)) { + return false; + } + + return true; +} + +/** + * @brief Compares the serialized bytes at @p pos with @p key without allocating a QString. + */ +bool blobKeyEquals(const QByteArray &blob, int pos, quint32 length, const QString &key) +{ + if (length != static_cast(key.size()) * 2) { + return false; + } + + const auto *bytes = reinterpret_cast(blob.constData()) + pos; + for (int i = 0; i < key.size(); ++i) { + const ushort codeUnit = static_cast((bytes[2 * i] << 8) | bytes[2 * i + 1]); + if (key.at(i).unicode() != codeUnit) { + return false; + } + } + + return true; +} + +/** + * @brief Builds a QString from the serialized UTF-16BE bytes at @p pos. + */ +QString blobString(const QByteArray &blob, int pos, quint32 length) +{ + const int codeUnits = static_cast(length / 2); + QString result; + result.resize(codeUnits); + + const auto *bytes = reinterpret_cast(blob.constData()) + pos; + for (int i = 0; i < codeUnits; ++i) { + result[i] = QChar(static_cast((bytes[2 * i] << 8) | bytes[2 * i + 1])); + } + + return result; +} +} // namespace + +LazyPropertiesHash::BlobReader::BlobReader(const QByteArray &_blob) : blob(_blob) +{ + if (blob.size() < 4) { + return; + } + + const auto *bytes = reinterpret_cast(blob.constData()); + remaining = (quint32(bytes[0]) << 24) | (quint32(bytes[1]) << 16) | (quint32(bytes[2]) << 8) | quint32(bytes[3]); + valid = true; +} + +bool LazyPropertiesHash::BlobReader::next(int &keyPos, quint32 &keyLength, int &valuePos, quint32 &valueLength) +{ + if (!valid || remaining == 0) { + return false; + } + + if (!readBlobLength(blob, pos, keyLength)) { + valid = false; + return false; + } + keyPos = pos; + pos += static_cast(keyLength); + + if (!readBlobLength(blob, pos, valueLength)) { + valid = false; + return false; + } + valuePos = pos; + pos += static_cast(valueLength); + + --remaining; + return true; +} + +quint32 LazyPropertiesHash::BlobReader::entryCount() const +{ + return valid ? remaining : 0; +} + +QString LazyPropertiesHash::blobValue(const QByteArray &blob, const QString &key) +{ + BlobReader reader(blob); + int keyPos = 0; + int valuePos = 0; + quint32 keyLength = 0; + quint32 valueLength = 0; + while (reader.next(keyPos, keyLength, valuePos, valueLength)) { + if (blobKeyEquals(blob, keyPos, keyLength, key)) { + return blobString(blob, valuePos, valueLength); + } + } + + return {}; +} + +bool LazyPropertiesHash::blobContains(const QByteArray &blob, const QString &key) +{ + BlobReader reader(blob); + int keyPos = 0; + int valuePos = 0; + quint32 keyLength = 0; + quint32 valueLength = 0; + while (reader.next(keyPos, keyLength, valuePos, valueLength)) { + if (blobKeyEquals(blob, keyPos, keyLength, key)) { + return true; + } + } + + return false; +} + +QStringList LazyPropertiesHash::blobKeys(const QByteArray &blob) +{ + BlobReader reader(blob); + QStringList result; + result.reserve(static_cast(reader.entryCount())); + + int keyPos = 0; + int valuePos = 0; + quint32 keyLength = 0; + quint32 valueLength = 0; + while (reader.next(keyPos, keyLength, valuePos, valueLength)) { + result.append(blobString(blob, keyPos, keyLength)); + } + + return result; +} LazyPropertiesHash::LazyPropertiesHash() : isMaterialized(true) { @@ -20,15 +180,15 @@ LazyPropertiesHash::LazyPropertiesHash(const QHash &properties LazyPropertiesHash::LazyPropertiesHash(const LazyPropertiesHash &other) { // since we do not allow dematerialization, we only need to lock if not materialized yet - if (other.isMaterialized) { + if (other.isMaterialized.load(std::memory_order_acquire)) { blob = other.blob; properties = other.properties; - isMaterialized = true; + isMaterialized.store(true, std::memory_order_relaxed); } else { QMutexLocker lock(&other.propertiesMutex); blob = other.blob; properties = other.properties; - isMaterialized = false; + isMaterialized.store(other.isMaterialized.load(std::memory_order_relaxed), std::memory_order_relaxed); } } @@ -39,15 +199,15 @@ LazyPropertiesHash &LazyPropertiesHash::operator=(const LazyPropertiesHash &othe } // since we do not allow dematerialization, we only need to lock if not materialized yet - if (other.isMaterialized) { + if (other.isMaterialized.load(std::memory_order_acquire)) { blob = other.blob; properties = other.properties; - isMaterialized = true; + isMaterialized.store(true, std::memory_order_relaxed); } else { QMutexLocker lock(&other.propertiesMutex); blob = other.blob; properties = other.properties; - isMaterialized = false; + isMaterialized.store(false, std::memory_order_relaxed); } return *this; @@ -55,9 +215,15 @@ LazyPropertiesHash &LazyPropertiesHash::operator=(const LazyPropertiesHash &othe void LazyPropertiesHash::ensureMaterialized() const { + // Materialization happens once and is never undone, so the common case can + // skip the mutex entirely once it has happened. + if (isMaterialized.load(std::memory_order_acquire)) { + return; + } + QMutexLocker lock(&propertiesMutex); - if (isMaterialized) { + if (isMaterialized.load(std::memory_order_relaxed)) { return; } @@ -69,13 +235,60 @@ void LazyPropertiesHash::ensureMaterialized() const blob.clear(); - isMaterialized = true; + isMaterialized.store(true, std::memory_order_release); +} + +QByteArray LazyPropertiesHash::serialize(const QHash &properties) +{ + QByteArray result; + QDataStream out(&result, QIODevice::WriteOnly); + out.setVersion(QDataStream::Qt_6_4); + out << properties; + return result; +} + +LazyPropertiesHash LazyPropertiesHash::serializedFrom(const QHash &properties) +{ + return LazyPropertiesHash(serialize(properties)); } QString LazyPropertiesHash::value(const QString &key) const { - ensureMaterialized(); - return properties.value(key); + return read([&key](const QHash &materialized) { return materialized.value(key); }, + [&key](const QByteArray &blob) { return LazyPropertiesHash::blobValue(blob, key); }); +} + +bool LazyPropertiesHash::contains(const QString &key) const +{ + return read([&key](const QHash &materialized) { return materialized.contains(key); }, + [&key](const QByteArray &blob) { return LazyPropertiesHash::blobContains(blob, key); }); +} + +int LazyPropertiesHash::size() const +{ + return read([](const QHash &materialized) { return static_cast(materialized.size()); }, + [](const QByteArray &blob) { return static_cast(BlobReader(blob).entryCount()); }); +} + +bool LazyPropertiesHash::isEmpty() const +{ + return size() == 0; +} + +QStringList LazyPropertiesHash::keys() const +{ + return read([](const QHash &materialized) { return QStringList(materialized.keys()); }, + [](const QByteArray &blob) { return blobKeys(blob); }); +} + +QByteArray LazyPropertiesHash::serialized() const +{ + return read([](const QHash &materialized) { return serialize(materialized); }, + [](const QByteArray &blob) { + // An empty blob is the "unknown/absent" form used by failed cache reads; give callers a + // well-formed serialization of the (empty) property map instead. + return blob.isEmpty() ? serialize({}) : blob; + }); } bool LazyPropertiesHash::insert(const QString &key, const QString &value) diff --git a/libcockatrice_card/libcockatrice/card/lazy_properties_hash.h b/libcockatrice_card/libcockatrice/card/lazy_properties_hash.h index 04f5d0251..c8170b7c2 100644 --- a/libcockatrice_card/libcockatrice/card/lazy_properties_hash.h +++ b/libcockatrice_card/libcockatrice/card/lazy_properties_hash.h @@ -4,24 +4,67 @@ #include #include #include +#include #include +#include +#include /** * @brief A property map that can lazily deserialize blobs to avoid loading overhead. * - * Properties are stored as a pre-serialized blob (cheap to load), and the QString is materialized on the first query, - * so the database load avoids constructing thousands of QString per card. + * Properties are stored as a pre-serialized blob (cheap to load), and individual values are read straight out of that + * blob, so the database load and ordinary queries avoid constructing thousands of QString per card. * - * Once the properties are materialized, it cannot be unmaterialized. - * If you want to reset the properties to an unmaterialized state, you should create a new LazyPropertiesHash. + * A full QHash is only built on the first insert() or getProperties() call. Once materialized, it + * cannot be unmaterialized; create a new LazyPropertiesHash to return to the blob form. */ class LazyPropertiesHash { + /** + * @brief Iterates over the key/value pairs of a serialized QHash blob without materializing it. + * + * The blob layout is the one produced by QDataStream for QHash: a quint32 entry count followed by each entry's + * serialized key and value. + */ + class BlobReader + { + public: + explicit BlobReader(const QByteArray &_blob); - mutable QByteArray blob; ///< Serialized properties (load form). - mutable QHash properties; ///< Materialized properties (query form). - mutable QMutex propertiesMutex; ///< Guards lazy materialization. - mutable bool isMaterialized = false; ///< Whether propertiesCache is valid. + /** + * @brief Reads the next pair's offsets. + * @return False when there are no more pairs or the blob is malformed. + */ + bool next(int &keyPos, quint32 &keyLength, int &valuePos, quint32 &valueLength); + + [[nodiscard]] quint32 entryCount() const; + + private: + const QByteArray &blob; + int pos = 4; + quint32 remaining = 0; + bool valid = false; + }; + + /** + * @brief Looks up @p key in a serialized blob without materializing it. + */ + static QString blobValue(const QByteArray &blob, const QString &key); + + /** + * @brief Checks whether @p key is present in a serialized blob without materializing it. + */ + static bool blobContains(const QByteArray &blob, const QString &key); + + /** + * @brief Lists the keys of a serialized blob without materializing it. + */ + static QStringList blobKeys(const QByteArray &blob); + + mutable QByteArray blob; ///< Serialized properties (load form). + mutable QHash properties; ///< Materialized properties (query form). + mutable QMutex propertiesMutex; ///< Guards lazy materialization. + mutable std::atomic isMaterialized{false}; ///< Whether properties is valid. /** * @brief Materializes properties from blob if not already done. Clears blob afterward. @@ -29,6 +72,32 @@ class LazyPropertiesHash */ void ensureMaterialized() const; + /** + * @brief Runs the accessor for the current storage form. + * + * The common case skips the mutex once the properties are materialized. While they are + * still in blob form, the mutex is taken and @p onBlob reads the values straight out of + * the blob without building the hash, so ordinary queries stay allocation-light. + * + * @param onMaterialized Called with the materialized hash when it is available. + * @param onBlob Called with the blob when it is still the source of truth. + * @return Whatever the invoked accessor returns; both accessors must return the same type. + */ + template + auto read(MaterializedFn &&onMaterialized, BlobFn &&onBlob) const + { + if (isMaterialized.load(std::memory_order_acquire)) { + return onMaterialized(properties); + } + + QMutexLocker lock(&propertiesMutex); + if (isMaterialized.load(std::memory_order_relaxed)) { + return onMaterialized(properties); + } + + return onBlob(blob); + } + public: /** * @brief Default constructor. @@ -52,12 +121,60 @@ public: LazyPropertiesHash &operator=(const LazyPropertiesHash &other); /** - * @brief Gets the value from the materialized properties hash + * @brief Serializes a property map into the blob form understood by this class. + * @param properties The properties to serialize + * @return The serialized blob + */ + static QByteArray serialize(const QHash &properties); + + /** + * @brief Creates an unmaterialized LazyPropertiesHash from a property map. + * + * Serializes @p properties once so queries read them from the blob form instead of first materializing a second + * copy of the hash. + * + * @param properties The properties to serialize + * @return An unmaterialized LazyPropertiesHash holding the serialized properties + */ + static LazyPropertiesHash serializedFrom(const QHash &properties); + + /** + * @brief Gets the value for a key, reading it from the blob when possible. * @param key The key * @return The value, or an empty string if the key is not present */ QString value(const QString &key) const; + /** + * @brief Checks whether a key is present, reading the blob when possible. + * @param key The key + * @return True if the key is present + */ + bool contains(const QString &key) const; + + /** + * @brief Returns the number of properties. + */ + int size() const; + + /** + * @brief Returns whether there are no properties. + */ + bool isEmpty() const; + + /** + * @brief Returns the property names, reading them from the blob when possible. + */ + QStringList keys() const; + + /** + * @brief Returns the properties in serialized blob form. + * + * While the object is unmaterialized this is a cheap copy of the stored blob; after materialization the hash is + * serialized on demand. + */ + QByteArray serialized() const; + /** * @brief Inserts a value into the materialized properties hash * @param key The key @@ -67,7 +184,7 @@ public: bool insert(const QString &key, const QString &value); /** - * @brief Gets a view of the materialized properties hash. + * @brief Gets a view of the materialized properties hash, materializing it if needed. * @return The properties hash */ const QHash &getProperties() const; diff --git a/libcockatrice_card/libcockatrice/card/printing/exact_card.h b/libcockatrice_card/libcockatrice/card/printing/exact_card.h index ea49072fb..83ba97bd0 100644 --- a/libcockatrice_card/libcockatrice/card/printing/exact_card.h +++ b/libcockatrice_card/libcockatrice/card/printing/exact_card.h @@ -47,7 +47,7 @@ public: * * May be null if the ExactCard is empty. */ - [[nodiscard]] CardInfoPtr getCardPtr() const + [[nodiscard]] const CardInfoPtr &getCardPtr() const { return card; } @@ -57,7 +57,7 @@ public: * * May be empty if no specific printing was assigned. */ - [[nodiscard]] PrintingInfo getPrinting() const + [[nodiscard]] const PrintingInfo &getPrinting() const { return printing; } diff --git a/libcockatrice_card/libcockatrice/card/printing/printing_info.cpp b/libcockatrice_card/libcockatrice/card/printing/printing_info.cpp index 654efac5e..9bc62a07c 100644 --- a/libcockatrice_card/libcockatrice/card/printing/printing_info.cpp +++ b/libcockatrice_card/libcockatrice/card/printing/printing_info.cpp @@ -12,15 +12,32 @@ void PrintingInfo::setProperty(const QString &_name, const QString &_value) properties.insert(_name, _value); } +bool PrintingInfo::operator==(const PrintingInfo &other) const +{ + if (set != other.set || properties.size() != other.properties.size()) { + return false; + } + + // Both property maps are usually still in blob form here, so compare key by key instead of + // materializing two full hashes just to compare them. + for (const QString &key : properties.keys()) { + if (!other.properties.contains(key) || properties.value(key) != other.properties.value(key)) { + return false; + } + } + + return true; +} + /** * Gets the uuid property of the printing, or an empty string if the property isn't present */ QString PrintingInfo::getUuid() const { - return getPropertiesHash().value("uuid"); + return getProperty("uuid"); } QString PrintingInfo::getFlavorName() const { - return getPropertiesHash().value("flavorName"); + return getProperty("flavorName"); } diff --git a/libcockatrice_card/libcockatrice/card/printing/printing_info.h b/libcockatrice_card/libcockatrice/card/printing/printing_info.h index 038960e86..dcfcc846b 100644 --- a/libcockatrice_card/libcockatrice/card/printing/printing_info.h +++ b/libcockatrice_card/libcockatrice/card/printing/printing_info.h @@ -54,10 +54,7 @@ public: * @param other Another PrintingInfo to compare against. * @return True if both set and properties are equal, otherwise false. */ - bool operator==(const PrintingInfo &other) const - { - return this->set == other.set && this->getPropertiesHash() == other.getPropertiesHash(); - } + bool operator==(const PrintingInfo &other) const; /** * @brief check if the info is empty, as if default constructed. @@ -66,7 +63,7 @@ public: */ bool isEmpty() const { - return set == nullptr && getPropertiesHash().isEmpty(); + return set == nullptr && properties.isEmpty(); } private: @@ -91,9 +88,22 @@ public: */ [[nodiscard]] QStringList getProperties() const { - return getPropertiesHash().keys(); + return properties.keys(); } + /** + * @brief Returns the printing properties in their serialized blob form. + */ + [[nodiscard]] QByteArray getPropertiesBlob() const + { + return properties.serialized(); + } + + /** + * @brief Returns the materialized property hash, forcing deserialization of the blob. + * + * Prefer getProperty() or getPropertiesBlob(), which avoid building the hash. + */ [[nodiscard]] const QHash &getPropertiesHash() const { return properties.getProperties(); diff --git a/libcockatrice_card/libcockatrice/card/set/card_set.cpp b/libcockatrice_card/libcockatrice/card/set/card_set.cpp index e873eeeab..a0f0f4578 100644 --- a/libcockatrice_card/libcockatrice/card/set/card_set.cpp +++ b/libcockatrice_card/libcockatrice/card/set/card_set.cpp @@ -27,17 +27,20 @@ CardSetPtr CardSet::newInstance(ICardSetPriorityController *_priorityController, const Priority _priority) { CardSetPtr ptr(new CardSet(_priorityController, _shortName, _longName, _setType, _releaseDate, _priority)); - // ptr->setSmartPointer(ptr); return ptr; } QString CardSet::getCorrectedShortName() const { // For Windows machines. - QSet invalidFileNames; - invalidFileNames << "CON" << "PRN" << "AUX" << "NUL" << "COM1" << "COM2" << "COM3" << "COM4" << "COM5" << "COM6" - << "COM7" << "COM8" << "COM9" << "LPT1" << "LPT2" << "LPT3" << "LPT4" << "LPT5" << "LPT6" << "LPT7" - << "LPT8" << "LPT9"; + static const QSet invalidFileNames = { + QStringLiteral("CON"), QStringLiteral("PRN"), QStringLiteral("AUX"), QStringLiteral("NUL"), + QStringLiteral("COM1"), QStringLiteral("COM2"), QStringLiteral("COM3"), QStringLiteral("COM4"), + QStringLiteral("COM5"), QStringLiteral("COM6"), QStringLiteral("COM7"), QStringLiteral("COM8"), + QStringLiteral("COM9"), QStringLiteral("LPT1"), QStringLiteral("LPT2"), QStringLiteral("LPT3"), + QStringLiteral("LPT4"), QStringLiteral("LPT5"), QStringLiteral("LPT6"), QStringLiteral("LPT7"), + QStringLiteral("LPT8"), QStringLiteral("LPT9"), + }; return invalidFileNames.contains(shortName) ? shortName + "_" : shortName; } diff --git a/libcockatrice_card/libcockatrice/card/set/card_set.h b/libcockatrice_card/libcockatrice/card/set/card_set.h index b9ac74e2d..b995ac88e 100644 --- a/libcockatrice_card/libcockatrice/card/set/card_set.h +++ b/libcockatrice_card/libcockatrice/card/set/card_set.h @@ -239,7 +239,7 @@ public: */ [[nodiscard]] bool getIsKnownIgnored() const { - return longName.length() + setType.length() + releaseDate.toString().length() == 0; + return longName.isEmpty() && setType.isEmpty() && !releaseDate.isValid(); } }; diff --git a/libcockatrice_deck_list/CMakeLists.txt b/libcockatrice_deck_list/CMakeLists.txt index e61a5702c..ffd4c28a1 100644 --- a/libcockatrice_deck_list/CMakeLists.txt +++ b/libcockatrice_deck_list/CMakeLists.txt @@ -7,28 +7,30 @@ set(HEADERS libcockatrice/deck_list/tree/abstract_deck_list_node.h libcockatrice/deck_list/tree/deck_list_card_node.h libcockatrice/deck_list/tree/inner_deck_list_node.h + libcockatrice/deck_list/deck_file_format.h libcockatrice/deck_list/deck_list.h libcockatrice/deck_list/deck_list_history_manager.h libcockatrice/deck_list/deck_list_node_tree.h libcockatrice/deck_list/deck_list_memento.h libcockatrice/deck_list/deck_list_plain_text_parser.h + libcockatrice/deck_list/loaded_deck.h libcockatrice/deck_list/playmat_resolver.h libcockatrice/deck_list/sideboard_plan.h ) -qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) - add_library( libcockatrice_deck_list STATIC - ${MOC_SOURCES} + ${HEADERS} libcockatrice/deck_list/tree/abstract_deck_list_card_node.cpp libcockatrice/deck_list/tree/abstract_deck_list_node.cpp libcockatrice/deck_list/tree/deck_list_card_node.cpp libcockatrice/deck_list/tree/inner_deck_list_node.cpp + libcockatrice/deck_list/deck_file_format.cpp libcockatrice/deck_list/deck_list.cpp libcockatrice/deck_list/deck_list_history_manager.cpp libcockatrice/deck_list/deck_list_node_tree.cpp libcockatrice/deck_list/deck_list_plain_text_parser.cpp + libcockatrice/deck_list/loaded_deck.cpp libcockatrice/deck_list/playmat_resolver.cpp libcockatrice/deck_list/sideboard_plan.cpp ) diff --git a/cockatrice/src/interface/deck_loader/deck_file_format.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/deck_file_format.cpp similarity index 100% rename from cockatrice/src/interface/deck_loader/deck_file_format.cpp rename to libcockatrice_deck_list/libcockatrice/deck_list/deck_file_format.cpp diff --git a/cockatrice/src/interface/deck_loader/deck_file_format.h b/libcockatrice_deck_list/libcockatrice/deck_list/deck_file_format.h similarity index 100% rename from cockatrice/src/interface/deck_loader/deck_file_format.h rename to libcockatrice_deck_list/libcockatrice/deck_list/deck_file_format.h diff --git a/cockatrice/src/interface/deck_loader/loaded_deck.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/loaded_deck.cpp similarity index 100% rename from cockatrice/src/interface/deck_loader/loaded_deck.cpp rename to libcockatrice_deck_list/libcockatrice/deck_list/loaded_deck.cpp diff --git a/cockatrice/src/interface/deck_loader/loaded_deck.h b/libcockatrice_deck_list/libcockatrice/deck_list/loaded_deck.h similarity index 100% rename from cockatrice/src/interface/deck_loader/loaded_deck.h rename to libcockatrice_deck_list/libcockatrice/deck_list/loaded_deck.h diff --git a/libcockatrice_filters/CMakeLists.txt b/libcockatrice_filters/CMakeLists.txt index 2d45b8e5b..df2ead83f 100644 --- a/libcockatrice_filters/CMakeLists.txt +++ b/libcockatrice_filters/CMakeLists.txt @@ -6,11 +6,9 @@ set(HEADERS libcockatrice/filters/filter_card.h libcockatrice/filters/filter_str libcockatrice/filters/filter_tree.h ) -qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) - add_library( - libcockatrice_filters STATIC ${MOC_SOURCES} 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_string.cpp + libcockatrice/filters/filter_tree.cpp ) add_dependencies(libcockatrice_filters libcockatrice_card) diff --git a/libcockatrice_filters/libcockatrice/filters/filter_string.cpp b/libcockatrice_filters/libcockatrice/filters/filter_string.cpp index bc137b0f6..081ada824 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_string.cpp +++ b/libcockatrice_filters/libcockatrice/filters/filter_string.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -43,31 +44,31 @@ SomewhatComplexQueryPart <- [(] QueryPartList [)] / QueryPart QueryPart <- NotQuery / SetQuery / RarityQuery / CMCQuery / FormatQuery / PowerQuery / ToughnessQuery / ColorQuery / TypeQuery / OracleQuery / FieldQuery / GenericQuery NotQuery <- ('NOT' ws/'-') SomewhatComplexQueryPart -SetQuery <- ('e'/'set') SetQueryValue -SetQueryValue <- ([=:] FlexStringValue) / (<[!][=]?> FlexStringValue) / SetExpression -OracleQuery <- 'o' [:] MatcherString +SetQuery <- ('e'/'set') ws? SetQueryValue +SetQueryValue <- ([=:] ws? FlexStringValue) / (<[!][=]?> ws? FlexStringValue) / SetExpression +OracleQuery <- 'o' ws? [:] ws? MatcherString CMCQuery <- ('cmc'/'mv') ws? NumericExpression PowerQuery <- [Pp] 'ow' 'er'? ws? NumericExpression ToughnessQuery <- [Tt] 'ou' 'ghness'? ws? NumericExpression -RarityQuery <- [rR] ':' Rarity +RarityQuery <- [rR] ws? ':' ws? Rarity Rarity <- [Cc] 'ommon'? / [Uu] 'ncommon'? / [Rr] 'are'? / [Mm] 'ythic'? / [Ss] 'pecial'? / [a-zA-Z] [a-z]* -FormatQuery <- 'f' ':' Format / Legality ':' Format +FormatQuery <- 'f' ws? ':' ws? Format / Legality ws? ':' ws? Format Format <- [a-zA-Z] [a-z]* Legality <- [Ll] 'egal'? / [Bb] 'anned'? / [Rr] 'estricted' -TypeQuery <- [tT] 'ype'? [:] StringValue +TypeQuery <- [tT] 'ype'? ws? [:] ws? StringValue Color <- < [Ww] 'hite'? / [Uu] / [Bb] 'lack'? / [Rr] 'ed'? / [Gg] 'reen'? / [Bb] 'lue'? > ColorEx <- Color / [mc] -ColorQuery <- [cC] 'olor'? <[iI]?> <[:!]> ColorEx* +ColorQuery <- [cC] 'olor'? <[iI]?> ws? <[:!]> ws? ColorEx* -FieldQuery <- String [:] MatcherString / String ws? NumericExpression +FieldQuery <- String ws? [:] ws? MatcherString / String ws? NumericExpression NonDoubleQuoteUnlessEscaped <- '\\\"'. / !["]. NonSingleQuoteUnlessEscaped <- "\\\'". / ![']. @@ -161,30 +162,28 @@ static void setupParserRules() if (sv.choice() == 0) { auto matcher = std::any_cast(sv[0]); return [=](const CardData &x) -> bool { - QList sets = x->getSets().keys(); - - auto matchesSet = [&matcher](const QString &set) { return matcher(set); }; - return std::any_of(sets.begin(), sets.end(), matchesSet); + const auto &sets = x->getSets(); + return std::any_of(sets.keyValueBegin(), sets.keyValueEnd(), + [&matcher](const auto &set) { return matcher(set.first); }); }; } if (sv.choice() == 1) { auto matcher = std::any_cast(sv[0]); return [=](const CardData &x) -> bool { - QList sets = x->getSets().keys(); - - auto matchesSet = [&matcher](const QString &set) { return matcher(set); }; - return std::none_of(sets.begin(), sets.end(), matchesSet); + const auto &sets = x->getSets(); + return std::none_of(sets.keyValueBegin(), sets.keyValueEnd(), + [&matcher](const auto &set) { return matcher(set.first); }); }; } auto matcher = std::any_cast(sv[0]); return [=](const CardData &x) -> bool { - const auto &sets = x->getSets().values(); - auto matchesSet = [&](const PrintingInfo &printing) { - return printing.getSet()->getEnabled() && matcher(printing.getSet()->getReleaseDate().toJulianDay()); - }; - return std::any_of(sets.begin(), sets.end(), [&](const auto &printings) { - return std::any_of(printings.begin(), printings.end(), matchesSet); + const auto &sets = x->getSets(); + return std::any_of(sets.constBegin(), sets.constEnd(), [&matcher](const auto &printings) { + return std::any_of(printings.begin(), printings.end(), [&matcher](const PrintingInfo &printing) { + const CardSetPtr set = printing.getSet(); + return set && set->getEnabled() && matcher(set->getReleaseDate().toJulianDay()); + }); }); }; }; @@ -207,15 +206,14 @@ static void setupParserRules() search["RarityQuery"] = [](const peg::SemanticValues &sv) -> Filter { const auto rarity = std::any_cast(sv[0]); return [=](const CardData &x) -> bool { - QList infos; for (const auto &printings : x->getSets()) { for (const auto &printing : printings) { - infos.append(printing); + if (rarity == printing.getProperty("rarity")) { + return true; + } } } - - auto matchesRarity = [&rarity](const PrintingInfo &info) { return rarity == info.getProperty("rarity"); }; - return std::any_of(infos.begin(), infos.end(), matchesRarity); + return false; }; }; @@ -411,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) { - QString match = identity ? x->getColors() : x->getProperty("coloridentity"); + const QString match = identity ? x->getColors() : x->getProperty("coloridentity"); if (parts.contains("m") && match.length() < 2) { return false; } @@ -429,7 +427,7 @@ static void setupParserRules() } return [=](const CardData &x) { - QString match = identity ? x->getColors() : x->getProperty("colorIdentity"); + const QString match = identity ? x->getColors() : x->getProperty("colorIdentity"); if (parts.contains("m") && match.length() < 2) { return false; } @@ -455,25 +453,37 @@ static void setupParserRules() }; search["PowerQuery"] = [](const peg::SemanticValues &sv) -> Filter { const auto matcher = std::any_cast(sv[0]); - return [=](const CardData &x) -> bool { return matcher(x->getPowTough().split("/")[0].toInt()); }; + return [=](const CardData &x) -> bool { + const QString &powTough = x->getPowTough(); + const qsizetype slash = powTough.indexOf('/'); + return matcher(slash < 0 ? powTough.toInt() : QStringView(powTough).left(slash).toInt()); + }; }; search["ToughnessQuery"] = [](const peg::SemanticValues &sv) -> Filter { const auto matcher = std::any_cast(sv[0]); return [=](const CardData &x) -> bool { - auto parts = x->getPowTough().split("/"); - return matcher(parts.length() == 2 ? parts[1].toInt() : 0); + const QString &powTough = x->getPowTough(); + const qsizetype slash = powTough.indexOf('/'); + return matcher(slash < 0 ? 0 : QStringView(powTough).mid(slash + 1).toInt()); }; }; search["FieldQuery"] = [](const peg::SemanticValues &sv) -> Filter { const auto field = std::any_cast(sv[0]); if (sv.choice() == 0) { const auto matcher = std::any_cast(sv[1]); - return [=](const CardData &x) -> bool { return x->hasProperty(field) && matcher(x->getProperty(field)); }; + return [=](const CardData &x) -> bool { + const auto &properties = x->getPropertiesHash(); + const auto it = properties.constFind(field); + return it != properties.constEnd() && matcher(it.value()); + }; } const auto matcher = std::any_cast(sv[1]); - return - [=](const CardData &x) -> bool { return x->hasProperty(field) && matcher(x->getProperty(field).toInt()); }; + return [=](const CardData &x) -> bool { + const auto &properties = x->getPropertiesHash(); + const auto it = properties.constFind(field); + return it != properties.constEnd() && matcher(it.value().toInt()); + }; }; search["GenericQuery"] = [](const peg::SemanticValues &sv) -> Filter { const auto matcher = std::any_cast(sv[0]); diff --git a/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp b/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp index 392571d50..2bd3ea7ef 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp +++ b/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp @@ -9,9 +9,16 @@ #include #include #include +#include #include #include +namespace +{ +/** Separator between the main and sub types of a card type ("Creature — Elf"). */ +const QString MAIN_SUB_TYPE_SEPARATOR = QStringLiteral(" — "); +} // namespace + template FilterTreeNode *FilterTreeBranch::nodeAt(int i) const { return (childNodes.size() > i) ? childNodes.at(i) : nullptr; @@ -108,7 +115,7 @@ FilterTreeNode *FilterItemList::termNode(const QString &term) return childNodes.at(i); } -bool FilterItemList::testTypeAnd(const CardInfoPtr info, +bool FilterItemList::testTypeAnd(const CardInfoPtr &info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const { @@ -125,7 +132,7 @@ bool FilterItemList::testTypeAnd(const CardInfoPtr info, return true; } -bool FilterItemList::testTypeAndNot(const CardInfoPtr info, +bool FilterItemList::testTypeAndNot(const CardInfoPtr &info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const { @@ -133,7 +140,7 @@ bool FilterItemList::testTypeAndNot(const CardInfoPtr info, return !testTypeOr(info, attr, searchLanguage); } -bool FilterItemList::testTypeOr(const CardInfoPtr info, +bool FilterItemList::testTypeOr(const CardInfoPtr &info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const { @@ -156,7 +163,7 @@ bool FilterItemList::testTypeOr(const CardInfoPtr info, return noChildEnabledChild; } -bool FilterItemList::testTypeOrNot(const CardInfoPtr info, +bool FilterItemList::testTypeOrNot(const CardInfoPtr &info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const { @@ -164,7 +171,118 @@ bool FilterItemList::testTypeOrNot(const CardInfoPtr info, return !testTypeAnd(info, attr, searchLanguage); } -bool FilterItem::acceptName(const CardInfoPtr info, const CardSearchLanguage &searchLanguage) const +FilterItem::FilterItem(QString trm, FilterItemList *parent) : p(parent), term(std::move(trm)) +{ + lowercaseTerm = term.toLower(); + + switch (attr()) { + case CardFilter::AttrColor: { + QString converted = term.trimmed(); + + 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(QString(" "), QString(""), Qt::CaseInsensitive); + + convertedColorTerm = converted; + break; + } + case CardFilter::AttrRarity: { + QString converted = term.trimmed(); + + /* + * The purpose of this loop is to only apply one of the replacement + * policies and then escape. If we attempt to layer them on top of + * each other, we will get awkward results (i.e. comythic rare mythic rareon) + * Conditional statement will exit once a case is successful in + * replacement OR we go through all possible cases. + * Will also need to replace just "mythic" + */ + for (int i = 0; converted.length() <= 3 && i <= 6; i++) { + switch (i) { + case 0: + converted.replace("mr", "mythic", Qt::CaseInsensitive); + break; + case 1: + converted.replace("m r", "mythic", Qt::CaseInsensitive); + break; + case 2: + converted.replace("m", "mythic", Qt::CaseInsensitive); + break; + case 3: + converted.replace("c", "common", Qt::CaseInsensitive); + break; + case 4: + converted.replace("u", "uncommon", Qt::CaseInsensitive); + break; + case 5: + converted.replace("r", "rare", Qt::CaseInsensitive); + break; + case 6: + converted.replace("s", "special", Qt::CaseInsensitive); + break; + default: + break; + } + } + + convertedRarityTerm = converted; + break; + } + case CardFilter::AttrManaCost: + sortedManaCostTerm = term.toUpper(); + // Sort the mana cost so it will be easy to find + std::sort(sortedManaCostTerm.begin(), sortedManaCostTerm.end()); + break; + case CardFilter::AttrLoyalty: + uppercaseLoyaltyTerm = term.trimmed().toUpper(); + break; + default: + break; + } + + // Parse the numeric relation used by cmc, power, toughness and loyalty once. + // If the term is not numeric, it probably starts with an operator. + const QString trimmedTerm = term.trimmed(); + bool converted = false; + const int value = trimmedTerm.toInt(&converted); + if (converted) { + numericOperator = NumericOperator::Equal; + numericOperand = value; + } else if (trimmedTerm.size() > 1) { + if (trimmedTerm.at(1) == '=') { + numericOperand = trimmedTerm.mid(2).toInt(); + if (trimmedTerm.startsWith('<')) { + numericOperator = NumericOperator::LessEqual; + } else if (trimmedTerm.startsWith('>')) { + numericOperator = NumericOperator::GreaterEqual; + } else { + numericOperator = NumericOperator::Equal; + } + } else { + numericOperand = trimmedTerm.mid(1).toInt(); + if (trimmedTerm.startsWith('<')) { + numericOperator = NumericOperator::Less; + } else if (trimmedTerm.startsWith('>')) { + numericOperator = NumericOperator::Greater; + } else if (trimmedTerm.startsWith('=')) { + numericOperator = NumericOperator::Equal; + } + } + } +} + +bool FilterItem::acceptName(const CardInfoPtr &info, const CardSearchLanguage &searchLanguage) const { const QString &englishName = info->getName(); const QString &localizedName = info->getLocalizedName(searchLanguage.language); @@ -187,7 +305,7 @@ bool FilterItem::acceptName(const CardInfoPtr info, const CardSearchLanguage &se return false; } -bool FilterItem::acceptNameExact(const CardInfoPtr info, const CardSearchLanguage &searchLanguage) const +bool FilterItem::acceptNameExact(const CardInfoPtr &info, const CardSearchLanguage &searchLanguage) const { const QString &englishName = info->getName(); const QString &localizedName = info->getLocalizedName(searchLanguage.language); @@ -210,44 +328,40 @@ bool FilterItem::acceptNameExact(const CardInfoPtr info, const CardSearchLanguag return false; } -bool FilterItem::acceptType(const CardInfoPtr info) const +bool FilterItem::acceptType(const CardInfoPtr &info) const { return info->getCardType().contains(term, Qt::CaseInsensitive); } -bool FilterItem::acceptMainType(const CardInfoPtr info) const +bool FilterItem::acceptMainType(const CardInfoPtr &info) const { - const QStringList typeParts = info->getCardType().split(" — "); - return !typeParts.isEmpty() && typeParts[0].contains(term, Qt::CaseInsensitive); + const QString type = info->getCardType(); + const qsizetype separator = type.indexOf(MAIN_SUB_TYPE_SEPARATOR); + if (separator >= 0) { + return QStringView(type).left(separator).contains(term, Qt::CaseInsensitive); + } + return type.contains(term, Qt::CaseInsensitive); } -bool FilterItem::acceptSubType(const CardInfoPtr info) const +bool FilterItem::acceptSubType(const CardInfoPtr &info) const { - const QStringList typeParts = info->getCardType().split(" — "); - return typeParts.size() > 1 && typeParts[1].contains(term, Qt::CaseInsensitive); + const QString type = info->getCardType(); + const qsizetype separator = type.indexOf(MAIN_SUB_TYPE_SEPARATOR); + if (separator < 0) { + return false; + } + + const qsizetype subTypeStart = separator + MAIN_SUB_TYPE_SEPARATOR.size(); + const qsizetype nextSeparator = type.indexOf(MAIN_SUB_TYPE_SEPARATOR, subTypeStart); + const QStringView subType = nextSeparator < 0 ? QStringView(type).mid(subTypeStart) + : QStringView(type).mid(subTypeStart, nextSeparator - subTypeStart); + return subType.contains(term, Qt::CaseInsensitive); } -static bool matchesColor(const QString &term, const QString &colors) +static bool matchesColor(const QString &convertedColorTerm, const QString &color) { - QString converted_term = term.trimmed().toLower(); - - converted_term.replace("green", "g"); - converted_term.replace("grn", "g"); - converted_term.replace("blue", "u"); - converted_term.replace("blu", "u"); - converted_term.replace("black", "b"); - converted_term.replace("blk", "b"); - converted_term.replace("red", "r"); - converted_term.replace("white", "w"); - converted_term.replace("wht", "w"); - converted_term.replace("colorless", "c"); - converted_term.replace("colourless", "c"); - converted_term.replace("none", "c"); - - converted_term.replace(QString(" "), QString("")); - // Colorless card filter - if (converted_term == "c" && colors.length() < 1) { + if (convertedColorTerm.toLower() == "c" && colors.length() < 1) { return true; } @@ -256,26 +370,26 @@ static bool matchesColor(const QString &term, const QString &colors) * then we should match all of them to the card's colors */ int match_count = 0; - for (auto &it : converted_term) { - if (colors.contains(it, Qt::CaseInsensitive)) { + for (const QChar &color : convertedColorTerm) { + if (colors.contains(color, Qt::CaseInsensitive)) { match_count++; } } - return match_count == converted_term.length(); + return match_count == convertedColorTerm.length(); } -bool FilterItem::acceptColor(const CardInfoPtr info) const +bool FilterItem::acceptColor(const CardInfoPtr &info) const { - return matchesColor(term, info->getColors()); + return matchesColor(convertedColorTerm, info->getColors()); } -bool FilterItem::acceptColorIdentity(const CardInfoPtr info) const +bool FilterItem::acceptColorIdentity(const CardInfoPtr &info) const { - return matchesColor(term, info->getColorIdentity()); + return matchesColor(convertedColorTerm, info->getColorIdentity()); } -bool FilterItem::acceptText(const CardInfoPtr info, const CardSearchLanguage &searchLanguage) const +bool FilterItem::acceptText(const CardInfoPtr &info, const CardSearchLanguage &searchLanguage) const { const QString &englishText = info->getText(); const QString &localizedText = info->getLocalizedText(searchLanguage.language); @@ -298,51 +412,47 @@ bool FilterItem::acceptText(const CardInfoPtr info, const CardSearchLanguage &se return false; } -bool FilterItem::acceptSet(const CardInfoPtr info) const +bool FilterItem::acceptSet(const CardInfoPtr &info) const { - bool status = false; for (const auto &printings : info->getSets()) { - for (const auto &set : printings) { - if (set.getSet()->getShortName().compare(term, Qt::CaseInsensitive) == 0 || - set.getSet()->getLongName().compare(term, Qt::CaseInsensitive) == 0) { - status = true; - break; + for (const auto &printing : printings) { + const CardSetPtr set = printing.getSet(); + if (set && (set->getShortName().compare(term, Qt::CaseInsensitive) == 0 || + set->getLongName().compare(term, Qt::CaseInsensitive) == 0)) { + return true; } } } - return status; + return false; } -bool FilterItem::acceptManaCost(const CardInfoPtr info) const +bool FilterItem::acceptManaCost(const CardInfoPtr &info) const { - QString partialCost = term.toUpper(); - - // Sort the mana cost so it will be easy to find - std::sort(partialCost.begin(), partialCost.end()); - // Try to seperate the mana cost in case it's a split card // if it's not a split card the loop will run only once - for (QString fullManaCost : info->getManaCost().split("//")) { + for (const QString &manaCostPart : info->getManaCost().split("//")) { + QString fullManaCost = manaCostPart; std::sort(fullManaCost.begin(), fullManaCost.end()); // If the partial is found in the full, return true - if (fullManaCost.contains(partialCost)) { + if (fullManaCost.contains(sortedManaCostTerm)) { return true; } } return false; } -bool FilterItem::acceptCmc(const CardInfoPtr info) const +bool FilterItem::acceptCmc(const CardInfoPtr &info) const { + const QString cmc = info->getCmc(); bool convertSuccess; - int cmcInt = info->getCmc().toInt(&convertSuccess); + int cmcInt = cmc.toInt(&convertSuccess); // if conversion failed, check for the "//" separator used in split cards if (!convertSuccess) { int cmcSum = 0; - for (const QString &cmc : info->getCmc().split("//")) { - cmcInt = cmc.toInt(); + for (const QString &cmcPart : cmc.split("//")) { + cmcInt = cmcPart.toInt(); cmcSum += cmcInt; if (relationCheck(cmcInt)) { return true; @@ -354,39 +464,36 @@ bool FilterItem::acceptCmc(const CardInfoPtr info) const } } -bool FilterItem::acceptFormat(const CardInfoPtr info) const +bool FilterItem::acceptFormat(const CardInfoPtr &info) const { - return info->getLegalityProp(term.toLower()) == "legal"; + return info->getLegalityProp(lowercaseTerm) == "legal"; } -bool FilterItem::acceptLoyalty(const CardInfoPtr info) const +bool FilterItem::acceptLoyalty(const CardInfoPtr &info) const { - if (info->getLoyalty().isEmpty()) { + const QString loyaltyString = info->getLoyalty(); + if (loyaltyString.isEmpty()) { return false; - } else { - bool success; - // if loyalty can't be converted to "int" it must be "X" - int loyalty = info->getLoyalty().toInt(&success); - if (success) { - return relationCheck(loyalty); - } else { - return term.trimmed().toUpper() == info->getLoyalty(); - } } + + bool success; + // if loyalty can't be converted to "int" it must be "X" + int loyalty = loyaltyString.toInt(&success); + if (success) { + return relationCheck(loyalty); + } + return uppercaseLoyaltyTerm == loyaltyString; } -bool FilterItem::acceptPowerToughness(const CardInfoPtr info, CardFilter::Attr attr) const +bool FilterItem::acceptPowerToughness(const CardInfoPtr &info, CardFilter::Attr attr) const { - int slash = info->getPowTough().indexOf("/"); + const QString powTough = info->getPowTough(); + const qsizetype slash = powTough.indexOf("/"); if (slash == -1) { return false; } - QString valueString; - if (attr == CardFilter::AttrPow) { - valueString = info->getPowTough().mid(0, slash); - } else { - valueString = info->getPowTough().mid(slash + 1); - } + + QString valueString = attr == CardFilter::AttrPow ? powTough.mid(0, slash) : powTough.mid(slash + 1); if (term == valueString) { return true; } @@ -396,49 +503,11 @@ bool FilterItem::acceptPowerToughness(const CardInfoPtr info, CardFilter::Attr a return conversion ? relationCheck(value) : false; } -bool FilterItem::acceptRarity(const CardInfoPtr info) const +bool FilterItem::acceptRarity(const CardInfoPtr &info) const { - QString converted_term = term.trimmed(); - - /* - * The purpose of this loop is to only apply one of the replacement - * policies and then escape. If we attempt to layer them on top of - * each other, we will get awkward results (i.e. comythic rare mythic rareon) - * Conditional statement will exit once a case is successful in - * replacement OR we go through all possible cases. - * Will also need to replace just "mythic" - */ - for (int i = 0; converted_term.length() <= 3 && i <= 6; i++) { - switch (i) { - case 0: - converted_term.replace("mr", "mythic", Qt::CaseInsensitive); - break; - case 1: - converted_term.replace("m r", "mythic", Qt::CaseInsensitive); - break; - case 2: - converted_term.replace("m", "mythic", Qt::CaseInsensitive); - break; - case 3: - converted_term.replace("c", "common", Qt::CaseInsensitive); - break; - case 4: - converted_term.replace("u", "uncommon", Qt::CaseInsensitive); - break; - case 5: - converted_term.replace("r", "rare", Qt::CaseInsensitive); - break; - case 6: - converted_term.replace("s", "special", Qt::CaseInsensitive); - break; - default: - break; - } - } - for (const auto &printings : info->getSets()) { for (const auto &printing : printings) { - if (printing.getProperty("rarity").compare(converted_term, Qt::CaseInsensitive) == 0) { + if (printing.getProperty("rarity").compare(convertedRarityTerm, Qt::CaseInsensitive) == 0) { return true; } } @@ -448,41 +517,25 @@ bool FilterItem::acceptRarity(const CardInfoPtr info) const bool FilterItem::relationCheck(int cardInfo) const { - bool result, conversion; - - // if int conversion fails, there's probably an operator at the start - result = (cardInfo == term.toInt(&conversion)); - if (!conversion) { - // leading whitespaces could cause indexing to fail - QString trimmedTerm = term.trimmed(); - // check whether it's a 2 char operator (<=, >=, or ==) - if (trimmedTerm[1] == '=') { - int termInt = trimmedTerm.mid(2).toInt(); - if (trimmedTerm.startsWith('<')) { - result = (cardInfo <= termInt); - } else if (trimmedTerm.startsWith('>')) { - result = (cardInfo >= termInt); - } else { - result = (cardInfo == termInt); - } - } else { - int termInt = trimmedTerm.mid(1).toInt(); - if (trimmedTerm.startsWith('<')) { - result = (cardInfo < termInt); - } else if (trimmedTerm.startsWith('>')) { - result = (cardInfo > termInt); - } else if (trimmedTerm.startsWith("=")) { - result = (cardInfo == termInt); - } else { - // the int conversion hasn't failed due to an operator at the start - result = false; - } - } + switch (numericOperator) { + case NumericOperator::Equal: + return cardInfo == numericOperand; + case NumericOperator::Less: + return cardInfo < numericOperand; + case NumericOperator::LessEqual: + return cardInfo <= numericOperand; + case NumericOperator::Greater: + return cardInfo > numericOperand; + case NumericOperator::GreaterEqual: + return cardInfo >= numericOperand; + case NumericOperator::Invalid: + return false; } - return result; + + return false; } -bool FilterItem::acceptCardAttr(const CardInfoPtr info, +bool FilterItem::acceptCardAttr(const CardInfoPtr &info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const { @@ -568,7 +621,7 @@ FilterTreeNode *FilterTree::termNode(const CardFilter *f) return termNode(f->attr(), f->type(), f->term()); } -bool FilterTree::testAttr(const CardInfoPtr info, const LogicMap *lm, const CardSearchLanguage &searchLanguage) const +bool FilterTree::testAttr(const CardInfoPtr &info, const LogicMap *lm, const CardSearchLanguage &searchLanguage) const { const FilterItemList *fil; bool status = true; @@ -601,7 +654,7 @@ bool FilterTree::testAttr(const CardInfoPtr info, const LogicMap *lm, const Card return status; } -bool FilterTree::acceptsCard(const CardInfoPtr info, const CardSearchLanguage &searchLanguage) const +bool FilterTree::acceptsCard(const CardInfoPtr &info, const CardSearchLanguage &searchLanguage) const { for (auto i = childNodes.constBegin(); i != childNodes.constEnd(); i++) { if ((*i)->isEnabled() && !testAttr(info, *i, searchLanguage)) { diff --git a/libcockatrice_filters/libcockatrice/filters/filter_tree.h b/libcockatrice_filters/libcockatrice/filters/filter_tree.h index e94e0a948..f1a5803e5 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_tree.h +++ b/libcockatrice_filters/libcockatrice/filters/filter_tree.h @@ -174,13 +174,13 @@ public: } [[nodiscard]] bool - testTypeAnd(CardInfoPtr info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const; + testTypeAnd(const CardInfoPtr &info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const; [[nodiscard]] bool - testTypeAndNot(CardInfoPtr info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const; + testTypeAndNot(const CardInfoPtr &info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const; [[nodiscard]] bool - testTypeOr(CardInfoPtr info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const; + testTypeOr(const CardInfoPtr &info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const; [[nodiscard]] bool - testTypeOrNot(CardInfoPtr info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const; + testTypeOrNot(const CardInfoPtr &info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const; }; class FilterItem : public FilterTreeNode @@ -188,12 +188,33 @@ class FilterItem : public FilterTreeNode private: FilterItemList *const p; + /** + * Values derived from the filter term that never change while this node + * exists. Computing them in the constructor keeps them out of the per-card + * evaluation path. + */ + QString convertedColorTerm; + QString convertedRarityTerm; + QString sortedManaCostTerm; + QString uppercaseLoyaltyTerm; + QString lowercaseTerm; + + enum class NumericOperator + { + Invalid, + Equal, + Less, + LessEqual, + Greater, + GreaterEqual, + }; + NumericOperator numericOperator = NumericOperator::Invalid; + int numericOperand = 0; + public: const QString term; - FilterItem(QString trm, FilterItemList *parent) : p(parent), term(std::move(trm)) - { - } + FilterItem(QString trm, FilterItemList *parent); virtual ~FilterItem() = default; [[nodiscard]] CardFilter::Attr attr() const @@ -217,23 +238,23 @@ public: return true; } - [[nodiscard]] bool acceptName(CardInfoPtr info, const CardSearchLanguage &searchLanguage) const; - [[nodiscard]] bool acceptNameExact(CardInfoPtr info, const CardSearchLanguage &searchLanguage) const; - [[nodiscard]] bool acceptType(CardInfoPtr info) const; - [[nodiscard]] bool acceptMainType(CardInfoPtr info) const; - [[nodiscard]] bool acceptSubType(CardInfoPtr info) const; - [[nodiscard]] bool acceptColor(CardInfoPtr info) const; - [[nodiscard]] bool acceptColorIdentity(CardInfoPtr info) const; - [[nodiscard]] bool acceptText(CardInfoPtr info, const CardSearchLanguage &searchLanguage) const; - [[nodiscard]] bool acceptSet(CardInfoPtr info) const; - [[nodiscard]] bool acceptManaCost(CardInfoPtr info) const; - [[nodiscard]] bool acceptCmc(CardInfoPtr info) const; - [[nodiscard]] bool acceptPowerToughness(CardInfoPtr info, CardFilter::Attr attr) const; - [[nodiscard]] bool acceptLoyalty(CardInfoPtr info) const; - [[nodiscard]] bool acceptRarity(CardInfoPtr info) const; + [[nodiscard]] bool acceptName(const CardInfoPtr &info, const CardSearchLanguage &searchLanguage) const; + [[nodiscard]] bool acceptNameExact(const CardInfoPtr &info, const CardSearchLanguage &searchLanguage) const; + [[nodiscard]] bool acceptType(const CardInfoPtr &info) const; + [[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; + [[nodiscard]] bool acceptCmc(const CardInfoPtr &info) const; + [[nodiscard]] bool acceptPowerToughness(const CardInfoPtr &info, CardFilter::Attr attr) const; + [[nodiscard]] bool acceptLoyalty(const CardInfoPtr &info) const; + [[nodiscard]] bool acceptRarity(const CardInfoPtr &info) const; [[nodiscard]] bool - acceptCardAttr(CardInfoPtr info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const; - [[nodiscard]] bool acceptFormat(CardInfoPtr info) const; + acceptCardAttr(const CardInfoPtr &info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const; + [[nodiscard]] bool acceptFormat(const CardInfoPtr &info) const; [[nodiscard]] bool relationCheck(int cardInfo) const; }; @@ -252,7 +273,7 @@ private: LogicMap *attrLogicMap(CardFilter::Attr attr); FilterItemList *attrTypeList(CardFilter::Attr attr, CardFilter::Type type); - bool testAttr(CardInfoPtr info, const LogicMap *lm, const CardSearchLanguage &searchLanguage) const; + bool testAttr(const CardInfoPtr &info, const LogicMap *lm, const CardSearchLanguage &searchLanguage) const; void nodeChanged() const override { @@ -291,7 +312,7 @@ public: return 0; } - [[nodiscard]] bool acceptsCard(CardInfoPtr info, const CardSearchLanguage &searchLanguage) const; + [[nodiscard]] bool acceptsCard(const CardInfoPtr &info, const CardSearchLanguage &searchLanguage) const; void removeFiltersByAttr(CardFilter::Attr filterType); void removeFilter(const CardFilter *toRemove); void clear(); diff --git a/libcockatrice_game/CMakeLists.txt b/libcockatrice_game/CMakeLists.txt new file mode 100644 index 000000000..e7d362ca2 --- /dev/null +++ b/libcockatrice_game/CMakeLists.txt @@ -0,0 +1,81 @@ +set(CMAKE_AUTOMOC ON) +set(CMAKE_AUTOUIC ON) +set(CMAKE_AUTORCC ON) + +set(HEADERS + libcockatrice/game/abstract_game.h + libcockatrice/game/board/arrow_data.h + libcockatrice/game/board/card_list.h + libcockatrice/game/board/card_state.h + libcockatrice/game/board/counter_state.h + libcockatrice/game/game.h + libcockatrice/game/game_event_handler.h + libcockatrice/game/game_meta_info.h + libcockatrice/game/game_state.h + libcockatrice/game/phase.h + libcockatrice/game/player/card_menu_action_type.h + libcockatrice/game/player/card_move_request.h + libcockatrice/game/player/event_processing_options.h + libcockatrice/game/player/move_top_cards_until_options.h + libcockatrice/game/player/player_actions.h + libcockatrice/game/player/player_event_handler.h + libcockatrice/game/player/player_info.h + libcockatrice/game/player/player_logic.h + libcockatrice/game/player/player_manager.h + libcockatrice/game/player/token_info.h + libcockatrice/game/replay.h + libcockatrice/game/translation.h + libcockatrice/game/zones/card_zone_algorithms.h + libcockatrice/game/zones/card_zone_logic.h + libcockatrice/game/zones/hand_zone_logic.h + libcockatrice/game/zones/pile_zone_logic.h + libcockatrice/game/zones/stack_zone_logic.h + libcockatrice/game/zones/table_zone_logic.h + libcockatrice/game/zones/view_zone_logic.h +) + +qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) + +add_library( + libcockatrice_game STATIC + ${MOC_SOURCES} + libcockatrice/game/abstract_game.cpp + libcockatrice/game/board/arrow_data.cpp + libcockatrice/game/board/card_list.cpp + libcockatrice/game/board/card_state.cpp + libcockatrice/game/board/counter_state.cpp + libcockatrice/game/game.cpp + libcockatrice/game/game_event_handler.cpp + libcockatrice/game/game_meta_info.cpp + libcockatrice/game/game_state.cpp + libcockatrice/game/phase.cpp + libcockatrice/game/player/player_actions.cpp + libcockatrice/game/player/player_event_handler.cpp + libcockatrice/game/player/player_info.cpp + libcockatrice/game/player/player_logic.cpp + libcockatrice/game/player/player_manager.cpp + libcockatrice/game/replay.cpp + libcockatrice/game/zones/card_zone_logic.cpp + libcockatrice/game/zones/hand_zone_logic.cpp + libcockatrice/game/zones/pile_zone_logic.cpp + libcockatrice/game/zones/stack_zone_logic.cpp + libcockatrice/game/zones/table_zone_logic.cpp + libcockatrice/game/zones/view_zone_logic.cpp +) + +target_include_directories(libcockatrice_game PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) + +target_link_libraries( + libcockatrice_game + PUBLIC libcockatrice_card + libcockatrice_deck_list + libcockatrice_filters + libcockatrice_interfaces + libcockatrice_network + libcockatrice_protocol + libcockatrice_settings + libcockatrice_utility + ${QT_CORE_MODULE} + ${COCKATRICE_QT_VERSION_NAME}::Concurrent + ${COCKATRICE_QT_VERSION_NAME}::Gui +) diff --git a/cockatrice/src/game/abstract_game.cpp b/libcockatrice_game/libcockatrice/game/abstract_game.cpp similarity index 88% rename from cockatrice/src/game/abstract_game.cpp rename to libcockatrice_game/libcockatrice/game/abstract_game.cpp index efc575eff..a93f44018 100644 --- a/cockatrice/src/game/abstract_game.cpp +++ b/libcockatrice_game/libcockatrice/game/abstract_game.cpp @@ -18,8 +18,6 @@ AbstractGame::AbstractGame(QObject *_parent) : QObject(_parent) { gameMetaInfo = new GameMetaInfo(this); gameEventHandler = new GameEventHandler(this); - - activeCard = nullptr; } bool AbstractGame::isHost() const @@ -48,12 +46,7 @@ void AbstractGame::loadReplay(const GameReplay *replay) gameMetaInfo->setSpectatorsOmniscient(true); } -void AbstractGame::setActiveCard(CardItem *card) -{ - activeCard = card; -} - -CardItem *AbstractGame::getCard(int playerId, const QString &zoneName, int cardId) const +CardState *AbstractGame::getCard(int playerId, const QString &zoneName, int cardId) const { PlayerLogic *player = playerManager->getPlayer(playerId); if (!player) { diff --git a/cockatrice/src/game/abstract_game.h b/libcockatrice_game/libcockatrice/game/abstract_game.h similarity index 82% rename from cockatrice/src/game/abstract_game.h rename to libcockatrice_game/libcockatrice/game/abstract_game.h index 326af8ac9..00a95a3b8 100644 --- a/cockatrice/src/game/abstract_game.h +++ b/libcockatrice_game/libcockatrice/game/abstract_game.h @@ -11,14 +11,14 @@ #include #include -class CardItem; class AbstractClient; +class CardState; class GameEventHandler; class GameMetaInfo; class GameReplay; class GameState; class PlayerManager; - +class CardState; class AbstractGame : public QObject { Q_OBJECT @@ -30,7 +30,6 @@ public: GameState *gameState; GameEventHandler *gameEventHandler; PlayerManager *playerManager; - CardItem *activeCard; GameMetaInfo *getGameMetaInfo() { @@ -58,13 +57,7 @@ public: void loadReplay(const GameReplay *replay); - CardItem *getCard(int playerId, const QString &zoneName, int cardId) const; - - void setActiveCard(CardItem *card); - CardItem *getActiveCard() const - { - return activeCard; - } + CardState *getCard(int playerId, const QString &zoneName, int cardId) const; }; #endif // COCKATRICE_ABSTRACT_GAME_H diff --git a/cockatrice/src/game/board/arrow_data.cpp b/libcockatrice_game/libcockatrice/game/board/arrow_data.cpp similarity index 100% rename from cockatrice/src/game/board/arrow_data.cpp rename to libcockatrice_game/libcockatrice/game/board/arrow_data.cpp diff --git a/cockatrice/src/game/board/arrow_data.h b/libcockatrice_game/libcockatrice/game/board/arrow_data.h similarity index 100% rename from cockatrice/src/game/board/arrow_data.h rename to libcockatrice_game/libcockatrice/game/board/arrow_data.h diff --git a/cockatrice/src/game/board/card_list.cpp b/libcockatrice_game/libcockatrice/game/board/card_list.cpp similarity index 70% rename from cockatrice/src/game/board/card_list.cpp rename to libcockatrice_game/libcockatrice/game/board/card_list.cpp index 94bae67cd..361c51502 100644 --- a/cockatrice/src/game/board/card_list.cpp +++ b/libcockatrice_game/libcockatrice/game/board/card_list.cpp @@ -1,7 +1,6 @@ #include "card_list.h" -#include "../../game_graphics/board/card_item.h" -#include "libcockatrice/card/printing/exact_card.h" +#include "card_state.h" #include #include @@ -11,26 +10,26 @@ #include #include -CardList::CardList(bool _contentsKnown) : QList(), contentsKnown(_contentsKnown) +CardList::CardList(bool _contentsKnown) : QList(), contentsKnown(_contentsKnown) { } /** - * @brief Finds the CardItem with the given id in the list. + * @brief Finds the CardState with the given id in the list. * If contentsKnown is false, then this just returns the first element of the list. * * @param cardId The id of the card to find. * - * @returns A pointer to the CardItem, or a nullptr if not found. + * @returns A pointer to the CardState, or a nullptr if not found. */ -CardItem *CardList::findCard(const int cardId) const +CardState *CardList::findCard(const int cardId) const { if (!contentsKnown && !empty()) { return at(0); } else { - for (auto *cardItem : *this) { - if (cardItem->getId() == cardId) { - return cardItem; + for (auto *cardState : *this) { + if (cardState->getId() == cardId) { + return cardState; } } } @@ -38,7 +37,7 @@ CardItem *CardList::findCard(const int cardId) const } /** - * @brief sorts the list by using string comparison on properties extracted from the CardItem + * @brief sorts the list by using string comparison on properties extracted from the CardState * The cards are compared using each property in order. * If two cards have the same value for a property, then the next property in the list is used. * @@ -51,7 +50,7 @@ void CardList::sortBy(const QList &option) return; } - auto comparator = [&option](CardItem *a, CardItem *b) { + auto comparator = [&option](CardState *a, CardState *b) { for (auto prop : option) { auto extractor = getExtractorFor(prop); QString t1 = extractor(a); @@ -116,26 +115,26 @@ static QString getColorSortString(const CardInfo &c, bool appendAtEnd) } /** - * @brief returns the function that extracts the given property from the CardItem. + * @brief returns the function that extracts the given property from the CardState. */ -std::function CardList::getExtractorFor(SortOption option) +std::function CardList::getExtractorFor(SortOption option) { switch (option) { case NoSort: - return [](CardItem *) { return ""; }; + return [](CardState *) { return ""; }; case SortByMainType: - return [](CardItem *c) { return c->getCardInfo().getMainCardType(); }; + return [](CardState *c) { return c->getCardInfo().getMainCardType(); }; case SortByManaValue: // getCmc returns the int as a string. We pad with 0's so that string comp also works on it - return [](CardItem *c) { return c->getCard() ? c->getCardInfo().getCmc().rightJustified(4, '0') : ""; }; + return [](CardState *c) { return c->getCard() ? c->getCardInfo().getCmc().rightJustified(4, '0') : ""; }; case SortByColorGrouping: - return [](CardItem *c) { return c->getCard() ? getColorSortString(c->getCardInfo(), false) : ""; }; + return [](CardState *c) { return c->getCard() ? getColorSortString(c->getCardInfo(), false) : ""; }; case SortByName: - return [](CardItem *c) { return c->getName(); }; + return [](CardState *c) { return c->getName(); }; case SortByType: - return [](CardItem *c) { return c->getCardInfo().getCardType(); }; + return [](CardState *c) { return c->getCardInfo().getCardType(); }; case SortByManaCost: - return [](CardItem *c) { + return [](CardState *c) { if (!c->getCard()) { return QString(); } @@ -147,18 +146,18 @@ std::function CardList::getExtractorFor(SortOption option) return QString("%1%2").arg(info.getCmc(), 4, QChar('0')).arg(info.getManaCost()); }; case SortByColors: - return [](CardItem *c) { return c->getCard() ? getColorSortString(c->getCardInfo(), true) : ""; }; + return [](CardState *c) { return c->getCard() ? getColorSortString(c->getCardInfo(), true) : ""; }; case SortByPt: // do the same padding trick as above return - [](CardItem *c) { return c->getCard() ? c->getCardInfo().getPowTough().rightJustified(10, '0') : ""; }; + [](CardState *c) { return c->getCard() ? c->getCardInfo().getPowTough().rightJustified(10, '0') : ""; }; case SortBySet: - return [](CardItem *c) { return c->getCardInfo().getSetsNames(); }; + return [](CardState *c) { return c->getCardInfo().getSetsNames(); }; case SortByPrinting: - return [](CardItem *c) { return c->getProviderId(); }; + return [](CardState *c) { return c->getProviderId(); }; } // this line should never be reached qCWarning(CardListLog) << "cardlist.cpp: Could not find extractor for SortOption" << option; - return [](CardItem *) { return ""; }; + return [](CardState *) { return ""; }; } \ No newline at end of file diff --git a/cockatrice/src/game/board/card_list.h b/libcockatrice_game/libcockatrice/game/board/card_list.h similarity index 84% rename from cockatrice/src/game/board/card_list.h rename to libcockatrice_game/libcockatrice/game/board/card_list.h index b5427df0d..b3037d0f6 100644 --- a/cockatrice/src/game/board/card_list.h +++ b/libcockatrice_game/libcockatrice/game/board/card_list.h @@ -14,9 +14,9 @@ inline Q_LOGGING_CATEGORY(CardListLog, "card_list"); -class CardItem; +class CardState; -class CardList : public QList +class CardList : public QList { protected: bool contentsKnown; @@ -43,7 +43,7 @@ public: SortByPrinting }; explicit CardList(bool _contentsKnown); - CardItem *findCard(const int cardId) const; + CardState *findCard(const int cardId) const; bool getContentsKnown() const { return contentsKnown; @@ -51,7 +51,7 @@ public: void sortBy(const QList &options); - static std::function getExtractorFor(SortOption option); + static std::function getExtractorFor(SortOption option); }; #endif diff --git a/libcockatrice_game/libcockatrice/game/board/card_state.cpp b/libcockatrice_game/libcockatrice/game/board/card_state.cpp new file mode 100644 index 000000000..7e8c079a4 --- /dev/null +++ b/libcockatrice_game/libcockatrice/game/board/card_state.cpp @@ -0,0 +1,240 @@ +#include "card_state.h" + +#include "../zones/card_zone_logic.h" + +#include +#include +#include +#include + +void CardState::resetState(bool keepAnnotations) +{ + attacking = false; + counters.clear(); + pt.clear(); + if (!keepAnnotations) { + annotation.clear(); + } + attachedTo = nullptr; + attachedCards.clear(); + setTapped(false, false); + setDoesntUntap(false); + emit stateReset(); +} + +void CardState::setColor(const QString &_color) +{ + color = _color; + emit colorChanged(); +} + +void CardState::deleteView() +{ + emit viewDeleteRequested(); +} + +void CardState::setCardRef(const CardRef &_cardRef) +{ + if (cardRef == _cardRef) { + return; + } + + const CardRef oldCardRef = cardRef; + if (exactCard) { + disconnect(exactCard.getCardPtr().data(), nullptr, this, nullptr); + } + cardRef = _cardRef; + + emit cardRefChanged(oldCardRef, cardRef); + refreshCardInfo(); +} + +void CardState::refreshCardInfo() +{ + exactCard = CardDatabaseManager::query()->getCard(cardRef); + + if (!exactCard && !cardRef.name.isEmpty()) { + CardInfo::UiAttributes attributes = {.tableRow = -1}; + auto info = CardInfo::newInstance(cardRef.name, "", true, {}, {}, {}, {}, attributes); + exactCard = ExactCard(info); + } + if (exactCard) { + connect(exactCard.getCardPtr().data(), &CardInfo::pixmapUpdated, this, &CardState::cardPixmapUpdated); + } + + emit cardInfoChanged(); +} + +void CardState::setTapped(bool _tapped, bool canAnimate) +{ + if (tapped == _tapped) { + return; + } + + tapped = _tapped; + emit tappedChanged(tapped, canAnimate); + emit stateChanged(); +} + +void CardState::setFaceDown(bool _facedown) +{ + facedown = _facedown; + emit facedownChanged(facedown); + emit stateChanged(); +} + +QVariantList CardState::parsePT(const QString &pt) +{ + QVariantList ptList = QVariantList(); + if (!pt.isEmpty()) { + int sep = pt.indexOf('/'); + if (sep == 0) { + ptList.append(QVariant(pt.mid(1))); // cut off starting '/' and take full string + } else { + int start = 0; + for (;;) { + QString item = pt.mid(start, sep - start); + if (item.isEmpty()) { + ptList.append(QVariant(QString())); + } else if (item[0] == '+') { + ptList.append(QVariant(item.mid(1).toInt())); // add as int + } else if (item[0] == '-') { + ptList.append(QVariant(item.toInt())); // add as int + } else { + ptList.append(QVariant(item)); // add as qstring + } + if (sep == -1) { + break; + } + start = sep + 1; + sep = pt.indexOf('/', start); + } + } + } + return ptList; +} + +void CardState::setZone(CardZoneLogic *_zone) +{ + if (zone == _zone) { + return; + } + + zone = _zone; + emit zoneChanged(this, zone); + emit stateChanged(); +} + +void CardState::setAttacking(bool _attacking) +{ + if (attacking == _attacking) { + return; + } + attacking = _attacking; + emit attackingChanged(_attacking); + emit stateChanged(); +} + +void CardState::insertCounter(int id, int value) +{ + counters.insert(id, value); + + emit countersChanged(counters); + emit stateChanged(); +} + +void CardState::setCounter(int id, int value) +{ + if (value) { + counters[id] = value; + } else { + counters.remove(id); + } + + emit countersChanged(counters); + emit stateChanged(); +} + +void CardState::clearCounters() +{ + counters.clear(); + emit countersChanged(counters); + emit stateChanged(); +} + +void CardState::setAnnotation(const QString &_annotation) +{ + if (annotation == _annotation) { + return; + } + annotation = _annotation; + emit annotationChanged(annotation); + emit stateChanged(); +} + +void CardState::setPT(const QString &_pt) +{ + if (pt == _pt) { + return; + } + pt = _pt; + emit ptChanged(pt); + emit stateChanged(); +} + +void CardState::setDoesntUntap(bool _doesntUntap) +{ + if (doesntUntap == _doesntUntap) { + return; + } + doesntUntap = _doesntUntap; + emit doesntUntapChanged(_doesntUntap); + emit stateChanged(); +} + +void CardState::setDestroyOnZoneChange(bool _destroyOnZoneChange) +{ + if (destroyOnZoneChange == _destroyOnZoneChange) { + return; + } + + destroyOnZoneChange = _destroyOnZoneChange; + emit destroyOnZoneChangeChanged(_destroyOnZoneChange); + emit stateChanged(); +} + +void CardState::setAttachedTo(CardState *_attachedTo) +{ + if (attachedTo == _attachedTo) { + return; + } + + if (attachedTo != nullptr) { + attachedTo->removeAttachedCard(this); + } + + setGridPoint(QPoint(-1, getGridPoint().y())); + attachedTo = _attachedTo; + emit attachedToChanged(_attachedTo); + emit stateChanged(); + + if (attachedTo != nullptr) { + // If the zone is being torn down, it might already be null by the time a card tries to un-attach all its + // attached cards. The graphics item that owns this state handles the teardown in that case. + CardZoneLogic *parentZone = attachedTo->getZone(); + if (parentZone == nullptr) { + return; + } + emit parentZone->cardAdded(this); + attachedTo->addAttachedCard(this); + if (getZone() != parentZone) { + parentZone->reorganizeCards(); + } + } else if (getZone() != nullptr) { + emit getZone() -> cardAdded(this); + } + + if (getZone() != nullptr) { + getZone()->reorganizeCards(); + } +} diff --git a/libcockatrice_game/libcockatrice/game/board/card_state.h b/libcockatrice_game/libcockatrice/game/board/card_state.h new file mode 100644 index 000000000..fa1e81393 --- /dev/null +++ b/libcockatrice_game/libcockatrice/game/board/card_state.h @@ -0,0 +1,243 @@ +#ifndef COCKATRICE_CARD_STATE_H +#define COCKATRICE_CARD_STATE_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class CardZoneLogic; +class CardState : public QObject +{ + Q_OBJECT + +private: + int id = -1; + CardRef cardRef; + ExactCard exactCard; + bool tapped = false; + bool facedown = false; + QPoint gridPoint; + + bool attacking = false; + QMap counters; + QString annotation; + QString pt; + bool doesntUntap = false; + bool destroyOnZoneChange = false; + QString color; + + CardState *attachedTo = nullptr; + QList attachedCards; + CardZoneLogic *zone = nullptr; + +signals: + void stateChanged(); + + void cardRefChanged(const CardRef &oldCardRef, const CardRef &newCardRef); + void cardInfoChanged(); + void cardPixmapUpdated(); + void tappedChanged(bool newTapped, bool canAnimate); + void facedownChanged(bool newFaceDown); + void stateReset(); + void colorChanged(); + + void attackingChanged(bool newValue); + void countersChanged(const QMap &newCounters); + void annotationChanged(const QString &newAnnotation); + void ptChanged(const QString &newPt); + void doesntUntapChanged(bool newValue); + void destroyOnZoneChangeChanged(bool newValue); + void attachedToChanged(CardState *newAttachedTo); + void zoneChanged(CardState *changedCard, CardZoneLogic *newZone); + + void viewDeleteRequested(); + +public: + explicit CardState(QObject *parent, CardZoneLogic *_zone = nullptr, const CardRef &_cardRef = {}, int _id = -1) + : QObject(parent), id(_id), cardRef(_cardRef), zone(_zone) + { + } + + void resetState(bool keepAnnotations); + + /** + * @brief Asks the card view owning this state to tear itself down. + * + * The state never holds a card item directly, so it cannot delete one. It asks instead and lets the view run + * its own teardown, which detaches attachments, drops it from the active card slot and unregisters its + * animations before the graphics object goes away. + */ + void deleteView(); + + int getId() const + { + return id; + } + + void setId(int _id) + { + id = _id; + } + + const CardRef &getCardRef() const + { + return cardRef; + } + + void setCardRef(const CardRef &_cardRef); + + QString getName() const + { + return cardRef.name; + } + + QString getProviderId() const + { + return cardRef.providerId; + } + + ExactCard getCard() const + { + return exactCard; + } + + const CardInfo &getCardInfo() const + { + return exactCard.getInfo(); + } + + void refreshCardInfo(); + + bool getTapped() const + { + return tapped; + } + + void setTapped(bool _tapped, bool canAnimate = false); + + bool getFaceDown() const + { + return facedown; + } + + void setFaceDown(bool _facedown); + + const QPoint &getGridPoint() const + { + return gridPoint; + } + + void setGridPoint(const QPoint &_gridPoint) + { + gridPoint = _gridPoint; + } + + CardZoneLogic *getZone() const + { + return zone; + } + + void setZone(CardZoneLogic *_zone); + + bool getAttacking() const + { + return attacking; + } + void setAttacking(bool _attacking); + + const QMap &getCounters() const + { + return counters; + } + + void insertCounter(int id, int value); + + void setCounter(int id, int value); + + void clearCounters(); + + QString getAnnotation() const + { + return annotation; + } + + void setAnnotation(const QString &_annotation); + + QString getPT() const + { + return pt; + } + + void setPT(const QString &_pt); + + /** + * @brief Parses a string representing a p/t in order to extract the values from it. + * + * If the string contains '/', the string will be split at the '/' and each side will be parsed separately, + * which means the result list will have two elements. + * + * If '/' is not found, then the entire string is parsed together, which means the result list will + * have a single element. + * + * If either side of the split is empty, there will also only be a single element in the result list. + * + * This function will attempt to parse each substring as an int first, handling plus and minus prefixes. + * If successful, it will put the parsed value into the QVariant as an int. + * If failed, it will just put the substring into the QVariant as a QString. + * + * @param pt The p/t string + * @return A QVariantList that can contain one or two elements, where each QVariant can be either int or QString + */ + static QVariantList parsePT(const QString &pt); + + [[nodiscard]] QString getColor() const + { + return color; + } + + void setColor(const QString &_color); + + bool getDoesntUntap() const + { + return doesntUntap; + } + + void setDoesntUntap(bool _doesntUntap); + + bool getDestroyOnZoneChange() const + { + return destroyOnZoneChange; + } + + void setDestroyOnZoneChange(bool _destroyOnZoneChange); + + CardState *getAttachedTo() const + { + return attachedTo; + } + + void setAttachedTo(CardState *_attachedTo); + + void addAttachedCard(CardState *card) + { + attachedCards.append(card); + } + + void removeAttachedCard(CardState *card) + { + attachedCards.removeOne(card); + } + + const QList &getAttachedCards() const + { + return attachedCards; + } +}; + +#endif // COCKATRICE_CARD_STATE_H diff --git a/cockatrice/src/game/board/counter_state.cpp b/libcockatrice_game/libcockatrice/game/board/counter_state.cpp similarity index 100% rename from cockatrice/src/game/board/counter_state.cpp rename to libcockatrice_game/libcockatrice/game/board/counter_state.cpp diff --git a/cockatrice/src/game/board/counter_state.h b/libcockatrice_game/libcockatrice/game/board/counter_state.h similarity index 100% rename from cockatrice/src/game/board/counter_state.h rename to libcockatrice_game/libcockatrice/game/board/counter_state.h diff --git a/cockatrice/src/game/game.cpp b/libcockatrice_game/libcockatrice/game/game.cpp similarity index 75% rename from cockatrice/src/game/game.cpp rename to libcockatrice_game/libcockatrice/game/game.cpp index 7343d052a..a1a4a31a8 100644 --- a/cockatrice/src/game/game.cpp +++ b/libcockatrice_game/libcockatrice/game/game.cpp @@ -16,13 +16,16 @@ Game::Game(QObject *_parent, bool isLocalGame, QList &_clients, const Event_GameJoined &event, - const QMap &_roomGameTypes) + const QMap &_roomGameTypes, + IInterfaceSettingsProvider *interfaceSettings, + ICardPreferenceProvider *cardPreferences) : AbstractGame(_parent) { gameMetaInfo->setFromProto(event.game_info()); gameMetaInfo->setRoomGameTypes(_roomGameTypes); gameState = new GameState(this, 0, event.host_id(), isLocalGame, _clients, false, event.resuming(), -1, false); connect(gameMetaInfo, &GameMetaInfo::startedChanged, gameState, &GameState::onStartedChanged); - playerManager = new PlayerManager(this, event.player_id(), event.judge(), event.spectator()); + playerManager = new PlayerManager(this, event.player_id(), event.judge(), event.spectator(), interfaceSettings, + cardPreferences); gameMetaInfo->setStarted(false); } diff --git a/cockatrice/src/game/game.h b/libcockatrice_game/libcockatrice/game/game.h similarity index 69% rename from cockatrice/src/game/game.h rename to libcockatrice_game/libcockatrice/game/game.h index 6a309cfb2..f80b75ea5 100644 --- a/cockatrice/src/game/game.h +++ b/libcockatrice_game/libcockatrice/game/game.h @@ -18,6 +18,9 @@ class QString; template class QMap; template class QList; +class ICardPreferenceProvider; +class IInterfaceSettingsProvider; + class Game : public AbstractGame { Q_OBJECT @@ -27,7 +30,9 @@ public: bool isLocalGame, QList &_clients, const Event_GameJoined &event, - const QMap &_roomGameTypes); + const QMap &_roomGameTypes, + IInterfaceSettingsProvider *interfaceSettings = nullptr, + ICardPreferenceProvider *cardPreferences = nullptr); }; #endif // COCKATRICE_GAME_H diff --git a/cockatrice/src/game/game_event_handler.cpp b/libcockatrice_game/libcockatrice/game/game_event_handler.cpp similarity index 89% rename from cockatrice/src/game/game_event_handler.cpp rename to libcockatrice_game/libcockatrice/game/game_event_handler.cpp index 7b08af47c..856475dfa 100644 --- a/cockatrice/src/game/game_event_handler.cpp +++ b/libcockatrice_game/libcockatrice/game/game_event_handler.cpp @@ -28,12 +28,20 @@ #include #include #include +#include #include +#include #include #include #include +#include #include #include +#include +#include +#include +#include +#include #include #include #include @@ -48,6 +56,7 @@ #include #include #include +#include #include #include @@ -223,6 +232,89 @@ void GameEventHandler::handleActivePhaseChanged(int phase) sendGameCommand(cmd); } +void GameEventHandler::handleUntapAll() +{ + Command_SetCardAttr cmd; + cmd.set_zone(ZoneNames::TABLE); + cmd.set_attribute(AttrTapped); + cmd.set_attr_value("0"); + + sendGameCommand(cmd); +} + +void GameEventHandler::handleDrawCard() +{ + Command_DrawCards cmd; + cmd.set_number(1); + + sendGameCommand(cmd); +} + +PendingCommand *GameEventHandler::prepareDeckSelect(const QString &deckList) +{ + Command_DeckSelect cmd; + cmd.set_deck(deckList.toStdString()); + + return prepareGameCommand(cmd); +} + +PendingCommand *GameEventHandler::prepareDeckSelectId(int deckId) +{ + Command_DeckSelect cmd; + cmd.set_deck_id(deckId); + + return prepareGameCommand(cmd); +} + +PendingCommand *GameEventHandler::prepareSetPlaymat(const PlaymatInfo &playmat) +{ + Command_SetPlaymat cmd; + auto *pp = cmd.mutable_playmat_params(); + pp->set_card_name(playmat.card.name.toStdString()); + pp->set_card_provider_id(playmat.card.providerId.toStdString()); + pp->set_margin_pct_l(playmat.params.marginPctL); + pp->set_margin_pct_r(playmat.params.marginPctR); + pp->set_vertical_offset(playmat.params.verticalOffset); + pp->set_zoom(playmat.params.zoom); + + return prepareGameCommand(cmd); +} + +void GameEventHandler::sendReadyStart(bool ready, int playerId) +{ + Command_ReadyStart cmd; + cmd.set_ready(ready); + + sendGameCommand(cmd, playerId); +} + +void GameEventHandler::sendForceStart(int playerId) +{ + Command_ReadyStart cmd; + cmd.set_force_start(true); + cmd.set_ready(true); + + sendGameCommand(cmd, playerId); +} + +void GameEventHandler::sendSetSideboardLock(bool locked, int playerId) +{ + Command_SetSideboardLock cmd; + cmd.set_locked(locked); + + sendGameCommand(cmd, playerId); +} + +void GameEventHandler::sendSetSideboardPlan(const QList &plan, int playerId) +{ + Command_SetSideboardPlan cmd; + for (const auto &zone : plan) { + cmd.add_move_list()->CopyFrom(zone); + } + + sendGameCommand(cmd, playerId); +} + void GameEventHandler::handleGameLeft() { sendGameCommand(Command_LeaveGame()); diff --git a/cockatrice/src/game/game_event_handler.h b/libcockatrice_game/libcockatrice/game/game_event_handler.h similarity index 83% rename from cockatrice/src/game/game_event_handler.h rename to libcockatrice_game/libcockatrice/game/game_event_handler.h index 30021d295..f7b11780b 100644 --- a/cockatrice/src/game/game_event_handler.h +++ b/libcockatrice_game/libcockatrice/game/game_event_handler.h @@ -29,13 +29,14 @@ // GameEventContext, ServerInfo_Player and ServerInfo_User are passed by value // to signals and slots, so the moc-generated code needs the complete types. #include +#include #include #include +#include class AbstractClient; class AbstractGame; class GameEventContainer; -class GameEventContext; class PendingCommand; class PlayerLogic; class Response; @@ -127,6 +128,12 @@ public: */ void handleActivePhaseChanged(int phase); + /** @brief Request untapping every permanent on the table. */ + void handleUntapAll(); + + /** @brief Request drawing a single card. */ + void handleDrawCard(); + /** @brief Leave the current game session. */ void handleGameLeft(); @@ -145,6 +152,70 @@ public: void handleArrowDeletion(int creatorId, int arrowId); void handleArrowDeletionFinished(const Response &response, int creatorId, int arrowId); + /** + * @brief Prepare a deck selection from its native deck list text. + * + * The returned command is not sent yet, so the caller can connect to + * PendingCommand::finished before handing it to sendGameCommand(). + * + * @param deckList Serialized deck list. + * @return The pending command awaiting the deck download response. + */ + PendingCommand *prepareDeckSelect(const QString &deckList); + + /** + * @brief Prepare a deck selection for a deck stored on the server. + * + * The returned command is not sent yet, so the caller can connect to + * PendingCommand::finished before handing it to sendGameCommand(). + * + * @param deckId Server-side deck identifier. + * @return The pending command awaiting the deck download response. + */ + PendingCommand *prepareDeckSelectId(int deckId); + + /** + * @brief Build the playmat command for a player without sending it. + * + * The returned command is not sent yet, so the caller can connect to + * PendingCommand::finished before handing it to sendGameCommand(). + * + * @param playmat Resolved playmat card and positioning parameters. + * @return The pending command awaiting the playmat response. + */ + PendingCommand *prepareSetPlaymat(const PlaymatInfo &playmat); + + /** + * @brief Set a player's ready state. + * + * @param ready Whether the player is ready. + * @param playerId Player the state applies to. + */ + void sendReadyStart(bool ready, int playerId); + + /** + * @brief Force-start the game, kicking every player who is not ready. + * + * @param playerId Player issuing the force start. + */ + void sendForceStart(int playerId); + + /** + * @brief Lock or unlock a player's sideboard. + * + * @param locked Whether the sideboard is locked. + * @param playerId Player the lock applies to. + */ + void sendSetSideboardLock(bool locked, int playerId); + + /** + * @brief Send a player's sideboard plan. + * + * @param plan Cards to move between zones when leaving sideboarding. + * @param playerId Player the plan belongs to. + */ + void sendSetSideboardPlan(const QList &plan, int playerId); + /** @} */ /** @name Incoming event processing diff --git a/cockatrice/src/game/game_meta_info.cpp b/libcockatrice_game/libcockatrice/game/game_meta_info.cpp similarity index 100% rename from cockatrice/src/game/game_meta_info.cpp rename to libcockatrice_game/libcockatrice/game/game_meta_info.cpp diff --git a/cockatrice/src/game/game_meta_info.h b/libcockatrice_game/libcockatrice/game/game_meta_info.h similarity index 100% rename from cockatrice/src/game/game_meta_info.h rename to libcockatrice_game/libcockatrice/game/game_meta_info.h diff --git a/cockatrice/src/game/game_state.cpp b/libcockatrice_game/libcockatrice/game/game_state.cpp similarity index 100% rename from cockatrice/src/game/game_state.cpp rename to libcockatrice_game/libcockatrice/game/game_state.cpp diff --git a/cockatrice/src/game/game_state.h b/libcockatrice_game/libcockatrice/game/game_state.h similarity index 100% rename from cockatrice/src/game/game_state.h rename to libcockatrice_game/libcockatrice/game/game_state.h diff --git a/cockatrice/src/game/phase.cpp b/libcockatrice_game/libcockatrice/game/phase.cpp similarity index 100% rename from cockatrice/src/game/phase.cpp rename to libcockatrice_game/libcockatrice/game/phase.cpp diff --git a/cockatrice/src/game/phase.h b/libcockatrice_game/libcockatrice/game/phase.h similarity index 100% rename from cockatrice/src/game/phase.h rename to libcockatrice_game/libcockatrice/game/phase.h diff --git a/cockatrice/src/game_graphics/player/card_menu_action_type.h b/libcockatrice_game/libcockatrice/game/player/card_menu_action_type.h similarity index 95% rename from cockatrice/src/game_graphics/player/card_menu_action_type.h rename to libcockatrice_game/libcockatrice/game/player/card_menu_action_type.h index 4cae22716..e5c4cd84e 100644 --- a/cockatrice/src/game_graphics/player/card_menu_action_type.h +++ b/libcockatrice_game/libcockatrice/game/player/card_menu_action_type.h @@ -1,6 +1,6 @@ /** * @file card_menu_action_type.h - * @ingroup GameMenusPlayers + * @ingroup GameLogicPlayers */ //! \todo Document this file. diff --git a/libcockatrice_game/libcockatrice/game/player/card_move_request.h b/libcockatrice_game/libcockatrice/game/player/card_move_request.h new file mode 100644 index 000000000..bed46ab60 --- /dev/null +++ b/libcockatrice_game/libcockatrice/game/player/card_move_request.h @@ -0,0 +1,22 @@ +/** + * @file card_move_request.h + * @ingroup GameLogicActions + */ +//! \todo Document this file. + +#ifndef COCKATRICE_CARD_MOVE_REQUEST_H +#define COCKATRICE_CARD_MOVE_REQUEST_H + +#include + +/** + * @brief Describes one card a view wants moved, in logic terms rather than wire terms. + */ +struct CardMoveRequest +{ + int cardId = -1; + bool faceDown = false; + QString pt = {}; +}; + +#endif // COCKATRICE_CARD_MOVE_REQUEST_H diff --git a/cockatrice/src/game/player/event_processing_options.h b/libcockatrice_game/libcockatrice/game/player/event_processing_options.h similarity index 100% rename from cockatrice/src/game/player/event_processing_options.h rename to libcockatrice_game/libcockatrice/game/player/event_processing_options.h diff --git a/libcockatrice_game/libcockatrice/game/player/move_top_cards_until_options.h b/libcockatrice_game/libcockatrice/game/player/move_top_cards_until_options.h new file mode 100644 index 000000000..da8028b0f --- /dev/null +++ b/libcockatrice_game/libcockatrice/game/player/move_top_cards_until_options.h @@ -0,0 +1,19 @@ +/** + * @file move_top_cards_until_options.h + * @ingroup GameLogicPlayers + */ +//! \todo Document this file. + +#ifndef COCKATRICE_MOVE_TOP_CARDS_UNTIL_OPTIONS_H +#define COCKATRICE_MOVE_TOP_CARDS_UNTIL_OPTIONS_H + +#include + +struct MoveTopCardsUntilOptions +{ + QStringList exprs = {}; + int numberOfHits = 1; + bool autoPlay = false; +}; + +#endif // COCKATRICE_MOVE_TOP_CARDS_UNTIL_OPTIONS_H diff --git a/cockatrice/src/game/player/player_actions.cpp b/libcockatrice_game/libcockatrice/game/player/player_actions.cpp similarity index 84% rename from cockatrice/src/game/player/player_actions.cpp rename to libcockatrice_game/libcockatrice/game/player/player_actions.cpp index 35682ede8..90823fec6 100644 --- a/cockatrice/src/game/player/player_actions.cpp +++ b/libcockatrice_game/libcockatrice/game/player/player_actions.cpp @@ -1,13 +1,8 @@ #include "player_actions.h" -#include "../../client/settings/cache_settings.h" -#include "../../game_graphics/board/card_item.h" -#include "../../game_graphics/dialogs/dlg_create_token.h" -#include "../../game_graphics/dialogs/dlg_move_top_cards_until.h" -#include "../../game_graphics/player/card_menu_action_type.h" -#include "../../game_graphics/zones/table_zone.h" -#include "../../interface/deck_loader/loaded_deck.h" #include "../abstract_game.h" +#include "../board/arrow_data.h" +#include "../board/card_state.h" #include "../board/counter_state.h" #include "../game_event_handler.h" #include "../zones/card_zone_logic.h" @@ -31,8 +26,6 @@ #include #include #include -#include -#include #include #include #include @@ -51,10 +44,15 @@ #include #include #include +#include +#include +#include #include #include +#include #include #include +#include #include #include #include @@ -64,11 +62,12 @@ #include #include #include +#include #include #include -#include -#include +#include #include +#include #include #include #include @@ -80,8 +79,11 @@ // milliseconds in between triggers of the move top cards until action static constexpr int MOVE_TOP_CARD_UNTIL_INTERVAL = 100; -PlayerActions::PlayerActions(PlayerLogic *_player) - : QObject(_player), player(_player), lastTokenTableRow(0), movingCardsUntil(false) +PlayerActions::PlayerActions(PlayerLogic *_player, + IInterfaceSettingsProvider *_interfaceSettings, + ICardPreferenceProvider *_cardPreferences) + : QObject(_player), player(_player), interfaceSettings(_interfaceSettings), cardPreferences(_cardPreferences), + lastTokenTableRow(0), movingCardsUntil(false) { connect(this, &PlayerActions::requestZoneViewToggle, player, &PlayerLogic::onRequestZoneViewToggle); @@ -91,7 +93,27 @@ PlayerActions::PlayerActions(PlayerLogic *_player) connect(moveTopCardTimer, &QTimer::timeout, [this]() { actMoveTopCardToPlay(); }); } -void PlayerActions::playCard(CardItem *card, bool faceDown) +bool PlayerActions::getPlayToStack() const +{ + return interfaceSettings ? interfaceSettings->getPlayToStack() : true; +} + +int PlayerActions::getStartingHandSize() const +{ + return interfaceSettings ? interfaceSettings->getStartingHandSize() : 7; +} + +bool PlayerActions::getAnnotateTokens() const +{ + return interfaceSettings ? interfaceSettings->getAnnotateTokens() : false; +} + +QString PlayerActions::getCardPreferenceOverride(const QString &cardName) const +{ + return cardPreferences ? cardPreferences->getCardPreferenceOverride(cardName) : QString(); +} + +void PlayerActions::playCard(CardState *card, bool faceDown) { if (card == nullptr) { return; @@ -112,7 +134,7 @@ void PlayerActions::playCard(CardItem *card, bool faceDown) const CardInfo &info = exactCard.getInfo(); int tableRow = info.getUiAttributes().tableRow; - bool playToStack = SettingsCache::instance().userInterface().getPlayToStack(); + bool playToStack = getPlayToStack(); QString currentZone = card->getZone()->getName(); if (!faceDown && currentZone == ZoneNames::STACK && tableRow == 3) { cmd.set_target_zone(ZoneNames::GRAVE); @@ -125,7 +147,7 @@ void PlayerActions::playCard(CardItem *card, bool faceDown) cmd.set_y(0); } else { tableRow = faceDown ? 2 : info.getUiAttributes().tableRow; - QPoint gridPoint = QPoint(-1, TableZone::tableRowToGridY(tableRow)); + QPoint gridPoint = QPoint(-1, TableZoneLogic::tableRowToGridY(tableRow)); cardToMove->set_face_down(faceDown); if (!faceDown) { cardToMove->set_pt(info.getPowTough().toStdString()); @@ -144,7 +166,7 @@ void PlayerActions::playCard(CardItem *card, bool faceDown) * Like {@link PlayerActions::playCard}, but forces the card to be played to the table zone. * Cards with tablerow 3 (the stack) will be played to tablerow 1 (the noncreatures row). */ -void PlayerActions::playCardToTable(const CardItem *card, bool faceDown) +void PlayerActions::playCardToTable(const CardState *card, bool faceDown) { if (card == nullptr) { return; @@ -165,7 +187,7 @@ void PlayerActions::playCardToTable(const CardItem *card, bool faceDown) const CardInfo &info = exactCard.getInfo(); int tableRow = faceDown ? 2 : info.getUiAttributes().tableRow; - QPoint gridPoint = QPoint(-1, TableZone::tableRowToGridY(tableRow)); + QPoint gridPoint = QPoint(-1, TableZoneLogic::tableRowToGridY(tableRow)); cardToMove->set_face_down(faceDown); if (!faceDown) { cardToMove->set_pt(info.getPowTough().toStdString()); @@ -177,6 +199,60 @@ void PlayerActions::playCardToTable(const CardItem *card, bool faceDown) sendGameCommand(cmd); } +void PlayerActions::attachCards(CardState *startCard, const CardState *targetCard) +{ + if (targetCard->getAttachedTo() || targetCard->getZone()->getName() != ZoneNames::TABLE) { + return; + } + + // move card onto table first if attaching from some other zone + if (startCard->getZone()->getName() != ZoneNames::TABLE) { + playCardToTable(startCard, false); + } + + Command_AttachCard cmd; + cmd.set_start_zone(ZoneNames::TABLE); + cmd.set_card_id(startCard->getId()); + cmd.set_target_player_id(targetCard->getZone()->getPlayer()->getPlayerInfo()->getId()); + cmd.set_target_zone(targetCard->getZone()->getName().toStdString()); + cmd.set_target_card_id(targetCard->getId()); + sendGameCommand(cmd); +} + +void PlayerActions::createArrow(CardState *startCard, const ArrowData &arrow, int deleteInPhase) +{ + Command_CreateArrow cmd; + cmd.mutable_arrow_color()->CopyFrom(convertQColorToColor(arrow.color)); + cmd.set_start_player_id(arrow.startPlayerId); + cmd.set_start_zone(arrow.startZone.toStdString()); + cmd.set_start_card_id(arrow.startCardId); + cmd.set_target_player_id(arrow.targetPlayerId); + + if (!arrow.isPlayerTargeted()) { + cmd.set_target_zone(arrow.targetZone.toStdString()); + cmd.set_target_card_id(arrow.targetCardId); + } + + // if the card is in hand then it has just been moved to the stack or table as part of drawing the arrow + if (arrow.startZone == ZoneNames::HAND) { + CardInfoPtr ci = startCard->getCard().getCardPtr(); + bool playToStack = getPlayToStack(); + if (ci && ((!playToStack && ci->getUiAttributes().tableRow == 3) || + (playToStack && ci->getUiAttributes().tableRow != 0 && + startCard->getZone()->getName() != ZoneNames::STACK))) { + cmd.set_start_zone(ZoneNames::STACK); + } else { + cmd.set_start_zone(playToStack ? ZoneNames::STACK : ZoneNames::TABLE); + } + } + + if (deleteInPhase != 0) { + cmd.set_delete_in_phase(deleteInPhase); + } + + sendGameCommand(cmd); +} + void PlayerActions::actViewLibrary() { emit requestZoneViewToggle(ZoneNames::DECK, -1); @@ -355,7 +431,7 @@ void PlayerActions::actDrawCard() void PlayerActions::actRequestMulliganDialog() { - int startSize = SettingsCache::instance().userInterface().getStartingHandSize(); + int startSize = getStartingHandSize(); int handSize = player->getHandZone()->getCards().size(); int deckSize = player->getDeckZone()->getCards().size() + handSize; @@ -371,7 +447,7 @@ void PlayerActions::actMulligan(int number) } doMulligan(number); - SettingsCache::instance().userInterface().setStartingHandSize(number); + emit startingHandSizeChosen(number); } void PlayerActions::actMulliganSameSize() @@ -549,7 +625,7 @@ void PlayerActions::moveTopCardsUntil(const QString &expr, MoveTopCardsUntilOpti } } -void PlayerActions::moveOneCardUntil(CardItem *card) +void PlayerActions::moveOneCardUntil(CardState *card) { moveTopCardTimer->stop(); @@ -741,61 +817,6 @@ void PlayerActions::actMoveBottomCardToTop() sendGameCommand(cmd); } -/** - * Selects all cards in the given zone. - * - * @param zone The zone to select from - * @param filter A predicate to filter which cards are selected. Defaults to always returning true. - */ -static void selectCardsInZone( - const CardZoneLogic *zone, - std::function filter = [](const CardItem *) { return true; }) -{ - if (!zone) { - return; - } - - for (auto &cardItem : zone->getCards()) { - if (cardItem && filter(cardItem)) { - cardItem->setSelected(true); - } - } -} - -void PlayerActions::actSelectAll() -{ - const CardItem *card = player->getGame()->getActiveCard(); - if (!card) { - return; - } - - selectCardsInZone(card->getZone()); -} - -void PlayerActions::actSelectRow() -{ - const CardItem *card = player->getGame()->getActiveCard(); - if (!card) { - return; - } - - auto isSameRow = [card](const CardItem *cardItem) { - return qAbs(card->scenePos().y() - cardItem->scenePos().y()) < 50; - }; - selectCardsInZone(card->getZone(), isSameRow); -} - -void PlayerActions::actSelectColumn() -{ - const CardItem *card = player->getGame()->getActiveCard(); - if (!card) { - return; - } - - auto isSameColumn = [card](const CardItem *cardItem) { return cardItem->x() == card->x(); }; - selectCardsInZone(card->getZone(), isSameColumn); -} - void PlayerActions::actDrawBottomCard() { if (player->getDeckZone()->getCards().empty()) { @@ -929,7 +950,7 @@ void PlayerActions::actCreateToken(TokenInfo tokenToCreate) if (correctedCard) { lastTokenInfo.name = correctedCard.getName(); int tableRow = lastTokenInfo.faceDown ? 2 : correctedCard.getInfo().getUiAttributes().tableRow; - lastTokenTableRow = TableZone::tableRowToGridY(tableRow); + lastTokenTableRow = TableZoneLogic::tableRowToGridY(tableRow); if (lastTokenInfo.pt.isEmpty()) { lastTokenInfo.pt = correctedCard.getInfo().getPowTough(); } @@ -975,15 +996,14 @@ void PlayerActions::setLastTokenInfo(CardInfoPtr cardInfo) return; } - lastTokenInfo = { - .name = cardInfo->getName(), - .color = cardInfo->getColors().isEmpty() ? QString() : cardInfo->getColors().left(1).toLower(), - .pt = cardInfo->getPowTough(), - .annotation = SettingsCache::instance().userInterface().getAnnotateTokens() ? cardInfo->getText() : "", - .destroy = true, - .providerId = SettingsCache::instance().cardOverrides().getCardPreferenceOverride(cardInfo->getName())}; + lastTokenInfo = {.name = cardInfo->getName(), + .color = cardInfo->getColors().isEmpty() ? QString() : cardInfo->getColors().left(1).toLower(), + .pt = cardInfo->getPowTough(), + .annotation = getAnnotateTokens() ? cardInfo->getText() : "", + .destroy = true, + .providerId = getCardPreferenceOverride(cardInfo->getName())}; - lastTokenTableRow = TableZone::tableRowToGridY(cardInfo->getUiAttributes().tableRow); + lastTokenTableRow = TableZoneLogic::tableRowToGridY(cardInfo->getUiAttributes().tableRow); emit requestEnableAndSetCreateAnotherTokenAction(lastTokenInfo.name); } @@ -1001,25 +1021,20 @@ void PlayerActions::actCreatePredefinedToken() actCreateAnotherToken(); } -void PlayerActions::actCreateRelatedCard() +void PlayerActions::actCreateRelatedCard(const CardState *sourceCard, int index) { - const CardItem *sourceCard = player->getGame()->getActiveCard(); - if (!sourceCard) { return; } - auto *action = static_cast(sender()); - // If there is a better way of passing a CardRelation through a QAction, please add it here. auto relatedCards = sourceCard->getCardInfo().getAllRelatedCards(); - CardRelation *cardRelation = relatedCards.at(action->data().toInt()); + CardRelation *cardRelation = relatedCards.at(index); actRequestCreateRelatedFromRelationDialog(sourceCard, cardRelation); } -void PlayerActions::actCreateAllRelatedCards() +void PlayerActions::actCreateAllRelatedCards(const CardState *sourceCard) { - const CardItem *sourceCard = player->getGame()->getActiveCard(); if (!sourceCard) { return; } @@ -1105,13 +1120,13 @@ void PlayerActions::actCreateAllRelatedCards() } } -void PlayerActions::actRequestCreateRelatedFromRelationDialog(const CardItem *sourceCard, +void PlayerActions::actRequestCreateRelatedFromRelationDialog(const CardState *sourceCard, const CardRelation *cardRelation) { emit requestCreateRelatedFromRelationDialog(sourceCard, cardRelation); } -bool PlayerActions::createRelatedFromRelation(const CardItem *sourceCard, +bool PlayerActions::createRelatedFromRelation(const CardState *sourceCard, const CardRelation *cardRelation, int variableCount) { @@ -1163,7 +1178,7 @@ bool PlayerActions::createRelatedFromRelation(const CardItem *sourceCard, return true; } -void PlayerActions::onRelatedCardCreated(const CardItem *sourceCard, const CardRelation *cardRelation) +void PlayerActions::onRelatedCardCreated(const CardState *sourceCard, const CardRelation *cardRelation) { if (sourceCard == nullptr || cardRelation == nullptr) { return; @@ -1183,7 +1198,7 @@ void PlayerActions::onRelatedCardCreated(const CardItem *sourceCard, const CardR setLastToken(relatedCard.getCardPtr()); } -void PlayerActions::createCard(const CardItem *sourceCard, +void PlayerActions::createCard(const CardState *sourceCard, const QString &dbCardName, CardRelationType attachType, bool persistent, @@ -1195,7 +1210,7 @@ void PlayerActions::createCard(const CardItem *sourceCard, return; } - QPoint gridPoint = QPoint(-1, TableZone::tableRowToGridY(cardInfo->getUiAttributes().tableRow)); + QPoint gridPoint = QPoint(-1, TableZoneLogic::tableRowToGridY(cardInfo->getUiAttributes().tableRow)); // create the token for the related card Command_CreateToken cmd; @@ -1214,7 +1229,7 @@ void PlayerActions::createCard(const CardItem *sourceCard, } cmd.set_pt(cardInfo->getPowTough().toStdString()); - if (SettingsCache::instance().userInterface().getAnnotateTokens()) { + if (getAnnotateTokens()) { cmd.set_annotation(cardInfo->getText().toStdString()); } else { cmd.set_annotation(""); @@ -1269,7 +1284,7 @@ void PlayerActions::actRequestMoveCardXCardsFromTopDialog() emit requestMoveCardXCardsFromTopDialog(defaultNumberTopCardsToPlaceBelow, deckSize); } -void PlayerActions::actMoveCardXCardsFromTop(QList selectedCards, int number) +void PlayerActions::actMoveCardXCardsFromTop(QList selectedCards, int number) { defaultNumberTopCardsToPlaceBelow = number; @@ -1303,14 +1318,14 @@ void PlayerActions::actMoveCardXCardsFromTop(QList selectedCards, in } } -void PlayerActions::actIncPT(QList selectedCards, int deltaP, int deltaT) +void PlayerActions::actIncPT(QList selectedCards, int deltaP, int deltaT) { int playerid = player->getPlayerInfo()->getId(); QList commandList; for (auto card : selectedCards) { QString pt = card->getPT(); - const auto ptList = CardItem::parsePT(pt); + const auto ptList = CardState::parsePT(pt); QString newpt; if (ptList.isEmpty()) { newpt = QString::number(deltaP) + (deltaT ? "/" + QString::number(deltaT) : ""); @@ -1336,7 +1351,7 @@ void PlayerActions::actIncPT(QList selectedCards, int deltaP, int de player->getGame()->getGameEventHandler()->sendGameCommand(prepareGameCommand(commandList), playerid); } -void PlayerActions::actResetPT(QList selectedCards) +void PlayerActions::actResetPT(QList selectedCards) { int playerid = player->getPlayerInfo()->getId(); QList commandList; @@ -1369,7 +1384,7 @@ void PlayerActions::actResetPT(QList selectedCards) } } -void PlayerActions::actRequestSetPTDialog(QList selectedCards) +void PlayerActions::actRequestSetPTDialog(QList selectedCards) { QString oldPT; @@ -1382,11 +1397,11 @@ void PlayerActions::actRequestSetPTDialog(QList selectedCards) emit requestSetPTDialog(oldPT); } -void PlayerActions::actSetPT(QList selectedCards, const QString &pt) +void PlayerActions::actSetPT(QList selectedCards, const QString &pt) { int playerid = player->getPlayerInfo()->getId(); - const auto ptList = CardItem::parsePT(pt); + const auto ptList = CardState::parsePT(pt); bool empty = ptList.isEmpty(); QList commandList; @@ -1394,7 +1409,7 @@ void PlayerActions::actSetPT(QList selectedCards, const QString &pt) auto *cmd = new Command_SetCardAttr; QString newpt = QString(); if (!empty) { - const auto oldpt = CardItem::parsePT(card->getPT()); + const auto oldpt = CardState::parsePT(card->getPT()); int ptIter = 0; for (const auto &_item : ptList) { if (_item.typeId() == QMetaType::Type::Int) { @@ -1422,55 +1437,65 @@ void PlayerActions::actSetPT(QList selectedCards, const QString &pt) player->getGame()->getGameEventHandler()->sendGameCommand(prepareGameCommand(commandList), playerid); } -void PlayerActions::actDrawArrow() -{ - auto *card = player->getGame()->getActiveCard(); - if (card) { - card->drawArrow(Qt::red); - } -} - -void PlayerActions::actIncP(QList selectedCards) +void PlayerActions::actIncP(QList selectedCards) { actIncPT(selectedCards, 1, 0); } -void PlayerActions::actDecP(QList selectedCards) +void PlayerActions::actDecP(QList selectedCards) { actIncPT(selectedCards, -1, 0); } -void PlayerActions::actIncT(QList selectedCards) +void PlayerActions::actIncT(QList selectedCards) { actIncPT(selectedCards, 0, 1); } -void PlayerActions::actDecT(QList selectedCards) +void PlayerActions::actDecT(QList selectedCards) { actIncPT(selectedCards, 0, -1); } -void PlayerActions::actIncPT(QList selectedCards) +void PlayerActions::actIncPT(QList selectedCards) { actIncPT(selectedCards, 1, 1); } -void PlayerActions::actDecPT(QList selectedCards) +void PlayerActions::actDecPT(QList selectedCards) { actIncPT(selectedCards, -1, -1); } -void PlayerActions::actFlowP(QList selectedCards) +void PlayerActions::actFlowP(QList selectedCards) { actIncPT(selectedCards, 1, -1); } -void PlayerActions::actFlowT(QList selectedCards) +void PlayerActions::actFlowT(QList selectedCards) { actIncPT(selectedCards, -1, 1); } -void PlayerActions::actReduceLifeByPower(QList selectedCards) +void PlayerActions::actIncrementCounter(int counterId, int delta) +{ + Command_IncCounter cmd; + cmd.set_counter_id(counterId); + cmd.set_delta(delta); + + sendGameCommand(cmd); +} + +void PlayerActions::actSetCounter(int counterId, int value) +{ + Command_SetCounter cmd; + cmd.set_counter_id(counterId); + cmd.set_value(value); + + sendGameCommand(cmd); +} + +void PlayerActions::actReduceLifeByPower(QList selectedCards) { // find life counter auto lifeCounter = player->getLifeCounter(); @@ -1481,7 +1506,7 @@ void PlayerActions::actReduceLifeByPower(QList selectedCards) // calculate total power; int total = 0; for (auto card : selectedCards) { - QVariantList parsed = CardItem::parsePT(card->getPT()); + QVariantList parsed = CardState::parsePT(card->getPT()); if (!parsed.isEmpty()) { int power = parsed.first().toInt(); // toInt will default to 0 if it's not an int total += qMax(power, 0); @@ -1495,17 +1520,7 @@ void PlayerActions::actReduceLifeByPower(QList selectedCards) sendGameCommand(prepareGameCommand(cmd)); } -void AnnotationDialog::keyPressEvent(QKeyEvent *event) -{ - if (event->key() == Qt::Key_Return && event->modifiers() & Qt::ControlModifier) { - event->accept(); - accept(); - return; - } - QInputDialog::keyPressEvent(event); -} - -void PlayerActions::actRequestSetAnnotationDialog(QList selectedCards) +void PlayerActions::actRequestSetAnnotationDialog(QList selectedCards) { QString oldAnnotation; for (auto card : selectedCards) { @@ -1517,7 +1532,7 @@ void PlayerActions::actRequestSetAnnotationDialog(QList selectedCard emit requestSetAnnotationDialog(oldAnnotation); } -void PlayerActions::actSetAnnotation(QList selectedCards, const QString &annotation) +void PlayerActions::actSetAnnotation(QList selectedCards, const QString &annotation) { QList commandList; for (auto card : selectedCards) { @@ -1531,17 +1546,7 @@ void PlayerActions::actSetAnnotation(QList selectedCards, const QStr sendGameCommand(prepareGameCommand(commandList)); } -void PlayerActions::actAttach() -{ - auto *card = player->getGame()->getActiveCard(); - if (!card) { - return; - } - - card->drawAttachArrow(); -} - -void PlayerActions::actUnattach(QList selectedCards) +void PlayerActions::actUnattach(QList selectedCards) { QList commandList; for (auto card : selectedCards) { @@ -1557,17 +1562,17 @@ void PlayerActions::actUnattach(QList selectedCards) sendGameCommand(prepareGameCommand(commandList)); } -void PlayerActions::actAddCardCounter(QList selectedCards, int counterId) +void PlayerActions::actAddCardCounter(QList selectedCards, int counterId) { offsetCardCounter(selectedCards, counterId, 1); } -void PlayerActions::actRemoveCardCounter(QList selectedCards, int counterId) +void PlayerActions::actRemoveCardCounter(QList selectedCards, int counterId) { offsetCardCounter(selectedCards, counterId, -1); } -void PlayerActions::offsetCardCounter(QList selectedCards, int counterId, int offset) +void PlayerActions::offsetCardCounter(QList selectedCards, int counterId, int offset) { QList commandList; for (auto card : selectedCards) { @@ -1593,7 +1598,7 @@ void PlayerActions::offsetCardCounter(QList selectedCards, int count sendGameCommand(prepareGameCommand(commandList)); } -void PlayerActions::actRequestSetCardCounterDialog(QList selectedCards, int counterId) +void PlayerActions::actRequestSetCardCounterDialog(QList selectedCards, int counterId) { // If a single card is selected, we show the old value in the dialog. Otherwise, we show "x" QString oldValueForDlg = "x"; @@ -1605,7 +1610,7 @@ void PlayerActions::actRequestSetCardCounterDialog(QList selectedCar emit requestSetCardCounterDialog(counterId, oldValueForDlg); } -void PlayerActions::actSetCardCounter(QList selectedCards, int counterId, const QString &counterValue) +void PlayerActions::actSetCardCounter(QList selectedCards, int counterId, const QString &counterValue) { QList commandList; for (auto card : selectedCards) { @@ -1626,11 +1631,13 @@ void PlayerActions::actSetCardCounter(QList selectedCards, int count sendGameCommand(prepareGameCommand(commandList)); } -void PlayerActions::actIncrementAllCardCounters(QList cardsToUpdate) +void PlayerActions::actIncrementAllCardCounters(QList cardsToUpdate) { if (cardsToUpdate.isEmpty()) { // If no cards selected, update all cards on table - cardsToUpdate = static_cast>(player->getTableZone()->getCards()); + for (auto *state : player->getTableZone()->getCards()) { + cardsToUpdate.append(state); + } } QList commandList; @@ -1673,7 +1680,7 @@ static bool isUnwritableRevealZone(CardZoneLogic *zone) return false; } -void PlayerActions::playSelectedCards(QList selectedCards, const bool faceDown) +void PlayerActions::playSelectedCards(QList selectedCards, const bool faceDown) { // CardIds will get shuffled downwards when cards leave the deck. // We need to iterate through the cards in reverse order so cardIds don't get changed out from under us as we play @@ -1688,27 +1695,26 @@ void PlayerActions::playSelectedCards(QList selectedCards, const boo } } -void PlayerActions::actPlay(QList selectedCards) +void PlayerActions::actPlay(QList selectedCards) { playSelectedCards(selectedCards, false); } -void PlayerActions::actPlayFacedown(QList selectedCards) +void PlayerActions::actPlayFacedown(QList selectedCards) { playSelectedCards(selectedCards, true); } -void PlayerActions::actHide(QList selectedCards) +void PlayerActions::actHide(QList selectedCards) { - for (const auto &item : selectedCards) { - auto *card = static_cast(item); - if (card && isUnwritableRevealZone(card->getZone())) { + for (const auto &card : selectedCards) { + if (isUnwritableRevealZone(card->getZone())) { card->getZone()->removeCard(card); } } } -void PlayerActions::actReveal(QList selectedCards, QAction *action) +void PlayerActions::actReveal(QList selectedCards, QAction *action) { const int otherPlayerId = action->data().toInt(); @@ -1799,9 +1805,9 @@ void PlayerActions::actRevealRandomGraveyardCard(int revealToPlayerId) sendGameCommand(cmd); } -void PlayerActions::cardMenuAction(QList selectedCards, CardMenuActionType type) +void PlayerActions::cardMenuAction(QList selectedCards, CardMenuActionType type) { - QList cardList = selectedCards; + QList cardList = selectedCards; QList commandList; if (type <= cmClone) { @@ -1991,7 +1997,7 @@ void PlayerActions::cardMenuAction(QList selectedCards, CardMenuActi ctm->set_tapped(info.getUiAttributes().cipt); } - cmd->set_y(TableZone::tableRowToGridY(tableRow)); + cmd->set_y(TableZoneLogic::tableRowToGridY(tableRow)); commandList.append(cmd); } break; @@ -2040,6 +2046,76 @@ PendingCommand *PlayerActions::prepareGameCommand(const QList &cards) +{ + if (!startZone || !targetZone) { + return; + } + + Command_MoveCard cmd; + cmd.set_start_player_id(startZone->getPlayer()->getPlayerInfo()->getId()); + cmd.set_start_zone(startZone->getName().toStdString()); + cmd.set_target_player_id(targetZone->getPlayer()->getPlayerInfo()->getId()); + cmd.set_target_zone(targetZone->getName().toStdString()); + cmd.set_x(x); + cmd.set_y(y); + + if (auto *viewLogic = qobject_cast(targetZone)) { + cmd.set_is_reversed(viewLogic->getIsReversed()); + } + + for (const CardMoveRequest &card : cards) { + auto *cardToMove = cmd.mutable_cards_to_move()->add_card(); + cardToMove->set_card_id(card.cardId); + if (card.faceDown) { + cardToMove->set_face_down(true); + } + if (!card.pt.isEmpty()) { + cardToMove->set_pt(card.pt.toStdString()); + } + } + + sendGameCommand(cmd); +} + +PendingCommand *PlayerActions::prepareZoneDump(CardZoneLogic *zone, int numberCards) +{ + if (!zone) { + return nullptr; + } + + Command_DumpZone cmd; + cmd.set_player_id(zone->getPlayer()->getPlayerInfo()->getId()); + cmd.set_zone_name(zone->getName().toStdString()); + cmd.set_number_cards(numberCards); + if (auto *viewLogic = qobject_cast(zone)) { + cmd.set_is_reversed(viewLogic->getIsReversed()); + } + + return prepareGameCommand(cmd); +} + +void PlayerActions::actToggleTapped(const QString &zoneName, const QList &cards, bool tapped) +{ + QList cmdList; + for (CardState *card : cards) { + if (card->getTapped() != tapped) { + auto *cmd = new Command_SetCardAttr; + cmd->set_zone(zoneName.toStdString()); + cmd->set_card_id(card->getId()); + cmd->set_attribute(AttrTapped); + cmd->set_attr_value(tapped ? "1" : "0"); + cmdList.append(cmd); + } + } + + sendGameCommand(prepareGameCommand(cmdList)); +} + void PlayerActions::sendGameCommand(const google::protobuf::Message &command) { if (player->getPlayerInfo()->getJudge() && !player->getPlayerInfo()->getLocal()) { diff --git a/cockatrice/src/game/player/player_actions.h b/libcockatrice_game/libcockatrice/game/player/player_actions.h similarity index 54% rename from cockatrice/src/game/player/player_actions.h rename to libcockatrice_game/libcockatrice/game/player/player_actions.h index 8fff81635..7ed52b9a1 100644 --- a/cockatrice/src/game/player/player_actions.h +++ b/libcockatrice_game/libcockatrice/game/player/player_actions.h @@ -8,12 +8,13 @@ #ifndef COCKATRICE_PLAYER_ACTIONS_H #define COCKATRICE_PLAYER_ACTIONS_H -#include "../../game_graphics/board/card_item.h" // IWYU pragma: keep - moc instantiates the metatype for QList, which Qt 6.4 needs complete -#include "../../game_graphics/dialogs/dlg_create_token.h" -#include "../../game_graphics/dialogs/dlg_move_top_cards_until.h" -#include "../../game_graphics/player/card_menu_action_type.h" #include "../board/card_list.h" +#include "../board/card_state.h" +#include "card_menu_action_type.h" +#include "card_move_request.h" #include "libcockatrice/card/card_info.h" +#include "move_top_cards_until_options.h" +#include "token_info.h" #include #include @@ -22,7 +23,6 @@ #include #include -class CardItem; class CardRelation; class QAction; class QTimer; @@ -36,10 +36,16 @@ class Message; } } // namespace google +class CardState; +class CardZoneLogic; class Command_MoveCard; +class GameEventContext; +class ICardPreferenceProvider; +class IInterfaceSettingsProvider; class PendingCommand; class PlayerLogic; - +class QAction; +struct ArrowData; class PlayerActions : public QObject { Q_OBJECT @@ -50,7 +56,9 @@ public: RANDOM_CARD_FROM_ZONE = -2 }; - explicit PlayerActions(PlayerLogic *player); + explicit PlayerActions(PlayerLogic *player, + IInterfaceSettingsProvider *interfaceSettings = nullptr, + ICardPreferenceProvider *cardPreferences = nullptr); void sendGameCommand(PendingCommand *pend); void sendGameCommand(const google::protobuf::Message &command); @@ -58,7 +66,52 @@ public: PendingCommand *prepareGameCommand(const ::google::protobuf::Message &cmd); PendingCommand *prepareGameCommand(const QList &cmdList); - void moveOneCardUntil(CardItem *card); + /** + * @brief Moves the requested cards between zones on behalf of a view. + * @param startZone Zone the cards are dragged out of. + * @param targetZone Zone the cards are dropped onto. + * @param x Horizontal target index within the zone. + * @param y Vertical target index within the zone. + * @param cards The cards to move, as chosen by the view. + */ + void + moveCards(CardZoneLogic *startZone, CardZoneLogic *targetZone, int x, int y, const QList &cards); + + /** + * @brief Prepares a dump of the given zone, for the caller to connect to and send. + * @param zone Zone to dump. + * @param numberCards How many cards to dump, -1 for all. + */ + PendingCommand *prepareZoneDump(CardZoneLogic *zone, int numberCards); + + /** + * @brief Sets the tapped state of the cards that do not already have it. + * @param zoneName Zone the cards belong to. + * @param cards Cards the view wants toggled. + * @param tapped Desired tapped state. + */ + void actToggleTapped(const QString &zoneName, const QList &cards, bool tapped); + + /** + * @brief Attaches one card to another on the table on behalf of a view. + * @param startCard Card being attached. + * @param targetCard Card being attached to. + */ + void attachCards(CardState *startCard, const CardState *targetCard); + + /** + * @brief Draws an arrow from a card to another card or a player on behalf of a view. + * + * The view resolves the endpoints before playing the card out of hand, so the + * arrow still records the zone the card started in. + * + * @param startCard Card the arrow starts from. + * @param arrow Arrow endpoints and color, as resolved by the view. + * @param deleteInPhase Phase the arrow expires in, 0 to keep it until removed. + */ + void createArrow(CardState *startCard, const ArrowData &arrow, int deleteInPhase); + + void moveOneCardUntil(CardState *card); void stopMoveTopCardsUntil(); [[nodiscard]] bool isMovingCardsUntil() const @@ -87,7 +140,7 @@ signals: void requestDrawBottomCardsDialog(int defaultNumberBottomCards, int maxCards); void requestRollDieDialog(); void requestCreateTokenDialog(const QStringList &predefinedTokens); - void requestCreateRelatedFromRelationDialog(const CardItem *sourceCard, const CardRelation *cardRelation); + void requestCreateRelatedFromRelationDialog(const CardState *sourceCard, const CardRelation *cardRelation); void requestMoveCardXCardsFromTopDialog(int defaultNumberTopCardsToPlaceBelow, int deckSize); void requestSetPTDialog(const QString &oldPT); void requestSetAnnotationDialog(const QString &oldAnnotation); @@ -96,12 +149,18 @@ signals: void requestSortHand(const QList &options); void requestEnableAndSetCreateAnotherTokenAction(const QString &lastTokenName); void requestSetLastToken(CardInfoPtr lastToken); + /** + * @brief The player chose a mulligan size to remember as the starting hand size. + * + * Game logic must not write client settings itself, so persistence is left to the application. + */ + void startingHandSizeChosen(int number); public slots: void setLastToken(CardInfoPtr cardInfo); void setLastTokenInfo(CardInfoPtr cardInfo); - void playCard(CardItem *c, bool faceDown); - void playCardToTable(const CardItem *c, bool faceDown); + void playCard(CardState *c, bool faceDown); + void playCardToTable(const CardState *c, bool faceDown); void actUntapAll(); void actRequestRollDieDialog(); @@ -110,9 +169,9 @@ public slots: void actRequestCreateTokenDialog(const QStringList &predefinedTokens); void actCreateToken(TokenInfo tokenToCreate); void actCreateAnotherToken(); - void actRequestCreateRelatedFromRelationDialog(const CardItem *sourceCard, const CardRelation *cardRelation); - bool createRelatedFromRelation(const CardItem *sourceCard, const CardRelation *cardRelation, int variableCount); - void onRelatedCardCreated(const CardItem *sourceCard, const CardRelation *cardRelation); + void actRequestCreateRelatedFromRelationDialog(const CardState *sourceCard, const CardRelation *cardRelation); + bool createRelatedFromRelation(const CardState *sourceCard, const CardRelation *cardRelation, int variableCount); + void onRelatedCardCreated(const CardState *sourceCard, const CardRelation *cardRelation); void setLastRelatedCreationSucceeded(bool succeeded) { lastRelatedCreationSucceeded = succeeded; @@ -132,9 +191,9 @@ public slots: void actMulliganMinusOne(); void doMulligan(int number); - void actPlay(QList selectedCards); - void actPlayFacedown(QList selectedCards); - void actHide(QList selectedCards); + void actPlay(QList selectedCards); + void actPlayFacedown(QList selectedCards); + void actHide(QList selectedCards); void actMoveTopCardToPlay(); void actMoveTopCardToPlayFaceDown(); @@ -164,10 +223,6 @@ public slots: void actRequestMoveBottomCardsToDialog(const QString &targetZone, const QString &zoneDisplayName, bool faceDown); void moveBottomCardsTo(int number, const QString &targetZone, bool faceDown); - void actSelectAll(); - void actSelectRow(); - void actSelectColumn(); - void actViewLibrary(); void actViewHand(); void actRequestViewTopCardsDialog(); @@ -187,47 +242,55 @@ public slots: void actOpenDeckInDeckEditor(); void actCreatePredefinedToken(); - void actCreateRelatedCard(); - void actCreateAllRelatedCards(); + void actCreateRelatedCard(const CardState *sourceCard, int index); + void actCreateAllRelatedCards(const CardState *sourceCard); void actRequestMoveCardXCardsFromTopDialog(); - void actMoveCardXCardsFromTop(QList selectedCards, int number); - void actRemoveCardCounter(QList selectedCards, int counterId); - void actAddCardCounter(QList selectedCards, int counterId); - void actRequestSetCardCounterDialog(QList selectedCards, int counterId); - void actSetCardCounter(QList selectedCards, int counterId, const QString &counterValue); - void actIncrementAllCardCounters(QList cardsToUpdate); - void actAttach(); - void actUnattach(QList selectedCards); - void actDrawArrow(); - void actIncPT(QList selectedCards, int deltaP, int deltaT); - void actResetPT(QList selectedCards); - void actRequestSetPTDialog(QList selectedCards); - void actSetPT(QList selectedCards, const QString &pt); - void actIncP(QList selectedCards); - void actDecP(QList selectedCards); - void actIncT(QList selectedCards); - void actDecT(QList selectedCards); - void actIncPT(QList selectedCards); - void actDecPT(QList selectedCards); - void actFlowP(QList selectedCards); - void actFlowT(QList selectedCards); + void actMoveCardXCardsFromTop(QList selectedCards, int number); + void actRemoveCardCounter(QList selectedCards, int counterId); + void actAddCardCounter(QList selectedCards, int counterId); + void actRequestSetCardCounterDialog(QList selectedCards, int counterId); + void actSetCardCounter(QList selectedCards, int counterId, const QString &counterValue); + void actIncrementAllCardCounters(QList cardsToUpdate); + void actUnattach(QList selectedCards); + void actIncPT(QList selectedCards, int deltaP, int deltaT); + void actResetPT(QList selectedCards); + void actRequestSetPTDialog(QList selectedCards); + void actSetPT(QList selectedCards, const QString &pt); + void actIncP(QList selectedCards); + void actDecP(QList selectedCards); + void actIncT(QList selectedCards); + void actDecT(QList selectedCards); + void actIncPT(QList selectedCards); + void actDecPT(QList selectedCards); + void actFlowP(QList selectedCards); + void actFlowT(QList selectedCards); - void actReduceLifeByPower(QList selectedCards); + /** @brief Adjusts a player counter by a signed delta. */ + void actIncrementCounter(int counterId, int delta); - void actRequestSetAnnotationDialog(QList selectedCards); - void actSetAnnotation(QList selectedCards, const QString &annotation); - void actReveal(QList selectedCards, QAction *action); + /** @brief Sets a player counter to an absolute value. */ + void actSetCounter(int counterId, int value); + + void actReduceLifeByPower(QList selectedCards); + + void actRequestSetAnnotationDialog(QList selectedCards); + void actSetAnnotation(QList selectedCards, const QString &annotation); + void actReveal(QList selectedCards, QAction *action); void actRevealHand(int revealToPlayerId); void actRevealRandomHandCard(int revealToPlayerId); void actRevealLibrary(int revealToPlayerId); void actSortHand(); - void cardMenuAction(QList selectedCards, CardMenuActionType type); + void cardMenuAction(QList selectedCards, CardMenuActionType type); private: PlayerLogic *player; + // Borrowed settings providers (owned by the application). Null outside the client, in which case the compiled-in + // defaults below apply. + IInterfaceSettingsProvider *interfaceSettings; + ICardPreferenceProvider *cardPreferences; int defaultNumberTopCards = 1; int defaultNumberTopCardsToPlaceBelow = 1; @@ -245,18 +308,23 @@ private: bool lastRelatedCreationSucceeded = false; - void createCard(const CardItem *sourceCard, + [[nodiscard]] bool getPlayToStack() const; + [[nodiscard]] int getStartingHandSize() const; + [[nodiscard]] bool getAnnotateTokens() const; + [[nodiscard]] QString getCardPreferenceOverride(const QString &cardName) const; + + void createCard(const CardState *sourceCard, const QString &dbCardName, CardRelationType attach = CardRelationType::DoesNotAttach, bool persistent = false, bool faceDown = false); - void playSelectedCards(QList selectedCards, bool faceDown = false); + void playSelectedCards(QList selectedCards, bool faceDown = false); void cmdSetTopCard(Command_MoveCard &cmd); void cmdSetBottomCard(Command_MoveCard &cmd); - void offsetCardCounter(QList selectedCards, int counterId, int offset); + void offsetCardCounter(QList selectedCards, int counterId, int offset); }; #endif // COCKATRICE_PLAYER_ACTIONS_H diff --git a/cockatrice/src/game/player/player_event_handler.cpp b/libcockatrice_game/libcockatrice/game/player/player_event_handler.cpp similarity index 88% rename from cockatrice/src/game/player/player_event_handler.cpp rename to libcockatrice_game/libcockatrice/game/player/player_event_handler.cpp index 18f6573df..6588547da 100644 --- a/cockatrice/src/game/player/player_event_handler.cpp +++ b/libcockatrice_game/libcockatrice/game/player/player_event_handler.cpp @@ -1,14 +1,14 @@ #include "player_event_handler.h" -#include "../../game_graphics/board/card_item.h" -#include "../../game_graphics/zones/view_zone.h" #include "../abstract_game.h" #include "../board/arrow_data.h" #include "../board/card_list.h" +#include "../board/card_state.h" #include "../board/counter_state.h" #include "../zones/card_zone_logic.h" #include "../zones/hand_zone_logic.h" #include "../zones/pile_zone_logic.h" +#include "../zones/view_zone_logic.h" #include "libcockatrice/card/card_info.h" #include "libcockatrice/card/printing/exact_card.h" #include "libcockatrice/protocol/pb/card_attributes.pb.h" @@ -27,6 +27,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -49,6 +52,7 @@ #include #include #include +#include #include #include #include @@ -76,12 +80,12 @@ void PlayerEventHandler::eventShuffle(const Event_Shuffle &event) } // close all views that contain shuffled cards - for (ZoneViewZone *view : zone->getViews()) { - if (view != nullptr) { - int length = view->getLogic()->getCards().length(); + for (auto *viewLogic : zone->getViews()) { + if (viewLogic != nullptr) { + int length = viewLogic->getCards().length(); // we want to close empty views as well if (length == 0 || length > absStart) { // note this assumes views always start at the top of the library - view->close(); + viewLogic->requestClose(); } } else { qWarning() << zone->getName() << "of" << player->getPlayerInfo()->getName() << "holds empty zoneview!"; @@ -155,23 +159,29 @@ void PlayerEventHandler::eventCreateToken(const Event_CreateToken &event) } CardRef cardRef = {QString::fromStdString(event.card_name()), QString::fromStdString(event.card_provider_id())}; - CardItem *card = new CardItem(player, nullptr, cardRef, event.card_id()); // use db PT if not provided in event and not face-down - if (!QString::fromStdString(event.pt()).isEmpty()) { - card->setPT(QString::fromStdString(event.pt())); - } else if (!event.face_down()) { - ExactCard dbCard = card->getCard(); + QString pt = QString::fromStdString(event.pt()); + if (pt.isEmpty() && !event.face_down()) { + ExactCard dbCard = CardDatabaseManager::query()->getCard(cardRef); if (dbCard) { - card->setPT(dbCard.getInfo().getPowTough()); + pt = dbCard.getInfo().getPowTough(); } } - card->setColor(QString::fromStdString(event.color())); - card->setAnnotation(QString::fromStdString(event.annotation())); - card->setDestroyOnZoneChange(event.destroy_on_zone_change()); - card->setFaceDown(event.face_down()); - emit logCreateToken(player, card->getName(), card->getPT(), card->getFaceDown()); - zone->addCard(card, true, event.x(), event.y()); + emit logCreateToken(player, cardRef.name, pt, event.face_down()); + + ServerInfo_Card cardInfo; + cardInfo.set_id(event.card_id()); + cardInfo.set_name(event.card_name()); + cardInfo.set_provider_id(event.card_provider_id()); + cardInfo.set_pt(pt.toStdString()); + cardInfo.set_color(event.color()); + cardInfo.set_annotation(event.annotation()); + cardInfo.set_destroy_on_zone_change(event.destroy_on_zone_change()); + cardInfo.set_face_down(event.face_down()); + cardInfo.set_x(event.x()); + cardInfo.set_y(event.y()); + emit zone->createCardRequested(cardInfo, true); } void PlayerEventHandler::eventSetCardAttr(const Event_SetCardAttr &event, @@ -193,7 +203,7 @@ void PlayerEventHandler::eventSetCardAttr(const Event_SetCardAttr &event, emit logSetTapped(player, nullptr, event.attr_value() == "1"); } } else { - CardItem *card = zone->getCard(event.card_id()); + CardState *card = zone->getCard(event.card_id()); if (!card) { qWarning() << "PlayerEventHandler::eventSetCardAttr: card id=" << event.card_id() << "not found"; return; @@ -203,7 +213,7 @@ void PlayerEventHandler::eventSetCardAttr(const Event_SetCardAttr &event, } void PlayerEventHandler::setCardAttrHelper(const GameEventContext &context, - CardItem *card, + CardState *card, CardAttribute attribute, const QString &avalue, bool allCards, @@ -264,7 +274,7 @@ void PlayerEventHandler::eventSetCardCounter(const Event_SetCardCounter &event) return; } - CardItem *card = zone->getCard(event.card_id()); + CardState *card = zone->getCard(event.card_id()); if (!card) { return; } @@ -341,13 +351,13 @@ void PlayerEventHandler::eventMoveCard(const Event_MoveCard &event, const GameEv if (x == -1) { x = 0; } - CardItem *card = startZone->takeCard(position, event.card_id(), startZone != targetZone); + CardState *card = startZone->takeCard(position, event.card_id(), startZone != targetZone); if (card == nullptr) { return; } - if (startZone != targetZone) { - card->deleteCardInfoPopup(); - } + const bool zoneChanged = startZone != targetZone; + const bool ownerChanged = zoneChanged && startZone->getPlayer() != targetZone->getPlayer(); + emit cardViewRefreshRequested(card, zoneChanged, ownerChanged ? targetZone->getPlayer() : nullptr); if (event.has_card_name()) { QString name = QString::fromStdString(event.card_name()); QString providerId = @@ -356,27 +366,19 @@ void PlayerEventHandler::eventMoveCard(const Event_MoveCard &event, const GameEv } if (card->getAttachedTo() && (startZone != targetZone)) { - CardItem *parentCard = card->getAttachedTo(); + CardState *parentCard = card->getAttachedTo(); + // CardState::setAttachedTo removes the card from the old parent's attachedCards list and emits the zone events. card->setAttachedTo(nullptr); parentCard->getZone()->reorganizeCards(); } - card->deleteDragItem(); - card->setId(event.new_card_id()); card->setFaceDown(event.face_down()); if (startZone != targetZone) { - card->setBeingPointedAt(false); - card->setHovered(false); - - const QList &attachedCards = card->getAttachedCards(); + const QList &attachedCards = card->getAttachedCards(); for (auto attachedCard : attachedCards) { emit targetZone->cardAdded(attachedCard); } - - if (startZone->getPlayer() != targetZone->getPlayer()) { - card->setOwner(targetZone->getPlayer()); - } } // The log event has to be sent before the card is added to the target zone @@ -404,7 +406,7 @@ void PlayerEventHandler::eventFlipCard(const Event_FlipCard &event) if (!zone) { return; } - CardItem *card = zone->getCard(event.card_id()); + CardState *card = zone->getCard(event.card_id()); if (!card) { return; } @@ -427,20 +429,28 @@ void PlayerEventHandler::eventDestroyCard(const Event_DestroyCard &event) return; } - CardItem *card = zone->getCard(event.card_id()); + CardState *card = zone->getCard(event.card_id()); if (!card) { return; } - QList attachedCards = card->getAttachedCards(); + QList attachedCards = card->getAttachedCards(); // This list is always empty except for buggy server implementations. for (auto &attachedCard : attachedCards) { + card->removeAttachedCard(attachedCard); + attachedCard->setGridPoint(QPoint(-1, attachedCard->getGridPoint().y())); attachedCard->setAttachedTo(nullptr); + if (attachedCard->getZone() == nullptr) { + attachedCard->deleteView(); + } else { + emit attachedCard->getZone()->cardAdded(attachedCard); + attachedCard->getZone()->reorganizeCards(); + } } emit logDestroyCard(player, card->getName()); zone->takeCard(-1, event.card_id(), true); - card->deleteLater(); + card->deleteView(); } void PlayerEventHandler::eventAttachCard(const Event_AttachCard &event) @@ -448,7 +458,7 @@ void PlayerEventHandler::eventAttachCard(const Event_AttachCard &event) const QMap &playerList = player->getGame()->getPlayerManager()->getPlayers(); PlayerLogic *targetPlayer = nullptr; CardZoneLogic *targetZone = nullptr; - CardItem *targetCard = nullptr; + CardState *targetCard = nullptr; if (event.has_target_player_id()) { targetPlayer = playerList.value(event.target_player_id(), 0); if (targetPlayer) { @@ -464,12 +474,12 @@ void PlayerEventHandler::eventAttachCard(const Event_AttachCard &event) return; } - CardItem *startCard = startZone->getCard(event.card_id()); + CardState *startCard = startZone->getCard(event.card_id()); if (!startCard) { return; } - CardItem *oldParent = startCard->getAttachedTo(); + CardState *oldParent = startCard->getAttachedTo(); startCard->setAttachedTo(targetCard); @@ -498,7 +508,7 @@ void PlayerEventHandler::eventDrawCards(const Event_DrawCards &event) if (listSize) { for (int i = 0; i < listSize; ++i) { const ServerInfo_Card &cardInfo = event.cards(i); - CardItem *card = _deck->takeCard(0, cardInfo.id()); + CardState *card = _deck->takeCard(0, cardInfo.id()); QString cardName = QString::fromStdString(cardInfo.name()); QString providerId = QString::fromStdString(cardInfo.provider_id()); card->setCardRef({cardName, providerId}); @@ -546,7 +556,7 @@ void PlayerEventHandler::eventRevealCards(const Event_RevealCards &event, EventP for (const auto &card : cardList) { QString cardName = QString::fromStdString(card->name()); QString providerId = QString::fromStdString(card->provider_id()); - CardItem *cardItem = zone->getCard(card->id()); + CardState *cardItem = zone->getCard(card->id()); if (!cardItem) { continue; } diff --git a/cockatrice/src/game/player/player_event_handler.h b/libcockatrice_game/libcockatrice/game/player/player_event_handler.h similarity index 89% rename from cockatrice/src/game/player/player_event_handler.h rename to libcockatrice_game/libcockatrice/game/player/player_event_handler.h index 88254119b..d253deef0 100644 --- a/cockatrice/src/game/player/player_event_handler.h +++ b/libcockatrice_game/libcockatrice/game/player/player_event_handler.h @@ -27,7 +27,7 @@ #include #include -class CardItem; +class CardState; class CardZoneLogic; class PlayerLogic; class Event_AttachCard; @@ -61,7 +61,7 @@ template class QList; * Design notes: * - All event handlers assume events are authoritative and already validated * by the server. - * - Most handlers mutate both logical state (CardItem, CardZoneLogic, counters) + * - Most handlers mutate both logical state (CardState, CardZoneLogic, counters) * and visual/UI state (views, arrows, menus). * - Logging signals are emitted *after* or *during* state mutation, depending * on whether later mutations would invalidate log data. @@ -238,7 +238,7 @@ signals: void logUndoDraw(PlayerLogic *player, QString cardName); void logUndoDrawFailed(PlayerLogic *player); void logMoveCard(PlayerLogic *player, - CardItem *card, + CardState *card, CardZoneLogic *startZone, int oldX, CardZoneLogic *targetZone, @@ -248,11 +248,11 @@ signals: void logAttachCard(PlayerLogic *player, QString cardName, PlayerLogic *targetPlayer, QString targetCardName); void logUnattachCard(PlayerLogic *player, QString cardName); void logSetCardCounter(PlayerLogic *player, QString cardName, int counterId, int value, int oldValue); - void logSetTapped(PlayerLogic *player, CardItem *card, bool tapped); + void logSetTapped(PlayerLogic *player, CardState *card, bool tapped); void logSetCounter(PlayerLogic *player, QString counterName, int value, int oldValue); - void logSetDoesntUntap(PlayerLogic *player, CardItem *card, bool doesntUntap); - void logSetPT(PlayerLogic *player, CardItem *card, QString newPT); - void logSetAnnotation(PlayerLogic *player, CardItem *card, QString newAnnotation); + void logSetDoesntUntap(PlayerLogic *player, CardState *card, bool doesntUntap); + void logSetPT(PlayerLogic *player, CardState *card, QString newPT); + void logSetAnnotation(PlayerLogic *player, CardState *card, QString newAnnotation); void logDumpZone(PlayerLogic *player, CardZoneLogic *zone, int numberCards, bool isReversed = false); void logRevealCards(PlayerLogic *player, CardZoneLogic *zone, @@ -266,15 +266,23 @@ signals: void logAlwaysLookAtTopCard(PlayerLogic *player, CardZoneLogic *zone, bool reveal); /** @} */ - void cardZoneChanged(CardItem *card, bool sameZone); - void requestCardMenuUpdate(const CardItem *card); + void cardZoneChanged(CardState *card, bool sameZone); + void requestCardMenuUpdate(CardState *card); + /** + * @brief A card was just moved by the server and its view needs refreshing. + * + * @param card The card that moved. + * @param zoneChanged Whether the card crossed a zone boundary. + * @param newOwner The player the card now belongs to, or nullptr to leave the owner alone. + */ + void cardViewRefreshRequested(CardState *card, bool zoneChanged, PlayerLogic *newOwner); private: /** Owning player instance. */ PlayerLogic *player; void setCardAttrHelper(const GameEventContext &context, - CardItem *card, + CardState *card, CardAttribute attribute, const QString &avalue, bool allCards, diff --git a/cockatrice/src/game/player/player_info.cpp b/libcockatrice_game/libcockatrice/game/player/player_info.cpp similarity index 100% rename from cockatrice/src/game/player/player_info.cpp rename to libcockatrice_game/libcockatrice/game/player/player_info.cpp diff --git a/cockatrice/src/game/player/player_info.h b/libcockatrice_game/libcockatrice/game/player/player_info.h similarity index 100% rename from cockatrice/src/game/player/player_info.h rename to libcockatrice_game/libcockatrice/game/player/player_info.h diff --git a/cockatrice/src/game/player/player_logic.cpp b/libcockatrice_game/libcockatrice/game/player/player_logic.cpp similarity index 89% rename from cockatrice/src/game/player/player_logic.cpp rename to libcockatrice_game/libcockatrice/game/player/player_logic.cpp index b545b7088..96bd07164 100644 --- a/cockatrice/src/game/player/player_logic.cpp +++ b/libcockatrice_game/libcockatrice/game/player/player_logic.cpp @@ -1,8 +1,9 @@ #include "player_logic.h" -#include "../../game_graphics/board/card_item.h" #include "../abstract_game.h" #include "../board/arrow_data.h" +#include "../board/card_list.h" +#include "../board/card_state.h" #include "../board/counter_state.h" #include "../game_meta_info.h" #include "../zones/hand_zone_logic.h" @@ -33,10 +34,17 @@ #include #include -PlayerLogic::PlayerLogic(const ServerInfo_User &info, int _id, bool _local, bool _judge, AbstractGame *_parent) +PlayerLogic::PlayerLogic(const ServerInfo_User &info, + int _id, + bool _local, + bool _judge, + AbstractGame *_parent, + IInterfaceSettingsProvider *interfaceSettings, + ICardPreferenceProvider *cardPreferences) : QObject(_parent), game(_parent), playerInfo(new PlayerInfo(info, _id, _local, _judge)), - playerEventHandler(new PlayerEventHandler(this)), playerActions(new PlayerActions(this)), active(false), - conceded(false), zoneId(0), dialogSemaphore(false) + playerEventHandler(new PlayerEventHandler(this)), + playerActions(new PlayerActions(this, interfaceSettings, cardPreferences)), active(false), conceded(false), + zoneId(0), dialogSemaphore(false) { initializeZones(); } @@ -172,22 +180,20 @@ void PlayerLogic::processPlayerInfo(const ServerInfo_Player &info) const int cardListSize = zoneInfo.card_list_size(); if (!cardListSize) { for (int j = 0; j < zoneInfo.card_count(); ++j) { - zone->addCard(new CardItem(this), false, -1); + emit zone->createCardRequested(ServerInfo_Card(), false); } } else { for (int j = 0; j < cardListSize; ++j) { - const ServerInfo_Card &cardInfo = zoneInfo.card_list(j); - auto *card = new CardItem(this); - card->processCardInfo(cardInfo); + ServerInfo_Card cardInfo = zoneInfo.card_list(j); // Zones without coordinates (hand, piles, stack) preserve the order // they arrive in on the server in the positions of their cards list. // The x coordinate of such cards is always 0, so inserting at it // would reverse the list on reconnect. Append instead. - if (zoneInfo.with_coords()) { - zone->addCard(card, false, cardInfo.x(), cardInfo.y()); - } else { - zone->addCard(card, false, -1); + if (!zoneInfo.with_coords()) { + cardInfo.set_x(-1); + cardInfo.set_y(-1); } + emit zone->createCardRequested(cardInfo, false); } } if (zoneInfo.has_always_reveal_top_card()) { @@ -219,8 +225,8 @@ void PlayerLogic::processCardAttachment(const ServerInfo_Player &info) for (int j = 0; j < cardListSize; ++j) { const ServerInfo_Card &cardInfo = zoneInfo.card_list(j); if (cardInfo.has_attach_player_id()) { - CardItem *startCard = zone->getCard(cardInfo.id()); - CardItem *targetCard = + CardState *startCard = zone->getCard(cardInfo.id()); + CardState *targetCard = game->getCard(cardInfo.attach_player_id(), QString::fromStdString(cardInfo.attach_zone()), cardInfo.attach_card_id()); if (!targetCard) { @@ -239,19 +245,19 @@ void PlayerLogic::processCardAttachment(const ServerInfo_Player &info) } } -void PlayerLogic::addCard(CardItem *card) +void PlayerLogic::addCard(AbstractCardItem *card) { emit newCardAdded(card); } -void PlayerLogic::deleteCard(CardItem *card) +void PlayerLogic::deleteCard(CardState *card) { if (card == nullptr) { return; } else if (dialogSemaphore) { cardsToDelete.append(card); } else { - card->deleteLater(); + card->deleteView(); } } @@ -332,7 +338,7 @@ bool PlayerLogic::clearCardsToDelete() for (auto &i : cardsToDelete) { if (i != nullptr) { - i->deleteLater(); + i->deleteView(); } } cardsToDelete.clear(); diff --git a/cockatrice/src/game/player/player_logic.h b/libcockatrice_game/libcockatrice/game/player/player_logic.h similarity index 88% rename from cockatrice/src/game/player/player_logic.h rename to libcockatrice_game/libcockatrice/game/player/player_logic.h index 59da7cc32..d6c4f5187 100644 --- a/cockatrice/src/game/player/player_logic.h +++ b/libcockatrice_game/libcockatrice/game/player/player_logic.h @@ -7,6 +7,8 @@ #ifndef PLAYER_H #define PLAYER_H +#include "../board/arrow_data.h" +#include "../board/counter_state.h" #include "../zones/card_zone_logic.h" #include "../zones/hand_zone_logic.h" #include "../zones/pile_zone_logic.h" @@ -17,12 +19,16 @@ #include "libcockatrice/utility/playmat_params.h" #include -#include #include #include #include #include #include +#include +#include +#include +#include +#include #include #include @@ -31,7 +37,6 @@ // signatures, which the moc-generated code needs to be complete. #include -class CardItem; class CounterState; class QWidget; class ServerInfo_PlayerProperties; @@ -42,6 +47,8 @@ template class QSharedPointer; inline Q_LOGGING_CATEGORY(PlayerLog, "player"); class AbstractCardItem; class AbstractGame; +class ICardPreferenceProvider; +class IInterfaceSettingsProvider; class PlayerInfo; class PlayerEventHandler; class PlayerActions; @@ -67,7 +74,7 @@ signals: /** @brief Emitted when the remote playmat (card/params) is updated from player properties. */ void playmatChanged(); void newCardAdded(AbstractCardItem *card); - void requestCardMenuUpdate(const CardItem *card); + void requestCardMenuUpdate(CardState *card); void counterAdded(CounterState *state); void counterRemoved(int counterId); void rearrangeCounters(); @@ -87,7 +94,13 @@ public slots: void onRequestZoneViewToggle(const QString &zoneName, int numberCards, bool isReversed); public: - PlayerLogic(const ServerInfo_User &info, int _id, bool _local, bool _judge, AbstractGame *_parent); + PlayerLogic(const ServerInfo_User &info, + int _id, + bool _local, + bool _judge, + AbstractGame *_parent, + IInterfaceSettingsProvider *interfaceSettings = nullptr, + ICardPreferenceProvider *cardPreferences = nullptr); ~PlayerLogic() override; void initializeZones(); @@ -97,8 +110,8 @@ public: void processPlayerInfo(const ServerInfo_Player &info); void processCardAttachment(const ServerInfo_Player &info); - void addCard(CardItem *c); - void deleteCard(CardItem *c); + void addCard(AbstractCardItem *c); + void deleteCard(CardState *c); bool clearCardsToDelete(); @@ -250,7 +263,7 @@ private: QMap counters; bool dialogSemaphore; - QList cardsToDelete; + QList cardsToDelete; // Playmat from player properties (for opponent display) CardRef remotePlaymatCard; @@ -258,15 +271,4 @@ private: bool hasRemotePlaymat = false; }; -class AnnotationDialog : public QInputDialog -{ - Q_OBJECT - void keyPressEvent(QKeyEvent *e) override; - -public: - explicit AnnotationDialog(QWidget *parent = nullptr) : QInputDialog(parent) - { - } -}; - #endif diff --git a/cockatrice/src/game/player/player_manager.cpp b/libcockatrice_game/libcockatrice/game/player/player_manager.cpp similarity index 86% rename from cockatrice/src/game/player/player_manager.cpp rename to libcockatrice_game/libcockatrice/game/player/player_manager.cpp index 4730805ad..284d21f68 100644 --- a/cockatrice/src/game/player/player_manager.cpp +++ b/libcockatrice_game/libcockatrice/game/player/player_manager.cpp @@ -12,9 +12,12 @@ PlayerManager::PlayerManager(AbstractGame *_game, int _localPlayerId, bool _localPlayerIsJudge, - bool localPlayerIsSpectator) + bool localPlayerIsSpectator, + IInterfaceSettingsProvider *_interfaceSettings, + ICardPreferenceProvider *_cardPreferences) : QObject(_game), game(_game), players(QMap()), localPlayerId(_localPlayerId), - localPlayerIsJudge(_localPlayerIsJudge), localPlayerIsSpectator(localPlayerIsSpectator) + localPlayerIsJudge(_localPlayerIsJudge), localPlayerIsSpectator(localPlayerIsSpectator), + interfaceSettings(_interfaceSettings), cardPreferences(_cardPreferences) { } @@ -52,7 +55,7 @@ bool PlayerManager::isLocalPlayer(int playerId) PlayerLogic *PlayerManager::addPlayer(int playerId, const ServerInfo_User &info) { auto *newPlayer = new PlayerLogic(info, playerId, isLocalPlayer(playerId) || game->getGameState()->getIsLocalGame(), - isJudge(), getGame()); + isJudge(), getGame(), interfaceSettings, cardPreferences); connect(newPlayer, &PlayerLogic::concededChanged, this, &PlayerManager::onPlayerConceded); players.insert(playerId, newPlayer); emit playerAdded(newPlayer); diff --git a/cockatrice/src/game/player/player_manager.h b/libcockatrice_game/libcockatrice/game/player/player_manager.h similarity index 84% rename from cockatrice/src/game/player/player_manager.h rename to libcockatrice_game/libcockatrice/game/player/player_manager.h index 7a9f7645b..c95bbbb96 100644 --- a/cockatrice/src/game/player/player_manager.h +++ b/libcockatrice_game/libcockatrice/game/player/player_manager.h @@ -16,6 +16,8 @@ #include class AbstractGame; +class ICardPreferenceProvider; +class IInterfaceSettingsProvider; class PlayerLogic; class PlayerManager : public QObject @@ -23,7 +25,12 @@ class PlayerManager : public QObject Q_OBJECT public: - PlayerManager(AbstractGame *_game, int _localPlayerId, bool _localPlayerIsJudge, bool localPlayerIsSpectator); + PlayerManager(AbstractGame *_game, + int _localPlayerId, + bool _localPlayerIsJudge, + bool localPlayerIsSpectator, + IInterfaceSettingsProvider *interfaceSettings = nullptr, + ICardPreferenceProvider *cardPreferences = nullptr); AbstractGame *game; QMap players; @@ -31,6 +38,9 @@ public: bool localPlayerIsJudge; bool localPlayerIsSpectator; QMap spectators; + // Borrowed settings providers forwarded to each PlayerLogic (owned by the application). + IInterfaceSettingsProvider *interfaceSettings; + ICardPreferenceProvider *cardPreferences; [[nodiscard]] bool isSpectator() const { diff --git a/libcockatrice_game/libcockatrice/game/player/token_info.h b/libcockatrice_game/libcockatrice/game/player/token_info.h new file mode 100644 index 000000000..99869f33e --- /dev/null +++ b/libcockatrice_game/libcockatrice/game/player/token_info.h @@ -0,0 +1,23 @@ +/** + * @file token_info.h + * @ingroup GameLogicPlayers + */ +//! \todo Document this file. + +#ifndef COCKATRICE_TOKEN_INFO_H +#define COCKATRICE_TOKEN_INFO_H + +#include + +struct TokenInfo +{ + QString name; + QString color; + QString pt; + QString annotation; + bool destroy = true; + bool faceDown = false; + QString providerId; +}; + +#endif // COCKATRICE_TOKEN_INFO_H diff --git a/cockatrice/src/game/replay.cpp b/libcockatrice_game/libcockatrice/game/replay.cpp similarity index 51% rename from cockatrice/src/game/replay.cpp rename to libcockatrice_game/libcockatrice/game/replay.cpp index 4ab4a6321..4d41a12ea 100644 --- a/cockatrice/src/game/replay.cpp +++ b/libcockatrice_game/libcockatrice/game/replay.cpp @@ -9,10 +9,15 @@ class GameReplay; class QObject; -Replay::Replay(QObject *_parent, const GameReplay *_replay, bool isLocalGame) : AbstractGame(_parent) +Replay::Replay(QObject *_parent, + const GameReplay *_replay, + bool isLocalGame, + IInterfaceSettingsProvider *interfaceSettings, + ICardPreferenceProvider *cardPreferences) + : AbstractGame(_parent) { gameState = new GameState(this, 0, -1, isLocalGame, {}, false, false, -1, false); connect(gameMetaInfo, &GameMetaInfo::startedChanged, gameState, &GameState::onStartedChanged); - playerManager = new PlayerManager(this, -1, false, true); + playerManager = new PlayerManager(this, -1, false, true, interfaceSettings, cardPreferences); loadReplay(_replay); } diff --git a/libcockatrice_game/libcockatrice/game/replay.h b/libcockatrice_game/libcockatrice/game/replay.h new file mode 100644 index 000000000..f8f82a70d --- /dev/null +++ b/libcockatrice_game/libcockatrice/game/replay.h @@ -0,0 +1,32 @@ +/** + * @file replay.h + * @ingroup GameLogic + * @ingroup Replay + */ +//! \todo Document this file. + +#ifndef COCKATRICE_REPLAY_H +#define COCKATRICE_REPLAY_H + +#include "abstract_game.h" + +#include + +class GameReplay; +class QObject; +class ICardPreferenceProvider; +class IInterfaceSettingsProvider; + +class Replay : public AbstractGame +{ + Q_OBJECT + +public: + explicit Replay(QObject *_parent, + const GameReplay *_replay, + bool isLocalGame, + IInterfaceSettingsProvider *interfaceSettings = nullptr, + ICardPreferenceProvider *cardPreferences = nullptr); +}; + +#endif // COCKATRICE_REPLAY_H diff --git a/cockatrice/src/client/translation.h b/libcockatrice_game/libcockatrice/game/translation.h similarity index 100% rename from cockatrice/src/client/translation.h rename to libcockatrice_game/libcockatrice/game/translation.h diff --git a/cockatrice/src/game/zones/card_zone_algorithms.h b/libcockatrice_game/libcockatrice/game/zones/card_zone_algorithms.h similarity index 81% rename from cockatrice/src/game/zones/card_zone_algorithms.h rename to libcockatrice_game/libcockatrice/game/zones/card_zone_algorithms.h index 118a7d29d..35e96b86c 100644 --- a/cockatrice/src/game/zones/card_zone_algorithms.h +++ b/libcockatrice_game/libcockatrice/game/zones/card_zone_algorithms.h @@ -5,9 +5,9 @@ namespace CardZoneAlgorithms { /** - * Shared insertion logic for zones where cards become visible on add and follow - * the standard pattern: clamp index, insert, clear identity if contents unknown, - * reset state, show card. + * Shared insertion logic for zones where cards follow the standard pattern: + * clamp index, insert, clear identity if contents unknown, reset state. + * Visibility and parenting are handled by the graphics zone's onCardAdded(). * * Zones with different post-add behavior (signal connections, positional resets, * hidden cards, or coordinate-based placement) should NOT use this — implement @@ -18,8 +18,7 @@ namespace CardZoneAlgorithms * * @tparam CardList Must provide: size() -> int, insert(int, CardType*), * getContentsKnown() -> bool - * @tparam CardType Must provide: setId(int), setCardRef(CardRefType), - * resetState(bool), setVisible(bool) + * @tparam CardType Must provide: setId(int), setCardRef(CardRefType), resetState(bool) * @param keepAnnotations Forwarded to card->resetState(). Stack-like zones preserve * annotations across zone transitions; hand-like zones clear them. */ @@ -37,7 +36,6 @@ void addCardToList(CardList &cards, CardType *card, int x, bool keepAnnotations) } card->resetState(keepAnnotations); - card->setVisible(true); } } // namespace CardZoneAlgorithms diff --git a/cockatrice/src/game/zones/card_zone_logic.cpp b/libcockatrice_game/libcockatrice/game/zones/card_zone_logic.cpp similarity index 79% rename from cockatrice/src/game/zones/card_zone_logic.cpp rename to libcockatrice_game/libcockatrice/game/zones/card_zone_logic.cpp index d997ab9ad..a160dcc1d 100644 --- a/cockatrice/src/game/zones/card_zone_logic.cpp +++ b/libcockatrice_game/libcockatrice/game/zones/card_zone_logic.cpp @@ -1,13 +1,11 @@ #include "card_zone_logic.h" -#include "../../client/translation.h" -#include "../../game_graphics/board/card_item.h" -#include "../../game_graphics/zones/card_zone.h" -#include "../../game_graphics/zones/view_zone.h" #include "../board/card_list.h" +#include "../board/card_state.h" #include "../player/player_actions.h" #include "../player/player_info.h" #include "../player/player_logic.h" +#include "../translation.h" #include "libcockatrice/card/database/card_database.h" #include "view_zone_logic.h" @@ -44,19 +42,24 @@ CardZoneLogic::CardZoneLogic(PlayerLogic *_player, &CardZoneLogic::refreshCardInfos); } -void CardZoneLogic::addCard(CardItem *card, const bool reorganize, const int x, const int y) +void CardZoneLogic::addCard(CardState *card, const bool reorganize, const int x, const int y) { if (!card) { - qCWarning(CardZoneLog) << "CardZoneLogic::addCard() card is null; this shouldn't normally happen"; + qCWarning(CardZoneLogicLog) << "CardZoneLogic::addCard() card is null; this shouldn't normally happen"; return; } - for (auto *view : views) { - if (qobject_cast(view->getLogic())->prepareAddCard(x)) { - auto copy = new CardItem(player, nullptr, card->getCardRef(), card->getId()); - copy->setFaceDown(card->getFaceDown()); - - view->getLogic()->addCard(copy, reorganize, x, y); + for (auto *viewLogic : views) { + if (viewLogic->prepareAddCard(x)) { + ServerInfo_Card cardInfo; + const CardRef &cardRef = card->getCardRef(); + cardInfo.set_name(cardRef.name.toStdString()); + cardInfo.set_provider_id(cardRef.providerId.toStdString()); + cardInfo.set_id(card->getId()); + cardInfo.set_face_down(card->getFaceDown()); + cardInfo.set_x(x); + cardInfo.set_y(y); + emit viewLogic->createCardRequested(cardInfo, reorganize); } } @@ -71,7 +74,7 @@ void CardZoneLogic::addCard(CardItem *card, const bool reorganize, const int x, emit cardCountChanged(); } -CardItem *CardZoneLogic::takeCard(int position, int cardId, bool toNewZone) +CardState *CardZoneLogic::takeCard(int position, int cardId, bool toNewZone) { if (position == -1) { // position == -1 means either that the zone is indexed by card id @@ -90,11 +93,11 @@ CardItem *CardZoneLogic::takeCard(int position, int cardId, bool toNewZone) return nullptr; } - for (auto *view : views) { - qobject_cast(view->getLogic())->removeCard(position, toNewZone); + for (auto *viewLogic : views) { + viewLogic->removeCard(position, toNewZone); } - CardItem *c = cards.takeAt(position); + CardState *c = cards.takeAt(position); c->setId(cardId); @@ -103,11 +106,11 @@ CardItem *CardZoneLogic::takeCard(int position, int cardId, bool toNewZone) return c; } -CardItem *CardZoneLogic::getCard(int cardId) +CardState *CardZoneLogic::getCard(int cardId) { - CardItem *c = cards.findCard(cardId); + CardState *c = cards.findCard(cardId); if (!c) { - qCWarning(CardZoneLog) << "CardZoneLogic::getCard: card id=" << cardId << "not found"; + qCWarning(CardZoneLogicLog) << "CardZoneLogic::getCard: card id=" << cardId << "not found"; return nullptr; } // If the card's id is -1, this zone is invisible, @@ -119,10 +122,10 @@ CardItem *CardZoneLogic::getCard(int cardId) return c; } -void CardZoneLogic::removeCard(CardItem *card) +void CardZoneLogic::removeCard(CardState *card) { if (!card) { - qCWarning(CardZoneLog) << "CardZoneLogic::removeCard: card is null, this shouldn't normally happen"; + qCWarning(CardZoneLogicLog) << "CardZoneLogic::removeCard: card is null, this shouldn't normally happen"; return; } @@ -135,8 +138,8 @@ void CardZoneLogic::removeCard(CardItem *card) void CardZoneLogic::refreshCardInfos() { - for (const auto &cardItem : cards) { - cardItem->refreshCardInfo(); + for (const auto &cardState : cards) { + cardState->refreshCardInfo(); } } @@ -168,17 +171,17 @@ void CardZoneLogic::clearContents() const CardList toClear = cards; // Detach and notify attached cards and zones *before* deleting anything. - for (CardItem *card : toClear) { + for (CardState *card : toClear) { // If an incorrectly implemented server doesn't return attached cards to whom they belong before dropping a // player, we have to return them to avoid a crash. - const QList &attachedCards = card->getAttachedCards(); - for (CardItem *attachedCard : attachedCards) { + const QList &attachedCards = card->getAttachedCards(); + for (CardState *attachedCard : attachedCards) { emit attachedCard->getZone()->cardAdded(attachedCard); } } // Now request deletions after all manipulations are done. - for (CardItem *card : toClear) { + for (CardState *card : toClear) { player->deleteCard(card); } diff --git a/cockatrice/src/game/zones/card_zone_logic.h b/libcockatrice_game/libcockatrice/game/zones/card_zone_logic.h similarity index 73% rename from cockatrice/src/game/zones/card_zone_logic.h rename to libcockatrice_game/libcockatrice/game/zones/card_zone_logic.h index 2e232b258..d3cd9121f 100644 --- a/cockatrice/src/game/zones/card_zone_logic.h +++ b/libcockatrice_game/libcockatrice/game/zones/card_zone_logic.h @@ -7,28 +7,30 @@ #ifndef COCKATRICE_CARD_ZONE_LOGIC_H #define COCKATRICE_CARD_ZONE_LOGIC_H -#include "../../client/translation.h" #include "../board/card_list.h" +#include "../translation.h" #include #include #include #include +#include #include -class CardItem; - inline Q_LOGGING_CATEGORY(CardZoneLogicLog, "card_zone_logic"); class PlayerLogic; -class ZoneViewZone; +class ZoneViewZoneLogic; +class CardDragItem; class CardZoneLogic : public QObject { Q_OBJECT signals: - void cardAdded(CardItem *addedCard); + void cardAdded(CardState *addedCard); + /** @brief Asks the graphics side to build the card item for @p cardInfo and add it to this zone. */ + void createCardRequested(const ServerInfo_Card &cardInfo, bool reorganize); void cardCountChanged(); void reorganizeCards(); void updateGraphics(); @@ -43,16 +45,17 @@ public: bool _contentsKnown, QObject *parent = nullptr); - void addCard(CardItem *card, bool reorganize, int x, int y = -1); + void addCard(CardState *card, bool reorganize, int x, int y = -1); // getCard() finds a card by id. - CardItem *getCard(int cardId); - void removeCard(CardItem *card); + CardState *getCard(int cardId); + void removeCard(CardState *card); // takeCard() finds a card by position and removes it from the zone and from all of its views. - virtual CardItem *takeCard(int position, int cardId, bool canResize = true); + virtual CardState *takeCard(int position, int cardId, bool canResize = true); - void rawInsertCard(CardItem *card, int index) + void rawInsertCard(CardState *card, int index) { cards.insert(index, card); + emit cardCountChanged(); } [[nodiscard]] const CardList &getCards() const @@ -63,6 +66,7 @@ public: void sortCards(const QList &options) { cards.sortBy(options); + emit cardCountChanged(); } [[nodiscard]] QString getName() const { @@ -77,7 +81,7 @@ public: { return cards.getContentsKnown(); } - QList &getViews() + QList &getViews() { return views; } @@ -109,12 +113,12 @@ protected: PlayerLogic *player; QString name; CardList cards; - QList views; + QList views; bool hasCardAttr; bool isShufflable; bool alwaysRevealTopCard; - virtual void addCardImpl(CardItem *card, int x, int y) = 0; + virtual void addCardImpl(CardState *card, int x, int y) = 0; }; #endif // COCKATRICE_CARD_ZONE_LOGIC_H diff --git a/cockatrice/src/game/zones/hand_zone_logic.cpp b/libcockatrice_game/libcockatrice/game/zones/hand_zone_logic.cpp similarity index 82% rename from cockatrice/src/game/zones/hand_zone_logic.cpp rename to libcockatrice_game/libcockatrice/game/zones/hand_zone_logic.cpp index cea17d031..1d3f54929 100644 --- a/cockatrice/src/game/zones/hand_zone_logic.cpp +++ b/libcockatrice_game/libcockatrice/game/zones/hand_zone_logic.cpp @@ -1,6 +1,6 @@ #include "hand_zone_logic.h" -#include "../../game_graphics/board/card_item.h" +#include "../board/card_state.h" #include "card_zone_algorithms.h" class PlayerLogic; @@ -16,7 +16,7 @@ HandZoneLogic::HandZoneLogic(PlayerLogic *_player, { } -void HandZoneLogic::addCardImpl(CardItem *card, int x, int /*y*/) +void HandZoneLogic::addCardImpl(CardState *card, int x, int /*y*/) { CardZoneAlgorithms::addCardToList(cards, card, x, false); } diff --git a/cockatrice/src/game/zones/hand_zone_logic.h b/libcockatrice_game/libcockatrice/game/zones/hand_zone_logic.h similarity index 91% rename from cockatrice/src/game/zones/hand_zone_logic.h rename to libcockatrice_game/libcockatrice/game/zones/hand_zone_logic.h index c7d63bc0d..f8756eafd 100644 --- a/cockatrice/src/game/zones/hand_zone_logic.h +++ b/libcockatrice_game/libcockatrice/game/zones/hand_zone_logic.h @@ -26,7 +26,7 @@ public: QObject *parent = nullptr); protected: - void addCardImpl(CardItem *card, int x, int y) override; + void addCardImpl(CardState *card, int x, int y) override; }; #endif // COCKATRICE_HAND_ZONE_LOGIC_H diff --git a/cockatrice/src/game/zones/pile_zone_logic.cpp b/libcockatrice_game/libcockatrice/game/zones/pile_zone_logic.cpp similarity index 70% rename from cockatrice/src/game/zones/pile_zone_logic.cpp rename to libcockatrice_game/libcockatrice/game/zones/pile_zone_logic.cpp index 40af25cc5..e33d8fe79 100644 --- a/cockatrice/src/game/zones/pile_zone_logic.cpp +++ b/libcockatrice_game/libcockatrice/game/zones/pile_zone_logic.cpp @@ -1,10 +1,6 @@ #include "pile_zone_logic.h" -#include "../../game_graphics/board/card_item.h" -#include "../board/card_list.h" - -class PlayerLogic; -class QObject; +#include "../board/card_state.h" PileZoneLogic::PileZoneLogic(PlayerLogic *_player, const QString &_name, @@ -16,15 +12,14 @@ PileZoneLogic::PileZoneLogic(PlayerLogic *_player, { } -void PileZoneLogic::addCardImpl(CardItem *card, int x, int /*y*/) +void PileZoneLogic::addCardImpl(CardState *card, int x, int /*y*/) { - connect(card, &CardItem::sigPixmapUpdated, this, &PileZoneLogic::callUpdate); + connect(card, &CardState::cardPixmapUpdated, this, &PileZoneLogic::callUpdate); // if x is negative set it to add at end if (x < 0 || x >= cards.size()) { x = cards.size(); } cards.insert(x, card); - card->setPos(0, 0); if (!contentsKnown()) { card->setCardRef({}); card->setId(-1); @@ -33,6 +28,5 @@ void PileZoneLogic::addCardImpl(CardItem *card, int x, int /*y*/) cards.at(x + 1)->setCardRef({}); } } - card->setVisible(false); - card->resetState(); + card->resetState(false); } \ No newline at end of file diff --git a/cockatrice/src/game/zones/pile_zone_logic.h b/libcockatrice_game/libcockatrice/game/zones/pile_zone_logic.h similarity index 91% rename from cockatrice/src/game/zones/pile_zone_logic.h rename to libcockatrice_game/libcockatrice/game/zones/pile_zone_logic.h index ed6841b75..662b0377f 100644 --- a/cockatrice/src/game/zones/pile_zone_logic.h +++ b/libcockatrice_game/libcockatrice/game/zones/pile_zone_logic.h @@ -31,7 +31,7 @@ public: QObject *parent = nullptr); protected: - void addCardImpl(CardItem *card, int x, int y) override; + void addCardImpl(CardState *card, int x, int y) override; }; #endif // COCKATRICE_PILE_ZONE_LOGIC_H diff --git a/cockatrice/src/game/zones/stack_zone_logic.cpp b/libcockatrice_game/libcockatrice/game/zones/stack_zone_logic.cpp similarity index 83% rename from cockatrice/src/game/zones/stack_zone_logic.cpp rename to libcockatrice_game/libcockatrice/game/zones/stack_zone_logic.cpp index 454274971..ccf0d32f3 100644 --- a/cockatrice/src/game/zones/stack_zone_logic.cpp +++ b/libcockatrice_game/libcockatrice/game/zones/stack_zone_logic.cpp @@ -1,6 +1,6 @@ #include "stack_zone_logic.h" -#include "../../game_graphics/board/card_item.h" +#include "../board/card_state.h" #include "card_zone_algorithms.h" class PlayerLogic; @@ -16,7 +16,7 @@ StackZoneLogic::StackZoneLogic(PlayerLogic *_player, { } -void StackZoneLogic::addCardImpl(CardItem *card, int x, int /*y*/) +void StackZoneLogic::addCardImpl(CardState *card, int x, int /*y*/) { CardZoneAlgorithms::addCardToList(cards, card, x, true); } diff --git a/cockatrice/src/game/zones/stack_zone_logic.h b/libcockatrice_game/libcockatrice/game/zones/stack_zone_logic.h similarity index 91% rename from cockatrice/src/game/zones/stack_zone_logic.h rename to libcockatrice_game/libcockatrice/game/zones/stack_zone_logic.h index 657d44ecf..ac6c4617c 100644 --- a/cockatrice/src/game/zones/stack_zone_logic.h +++ b/libcockatrice_game/libcockatrice/game/zones/stack_zone_logic.h @@ -26,7 +26,7 @@ public: QObject *parent = nullptr); protected: - void addCardImpl(CardItem *card, int x, int y) override; + void addCardImpl(CardState *card, int x, int y) override; }; #endif // COCKATRICE_STACK_ZONE_LOGIC_H diff --git a/cockatrice/src/game/zones/table_zone_logic.cpp b/libcockatrice_game/libcockatrice/game/zones/table_zone_logic.cpp similarity index 60% rename from cockatrice/src/game/zones/table_zone_logic.cpp rename to libcockatrice_game/libcockatrice/game/zones/table_zone_logic.cpp index 07e4a759b..834794268 100644 --- a/cockatrice/src/game/zones/table_zone_logic.cpp +++ b/libcockatrice_game/libcockatrice/game/zones/table_zone_logic.cpp @@ -1,14 +1,9 @@ #include "table_zone_logic.h" -#include "../../game_graphics/board/card_item.h" -#include "../board/card_list.h" -#include "libcockatrice/card/card_info.h" +#include "../board/card_state.h" #include -class PlayerLogic; -class QObject; - TableZoneLogic::TableZoneLogic(PlayerLogic *_player, const QString &_name, bool _hasCardAttr, @@ -19,7 +14,7 @@ TableZoneLogic::TableZoneLogic(PlayerLogic *_player, { } -void TableZoneLogic::addCardImpl(CardItem *card, int _x, int _y) +void TableZoneLogic::addCardImpl(CardState *card, int _x, int _y) { cards.append(card); if (!card->getFaceDown() && card->getPT().isEmpty()) { @@ -29,15 +24,33 @@ void TableZoneLogic::addCardImpl(CardItem *card, int _x, int _y) card->setDoesntUntap(true); } card->setGridPoint(QPoint(_x, _y)); - card->setVisible(true); } -CardItem *TableZoneLogic::takeCard(int position, int cardId, bool toNewZone) +CardState *TableZoneLogic::takeCard(int position, int cardId, bool toNewZone) { - CardItem *result = CardZoneLogic::takeCard(position, cardId); + CardState *result = CardZoneLogic::takeCard(position, cardId); if (toNewZone) { emit contentSizeChanged(); } return result; -} \ No newline at end of file +} + +int TableZoneLogic::clampValidTableRow(const int row) +{ + if (row < 0) { + return 0; + } + if (row >= ROW_COUNT) { + return ROW_COUNT - 1; + } + return row; +} + +int TableZoneLogic::tableRowToGridY(int tableRow) +{ + if (tableRow > 2) { + tableRow = 1; + } + return clampValidTableRow(2 - tableRow); +} diff --git a/cockatrice/src/game/zones/table_zone_logic.h b/libcockatrice_game/libcockatrice/game/zones/table_zone_logic.h similarity index 57% rename from cockatrice/src/game/zones/table_zone_logic.h rename to libcockatrice_game/libcockatrice/game/zones/table_zone_logic.h index 8078bfd05..c828d8b91 100644 --- a/cockatrice/src/game/zones/table_zone_logic.h +++ b/libcockatrice_game/libcockatrice/game/zones/table_zone_logic.h @@ -29,8 +29,23 @@ public: bool _contentsKnown, QObject *parent = nullptr); + /** @brief Number of rows in the table zone grid (0=creatures, 1=noncreatures, 2=lands). */ + static const int ROW_COUNT = 3; + + /** + * Clamps a grid row index into [0, ROW_COUNT - 1]. + */ + static int clampValidTableRow(const int row); + + /** + * Converts a card's logical table row (0=creatures, 1=noncreatures, 2=lands) + * to the corresponding grid Y coordinate. Cards with tableRow > 2 (e.g., + * instants/sorceries) default to the noncreatures row. + */ + static int tableRowToGridY(int tableRow); + protected: - void addCardImpl(CardItem *card, int x, int y) override; + void addCardImpl(CardState *card, int x, int y) override; /** * @brief Removes a card from view. @@ -38,9 +53,9 @@ protected: * @param position card position * @param cardId id of card to take * @param toNewZone Whether the destination of the card is not the same as the starting zone. Defaults to true - * @return CardItem that has been removed + * @return CardState that has been removed */ - CardItem *takeCard(int position, int cardId, bool toNewZone = true) override; + CardState *takeCard(int position, int cardId, bool toNewZone = true) override; }; #endif // COCKATRICE_TABLE_ZONE_LOGIC_H diff --git a/cockatrice/src/game/zones/view_zone_logic.cpp b/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.cpp similarity index 81% rename from cockatrice/src/game/zones/view_zone_logic.cpp rename to libcockatrice_game/libcockatrice/game/zones/view_zone_logic.cpp index eb3b7715f..db1fa32c3 100644 --- a/cockatrice/src/game/zones/view_zone_logic.cpp +++ b/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.cpp @@ -1,10 +1,11 @@ #include "view_zone_logic.h" -#include "../../client/settings/cache_settings.h" -#include "../../game_graphics/board/card_item.h" -#include "../board/card_list.h" +#include "../board/card_state.h" +#include "../player/player_actions.h" +#include "../player/player_logic.h" #include +#include #include class PlayerLogic; @@ -22,13 +23,19 @@ ZoneViewZoneLogic::ZoneViewZoneLogic(PlayerLogic *_player, bool _revealZone, bool _writeableRevealZone, bool _isReversed, + IInterfaceSettingsProvider *_interfaceSettings, QObject *parent) : CardZoneLogic(_player, _origZone->getName(), false, false, true, parent), origZone(_origZone), numberCards(_numberCards), revealZone(_revealZone), writeableRevealZone(_writeableRevealZone), - isReversed(_isReversed) + isReversed(_isReversed), interfaceSettings(_interfaceSettings) { } +bool ZoneViewZoneLogic::getCloseEmptyCardView() const +{ + return interfaceSettings ? interfaceSettings->getCloseEmptyCardView() : true; +} + /** * 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. @@ -36,7 +43,7 @@ ZoneViewZoneLogic::ZoneViewZoneLogic(PlayerLogic *_player, * Note that this method can end up modifying the cardIds despite returning false. * (for example, if the card is inserted into a hidden portion of the deck while the view is reversed) * - * Make sure to call this method once before calling addCard(), so that you skip creating a new CardItem and calling + * Make sure to call this method once before calling addCard(), so that you skip creating the card and calling * addCard() if it's not required. * * @param x The position to insert the card at. @@ -64,7 +71,7 @@ bool ZoneViewZoneLogic::prepareAddCard(int x) // autoclose check is done both here and in removeCard - if (cards.isEmpty() && !doInsert && SettingsCache::instance().userInterface().getCloseEmptyCardView()) { + if (cards.isEmpty() && !doInsert && getCloseEmptyCardView()) { emit closeView(); } @@ -75,7 +82,7 @@ bool ZoneViewZoneLogic::prepareAddCard(int x) * Make sure prepareAddCard() was called before calling addCard(). * This method assumes we already checked that the card is being inserted into the visible portion */ -void ZoneViewZoneLogic::addCardImpl(CardItem *card, int x, int /*y*/) +void ZoneViewZoneLogic::addCardImpl(CardState *card, int x, int /*y*/) { if (!isReversed) { // if x is negative set it to add at end @@ -144,14 +151,14 @@ void ZoneViewZoneLogic::removeCard(int position, bool toNewZone) return; } - CardItem *card = cards.takeAt(position); - card->deleteLater(); + CardState *card = cards.takeAt(position); + card->deleteView(); // The toNewZone check is to prevent the view from auto-closing if the view contains only a single card and that // card gets dragged within the view. // Another autoclose check is done in prepareAddCard so that the view autocloses if the last card was moved to an // unrevealed portion of the same zone. - if (cards.isEmpty() && SettingsCache::instance().userInterface().getCloseEmptyCardView() && toNewZone) { + if (cards.isEmpty() && getCloseEmptyCardView() && toNewZone) { emit closeView(); return; } @@ -160,12 +167,18 @@ void ZoneViewZoneLogic::removeCard(int position, bool toNewZone) reorganizeCards(); } +void ZoneViewZoneLogic::requestClose() +{ + emit closeView(); +} + void ZoneViewZoneLogic::clearCards() { for (auto card : cards) { - card->deleteLater(); + card->deleteView(); } cards.clear(); + emit cardCountChanged(); } void ZoneViewZoneLogic::setWriteableRevealZone(bool _writeableRevealZone) @@ -177,4 +190,16 @@ void ZoneViewZoneLogic::setWriteableRevealZone(bool _writeableRevealZone) emit removeFromViews(); } writeableRevealZone = _writeableRevealZone; -} \ No newline at end of file +} + +PendingCommand *ZoneViewZoneLogic::prepareZoneDump() +{ + return getPlayer()->getPlayerActions()->prepareZoneDump(this, numberCards); +} + +void ZoneViewZoneLogic::sendZoneDump(PendingCommand *pendingCommand) +{ + if (pendingCommand != nullptr) { + getPlayer()->getPlayerActions()->sendGameCommand(pendingCommand); + } +} diff --git a/cockatrice/src/game/zones/view_zone_logic.h b/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.h similarity index 55% rename from cockatrice/src/game/zones/view_zone_logic.h rename to libcockatrice_game/libcockatrice/game/zones/view_zone_logic.h index 6fae301e4..a29e543a3 100644 --- a/cockatrice/src/game/zones/view_zone_logic.h +++ b/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.h @@ -12,6 +12,8 @@ class PlayerLogic; class QObject; +class PendingCommand; +class IInterfaceSettingsProvider; class ZoneViewZoneLogic : public CardZoneLogic { @@ -26,6 +28,8 @@ private: int numberCards; bool revealZone, writeableRevealZone; bool isReversed; + // Borrowed settings provider (owned by the application). Null outside the client, in which case views stay open. + IInterfaceSettingsProvider *interfaceSettings; public: enum CardAction @@ -41,15 +45,25 @@ public: bool _revealZone, bool _writeableRevealZone, bool _isReversed, + IInterfaceSettingsProvider *interfaceSettings = nullptr, QObject *parent = nullptr); bool prepareAddCard(int x); void removeCard(int position, bool toNewZone); void updateCardIds(CardAction action); + /** + * @brief Asks the graphics view showing this zone to close itself. + * + * Logic code has no handle on the view, so it goes through the logic object the view was built from. + */ + void requestClose(); + /** @brief Removes all cards from the view, without touching anything else. Used in replay rewind */ void clearCards(); + [[nodiscard]] bool getCloseEmptyCardView() const; + int getNumberCards() const { return numberCards; @@ -73,8 +87,25 @@ public: return origZone; } + /** + * @brief Builds the zone-dump command for this view without sending it. + * @return The pending command, or nullptr when there is no zone to dump. + * + * Keeps command construction in game/ so graphics views don't reach + * through the player into PlayerActions to set it up. The response can + * arrive synchronously (local games), so callers must connect to + * PendingCommand::finished before calling sendZoneDump(). + */ + PendingCommand *prepareZoneDump(); + + /** + * @brief Sends a command returned by prepareZoneDump(). + * @param pendingCommand The prepared command; nullptr is ignored. + */ + void sendZoneDump(PendingCommand *pendingCommand); + protected: - void addCardImpl(CardItem *card, int x, int y) override; + void addCardImpl(CardState *card, int x, int y) override; }; #endif // COCKATRICE_VIEW_ZONE_LOGIC_H diff --git a/libcockatrice_interfaces/CMakeLists.txt b/libcockatrice_interfaces/CMakeLists.txt index 3b17569b1..16ea686b5 100644 --- a/libcockatrice_interfaces/CMakeLists.txt +++ b/libcockatrice_interfaces/CMakeLists.txt @@ -24,9 +24,7 @@ set(HEADERS libcockatrice/interfaces/noop_card_set_priority_controller.h ) -qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) - -add_library(libcockatrice_interfaces STATIC ${MOC_SOURCES}) +add_library(libcockatrice_interfaces STATIC ${HEADERS}) target_include_directories(libcockatrice_interfaces PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) diff --git a/libcockatrice_models/libcockatrice/models/database/CMakeLists.txt b/libcockatrice_models/libcockatrice/models/database/CMakeLists.txt index a73e36dbb..2471adacf 100644 --- a/libcockatrice_models/libcockatrice/models/database/CMakeLists.txt +++ b/libcockatrice_models/libcockatrice/models/database/CMakeLists.txt @@ -12,11 +12,9 @@ set(HEADERS token/token_edit_model.h ) -qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) - add_library( libcockatrice_models_database STATIC - ${MOC_SOURCES} + ${HEADERS} card_database_model.cpp card_database_display_model.cpp card/card_completer_proxy_model.cpp diff --git a/libcockatrice_models/libcockatrice/models/database/card/card_search_model.cpp b/libcockatrice_models/libcockatrice/models/database/card/card_search_model.cpp index 5bde6e3fc..8a267ed7f 100644 --- a/libcockatrice_models/libcockatrice/models/database/card/card_search_model.cpp +++ b/libcockatrice_models/libcockatrice/models/database/card/card_search_model.cpp @@ -9,6 +9,7 @@ #include #include #include +#include CardSearchModel::CardSearchModel(CardDatabaseDisplayModel *sourceModel, QObject *parent) : QAbstractListModel(parent), sourceModel(sourceModel) @@ -69,31 +70,28 @@ void CardSearchModel::updateSearchResults(const QString &query) continue; } - // The completer suggestions match against the same languages the card - // search uses, so typing a localized name finds the card. In Both mode - // either language can match. - for (const QString &matchName : searchableNames(card)) { - const QString lowerName = matchName.toLower(); - if (!lowerName.contains(lowerQuery)) { - continue; - } + const std::optional lowerName = matchingLowercaseName(card, lowerQuery); + if (!lowerName) { + continue; + } - const int distance = levenshteinDistance(lowerQuery, lowerName); + const int distance = levenshteinDistance(lowerQuery, *lowerName); + // Length of the matched name, used to break distance ties. Stored here + // so the comparator does not look the name up again for every comparison. + const int nameLength = static_cast(lowerName->size()); - if (lowerName.startsWith(lowerQuery)) { - prefixMatches.append({card, distance}); - } else { - containsMatches.append({card, distance}); - } - break; + if (lowerName->startsWith(lowerQuery)) { + prefixMatches.append({card, distance, nameLength}); + } else { + containsMatches.append({card, distance, nameLength}); } } - auto sortByDistanceThenLength = [this](const SearchResult &a, const SearchResult &b) { + auto sortByDistanceThenLength = [](const SearchResult &a, const SearchResult &b) { if (a.distance != b.distance) { return a.distance < b.distance; } - return sortableName(a.card).size() < sortableName(b.card).size(); + return a.nameLength < b.nameLength; }; std::sort(prefixMatches.begin(), prefixMatches.end(), sortByDistanceThenLength); @@ -112,24 +110,28 @@ void CardSearchModel::updateSearchResults(const QString &query) endResetModel(); } -QStringList CardSearchModel::searchableNames(const CardInfoPtr &card) const +std::optional CardSearchModel::matchingLowercaseName(const CardInfoPtr &card, const QString &lowerQuery) const { + if (!searchLanguage.isEnglishOnly() && searchLanguage.mode == SearchLanguageMode::Selected) { + QString localizedName = card->getLocalizedName(searchLanguage.language).toLower(); + if (localizedName.contains(lowerQuery)) { + return std::optional(std::move(localizedName)); + } + return std::nullopt; + } + + QString englishName = card->getName().toLower(); + if (englishName.contains(lowerQuery)) { + return std::optional(std::move(englishName)); + } + if (searchLanguage.isEnglishOnly()) { - return {card->getName()}; + return std::nullopt; } - const QString localizedName = card->getLocalizedName(searchLanguage.language); - if (searchLanguage.mode == SearchLanguageMode::Selected) { - return {localizedName}; + QString localizedName = card->getLocalizedName(searchLanguage.language).toLower(); + if (localizedName.contains(lowerQuery)) { + return std::optional(std::move(localizedName)); } - - return {card->getName(), localizedName}; -} - -QString CardSearchModel::sortableName(const CardInfoPtr &card) const -{ - if (searchLanguage.isEnglishOnly()) { - return card->getName(); - } - return card->getLocalizedName(searchLanguage.language); + return std::nullopt; } diff --git a/libcockatrice_models/libcockatrice/models/database/card/card_search_model.h b/libcockatrice_models/libcockatrice/models/database/card/card_search_model.h index 08d0e8636..ec7c1195e 100644 --- a/libcockatrice_models/libcockatrice/models/database/card/card_search_model.h +++ b/libcockatrice_models/libcockatrice/models/database/card/card_search_model.h @@ -16,6 +16,7 @@ #include #include #include +#include #include #include @@ -51,13 +52,20 @@ private: { CardInfoPtr card; int distance; + int nameLength; }; - /** @brief The names a card is searched by with the current search language. */ - [[nodiscard]] QStringList searchableNames(const CardInfoPtr &card) const; - - /** @brief The name used to break distance ties when sorting suggestions. */ - [[nodiscard]] QString sortableName(const CardInfoPtr &card) const; + /** + * @brief Finds the lowercased card name that matches the query. + * + * The completer suggestions match against the same languages the card + * search uses, so typing a localized name finds the card. In Both mode + * either language can match. + * + * @return The matched lowercased name, or std::nullopt when the card does not match. + */ + [[nodiscard]] std::optional matchingLowercaseName(const CardInfoPtr &card, + const QString &lowerQuery) const; CardDatabaseDisplayModel *sourceModel; QList searchResults; diff --git a/libcockatrice_models/libcockatrice/models/database/card_database_display_model.cpp b/libcockatrice_models/libcockatrice/models/database/card_database_display_model.cpp index 39bb8314c..5573deb18 100644 --- a/libcockatrice_models/libcockatrice/models/database/card_database_display_model.cpp +++ b/libcockatrice_models/libcockatrice/models/database/card_database_display_model.cpp @@ -1,5 +1,6 @@ #include "card_database_display_model.h" +#include "../natural_sort_collator.h" #include "card_database_model.h" #include "libcockatrice/card/card_localization.h" #include "libcockatrice/filters/filter_string.h" @@ -20,7 +21,8 @@ class QObject; CardDatabaseDisplayModel::CardDatabaseDisplayModel(QObject *parent) - : QSortFilterProxyModel(parent), isToken(ShowAll), filterString(nullptr) + : QSortFilterProxyModel(parent), isToken(ShowAll), filterString(nullptr), + collator(NaturalSort::makeNaturalSortCollator()) { filterTree = nullptr; setFilterCaseSensitivity(Qt::CaseInsensitive); @@ -82,13 +84,12 @@ int CardDatabaseDisplayModel::rowCount(const QModelIndex &parent) const bool CardDatabaseDisplayModel::lessThan(const QModelIndex &left, const QModelIndex &right) const { - - QString leftString = sourceModel()->data(left, CardDatabaseModel::SortRole).toString(); - QString rightString = sourceModel()->data(right, CardDatabaseModel::SortRole).toString(); + const QString leftString = sourceModel()->data(left, CardDatabaseModel::SortRole).toString(); + const QString rightString = sourceModel()->data(right, CardDatabaseModel::SortRole).toString(); if (!cardName.isEmpty() && left.column() == CardDatabaseModel::NameColumn) { - bool isLeftType = leftString.startsWith(cardName, Qt::CaseInsensitive); - bool isRightType = rightString.startsWith(cardName, Qt::CaseInsensitive); + const bool isLeftType = leftString.startsWith(cardName, Qt::CaseInsensitive); + const bool isRightType = rightString.startsWith(cardName, Qt::CaseInsensitive); // test for an exact match: isLeftType && leftString.size() == cardName.size() // or an exclusive start match: isLeftType && !isRightType @@ -101,31 +102,37 @@ bool CardDatabaseDisplayModel::lessThan(const QModelIndex &left, const QModelInd return false; } } else if (right.column() == CardDatabaseModel::PTColumn && left.column() == CardDatabaseModel::PTColumn) { - QStringList leftList = leftString.split("/"); - QStringList rightList = rightString.split("/"); + // The comparator runs O(n log n) times while the view is sorted, so + // splitting every power/toughness string into a QStringList allocates + // a lot for data that has exactly one separator. Work on views and + // only split the two halves out when both strings are well formed. + const QStringView leftPT = leftString; + const QStringView rightPT = rightString; + const qsizetype leftSlash = leftPT.indexOf(u'/'); + const qsizetype rightSlash = rightPT.indexOf(u'/'); + const bool leftIsWellFormed = leftSlash >= 0 && leftPT.indexOf(u'/', leftSlash + 1) < 0; + const bool rightIsWellFormed = rightSlash >= 0 && rightPT.indexOf(u'/', rightSlash + 1) < 0; - if (leftList.size() == 2 && rightList.size() == 2) { - - // cool, have both P/T in list now - int lessThanNum = lessThanNumerically(leftList.at(0), rightList.at(0)); - if (lessThanNum != 0) { - return lessThanNum < 0; - } else { - // power equal, check toughness - return lessThanNumerically(leftList.at(1), rightList.at(1)) < 0; + if (leftIsWellFormed && rightIsWellFormed) { + const int powerComparison = lessThanNumerically(leftPT.left(leftSlash), rightPT.left(rightSlash)); + if (powerComparison != 0) { + return powerComparison < 0; } + // power equal, check toughness + return lessThanNumerically(leftPT.mid(leftSlash + 1), rightPT.mid(rightSlash + 1)) < 0; } } - return QString::localeAwareCompare(leftString, rightString) < 0; + return collator.compare(leftString, rightString) < 0; } -int CardDatabaseDisplayModel::lessThanNumerically(const QString &left, const QString &right) +int CardDatabaseDisplayModel::lessThanNumerically(QStringView left, QStringView right) const { if (left == right) { return 0; } - bool okLeft, okRight; + bool okLeft = false; + bool okRight = false; float leftNum = left.toFloat(&okLeft); float rightNum = right.toFloat(&okRight); @@ -139,11 +146,11 @@ int CardDatabaseDisplayModel::lessThanNumerically(const QString &left, const QSt } } // try and parsing again, for weird ones like "1+*" - QString leftAfterNum = ""; - QString rightAfterNum = ""; + QStringView leftAfterNum; + QStringView rightAfterNum; if (!okLeft) { int leftNumIndex = 0; - for (; leftNumIndex < left.length(); leftNumIndex++) { + for (; leftNumIndex < left.size(); leftNumIndex++) { if (!left.at(leftNumIndex).isDigit()) { break; } @@ -155,7 +162,7 @@ int CardDatabaseDisplayModel::lessThanNumerically(const QString &left, const QSt } if (!okRight) { int rightNumIndex = 0; - for (; rightNumIndex < right.length(); rightNumIndex++) { + for (; rightNumIndex < right.size(); rightNumIndex++) { if (!right.at(rightNumIndex).isDigit()) { break; } @@ -177,7 +184,7 @@ int CardDatabaseDisplayModel::lessThanNumerically(const QString &left, const QSt } else { // both parsed, same number, but at least one has something else // so compare the part after the number - prefer nothing - return QString::localeAwareCompare(leftAfterNum, rightAfterNum); + return collator.compare(leftAfterNum, rightAfterNum); } } else if (okLeft) { return -1; @@ -185,7 +192,7 @@ int CardDatabaseDisplayModel::lessThanNumerically(const QString &left, const QSt return 1; } // couldn't parse it, just return String comparison - return QString::localeAwareCompare(left, right); + return collator.compare(left, right); } bool CardDatabaseDisplayModel::filterAcceptsRow(int sourceRow, const QModelIndex & /*sourceParent*/) const { diff --git a/libcockatrice_models/libcockatrice/models/database/card_database_display_model.h b/libcockatrice_models/libcockatrice/models/database/card_database_display_model.h index d12dd1fb4..343a7482e 100644 --- a/libcockatrice_models/libcockatrice/models/database/card_database_display_model.h +++ b/libcockatrice_models/libcockatrice/models/database/card_database_display_model.h @@ -10,10 +10,12 @@ #include "libcockatrice/card/card_info.h" +#include #include #include #include #include +#include #include #include #include @@ -48,6 +50,9 @@ private: /** The translation table that will be used for sanitizeCardName. */ static QMap characterTranslation; + /** Natural, locale-aware comparison shared by every sort in this model. */ + QCollator collator; + public: explicit CardDatabaseDisplayModel(QObject *parent = nullptr); void setSourceModel(QAbstractItemModel *model) override; @@ -88,7 +93,7 @@ signals: protected: [[nodiscard]] bool lessThan(const QModelIndex &left, const QModelIndex &right) const override; - static int lessThanNumerically(const QString &left, const QString &right); + int lessThanNumerically(QStringView left, QStringView right) const; [[nodiscard]] bool filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const override; [[nodiscard]] bool rowMatchesCardName(CardInfoPtr info) const; diff --git a/libcockatrice_models/libcockatrice/models/database/card_set/card_sets_model.cpp b/libcockatrice_models/libcockatrice/models/database/card_set/card_sets_model.cpp index a0b9c1061..b8bdc3466 100644 --- a/libcockatrice_models/libcockatrice/models/database/card_set/card_sets_model.cpp +++ b/libcockatrice_models/libcockatrice/models/database/card_set/card_sets_model.cpp @@ -1,5 +1,6 @@ #include "card_sets_model.h" +#include "../../natural_sort_collator.h" #include "libcockatrice/card/database/card_database.h" #include "libcockatrice/card/set/card_set.h" #include "libcockatrice/interfaces/interface_card_set_priority_controller.h" @@ -296,7 +297,8 @@ QStringList SetsModel::mimeTypes() const return QStringList() << "application/x-cockatricecardset"; } -SetsDisplayModel::SetsDisplayModel(QObject *parent) : QSortFilterProxyModel(parent) +SetsDisplayModel::SetsDisplayModel(QObject *parent) + : QSortFilterProxyModel(parent), collator(NaturalSort::makeNaturalSortCollator()) { setFilterCaseSensitivity(Qt::CaseInsensitive); setSortCaseSensitivity(Qt::CaseInsensitive); @@ -331,5 +333,5 @@ bool SetsDisplayModel::lessThan(const QModelIndex &left, const QModelIndex &righ QString leftString = sourceModel()->data(left, SetsModel::SortRole).toString(); QString rightString = sourceModel()->data(right, SetsModel::SortRole).toString(); - return QString::localeAwareCompare(leftString, rightString) < 0; + return collator.compare(leftString, rightString) < 0; } diff --git a/libcockatrice_models/libcockatrice/models/database/card_set/card_sets_model.h b/libcockatrice_models/libcockatrice/models/database/card_set/card_sets_model.h index 354414dc8..17eab6c53 100644 --- a/libcockatrice_models/libcockatrice/models/database/card_set/card_sets_model.h +++ b/libcockatrice_models/libcockatrice/models/database/card_set/card_sets_model.h @@ -11,6 +11,7 @@ #include "libcockatrice/card/set/card_set_list.h" #include +#include #include #include #include @@ -112,6 +113,9 @@ protected: [[nodiscard]] bool lessThan(const QModelIndex &left, const QModelIndex &right) const override; [[nodiscard]] bool filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const override; void fetchMore(const QModelIndex &index) override; + +private: + QCollator collator; }; #endif diff --git a/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt b/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt index 324bed102..b46275437 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt +++ b/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt @@ -4,10 +4,8 @@ set(CMAKE_AUTORCC ON) set(HEADERS deck_list_model.h deck_list_sort_filter_proxy_model.h) -qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) - add_library( - libcockatrice_models_deck_list STATIC ${MOC_SOURCES} deck_list_model.cpp deck_list_model_custom_zones.cpp + libcockatrice_models_deck_list STATIC ${HEADERS} deck_list_model.cpp deck_list_model_custom_zones.cpp deck_list_sort_filter_proxy_model.cpp ) diff --git a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_sort_filter_proxy_model.cpp b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_sort_filter_proxy_model.cpp index dcb294ec8..cf3410560 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_sort_filter_proxy_model.cpp +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_sort_filter_proxy_model.cpp @@ -23,7 +23,7 @@ bool DeckListSortFilterProxyModel::lessThan(const QModelIndex &left, const QMode if (!leftIsCard || !rightIsCard) { QString lName = src->data(left.siblingAtColumn(DeckListModelColumns::CARD_NAME), Qt::EditRole).toString(); QString rName = src->data(right.siblingAtColumn(DeckListModelColumns::CARD_NAME), Qt::EditRole).toString(); - return lName.localeAwareCompare(rName) < 0; + return collator.compare(lName, rName) < 0; } // Both are cards -> apply sort criteria @@ -38,7 +38,7 @@ bool DeckListSortFilterProxyModel::lessThan(const QModelIndex &left, const QMode if (crit == "name") { QString ln = lNode->getName(); QString rn = rNode->getName(); - int cmp = ln.localeAwareCompare(rn); + int cmp = collator.compare(ln, rn); if (cmp != 0) { return cmp < 0; } @@ -51,14 +51,14 @@ bool DeckListSortFilterProxyModel::lessThan(const QModelIndex &left, const QMode } else if (crit == "colors") { QString lr = lInfo ? lInfo->getColors() : QString(); QString rr = rInfo ? rInfo->getColors() : QString(); - int cmp = lr.localeAwareCompare(rr); + int cmp = collator.compare(lr, rr); if (cmp != 0) { return cmp < 0; } } else if (crit == "maintype") { QString lr = lInfo ? lInfo->getMainCardType() : QString(); QString rr = rInfo ? rInfo->getMainCardType() : QString(); - int cmp = lr.localeAwareCompare(rr); + int cmp = collator.compare(lr, rr); if (cmp != 0) { return cmp < 0; } diff --git a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_sort_filter_proxy_model.h b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_sort_filter_proxy_model.h index 522ed1e23..b911b4740 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_sort_filter_proxy_model.h +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_sort_filter_proxy_model.h @@ -7,6 +7,9 @@ #ifndef COCKATRICE_DECK_LIST_SORT_FILTER_PROXY_MODEL_H #define COCKATRICE_DECK_LIST_SORT_FILTER_PROXY_MODEL_H +#include "../natural_sort_collator.h" + +#include #include #include #include @@ -19,7 +22,8 @@ class DeckListSortFilterProxyModel : public QSortFilterProxyModel { Q_OBJECT public: - explicit DeckListSortFilterProxyModel(QObject *parent = nullptr) : QSortFilterProxyModel(parent) + explicit DeckListSortFilterProxyModel(QObject *parent = nullptr) + : QSortFilterProxyModel(parent), collator(NaturalSort::makeNaturalSortCollator()) { } @@ -34,6 +38,7 @@ protected: private: QStringList sortCriteria; + QCollator collator; }; #endif // COCKATRICE_DECK_LIST_SORT_FILTER_PROXY_MODEL_H diff --git a/libcockatrice_models/libcockatrice/models/natural_sort_collator.h b/libcockatrice_models/libcockatrice/models/natural_sort_collator.h new file mode 100644 index 000000000..8c026ca06 --- /dev/null +++ b/libcockatrice_models/libcockatrice/models/natural_sort_collator.h @@ -0,0 +1,30 @@ +#ifndef NATURAL_SORT_COLLATOR_H +#define NATURAL_SORT_COLLATOR_H + +#include +#include + +namespace NaturalSort +{ + +/** + * @brief A collator that compares runs of digits numerically, whatever the process locale is. + * + * Numeric mode only takes effect through a real locale. Under the C locale, which + * headless CI processes run in, QCollator silently falls back to plain string order + * and sorts "Forest 10" before "Forest 2". Pin English for that single degenerate + * locale so sorting is the same everywhere, and keep the user's locale otherwise. + */ +inline QCollator makeNaturalSortCollator() +{ + QCollator collator; + collator.setNumericMode(true); + if (QLocale().language() == QLocale::C) { + collator.setLocale(QLocale(QLocale::English)); + } + return collator; +} + +} // namespace NaturalSort + +#endif // NATURAL_SORT_COLLATOR_H \ No newline at end of file diff --git a/libcockatrice_network/libcockatrice/network/client/abstract/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/client/abstract/CMakeLists.txt index 53ad524b4..880cef5a1 100644 --- a/libcockatrice_network/libcockatrice/network/client/abstract/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/client/abstract/CMakeLists.txt @@ -6,9 +6,7 @@ set(HEADERS abstract_client.h latency_tracker.h) set(SOURCES abstract_client.cpp latency_tracker.cpp) -qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) - -add_library(libcockatrice_network_client_abstract STATIC ${MOC_SOURCES} ${SOURCES}) +add_library(libcockatrice_network_client_abstract STATIC ${HEADERS} ${SOURCES}) add_dependencies(libcockatrice_network_client_abstract libcockatrice_protocol libcockatrice_network_server_remote) diff --git a/libcockatrice_network/libcockatrice/network/client/local/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/client/local/CMakeLists.txt index 08c12b7f8..4d5676fc0 100644 --- a/libcockatrice_network/libcockatrice/network/client/local/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/client/local/CMakeLists.txt @@ -6,9 +6,7 @@ set(HEADERS local_client.h) set(SOURCES local_client.cpp) -qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) - -add_library(libcockatrice_network_client_local STATIC ${MOC_SOURCES} ${SOURCES}) +add_library(libcockatrice_network_client_local STATIC ${HEADERS} ${SOURCES}) add_dependencies(libcockatrice_network_client_local libcockatrice_network_client_abstract) diff --git a/libcockatrice_network/libcockatrice/network/client/local/local_client.cpp b/libcockatrice_network/libcockatrice/network/client/local/local_client.cpp index c1c1cc976..f7d8645e8 100644 --- a/libcockatrice_network/libcockatrice/network/client/local/local_client.cpp +++ b/libcockatrice_network/libcockatrice/network/client/local/local_client.cpp @@ -39,14 +39,18 @@ LocalClient::~LocalClient() void LocalClient::sendCommandContainer(const CommandContainer &cont) { - qCDebug(LocalClientLog).noquote() << userName << "OUT" << getSafeDebugString(cont); + if (LocalClientLog().isDebugEnabled()) { + qCDebug(LocalClientLog).noquote() << userName << "OUT" << getSafeDebugString(cont); + } lsi->itemFromClient(cont); } void LocalClient::itemFromServer(const ServerMessage &item) { - qCDebug(LocalClientLog).noquote() << userName << "IN" << getSafeDebugString(item); + if (LocalClientLog().isDebugEnabled()) { + qCDebug(LocalClientLog).noquote() << userName << "IN" << getSafeDebugString(item); + } processProtocolItem(item); } diff --git a/libcockatrice_network/libcockatrice/network/client/remote/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/client/remote/CMakeLists.txt index c32ca0aa8..ecc822b7c 100644 --- a/libcockatrice_network/libcockatrice/network/client/remote/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/client/remote/CMakeLists.txt @@ -6,9 +6,7 @@ set(HEADERS remote_client.h) set(SOURCES remote_client.cpp) -qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) - -add_library(libcockatrice_network_client_remote STATIC ${MOC_SOURCES} ${SOURCES}) +add_library(libcockatrice_network_client_remote STATIC ${HEADERS} ${SOURCES}) add_dependencies(libcockatrice_network_client_remote libcockatrice_network_client_abstract libcockatrice_protocol) diff --git a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp index cbf7dbda2..7eab58668 100644 --- a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp +++ b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp @@ -40,6 +40,47 @@ class QObject; static const unsigned int protocolVersion = 14; +// Explanations for logins that do not complete but carry no missing-feature list: the +// ones RemoteClient stops itself, plus a strict server asking for a password. They all +// travel with RespClientUpdateRequired and are shown by the same dialog, so their wording +// is kept together here and adjusted as a set. The context has to stay "RemoteClient" for +// the tr() calls at the use sites to find the translations. +namespace +{ +// The server warns that this account still uses the legacy password hash while this client +// holds a scrypt verifier. Sending the verifier would downgrade the proof, so the client +// stops; the login only succeeds after the saved verifier is gone and the user types the +// password, which also upgrades the account. +constexpr const char *const LOGIN_ABORT_AUTH_DOWNGRADE = QT_TRANSLATE_NOOP( + "RemoteClient", + "The server still uses an old password format for this account, so the saved login on this computer cannot be " + "used. Untick 'Save password' in the Connect window and connect; when it opens again, type your password."); +// The account is still on the legacy hash and only the plaintext password can produce it. +constexpr const char *const LOGIN_ABORT_PASSWORD_REQUIRED = + QT_TRANSLATE_NOOP("RemoteClient", + "This account still uses an old password format. Type your password in the Connect window and " + "log in; the account is upgraded automatically."); +// Rejected by PasswordHasher::costParamsAreSane, either for being weak or for being too +// expensive for this client. Nothing on the client side can fix it. +constexpr const char *const LOGIN_ABORT_UNSAFE_SCRYPT_PARAMS = + QT_TRANSLATE_NOOP("RemoteClient", + "The server asked for password settings that Cockatrice will not accept because they are too " + "weak or too demanding. The login was stopped; only the server's staff can change this."); +// Scrypt derivation failed locally: a device resource problem, or server parameters this +// client cannot satisfy. +constexpr const char *const LOGIN_ABORT_VERIFIER_NOT_DERIVED = + QT_TRANSLATE_NOOP("RemoteClient", + "Cockatrice could not prepare your password for this login. Try again; if it keeps happening, " + "the server's password settings may be too demanding for this version of Cockatrice."); +constexpr const char *const LOGIN_ABORT_STORED_VERIFIER_UNUSABLE = + QT_TRANSLATE_NOOP("RemoteClient", + "The saved login for this account cannot be used. Type your password in the Connect window; if " + "'Save password' is ticked, a new saved login is created after you log in."); +constexpr const char *const LOGIN_ABORT_PASSWORD_MISSING = + QT_TRANSLATE_NOOP("RemoteClient", + "This server requires your password to log in. Type it in the Connect window and try again."); +} // namespace + RemoteClient::RemoteClient(QObject *parent, INetworkSettingsProvider *_networkSettingsProvider) : AbstractClient(parent), networkSettingsProvider(_networkSettingsProvider), timeRunning(0), lastDataReceived(0), messageInProgress(false), handshakeStarted(false), usingWebSocket(false), messageLength(0), hashedPassword(), @@ -335,9 +376,7 @@ void RemoteClient::passwordSaltResponse(const Response &response) // 1000-round legacy hash is a downgrade of the KDF — refuse it rather // than hand an attacker a weakly-keyed HMAC they can attack offline. if (resp.needs_migration() && PasswordHasher::parsePasswordVerifier(storedVerifier).isValid) { - emit loginError(Response::RespClientUpdateRequired, - QStringLiteral("Server asked to downgrade authentication for a migrated account."), 0, - {}); + emit loginError(Response::RespClientUpdateRequired, tr(LOGIN_ABORT_AUTH_DOWNGRADE), 0, {}); return; } QByteArray key; @@ -345,8 +384,7 @@ void RemoteClient::passwordSaltResponse(const Response &response) // The account still uses the legacy format; the legacy full hash // is only derivable from the plaintext password. if (password.isEmpty()) { - emit loginError(Response::RespClientUpdateRequired, - QStringLiteral("This account must be logged in with its password once."), 0, {}); + emit loginError(Response::RespClientUpdateRequired, tr(LOGIN_ABORT_PASSWORD_REQUIRED), 0, {}); return; } key = PasswordHasher::computeHash(password, passwordSalt).toUtf8(); @@ -357,8 +395,7 @@ void RemoteClient::passwordSaltResponse(const Response &response) const int r = resp.has_r() ? resp.r() : SCRYPT_R; const int p = resp.has_p() ? resp.p() : SCRYPT_P; if (!PasswordHasher::costParamsAreSane(n, r, p)) { - emit loginError(Response::RespClientUpdateRequired, - QStringLiteral("The server requested unreasonable scrypt cost parameters."), 0, {}); + emit loginError(Response::RespClientUpdateRequired, tr(LOGIN_ABORT_UNSAFE_SCRYPT_PARAMS), 0, {}); return; } // password_salt may carry a legacy backup salt for pre-challenge clients; @@ -367,8 +404,7 @@ void RemoteClient::passwordSaltResponse(const Response &response) resp.has_scrypt_salt() ? QString::fromStdString(resp.scrypt_salt()) : passwordSalt; key = PasswordHasher::deriveKey(password, QByteArray::fromBase64(scryptSalt.toUtf8()), n, r, p); if (key.isEmpty()) { - emit loginError(Response::RespClientUpdateRequired, QStringLiteral("Unable to derive verifier."), 0, - {}); + emit loginError(Response::RespClientUpdateRequired, tr(LOGIN_ABORT_VERIFIER_NOT_DERIVED), 0, {}); return; } derivedVerifier = @@ -376,8 +412,8 @@ void RemoteClient::passwordSaltResponse(const Response &response) } else if (!storedVerifier.isEmpty()) { const PasswordVerifier verifier = PasswordHasher::parsePasswordVerifier(storedVerifier); if (!verifier.isValid) { - emit loginError(Response::RespClientUpdateRequired, QStringLiteral("Stored verifier is invalid."), - 0, {}); + emit loginError(Response::RespClientUpdateRequired, tr(LOGIN_ABORT_STORED_VERIFIER_UNUSABLE), 0, + {}); return; } key = verifier.verifier; @@ -446,8 +482,19 @@ void RemoteClient::loginResponse(const Response &response) missingFeatures << QString::fromStdString(resp.missing_features(i)); } } - emit loginError(response.response_code(), QString::fromStdString(resp.denied_reason_str()), - static_cast(resp.denied_end_time()), missingFeatures); + + QString reasonStr = QString::fromStdString(resp.denied_reason_str()); + // A strict server reports a login that carried no credential as a missing feature, + // because challenge-response needs one. No client update can supply a password, so + // report it like the other local aborts instead of advising an update. + if (response.response_code() == Response::RespClientUpdateRequired && missingFeatures.size() == 1 && + missingFeatures.first() == "challenge_response_auth" && password.isEmpty() && storedVerifier.isEmpty()) { + missingFeatures.clear(); + reasonStr = tr(LOGIN_ABORT_PASSWORD_MISSING); + } + + emit loginError(response.response_code(), reasonStr, static_cast(resp.denied_end_time()), + missingFeatures); setStatus(StatusDisconnecting); } } @@ -496,9 +543,15 @@ void RemoteClient::readData() inputBuffer.append(data); - do { + // Parse messages straight out of inputBuffer at readOffset. Removing each + // consumed message from the front of the buffer would memmove all of the + // remaining bytes once per message; instead the consumed prefix is dropped + // once parsing stops. + qsizetype readOffset = 0; + + while (true) { if (!messageInProgress) { - if (inputBuffer.size() >= 4) { + if (inputBuffer.size() - readOffset >= 4) { // dirty hack to be compatible with v14 server that sends 60 bytes of garbage at the beginning if (!handshakeStarted) { handshakeStarted = true; @@ -508,29 +561,30 @@ void RemoteClient::readData() } } else { // end of hack - messageLength = (((quint32)(unsigned char)inputBuffer[0]) << 24) + - (((quint32)(unsigned char)inputBuffer[1]) << 16) + - (((quint32)(unsigned char)inputBuffer[2]) << 8) + - ((quint32)(unsigned char)inputBuffer[3]); - inputBuffer.remove(0, 4); + const auto *header = reinterpret_cast(inputBuffer.constData() + readOffset); + messageLength = (static_cast(header[0]) << 24) + (static_cast(header[1]) << 16) + + (static_cast(header[2]) << 8) + static_cast(header[3]); + readOffset += 4; messageInProgress = true; } } else { - return; + break; } } - if (inputBuffer.size() < messageLength) { - return; + if (inputBuffer.size() - readOffset < messageLength) { + break; } ServerMessage newServerMessage; - bool ok = newServerMessage.ParseFromArray(inputBuffer.data(), messageLength); + bool ok = newServerMessage.ParseFromArray(inputBuffer.constData() + readOffset, messageLength); - inputBuffer.remove(0, messageLength); + readOffset += messageLength; messageInProgress = false; if (ok) { - qCDebug(RemoteClientLog).noquote() << "IN" << getSafeDebugString(newServerMessage); + if (RemoteClientLog().isDebugEnabled()) { + qCDebug(RemoteClientLog).noquote() << "IN" << getSafeDebugString(newServerMessage); + } processProtocolItem(newServerMessage); } else { @@ -540,7 +594,12 @@ void RemoteClient::readData() if (getStatus() == StatusDisconnecting) { // use thread-safe getter doDisconnectFromServer(); } - } while (!inputBuffer.isEmpty()); + } + + // Drop the consumed prefix once, instead of shifting the buffer per message. + if (readOffset > 0) { + inputBuffer.remove(0, readOffset); + } } void RemoteClient::websocketMessageReceived(const QByteArray &message) @@ -548,7 +607,9 @@ void RemoteClient::websocketMessageReceived(const QByteArray &message) lastDataReceived = timeRunning; ServerMessage newServerMessage; if (newServerMessage.ParseFromArray(message.data(), message.length())) { - qCDebug(RemoteClientLog).noquote() << "IN" << getSafeDebugString(newServerMessage); + if (RemoteClientLog().isDebugEnabled()) { + qCDebug(RemoteClientLog).noquote() << "IN" << getSafeDebugString(newServerMessage); + } processProtocolItem(newServerMessage); } else { @@ -564,7 +625,9 @@ void RemoteClient::sendCommandContainer(const CommandContainer &cont) auto size = static_cast(cont.ByteSize()); #endif - qCDebug(RemoteClientLog).noquote() << "OUT" << getSafeDebugString(cont); + if (RemoteClientLog().isDebugEnabled()) { + qCDebug(RemoteClientLog).noquote() << "OUT" << getSafeDebugString(cont); + } QByteArray buf; bool ok; @@ -821,7 +884,7 @@ void RemoteClient::requestForgotPasswordResponse(const Response &response) emit sigPromptForForgotPasswordReset(); } } else { - emit sigForgotPasswordError(); + emit sigForgotPasswordError(response.response_code()); } doDisconnectFromServer(); @@ -857,7 +920,7 @@ void RemoteClient::submitForgotPasswordResetResponse(const Response &response) derivedVerifier.clear(); } } else { - emit sigForgotPasswordError(); + emit sigForgotPasswordError(response.response_code()); } doDisconnectFromServer(); @@ -892,7 +955,7 @@ void RemoteClient::submitForgotPasswordChallengeResponse(const Response &respons if (response.response_code() == Response::RespOk) { emit sigPromptForForgotPasswordReset(); } else { - emit sigForgotPasswordError(); + emit sigForgotPasswordError(response.response_code()); } doDisconnectFromServer(); diff --git a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.h b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.h index dd91c4c6d..d0c0a18ce 100644 --- a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.h +++ b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.h @@ -54,7 +54,7 @@ signals: void notifyUserAboutUpdate(); void sigRequestForgotPasswordToServer(const QString &hostname, unsigned int port, const QString &_userName); void sigForgotPasswordSuccess(); - void sigForgotPasswordError(); + void sigForgotPasswordError(int responseCode); void sigPromptForForgotPasswordReset(); void sigSubmitForgotPasswordResetToServer(const QString &hostname, unsigned int port, diff --git a/libcockatrice_network/libcockatrice/network/network_manager.h b/libcockatrice_network/libcockatrice/network/network_manager.h new file mode 100644 index 000000000..bb20e1315 --- /dev/null +++ b/libcockatrice_network/libcockatrice/network/network_manager.h @@ -0,0 +1,38 @@ +#ifndef COCKATRICE_NETWORK_NETWORK_MANAGER_H +#define COCKATRICE_NETWORK_NETWORK_MANAGER_H + +#include +#include +#include + +/** + * @file network_manager.h + * @ingroup Network + * @brief Creates QNetworkAccessManagers with the application-wide baseline. + * + * Every manager in the client should start from NetworkManagerFactory::create() + * so features that apply to all HTTP traffic (the system proxy, a transfer + * timeout) cannot be forgotten in one of the many call sites. Feature-specific + * setup such as a disk cache or a manual redirect policy stays with the caller. + */ +namespace NetworkManagerFactory +{ +/** + * @brief Returns a QNetworkAccessManager configured for Cockatrice. + * + * The manager is parented to @p parent. The system proxy is enabled once, + * process-wide, the first time this function is called. Transfers use Qt's + * default timeout instead of waiting forever. + */ +inline QNetworkAccessManager *create(QObject *parent = nullptr) +{ + static std::once_flag systemProxyFlag; + std::call_once(systemProxyFlag, [] { QNetworkProxyFactory::setUseSystemConfiguration(true); }); + + auto *manager = new QNetworkAccessManager(parent); + manager->setTransferTimeout(); + return manager; +} +} // namespace NetworkManagerFactory + +#endif // COCKATRICE_NETWORK_NETWORK_MANAGER_H diff --git a/libcockatrice_network/libcockatrice/network/server/local/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/server/local/CMakeLists.txt index 020753d7f..c5f8ee432 100644 --- a/libcockatrice_network/libcockatrice/network/server/local/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/server/local/CMakeLists.txt @@ -6,9 +6,7 @@ set(HEADERS local_server.h local_server_interface.h) set(SOURCES local_server.cpp local_server_interface.cpp) -qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) - -add_library(libcockatrice_network_server_local STATIC ${MOC_SOURCES} ${SOURCES}) +add_library(libcockatrice_network_server_local STATIC ${HEADERS} ${SOURCES}) add_dependencies(libcockatrice_network_server_local libcockatrice_protocol) diff --git a/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt index 2127487d2..26c2be69a 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt @@ -29,11 +29,9 @@ set(HEADERS serverinfo_user_container.h ) -qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) - add_library( libcockatrice_network_server_remote STATIC - ${MOC_SOURCES} + ${HEADERS} game/dump_zone_range.cpp game/server_abstract_participant.cpp game/server_abstract_player.cpp diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_abstractuserinterface.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server_abstractuserinterface.cpp index 25aa415fc..c3524bb93 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_abstractuserinterface.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_abstractuserinterface.cpp @@ -84,6 +84,18 @@ void Server_AbstractUserInterface::onLogin(ResponseContainer &) { } +bool Server_AbstractUserInterface::getGameInfo(int gameId, int &roomId, int &playerId) const +{ + QMutexLocker locker(&gameListMutex); + const auto it = games.constFind(gameId); + if (it == games.constEnd()) { + return false; + } + roomId = it.value().first; + playerId = it.value().second; + return true; +} + void Server_AbstractUserInterface::playerRemovedFromGame(Server_Game *game) { qDebug() << "Server_AbstractUserInterface::playerRemovedFromGame(): gameId =" << game->getGameId(); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_abstractuserinterface.h b/libcockatrice_network/libcockatrice/network/server/remote/server_abstractuserinterface.h index a19475e34..a2a4ed8c7 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_abstractuserinterface.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_abstractuserinterface.h @@ -58,6 +58,13 @@ public: return games; } + /** + * @brief Looks up the room and player id of one game without copying the game list. + * + * @return True if the game is known; otherwise false and the outputs are untouched. + */ + bool getGameInfo(int gameId, int &roomId, int &playerId) const; + virtual void sendProtocolItem(const Response &item) = 0; virtual void sendProtocolItem(const SessionEvent &item) = 0; virtual void sendProtocolItem(const GameEventContainer &item) = 0; diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp index eb2b2fc1c..dd074a517 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp @@ -208,7 +208,7 @@ Response::ResponseCode Server_ProtocolHandler::processSessionCommandContainer(co Response::ResponseCode resp = Response::RespInvalidCommand; const SessionCommand &sc = cont.session_command(i); const int num = getPbExtension(sc); - if (num != SessionCommand::PING) { // don't log ping commands + if (num != SessionCommand::PING && logsDebugMessages()) { // don't log ping commands logDebugMessage(getSafeDebugString(sc)); } const auto commandType = static_cast(num); @@ -271,7 +271,9 @@ Response::ResponseCode Server_ProtocolHandler::processRoomCommandContainer(const Response::ResponseCode resp = Response::RespInvalidCommand; const RoomCommand &sc = cont.room_command(i); const int num = getPbExtension(sc); - logDebugMessage(getSafeDebugString(sc)); + if (logsDebugMessages()) { + logDebugMessage(getSafeDebugString(sc)); + } switch ((RoomCommand::RoomCommandType)num) { case RoomCommand::LEAVE_ROOM: resp = cmdLeaveRoom(sc.GetExtension(Command_LeaveRoom::ext), room, rc); @@ -320,14 +322,14 @@ Response::ResponseCode Server_ProtocolHandler::processGameCommandContainer(const return Response::RespLoginNeeded; } - QMap> gameMap = getGames(); - if (!gameMap.contains(cont.game_id())) { + int roomId; + int playerId; + if (!getGameInfo(cont.game_id(), roomId, playerId)) { return Response::RespNotInRoom; } - const QPair roomIdAndPlayerId = gameMap.value(cont.game_id()); QReadLocker roomsLocker(&server->roomsLock); - Server_Room *room = server->getRooms().value(roomIdAndPlayerId.first); + Server_Room *room = server->getRooms().value(roomId); if (!room) { return Response::RespNotInRoom; } @@ -337,14 +339,14 @@ Response::ResponseCode Server_ProtocolHandler::processGameCommandContainer(const if (!game) { if (room->getExternalGames().contains(cont.game_id())) { server->sendIsl_GameCommand(cont, room->getExternalGames().value(cont.game_id()).server_id(), - userInfo->session_id(), roomIdAndPlayerId.first, roomIdAndPlayerId.second); + userInfo->session_id(), roomId, playerId); return Response::RespNothing; } return Response::RespNotInRoom; } QMutexLocker gameLocker(&game->gameMutex); - auto *participant = game->getParticipants().value(roomIdAndPlayerId.second); + auto *participant = game->getParticipants().value(playerId); if (!participant) { return Response::RespNotInRoom; } @@ -357,8 +359,9 @@ Response::ResponseCode Server_ProtocolHandler::processGameCommandContainer(const Response::ResponseCode finalResponseCode = Response::RespOk; for (int i = cont.game_command_size() - 1; i >= 0; --i) { const GameCommand &sc = cont.game_command(i); - logDebugMessage(QString("game %1 player %2: ").arg(cont.game_id()).arg(roomIdAndPlayerId.second) + - getSafeDebugString(sc)); + if (logsDebugMessages()) { + logDebugMessage(QString("game %1 player %2: ").arg(cont.game_id()).arg(playerId) + getSafeDebugString(sc)); + } if (commandCountingInterval > 0) { int totalCount = 0; @@ -407,7 +410,9 @@ Response::ResponseCode Server_ProtocolHandler::processModeratorCommandContainer( Response::ResponseCode resp = Response::RespInvalidCommand; const ModeratorCommand &sc = cont.moderator_command(i); const int num = getPbExtension(sc); - logDebugMessage(getSafeDebugString(sc)); + if (logsDebugMessages()) { + logDebugMessage(getSafeDebugString(sc)); + } resp = processExtendedModeratorCommand(num, sc, rc); if (resp != Response::RespOk) { @@ -434,7 +439,9 @@ Response::ResponseCode Server_ProtocolHandler::processAdminCommandContainer(cons Response::ResponseCode resp = Response::RespInvalidCommand; const AdminCommand &sc = cont.admin_command(i); const int num = getPbExtension(sc); - logDebugMessage(getSafeDebugString(sc)); + if (logsDebugMessages()) { + logDebugMessage(getSafeDebugString(sc)); + } resp = processExtendedAdminCommand(num, sc, rc); if (resp != Response::RespOk) { @@ -461,7 +468,9 @@ Response::ResponseCode Server_ProtocolHandler::processDeveloperCommandContainer( Response::ResponseCode resp = Response::RespInvalidCommand; const DeveloperCommand &sc = cont.developer_command(i); const int num = getPbExtension(sc); - logDebugMessage(getSafeDebugString(sc)); + if (logsDebugMessages()) { + logDebugMessage(getSafeDebugString(sc)); + } resp = processExtendedDeveloperCommand(num, sc, rc); if (resp != Response::RespOk) { diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h index c0a1ffe9a..266c4b26c 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h @@ -55,6 +55,17 @@ protected: virtual void logDebugMessage(const QString & /* message */) { } + /** + * @brief Whether debug messages produced by this handler are actually written anywhere. + * + * Formatting a protocol message for the log is expensive, so callers only + * build the debug string when this returns true. The base handler has no + * log destination and therefore never needs one. + */ + [[nodiscard]] virtual bool logsDebugMessages() const + { + return false; + } private: QList messageSizeOverTime, messageCountOverTime, commandCountOverTime; diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt index bd0b5bda2..059e83096 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt @@ -174,6 +174,8 @@ set(PROTO_FILES response_report_user_info.proto response_moderator_last_logins.proto response_remove_user_avatar.proto + response_remove_user_banner.proto + response_clear_blacklisted_banners.proto response_reset_user_password.proto response_user_alts.proto response_user_sessions.proto diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto index 5298d3dad..292917fa9 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto @@ -20,6 +20,8 @@ message ModeratorCommand { GET_MODERATOR_LAST_LOGINS = 1015; RESET_USER_PASSWORD = 1016; REMOVE_USER_AVATAR = 1017; + REMOVE_USER_BANNER = 1018; + CLEAR_BLACKLISTED_BANNERS = 1019; REPORT_LIST = 1200; REPORT_ASSIGN = 1201; REPORT_RESOLVE = 1202; @@ -179,3 +181,16 @@ message Command_RemoveUserAvatar { } optional string user_name = 1; } + +message Command_RemoveUserBanner { + extend ModeratorCommand { + optional Command_RemoveUserBanner ext = 1018; + } + optional string user_name = 1; +} + +message Command_ClearBlacklistedBanners { + extend ModeratorCommand { + optional Command_ClearBlacklistedBanners ext = 1019; + } +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_clear_blacklisted_banners.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_clear_blacklisted_banners.proto new file mode 100644 index 000000000..86cbc92f3 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_clear_blacklisted_banners.proto @@ -0,0 +1,9 @@ +syntax = "proto2"; +import "response.proto"; + +message Response_ClearBlacklistedBanners { + extend Response { + optional Response_ClearBlacklistedBanners ext = 1222; + } + optional uint32 cleared_count = 1 [default = 0]; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_remove_user_banner.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_remove_user_banner.proto new file mode 100644 index 000000000..bfad7333c --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_remove_user_banner.proto @@ -0,0 +1,11 @@ +syntax = "proto2"; +import "response.proto"; + +message Response_RemoveUserBanner { + extend Response { + optional Response_RemoveUserBanner ext = 1221; + } + optional string user_name = 1; + // false when the user exists but had no banner card set, so nothing was cleared + optional bool had_banner = 2 [default = true]; +} diff --git a/libcockatrice_rng/CMakeLists.txt b/libcockatrice_rng/CMakeLists.txt index 6128e3f6e..c5b19ae85 100644 --- a/libcockatrice_rng/CMakeLists.txt +++ b/libcockatrice_rng/CMakeLists.txt @@ -4,10 +4,8 @@ set(CMAKE_AUTORCC ON) set(HEADERS libcockatrice/rng/rng_abstract.h libcockatrice/rng/rng_sfmt.h libcockatrice/rng/sfmt/SFMT.h) -qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) - add_library( - libcockatrice_rng STATIC ${MOC_SOURCES} libcockatrice/rng/rng_abstract.cpp libcockatrice/rng/rng_sfmt.cpp + libcockatrice_rng STATIC ${HEADERS} libcockatrice/rng/rng_abstract.cpp libcockatrice/rng/rng_sfmt.cpp libcockatrice/rng/sfmt/SFMT.c ) diff --git a/libcockatrice_rng/libcockatrice/rng/sfmt/SFMT.h b/libcockatrice_rng/libcockatrice/rng/sfmt/SFMT.h index 34d9e746f..df114cf9b 100644 --- a/libcockatrice_rng/libcockatrice/rng/sfmt/SFMT.h +++ b/libcockatrice_rng/libcockatrice/rng/sfmt/SFMT.h @@ -113,6 +113,14 @@ union W128_T { /** 128-bit data type */ typedef union W128_T w128_t; +#if defined(__AVX2__) +/* The AVX2 state below names __m256i (and __m512i under AVX-512VL). The SIMD header + above is only pulled in for HAVE_SSE2, so a build that has __AVX2__ from the compiler + (e.g. -march=native) but not HAVE_SSE2 from the build system would otherwise not have + those types declared. */ + #include +#endif + /** * SFMT internal state */ diff --git a/libcockatrice_settings/CMakeLists.txt b/libcockatrice_settings/CMakeLists.txt index 5a2fa42a9..d23fb3350 100644 --- a/libcockatrice_settings/CMakeLists.txt +++ b/libcockatrice_settings/CMakeLists.txt @@ -31,11 +31,9 @@ set(HEADERS libcockatrice/settings/visual_deck_storage_settings.h ) -qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) - add_library( libcockatrice_settings STATIC - ${MOC_SOURCES} + ${HEADERS} libcockatrice/settings/appearance_settings.cpp libcockatrice/settings/cache_storage_settings.cpp libcockatrice/settings/card_database_settings.cpp diff --git a/libcockatrice_settings/libcockatrice/settings/debug_settings.cpp b/libcockatrice_settings/libcockatrice/settings/debug_settings.cpp index c36e7187e..709155b1c 100644 --- a/libcockatrice_settings/libcockatrice/settings/debug_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/debug_settings.cpp @@ -34,4 +34,4 @@ int DebugSettings::getLocalGamePlayerCount() const QString DebugSettings::getDeckPathForPlayer(const QString &playerName) const { return getValue(playerName, "localgame", "deck").toString(); -} \ No newline at end of file +} diff --git a/libcockatrice_settings/libcockatrice/settings/personal_settings.cpp b/libcockatrice_settings/libcockatrice/settings/personal_settings.cpp index 682144485..79d06ddd4 100644 --- a/libcockatrice_settings/libcockatrice/settings/personal_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/personal_settings.cpp @@ -51,3 +51,24 @@ void PersonalSettings::setSeenTips(const QList &_seenTips) } setValue(QVariant::fromValue(storedTipList), "seenTips", "tipOfDay"); } + +PersonalSettings::CrashReportingMode PersonalSettings::getCrashReportingMode() const +{ + const int raw = getValue("crashReportingMode", QString(), QString(), 0).toInt(); + switch (raw) { + case 1: + return CrashReportingMode::AlwaysSend; + case 2: + return CrashReportingMode::NeverSend; + case 0: + default: + // Clamp corrupt ini values back to Ask; the QSettings key string is unchanged (no migration). + return CrashReportingMode::Ask; + } +} + +void PersonalSettings::setCrashReportingMode(CrashReportingMode _crashReportingMode) +{ + setValue(static_cast(_crashReportingMode), "crashReportingMode"); + emit crashReportingModeChanged(_crashReportingMode); +} diff --git a/libcockatrice_settings/libcockatrice/settings/personal_settings.h b/libcockatrice_settings/libcockatrice/settings/personal_settings.h index 0819af41b..0cd51cdd0 100644 --- a/libcockatrice_settings/libcockatrice/settings/personal_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/personal_settings.h @@ -24,8 +24,19 @@ public: void setShowTipsOnStartup(bool _showTipsOnStartup); void setSeenTips(const QList &_seenTips); + enum class CrashReportingMode + { + Ask = 0, ///< Show the crash-report dialog after an unclean exit. + AlwaysSend = 1, ///< Upload crash reports without asking. + NeverSend = 2 ///< Never upload; stay silent. + }; + + [[nodiscard]] CrashReportingMode getCrashReportingMode() const; + void setCrashReportingMode(CrashReportingMode _crashReportingMode); + signals: void langChanged(); + void crashReportingModeChanged(CrashReportingMode mode); public: explicit PersonalSettings(const QString &settingPath, QObject *parent = nullptr); diff --git a/libcockatrice_utility/CMakeLists.txt b/libcockatrice_utility/CMakeLists.txt index 9b21a071f..55f1daf24 100644 --- a/libcockatrice_utility/CMakeLists.txt +++ b/libcockatrice_utility/CMakeLists.txt @@ -21,12 +21,15 @@ set(UTILITY_HEADERS libcockatrice/utility/card_ref.h libcockatrice/utility/color.h libcockatrice/utility/cryptoutil.h + libcockatrice/utility/debouncer.h libcockatrice/utility/expression.h + libcockatrice/utility/installer_close_protocol.h libcockatrice/utility/levenshtein.h libcockatrice/utility/local_server_name.h libcockatrice/utility/macros.h libcockatrice/utility/passwordhasher.h libcockatrice/utility/playmat_params.h + libcockatrice/utility/qt_utils.h libcockatrice/utility/report_categories.h libcockatrice/utility/string_limits.h libcockatrice/utility/dice_limits.h diff --git a/libcockatrice_utility/libcockatrice/utility/debouncer.h b/libcockatrice_utility/libcockatrice/utility/debouncer.h new file mode 100644 index 000000000..54635db4f --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/debouncer.h @@ -0,0 +1,62 @@ +#ifndef COCKATRICE_DEBOUNCER_H +#define COCKATRICE_DEBOUNCER_H + +#include +#include +#include +#include + +namespace QtUtils +{ +/** + * @brief Runs the most recent call() after a period without further calls. + * + * Typical use is coalescing rapid model or editor updates into one refresh: + * + * debouncer.call([this] { refresh(); }); + * + * The pending callback is owned by the debouncer and replaced when call() is invoked + * again. The debouncer must outlive any callback it schedules; parenting it to the + * owning widget is usually enough. + */ +class Debouncer : public QObject +{ +public: + explicit Debouncer(int intervalMs, QObject *parent = nullptr) : QObject(parent) + { + timer.setSingleShot(true); + timer.setInterval(intervalMs); + connect(&timer, &QTimer::timeout, this, [this] { + if (callback) { + auto pending = std::move(callback); + callback = nullptr; + pending(); + } + }); + } + + /** + * @brief Schedules @p newCallback, replacing any callback not run yet. + */ + void call(std::function newCallback) + { + callback = std::move(newCallback); + timer.start(); + } + + /** + * @brief Drops the pending callback and stops the timer. + */ + void cancel() + { + timer.stop(); + callback = nullptr; + } + +private: + QTimer timer; + std::function callback; +}; +} // namespace QtUtils + +#endif // COCKATRICE_DEBOUNCER_H diff --git a/libcockatrice_utility/libcockatrice/utility/installer_close_protocol.h b/libcockatrice_utility/libcockatrice/utility/installer_close_protocol.h new file mode 100644 index 000000000..1db467b2f --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/installer_close_protocol.h @@ -0,0 +1,18 @@ +#ifndef COCKATRICE_INSTALLER_CLOSE_PROTOCOL_H +#define COCKATRICE_INSTALLER_CLOSE_PROTOCOL_H + +/** + * @brief The one line the installer's holder probe writes to a running instance. + * + * Both ends include this header: the probe writes the line + * (tools/installer-holder-probe/installer_close_request.cpp) and the client's + * InstallerCloseServer reads it. Keeping the bytes in one place means the two ends cannot + * drift apart, and the test pins the exact line so a change to it is a deliberate protocol + * change rather than a typo in one copy. + * + * Plain C++ on purpose: the probe links no Qt and no part of this project, so this header + * must stay free of both. + */ +inline constexpr char INSTALLER_CLOSE_COMMAND[] = "COCKATRICE_INSTALLER_CLOSE\n"; + +#endif // COCKATRICE_INSTALLER_CLOSE_PROTOCOL_H diff --git a/libcockatrice_utility/libcockatrice/utility/levenshtein.cpp b/libcockatrice_utility/libcockatrice/utility/levenshtein.cpp index f7d8459cb..11eeef19b 100644 --- a/libcockatrice_utility/libcockatrice/utility/levenshtein.cpp +++ b/libcockatrice_utility/libcockatrice/utility/levenshtein.cpp @@ -7,23 +7,40 @@ int levenshteinDistance(const QString &s1, const QString &s2) { - int len1 = s1.size(); - int len2 = s2.size(); - std::vector> dp(len1 + 1, std::vector(len2 + 1)); + const qsizetype len1 = s1.size(); + const qsizetype len2 = s2.size(); - for (int i = 0; i <= len1; i++) { - dp[i][0] = i; + if (len1 == 0) { + return static_cast(len2); } - for (int j = 0; j <= len2; j++) { - dp[0][j] = j; + if (len2 == 0) { + return static_cast(len1); + } + if (s1 == s2) { + return 0; } - for (int i = 1; i <= len1; i++) { - for (int j = 1; j <= len2; j++) { - int cost = (s1[i - 1] == s2[j - 1]) ? 0 : 1; - dp[i][j] = std::min({dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost}); + // Only the previous row of the distance matrix is needed to compute the + // next one, so two rows replace the full len1 x len2 matrix. The card + // search runs this for every matching card on each keystroke. + std::vector previous(len2 + 1); + std::vector current(len2 + 1); + + for (qsizetype j = 0; j <= len2; j++) { + previous[j] = static_cast(j); + } + + for (qsizetype i = 1; i <= len1; i++) { + current[0] = static_cast(i); + const QChar c1 = s1.at(i - 1); + + for (qsizetype j = 1; j <= len2; j++) { + const int cost = (c1 == s2.at(j - 1)) ? 0 : 1; + current[j] = std::min(previous[j] + 1, std::min(current[j - 1] + 1, previous[j - 1] + cost)); } + + std::swap(previous, current); } - return dp[len1][len2]; + return previous[len2]; } diff --git a/libcockatrice_utility/libcockatrice/utility/qt_utils.h b/libcockatrice_utility/libcockatrice/utility/qt_utils.h index 8e5212031..1d06c4d72 100644 --- a/libcockatrice_utility/libcockatrice/utility/qt_utils.h +++ b/libcockatrice_utility/libcockatrice/utility/qt_utils.h @@ -1,13 +1,33 @@ #ifndef COCKATRICE_QT_UTILS_H #define COCKATRICE_QT_UTILS_H +#include +#include +#include #include +#include #include +#include +#include +#include +#include +#include namespace QtUtils { -template T *findParentOfType(const QObject *obj) +/** + * @brief Returns the nearest ancestor of @p obj that casts to @c T. + * + * Walks the QObject parent chain. QWidget::parentWidget() is the same pointer as + * QObject::parent() for widgets, so this also covers the common "find the parent tab, + * splitter or scroll area" lookups. + * + * @param obj Object to start from. May be null. + * @param includeSelf Also test @p obj itself before walking upwards. + * @return The first matching ancestor, or nullptr if there is none. + */ +template T *findParentOfType(const QObject *obj, bool includeSelf = false) { - const QObject *p = obj ? obj->parent() : nullptr; + const QObject *p = includeSelf ? obj : (obj ? obj->parent() : nullptr); while (p) { if (auto casted = qobject_cast(const_cast(p))) { return casted; @@ -17,22 +37,172 @@ template T *findParentOfType(const QObject *obj) return nullptr; } -static inline void clearLayoutRec(QLayout *l) +/** + * @brief How clearLayoutRec() disposes of the widgets it removes. + * + * Deferred is the safe default: deleteLater() lets a widget finish handling the event that + * triggered the rebuild. Use Immediate when the caller rebuilds the same layout right away + * and a lingering widget would be visible or otherwise in the way until the next event loop + * turn. + */ +enum class DeletePolicy +{ + Deferred, + Immediate +}; + +/** + * @brief Removes and deletes every item in @p l, recursing into nested layouts. + * + * Widgets are disposed of according to @p policy; spacers and nested layouts are simply + * deleted. + */ +static inline void clearLayoutRec(QLayout *l, DeletePolicy policy = DeletePolicy::Deferred) { if (!l) { return; } - QLayoutItem *it; - while ((it = l->takeAt(0)) != nullptr) { + while (QLayoutItem *it = l->takeAt(0)) { if (QWidget *w = it->widget()) { - w->deleteLater(); + if (policy == DeletePolicy::Immediate) { + delete w; + } else { + w->deleteLater(); + } } if (QLayout *sub = it->layout()) { - clearLayoutRec(sub); + clearLayoutRec(sub, policy); } delete it; } } + +/** + * @brief Creates a layout of type @c LayoutT with zero margins. + * + * @param parent Parent widget, or nullptr when the layout is added later. + * @param spacing Optional spacing. Values below zero leave the style's default spacing + * untouched; pass a non-negative value to call setSpacing(). + * @param margin Uniform margin. Defaults to zero. + */ +template LayoutT *makeLayout(QWidget *parent = nullptr, int spacing = -1, int margin = 0) +{ + auto *layout = new LayoutT(parent); + layout->setContentsMargins(margin, margin, margin, margin); + if (spacing >= 0) { + layout->setSpacing(spacing); + } + return layout; +} + +/** + * @brief Forces @p layout to recompute and repaint immediately. + * + * Convenience for the invalidate/activate/update sequence used after changing a layout's + * contents outside of a resize event. + */ +inline void relayout(QLayout *layout) +{ + if (!layout) { + return; + } + layout->invalidate(); + layout->activate(); + layout->update(); +} + +/** + * @brief Creates a QScrollArea using the conventions the client applies nearly everywhere: + * the inner widget resizes with the viewport and the horizontal bar is disabled. + * + * @param parent Parent widget, or nullptr for a parentless scroll area. + * @param verticalPolicy Vertical scroll bar policy. + * @param horizontalPolicy Horizontal scroll bar policy. + */ +static inline QScrollArea *makeScrollArea(QWidget *parent = nullptr, + Qt::ScrollBarPolicy verticalPolicy = Qt::ScrollBarAsNeeded, + Qt::ScrollBarPolicy horizontalPolicy = Qt::ScrollBarAlwaysOff) +{ + auto *scrollArea = new QScrollArea(parent); + scrollArea->setWidgetResizable(true); + scrollArea->setVerticalScrollBarPolicy(verticalPolicy); + scrollArea->setHorizontalScrollBarPolicy(horizontalPolicy); + return scrollArea; +} + +/** + * @brief A monospace font at the system UI font's point size. + * + * Used for log dumps, deck codes and other text that has to line up. + */ +static inline QFont monospaceFont() +{ + QFont font(QStringLiteral("monospace")); + font.setStyleHint(QFont::Monospace); + const int systemPointSize = QFontDatabase::systemFont(QFontDatabase::GeneralFont).pointSize(); + if (systemPointSize > 0) { + font.setPointSize(systemPointSize); + } + return font; +} + +/** + * @brief Fades @p window between two opacity values. + * + * @param window Window to animate. + * @param startOpacity Starting opacity. + * @param endOpacity Ending opacity. + * @param durationMs Duration in milliseconds. + * @param hideWhenFinished Hide the window when the animation finishes. + * @return The running animation. It is owned by @p window and deletes itself when stopped. + */ +static inline QPropertyAnimation * +fadeWindow(QWidget *window, qreal startOpacity, qreal endOpacity, int durationMs, bool hideWhenFinished = false) +{ + auto *animation = new QPropertyAnimation(window, "windowOpacity", window); + animation->setDuration(durationMs); + animation->setStartValue(startOpacity); + animation->setEndValue(endOpacity); + if (hideWhenFinished) { + QObject::connect(animation, &QPropertyAnimation::finished, window, &QWidget::hide); + } + animation->start(QAbstractAnimation::DeleteWhenStopped); + return animation; +} + +/** + * @brief Opens @p path with the platform's file manager or default handler. + */ +static inline bool openPathInFileManager(const QString &path) +{ + return QDesktopServices::openUrl(QUrl::fromLocalFile(path)); +} + +/** + * @brief Returns @p font with bold styling enabled. + */ +static inline QFont boldFont(QFont font) +{ + font.setBold(true); + return font; +} + +/** + * @brief Shows a Yes/No confirmation dialog and returns true when the user confirms. + * + * @param parent Parent widget. + * @param title Dialog title. + * @param text Dialog text. + * @param defaultButton Button focused by default. NoButton lets Qt choose. + */ +static inline bool confirm(QWidget *parent, + const QString &title, + const QString &text, + QMessageBox::StandardButton defaultButton = QMessageBox::NoButton) +{ + return QMessageBox::question(parent, title, text, QMessageBox::Yes | QMessageBox::No, defaultButton) == + QMessageBox::Yes; +} } // namespace QtUtils #endif // COCKATRICE_QT_UTILS_H diff --git a/libcockatrice_utility/libcockatrice/utility/translation_loader.cpp b/libcockatrice_utility/libcockatrice/utility/translation_loader.cpp index 41c6107b3..e5761fcaf 100644 --- a/libcockatrice_utility/libcockatrice/utility/translation_loader.cpp +++ b/libcockatrice_utility/libcockatrice/utility/translation_loader.cpp @@ -5,8 +5,10 @@ #include #include #include +#include #include #include +#include #include #include #include @@ -100,6 +102,60 @@ QList TranslationLoader::availableLanguages(const QString &_prefix, con return sortedLanguages; } +QString TranslationLoader::languageDisplayName(const QString &_lang) +{ + if (_lang.isEmpty()) { + return QString(); + } + + // The native name first, so a speaker finds their own language, then the English one, so everyone + // else does. A language shipped with a .qm file belongs in here: the names used to come from the + // translation files, where a missing translation left the selector blank. + static const QHash displayNames = { + {QStringLiteral("ca"), QStringLiteral("Català (Catalan)")}, + {QStringLiteral("cs"), QStringLiteral("Česky (Czech)")}, + {QStringLiteral("de"), QStringLiteral("Deutsch (German)")}, + {QStringLiteral("el"), QStringLiteral("Ελληνικά (Greek)")}, + {QStringLiteral("en@pirate"), QStringLiteral("English, arr! (Pirate English)")}, + {QStringLiteral("en_US"), QStringLiteral("English (United States)")}, + {QStringLiteral("es"), QStringLiteral("Español (Spanish)")}, + {QStringLiteral("et"), QStringLiteral("Eesti Keel (Estonian)")}, + {QStringLiteral("fi"), QStringLiteral("Suomi (Finnish)")}, + {QStringLiteral("fr"), QStringLiteral("Français (French)")}, + {QStringLiteral("hu"), QStringLiteral("Magyar (Hungarian)")}, + {QStringLiteral("it"), QStringLiteral("Italiano (Italian)")}, + {QStringLiteral("ja"), QStringLiteral("日本語 (Japanese)")}, + {QStringLiteral("ko"), QStringLiteral("한국어 (Korean)")}, + {QStringLiteral("nb"), QStringLiteral("Norsk Bokmål (Norwegian Bokmål)")}, + {QStringLiteral("nl"), QStringLiteral("Nederlands (Dutch)")}, + {QStringLiteral("pl"), QStringLiteral("Polski (Polish)")}, + {QStringLiteral("pt"), QStringLiteral("Português (Portuguese)")}, + {QStringLiteral("pt_BR"), QStringLiteral("Português do Brasil (Brazilian Portuguese)")}, + {QStringLiteral("ru"), QStringLiteral("Русский (Russian)")}, + {QStringLiteral("sr"), QStringLiteral("Srpski (Serbian)")}, + {QStringLiteral("sv"), QStringLiteral("Svenska (Swedish)")}, + {QStringLiteral("tr"), QStringLiteral("Türkçe (Turkish)")}, + {QStringLiteral("yue"), QStringLiteral("粵語 (Cantonese)")}, + {QStringLiteral("zh-Hans"), QStringLiteral("简体中文 (Chinese Simplified)")}, + {QStringLiteral("zh-Hant"), QStringLiteral("繁體中文 (Chinese Traditional)")}, + }; + + const QString displayName = displayNames.value(_lang); + if (!displayName.isEmpty()) { + return displayName; + } + + // A language added after this table still gets a label instead of the blank one this function exists + // to prevent. Qt knows no name for an unknown code and answers with an empty string. + const QString nativeName = QLocale(_lang).nativeLanguageName(); + return nativeName.isEmpty() ? _lang : nativeName; +} + +QString TranslationLoader::defaultLanguageCode() +{ + return QStringLiteral("en_US"); +} + TranslationLoadResult TranslationLoader::loadFrom(QTranslator &_translator, const QString &_nameHint, const QList &_directories) { diff --git a/libcockatrice_utility/libcockatrice/utility/translation_loader.h b/libcockatrice_utility/libcockatrice/utility/translation_loader.h index 645489696..53f419cf2 100644 --- a/libcockatrice_utility/libcockatrice/utility/translation_loader.h +++ b/libcockatrice_utility/libcockatrice/utility/translation_loader.h @@ -74,6 +74,26 @@ TranslationLoadResult loadQt(QTranslator &_translator, const QString &_lang); */ QList availableLanguages(const QString &_prefix, const QList &_directories); +/** + * @brief A human-readable name for a language, e.g. "Deutsch (German)". + * + * The names are data, not translations: a language selector has to be readable before any translation + * is loaded, and its labels must not depend on the translation of the language they name. + * + * @param _lang The language code, e.g. "de" or "pt_BR". + * @return The curated name; for a code it does not know, the native language name; the code itself when + * Qt has no name for it either; an empty string when _lang is empty. + */ +QString languageDisplayName(const QString &_lang); + +/** + * @brief The language a client runs with when none is configured, for preselecting a selector entry. + * + * English is the untranslated default and ships no translation file of its own, so a selector has nothing + * of its own to point at and uses the entry that carries an English translation instead. + */ +QString defaultLanguageCode(); + /** * @brief Loads the <_prefix>_<_lang>.qm translation file from _directories into _translator. * diff --git a/oracle/CMakeLists.txt b/oracle/CMakeLists.txt index 0df345812..587463572 100644 --- a/oracle/CMakeLists.txt +++ b/oracle/CMakeLists.txt @@ -63,7 +63,7 @@ set(oracle_RESOURCES oracle.qrc) # ------------------------ # Qt resources # ------------------------ -qt6_add_resources(oracle_RESOURCES_RCC ${oracle_RESOURCES}) +qt_add_resources(oracle_RESOURCES_RCC ${oracle_RESOURCES}) # ------------------------ # Include directories @@ -108,7 +108,7 @@ set(ORACLE_UNIX_QM_INSTALL_DIR "share/oracle/translations") set(ORACLE_WIN32_QM_INSTALL_DIR "translations") # Qt6 Translations are linked after the executable is created in manual mode -qt6_add_executable( +qt_add_executable( oracle WIN32 MACOSX_BUNDLE @@ -283,7 +283,7 @@ endif() if(Qt6LinguistTools_FOUND) #Qt6 Translations happen after the executable is built up if(UPDATE_TRANSLATIONS) - qt6_add_translations( + qt_add_translations( oracle TS_FILES ${oracle_TS} @@ -293,7 +293,7 @@ if(Qt6LinguistTools_FOUND) oracle_QM ) else() - qt6_add_translations(oracle TS_FILES ${oracle_TS} QM_FILES_OUTPUT_VARIABLE oracle_QM) + qt_add_translations(oracle TS_FILES ${oracle_TS} QM_FILES_OUTPUT_VARIABLE oracle_QM) endif() if(UNIX) @@ -319,4 +319,4 @@ if(Qt6LinguistTools_FOUND) endforeach() endif() -qt6_finalize_target(oracle) +qt_finalize_target(oracle) diff --git a/oracle/src/oracleimporter.cpp b/oracle/src/oracleimporter.cpp index 3382e36d6..477c54d21 100644 --- a/oracle/src/oracleimporter.cpp +++ b/oracle/src/oracleimporter.cpp @@ -486,7 +486,7 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson } } - PrintingInfo printingInfo(currentSet, LazyPropertiesHash(printingProps)); + PrintingInfo printingInfo(currentSet, LazyPropertiesHash::serializedFrom(printingProps)); QString numComponent; const QString numProperty = printingInfo.getProperty("num"); diff --git a/oracle/src/oraclewizard.cpp b/oracle/src/oraclewizard.cpp index d2b3a71a4..c33d821a5 100644 --- a/oracle/src/oraclewizard.cpp +++ b/oracle/src/oraclewizard.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -31,9 +32,6 @@ class QWidget; OracleWizard::OracleWizard(QWidget *parent) : QWizard(parent) { - // define a dummy context that will be used where needed - QString dummy = QT_TRANSLATE_NOOP("i18n", "English"); - #ifdef Q_OS_WIN setWizardStyle(QWizard::ModernStyle); #endif @@ -53,7 +51,7 @@ OracleWizard::OracleWizard(QWidget *parent) : QWizard(parent) // foreignData for any other language is never imported. importer->setCardLang(SettingsCache::instance().cardsDisplay().getCardLang()); - nam = new QNetworkAccessManager(this); + nam = NetworkManagerFactory::create(this); QList pages; diff --git a/oracle/src/pages.cpp b/oracle/src/pages.cpp index ed8e04d1a..4c664a38c 100644 --- a/oracle/src/pages.cpp +++ b/oracle/src/pages.cpp @@ -10,7 +10,6 @@ #include #include #include -#include #include #include #include @@ -39,7 +38,6 @@ #include #include #include -#include #include #include #include @@ -203,16 +201,23 @@ IntroPage::IntroPage(QWidget *parent) : OracleWizardPage(parent) QStringList languageCodes = findQmFiles(); for (const QString &code : languageCodes) { - QString langName = languageName(code); - languageBox->addItem(langName, code); + languageBox->addItem(TranslationLoader::languageDisplayName(code), code); } - QString setLanguage = QCoreApplication::translate("i18n", DEFAULT_LANG_NAME); - int index = languageBox->findText(setLanguage, Qt::MatchExactly); - if (index == -1) { - qWarning() << "could not find language" << setLanguage; - } else { + // Select the language the wizard runs with: its own entry, the base language for a variant without a + // file of its own (de_CH -> de), and the default language for an unset setting, which names no file. + const QString configuredLanguage = SettingsCache::instance().personal().getLang(); + int index = languageBox->findData(configuredLanguage); + if (index < 0) { + index = languageBox->findData(configuredLanguage.section('_', 0, 0)); + } + if (index < 0) { + index = languageBox->findData(TranslationLoader::defaultLanguageCode()); + } + if (index >= 0) { languageBox->setCurrentIndex(index); + } else { + qWarning() << "could not find language" << configuredLanguage; } connect(languageBox, qOverload(&QComboBox::currentIndexChanged), this, &IntroPage::languageBoxChanged); @@ -239,18 +244,6 @@ QStringList IntroPage::findQmFiles() TranslationLoader::applicationTranslationPaths(translationPath)); } -QString IntroPage::languageName(const QString &lang) -{ - QTranslator qTranslator; - - // Only the language name is needed here, so the file is loaded without logging a line per - // language: findQmFiles() asked for languages whose files exist in the first place. - TranslationLoader::loadFrom(qTranslator, translationPrefix + "_" + lang, - TranslationLoader::applicationTranslationPaths(translationPath)); - - return qTranslator.translate("i18n", DEFAULT_LANG_NAME); -} - void IntroPage::languageBoxChanged(int index) { SettingsCache::instance().personal().setLang(languageBox->itemData(index).toString()); diff --git a/oracle/src/pages.h b/oracle/src/pages.h index 221f032d3..0530010ba 100644 --- a/oracle/src/pages.h +++ b/oracle/src/pages.h @@ -31,7 +31,6 @@ public: private: QStringList findQmFiles(); - QString languageName(const QString &lang); private: QLabel *label, *languageLabel, *versionLabel; diff --git a/publish_beta.sh b/publish_beta.sh new file mode 100755 index 000000000..b0c3960b3 --- /dev/null +++ b/publish_beta.sh @@ -0,0 +1,186 @@ +#!/bin/bash +# Publish a new Development beta tag. +# Wraps the manual flow: +# git checkout master && git remote update -p && git pull +# git tag -Development--beta[.N] +# git push upstream +# +# Tag convention (see cmake/getversion.cmake): +# first beta for a version: YYYY-MM-DD-Development-X.Y.Z-beta +# subsequent betas: YYYY-MM-DD-Development-X.Y.Z-beta.2, .3, ... +# Version defaults to the PROJECT VERSION in CMakeLists.txt. +# +# Usage: +# ./publish_beta.sh [--dry-run] [--yes] [--version X.Y.Z] [--remote upstream] [--branch master] + +set -euo pipefail + +REMOTE="upstream" +BRANCH="master" +VERSION="" +DRY_RUN=0 +ASSUME_YES=0 + +usage() { + cat <&2 + usage >&2 + exit 1 + ;; + esac +done + +cd "$(dirname "${BASH_SOURCE[0]}")" || exit 2 + +if ! git rev-parse --git-dir >/dev/null 2>&1; then + echo "Error: not inside a git repository." >&2 + exit 1 +fi + +if [[ -z "$VERSION" ]]; then + VERSION="$(grep -m1 -oP 'project\(\s*"Cockatrice"\s+VERSION\s+\K[0-9]+\.[0-9]+\.[0-9]+' CMakeLists.txt || true)" +fi +if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: could not determine version (got '$VERSION'). Pass --version X.Y.Z." >&2 + exit 1 +fi + +if [[ -n "$(git status --porcelain)" ]]; then + echo "Warning: working tree has uncommitted changes. They will not be included in the tag." >&2 + git status --short >&2 +fi + +echo "==> git checkout $BRANCH" +if [[ "$DRY_RUN" -eq 0 ]]; then + git checkout "$BRANCH" +else + echo "(dry-run) skipped" +fi + +echo "==> git remote update -p" +if [[ "$DRY_RUN" -eq 0 ]]; then + git remote update -p +else + echo "(dry-run) skipped" +fi + +echo "==> git pull --ff-only" +if [[ "$DRY_RUN" -eq 0 ]]; then + git pull --ff-only + git fetch --tags "$REMOTE" +else + echo "(dry-run) skipped" +fi + +DATE="$(date +%F)" +PATTERN="*-Development-${VERSION}-beta*" + +# Highest beta number used so far for this version. +# A bare "-beta" (no suffix) counts as 1; "-beta.N" counts as N. +MAX_BETA=0 +while IFS= read -r tag; do + [[ -n "$tag" ]] || continue + if [[ "$tag" =~ -beta\.([0-9]+)$ ]]; then + NUM="${BASH_REMATCH[1]}" + elif [[ "$tag" =~ -beta$ ]]; then + NUM=1 + else + continue + fi + if [[ "$NUM" -gt "$MAX_BETA" ]]; then + MAX_BETA="$NUM" + fi +done < <(git tag --list "$PATTERN") + +NEXT_BETA=$((MAX_BETA + 1)) +if [[ "$NEXT_BETA" -le 1 ]]; then + NEW_TAG="${DATE}-Development-${VERSION}-beta" +else + NEW_TAG="${DATE}-Development-${VERSION}-beta.${NEXT_BETA}" +fi + +echo "" +echo "Version: $VERSION" +echo "Latest beta: $MAX_BETA (for $VERSION)" +echo "New tag: $NEW_TAG" +echo "Head: $(git rev-parse --short HEAD) $(git log -1 --oneline | head -c 120)" +echo "" + +if git rev-parse -q --verify "refs/tags/${NEW_TAG}" >/dev/null; then + echo "Error: tag $NEW_TAG already exists locally." >&2 + exit 1 +fi +if git ls-remote --tags "$REMOTE" | grep -q "refs/tags/${NEW_TAG}$"; then + echo "Error: tag $NEW_TAG already exists on $REMOTE." >&2 + exit 1 +fi + +if [[ "$DRY_RUN" -eq 1 ]]; then + echo "(dry-run) would run: git tag $NEW_TAG" + echo "(dry-run) would run: git push $REMOTE $NEW_TAG" + exit 0 +fi + +if [[ "$ASSUME_YES" -eq 0 ]]; then + read -r -p "Create tag $NEW_TAG and push to $REMOTE? [y/N] " answer + if [[ ! "$answer" =~ ^[Yy]$ ]]; then + echo "Aborted." + exit 1 + fi +fi + +git tag "$NEW_TAG" +git push "$REMOTE" "$NEW_TAG" + +echo "Done: $NEW_TAG" diff --git a/servatrice/CMakeLists.txt b/servatrice/CMakeLists.txt index 255469934..9d8dd46d2 100644 --- a/servatrice/CMakeLists.txt +++ b/servatrice/CMakeLists.txt @@ -56,7 +56,7 @@ if(APPLE) set(servatrice_SOURCES ${servatrice_SOURCES} ${CMAKE_CURRENT_SOURCE_DIR}/resources/appicon.icns) endif(APPLE) -qt6_add_resources(servatrice_RESOURCES_RCC ${servatrice_RESOURCES}) +qt_add_resources(servatrice_RESOURCES_RCC ${servatrice_RESOURCES}) set(QT_DONT_USE_QTGUI TRUE) diff --git a/servatrice/src/isl_interface.cpp b/servatrice/src/isl_interface.cpp index 8733c9a40..8635808ac 100644 --- a/servatrice/src/isl_interface.cpp +++ b/servatrice/src/isl_interface.cpp @@ -59,8 +59,7 @@ void IslInterface::sharedCtor(const QSslCertificate &cert, const QSslKey &privat socket->setPrivateKey(privateKey); connect(socket, SIGNAL(readyRead()), this, SLOT(readClient()), Qt::QueuedConnection); - connect(socket, SIGNAL(error(QAbstractSocket::SocketError)), this, - SLOT(catchSocketError(QAbstractSocket::SocketError))); + connect(socket, &QAbstractSocket::errorOccurred, this, &IslInterface::catchSocketError); connect(this, SIGNAL(outputBufferChanged()), this, SLOT(flushOutputBuffer()), Qt::QueuedConnection); } @@ -68,7 +67,7 @@ IslInterface::IslInterface(int _socketDescriptor, const QSslCertificate &cert, const QSslKey &privateKey, Servatrice *_server) - : QObject(), socketDescriptor(_socketDescriptor), server(_server), messageInProgress(false) + : QObject(), serverId(-1), socketDescriptor(_socketDescriptor), server(_server), messageInProgress(false) { sharedCtor(cert, privateKey); } @@ -264,13 +263,17 @@ void IslInterface::readClient() server->incRxBytes(data.size()); inputBuffer.append(data); - do { + // Parse messages straight out of inputBuffer at readOffset. Removing each + // consumed message from the front would memmove the remaining bytes once + // per message; the consumed prefix is dropped once parsing stops. + qsizetype readOffset = 0; + + while (true) { if (!messageInProgress) { - if (inputBuffer.size() >= 4) { - messageLength = (((quint32)(unsigned char)inputBuffer[0]) << 24) + - (((quint32)(unsigned char)inputBuffer[1]) << 16) + - (((quint32)(unsigned char)inputBuffer[2]) << 8) + - ((quint32)(unsigned char)inputBuffer[3]); + if (inputBuffer.size() - readOffset >= 4) { + const auto *header = reinterpret_cast(inputBuffer.constData() + readOffset); + messageLength = (static_cast(header[0]) << 24) + (static_cast(header[1]) << 16) + + (static_cast(header[2]) << 8) + static_cast(header[3]); // Reject implausible lengths instead of buffering until they arrive. // This also handles lengths that overflow into negative int values. if (messageLength < 0 || messageLength > MAX_ISL_MESSAGE_SIZE) { @@ -278,19 +281,19 @@ void IslInterface::readClient() socket->disconnectFromHost(); return; } - inputBuffer.remove(0, 4); + readOffset += 4; messageInProgress = true; } else { - return; + break; } } - if (inputBuffer.size() < messageLength) { - return; + if (inputBuffer.size() - readOffset < messageLength) { + break; } IslMessage newMessage; - bool ok = newMessage.ParseFromArray(inputBuffer.data(), messageLength); - inputBuffer.remove(0, messageLength); + bool ok = newMessage.ParseFromArray(inputBuffer.constData() + readOffset, messageLength); + readOffset += messageLength; messageInProgress = false; if (ok) { @@ -298,7 +301,12 @@ void IslInterface::readClient() } else { qCWarning(IslInterfaceLog) << "parsing error!"; } - } while (!inputBuffer.isEmpty()); + } + + // Drop the consumed prefix once, instead of shifting the buffer per message. + if (readOffset > 0) { + inputBuffer.remove(0, readOffset); + } } void IslInterface::catchSocketError(QAbstractSocket::SocketError socketError) @@ -495,7 +503,9 @@ void IslInterface::processRoomCommand(const CommandContainer &cont, qint64 sessi void IslInterface::processMessage(const IslMessage &item) { - qCDebug(IslInterfaceLog) << getSafeDebugString(item); + if (IslInterfaceLog().isDebugEnabled()) { + qCDebug(IslInterfaceLog) << getSafeDebugString(item); + } switch (item.message_type()) { case IslMessage::ROOM_COMMAND_CONTAINER: { diff --git a/servatrice/src/main.cpp b/servatrice/src/main.cpp index eb019a2b7..4d513d026 100644 --- a/servatrice/src/main.cpp +++ b/servatrice/src/main.cpp @@ -41,6 +41,7 @@ #include #include #include +#include #include #include #include @@ -162,14 +163,15 @@ int main(int argc, char *argv[]) configPath = SettingsCache::guessConfigurationPath(); } else if (!QFile::exists(configPath)) { qCritical() << "Could not find configuration file at" << configPath; - return 1; + return EXIT_FAILURE; } qWarning() << "Using configuration file: " << configPath; settingsCache = new SettingsCache(configPath); loggerThread = new QThread; loggerThread->setObjectName("logger"); - logger = new ServerLogger(logToConsole); + logger = new ServerLogger(logToConsole, settingsCache->value("server/writelog", 1).toBool(), + settingsCache->value("server/logfilters").toString()); logger->moveToThread(loggerThread); loggerThread->start(); @@ -214,8 +216,16 @@ int main(int argc, char *argv[]) std::cerr << "Server quit." << std::endl; std::cerr << "-------------------------" << std::endl; + } else { + // initServer() logged the reason; a failed startup must not look like a clean exit. + std::cerr << "Server initialization failed, exiting." << std::endl; + retval = EXIT_FAILURE; } + // The handler reads the logger and settingsCache globals torn down below; leaving it + // installed lets Qt's own shutdown warnings dereference freed memory. + qInstallMessageHandler(nullptr); + delete smtpClient; delete rng; delete signalhandler; diff --git a/servatrice/src/servatrice.cpp b/servatrice/src/servatrice.cpp index b6eaf11f4..49d8e1e4c 100644 --- a/servatrice/src/servatrice.cpp +++ b/servatrice/src/servatrice.cpp @@ -308,7 +308,8 @@ bool Servatrice::initServer() authenticationMethod = AuthenticationPassword; } else { if (getRegOnlyServerEnabled()) { - qDebug() << "Registration only server enabled but no authentication method defined: Error."; + qCCritical(ServatriceLog) << "Registration only server enabled but no authentication method defined; " + "refusing to start."; return false; } qDebug() << "Authenticating method: none"; @@ -379,7 +380,7 @@ bool Servatrice::initServer() bool dbOpened = servatriceDatabaseInterface->initDatabase( "QMYSQL", getDBHostNameString(), getDBDatabaseNameString(), getDBUserNameString(), getDBPasswordString()); if (!dbOpened) { - qDebug() << "Failed to open database"; + qCCritical(ServatriceLog) << "Failed to open the database; refusing to start."; return false; } updateServerList(); @@ -600,11 +601,11 @@ bool Servatrice::initServer() if (islServer->listen(QHostAddress::Any, static_cast(getISLNetworkPort()))) { qDebug() << "ISL server listening."; } else { - throw QString("islServer->listen()"); + throw islServer->errorString(); } } } catch (QString &error) { - qDebug() << "ERROR --" << error; + qCCritical(ServatriceLog) << "Failed to start the ISL server:" << error; return false; } @@ -641,7 +642,7 @@ bool Servatrice::initServer() if (gameServer->listen(tcpHost, static_cast(getServerTCPPort()))) { qDebug() << "Server listening."; } else { - qDebug() << "gameServer->listen(): Error:" << gameServer->errorString(); + qCCritical(ServatriceLog) << "Failed to listen for TCP clients:" << gameServer->errorString(); return false; } } @@ -657,7 +658,8 @@ bool Servatrice::initServer() if (websocketGameServer->listen(webSocketHost, static_cast(getServerWebSocketPort()))) { qDebug() << "Websocket server listening."; } else { - qDebug() << "websocketGameServer->listen(): Error:" << websocketGameServer->errorString(); + qCCritical(ServatriceLog) << "Failed to listen for websocket clients:" + << websocketGameServer->errorString(); return false; } } diff --git a/servatrice/src/servatrice_database_interface.cpp b/servatrice/src/servatrice_database_interface.cpp index 04f6047fa..b5c45065c 100644 --- a/servatrice/src/servatrice_database_interface.cpp +++ b/servatrice/src/servatrice_database_interface.cpp @@ -2077,6 +2077,108 @@ bool Servatrice_DatabaseInterface::removeUserAvatar(const QString &userName) return query->numRowsAffected() > 0; } +Servatrice_DatabaseInterface::RemoveUserBannerResult +Servatrice_DatabaseInterface::removeUserBanner(const QString &userName) +{ + if (!checkSql()) { + return RemoveUserBannerResult::DatabaseError; + } + + // The UPDATE alone cannot tell a missing user from a user without a banner, + // so read the row first and let the moderator know which case they hit. + QSqlQuery *lookupQuery = prepareQuery("SELECT card_art_params FROM {prefix}_users WHERE name = :user_name"); + lookupQuery->bindValue(":user_name", userName); + if (!execSqlQuery(lookupQuery)) { + return RemoveUserBannerResult::DatabaseError; + } + if (!lookupQuery->next()) { + return RemoveUserBannerResult::NoSuchUser; + } + if (lookupQuery->value(0).toString().isEmpty()) { + return RemoveUserBannerResult::NothingToRemove; + } + + QSqlQuery *query = prepareQuery("UPDATE {prefix}_users SET card_art_params = NULL WHERE name = :user_name"); + query->bindValue(":user_name", userName); + if (!execSqlQuery(query)) { + return RemoveUserBannerResult::DatabaseError; + } + return RemoveUserBannerResult::Removed; +} + +// isCardArtAllowed() compares against the rule table with the database's +// case-insensitive collation, so the sweep has to fold case the same way or it +// leaves behind banners the server already treats as blacklisted. +static QPair bannerKey(const QString &cardName, const QString &cardProviderId) +{ + return {cardName.toCaseFolded(), cardProviderId.toCaseFolded()}; +} + +int Servatrice_DatabaseInterface::clearBlacklistedBanners() +{ + if (!checkSql()) { + return -1; + } + + QSqlQuery *ruleQuery = prepareQuery("SELECT card_name, card_provider_id FROM {prefix}_card_art_name_rules " + "WHERE mode = 'DENY'"); + if (!execSqlQuery(ruleQuery)) { + return -1; + } + + QSet> denied; + while (ruleQuery->next()) { + denied.insert(bannerKey(ruleQuery->value(0).toString(), ruleQuery->value(1).toString())); + } + if (denied.isEmpty()) { + return 0; + } + + QSqlQuery *userQuery = + prepareQuery("SELECT id, card_art_params FROM {prefix}_users WHERE card_art_params IS NOT NULL"); + if (!execSqlQuery(userQuery)) { + return -1; + } + + QList matchingIds; + while (userQuery->next()) { + const QJsonDocument doc = QJsonDocument::fromJson(userQuery->value(1).toString().toUtf8()); + if (!doc.isObject()) { + continue; + } + const QJsonObject obj = doc.object(); + if (denied.contains(bannerKey(obj.value("card_name").toString(), obj.value("card_provider_id").toString()))) { + matchingIds.append(userQuery->value(0)); + } + } + + if (matchingIds.isEmpty()) { + return 0; + } + + // Chunk the UPDATE so very large user tables do not exceed driver placeholder limits. + int cleared = 0; + constexpr int BATCH_SIZE = 500; + for (int i = 0; i < matchingIds.size(); i += BATCH_SIZE) { + const QList batch = matchingIds.mid(i, BATCH_SIZE); + QStringList placeholders; + placeholders.reserve(batch.size()); + for (int j = 0; j < batch.size(); ++j) { + placeholders.append(QString(":id%1").arg(j)); + } + QSqlQuery *clearQuery = prepareQuery( + QString("UPDATE {prefix}_users SET card_art_params = NULL WHERE id IN (%1)").arg(placeholders.join(","))); + for (int j = 0; j < batch.size(); ++j) { + clearQuery->bindValue(placeholders.at(j), batch.at(j)); + } + if (!execSqlQuery(clearQuery)) { + return -1; + } + cleared += clearQuery->numRowsAffected(); + } + return cleared; +} + QList Servatrice_DatabaseInterface::getMessageLogHistory(const QString &user, const QString &ipaddress, const QString &gamename, diff --git a/servatrice/src/servatrice_database_interface.h b/servatrice/src/servatrice_database_interface.h index 822accada..6b38384e4 100644 --- a/servatrice/src/servatrice_database_interface.h +++ b/servatrice/src/servatrice_database_interface.h @@ -231,6 +231,18 @@ public: }; UptimeSnapshot getLatestUptimeSnapshot(int serverId); bool removeUserAvatar(const QString &userName); + + // Outcome of removeUserBanner(), so the caller can tell a missing user apart + // from a user who simply has no banner card set. + enum class RemoveUserBannerResult + { + Removed, + NothingToRemove, + NoSuchUser, + DatabaseError, + }; + RemoveUserBannerResult removeUserBanner(const QString &userName); + int clearBlacklistedBanners(); bool isCardArtAllowed(const QString &cardName, const QString &cardProviderId) override; bool addForgotPassword(const QString &user); bool removeForgotPassword(const QString &user) override; diff --git a/servatrice/src/server_logger.cpp b/servatrice/src/server_logger.cpp index 1972444f1..09453b7ee 100644 --- a/servatrice/src/server_logger.cpp +++ b/servatrice/src/server_logger.cpp @@ -1,42 +1,45 @@ #include "server_logger.h" -#include "settingscache.h" - #include #include #include #include #include #include +#include #include #include -#include #include #include #include #include #include -ServerLogger::ServerLogger(bool _logToConsole, QObject *parent) +ServerLogger::ServerLogger(bool _logToConsole, bool _writeLog, const QString &_logFilters, QObject *parent) : QObject(parent), logToConsole(_logToConsole), flushRunning(false) { + reloadConfig(_writeLog, _logFilters); + connect(this, SIGNAL(sigFlushBuffer()), this, SLOT(flushBuffer()), Qt::QueuedConnection); } ServerLogger::~ServerLogger() { flushBuffer(); + // logFile is a child of this object and is closed and deleted by ~QObject. // This does not work with the destroyed() signal as this destructor is called after the main event loop is done. thread()->quit(); } void ServerLogger::startLog(const QString &logFileName) { + delete logFile; + logFile = nullptr; + if (!logFileName.isEmpty()) { QFileInfo fi(logFileName); QDir fileDir(fi.path()); if (!fileDir.exists() && !fileDir.mkpath(fileDir.absolutePath())) { std::cerr << "ERROR: logfile folder doesn't exist and i can't create it." << std::endl; - logFile = 0; return; } @@ -44,55 +47,81 @@ void ServerLogger::startLog(const QString &logFileName) if (!logFile->open(QIODevice::Append)) { std::cerr << "ERROR: can't open() logfile." << std::endl; delete logFile; - logFile = 0; + logFile = nullptr; return; } - } else { - logFile = 0; } +} - connect(this, SIGNAL(sigFlushBuffer()), this, SLOT(flushBuffer()), Qt::QueuedConnection); +void ServerLogger::reloadConfig(bool _writeLog, const QString &_logFilters) +{ + const bool filterActive = !_logFilters.trimmed().isEmpty(); + const QStringList filters = _logFilters.split(QStringLiteral(","), Qt::SkipEmptyParts); + + configMutex.lock(); + writeLog = _writeLog; + messageFilterActive = filterActive; + logFilters = filters; + configMutex.unlock(); +} + +bool ServerLogger::isLoggingEnabled() const +{ + // Mirror logMessage(): the console is an independent sink and the logfile needs writeLog. + QMutexLocker locker(&configMutex); + return logToConsole || (logFile != nullptr && writeLog); } void ServerLogger::logMessage(const QString &message, void *caller) { - if (!logFile) { - return; - } - QString callerString; if (caller) { - callerString = QString::number((qulonglong)caller, 16) + " "; + callerString = QString::number(reinterpret_cast(caller), 16) + " "; } - // filter out all log entries based on values in configuration file - bool shouldWeWriteLog = settingsCache->value("server/writelog", 1).toBool(); - QString logFilters = settingsCache->value("server/logfilters").toString(); - QStringList listlogFilters = logFilters.split(",", Qt::SkipEmptyParts); - bool shouldWeSkipLine = false; + const QString line = QDateTime::currentDateTime().toString() + " " + callerString + message; + bool queued = false; - if (!shouldWeWriteLog) { - return; + // The console is an independent sink: writelog and logfilters only describe the logfile + // (see servatrice.ini.example), so a disabled or unopenable logfile must not swallow + // startup errors such as a refused database connection. Lines are buffered for the logger + // thread instead of written here, so a stalled stdout reader cannot stall game threads. + if (logToConsole) { + bufferMutex.lock(); + consoleBuffer.append(line); + bufferMutex.unlock(); + queued = true; } - if (!logFilters.trimmed().isEmpty()) { - shouldWeSkipLine = true; - for (const QString &logFilter : listlogFilters) { - if (message.contains(logFilter, Qt::CaseInsensitive)) { - shouldWeSkipLine = false; - break; + configMutex.lock(); + const bool shouldWriteFile = writeLog; + const bool filterMessages = messageFilterActive; + const QStringList filters = logFilters; + configMutex.unlock(); + + if (logFile && shouldWriteFile) { + // filter out all log entries based on values in configuration file + bool shouldWriteLine = !filterMessages; + if (filterMessages) { + for (const QString &logFilter : filters) { + if (message.contains(logFilter, Qt::CaseInsensitive)) { + shouldWriteLine = true; + break; + } } } + + if (shouldWriteLine) { + bufferMutex.lock(); + buffer.append(line); + bufferMutex.unlock(); + queued = true; + } } - if (shouldWeSkipLine) { - return; + if (queued) { + emit sigFlushBuffer(); } - - bufferMutex.lock(); - buffer.append(QDateTime::currentDateTime().toString() + " " + callerString + message); - bufferMutex.unlock(); - emit sigFlushBuffer(); } void ServerLogger::flushBuffer() @@ -102,25 +131,42 @@ void ServerLogger::flushBuffer() } flushRunning = true; - QTextStream stream(logFile); - forever - { - bufferMutex.lock(); - if (buffer.isEmpty()) { - bufferMutex.unlock(); - flushRunning = false; - return; - } - QString message = buffer.takeFirst(); - bufferMutex.unlock(); - stream << message << "\n"; - stream.flush(); + bufferMutex.lock(); + QStringList consoleLines; + consoleLines.swap(consoleBuffer); + bufferMutex.unlock(); - if (logToConsole) { - std::cout << message.toStdString() << std::endl; - } + for (const QString &line : consoleLines) { + std::cout << line.toStdString() << '\n'; } + if (!consoleLines.isEmpty()) { + std::cout.flush(); + } + + if (logFile) { + QTextStream stream(logFile); + forever + { + bufferMutex.lock(); + if (buffer.isEmpty()) { + bufferMutex.unlock(); + break; + } + QString message = buffer.takeFirst(); + bufferMutex.unlock(); + + stream << message << "\n"; + stream.flush(); + } + } else { + // No logfile to write to; do not let the buffer grow without bound. + bufferMutex.lock(); + buffer.clear(); + bufferMutex.unlock(); + } + + flushRunning = false; } void ServerLogger::rotateLogs() @@ -136,5 +182,3 @@ void ServerLogger::rotateLogs() std::cerr << "ERROR: Failed to open log file for writing!" << std::endl; } } - -QFile *ServerLogger::logFile; diff --git a/servatrice/src/server_logger.h b/servatrice/src/server_logger.h index f2875a6a3..40df9fd62 100644 --- a/servatrice/src/server_logger.h +++ b/servatrice/src/server_logger.h @@ -14,10 +14,20 @@ class ServerLogger : public QObject { Q_OBJECT public: - ServerLogger(bool _logToConsole, QObject *parent = 0); + ServerLogger(bool _logToConsole, bool _writeLog, const QString &_logFilters, QObject *parent = nullptr); ~ServerLogger(); + + /** + * @brief Whether messages passed to logMessage() would be written anywhere. + * + * Callers use this to avoid formatting expensive debug payloads that + * logMessage() would discard. + */ + [[nodiscard]] bool isLoggingEnabled() const; + public slots: void startLog(const QString &logFileName); + void reloadConfig(bool _writeLog, const QString &_logFilters); void logMessage(const QString &message, void *caller = 0); void rotateLogs(); private slots: @@ -27,9 +37,17 @@ signals: private: bool logToConsole; - static QFile *logFile; + bool writeLog = true; + bool messageFilterActive = false; + QStringList logFilters; + // Guards writeLog/messageFilterActive/logFilters: SIGHUP rewrites them while other threads log. + mutable QMutex configMutex; + QFile *logFile = nullptr; bool flushRunning; QStringList buffer; + // Console lines wait here for the logger thread, so game threads never block on stdout. + QStringList consoleBuffer; + // Guards buffer and consoleBuffer. QMutex bufferMutex; }; diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index 890a93fbc..78593f6dc 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -126,6 +126,7 @@ #include #include #include +#include #include #include #include @@ -140,6 +141,7 @@ #include #include #include +#include #include #include #include @@ -265,6 +267,11 @@ void AbstractServerSocketInterface::logDebugMessage(const QString &message) logger->logMessage(message, this); } +bool AbstractServerSocketInterface::logsDebugMessages() const +{ + return logger != nullptr && logger->isLoggingEnabled(); +} + void AbstractServerSocketInterface::processCommandContainer(const CommandContainer &cont) { QElapsedTimer timer; @@ -479,6 +486,10 @@ Response::ResponseCode AbstractServerSocketInterface::processExtendedModeratorCo return cmdGetModeratorLastLogins(cmd.GetExtension(Command_GetModeratorLastLogins::ext), rc); case ModeratorCommand::REMOVE_USER_AVATAR: return cmdRemoveUserAvatar(cmd.GetExtension(Command_RemoveUserAvatar::ext), rc); + case ModeratorCommand::REMOVE_USER_BANNER: + return cmdRemoveUserBanner(cmd.GetExtension(Command_RemoveUserBanner::ext), rc); + case ModeratorCommand::CLEAR_BLACKLISTED_BANNERS: + return cmdClearBlacklistedBanners(cmd.GetExtension(Command_ClearBlacklistedBanners::ext), rc); default: return Response::RespFunctionNotAllowed; } @@ -2686,6 +2697,67 @@ Response::ResponseCode AbstractServerSocketInterface::cmdRemoveUserAvatar(const return Response::RespOk; } +Response::ResponseCode AbstractServerSocketInterface::cmdRemoveUserBanner(const Command_RemoveUserBanner &cmd, + ResponseContainer &rc) +{ + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + const QString userName = nameFromStdString(cmd.user_name()).simplified(); + if (userName.isEmpty()) { + return Response::RespContextError; + } + + bool hadBanner = true; + switch (sqlInterface->removeUserBanner(userName)) { + case Servatrice_DatabaseInterface::RemoveUserBannerResult::Removed: + break; + case Servatrice_DatabaseInterface::RemoveUserBannerResult::NothingToRemove: + hadBanner = false; + break; + case Servatrice_DatabaseInterface::RemoveUserBannerResult::NoSuchUser: + return Response::RespNameNotFound; + case Servatrice_DatabaseInterface::RemoveUserBannerResult::DatabaseError: + default: + return Response::RespInternalError; + } + + if (hadBanner) { + sqlInterface->addAuditRecord(userName, this->getAddress(), QString::fromStdString(userInfo->clientid()), + "REMOVE_USER_BANNER", "Moderator removed user banner card", true); + } + + Response_RemoveUserBanner *re = new Response_RemoveUserBanner; + re->set_user_name(userName.toStdString()); + re->set_had_banner(hadBanner); + rc.setResponseExtension(re); + return Response::RespOk; +} + +Response::ResponseCode +AbstractServerSocketInterface::cmdClearBlacklistedBanners(const Command_ClearBlacklistedBanners & /* cmd */, + ResponseContainer &rc) +{ + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + const int cleared = sqlInterface->clearBlacklistedBanners(); + if (cleared < 0) { + return Response::RespInternalError; + } + + sqlInterface->addAuditRecord(QString::fromStdString(userInfo->name()), this->getAddress(), + QString::fromStdString(userInfo->clientid()), "CLEAR_BLACKLISTED_BANNERS", + QString("Moderator cleared %1 blacklisted banner(s)").arg(cleared), true); + + Response_ClearBlacklistedBanners *re = new Response_ClearBlacklistedBanners; + re->set_cleared_count(static_cast(cleared)); + rc.setResponseExtension(re); + return Response::RespOk; +} + void AbstractServerSocketInterface::sendPendingReportNotifications(ResponseContainer &rc) { if (!sqlInterface->checkSql()) { @@ -4431,13 +4503,17 @@ void TcpServerSocketInterface::readClient() servatrice->incRxBytes(data.size()); inputBuffer.append(data); - do { + // Parse messages straight out of inputBuffer at readOffset. Removing each + // consumed message from the front would memmove the remaining bytes once + // per message; the consumed prefix is dropped once parsing stops. + qsizetype readOffset = 0; + + while (true) { if (!messageInProgress) { - if (inputBuffer.size() >= 4) { - messageLength = (((quint32)(unsigned char)inputBuffer[0]) << 24) + - (((quint32)(unsigned char)inputBuffer[1]) << 16) + - (((quint32)(unsigned char)inputBuffer[2]) << 8) + - ((quint32)(unsigned char)inputBuffer[3]); + if (inputBuffer.size() - readOffset >= 4) { + const auto *header = reinterpret_cast(inputBuffer.constData() + readOffset); + messageLength = (static_cast(header[0]) << 24) + (static_cast(header[1]) << 16) + + (static_cast(header[2]) << 8) + static_cast(header[3]); // Reject implausible lengths instead of buffering until they arrive. // This also handles lengths that overflow into negative int values. if (messageLength < 0 || messageLength > MAX_TCP_MESSAGE_SIZE) { @@ -4446,32 +4522,33 @@ void TcpServerSocketInterface::readClient() prepareDestroy(); return; } - inputBuffer.remove(0, 4); + readOffset += 4; messageInProgress = true; } else { - return; + break; } } - if (inputBuffer.size() < messageLength) { - return; + if (inputBuffer.size() - readOffset < messageLength) { + break; } CommandContainer newCommandContainer; - bool ok; + bool ok = false; try { - ok = newCommandContainer.ParseFromArray(inputBuffer.data(), messageLength); + ok = newCommandContainer.ParseFromArray(inputBuffer.constData() + readOffset, messageLength); } catch (std::exception &e) { - qCWarning(TcpServerSocketInterfaceLog) << "Caught std::exception in" << __FILE__ << __LINE__ << + qCWarning(TcpServerSocketInterfaceLog) + << "Caught std::exception in" << __FILE__ << __LINE__ << #ifdef _MSC_VER // Visual Studio __FUNCTION__ #else __PRETTY_FUNCTION__ #endif - << Qt::endl - << "Exception:" << e.what() << Qt::endl - << "Message coming from:" << getAddress() << Qt::endl - << "Message length:" << messageLength << Qt::endl - << "Message content:" << inputBuffer.toHex(); + << Qt::endl + << "Exception:" << e.what() << Qt::endl + << "Message coming from:" << getAddress() << Qt::endl + << "Message length:" << messageLength << Qt::endl + << "Message content:" << inputBuffer.mid(readOffset, messageLength).toHex(); } catch (...) { qCWarning(TcpServerSocketInterfaceLog) << "Unhandled exception in" << __FILE__ << __LINE__ << #ifdef _MSC_VER // Visual Studio @@ -4482,7 +4559,7 @@ void TcpServerSocketInterface::readClient() << Qt::endl << "Message coming from:" << getAddress(); } - inputBuffer.remove(0, messageLength); + readOffset += messageLength; messageInProgress = false; if (ok) { @@ -4499,8 +4576,12 @@ void TcpServerSocketInterface::readClient() } else { qCWarning(TcpServerSocketInterfaceLog) << "parsing error!"; } + } - } while (!inputBuffer.isEmpty()); + // Drop the consumed prefix once, instead of shifting the buffer per message. + if (readOffset > 0) { + inputBuffer.remove(0, readOffset); + } } bool TcpServerSocketInterface::initTcpSession() @@ -4570,8 +4651,15 @@ void WebsocketServerSocketInterface::initConnection(void *_socket) connect(socket, SIGNAL(binaryMessageReceived(const QByteArray &)), this, SLOT(binaryMessageReceived(const QByteArray &))); + // QWebSocket::errorOccurred was added in Qt 6.5 (error() is deprecated from 6.5 on), + // so a typed connect cannot compile against the 6.4 floor. +#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0) + connect(socket, &QWebSocket::errorOccurred, this, + [this](QAbstractSocket::SocketError error) { catchSocketError(error); }); +#else connect(socket, SIGNAL(error(QAbstractSocket::SocketError)), this, SLOT(catchSocketError(QAbstractSocket::SocketError))); +#endif connect(socket, SIGNAL(disconnected()), this, SLOT(catchSocketDisconnected())); // Add this object to the server's list of connections before it can receive socket events. diff --git a/servatrice/src/serversocketinterface.h b/servatrice/src/serversocketinterface.h index dd9d3f2bb..cd37286c6 100644 --- a/servatrice/src/serversocketinterface.h +++ b/servatrice/src/serversocketinterface.h @@ -73,6 +73,7 @@ class Command_AccountPassword; class Command_Activate; class Command_AddCardArtRule; class Command_AdjustMod; +class Command_ClearBlacklistedBanners; class Command_ForceActivateUser; class Command_ForgotPasswordChallenge; class Command_ForgotPasswordRequest; @@ -90,6 +91,7 @@ class Command_ListCardArtRules; class Command_Register; class Command_RemoveCardArtRule; class Command_RemoveUserAvatar; +class Command_RemoveUserBanner; class Command_Report; class Command_ReportAddComment; class Command_ReportAssign; @@ -123,6 +125,7 @@ signals: protected: void logDebugMessage(const QString &message) override; + [[nodiscard]] bool logsDebugMessages() const override; bool tooManyRegistrationAttempts(const QString &ipAddress); void processCommandContainer(const CommandContainer &cont) override; bool acceptsCredentialFormat(bool passwordNeedsHash, const QString &password) const; @@ -235,6 +238,9 @@ private: Response::ResponseCode cmdGetModeratorLastLogins(const Command_GetModeratorLastLogins &cmd, ResponseContainer &rc); Response::ResponseCode cmdResetUserPassword(const Command_ResetUserPassword &cmd, ResponseContainer &rc); Response::ResponseCode cmdRemoveUserAvatar(const Command_RemoveUserAvatar &cmd, ResponseContainer &rc); + Response::ResponseCode cmdRemoveUserBanner(const Command_RemoveUserBanner &cmd, ResponseContainer &rc); + Response::ResponseCode cmdClearBlacklistedBanners(const Command_ClearBlacklistedBanners &cmd, + ResponseContainer &rc); Response::ResponseCode cmdGetServerStats(const Command_GetServerStats &cmd, ResponseContainer &rc); diff --git a/servatrice/src/signalhandler.cpp b/servatrice/src/signalhandler.cpp index 32cd5b206..b8a7e3538 100644 --- a/servatrice/src/signalhandler.cpp +++ b/servatrice/src/signalhandler.cpp @@ -73,6 +73,8 @@ void SignalHandler::internalSigHupHandler() logger->rotateLogs(); settingsCache->sync(); + logger->reloadConfig(settingsCache->value("server/writelog", 1).toBool(), + settingsCache->value("server/logfilters").toString()); snHup->setEnabled(true); } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 8811b8623..c8d3fbd59 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -178,6 +178,27 @@ cockatrice_add_test(NAME user_data_backup_test SOURCES user_data_backup_test.cpp cockatrice_add_test(NAME password_hash_test SOURCES password_hash_test.cpp LIBS libcockatrice_utility) cockatrice_add_test(NAME report_categories_test SOURCES report_categories_test.cpp LIBS libcockatrice_utility) cockatrice_add_test(NAME warning_categories_test SOURCES warning_categories_test.cpp LIBS libcockatrice_utility) +# The installer's holder probe is Windows-only, but the wording it produces and the +# question which programs are the payload's own are plain C++ and are what a stuck user +# has to act on, so they are tested everywhere. +cockatrice_add_test( + NAME + holder_line_test + SOURCES + ${CMAKE_SOURCE_DIR}/tools/installer-holder-probe/holder_line.cpp + holder_line_test.cpp + INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/tools/installer-holder-probe +) +cockatrice_add_test( + NAME + payload_paths_test + SOURCES + ${CMAKE_SOURCE_DIR}/tools/installer-holder-probe/payload_paths.cpp + payload_paths_test.cpp + INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/tools/installer-holder-probe +) cockatrice_add_test( NAME playmat_resolver_test @@ -230,6 +251,17 @@ cockatrice_add_test( LABELS server ) +cockatrice_add_test( + NAME + server_logger_test + SOURCES + ${CMAKE_SOURCE_DIR}/servatrice/src/server_logger.cpp + server_logger_test.cpp + INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/servatrice/src + LABELS + server +) cockatrice_add_test( NAME server_card_counter_test @@ -358,6 +390,7 @@ cockatrice_add_test( ${VERSION_STRING_CPP} loader_local_matching_test.cpp LIBS + libcockatrice_network libcockatrice_settings INCLUDE_DIRS ${CMAKE_SOURCE_DIR}/cockatrice/src @@ -394,6 +427,7 @@ if(TARGET Qt6::lrelease) libcockatrice_utility DEFINITIONS TRANSLATION_LOADER_TEST_DIR="${CMAKE_CURRENT_BINARY_DIR}" + COCKATRICE_TRANSLATIONS_DIR="${CMAKE_SOURCE_DIR}/cockatrice/translations" ) add_dependencies(translation_loader_test translation_loader_test_qm) @@ -422,6 +456,37 @@ if(WITH_CLIENT) ${CMAKE_SOURCE_DIR}/cockatrice/src ${CMAKE_SOURCE_DIR}/libcockatrice_utility ) + # The installer's probe and the client build the socket name independently, and the + # command they exchange is spelled in both places. Compiling both ends into one test is + # what keeps them from drifting apart unnoticed: a mismatch would leave the installer + # unable to close an instance whose window it cannot reach. + cockatrice_add_test( + NAME + installer_close_server_test + SOURCES + ${CMAKE_SOURCE_DIR}/cockatrice/src/installer_close_server.cpp + ${CMAKE_SOURCE_DIR}/tools/installer-holder-probe/installer_close_name.cpp + installer_close_server_test.cpp + INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/cockatrice/src + ${CMAKE_SOURCE_DIR}/libcockatrice_utility + ${CMAKE_SOURCE_DIR}/tools/installer-holder-probe + ) + # CrashReporter is client code; DEFINITIONS keeps the test hermetic by compiling the + # no-op path (no sentry-native headers needed) regardless of WITH_CRASH_REPORTING. + cockatrice_add_test( + NAME + crash_reporter_test + SOURCES + ${CMAKE_SOURCE_DIR}/cockatrice/src/client/crash/crash_reporter.cpp + crash_reporter_test.cpp + LIBS + libcockatrice_settings + INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/cockatrice/src + DEFINITIONS + COCKATRICE_WITH_CRASH_REPORTING=0 + ) endif() add_subdirectory(card_zone_algorithms) @@ -433,6 +498,11 @@ add_subdirectory(movecard_tests) add_subdirectory(oracle) add_subdirectory(settings) +# libcockatrice_game only exists when the client is built. +if(WITH_CLIENT) + add_subdirectory(game) +endif() + # theme_migration_test compiles client sources, whose headers are only reachable from a client build. if(WITH_CLIENT) add_subdirectory(theme_migration) diff --git a/tests/card_zone_algorithms/CMakeLists.txt b/tests/card_zone_algorithms/CMakeLists.txt index 945f8a0ed..2418092de 100644 --- a/tests/card_zone_algorithms/CMakeLists.txt +++ b/tests/card_zone_algorithms/CMakeLists.txt @@ -1,4 +1,4 @@ cockatrice_add_test( NAME card_zone_algorithms_test SOURCES card_zone_algorithms_test.cpp INCLUDE_DIRS - ${CMAKE_SOURCE_DIR}/cockatrice/src/game/zones + ${CMAKE_SOURCE_DIR}/libcockatrice_game/libcockatrice/game/zones ) diff --git a/tests/card_zone_algorithms/card_zone_algorithms_test.cpp b/tests/card_zone_algorithms/card_zone_algorithms_test.cpp index 4f5282235..3d4baa430 100644 --- a/tests/card_zone_algorithms/card_zone_algorithms_test.cpp +++ b/tests/card_zone_algorithms/card_zone_algorithms_test.cpp @@ -16,7 +16,6 @@ struct MockCard bool cardRefWasCalled = false; bool resetStateCalled = false; bool resetStateKeepAnnotations = false; - bool visibleSet = false; void setId(int id) { @@ -35,11 +34,6 @@ struct MockCard resetStateCalled = true; resetStateKeepAnnotations = keepAnnotations; } - - void setVisible(bool visible) - { - visibleSet = visible; - } }; class MockCardList @@ -109,7 +103,6 @@ TEST_F(AddCardAlgorithmTest, ContentsKnownPreservesIdentity) EXPECT_FALSE(card.idWasCalled); EXPECT_FALSE(card.cardRefWasCalled); - EXPECT_TRUE(card.visibleSet); } TEST_F(AddCardAlgorithmTest, ContentsUnknownClearsIdentity) diff --git a/tests/carddatabase/carddatabase_test.cpp b/tests/carddatabase/carddatabase_test.cpp index 1b48d934b..22cbe9074 100644 --- a/tests/carddatabase/carddatabase_test.cpp +++ b/tests/carddatabase/carddatabase_test.cpp @@ -1,6 +1,7 @@ #include "libcockatrice/card/database/card_database.h" #include "libcockatrice/card/database/card_database_loader.h" #include "libcockatrice/card/database/card_database_querier.h" +#include "libcockatrice/card/printing/exact_card.h" #include "libcockatrice/card/set/card_set.h" #include "libcockatrice/card/set/card_set_list.h" #include "test_card_database_path_provider.h" @@ -16,19 +17,23 @@ #include #include #include +#include #include #include #include #include +#include #include #include #include #include #include +#include #include #include #include #include +#include #include namespace @@ -60,6 +65,94 @@ TEST(CardDatabaseTest, LoadXml) ASSERT_EQ(NotLoaded, db->getLoadStatus()) << "Incorrect status after clear"; } +TEST(CardDatabaseTest, LazyPropertiesHashStreamsBlobAccessors) +{ + QHash props; + props.insert("manacost", "1R"); + props.insert("colors", "R"); + props.insert("format-standard", "legal"); + + LazyPropertiesHash lazy = LazyPropertiesHash::serializedFrom(props); + const QByteArray blob = lazy.serialized(); + + EXPECT_EQ(3, lazy.size()); + EXPECT_FALSE(lazy.isEmpty()); + EXPECT_TRUE(lazy.contains("colors")); + EXPECT_FALSE(lazy.contains("missing")); + EXPECT_EQ("1R", lazy.value("manacost")); + EXPECT_EQ(QString(), lazy.value("missing")); + EXPECT_EQ(blob, lazy.serialized()) << "an untouched blob must be passed through unchanged"; + + QStringList keys = lazy.keys(); + keys.sort(); + QStringList expectedKeys = props.keys(); + expectedKeys.sort(); + EXPECT_EQ(expectedKeys, keys); + + // Mutating materializes the hash, after which every accessor keeps working. + EXPECT_TRUE(lazy.insert("loyalty", "3")); + EXPECT_FALSE(lazy.insert("loyalty", "3")); + EXPECT_EQ(4, lazy.size()); + EXPECT_EQ("3", lazy.value("loyalty")); + EXPECT_TRUE(lazy.contains("loyalty")); + + LazyPropertiesHash roundTrip(lazy.serialized()); + EXPECT_EQ(4, roundTrip.size()); + EXPECT_EQ("3", roundTrip.value("loyalty")); + EXPECT_EQ("legal", roundTrip.value("format-standard")); +} + +TEST(CardDatabaseTest, GuessCardWithProviderIdOnUnknownCardReturnsEmpty) +{ + // An unknown name plus a providerId used to dereference the missing card + // inside findPrintingWithId(); the lookup must come back empty instead. + NoopCardPreferenceProvider prefs; + TestCardDatabasePathProvider pathProvider; + NoopCardSetPriorityController controller; + CardDatabase db(nullptr, &prefs, &pathProvider, &controller); + + const ExactCard guessed = db.query()->guessCard({"Not a Card", "card_Not a Card_fixture"}); + + EXPECT_TRUE(guessed.isEmpty()); + EXPECT_FALSE(guessed); +} + +TEST(CardDatabaseTest, FindPrintingWithIdOnNullCardInfoReturnsEmpty) +{ + NoopCardPreferenceProvider prefs; + TestCardDatabasePathProvider pathProvider; + NoopCardSetPriorityController controller; + CardDatabase db(nullptr, &prefs, &pathProvider, &controller); + + EXPECT_TRUE(db.query()->findPrintingWithId(nullptr, "card_Not a Card_fixture").isEmpty()); +} + +TEST(CardInfoTest, ClearDirectRelationsDropsRawPointers) +{ + auto *forward = new CardRelation("Child"); + auto *reverse = new CardRelation("Parent"); + auto *toMe = new CardRelation("Child"); + + auto card = CardInfo::newInstance("Parent", "", false, QHash(), {forward}, {reverse}, + SetToPrintingsMap(), CardInfo::UiAttributes()); + card->addReverseRelatedCards2Me(toMe); + ASSERT_EQ(1, card->getRelatedCards().size()); + ASSERT_EQ(1, card->getReverseRelatedCards().size()); + ASSERT_EQ(1, card->getReverseRelatedCards2Me().size()); + ASSERT_EQ(2, card->getAllRelatedCards().size()); + + card->clearDirectRelations(); + + EXPECT_TRUE(card->getRelatedCards().isEmpty()); + EXPECT_TRUE(card->getReverseRelatedCards().isEmpty()); + EXPECT_TRUE(card->getReverseRelatedCards2Me().isEmpty()); + EXPECT_TRUE(card->getAllRelatedCards().isEmpty()); + + delete forward; + delete reverse; + delete toMe; +} + TEST(CardDatabaseTest, Xml4LocalizedDataRoundTrip) { NoopCardSetPriorityController controller; @@ -77,6 +170,8 @@ TEST(CardDatabaseTest, Xml4LocalizedDataRoundTrip) CardInfo::newInstance("Lightning Bolt", "Deal 3 damage.", false, {}, {}, {}, setsInfo, attributes); card->setLocalizedName("de", "Blitzschlag"); card->setLocalizedText("de", "Blitzschlag fügt 3 Schadenspunkte zu."); + card->setProperty("format-standard", "legal"); + card->setProperty("colors", "R"); SetNameMap sets; sets.insert("TST", set); @@ -100,6 +195,13 @@ TEST(CardDatabaseTest, Xml4LocalizedDataRoundTrip) ASSERT_EQ(loaded->getLocalizedName("de"), "Blitzschlag"); ASSERT_EQ(loaded->getLocalizedText("de"), "Blitzschlag fügt 3 Schadenspunkte zu."); ASSERT_EQ(loaded->getLocalizedText("fr"), "Deal 3 damage."); + ASSERT_EQ(loaded->getProperty("format-standard"), "legal"); + ASSERT_TRUE(loaded->hasProperty("colors")); + ASSERT_EQ(loaded->getProperties().size(), 2); + + const QList printings = loaded->getSets().value("TST"); + ASSERT_FALSE(printings.isEmpty()); + ASSERT_EQ(printings.first().getProperty("manacost"), "1R"); } /** @@ -508,6 +610,110 @@ TEST_F(CardDatabaseCacheInvalidationTest, MarkAllSetsAsKnownIsBatched) // Ignored sets keep their known state but are enabled. EXPECT_TRUE(controller.isEnabled("XXX")); } + +TEST_F(CardDatabaseCacheInvalidationTest, ReloadReleasesPreviousSnapshot) +{ + NoopCardPreferenceProvider prefs; + SandboxPathProvider pathProvider(root); + CardDatabase db(nullptr, &prefs, &pathProvider, &controller); + + db.loadCardDatabases(); + CardInfoPtr card = db.getCardList().value("Cat"); + ASSERT_FALSE(card.isNull()); + QWeakPointer weakCard(card); + card.clear(); + + db.reloadCardDatabasesAndNotify(); + + // CardSet holds strong CardInfoPtrs and CardInfo holds the CardSetPtr through its printings; if that + // cycle is not broken, the previous snapshot survives the reload. + EXPECT_TRUE(weakCard.isNull()) << "a reload must release the previous card snapshot"; +} + +TEST_F(CardDatabaseCacheInvalidationTest, ClearReleasesCards) +{ + NoopCardPreferenceProvider prefs; + SandboxPathProvider pathProvider(root); + CardDatabase db(nullptr, &prefs, &pathProvider, &controller); + + db.loadCardDatabases(); + CardInfoPtr card = db.getCardList().value("Cat"); + ASSERT_FALSE(card.isNull()); + QWeakPointer weakCard(card); + card.clear(); + + db.clear(); + + EXPECT_TRUE(weakCard.isNull()) << "clear() must release cards held by set membership"; +} + +TEST_F(CardDatabaseCacheInvalidationTest, RemoveCardDetachesItFromItsSet) +{ + // removeCard() is the one teardown path a caller can trigger for a single card (the token editor + // uses it). Without the detach the set's strong reference keeps the removed card alive forever. + NoopCardPreferenceProvider prefs; + SandboxPathProvider pathProvider(root); + CardDatabase db(nullptr, &prefs, &pathProvider, &controller); + + db.loadCardDatabases(); + CardInfoPtr card = db.getCardList().value("Cat"); + ASSERT_FALSE(card.isNull()); + const CardSetPtr catSet = db.getSet("CAT"); + ASSERT_TRUE(catSet->contains(card)); + + db.removeCard(card); + + EXPECT_FALSE(catSet->contains(card)) << "removeCard() must detach the card from its set"; + QWeakPointer weakCard(card); + card.clear(); + EXPECT_TRUE(weakCard.isNull()) << "a removed card must not stay alive through its set"; +} + +TEST_F(CardDatabaseCacheInvalidationTest, RemoveSelfPointerStillNotifiesDatabaseCard) +{ + // cardInfoChanged() used to be emitted through a self pointer stored on the card. Without it the + // card must still notify its observers whenever its properties change. + NoopCardPreferenceProvider prefs; + SandboxPathProvider pathProvider(root); + CardDatabase db(nullptr, &prefs, &pathProvider, &controller); + + db.loadCardDatabases(); + const CardInfoPtr card = db.getCardList().value("Cat"); + ASSERT_FALSE(card.isNull()); + QSignalSpy spy(card.data(), &CardInfo::cardInfoChanged); + + card->setProperty("manacost", "1G"); + + ASSERT_EQ(spy.count(), 1) << "a property change on a database card must still emit cardInfoChanged"; + const QList arguments = spy.takeFirst(); + ASSERT_EQ(arguments.size(), 1); + EXPECT_EQ(arguments.first().value().data(), card.data()) + << "the signal must carry the card that changed"; +} + +TEST_F(CardDatabaseCacheInvalidationTest, RemoveSelfPointerCloneNotifiesAsItself) +{ + // A clone owns its own shared reference, so its changes must be reported as the clone's, never the + // original's: decks and views listening to the original must not be invalidated by a clone edit. + NoopCardPreferenceProvider prefs; + SandboxPathProvider pathProvider(root); + CardDatabase db(nullptr, &prefs, &pathProvider, &controller); + + db.loadCardDatabases(); + const CardInfoPtr original = db.getCardList().value("Cat"); + ASSERT_FALSE(original.isNull()); + const CardInfoPtr clone = original->clone(); + QSignalSpy originalSpy(original.data(), &CardInfo::cardInfoChanged); + QSignalSpy cloneSpy(clone.data(), &CardInfo::cardInfoChanged); + + clone->setProperty("manacost", "1G"); + + ASSERT_EQ(cloneSpy.count(), 1) << "the clone must notify its own observers"; + EXPECT_EQ(cloneSpy.first().first().value().data(), clone.data()) + << "the clone's signal must carry the clone"; + EXPECT_EQ(originalSpy.count(), 0) << "the clone must not notify the original's observers"; +} + } // namespace int main(int argc, char **argv) diff --git a/tests/carddatabase/filter_string_test.cpp b/tests/carddatabase/filter_string_test.cpp index 95225e28c..e561db590 100644 --- a/tests/carddatabase/filter_string_test.cpp +++ b/tests/carddatabase/filter_string_test.cpp @@ -99,6 +99,17 @@ QUERY(SetCodeWrongSet2, doctor, "set:cat", false) QUERY(BracketNextToUnquotedString, cat, "(o:woof OR o:meow)", true) +// Spaces around the colon are optional for all field queries (e.g. "o: flying") +QUERY(OracleSpaceAfterColon, cat, "o: meow", true) +QUERY(OracleSpaceBeforeColon, cat, "o :meow", true) +QUERY(OracleSpacesAroundColon, cat, "o : meow", true) +QUERY(OracleSpaceAfterColonNoMatch, cat, "o: woof", false) +QUERY(TypeSpaceAfterColon, cat, "t: creature", true) +QUERY(FieldSpaceAfterColon, cat, "pt: \"3/3\"", true) +QUERY(ColorSpaceAfterColon, cat, "c: g", true) +QUERY(ColorSpaceBeforeOperator, cat, "c !g", true) +QUERY(SetSpaceAfterColon, cat, "set: cat", true) + CardInfoPtr localizedCat() { CardInfoPtr localized = CardInfo::newInstance("Cat", "Meow!", false, {}, {}, {}, {}, {}); @@ -107,6 +118,22 @@ CardInfoPtr localizedCat() return localized; } +CardInfoPtr greenIdentityCard() +{ + return CardInfo::newInstance("Identity Cat", "", false, {{"colors", "G"}, {"coloridentity", "G"}}, {}, {}, {}, {}); +} + +// 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) +{ + const CardData identityCard = greenIdentityCard(); + ASSERT_TRUE(FilterString("ci:g").check(identityCard)); + ASSERT_TRUE(FilterString("ci: g").check(identityCard)); + ASSERT_TRUE(FilterString("ci : g").check(identityCard)); + ASSERT_FALSE(FilterString("c i: g").check(identityCard)); +} + TEST_F(CardQuery, SearchLanguageEnglishMatchesOnlyEnglish) { const CardData localized = localizedCat(); @@ -136,6 +163,13 @@ TEST_F(CardQuery, SearchLanguageBothMatchesEitherLanguage) ASSERT_TRUE(FilterString("Kater", CardSearchLanguage{"de", SearchLanguageMode::Both}).check(localized)); } +TEST_F(CardQuery, SearchLanguageBothMatchesOracleTextWithSpaceAfterColon) +{ + const CardData localized = localizedCat(); + ASSERT_TRUE(FilterString("o: Meow", CardSearchLanguage{"de", SearchLanguageMode::Both}).check(localized)); + ASSERT_TRUE(FilterString("o: miaut", CardSearchLanguage{"de", SearchLanguageMode::Both}).check(localized)); +} + TEST_F(CardQuery, SearchLanguageIsBoundPerInstance) { const CardData localized = localizedCat(); diff --git a/tests/carddatabase/load_benchmark.cpp b/tests/carddatabase/load_benchmark.cpp index 5492b9531..da077a50f 100644 --- a/tests/carddatabase/load_benchmark.cpp +++ b/tests/carddatabase/load_benchmark.cpp @@ -125,7 +125,7 @@ RunResult runLoad(CardDatabase *db, ChurnCounters &counters) result.churn.modelResets = counters.modelResets - before.modelResets; for (const CardInfoPtr &card : db->getCardList()) { - result.propTotal += card->getPropertiesHash().size(); + result.propTotal += card->getProperties().size(); if (!card->getProperty("colors").isEmpty()) { ++result.propsQueried; } diff --git a/tests/crash_reporter_test.cpp b/tests/crash_reporter_test.cpp new file mode 100644 index 000000000..e44a34a60 --- /dev/null +++ b/tests/crash_reporter_test.cpp @@ -0,0 +1,427 @@ +#include "client/crash/crash_reporter.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + +TEST(CrashReporterTest, SanitizeLogLineStripsAnsiColorCodes) +{ + ASSERT_EQ(CrashReporter::sanitizeLogLine(QStringLiteral("\033[32mhello\033[0m")), QStringLiteral("hello")); + ASSERT_EQ(CrashReporter::sanitizeLogLine(QStringLiteral("\033[1;31mfatal\033[0m")), QStringLiteral("fatal")); + ASSERT_EQ(CrashReporter::sanitizeLogLine(QStringLiteral("plain line")), QStringLiteral("plain line")); +} + +TEST(CrashReporterTest, RedactCockatriceUrlsKeepsSchemeAndHostOnly) +{ + ASSERT_EQ( + CrashReporter::redactCockatriceUrls(QStringLiteral("open cockatrice://opendeck?token=secret&user=me now")), + QStringLiteral("open cockatrice://opendeck now")); +} + +TEST(CrashReporterTest, RedactCockatriceUrlsIsIdempotent) +{ + // A secret-free URL must not match again after redaction (regression: this hung forever). + ASSERT_EQ(CrashReporter::redactCockatriceUrls(QStringLiteral("open cockatrice://opendeck now")), + QStringLiteral("open cockatrice://opendeck now")); +} + +TEST(CrashReporterTest, RedactCockatriceUrlsLeavesOtherTextAlone) +{ + ASSERT_EQ(CrashReporter::redactCockatriceUrls(QStringLiteral("https://cockatrice.github.io/?token=secret")), + QStringLiteral("https://cockatrice.github.io/?token=secret")); + ASSERT_EQ(CrashReporter::sanitizeLogLine(QStringLiteral("")), QStringLiteral("")); +} + +TEST(CrashReporterTest, InitSkippedWithoutDsn) +{ + CrashReporter reporter; + reporter.init(QString(), QStringLiteral("3.1.2"), QDir::tempPath() + QStringLiteral("/cockatrice-crash-test"), + PersonalSettings::CrashReportingMode::Ask); + ASSERT_FALSE(reporter.isReady()); + reporter.shutdown(); + ASSERT_FALSE(reporter.isReady()); +} + +TEST(CrashReporterTest, InitSkippedWhenNeverSend) +{ + CrashReporter reporter; + reporter.init(QStringLiteral("https://key@sentry.io/1"), QStringLiteral("3.1.2"), + QDir::tempPath() + QStringLiteral("/cockatrice-crash-test"), + PersonalSettings::CrashReportingMode::NeverSend); + ASSERT_FALSE(reporter.isReady()); + reporter.shutdown(); +} + +// The init() tests above compile to no-ops when the SDK is off, so they pass +// regardless of the gating; the gating decision itself is covered here, in both +// build modes, through the SDK-independent helper init() consults. +TEST(CrashReporterTest, ReportingEnabledGating) +{ + using Mode = PersonalSettings::CrashReportingMode; + const QString dsn = QStringLiteral("https://key@sentry.io/1"); + ASSERT_FALSE(CrashReporter::isReportingEnabled(QString(), Mode::Ask)); + ASSERT_FALSE(CrashReporter::isReportingEnabled(QString(), Mode::AlwaysSend)); + ASSERT_FALSE(CrashReporter::isReportingEnabled(QString(), Mode::NeverSend)); + ASSERT_TRUE(CrashReporter::isReportingEnabled(dsn, Mode::Ask)); + ASSERT_TRUE(CrashReporter::isReportingEnabled(dsn, Mode::AlwaysSend)); + ASSERT_FALSE(CrashReporter::isReportingEnabled(dsn, Mode::NeverSend)); +} + +TEST(CrashReporterTest, IsDsnWellFormedAcceptsWhatTheBuildAcceptsAndRejectsUiLinks) +{ + ASSERT_TRUE(CrashReporter::isDsnWellFormed(QStringLiteral("https://abc123@o0.ingest.sentry.io/1234567"))); + ASSERT_TRUE(CrashReporter::isDsnWellFormed(QStringLiteral("http://key@localhost:9000/42"))); + // Unanchored like cmake/CrashReporting.cmake's MATCHES, so a DSN the build baked is never + // rejected here: a trailing path still matches. + ASSERT_TRUE(CrashReporter::isDsnWellFormed(QStringLiteral("https://key@host.example/7/extra"))); + // The project-settings URL is the mistake a tester actually makes. + ASSERT_FALSE(CrashReporter::isDsnWellFormed( + QStringLiteral("https://sentry.io/settings/cockatrice/projects/cockatrice-desktop/keys/"))); + ASSERT_FALSE(CrashReporter::isDsnWellFormed(QStringLiteral("https://key@host.example/not-a-number"))); + ASSERT_FALSE(CrashReporter::isDsnWellFormed(QStringLiteral("o0.ingest.sentry.io/1234567"))); + ASSERT_FALSE(CrashReporter::isDsnWellFormed(QString())); +} + +TEST(CrashReporterTest, DescribeDsnNamesHostAndProjectWithoutTheKey) +{ + const QString described = + CrashReporter::describeDsn(QStringLiteral("https://supersecretkey@o0.ingest.sentry.io/1234567")); + ASSERT_EQ(described, QStringLiteral("o0.ingest.sentry.io (project 1234567)")); + ASSERT_FALSE(described.contains(QStringLiteral("supersecretkey"))); + ASSERT_TRUE(CrashReporter::describeDsn(QStringLiteral("not a dsn")).isEmpty()); + ASSERT_TRUE(CrashReporter::describeDsn(QString()).isEmpty()); +} + +TEST(CrashReporterTest, DescribeOriginMismatchReportsOnlyRealDestinationChanges) +{ + const QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString envelopePath = dir.filePath(QStringLiteral("crash.envelope")); + QFile file(envelopePath); + ASSERT_TRUE(file.open(QIODevice::WriteOnly)); + file.write(QByteArrayLiteral("{\"event_id\":\"x\",\"dsn\":\"https://keyA@hostA.example/111\"}\n")); + file.write(QByteArrayLiteral("{\"type\":\"event\",\"length\":2}\n{}\n")); + file.close(); + + const QString described = + CrashReporter::describeOriginMismatch(envelopePath, QStringLiteral("https://keyB@hostB.example/222")); + ASSERT_FALSE(described.isEmpty()); + ASSERT_TRUE(described.contains(QStringLiteral("hostA.example"))); + ASSERT_TRUE(described.contains(QStringLiteral("hostB.example"))); + ASSERT_FALSE(described.contains(QStringLiteral("keyA"))); + ASSERT_FALSE(described.contains(QStringLiteral("keyB"))); + + // A different key for the SAME host and project is not a routing change and must stay quiet. + ASSERT_TRUE( + CrashReporter::describeOriginMismatch(envelopePath, QStringLiteral("https://otherkey@hostA.example/111")) + .isEmpty()); + // An unreadable envelope, a header without a dsn, or no current dsn are all silent. + ASSERT_TRUE(CrashReporter::describeOriginMismatch(dir.filePath(QStringLiteral("absent.envelope")), + QStringLiteral("https://keyB@hostB.example/222")) + .isEmpty()); + ASSERT_TRUE(CrashReporter::describeOriginMismatch(envelopePath, QString()).isEmpty()); + const QString headerlessPath = dir.filePath(QStringLiteral("no-dsn.envelope")); + QFile headerless(headerlessPath); + ASSERT_TRUE(headerless.open(QIODevice::WriteOnly)); + headerless.write(QByteArrayLiteral("{\"event_id\":\"y\"}\n")); + headerless.close(); + ASSERT_TRUE(CrashReporter::describeOriginMismatch(headerlessPath, QStringLiteral("https://keyB@hostB.example/222")) + .isEmpty()); +} + +TEST(CrashReporterTest, ShutdownWithoutInitIsSafe) +{ + CrashReporter reporter; + ASSERT_FALSE(reporter.isReady()); + reporter.shutdown(); + ASSERT_FALSE(reporter.isReady()); +} + +TEST(CrashReporterTest, ConsentAndSendWithoutDsnAreSafeNoOps) +{ + CrashReporter reporter; + reporter.init(QString(), QStringLiteral("3.1.2"), QDir::tempPath() + QStringLiteral("/cockatrice-crash-test"), + PersonalSettings::CrashReportingMode::Ask); + ASSERT_FALSE(reporter.isReady()); + reporter.giveConsent(); + reporter.revokeConsent(); + ASSERT_FALSE(reporter.isReady()); + ASSERT_FALSE(reporter.sendReport(QStringLiteral("preview"))); + ASSERT_FALSE(reporter.sendReport(QString())); + reporter.shutdown(); + ASSERT_FALSE(reporter.isReady()); +} + +namespace +{ + +QByteArray readFileBytes(const QString &path) +{ + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + return {}; + } + return file.readAll(); +} + +bool writeFileBytes(const QString &path, const QByteArray &content) +{ + QFile file(path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + return false; + } + return file.write(content) == content.size(); +} + +QString uuidOf(char marker) +{ + // 36 chars, 4 hyphens — structurally a UUID, content irrelevant for these tests. + return QStringLiteral("00000000-0000-4000-8000-00000000000%1").arg(marker); +} + +} // namespace + +TEST(CrashReporterTest, AppendAttachmentAppendsWellFormedItemAfterExistingContent) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString path = dir.filePath(QStringLiteral("event.envelope")); + // Existing envelope content deliberately WITHOUT a trailing newline. + const QByteArray original = QByteArrayLiteral("{\"event_id\":\"x\"}\n{\"type\":\"event\",\"length\":2}\n{}"); + ASSERT_TRUE(writeFileBytes(path, original)); + + ASSERT_TRUE( + CrashReporter::appendAttachmentToEnvelope(path, QStringLiteral("crash-log.txt"), QByteArrayLiteral("hello"))); + const QByteArray expected = original + '\n' + + QByteArrayLiteral("{\"type\":\"attachment\",\"length\":5,\"attachment_length\":5," + "\"filename\":\"crash-log.txt\",\"content_type\":\"text/plain\"}\n") + + QByteArrayLiteral("hello"); + ASSERT_EQ(readFileBytes(path), expected); +} + +TEST(CrashReporterTest, AppendAttachmentOnEmptyFileHasNoLeadingNewline) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString path = dir.filePath(QStringLiteral("event.envelope")); + ASSERT_TRUE(writeFileBytes(path, QByteArray())); + + ASSERT_TRUE( + CrashReporter::appendAttachmentToEnvelope(path, QStringLiteral("user-note.txt"), QByteArrayLiteral("note"))); + const QByteArray expected = QByteArrayLiteral("{\"type\":\"attachment\",\"length\":4,\"attachment_length\":4," + "\"filename\":\"user-note.txt\",\"content_type\":\"text/plain\"}\n") + + QByteArrayLiteral("note"); + ASSERT_EQ(readFileBytes(path), expected); +} + +TEST(CrashReporterTest, AppendAttachmentRejectsMissingFile) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + ASSERT_FALSE(CrashReporter::appendAttachmentToEnvelope(dir.filePath(QStringLiteral("missing.envelope")), + QStringLiteral("crash-log.txt"), QByteArrayLiteral("x"))); +} + +TEST(CrashReporterTest, AppendAttachmentLengthIsByteAccurate) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString path = dir.filePath(QStringLiteral("event.envelope")); + ASSERT_TRUE(writeFileBytes(path, QByteArrayLiteral("{}"))); + + const QByteArray utf8Payload = QString::fromUtf8("héllo").toUtf8(); + ASSERT_EQ(utf8Payload.size(), 6); // 5 characters, "é" is 2 bytes + ASSERT_TRUE(CrashReporter::appendAttachmentToEnvelope(path, QStringLiteral("crash-log.txt"), utf8Payload)); + const QByteArray content = readFileBytes(path); + ASSERT_TRUE(content.contains(QByteArrayLiteral("\"length\":6"))); + ASSERT_TRUE(content.contains(QByteArrayLiteral("\"attachment_length\":6"))); + ASSERT_TRUE(content.endsWith(utf8Payload)); +} + +// The SDK's envelope re-parser frames each item's payload by its `length` header; a +// missing or wrong length ends the payload at the first newline, so the remaining log +// lines get parsed as item headers and the whole envelope is rejected (and deleted). +TEST(CrashReporterTest, AppendAttachmentKeepsMultiLinePayloadIntact) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString path = dir.filePath(QStringLiteral("event.envelope")); + ASSERT_TRUE(writeFileBytes(path, QByteArrayLiteral("{}"))); + + const QByteArray payload = QByteArrayLiteral("first line\nsecond line\nthird line"); + ASSERT_TRUE(CrashReporter::appendAttachmentToEnvelope(path, QStringLiteral("crash-log.txt"), payload)); + const QByteArray content = readFileBytes(path); + ASSERT_TRUE(content.contains("\"length\":" + QByteArray::number(payload.size()))); + ASSERT_TRUE(content.contains("\"attachment_length\":" + QByteArray::number(payload.size()))); + ASSERT_TRUE(content.endsWith(payload)); +} + +TEST(CrashReporterTest, PendingEnvelopePathsMatchesSdkNamingInCacheAndRunDirs) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString dbPath = dir.path(); + ASSERT_TRUE(QDir().mkpath(dbPath + QStringLiteral("/cache"))); + ASSERT_TRUE(QDir().mkpath(dbPath + QStringLiteral("/aaaa.run"))); + ASSERT_TRUE(QDir().mkpath(dbPath + QStringLiteral("/other"))); + + const QString retryName = QStringLiteral("1700000000000-0-") + uuidOf('a') + QStringLiteral(".envelope"); + const QString bareName = uuidOf('b') + QStringLiteral(".envelope"); + // Valid: retry-style cache file + bare run-dir file. + ASSERT_TRUE(writeFileBytes(dbPath + QStringLiteral("/cache/") + retryName, QByteArrayLiteral("{}"))); + ASSERT_TRUE(writeFileBytes(dbPath + QStringLiteral("/aaaa.run/") + bareName, QByteArrayLiteral("{}"))); + // Rejected: junk names and the SDK's own marker file. + ASSERT_TRUE(writeFileBytes(dbPath + QStringLiteral("/cache/") + uuidOf('c') + QStringLiteral(".envelope.bak"), + QByteArrayLiteral("{}"))); + ASSERT_TRUE(writeFileBytes(dbPath + QStringLiteral("/cache/") + QStringLiteral("random.envelope"), + QByteArrayLiteral("{}"))); + ASSERT_TRUE(writeFileBytes(dbPath + QStringLiteral("/aaaa.run/") + QStringLiteral("__sentry-crash.envelope"), + QByteArrayLiteral("{}"))); + ASSERT_TRUE(writeFileBytes(dbPath + QStringLiteral("/other/") + bareName, QByteArrayLiteral("{}"))); + + const QStringList found = CrashReporter::pendingEnvelopePaths(dbPath); + ASSERT_EQ(found.size(), 2); + ASSERT_TRUE(found.contains(dbPath + QStringLiteral("/cache/") + retryName)); + ASSERT_TRUE(found.contains(dbPath + QStringLiteral("/aaaa.run/") + bareName)); +} + +TEST(CrashReporterTest, PendingEnvelopePathsWithoutDatabaseIsEmpty) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + ASSERT_TRUE(CrashReporter::pendingEnvelopePaths(dir.filePath(QStringLiteral("no-such-db"))).isEmpty()); + ASSERT_TRUE(CrashReporter::pendingEnvelopePaths(QString()).isEmpty()); +} + +TEST(CrashReporterTest, AttachFoldsLogAndNoteIntoPendingEnvelopes) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString dbPath = dir.path(); + ASSERT_TRUE(QDir().mkpath(dbPath + QStringLiteral("/cache"))); + const QString envelopePath = dbPath + QStringLiteral("/cache/") + uuidOf('d') + QStringLiteral(".envelope"); + ASSERT_TRUE(writeFileBytes(envelopePath, QByteArrayLiteral("{\"event_id\":\"x\"}\n{}\n"))); + + CrashReporter reporter; + // Empty DSN: no SDK, but init() still records the database path the dialog uses. + reporter.init(QString(), QStringLiteral("3.1.2"), dbPath, PersonalSettings::CrashReportingMode::Ask); + ASSERT_EQ(reporter.attachToPendingCrashEnvelopes(QStringLiteral("log line"), QStringLiteral("my note")), 1); + + const QByteArray content = readFileBytes(envelopePath); + ASSERT_TRUE(content.startsWith(QByteArrayLiteral("{\"event_id\":\"x\"}\n{}\n"))); + ASSERT_TRUE(content.contains(QByteArrayLiteral("\"filename\":\"crash-log.txt\""))); + ASSERT_TRUE(content.contains(QByteArrayLiteral("\nlog line"))); + ASSERT_TRUE(content.contains(QByteArrayLiteral("\"filename\":\"user-note.txt\""))); + ASSERT_TRUE(content.endsWith(QByteArrayLiteral("\nmy note"))); +} + +TEST(CrashReporterTest, AttachSkipsNoteWhenEmptyAndFindsRunDirEnvelopes) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString dbPath = dir.path(); + ASSERT_TRUE(QDir().mkpath(dbPath + QStringLiteral("/bbbb.run"))); + const QString envelopePath = dbPath + QStringLiteral("/bbbb.run/") + uuidOf('e') + QStringLiteral(".envelope"); + ASSERT_TRUE(writeFileBytes(envelopePath, QByteArrayLiteral("{}"))); + + // Static overload (the pre-init AlwaysSend path) with an empty note. + ASSERT_EQ(CrashReporter::attachToPendingCrashEnvelopes(dbPath, QStringLiteral("log only"), QString()), 1); + const QByteArray content = readFileBytes(envelopePath); + ASSERT_TRUE(content.contains(QByteArrayLiteral("\"filename\":\"crash-log.txt\""))); + ASSERT_FALSE(content.contains(QByteArrayLiteral("user-note.txt"))); +} + +TEST(CrashReporterTest, BeginSessionOnEmptyDirReportsClean) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + CrashReporter reporter; + ASSERT_FALSE(reporter.beginSession(dir.path())); + reporter.endSession(); +} + +TEST(CrashReporterTest, BeginSessionIgnoresLiveConcurrentInstance) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + // A second QLockFile held in-process stands in for a live concurrent instance: same + // pid, alive, so the scan must leave it alone and report a clean start. + QLockFile liveInstance(dir.filePath(QStringLiteral("session-424242"))); + ASSERT_TRUE(liveInstance.tryLock(0)); + CrashReporter reporter; + ASSERT_FALSE(reporter.beginSession(dir.path())); + ASSERT_TRUE(QFile::exists(liveInstance.fileName())); + reporter.endSession(); + liveInstance.unlock(); +} + +TEST(CrashReporterTest, BeginSessionDetectsStaleDeadOwnerLock) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + // A well-formed lock whose owner is dead: our own pid (so the host matches) but a + // process name that cannot match, which is how QLockFile spots pid reuse. Must be + // reported unclean and cleaned up, even though the file is brand new (this is why + // the scan probes with staleLockTime 0 rather than the 30 s default). + const QString stalePath = dir.filePath(QStringLiteral("session-stale-owner")); + QFile stale(stalePath); + ASSERT_TRUE(stale.open(QIODevice::WriteOnly | QIODevice::Text)); + QTextStream out(&stale); + out << QCoreApplication::applicationPid() << '\n' + << QStringLiteral("definitely-not-the-test-binary") << '\n' + << QSysInfo::machineHostName() << '\n'; + stale.close(); + + CrashReporter reporter; + ASSERT_TRUE(reporter.beginSession(dir.path())); + ASSERT_FALSE(QFile::exists(stalePath)); + reporter.endSession(); +} + +TEST(CrashReporterTest, BeginSessionCleansGarbageFileAndReportsUnclean) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + // An unreadable lock file has no live owner by definition: report unclean and remove it. + const QString garbagePath = dir.filePath(QStringLiteral("session-garbage")); + QFile garbage(garbagePath); + ASSERT_TRUE(garbage.open(QIODevice::WriteOnly)); + garbage.write("not-a-lock-file\x00\xff", 17); + garbage.close(); + + CrashReporter reporter; + ASSERT_TRUE(reporter.beginSession(dir.path())); + ASSERT_FALSE(QFile::exists(garbagePath)); + reporter.endSession(); +} + +TEST(CrashReporterTest, BeginSessionEndSessionRoundTripReportsClean) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + CrashReporter reporter; + ASSERT_FALSE(reporter.beginSession(dir.path())); + reporter.endSession(); + ASSERT_FALSE(reporter.beginSession(dir.path())); + reporter.endSession(); +} + +} // namespace + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/deck_list_model/CMakeLists.txt b/tests/deck_list_model/CMakeLists.txt index cd7d693f3..82a1965d2 100644 --- a/tests/deck_list_model/CMakeLists.txt +++ b/tests/deck_list_model/CMakeLists.txt @@ -21,3 +21,15 @@ cockatrice_add_test( libcockatrice_card libcockatrice_deck_list ) + +cockatrice_add_test( + NAME + collator_sort_test + SOURCES + ${VERSION_STRING_CPP} + collator_sort_test.cpp + LIBS + libcockatrice_models + libcockatrice_card + libcockatrice_deck_list +) diff --git a/tests/deck_list_model/collator_sort_test.cpp b/tests/deck_list_model/collator_sort_test.cpp new file mode 100644 index 000000000..b4ed85525 --- /dev/null +++ b/tests/deck_list_model/collator_sort_test.cpp @@ -0,0 +1,30 @@ +#include +#include +#include +#include +#include +#include + +namespace +{ +TEST(CollatorSort, NumericNamesSortNaturally) +{ + QStandardItemModel source(0, 2); + const QStringList names = {"Forest 10", "Forest 2", "Forest 1"}; + for (int row = 0; row < names.size(); ++row) { + source.setItem(row, DeckListModelColumns::CARD_NAME, new QStandardItem(names.at(row))); + } + + DeckListSortFilterProxyModel proxy; + proxy.setSourceModel(&source); + proxy.setSortCriteria({"name"}); + proxy.sort(DeckListModelColumns::CARD_NAME, Qt::AscendingOrder); + + QStringList actual; + for (int row = 0; row < proxy.rowCount(); ++row) { + actual.append(proxy.index(row, DeckListModelColumns::CARD_NAME).data(Qt::DisplayRole).toString()); + } + + EXPECT_EQ(actual, (QStringList{"Forest 1", "Forest 2", "Forest 10"})); +} +} // namespace diff --git a/tests/game/CMakeLists.txt b/tests/game/CMakeLists.txt new file mode 100644 index 000000000..4a7492c71 --- /dev/null +++ b/tests/game/CMakeLists.txt @@ -0,0 +1,28 @@ +add_executable( + game_smoke_test + ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/cache_settings.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/card_counter_settings.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/shortcuts_settings.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/client/network/update/client/release_channel.cpp + ${VERSION_STRING_CPP} + game_smoke_test.cpp +) + +# SettingsCache is still reached from the game logic, but it lives in the application sources +# rather than in libcockatrice_settings, so the client settings sources are compiled in here +# exactly like loader_local_matching_test and theme_migration_test do. +target_include_directories(game_smoke_test PRIVATE ${CMAKE_SOURCE_DIR}/cockatrice/src) + +target_link_libraries( + game_smoke_test + PRIVATE libcockatrice_game + PRIVATE Threads::Threads + PRIVATE ${GTEST_BOTH_LIBRARIES} + PRIVATE ${TEST_QT_MODULES} +) + +add_test(NAME game_smoke_test COMMAND game_smoke_test) + +if(NOT GTEST_FOUND) + add_dependencies(game_smoke_test gtest) +endif() diff --git a/tests/game/game_smoke_test.cpp b/tests/game/game_smoke_test.cpp new file mode 100644 index 000000000..356d544bf --- /dev/null +++ b/tests/game/game_smoke_test.cpp @@ -0,0 +1,106 @@ +/** + * @file game_smoke_test.cpp + * + * Links the game logic library into a target that contains no graphics code at all, and drives + * the two seams the graphics layer used to own: the attachment graph on CardState and the + * card-creation request a zone makes so the graphics side can build the matching card item. + */ + +#include "gtest/gtest.h" +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + +CardRef cardRefFor(const QString &name) +{ + CardRef cardRef; + cardRef.name = name; + return cardRef; +} + +} // namespace + +TEST(CardStateTest, AttachmentGraphKeepsBothEndsConsistent) +{ + CardState host(nullptr, nullptr, cardRefFor(QStringLiteral("Host")), 1); + CardState guest(nullptr, nullptr, cardRefFor(QStringLiteral("Guest")), 2); + + guest.setAttachedTo(&host); + host.addAttachedCard(&guest); + + EXPECT_EQ(guest.getAttachedTo(), &host); + ASSERT_EQ(host.getAttachedCards().size(), 1); + EXPECT_EQ(host.getAttachedCards().first(), &guest); + + host.removeAttachedCard(&guest); + guest.setAttachedTo(nullptr); + + EXPECT_TRUE(host.getAttachedCards().isEmpty()); + EXPECT_EQ(guest.getAttachedTo(), nullptr); +} + +TEST(CardZoneLogicTest, AddsAndTakesCardsWithoutAGraphicsSide) +{ + HandZoneLogic zone(nullptr, QStringLiteral("hand"), false, false, true); + CardState card(nullptr, nullptr, cardRefFor(QStringLiteral("Lightning Bolt")), 7); + + QSignalSpy cardAddedSpy(&zone, &CardZoneLogic::cardAdded); + QSignalSpy cardCountSpy(&zone, &CardZoneLogic::cardCountChanged); + + zone.addCard(&card, false, 0); + + ASSERT_EQ(zone.getCards().size(), 1); + EXPECT_EQ(zone.getCards().first(), &card); + EXPECT_EQ(card.getZone(), &zone); + EXPECT_EQ(cardAddedSpy.count(), 1); + EXPECT_GE(cardCountSpy.count(), 1); + + EXPECT_EQ(zone.takeCard(0, 7), &card); + EXPECT_TRUE(zone.getCards().isEmpty()); + EXPECT_EQ(card.getId(), 7); +} + +TEST(CardZoneLogicTest, AsksTheGraphicsSideToCreateCards) +{ + HandZoneLogic zone(nullptr, QStringLiteral("library"), false, true, true); + QSignalSpy requestSpy(&zone, &CardZoneLogic::createCardRequested); + + ServerInfo_Card cardInfo; + cardInfo.set_name("Lightning Bolt"); + cardInfo.set_id(7); + cardInfo.set_x(0); + cardInfo.set_y(1); + + zone.createCardRequested(cardInfo, true); + + ASSERT_EQ(requestSpy.count(), 1); + const QList arguments = requestSpy.takeFirst(); + EXPECT_EQ(arguments.at(0).value().id(), 7); + EXPECT_TRUE(arguments.at(1).toBool()); +} + +int main(int argc, char **argv) +{ + // Keep SettingsCache reads and writes away from the real user profile: the game logic still + // reaches for a handful of settings, and the client settings sources linked into this test + // would otherwise touch the developer's own configuration. + QStandardPaths::setTestModeEnabled(true); + + QTemporaryDir home; + if (home.isValid()) { + qputenv("HOME", home.path().toLocal8Bit()); + } + + QCoreApplication app(argc, argv); + QLoggingCategory::setFilterRules("settings_cache.*=false"); + + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/holder_line_test.cpp b/tests/holder_line_test.cpp new file mode 100644 index 000000000..8588d8515 --- /dev/null +++ b/tests/holder_line_test.cpp @@ -0,0 +1,102 @@ +#include "holder_line.h" + +#include +#include + +namespace +{ +Holder runningProcess(const std::wstring &imageName, unsigned long processId) +{ + Holder holder; + holder.imageName = imageName; + holder.processId = processId; + return holder; +} + +Holder service(const std::wstring &appName, const std::wstring &shortName, unsigned long processId) +{ + Holder holder; + holder.appName = appName; + holder.serviceShortName = shortName; + holder.processId = processId; + holder.isService = true; + return holder; +} +} // namespace + +TEST(HolderLine, NamesARunningProcessTheWayTaskManagerDoes) +{ + EXPECT_EQ(formatHolder(runningProcess(L"cockatrice.exe", 24340)), + std::wstring(L"cockatrice.exe (pid 24340, running)")); +} + +TEST(HolderLine, NamesAServiceByTheNameTheUserCanSearchFor) +{ + // The image of a service is often a shared host process, so the image name would + // point at the wrong thing; both of the service's own names are kept. + Holder holder = service(L"Sophos Anti-Virus", L"SAVService", 4412); + holder.imageName = L"svchost.exe"; + EXPECT_EQ(formatHolder(holder), std::wstring(L"Sophos Anti-Virus (pid 4412, service SAVService)")); +} + +TEST(HolderLine, FallsBackFromTheImageToTheApplicationName) +{ + // Restart Manager can name a process whose image this one is not allowed to open. + Holder holder; + holder.appName = L"Cockatrice"; + holder.processId = 7; + EXPECT_EQ(formatHolder(holder), std::wstring(L"Cockatrice (pid 7, running)")); +} + +TEST(HolderLine, NamesAnEmptyHolderRatherThanPrintingAnEmptyParenthesis) +{ + Holder holder; + holder.processId = 9; + EXPECT_EQ(formatHolder(holder), std::wstring(L"an unnamed process (pid 9, running)")); +} + +TEST(HolderLine, JoinsHoldersIntoOneLine) +{ + const std::vector holders = { + runningProcess(L"cockatrice.exe", 1), + runningProcess(L"oracle.exe", 2), + }; + EXPECT_EQ(formatHolders(holders), std::wstring(L"cockatrice.exe (pid 1, running); oracle.exe (pid 2, running)")); +} + +TEST(HolderLine, CountsTheHoldersItLeavesOut) +{ + std::vector holders; + for (unsigned long processId = 1; processId <= HOLDER_LINE_MAX + 2; ++processId) { + holders.push_back(runningProcess(L"cockatrice.exe", processId)); + } + const std::wstring line = formatHolders(holders); + // The dialog has to stay readable, but a list that silently stopped at five would + // hide that there is more to close. + EXPECT_NE(line.find(L"pid 5, running"), std::wstring::npos); + EXPECT_EQ(line.find(L"pid 6, running"), std::wstring::npos); + EXPECT_NE(line.find(L"and 2 more"), std::wstring::npos); +} + +TEST(HolderLine, StopsNamingHoldersBeforeTheLineOutgrowsTheDialog) +{ + // A Windows service name can be 255 characters, and the abort dialog has to fit the + // list next to its own text and the log path in a 1024-character NSIS string. + std::vector holders; + for (unsigned long processId = 1; processId <= 3; ++processId) { + holders.push_back(service(std::wstring(255, L'S'), L"S", processId)); + } + const std::wstring line = formatHolders(holders); + // The first holder is still named in full; the others are counted rather than written + // out until the line overflows. + EXPECT_EQ(line.compare(0, 255, std::wstring(255, L'S')), 0); + EXPECT_NE(line.find(L"(pid 1, service S)"), std::wstring::npos); + EXPECT_EQ(line.find(L"pid 2"), std::wstring::npos); + EXPECT_NE(line.find(L"and 2 more"), std::wstring::npos); + EXPECT_LE(line.size(), HOLDER_LINE_CHAR_MAX + 20); +} + +TEST(HolderLine, FormatsNothingAsAnEmptyLine) +{ + EXPECT_EQ(formatHolders({}), std::wstring()); +} diff --git a/tests/installer_close_server_test.cpp b/tests/installer_close_server_test.cpp new file mode 100644 index 000000000..158d0ce47 --- /dev/null +++ b/tests/installer_close_server_test.cpp @@ -0,0 +1,101 @@ +#include "installer_close_name.h" +#include "installer_close_server.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +constexpr int HANDSHAKE_TIMEOUT_MS = 5000; + +/// The bytes both ends exchange, from the header they both include. The separate +/// WireCommandIsThePinnedBytes test is what catches an accidental change to the literal. +const QByteArray CLOSE_COMMAND = QByteArray::fromRawData(INSTALLER_CLOSE_COMMAND, sizeof(INSTALLER_CLOSE_COMMAND) - 1); + +/// Scopes the socket to this test process, so a Cockatrice the developer happens to be +/// running cannot claim the name and the test cannot close it. Kept short on purpose: +/// QLocalServer on Unix puts the socket file under QDir::tempPath(), and macOS caps that +/// path at 104 bytes (sun_path). A longer name passes on Linux (/tmp) and fails to listen +/// on the macOS CI runners, whose temp directory is already ~48 characters. +QByteArray testUserName() +{ + return QByteArrayLiteral("t") + QByteArray::number(QCoreApplication::applicationPid()); +} +} // namespace + +// The probe builds the name from the process id and the account name it reads from the +// environment; the client builds it with scopedLocalServerName(). They have to produce the +// same string or the installer can never reach the instance it is trying to close. +TEST(InstallerCloseServerTest, SocketNameMatchesTheProbeForTheSameProcessAndUser) +{ + constexpr unsigned long PROCESS_ID = 4242; + const QString userName = QStringLiteral("installer-test"); + const QString expected = QStringLiteral("CockatriceInstallerClose-4242-installer-test"); + + EXPECT_EQ(expected, InstallerCloseServer::socketNameForUser(PROCESS_ID, userName)); + EXPECT_EQ(expected.toStdWString(), installerCloseSocketName(PROCESS_ID, userName.toStdWString())); +} + +TEST(InstallerCloseServerTest, SocketNameScopesTheProcessIdByTheCurrentUser) +{ + qputenv("USER", testUserName()); + + EXPECT_EQ(QStringLiteral("CockatriceInstallerClose-4242-") + QString::fromLatin1(testUserName()), + InstallerCloseServer::socketName(4242)); +} + +// The line is a wire format shared with the Windows-only probe, which cannot be compiled +// into this test. Pinning the exact bytes here makes a change to the shared header a +// deliberate protocol change rather than a typo that only shows up in the installer. +TEST(InstallerCloseServerTest, WireCommandIsThePinnedBytes) +{ + EXPECT_EQ(QByteArrayLiteral("COCKATRICE_INSTALLER_CLOSE\n"), CLOSE_COMMAND); + EXPECT_EQ(27, CLOSE_COMMAND.size()); +} + +// The installer only writes the command; the listening instance is what turns it into a +// close. Anything but that exact line must not be treated as a request. +TEST(InstallerCloseServerTest, CloseRequestIsEmittedForTheProbeCommand) +{ + qputenv("USER", testUserName()); + + InstallerCloseServer server; + ASSERT_TRUE(server.listen()) << "the installer close socket could not be started"; + + int closeRequests = 0; + QObject::connect(&server, &InstallerCloseServer::closeRequested, &server, [&closeRequests]() { ++closeRequests; }); + + QLocalSocket socket; + socket.connectToServer(InstallerCloseServer::socketName(QCoreApplication::applicationPid())); + ASSERT_TRUE(socket.waitForConnected(HANDSHAKE_TIMEOUT_MS)) << "could not reach the installer close socket"; + ASSERT_EQ(CLOSE_COMMAND.size(), socket.write(CLOSE_COMMAND)) << "the close request was not accepted"; + + // No waitForBytesWritten(): on Windows QLocalSocket completes the write through the + // event dispatcher, and this test runs the server in the same thread, so a blocking + // wait would hold the loop the server needs to accept and read. The loop below is the + // real handshake: it flushes the write and delivers the server's readyRead. + QEventLoop loop; + QTimer timeout; + timeout.setSingleShot(true); + QObject::connect(&timeout, &QTimer::timeout, &loop, &QEventLoop::quit); + QObject::connect(&server, &InstallerCloseServer::closeRequested, &loop, &QEventLoop::quit); + timeout.start(HANDSHAKE_TIMEOUT_MS); + loop.exec(); + + EXPECT_EQ(1, closeRequests) << "the probe's line must reach the close handler exactly once"; +} + +int main(int argc, char **argv) +{ + QCoreApplication app(argc, argv); + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/payload_paths_test.cpp b/tests/payload_paths_test.cpp new file mode 100644 index 000000000..c4576d751 --- /dev/null +++ b/tests/payload_paths_test.cpp @@ -0,0 +1,57 @@ +#include "payload_paths.h" + +#include +#include + +namespace +{ +/// The directory part of a path spelled with the same case as the install directory, which +/// is the comparison the probe makes once both sides are folded - see isSameDirectory in +/// restart_manager_holders.cpp. Kept as a plain string here so the shape of a path can be +/// tested without Windows. +bool runsFrom(const std::wstring &imagePath, const std::wstring &installDir) +{ + return parentDirectoryOf(imagePath) == installDir; +} +} // namespace + +TEST(PayloadPaths, SplitsAnImagePathIntoItsDirectoryAndName) +{ + EXPECT_EQ(fileNameOf(L"D:\\Games\\Cockatrice\\cockatrice.exe"), std::wstring(L"cockatrice.exe")); + EXPECT_EQ(parentDirectoryOf(L"D:\\Games\\Cockatrice\\cockatrice.exe"), std::wstring(L"D:\\Games\\Cockatrice")); + EXPECT_EQ(fileNameOf(L"cockatrice.exe"), std::wstring(L"cockatrice.exe")); + EXPECT_EQ(parentDirectoryOf(L"cockatrice.exe"), std::wstring()); + EXPECT_EQ(parentDirectoryOf(L"D:/Games/Cockatrice/cockatrice.exe"), std::wstring(L"D:/Games/Cockatrice")); +} + +TEST(PayloadPaths, OnlyAnImageInTheInstallDirectoryRunsFromIt) +{ + const std::wstring installDir = L"D:\\Games\\Cockatrice"; + EXPECT_TRUE(runsFrom(L"D:\\Games\\Cockatrice\\cockatrice.exe", installDir)); + // A folder the user keeps other applications in is not the installation, and neither is + // a sibling whose name merely begins with the same letters. + EXPECT_FALSE(runsFrom(L"D:\\Games\\Cockatrice\\old\\cockatrice.exe", installDir)); + EXPECT_FALSE(runsFrom(L"D:\\Games\\Cockatrice-old\\cockatrice.exe", installDir)); + EXPECT_FALSE(runsFrom(L"D:\\Games\\cockatrice.exe", installDir)); +} + +TEST(PayloadPaths, KnowsTheProgramsThePayloadInstalls) +{ + EXPECT_TRUE(isPayloadProgram(L"cockatrice.exe")); + EXPECT_TRUE(isPayloadProgram(L"oracle.exe")); + EXPECT_TRUE(isPayloadProgram(L"servatrice.exe")); + EXPECT_TRUE(isPayloadProgram(L"COCKATRICE.EXE")); + EXPECT_TRUE(isPayloadProgram(L"Servatrice.Exe")); +} + +TEST(PayloadPaths, LeavesEveryOtherProgramAlone) +{ + // A program the user keeps in the install directory, the uninstaller, a service host and + // a name that only looks like a payload program are all not instances of the payload. + EXPECT_FALSE(isPayloadProgram(L"uninstall.exe")); + EXPECT_FALSE(isPayloadProgram(L"tool.exe")); + EXPECT_FALSE(isPayloadProgram(L"svchost.exe")); + EXPECT_FALSE(isPayloadProgram(L"cockatrice2.exe")); + EXPECT_FALSE(isPayloadProgram(L"mycockatrice.exe")); + EXPECT_FALSE(isPayloadProgram(L"")); +} diff --git a/tests/picture_loader_benchmark/CMakeLists.txt b/tests/picture_loader_benchmark/CMakeLists.txt index 0684f58f4..9b8d15999 100644 --- a/tests/picture_loader_benchmark/CMakeLists.txt +++ b/tests/picture_loader_benchmark/CMakeLists.txt @@ -15,8 +15,9 @@ cockatrice_add_manual_test( settings_cache_mock.cpp LIBS libcockatrice_card - libcockatrice_settings libcockatrice_interfaces + libcockatrice_network + libcockatrice_settings libcockatrice_utility INCLUDE_DIRS ${CMAKE_SOURCE_DIR}/cockatrice/src diff --git a/tests/server_card_counter_test.cpp b/tests/server_card_counter_test.cpp index 6308d80b9..6b884864d 100644 --- a/tests/server_card_counter_test.cpp +++ b/tests/server_card_counter_test.cpp @@ -8,10 +8,14 @@ #include #include #include +#include #include #include #include +// Referenced by other translation units in the server_remote library. +RNG_Abstract *rng = nullptr; + TEST(ServerCardCounter, IncrementNewCounter) { Server_Card card(CardRef{"TestCard", ""}, 1, 0, 0); diff --git a/tests/server_logger_test.cpp b/tests/server_logger_test.cpp new file mode 100644 index 000000000..3c2d047cc --- /dev/null +++ b/tests/server_logger_test.cpp @@ -0,0 +1,201 @@ +#include "server_logger.h" + +#include "gtest/gtest.h" +#include +#include +#include +#include +#include +#include + +namespace +{ + +/** + * @brief Redirects std::cout for the lifetime of the object so the logger's console + * sink can be asserted on. + */ +class CoutCapture +{ +public: + CoutCapture() : previousBuffer(std::cout.rdbuf(captured.rdbuf())) + { + } + ~CoutCapture() + { + std::cout.rdbuf(previousBuffer); + } + + QString text() const + { + return QString::fromStdString(captured.str()); + } + +private: + std::ostringstream captured; + std::streambuf *previousBuffer; +}; + +// The console sink is written by the logger thread; drive it directly here, as the event +// loop would in the server. +void flushLogger(ServerLogger &logger) +{ + QMetaObject::invokeMethod(&logger, "flushBuffer", Qt::DirectConnection); +} + +// Regression: --log-to-console used to be gated behind a successfully opened logfile, so an +// empty server/logfile silenced every diagnostic, including critical startup errors. +TEST(ServerLoggerTest, ConsoleSinkDoesNotDependOnLogFile) +{ + ServerLogger logger(true, true, QString()); + logger.startLog(QString()); + + CoutCapture capture; + logger.logMessage("could not open the database"); + flushLogger(logger); + + EXPECT_TRUE(capture.text().contains("could not open the database")) << capture.text().toStdString(); +} + +// writelog only describes the logfile; disabling it must not silence the console. +TEST(ServerLoggerTest, ConsoleSinkSurvivesDisabledFileLog) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + + ServerLogger logger(true, false, QString()); + logger.startLog(dir.filePath("server.log")); + + CoutCapture capture; + logger.logMessage("startup failed"); + flushLogger(logger); + + EXPECT_TRUE(capture.text().contains("startup failed")) << capture.text().toStdString(); +} + +// logfilters selects what goes into the logfile; the console is not filtered. +TEST(ServerLoggerTest, ConsoleSinkIgnoresLogFilters) +{ + ServerLogger logger(true, true, "database"); + logger.startLog(QString()); + + CoutCapture capture; + logger.logMessage("unrelated message"); + flushLogger(logger); + + EXPECT_TRUE(capture.text().contains("unrelated message")) << capture.text().toStdString(); +} + +TEST(ServerLoggerTest, StaysQuietWhenConsoleIsDisabled) +{ + ServerLogger logger(false, false, QString()); + logger.startLog(QString()); + + CoutCapture capture; + logger.logMessage("not for the console"); + flushLogger(logger); + + EXPECT_TRUE(capture.text().isEmpty()) << capture.text().toStdString(); +} + +TEST(ServerLoggerTest, IsLoggingEnabledMirrorsTheSinks) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + + // The console is a sink of its own: messages must not be discarded for want of a logfile. + { + ServerLogger logger(true, false, QString()); + EXPECT_TRUE(logger.isLoggingEnabled()); + } + + // The logfile sink needs an open file and writelog; reloadConfig() carries the SIGHUP change. + { + ServerLogger logger(false, true, QString()); + EXPECT_FALSE(logger.isLoggingEnabled()) << "no logfile is open yet"; + logger.startLog(dir.filePath("enabled.log")); + EXPECT_TRUE(logger.isLoggingEnabled()); + logger.reloadConfig(false, QString()); + EXPECT_FALSE(logger.isLoggingEnabled()) << "writelog=false leaves no sink enabled"; + } +} + +TEST(ServerLoggerTest, WritesMatchingFilteredMessagesToTheLogFile) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString logPath = dir.filePath("server.log"); + + { + ServerLogger logger(false, true, "database"); + logger.startLog(logPath); + logger.logMessage("database is unavailable"); + logger.logMessage("unrelated message"); + } + + QFile logFile(logPath); + ASSERT_TRUE(logFile.open(QIODevice::ReadOnly)); + const QString contents = QString::fromUtf8(logFile.readAll()); + EXPECT_TRUE(contents.contains("database is unavailable")) << contents.toStdString(); + EXPECT_FALSE(contents.contains("unrelated message")) << contents.toStdString(); +} + +// SIGHUP reloads the configuration; the logger must pick up the new filter. +TEST(ServerLoggerTest, ReloadConfigAppliesANewFilter) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString logPath = dir.filePath("server.log"); + + { + ServerLogger logger(false, true, "database"); + logger.startLog(logPath); + logger.logMessage("filtered out"); + logger.reloadConfig(true, QString()); + logger.logMessage("kept after reload"); + } + + QFile logFile(logPath); + ASSERT_TRUE(logFile.open(QIODevice::ReadOnly)); + const QString contents = QString::fromUtf8(logFile.readAll()); + EXPECT_FALSE(contents.contains("filtered out")) << contents.toStdString(); + EXPECT_TRUE(contents.contains("kept after reload")) << contents.toStdString(); +} + +TEST(ServerLoggerTest, DisabledFileLogLeavesTheLogFileEmpty) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString logPath = dir.filePath("server.log"); + + { + ServerLogger logger(false, false, QString()); + logger.startLog(logPath); + logger.logMessage("not for the logfile"); + } + + QFile logFile(logPath); + ASSERT_TRUE(logFile.open(QIODevice::ReadOnly)); + EXPECT_TRUE(logFile.readAll().isEmpty()); +} + +// A filter list consisting only of separators activates filtering with no entries; the old +// trimmed-empty check suppressed the logfile in that case and this preserves it. +TEST(ServerLoggerTest, DegenerateFilterSuppressesTheLogFile) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString logPath = dir.filePath("server.log"); + + { + ServerLogger logger(false, true, ","); + logger.startLog(logPath); + logger.logMessage("anything"); + } + + QFile logFile(logPath); + ASSERT_TRUE(logFile.open(QIODevice::ReadOnly)); + EXPECT_TRUE(logFile.readAll().isEmpty()); +} + +} // namespace diff --git a/tests/settings/settings_defaults_test.cpp b/tests/settings/settings_defaults_test.cpp index 0d4cdbafe..aea7f1d54 100644 --- a/tests/settings/settings_defaults_test.cpp +++ b/tests/settings/settings_defaults_test.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -367,6 +368,32 @@ TEST_F(SettingsDefaultsTest, Personal_ShowTipsOnStartup_Default) ASSERT_EQ(s.getShowTipsOnStartup(), true); } +TEST_F(SettingsDefaultsTest, Personal_CrashReportingMode_Default) +{ + PersonalSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getCrashReportingMode(), PersonalSettings::CrashReportingMode::Ask); +} + +TEST_F(SettingsDefaultsTest, Personal_CrashReportingMode_SetAndGet) +{ + PersonalSettings s(settingsPath, nullptr); + s.setCrashReportingMode(PersonalSettings::CrashReportingMode::AlwaysSend); + ASSERT_EQ(s.getCrashReportingMode(), PersonalSettings::CrashReportingMode::AlwaysSend); + s.setCrashReportingMode(PersonalSettings::CrashReportingMode::NeverSend); + ASSERT_EQ(s.getCrashReportingMode(), PersonalSettings::CrashReportingMode::NeverSend); + s.setCrashReportingMode(PersonalSettings::CrashReportingMode::Ask); + ASSERT_EQ(s.getCrashReportingMode(), PersonalSettings::CrashReportingMode::Ask); +} + +TEST_F(SettingsDefaultsTest, Personal_CrashReportingMode_ClampsCorruptValue) +{ + QSettings raw(settingsPath + "personal.ini", QSettings::IniFormat); + raw.setValue("personal/crashReportingMode", 99); + raw.sync(); + PersonalSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getCrashReportingMode(), PersonalSettings::CrashReportingMode::Ask); +} + // --- DownloadSettings --- TEST_F(SettingsDefaultsTest, Download_PicDownload_Default) diff --git a/tests/translation_loader_test.cpp b/tests/translation_loader_test.cpp index 745cd04bc..7a59e1b9b 100644 --- a/tests/translation_loader_test.cpp +++ b/tests/translation_loader_test.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include #include #include @@ -19,6 +20,11 @@ #define TRANSLATION_LOADER_TEST_DIR "" #endif +// The client's translation files, whose language codes are the languages the language selector offers. +#ifndef COCKATRICE_TRANSLATIONS_DIR +#define COCKATRICE_TRANSLATIONS_DIR "" +#endif + namespace { const QString testContext = QStringLiteral("TranslationLoaderTest"); @@ -233,6 +239,49 @@ TEST(TranslationLoaderTest, AvailableLanguagesListsTheTranslationsThatCanBeLoade EXPECT_TRUE(TranslationLoader::availableLanguages(QStringLiteral("translation_loader_test"), {}).isEmpty()); } +TEST(TranslationLoaderTest, LanguageDisplayNamesDoNotNeedATranslationFile) +{ + // The regression this guards: the selector took every label from the language's own .qm file, so it + // went blank as soon as translators had not translated the "English" placeholder in the i18n context. + EXPECT_EQ(QStringLiteral("Deutsch (German)"), TranslationLoader::languageDisplayName(QStringLiteral("de"))); + EXPECT_EQ(QStringLiteral("简体中文 (Chinese Simplified)"), + TranslationLoader::languageDisplayName(QStringLiteral("zh-Hans"))); + EXPECT_EQ(QStringLiteral("English, arr! (Pirate English)"), + TranslationLoader::languageDisplayName(QStringLiteral("en@pirate"))); +} + +TEST(TranslationLoaderTest, EveryLanguageTheClientShipsHasItsOwnDisplayName) +{ + // The shipped translation files are what the selector offers, so each of them has to end up with a + // label: an empty one is what made the dropdown look broken, and two equal ones are a coin flip. + const QDir translations(QStringLiteral(COCKATRICE_TRANSLATIONS_DIR)); + const QStringList files = translations.entryList({QStringLiteral("cockatrice_*.ts")}, QDir::Files); + ASSERT_FALSE(files.isEmpty()); + + QStringList languages; + QSet displayNames; + for (const QString &file : files) { + const QString language = QFileInfo(file).completeBaseName().section(QLatin1Char('_'), 1); + const QString displayName = TranslationLoader::languageDisplayName(language); + EXPECT_FALSE(displayName.isEmpty()) << language.toStdString(); + languages << language; + displayNames.insert(displayName); + } + + EXPECT_EQ(files.size(), displayNames.size()); + + // The selector preselects this one for an unset setting, so it has to be offered at all. + EXPECT_TRUE(languages.contains(TranslationLoader::defaultLanguageCode())); +} + +TEST(TranslationLoaderTest, UnknownLanguagesFallBackToTheNativeNameThenTheCode) +{ + // A language added after the table, and a code Qt does not know either. + EXPECT_FALSE(TranslationLoader::languageDisplayName(QStringLiteral("sv_FI")).isEmpty()); + EXPECT_EQ(QStringLiteral("zz_QQ"), TranslationLoader::languageDisplayName(QStringLiteral("zz_QQ"))); + EXPECT_TRUE(TranslationLoader::languageDisplayName(QString()).isEmpty()); +} + TEST(TranslationLoaderTest, LoadApplicationReportsAMissingTranslationDirectory) { QTemporaryDir emptyDir; diff --git a/tools/installer-holder-probe/CMakeLists.txt b/tools/installer-holder-probe/CMakeLists.txt new file mode 100644 index 000000000..99e7d5045 --- /dev/null +++ b/tools/installer-holder-probe/CMakeLists.txt @@ -0,0 +1,36 @@ +# Windows-only. The installer runs this before anything is replaced (the running-instance +# guard in .onInit) and on the abort path of a failed purge, to name the applications and +# services holding the payload open (Restart Manager); see the GuardAppsNotRunning and +# LogProcessesUsingInstallDir functions in cmake/NSIS.template.in. It ships inside the +# installer and the uninstaller and is extracted to $PLUGINSDIR there, never installed, so +# it links nothing from this project: no Qt, and a static C runtime because the installed +# runtime is exactly what the purge may have failed to replace. +add_executable( + installer-holder-probe + close_holders.cpp + holder_line.cpp + installer_close_name.cpp + installer_close_request.cpp + main.cpp + payload_locks.cpp + payload_paths.cpp + restart_manager_holders.cpp +) + +target_link_libraries(installer-holder-probe PRIVATE rstrtmgr) + +# The close command is a header-only constant shared with the client. The probe still links +# nothing from this project: the header is plain C++ and pulls in no library. +target_include_directories(installer-holder-probe PRIVATE ${CMAKE_SOURCE_DIR}/libcockatrice_utility) + +# The NSIS template embeds the built file by the same relative path that resolves +# NSIS.definitions.nsh, and that path points at the build root. +set_target_properties( + installer-holder-probe + PROPERTIES MSVC_RUNTIME_LIBRARY "MultiThreaded" + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}" + RUNTIME_OUTPUT_DIRECTORY_DEBUG "${CMAKE_BINARY_DIR}" + RUNTIME_OUTPUT_DIRECTORY_MINSIZEREL "${CMAKE_BINARY_DIR}" + RUNTIME_OUTPUT_DIRECTORY_RELEASE "${CMAKE_BINARY_DIR}" + RUNTIME_OUTPUT_DIRECTORY_RELWITHDEBINFO "${CMAKE_BINARY_DIR}" +) diff --git a/tools/installer-holder-probe/close_holders.cpp b/tools/installer-holder-probe/close_holders.cpp new file mode 100644 index 000000000..24fe7f0e7 --- /dev/null +++ b/tools/installer-holder-probe/close_holders.cpp @@ -0,0 +1,417 @@ +#include "close_holders.h" + +#include "installer_close_request.h" +#include "payload_paths.h" +#include "restart_manager_holders.h" + +// clang-format off +// windows.h has to come before anything that pulls in a Windows type, as everywhere in +// this tool. +#include +// clang-format on + +#include +#include +#include +#include + +namespace +{ +/// How long one poll of the asked processes takes. One WaitForSingleObject per process +/// and no enumeration, so polling costs nothing next to the seconds this is called on. +constexpr unsigned long WAIT_POLL_MILLISECONDS = 200; + +/// How long the report line may get before it is cut. NSIS holds one string in 1024 +/// characters, and this one is written there verbatim; every holder is named, so normally +/// the line stays far below it. +constexpr std::size_t REPORT_CHAR_MAX = 900; + +/// The holder as the log has to name it to be actionable. Deliberately not +/// formatHolder(): that one is worded for the dialog, where ", running" says something +/// the line here continues after. +std::wstring processLabel(const Holder &holder) +{ + std::wstring label = holder.imageName.empty() ? holder.appName : holder.imageName; + if (label.empty()) { + label = L"an unnamed process"; + } + label += L" (pid "; + label += std::to_wstring(holder.processId); + label += L")"; + return label; +} + +/// Joins the per-holder lines into the one line the installer logs and cuts it to what one +/// NSIS string holds. +std::wstring joinReport(const std::vector &lines) +{ + std::wstring report; + for (const std::wstring &line : lines) { + if (!report.empty()) { + report += L"; "; + } + report += line; + } + if (report.size() > REPORT_CHAR_MAX) { + report.erase(REPORT_CHAR_MAX); + report += L"..."; + } + return report; +} + +struct WindowSearch +{ + DWORD processId = 0; + std::vector windows; ///< every top-level window of the process, in Z-order +}; + +BOOL CALLBACK collectProcessWindow(HWND window, LPARAM parameter) +{ + auto &search = *reinterpret_cast(parameter); + DWORD processId = 0; + GetWindowThreadProcessId(window, &processId); + if (processId == search.processId) { + search.windows.push_back(window); + } + return TRUE; +} + +/// Whether a window is one the user could be asked to answer. +/// +/// Only a framed, non-empty window qualifies. Qt keeps hidden top-level windows of its own +/// next to the application's: the tray icon creates one to receive the TaskbarCreated +/// message, styled WS_OVERLAPPED - which is no frame at all - and titled +/// QTrayIconMessageWindow. Showing one of those puts an empty white rectangle on the +/// taskbar that nothing ever closes again: it has no window procedure of its own to hide it, +/// and the process is busy in the loop this is meant to break, so it stays until the process +/// dies. Posting a close to one is worse than useless too: Qt's tray window answers WM_CLOSE +/// by asking the application to terminate, without going through the confirmation the close +/// is supposed to reach. +bool isAnswerableWindow(HWND window) +{ + if ((GetWindowLongPtr(window, GWL_EXSTYLE) & WS_EX_TOOLWINDOW) != 0) { + return false; + } + if ((GetWindowLongPtr(window, GWL_STYLE) & WS_CAPTION) == 0) { + return false; + } + RECT bounds{}; + if (GetWindowRect(window, &bounds) == FALSE) { + return false; + } + return bounds.right > bounds.left && bounds.bottom > bounds.top; +} + +/// Picks the window to post the close to and the one the user should end up looking at. +struct CloseTarget +{ + HWND main = nullptr; ///< first answerable window without an owner: the application's own + HWND toRaise = nullptr; ///< topmost visible window: a dialog when one is up + bool hidden = false; ///< the window being closed is not on screen (notification area) +}; + +/// Only the windows the user could be asked to answer are candidates - see isAnswerableWindow. +/// +/// A hidden window is a candidate like a visible one: WM_CLOSE does not need a window on +/// screen, so an instance that is only in the notification area answers it the way it answers +/// its own close button, and the confirmation it raises then is a window Qt shows itself - +/// painted, unlike one restored from outside. Nothing is shown from here. Qt's own bookkeeping +/// windows stay excluded, because a close posted to the notification-area one terminates the +/// instance without the confirmation and that is worse than no close at all. +/// +/// @param windows every top-level window the process owns, in Z-order +/// @param askHidden when false, only a window that is on screen is a target: the launcher +/// mode reads "no window" as "past its own prompts and stuck", which stops being true +/// the moment a hidden window is asked to close +CloseTarget chooseCloseTarget(const std::vector &windows, bool askHidden) +{ + CloseTarget target; + for (const HWND window : windows) { + if (!isAnswerableWindow(window)) { + continue; + } + const bool visible = IsWindowVisible(window) != FALSE; + if (visible && target.toRaise == nullptr) { + target.toRaise = window; + } + if (target.main == nullptr && GetWindow(window, GW_OWNER) == nullptr && (visible || askHidden)) { + target.main = window; + target.hidden = !visible; + } + } + return target; +} + +/// What is left of the window the close was posted to, so a log line tells an instance that +/// answered by hiding from one that never answered at all. A process that hid its window and +/// stayed alive is the one the guard's end answer is for. +/// +/// @param window the window the close was posted to, or null when there was none +/// @param wasHidden whether that window was already off screen before the close +std::wstring describeWindowState(HWND window, bool wasHidden) +{ + if (window == nullptr || IsWindow(window) == FALSE) { + return L", its window is gone"; + } + // WM_NULL is the cheapest way to ask whether the thread behind the window still dispatches. + // A hung instance does not answer at all, which is a different problem from one that + // answered the close and stayed open, and the two want different words in the prompt. + DWORD_PTR ignored = 0; + const bool responding = + SendMessageTimeoutW(window, WM_NULL, 0, 0, SMTO_ABORTIFHUNG | SMTO_BLOCK, 1000, &ignored) != 0; + std::wstring state; + if (IsWindowVisible(window) != FALSE) { + state = L", window still open"; + } else { + state = wasHidden ? L", window still hidden" : L", window hidden again"; + } + if (!responding) { + state += L" and not responding"; + } + return state; +} + +/// Restores a minimized window and takes the foreground, so a confirmation Cockatrice +/// answers the close with is in front of the installer instead of behind it. +/// +/// A process in the background may not take the foreground on its own. Attaching to the +/// thread that owns the foreground window lifts that restriction for the duration of the +/// call; detaching afterwards leaves the two input queues as they were. +bool bringForward(HWND window) +{ + if (IsIconic(window) != FALSE) { + ShowWindow(window, SW_RESTORE); + } else { + ShowWindow(window, SW_SHOW); + } + + const HWND foreground = GetForegroundWindow(); + const DWORD foregroundThread = foreground != nullptr ? GetWindowThreadProcessId(foreground, nullptr) : 0; + const DWORD currentThread = GetCurrentThreadId(); + const bool attached = foregroundThread != 0 && foregroundThread != currentThread && + AttachThreadInput(currentThread, foregroundThread, TRUE) != FALSE; + BringWindowToTop(window); + SetForegroundWindow(window); + if (attached) { + AttachThreadInput(currentThread, foregroundThread, FALSE); + } + return GetForegroundWindow() == window; +} + +/// Waits for @p processes to signal, up to @p waitMilliseconds. Returns whether they all +/// exited and how long the wait took. +bool waitForExit(const std::vector &processes, unsigned long waitMilliseconds, unsigned long long &elapsed) +{ + const ULONGLONG start = GetTickCount64(); + const ULONGLONG deadline = start + waitMilliseconds; + for (;;) { + bool allExited = true; + for (const HANDLE process : processes) { + if (WaitForSingleObject(process, 0) != WAIT_OBJECT_0) { + allExited = false; + break; + } + } + elapsed = GetTickCount64() - start; + if (allExited || GetTickCount64() >= deadline) { + return allExited; + } + Sleep(WAIT_POLL_MILLISECONDS); + } +} +} // namespace + +bool isCockatriceImage(const std::wstring &imageName) +{ + constexpr wchar_t COCKATRICE_IMAGE[] = L"cockatrice.exe"; + return CompareStringOrdinal(imageName.c_str(), -1, COCKATRICE_IMAGE, -1, TRUE) == CSTR_EQUAL; +} + +CloseHoldersResult +closeCockatriceHolders(const std::vector &holders, unsigned long waitMilliseconds, bool askHiddenWindows) +{ + CloseHoldersResult result; + std::vector lines; + std::vector asked; + std::vector askedLines; + std::vector askedWindows; + std::vector askedWasHidden; + + for (const Holder &holder : holders) { + if (holder.isService || !isCockatriceImage(holder.imageName)) { + // Oracle and Servatrice are named by the prompt and close by hand: Oracle + // writes the card database in place. A service has no window to ask either. + lines.push_back(processLabel(holder) + L" left to you"); + continue; + } + + WindowSearch search; + search.processId = holder.processId; + EnumWindows(collectProcessWindow, reinterpret_cast(&search)); + CloseTarget target = chooseCloseTarget(search.windows, askHiddenWindows); + if (target.main == nullptr && !askHiddenWindows) { + // A launcher with no window on screen has already answered its own close and is + // stuck in shutdown (see the header): asking it again cannot reach a user who is + // not there, so it is named and the installer's wait ends it. + ++result.withoutWindow; + lines.push_back(processLabel(holder) + L" has no window of its own to close"); + continue; + } + + std::wstring line = processLabel(holder); + if (target.main == nullptr) { + // No window of the application's own at all: every window left is one of Qt's + // bookkeeping windows, which must never be asked to close. The installer socket + // below is the only thing that can reach it. + } else if (target.hidden) { + line += L" is hidden in the notification area"; + } else { + if (target.toRaise == nullptr) { + target.toRaise = target.main; + } + line += bringForward(target.toRaise) ? L" brought forward" : L" could not be brought forward"; + } + + // Kept open across the wait so a pid reused after the process exits cannot be + // mistaken for the instance that was asked to close. + const HANDLE process = OpenProcess(SYNCHRONIZE, FALSE, holder.processId); + const DWORD openError = process == nullptr ? GetLastError() : ERROR_SUCCESS; + + // Ask over the installer socket first. It is the same request as the window's close + // button - the application still answers its own confirmation - but it reaches an + // instance whose window is hidden in the notification area or is sitting on that + // confirmation, the two states a posted close cannot get through. A client from + // before the socket answers with a failed connect, so the posted close below stays + // the fallback and older versions keep behaving exactly as they did. + if (requestInstallerClose(holder.processId)) { + // "and" only when the line already says something about the instance's window. + line += target.main == nullptr ? L" asked to close over its installer socket" + : L" and asked to close over its installer socket"; + } else if (target.main == nullptr) { + // Nothing of the application's own to post a close to, and no socket to ask: the + // instance is named and the guard's end answer is what can free its files. + if (process != nullptr) { + CloseHandle(process); + } + ++result.withoutWindow; + lines.push_back(line + L" has no window of its own to close"); + continue; + } else if (PostMessageW(target.main, WM_CLOSE, 0, 0) == FALSE) { + // Posted to the instance's own window even when a question of its own is up. The + // close is what that window's close button does, and the application is the one + // that answers it: a current client refuses the close while its confirmation is + // pending, leaving the question in front, and only a client from before that guard + // accepts it and hides the window with the question. Withholding the request + // therefore protected nobody - it left the installer asking the user to close a + // program it had never asked - while the guard's end answer is what frees the files + // when the close cannot. + // + // Read the error before CloseHandle, which is free to reset it. + const DWORD postError = GetLastError(); + if (process != nullptr) { + CloseHandle(process); + } + lines.push_back(line + L", and the close request could not be sent (win32 " + std::to_wstring(postError) + + L")"); + continue; + } else { + line += L" and asked to close"; + } + ++result.askedToClose; + if (process == nullptr) { + line += L", but its exit could not be watched (win32 " + std::to_wstring(openError) + L")"; + } else { + askedLines.push_back(lines.size()); + asked.push_back(process); + askedWindows.push_back(target.main); + askedWasHidden.push_back(target.hidden); + } + lines.push_back(std::move(line)); + } + + if (!asked.empty() && waitMilliseconds > 0) { + unsigned long long elapsed = 0; + const bool allExited = waitForExit(asked, waitMilliseconds, elapsed); + for (std::size_t index = 0; index < asked.size(); ++index) { + const bool exited = allExited || WaitForSingleObject(asked[index], 0) == WAIT_OBJECT_0; + if (exited) { + lines[askedLines[index]] += L", exited after " + std::to_wstring(elapsed) + L" ms"; + } else if (askedWindows[index] == nullptr) { + // Asked over the socket with no window of its own: there is no window state + // to describe, only that it is still there. + lines[askedLines[index]] += L", still running after " + std::to_wstring(elapsed) + L" ms"; + } else { + lines[askedLines[index]] += L", still running after " + std::to_wstring(elapsed) + L" ms" + + describeWindowState(askedWindows[index], askedWasHidden[index]); + } + } + } + for (const HANDLE process : asked) { + CloseHandle(process); + } + + if (lines.empty()) { + result.report = L"nothing is holding the files"; + return result; + } + result.report = joinReport(lines); + return result; +} + +EndHoldersResult endHolders(const std::vector &holders, unsigned long waitMilliseconds) +{ + EndHoldersResult result; + std::vector lines; + std::vector ended; + std::vector endedLines; + + for (const Holder &holder : holders) { + if (!isPayloadProgram(holder.imageName)) { + // Not one of the programs the payload installs: a scanner that has a DLL open, or + // a program the user keeps in the install directory. It is named and left to the + // user, because ending it would destroy something this installer does not own. + lines.push_back(processLabel(holder) + L" left to you"); + continue; + } + + // Services included: the installer asked before getting here, and the Service + // Control Manager reports the terminated process as a stopped service afterwards. + const HANDLE process = OpenProcess(PROCESS_TERMINATE | SYNCHRONIZE, FALSE, holder.processId); + if (process == nullptr) { + lines.push_back(processLabel(holder) + L" could not be ended (win32 " + std::to_wstring(GetLastError()) + + L")"); + continue; + } + if (TerminateProcess(process, 1) == FALSE) { + // Read the error before CloseHandle, which is free to reset it. + const DWORD error = GetLastError(); + CloseHandle(process); + lines.push_back(processLabel(holder) + L" could not be ended (win32 " + std::to_wstring(error) + L")"); + continue; + } + ++result.ended; + endedLines.push_back(lines.size()); + lines.push_back(processLabel(holder) + L" ended"); + ended.push_back(process); + } + + if (!ended.empty() && waitMilliseconds > 0) { + unsigned long long elapsed = 0; + const bool allExited = waitForExit(ended, waitMilliseconds, elapsed); + for (std::size_t index = 0; index < ended.size(); ++index) { + const bool exited = allExited || WaitForSingleObject(ended[index], 0) == WAIT_OBJECT_0; + lines[endedLines[index]] += exited ? L", exited after " + std::to_wstring(elapsed) + L" ms" + : L", still running after " + std::to_wstring(elapsed) + L" ms"; + } + } + for (const HANDLE process : ended) { + CloseHandle(process); + } + + if (lines.empty()) { + result.report = L"nothing is holding the files"; + return result; + } + result.report = joinReport(lines); + return result; +} diff --git a/tools/installer-holder-probe/close_holders.h b/tools/installer-holder-probe/close_holders.h new file mode 100644 index 000000000..3f6c889fd --- /dev/null +++ b/tools/installer-holder-probe/close_holders.h @@ -0,0 +1,113 @@ +#ifndef CLOSE_HOLDERS_H +#define CLOSE_HOLDERS_H + +#include "holder_line.h" + +#include +#include + +/** + * @brief What asking the holders to close did, in the form the installer logs. + * + * @c report is one line naming every holder, including the ones this deliberately + * leaves alone, because the installer repeats it in its log. + * @c withoutWindow counts the holders that had no window of their own to ask: they were + * named and left alone, and the launcher mode reads that count as "nothing to ask" rather + * than as a window the user still has to answer. + */ +struct CloseHoldersResult +{ + unsigned int askedToClose = 0; ///< holders asked to close, over the socket or with WM_CLOSE + unsigned int withoutWindow = 0; ///< holders with no window of their own to ask + std::wstring report; ///< one line, narrowed to ASCII when written +}; + +/** + * @brief Whether @p imageName is the client the update belongs to, matched without case. + * + * The close mode asks only this image to close, and the launcher mode is only allowed to + * report it as safe to end, so both read the answer from here. + * + * @param imageName the file name of a process image, empty when it could not be read + */ +bool isCockatriceImage(const std::wstring &imageName); + +/** + * @brief Brings every running Cockatrice holding an installer file forward and asks it + * to close. + * + * The set is the Restart Manager answer, so it is exactly the processes holding a file of + * the previous version: nothing here has to guess at an executable path, and an + * application that merely shares the image name is never touched. Only cockatrice.exe is + * asked to close. Oracle and Servatrice are left to the user - Oracle writes the card + * database in place - and so is every service. + * + * One thing happens per instance: it is asked to close over the local socket it listens on, + * which is the same request as clicking its close button and is the only way to reach one + * whose window is hidden in the notification area or is sitting on its own confirmation. A + * client from before that socket is asked with WM_CLOSE on its own window instead, whether + * the window is on screen or only in the notification area, since the request does not need + * the window to be visible and the confirmation Cockatrice answers it with is a window Qt + * shows itself, painted, unlike one restored from outside. A window that is on screen is + * brought forward first, because that confirmation would otherwise open behind the + * installer's prompt; nothing is shown from here. Qt's own bookkeeping windows are never the + * target - posting a close to the notification-area one terminates the instance without its + * confirmation - so a process without a window of its own that does not answer the socket is + * named and left to the guard, which offers to end it. Stopping the process is never + * attempted here. + * + * Once the requests are out, this waits up to @c waitMilliseconds for the processes to + * exit, so the caller can measure the purge immediately afterwards instead of sleeping + * for a fixed time or re-asking a question this call already answered. A process that is + * still running is described further: whether its window is gone, still open, still + * hidden or hidden again, and whether it answers at all, so the log can tell an instance + * that refused the close from one that never received it. + * + * @param holders the Restart Manager answer, one entry per application or service + * @param waitMilliseconds how long to wait for the asked instances to exit, 0 to not wait + * @param askHiddenWindows whether a window that is not on screen is asked to close too. + * The guard wants that: an instance in the notification area is the one this update + * has no other way to reach. The launcher mode does not - a launcher without a + * visible window has already answered its own close and is stuck in shutdown, and + * the installer ends it instead of putting a question to a user who is not there. + */ +CloseHoldersResult +closeCockatriceHolders(const std::vector &holders, unsigned long waitMilliseconds, bool askHiddenWindows); + +/** + * @brief What ending the holders did, in the form the installer logs. + * + * @c report is one line naming every holder, including the ones this deliberately leaves + * alone, because the installer repeats it in its log. + */ +struct EndHoldersResult +{ + unsigned int ended = 0; ///< holders TerminateProcess was accepted for + std::wstring report; ///< one line, narrowed to ASCII when written +}; + +/** + * @brief Ends every running Cockatrice, Oracle and Servatrice the probe named. + * + * This is the last resort behind the installer's "end it now" answer, never a first move: a + * client that is still running may have an unsaved deck, an open game or a settings write in + * flight, Oracle may be part-way through writing the card database, and Servatrice may be + * serving a game, so the caller only gets here after the user picked it from a prompt that + * says so. + * + * Every program the payload installs is ended, services included. A service is terminated + * like a process rather than stopped through the Service Control Manager: the installer has + * asked the user first, and the SCM notices the process exit and marks the service stopped + * afterwards, so it can be started again normally. Anything else the Restart Manager named - + * a scanner holding a DLL, a program the user keeps in the install directory - is left to + * the user, because ending it would destroy something this installer does not own. + * + * Waits up to @c waitMilliseconds for what it ended to be gone, so the caller can measure the + * purge immediately afterwards instead of racing a process that is still exiting. + * + * @param holders what the probe named, one entry per application or service + * @param waitMilliseconds how long to wait for the ended instances to be gone, 0 to not wait + */ +EndHoldersResult endHolders(const std::vector &holders, unsigned long waitMilliseconds); + +#endif diff --git a/tools/installer-holder-probe/holder_line.cpp b/tools/installer-holder-probe/holder_line.cpp new file mode 100644 index 000000000..8d438ca17 --- /dev/null +++ b/tools/installer-holder-probe/holder_line.cpp @@ -0,0 +1,64 @@ +#include "holder_line.h" + +#include + +namespace +{ +std::wstring displayNameOf(const Holder &holder) +{ + // A service's application name is its long name, which is what the user can + // search for; its image name is usually a shared host process (svchost.exe) and + // would name the wrong thing. + if (holder.isService && !holder.appName.empty()) { + return holder.appName; + } + if (!holder.imageName.empty()) { + return holder.imageName; + } + if (!holder.appName.empty()) { + return holder.appName; + } + return L"an unnamed process"; +} +} // namespace + +std::wstring formatHolder(const Holder &holder) +{ + std::wstring line = displayNameOf(holder); + line += L" (pid "; + line += std::to_wstring(holder.processId); + if (holder.isService) { + line += L", service "; + line += holder.serviceShortName.empty() ? L"without a short name" : holder.serviceShortName; + } else { + line += L", running"; + } + line += L")"; + return line; +} + +std::wstring formatHolders(const std::vector &holders) +{ + const std::size_t wanted = std::min(holders.size(), HOLDER_LINE_MAX); + std::wstring line; + std::size_t listed = 0; + for (; listed < wanted; ++listed) { + const std::wstring entry = formatHolder(holders[listed]); + const std::size_t separator = line.empty() ? 0 : 2; + // The first holder is always named, however long its name: it is the answer the + // dialog exists to show, and Windows bounds one entry below the whole budget. + if (!line.empty() && line.size() + separator + entry.size() > HOLDER_LINE_CHAR_MAX) { + break; + } + if (!line.empty()) { + line += L"; "; + } + line += entry; + } + if (holders.size() > listed) { + line += L"; and "; + line += std::to_wstring(holders.size() - listed); + line += L" more"; + } + return line; +} diff --git a/tools/installer-holder-probe/holder_line.h b/tools/installer-holder-probe/holder_line.h new file mode 100644 index 000000000..675d79fc6 --- /dev/null +++ b/tools/installer-holder-probe/holder_line.h @@ -0,0 +1,62 @@ +#ifndef HOLDER_LINE_H +#define HOLDER_LINE_H + +#include +#include +#include + +/** + * @brief One application or service that has an installer file open or mapped. + * + * Deliberately free of Windows types: the wording is what the abort dialog shows, + * and it is the only part of the probe that can be exercised off Windows. + */ +struct Holder +{ + std::wstring imageName; ///< "cockatrice.exe", empty when the image could not be read + std::wstring appName; ///< Restart Manager's name for the application or service + std::wstring serviceShortName; ///< service short name, empty when this is not a service + unsigned long processId = 0; + bool isService = false; +}; + +/// How many holders the single-line summary names before it says how many were left out. +inline constexpr std::size_t HOLDER_LINE_MAX = 5; + +/** + * @brief How many characters the summary may spend on the holders it names. + * + * The line is not the whole string: every prompt puts it after a fixed paragraph and before + * the answers, and one NSIS string holds 1024 characters. Those paragraphs grew when the + * guard started spelling out what ending the programs costs and what is left to do by hand, + * so this budget is what keeps the longest message - the one with the longest listing, plus + * the "; and N more" the summary appends after the budget - complete, including the sentence + * that offers the other answer at its end. Windows caps one application or service name well + * below this, so it bounds the list without ever cutting a single holder short. + */ +inline constexpr std::size_t HOLDER_LINE_CHAR_MAX = 320; + +/** + * @brief Renders one holder the way the user has to recognise it, e.g. + * "cockatrice.exe (pid 24340, running)". + * + * The image name is preferred over the application name because it is what Task + * Manager shows, and the application name is what remains for a process whose + * image cannot be opened (another account, a protected process). A service is + * named by Restart Manager's long name and identified by its short name, because + * both are how the user finds it again in services.msc. + */ +std::wstring formatHolder(const Holder &holder); + +/** + * @brief Renders every holder as a single "; "-joined line. + * + * One line because it is written to the installer log as one and repeated in a + * message box, where nsExec hands the text back as one string. Only the first + * HOLDER_LINE_MAX are named, and only while they fit in HOLDER_LINE_CHAR_MAX + * characters; the rest are counted, so a machine with a dozen open handles cannot + * push the real answer off the dialog. + */ +std::wstring formatHolders(const std::vector &holders); + +#endif diff --git a/tools/installer-holder-probe/installer_close_name.cpp b/tools/installer-holder-probe/installer_close_name.cpp new file mode 100644 index 000000000..74659e151 --- /dev/null +++ b/tools/installer-holder-probe/installer_close_name.cpp @@ -0,0 +1,12 @@ +#include "installer_close_name.h" + +#include + +std::wstring installerCloseSocketName(unsigned long processId, const std::wstring &userName) +{ + std::wstring name = L"CockatriceInstallerClose-"; + name += std::to_wstring(processId); + name += L"-"; + name += userName; + return name; +} diff --git a/tools/installer-holder-probe/installer_close_name.h b/tools/installer-holder-probe/installer_close_name.h new file mode 100644 index 000000000..b659f8b8f --- /dev/null +++ b/tools/installer-holder-probe/installer_close_name.h @@ -0,0 +1,24 @@ +#ifndef INSTALLER_CLOSE_NAME_H +#define INSTALLER_CLOSE_NAME_H + +#include + +/** + * @brief The local-socket name a running instance listens on for the installer's close + * request. + * + * The name carries the process id, so every instance of the payload has its own socket - a + * second Cockatrice that was started independently, an Oracle and a Servatrice - and the + * installer can ask each one instead of guessing which is the primary. It also carries the + * account name, so one user's installer cannot reach another user's session. + * + * This is the same string the client builds with scopedLocalServerName() in + * libcockatrice_utility/local_server_name.h: `CockatriceInstallerClose--`. It is + * plain C++ so the probe and the client can be tested against each other off Windows. + * + * @param processId the id of the process that listens on the socket + * @param userName the account name scopedLocalServerName() appends + */ +std::wstring installerCloseSocketName(unsigned long processId, const std::wstring &userName); + +#endif // INSTALLER_CLOSE_NAME_H diff --git a/tools/installer-holder-probe/installer_close_request.cpp b/tools/installer-holder-probe/installer_close_request.cpp new file mode 100644 index 000000000..0475aedf3 --- /dev/null +++ b/tools/installer-holder-probe/installer_close_request.cpp @@ -0,0 +1,96 @@ +#include "installer_close_request.h" + +#include "installer_close_name.h" + +// clang-format off +// windows.h has to come before anything that pulls in a Windows type, as everywhere in +// this tool. +#include +// clang-format on + +#include +#include +#include +#include + +namespace +{ +/// How long a busy pipe may take before this gives up. The client answers in milliseconds; +/// the wait only covers a machine under load. +constexpr DWORD PIPE_WAIT_MILLISECONDS = 1000; + +std::wstring environmentVariable(const wchar_t *name) +{ + for (DWORD size = MAX_PATH; size <= 32768U; size *= 2) { + std::vector buffer(size); + const DWORD length = GetEnvironmentVariableW(name, buffer.data(), size); + if (length == 0) { + return {}; + } + if (length < size) { + return std::wstring(buffer.data(), length); + } + } + return {}; +} + +/// The account name scopedLocalServerName() appends: USER, then USERNAME, then the last +/// component of the profile path - the same order and the same fallbacks the client uses. +std::wstring currentUserName() +{ + for (const wchar_t *name : {L"USER", L"USERNAME"}) { + const std::wstring value = environmentVariable(name); + if (!value.empty()) { + return value; + } + } + const std::wstring profile = environmentVariable(L"USERPROFILE"); + const std::size_t separator = profile.find_last_of(L"\\/"); + if (separator == std::wstring::npos) { + return profile; + } + return profile.substr(separator + 1); +} + +/// The named pipe QLocalServer creates for the client's socket, or empty when the account +/// name could not be read and there is nothing to connect to. +std::wstring pipePath(unsigned long processId) +{ + const std::wstring userName = currentUserName(); + if (userName.empty()) { + return {}; + } + return L"\\\\.\\pipe\\" + installerCloseSocketName(processId, userName); +} +} // namespace + +bool requestInstallerClose(unsigned long processId) +{ + const std::wstring path = pipePath(processId); + if (path.empty()) { + return false; + } + // A client that is still serving the previous connection answers ERROR_PIPE_BUSY; one + // retry covers that, while a client that is not listening at all (an older version, or + // one that has already exited) is answered with a plain failure and the caller falls + // back to WM_CLOSE. + for (int attempt = 0; attempt < 2; ++attempt) { + const HANDLE pipe = CreateFileW(path.c_str(), GENERIC_WRITE, 0, nullptr, OPEN_EXISTING, 0, nullptr); + if (pipe == INVALID_HANDLE_VALUE) { + if (GetLastError() == ERROR_PIPE_BUSY && WaitNamedPipeW(path.c_str(), PIPE_WAIT_MILLISECONDS) != FALSE) { + continue; + } + return false; + } + DWORD written = 0; + const BOOL ok = + WriteFile(pipe, INSTALLER_CLOSE_COMMAND, sizeof(INSTALLER_CLOSE_COMMAND) - 1, &written, nullptr); + CloseHandle(pipe); + // The request is in the pipe buffer, so the client reads it even though this handle + // is closed immediately. No acknowledgment is waited for: the caller only needs to + // know whether there was a listening instance to ask, and a hung client must not + // stall the guard. + return ok != FALSE && written == sizeof(INSTALLER_CLOSE_COMMAND) - 1; + } + return false; +} diff --git a/tools/installer-holder-probe/installer_close_request.h b/tools/installer-holder-probe/installer_close_request.h new file mode 100644 index 000000000..f30c49b2c --- /dev/null +++ b/tools/installer-holder-probe/installer_close_request.h @@ -0,0 +1,21 @@ +#ifndef INSTALLER_CLOSE_REQUEST_H +#define INSTALLER_CLOSE_REQUEST_H + +/** + * @brief Asks one running payload instance to close over its installer socket. + * + * A 3.1.3 client (and later) listens on a local socket named by + * installerCloseSocketName(). Connecting to it and writing one line is the same request as + * the window's close button, but it reaches an instance whose window is hidden in the + * notification area or is sitting on its own confirmation - the two states WM_CLOSE cannot + * get through. Older clients have no socket, so the caller still falls back to WM_CLOSE. + * + * Best effort: a failed connect or write is not an error. The caller has the window close + * and the guard's force-close answer left. + * + * @param processId the process to ask, as the Restart Manager named it + * @return true when the request was written to a listening instance + */ +bool requestInstallerClose(unsigned long processId); + +#endif // INSTALLER_CLOSE_REQUEST_H diff --git a/tools/installer-holder-probe/main.cpp b/tools/installer-holder-probe/main.cpp new file mode 100644 index 000000000..31901a0ec --- /dev/null +++ b/tools/installer-holder-probe/main.cpp @@ -0,0 +1,300 @@ +#include "close_holders.h" +#include "holder_line.h" +#include "payload_locks.h" +#include "payload_paths.h" +#include "restart_manager_holders.h" + +#include +#include +#include +#include +#include + +namespace +{ +/// The installer sets this, the same variable the running-instance guard uses, instead +/// of passing the directory on the command line: a path with spaces, quotes or a +/// trailing backslash then never has to survive NSIS quoting or a command line. +constexpr wchar_t INSTALL_DIR_VARIABLE[] = L"COCKATRICE_GUARD_DIR"; + +/// The installer sets this to the process it waits on (/PID=), so the launcher mode reads +/// the same process id the wait does instead of one that has to survive NSIS quoting. +constexpr wchar_t LAUNCHER_PID_VARIABLE[] = L"COCKATRICE_LAUNCHER_PID"; + +/// How long the close mode waits for the instance it asked to exit before it reports the +/// process as still running. The installer measures the purge right after this call, so +/// the wait belongs here: it is the only place that holds a handle on the process whose +/// exit it is waiting for. +constexpr unsigned long CLOSE_WAIT_MILLISECONDS = 30000; + +/// How long the end mode waits for what it terminated to be gone. TerminateProcess is +/// asynchronous, and the installer measures the purge right after this returns, so a process +/// that is still tearing down would otherwise be reported as a holder for one more round. +constexpr unsigned long END_WAIT_MILLISECONDS = 5000; + +/// Exit codes the installer reads. "Something was found" is deliberately not 0, so an +/// answer of "nothing holds the files" stays distinguishable from a probe that failed. +/// In the close mode 1 means a close was actually sent and 0 that every holder was left +/// to the user, which is what the report line next to the code spells out. 3 is for an +/// answer that could not name what it found: a lookup that failed and named nobody, or the +/// lookups naming nobody while a payload program refuses to be replaced, which is proof one +/// runs from the directory. 4 is only the launcher mode: the process the installer waits on +/// has no window a user could answer, which is the one answer the installer acts on by +/// itself instead of asking the user. +enum ExitCode +{ + EXIT_NO_HOLDERS = 0, + EXIT_HOLDERS_FOUND = 1, + EXIT_NOT_CONFIGURED = 2, + EXIT_PROBE_FAILED = 3, + EXIT_NO_WINDOW = 4, +}; + +/// The probe has four modes, selected by the first argument: +/// +/// (none) name what holds the payload, one line, for the guard prompt and the +/// abort dialog/log +/// --close-holders bring the running Cockatrice forward, ask it to close, and wait for +/// it to exit, for the offer to close after a failed purge +/// --end-holders end the running Cockatrice, Oracle and Servatrice and wait for them to +/// be gone, for the last resort the guard prompt offers when a close did +/// not free the files +/// --close-launcher ask the one process the installer waits on (/PID=) to close and say +/// whether there was a window to ask at all, because an instance with +/// no window has answered its own close and is stuck in shutdown +/// +/// The guard's three modes answer from three questions that cover each other's blind spots: +/// the Restart Manager names whoever has a payload file open, every payload program running +/// from the directory itself is named as well, because a running instance holds its own image +/// and the modules it loaded as mappings rather than open files and the Restart Manager does +/// not always report those, and a payload executable that refuses to be written is running +/// even when neither lookup could name it (see payloadExecutablesInUse). No one of them is +/// the guard's authority: an instance the process lookups do not name, while its executable +/// is locked, is exactly the case that left a half-replaced install behind, so it is reported +/// as a check that could not name what it found instead of as a free payload. +/// A program in a folder below the directory is not named by the second lookup: it is not an +/// instance of this payload, and the guard could neither close nor end it. +enum class Mode +{ + Name, ///< name the holders + Close, ///< ask the holders to close + End, ///< end the holders + CloseLauncher, ///< ask the one process the installer waits on to close +}; + +Mode requestedMode(int argumentCount, char **arguments) +{ + if (argumentCount > 1) { + const std::string mode(arguments[1]); + if (mode == "--close-holders") { + return Mode::Close; + } + if (mode == "--end-holders") { + return Mode::End; + } + if (mode == "--close-launcher") { + return Mode::CloseLauncher; + } + } + return Mode::Name; +} + +/// Writes one line to the pipe nsExec reads. Narrowed to ASCII on purpose: the bytes +/// come back to the installer as an ANSI string, so a non-ASCII process name would be +/// mojibake in the log; a '?' at least stays honest. Control characters are dropped +/// too, so a name can never forge a second log line. +void writeLine(const std::wstring &text) +{ + std::string narrow; + narrow.reserve(text.size() + 2); + for (const wchar_t character : text) { + narrow.push_back(character >= 0x20 && character < 0x7F ? static_cast(character) : '?'); + } + narrow += "\r\n"; + DWORD written = 0; + WriteFile(GetStdHandle(STD_OUTPUT_HANDLE), narrow.data(), static_cast(narrow.size()), &written, nullptr); +} + +/// The line for a payload executable that is in use while no lookup could name the program +/// holding it: the answer the guard shows, so it names the file the user has to free and +/// says that the program doing it is still to be closed by hand. +std::wstring inUseLine(const std::vector &files) +{ + std::wstring names; + for (const std::wstring &file : files) { + if (!names.empty()) { + names += L", "; + } + names += fileNameOf(file); + } + if (files.size() == 1) { + return names + L" is in use and the program holding it could not be identified"; + } + return names + L" are in use and the programs holding them could not be identified"; +} + +std::wstring environmentVariable(const wchar_t *name) +{ + for (DWORD size = MAX_PATH; size <= 32768U; size *= 2) { + std::vector buffer(size); + const DWORD length = GetEnvironmentVariableW(name, buffer.data(), size); + if (length == 0) { + return {}; + } + if (length < size) { + return std::wstring(buffer.data(), length); + } + } + return {}; +} + +std::wstring installDirFromEnvironment() +{ + return environmentVariable(INSTALL_DIR_VARIABLE); +} + +/// The process id the installer waits on, or false when it was not passed or is not a +/// number. A pid of 0 is refused because the installer never passes one. +bool launcherProcessIdFromEnvironment(unsigned long &processId) +{ + const std::wstring text = environmentVariable(LAUNCHER_PID_VARIABLE); + if (text.empty()) { + return false; + } + wchar_t *end = nullptr; + const unsigned long parsed = std::wcstoul(text.c_str(), &end, 10); + if (parsed == 0 || end == nullptr || *end != L'\0') { + return false; + } + processId = parsed; + return true; +} + +/// Asks the one process the installer waits on to close and answers what it could do. +/// +/// The guard closes a set and reports every holder; here there is exactly one process, and +/// the installer needs an answer it can act on rather than a sentence: a launcher with no +/// visible window has already answered its own close and is stuck in shutdown, so no answer +/// the installer offers would ever reach it. A window that was asked - including one the +/// instance is showing a question in - is still the user's decision. +int closeLauncher(const std::wstring &installDir) +{ + unsigned long processId = 0; + if (!launcherProcessIdFromEnvironment(processId)) { + writeLine(L"the probe was not given the launcher process id"); + return EXIT_NOT_CONFIGURED; + } + + // Only an instance that still runs from the directory being replaced is asked: a bare + // process id names anything once it is reused, while a process running from the + // directory is the one the installer started this update from. + for (const Holder &holder : findProcessesRunningFrom(installDir)) { + if (holder.processId != processId) { + continue; + } + if (!isCockatriceImage(holder.imageName)) { + writeLine(L"the launcher is not cockatrice.exe, left to you"); + return EXIT_PROBE_FAILED; + } + const CloseHoldersResult result = closeCockatriceHolders({holder}, CLOSE_WAIT_MILLISECONDS, false); + writeLine(result.report); + if (result.askedToClose > 0) { + return EXIT_HOLDERS_FOUND; + } + return result.withoutWindow > 0 ? EXIT_NO_WINDOW : EXIT_HOLDERS_FOUND; + } + // Not an answer: the lookup may have failed or refused the image, and the installer + // holds a live handle on that process, so "not found" must not read as "gone". The + // installer shows its dialog for this code instead of acting on it. + writeLine(L"the launcher is not running from the directory being replaced, left to you"); + return EXIT_PROBE_FAILED; +} +} // namespace + +int main(int argumentCount, char **arguments) +{ + const std::wstring installDir = installDirFromEnvironment(); + if (installDir.empty()) { + writeLine(L"the probe was not given the install directory"); + return EXIT_NOT_CONFIGURED; + } + + const Mode mode = requestedMode(argumentCount, arguments); + // The launcher mode answers about exactly one process, so the holder set below is what it + // does not need - and the Restart Manager call behind it is the slowest thing this tool + // does, which must not delay a wait that started with a ten-second grace. + if (mode == Mode::CloseLauncher) { + return closeLauncher(installDir); + } + + // The payload programs running from the directory first: they are the instance the user + // is asked to close, and the Restart Manager may not name any of them (see + // findProcessesRunningFrom). The file holders follow, deduplicated by process, so an + // instance that both runs from the directory and holds a payload file is named once. + // Neither lookup may name the probe or the process that started it, whichever one found + // it: an installer or uninstaller running from the directory it is replacing would + // otherwise be named as a holder, and closing it is a request nobody can answer. + std::vector holders = findProcessesRunningFrom(installDir); + const std::vector asking = askingProcessIds(); + std::vector fileHolders; + const unsigned long error = findProcessesUsingFiles(collectPayloadFiles(installDir), fileHolders); + for (const Holder &holder : fileHolders) { + if (std::find(asking.begin(), asking.end(), holder.processId) != asking.end()) { + continue; + } + const bool alreadyNamed = std::any_of(holders.begin(), holders.end(), [&holder](const Holder &named) { + return named.processId == holder.processId; + }); + if (!alreadyNamed) { + holders.push_back(holder); + } + } + + // Nobody was named. That is not yet the answer that nothing holds the payload: a lookup + // that failed only makes the probe unable to answer when it also named nobody, and a + // payload executable that refuses to be replaced is running even when neither lookup + // could name it. + if (holders.empty()) { + // Neither process lookup is proof that the payload is free: the Restart Manager does + // not report every mapping, and a program running under another account cannot be + // read from here, so "nobody was named" is not the answer "nothing is running". The + // file cannot be missed - a running image refuses the write the replacement needs - + // and an answer of "nothing is holding them" over that locked file is what sent + // updates into an uninstall that could only fail halfway. Reported as a check that + // could not name what it found, so the guard asks the user to close it and retry + // instead of replacing a payload that is still running. + const std::vector inUse = payloadExecutablesInUse(installDir); + if (!inUse.empty()) { + writeLine(inUseLine(inUse)); + return EXIT_PROBE_FAILED; + } + + if (error != ERROR_SUCCESS) { + writeLine(L"the Restart Manager lookup failed with win32 error " + std::to_wstring(error)); + return EXIT_PROBE_FAILED; + } + + // No output in the naming mode: the installer words the "nothing is holding them" + // case, and it owns the message either way. The acting modes say it themselves, + // because their line is logged verbatim next to what they were asked to do. + if (mode != Mode::Name) { + writeLine(L"nothing is holding the files"); + } + return EXIT_NO_HOLDERS; + } + if (mode == Mode::Name) { + writeLine(formatHolders(holders)); + return EXIT_HOLDERS_FOUND; + } + if (mode == Mode::End) { + const EndHoldersResult result = endHolders(holders, END_WAIT_MILLISECONDS); + writeLine(result.report); + return result.ended > 0 ? EXIT_HOLDERS_FOUND : EXIT_NO_HOLDERS; + } + + // Hidden windows are asked to close here: an instance in the notification area is the one + // this update has no other way to reach, and the guard's end answer is behind that attempt. + const CloseHoldersResult result = closeCockatriceHolders(holders, CLOSE_WAIT_MILLISECONDS, true); + writeLine(result.report); + return result.askedToClose > 0 ? EXIT_HOLDERS_FOUND : EXIT_NO_HOLDERS; +} diff --git a/tools/installer-holder-probe/payload_locks.cpp b/tools/installer-holder-probe/payload_locks.cpp new file mode 100644 index 000000000..83b77ac58 --- /dev/null +++ b/tools/installer-holder-probe/payload_locks.cpp @@ -0,0 +1,67 @@ +#include "payload_locks.h" + +#include "payload_paths.h" + +#include + +namespace +{ +/// How long the second look waits before it re-tests the files that refused the write open. +/// Only those are re-tested, so the one answer costs this at most once: a scanner, or a +/// program that is only starting, holds a file without write sharing for a moment, and that +/// must not turn a free payload into a run the user has to answer. +constexpr DWORD RECHECK_DELAY_MILLISECONDS = 300; + +/// Whether @p path refuses an open that asks to write it. +/// +/// Windows opens a program's image, and every module loaded into it, with read and delete +/// sharing but not write sharing, so this is refused for as long as the program runs and by +/// nothing else that merely reads the file. A read-only file is skipped: it refuses a write +/// open for its own reason and would answer yes for a payload nobody is running, and the +/// purge clears that attribute before it deletes anything. Any other error is no answer at +/// all - access this probe does not have says nothing about whether the file is in use, and +/// still answering "in use" would stop installs that could have finished. +bool refusesWrite(const std::wstring &path) +{ + const DWORD attributes = GetFileAttributesW(path.c_str()); + if (attributes == INVALID_FILE_ATTRIBUTES || + (attributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_READONLY)) != 0) { + return false; + } + const HANDLE file = CreateFileW(path.c_str(), GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + if (file == INVALID_HANDLE_VALUE) { + return GetLastError() == ERROR_SHARING_VIOLATION; + } + // Nothing was written: the open is the whole test, and closing the handle leaves the + // file exactly as it was. + CloseHandle(file); + return false; +} +} // namespace + +std::vector payloadExecutablesInUse(const std::wstring &installDir) +{ + std::vector refused; + for (const wchar_t *name : PAYLOAD_PROGRAMS) { + const std::wstring path = installDir + L"\\" + name; + if (refusesWrite(path)) { + refused.push_back(path); + } + } + if (refused.empty()) { + return refused; + } + + // One re-test of the files that refused, and only those: the answer is shown to a user + // who then has to close a program and run the installer again, so it has to be the + // settled state rather than a moment in which something happened to hold the file. + Sleep(RECHECK_DELAY_MILLISECONDS); + std::vector settled; + for (const std::wstring &path : refused) { + if (refusesWrite(path)) { + settled.push_back(path); + } + } + return settled; +} diff --git a/tools/installer-holder-probe/payload_locks.h b/tools/installer-holder-probe/payload_locks.h new file mode 100644 index 000000000..1d23648a2 --- /dev/null +++ b/tools/installer-holder-probe/payload_locks.h @@ -0,0 +1,36 @@ +#ifndef PAYLOAD_LOCKS_H +#define PAYLOAD_LOCKS_H + +#include +#include + +/** + * @brief The payload executables in @p installDir that refuse to be written, as full paths: + * the programs that are running from it right now. + * + * The guard asks two questions and both can come back empty for a running instance: the + * Restart Manager does not reliably report a program's own image or the modules it mapped, + * and the image path of a process running under another account cannot be read from here, + * so the process is not named either. The file itself is the evidence neither question + * depends on: a program's image is opened without write sharing for as long as it runs, so + * the write access replacing it needs is refused for exactly that long, while a file that is + * merely being read still accepts it. + * + * An installer that answers "nothing is holding the payload" from two lookups that both + * missed is what sent updates into an uninstall that could only fail halfway, leaving the + * previous version half removed. This answer is what keeps that from being said: a payload + * executable that refuses the write is running, whether or not this process may name it. + * + * Only the programs the payload installs are tested, not every file the purge removes. A + * payload executable refusing the write proves that it is running; a mapped module belongs + * to a running executable, which is tested. A DLL or qt.conf on its own can be held by the + * program that is asking - the uninstaller runs in place from the directory it empties - and + * naming that as a holder is the loop the guard already had. + * + * @return one full path per executable that still refused after the re-test, in the order + * PAYLOAD_PROGRAMS names them; empty when every one of them can be written, or none + * is installed. + */ +std::vector payloadExecutablesInUse(const std::wstring &installDir); + +#endif diff --git a/tools/installer-holder-probe/payload_paths.cpp b/tools/installer-holder-probe/payload_paths.cpp new file mode 100644 index 000000000..8d6274e91 --- /dev/null +++ b/tools/installer-holder-probe/payload_paths.cpp @@ -0,0 +1,48 @@ +#include "payload_paths.h" + +#include +#include +#include + +namespace +{ +/// Compares two file names the way the file system does, without a locale. +/// +/// Only the names above are ever compared against, and all of them are ASCII, so folding +/// the ASCII letters is the whole answer. The Windows ordinal comparison is not used here +/// on purpose: this file is compiled into a test that runs everywhere, and the question +/// "is this image a program the payload installs?" must be answerable without Windows. +bool equalsIgnoringAsciiCase(const std::wstring &left, const std::wstring &right) +{ + if (left.size() != right.size()) { + return false; + } + return std::equal(left.begin(), left.end(), right.begin(), [](wchar_t a, wchar_t b) { + const wchar_t foldedA = a >= L'A' && a <= L'Z' ? static_cast(a - L'A' + L'a') : a; + const wchar_t foldedB = b >= L'A' && b <= L'Z' ? static_cast(b - L'A' + L'a') : b; + return foldedA == foldedB; + }); +} +} // namespace + +std::wstring fileNameOf(const std::wstring &path) +{ + const std::size_t separator = path.find_last_of(L"\\/"); + return separator == std::wstring::npos ? path : path.substr(separator + 1); +} + +std::wstring parentDirectoryOf(const std::wstring &path) +{ + const std::size_t separator = path.find_last_of(L"\\/"); + return separator == std::wstring::npos ? std::wstring() : path.substr(0, separator); +} + +bool isPayloadProgram(const std::wstring &imageName) +{ + for (const wchar_t *program : PAYLOAD_PROGRAMS) { + if (equalsIgnoringAsciiCase(imageName, program)) { + return true; + } + } + return false; +} diff --git a/tools/installer-holder-probe/payload_paths.h b/tools/installer-holder-probe/payload_paths.h new file mode 100644 index 000000000..ea63732f5 --- /dev/null +++ b/tools/installer-holder-probe/payload_paths.h @@ -0,0 +1,51 @@ +#ifndef PAYLOAD_PATHS_H +#define PAYLOAD_PATHS_H + +#include + +/** + * @brief The file names of the programs the payload installs, in the order the guard names + * them. + * + * The single list behind isPayloadProgram(), so the guard acts on exactly the programs it + * names, and so a payload program added later is closed, ended and tested for a lock + * without a second list to update. A payload executable that refuses to be written is + * running, which is what makes this list the evidence the two process lookups cannot + * provide - see payloadExecutablesInUse(). + */ +inline constexpr const wchar_t *PAYLOAD_PROGRAMS[] = {L"cockatrice.exe", L"oracle.exe", L"servatrice.exe"}; + +/** + * @brief The last name in @p path: what follows its last separator, or @p path itself when + * it has none. + * + * Empty for an empty path and for one that ends in a separator, which has no name of its + * own - a caller that hands in a directory there gets no name rather than the directory's + * own last component. + */ +std::wstring fileNameOf(const std::wstring &path); + +/** + * @brief The directory @p path lies in, without a trailing separator. + * + * Empty when @p path has no directory part. Used to tell where a process was started from: + * an image running directly in the install directory has that directory as its parent, + * while one started from a folder below it has that folder, and the two are not the same + * answer to "does this process belong to the installation being replaced?". + */ +std::wstring parentDirectoryOf(const std::wstring &path); + +/** + * @brief Whether @p imageName is one of the programs the payload installs. + * + * The single place that answers which images are Cockatrice's own, so the guard acts on the + * same set it names, and so a program a user happens to keep in the install directory is + * never treated as an instance of the application. Matched on the image name alone, without + * case: the names are the payload's own, and the comparison is deliberately free of Windows + * API so it can be exercised off Windows. + * + * @param imageName the file name of a process image, empty when it could not be read + */ +bool isPayloadProgram(const std::wstring &imageName); + +#endif diff --git a/tools/installer-holder-probe/restart_manager_holders.cpp b/tools/installer-holder-probe/restart_manager_holders.cpp new file mode 100644 index 000000000..21ddd6988 --- /dev/null +++ b/tools/installer-holder-probe/restart_manager_holders.cpp @@ -0,0 +1,305 @@ +#include "restart_manager_holders.h" + +// clang-format off +// restartmanager.h uses the types from windows.h without including it, so the order +// here is a requirement; Regroup would sort them the other way round. +#include +#include +#include +// clang-format on + +#include +#include +#include + +namespace +{ +/// Stop registering after this many files. The install directory carries around 150 +/// files in practice; the cap only exists so a directory someone has filled with files +/// cannot turn the abort path into a long Restart Manager call. +constexpr std::size_t MAX_PROBED_FILES = 2000; + +/// How deep the Plugins walk goes. Its real shape is Plugins\\.dll. +constexpr int MAX_PLUGIN_DEPTH = 4; + +/// How many parents askingProcessIds() walks up before it stops on its own. A real chain +/// is the probe, the installer or uninstaller, and the shell that started it; the bound +/// only keeps a snapshot with a cycle in it from walking forever. +constexpr int MAX_ASKER_LEVELS = 8; + +void collectFilesOf(const std::wstring &directory, int depth, int maxDepth, std::vector &files) +{ + if (files.size() >= MAX_PROBED_FILES) { + return; + } + WIN32_FIND_DATAW entry{}; + const HANDLE find = FindFirstFileW((directory + L"\\*").c_str(), &entry); + if (find == INVALID_HANDLE_VALUE) { + return; + } + do { + if (files.size() >= MAX_PROBED_FILES) { + break; + } + const std::wstring name = entry.cFileName; + if (name == L"." || name == L".." || (entry.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) { + continue; + } + const std::wstring path = directory + L"\\" + name; + if ((entry.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0) { + if (depth < maxDepth) { + collectFilesOf(path, depth + 1, maxDepth, files); + } + } else { + files.push_back(path); + } + } while (FindNextFileW(find, &entry) != 0); + FindClose(find); +} + +std::wstring imagePathOf(unsigned long processId) +{ + // PROCESS_QUERY_LIMITED_INFORMATION is the right to ask, not to debug: it reads the + // path of a process this one does not own, which is what keeps a SYSTEM-owned holder + // from turning the whole list into an error. One that refuses even this is still + // named, by whatever Restart Manager called it. + const HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, processId); + if (process == nullptr) { + return {}; + } + wchar_t path[MAX_PATH * 2] = {}; + DWORD length = static_cast(std::size(path)); + std::wstring imagePath; + if (QueryFullProcessImageNameW(process, 0, path, &length) != 0) { + imagePath.assign(path, length); + } + CloseHandle(process); + return imagePath; +} + +/// @p directory with exactly one trailing separator, so that two spellings of the same +/// directory compare equal and a sibling whose name merely begins with the same letters +/// ("Cockatrice-old" next to "Cockatrice") cannot pass for it. +std::wstring directoryPrefixOf(const std::wstring &directory) +{ + std::wstring prefix = directory; + while (!prefix.empty() && (prefix.back() == L'\\' || prefix.back() == L'/')) { + prefix.pop_back(); + } + prefix += L'\\'; + return prefix; +} + +/// Whether @p left and @p right name the same directory, compared the way the file system +/// does: a difference in case is not a difference in directory, and a trailing separator +/// changes nothing. +bool isSameDirectory(const std::wstring &left, const std::wstring &right) +{ + if (left.empty() || right.empty()) { + return false; + } + const std::wstring leftPrefix = directoryPrefixOf(left); + const std::wstring rightPrefix = directoryPrefixOf(right); + return CompareStringOrdinal(leftPrefix.c_str(), -1, rightPrefix.c_str(), -1, TRUE) == CSTR_EQUAL; +} + +/// Copies a fixed-size, NUL-terminated WCHAR array the API filled in. +template std::wstring fixedString(const wchar_t (&text)[N]) +{ + return std::wstring(text, std::find(text, text + N, L'\0')); +} + +Holder holderOf(const RM_PROCESS_INFO &info) +{ + Holder holder; + holder.processId = info.Process.dwProcessId; + holder.isService = info.ApplicationType == RmService; + holder.appName = fixedString(info.strAppName); + if (holder.isService) { + holder.serviceShortName = fixedString(info.strServiceShortName); + } + holder.imageName = fileNameOf(imagePathOf(holder.processId)); + return holder; +} +} // namespace + +std::vector collectPayloadFiles(const std::wstring &installDir) +{ + std::vector files; + // The programs the payload installs, by name, plus the configuration file next to + // them: what the purge deletes by name, and therefore what an instance can have + // mapped. + std::vector replaced(std::begin(PAYLOAD_PROGRAMS), std::end(PAYLOAD_PROGRAMS)); + replaced.emplace_back(L"qt.conf"); + for (const std::wstring &name : replaced) { + const std::wstring path = installDir + L"\\" + name; + const DWORD attributes = GetFileAttributesW(path.c_str()); + if (attributes != INVALID_FILE_ATTRIBUTES && (attributes & FILE_ATTRIBUTE_DIRECTORY) == 0) { + files.push_back(path); + } + } + + // Every top-level DLL: the set the purge deletes and therefore the set a running + // instance can have mapped. Matched by mask rather than by a list of expected names, + // so a transitive dependency nobody thought of is covered as well. + WIN32_FIND_DATAW entry{}; + const HANDLE dlls = FindFirstFileW((installDir + L"\\*.dll").c_str(), &entry); + if (dlls != INVALID_HANDLE_VALUE) { + do { + if (files.size() >= MAX_PROBED_FILES) { + break; + } + if ((entry.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0 || + (entry.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) { + continue; + } + files.push_back(installDir + L"\\" + entry.cFileName); + } while (FindNextFileW(dlls, &entry) != 0); + FindClose(dlls); + } + + // Plugins is the other tree the purge removes. Its data directory is not: it holds + // downloaded pictures, which the purge does not touch and which would turn this into + // a walk over thousands of files. + if (files.size() < MAX_PROBED_FILES) { + collectFilesOf(installDir + L"\\Plugins", 0, MAX_PLUGIN_DEPTH, files); + } + return files; +} + +unsigned long findProcessesUsingFiles(const std::vector &files, std::vector &holders) +{ + holders.clear(); + if (files.empty()) { + return ERROR_SUCCESS; + } + + DWORD session = 0; + wchar_t sessionKey[CCH_RM_SESSION_KEY + 1] = {}; + DWORD error = RmStartSession(&session, 0, sessionKey); + if (error != ERROR_SUCCESS) { + return error; + } + + // One call for the whole set, as the API asks: RmRegisterResources does registry + // work per file and is documented as too expensive to call once per file. + std::vector names; + names.reserve(files.size()); + for (const std::wstring &file : files) { + names.push_back(file.c_str()); + } + error = RmRegisterResources(session, static_cast(names.size()), names.data(), 0, nullptr, 0, nullptr); + + if (error == ERROR_SUCCESS) { + UINT needed = 0; + UINT count = 0; + DWORD rebootReasons = 0; + // The first call asks how large the answer is; Restart Manager reports + // ERROR_MORE_DATA rather than truncating a list that does not fit. + error = RmGetList(session, &needed, &count, nullptr, &rebootReasons); + // Bounded: each retry more than covers the list growing between the two calls, + // and an abort path cannot chase a process that keeps opening files. Giving up + // reports the code, which is a truthful answer instead of a truncated list. + for (int attempt = 0; attempt < 3 && error == ERROR_MORE_DATA; ++attempt) { + count = needed; + std::vector infos(count); + error = RmGetList(session, &needed, &count, infos.data(), &rebootReasons); + if (error == ERROR_SUCCESS) { + holders.reserve(count); + for (UINT index = 0; index < count; ++index) { + holders.push_back(holderOf(infos[index])); + } + } + } + } + + RmEndSession(session); + return error; +} + +std::vector askingProcessIds() +{ + std::vector asking; + asking.push_back(GetCurrentProcessId()); + + // One snapshot for the whole walk: the parent ids come from it, so a level costs no + // second lookup. + std::vector processes; + const HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if (snapshot == INVALID_HANDLE_VALUE) { + return asking; + } + PROCESSENTRY32W entry{}; + entry.dwSize = sizeof(entry); + if (Process32FirstW(snapshot, &entry) != FALSE) { + do { + processes.push_back(entry); + } while (Process32NextW(snapshot, &entry) != FALSE); + } + CloseHandle(snapshot); + + unsigned long current = asking.back(); + for (int level = 0; level < MAX_ASKER_LEVELS; ++level) { + const auto named = std::find_if(processes.begin(), processes.end(), [current](const PROCESSENTRY32W &process) { + return process.th32ProcessID == current; + }); + if (named == processes.end() || named->th32ParentProcessID == 0) { + break; + } + const unsigned long parent = named->th32ParentProcessID; + if (isPayloadProgram(fileNameOf(imagePathOf(parent)))) { + break; + } + asking.push_back(parent); + current = parent; + } + return asking; +} + +std::vector findProcessesRunningFrom(const std::wstring &installDir) +{ + std::vector holders; + if (installDir.empty()) { + return holders; + } + + // Never the probe, and never the installer or uninstaller that started it: either can + // run from the directory being replaced, and naming one asks the user to close the + // program that is asking (see askingProcessIds). + const std::vector asking = askingProcessIds(); + + const HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if (snapshot == INVALID_HANDLE_VALUE) { + return holders; + } + PROCESSENTRY32W entry{}; + entry.dwSize = sizeof(entry); + if (Process32FirstW(snapshot, &entry) != FALSE) { + do { + if (std::find(asking.begin(), asking.end(), entry.th32ProcessID) != asking.end()) { + continue; + } + // One process at a time, because the path is per process; a process that + // refuses to be read is not named here and is still named by Restart + // Manager when it holds a handle. The idle process has no image at all. + const std::wstring imagePath = imagePathOf(entry.th32ProcessID); + const std::wstring imageName = fileNameOf(imagePath); + // Only the programs the payload installs, and only those started from the + // directory itself: a folder a user installs into can hold other applications, + // each in its own subfolder, and one of those is not an instance of this + // payload. Naming it would ask for a close the probe cannot send and an end it + // would refuse to perform, so the guard could only ever abort. A program below + // the directory that really does hold a payload file is named by + // findProcessesUsingFiles, which is the lookup for that question. + if (!isPayloadProgram(imageName) || !isSameDirectory(parentDirectoryOf(imagePath), installDir)) { + continue; + } + Holder holder; + holder.processId = entry.th32ProcessID; + holder.imageName = imageName; + holders.push_back(std::move(holder)); + } while (Process32NextW(snapshot, &entry) != FALSE); + } + CloseHandle(snapshot); + return holders; +} diff --git a/tools/installer-holder-probe/restart_manager_holders.h b/tools/installer-holder-probe/restart_manager_holders.h new file mode 100644 index 000000000..e966b70b8 --- /dev/null +++ b/tools/installer-holder-probe/restart_manager_holders.h @@ -0,0 +1,88 @@ +#ifndef RESTART_MANAGER_HOLDERS_H +#define RESTART_MANAGER_HOLDERS_H + +#include "holder_line.h" +#include "payload_paths.h" + +#include +#include + +/** + * @brief Lists the payload files, as full paths. + * + * The set is what the installer replaces and the purge removes: the three executables, + * qt.conf, every top-level DLL and everything below Plugins. A document, a log or a + * screenshot the user happens to have open in the install directory is not part of it, + * so it cannot be mistaken for a running instance and block an upgrade. + * + * Restart Manager refuses a directory with ERROR_ACCESS_DENIED, which is why the probe + * registers files and this has to name them first. Missing files and unreadable + * directories are skipped rather than reported, and the list is capped: this runs before + * the first page and on the abort path, and must not walk a tree of downloaded pictures. + */ +std::vector collectPayloadFiles(const std::wstring &installDir); + +/** + * @brief Names the applications and services that have one of @p files open or mapped. + * + * Restart Manager reports both a process that loaded one of the files as a module and + * one that merely holds a handle to it, and it reports services, which is the whole + * reason it is used instead of walking every process's module list. + * + * @param holders replaced with one entry per holder, in the order Windows reported them. + * @return 0 when the lookup ran, even if nothing is holding the files; otherwise the + * Win32 code of the Restart Manager call that failed. + */ +unsigned long findProcessesUsingFiles(const std::vector &files, std::vector &holders); + +/** + * @brief Lists every payload program whose image lies in @p installDir itself. + * + * Restart Manager answers the question "who has this file open", and a running instance + * does not necessarily have one open: its own image and the modules it loaded are mapped + * into it, not opened by it, and RmGetList does not reliably report a mapping. That is + * the state the failed updates this probe exists for were in - every DLL still on disk, + * an instance running from the directory, and "nothing is holding them any more" as the + * answer - so an answer built on file handles alone cannot be the guard's authority. + * + * Only the programs the payload installs are named, and only when the image lies directly + * in @p installDir. A folder a user installs into can hold other applications, each in its + * own subfolder, and those are not instances of this one: they hold none of the payload, + * neither closing nor ending them would free a file the purge removes, and naming them + * leaves the guard with an answer that can never be carried out. A program below the + * directory that does hold a payload file is still named, by findProcessesUsingFiles. + * + * Reading the image path needs PROCESS_QUERY_LIMITED_INFORMATION and + * QueryFullProcessImageNameW, neither of which depends on the bitness of either process, + * so a 64-bit instance is named from this 32-bit stub. The comparison is on the directory, + * not on an image name alone, so a program that merely shares a name with a payload program + * is never named. + * + * @param installDir the directory being replaced, as a full path. + * @return one entry per payload program running from it, in snapshot order, without the + * processes named by askingProcessIds(); empty when the directory is empty or no + * payload program runs from it. + */ +std::vector findProcessesRunningFrom(const std::wstring &installDir); + +/** + * @brief The processes that must never be named as holders: the probe and the ones that + * started it. + * + * The installer or uninstaller doing the asking can legitimately run from the directory it + * is replacing - the old uninstaller is started with _?= so it stays in $INSTDIR, and an + * installer can be downloaded into the folder - and naming it asks the user to close the + * program that is asking. That cannot be answered: the close skips it, the end offer skips + * it, and the guard loops until the user cancels, which is what a run that named itself + * did. + * + * The walk up the parent chain stops at the first program the payload installs, because in + * an in-app update the installer's own parent is the Cockatrice instance being replaced - + * the one the guard exists to name. + * + * @return process ids, the probe first, then its ancestors while they are not payload + * programs; only the probe itself when the snapshot fails. + */ +std::vector askingProcessIds(); + +#endif diff --git a/vcpkg b/vcpkg index 9e593bb18..9eec002df 160000 --- a/vcpkg +++ b/vcpkg @@ -1 +1 @@ -Subproject commit 9e593bb18ea69cc5095e012465dcd675a822ed0d +Subproject commit 9eec002df1cd109f8f104cd966a763daf9751489 diff --git a/vcpkg.json b/vcpkg.json index f12227024..8c9c64161 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/microsoft/vcpkg-tool/main/docs/vcpkg.schema.json", - "builtin-baseline": "9e593bb18ea69cc5095e012465dcd675a822ed0d", + "builtin-baseline": "9eec002df1cd109f8f104cd966a763daf9751489", "dependencies": [ "gtest", "liblzma",