diff --git a/.ci/compile.sh b/.ci/compile.sh index 63c027473..9be8ba741 100755 --- a/.ci/compile.sh +++ b/.ci/compile.sh @@ -311,16 +311,24 @@ if [[ $RUNNER_OS == macOS ]]; then if [[ $MAKE_PACKAGE ]]; then # Workaround https://github.com/actions/runner-images/issues/7522 - # have hdiutil repeat the command 10 times in hope of success + # have hdiutil repeat the command 10 times in hope of success. + # The wrapper has to exit non-zero once it gives up: CPack reads that status to + # decide whether the disk image was created, so a zero exit turns a failed hdiutil + # into a much later, misleading "Problem copying the package" error that hides + # hdiutil's own message. hdiutil_script="/tmp/hdiutil.sh" # shellcheck disable=SC2016 echo '#!/bin/bash + attempts=10 i=0 - while ! hdiutil "$@"; do - if (( ++i >= 10 )); then - echo "Error: hdiutil failed $i times!" >&2 - break + while true; do + hdiutil "$@" && exit 0 + rc=$? + if (( ++i >= attempts )); then + echo "::error::hdiutil $1 failed $i times (last status $rc), giving up" >&2 + exit "$rc" fi + echo "hdiutil $1 attempt $i/$attempts failed (status $rc), retrying" >&2 sleep 1 done' >"$hdiutil_script" chmod +x "$hdiutil_script" @@ -459,6 +467,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/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index c97b48342..df054ea26 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -101,6 +101,36 @@ jobs: --title "$release_name" \ --notes-file "$body_path" + vcpkg-baseline: + name: vcpkg baseline + runs-on: ubuntu-slim + steps: + - name: "Checkout" + uses: actions/checkout@v7 + + # vcpkg-tool reads versions/baseline.json out of the commit named by + # `builtin-baseline`, through the `vcpkg` submodule's git history. A submodule + # pointer that lags the manifest - e.g. a stale `vcpkg/` worktree captured by + # `git add` in a PR - makes every vcpkg leg fail in configure with "failed to + # `git show` versions/baseline.json". That reads like a baseline or network + # problem, when the pointer is what has to move. Check the two agree here in + # seconds instead of on five build legs. + - name: "Check the submodule pointer against the manifest baseline" + shell: bash + run: | + pointer="$(git rev-parse HEAD:vcpkg 2>/dev/null || true)" + baseline="$(sed -nE 's/.*"builtin-baseline"[[:space:]]*:[[:space:]]*"([0-9a-f]{40})".*/\1/p' vcpkg.json)" + if [[ -z "$pointer" || -z "$baseline" ]]; then + echo "::error file=vcpkg.json::could not read the vcpkg submodule pointer or the builtin-baseline" + exit 1 + fi + if [[ "$pointer" != "$baseline" ]]; then + echo "::error file=vcpkg.json::vcpkg submodule is at $pointer but builtin-baseline is $baseline" + echo "Move them together: git -C vcpkg checkout $baseline && git add vcpkg" + exit 1 + fi + echo "vcpkg submodule and builtin-baseline agree on $baseline" + build-linux: strategy: fail-fast: false @@ -466,7 +496,7 @@ jobs: vcpkg_upload: 1 # only one leg per vcpkg_scope hands its archives over name: ${{ matrix.os }} ${{ matrix.target }}${{ matrix.soc == 'Intel' && ' Intel' || '' }}${{ matrix.type == 'Debug' && ' Debug' || '' }} - needs: configure + needs: [configure, vcpkg-baseline] runs-on: ${{ matrix.runner }} timeout-minutes: 100 env: @@ -584,11 +614,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" diff --git a/CMakeLists.txt b/CMakeLists.txt index 97b87a1d8..8a25fe2c6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -90,7 +90,7 @@ endif() # A project name is needed for CPack # Version can be overriden by git tags, see cmake/getversion.cmake -project("Cockatrice" VERSION 3.1.2) +project("Cockatrice" VERSION 3.2.0) # Set release name if not provided via env/cmake var if(NOT DEFINED GIT_TAG_RELEASENAME) @@ -250,7 +250,22 @@ 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 once here for every target: these variables are inherited by the +# subdirectories, so the per-directory copies were redundant. Only the +# variables are set; qt_standard_project_setup would also change the runtime +# output directories and the install RPATH. set(CMAKE_AUTOMOC TRUE) +set(CMAKE_AUTOUIC TRUE) +set(CMAKE_AUTORCC TRUE) # Find other needed libraries find_package(Protobuf CONFIG) @@ -420,6 +435,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) @@ -431,6 +449,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}) @@ -443,5 +468,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/NSIS.template.in b/cmake/NSIS.template.in index 0ef746430..923aded9d 100644 --- a/cmake/NSIS.template.in +++ b/cmake/NSIS.template.in @@ -12,10 +12,24 @@ Var NormalDestDir Var PortableDestDir Var PortableMode Var ReinstallMode -Var AllowForceClose Var GuardDir Var OldUninstallString Var OldInstallDir +; What ExecWait reported for the previous version's uninstaller, or "missing" / +; "launch-failed" when RunOldUninstaller could not start it. Read by WaitForOldPayloadToDisappear: an uninstaller that ran in place +; (_?=) and failed or was cancelled will not remove anything more, however long we wait. +Var OldUninstallExit +; Executable path parsed out of $OldUninstallString by ResolveOldUninstallExe. Used to +; tell an uninstaller that is missing or cannot be started ("missing" / "launch-failed" +; in $OldUninstallExit) from one that ran and failed. +Var OldUninstallExe +; Set to 1 by RemoveOldPayloadLeftovers only when every leftover it is responsible for is +; gone; WaitForOldPayloadToDisappear aborts while it is still 0. +Var OldPayloadRemoved +; Scratch state of AppendUninstallerLog. +Var UninstLogHandle +Var UninstLogLine +Var UninstLogChar Var DataBackupDir Var DataBackupDone Var DataBackupForDir @@ -27,9 +41,48 @@ Var StaleFindName Var DataBackupFindHandle Var DataBackupFindName Var IsUpgrade +; Set together by whichever purge block ran: the verdict RetryDllPurge measured +; and whether a purge ran at all - a portable install into a directory without +; portable.dat is neither purge block, and the abort below measures it itself. +Var StaleRuntimeAfterPurge +Var DllPurgeRan Var AbortReason +; Diagnostics for a failed purge: the holder probe in LogProcessesUsingInstallDir writes +; $HolderOutput (one log line) and sets $HolderFound when it named anything. +Var HolderExit +Var HolderOutput +Var HolderFound +; What the last RunHolderProbe call answered: its exit code and its line. Kept +; apart from $HolderExit/$HolderOutput because the guard only wants to know +; whether the payload is free, while the abort dialog reports what survived. +Var GuardExit +Var GuardOutput +; How many times the guard has asked the payload programs to close by itself. Bounded, and +; only repeated while the listing changed between rounds (see GuardAppsNotRunning). +Var GuardCloseRounds +; The listing the last automatic close was sent against, so a second round is spent only +; when something actually exited and the rest is worth asking again. Empty before round one. +Var GuardLastHolders +; How many times the user asked to check again after closing programs by hand (logged only). +Var GuardRecheckCount +; The question CockEndPrompt/CockEndRecheckPrompt assemble: fixed sentences around the +; probe's line, and the result has to stay inside the characters one NSIS string holds. +Var GuardPrompt Var WaitPid Var LauncherExited +; How long one WaitForSingleObject on the launcher's handle waits before asking the user +; (see WaitForLauncherToExit). A variable because the first slice is short and the ones +; after the first question are not. +Var WaitSlice +; Whether that handle carries PROCESS_TERMINATE as well as SYNCHRONIZE, and can therefore +; be ended from the answer the dialog offers. Cleared when only the synchronize right could +; be had (see WaitForLauncherToExit). +Var WaitCanEnd +; How far the launcher wait got with a launcher that has no window to ask: 0 before the +; first round, 1 after the grace it was given, 2 after it was ended. A launcher whose +; window is gone has answered its own close and is stuck in shutdown, so the dialog cannot +; reach it and the wait may end it instead of asking the user (see WaitForLauncherToExit). +Var WaitWindowless Var InstallLogPath Var LogHandle Var LogMessage @@ -94,7 +147,10 @@ Page Custom PortableModePageCreate PortableModePageLeave Call CockLogWrite !macroend -Function CockLogWrite +; The body is shared because the uninstaller needs its own copy: un.* functions are +; compiled into a separate binary, and inside one a Call may only name an un. function, +; so CockLog's Call to CockLogWrite cannot be used there. +!macro CockLogWriteBody Pop $LogMessage FileOpen $LogHandle "$InstallLogPath" a ${If} $LogHandle == "" @@ -106,6 +162,20 @@ Function CockLogWrite FileSeek $LogHandle 0 END FileWrite $LogHandle "$LogMessage$\r$\n" FileClose $LogHandle +!macroend + +Function CockLogWrite + !insertmacro CockLogWriteBody +FunctionEnd + +; CockLog for un.* code: same lines, prefixed call target. +!macro CockLogUn text + Push "${text}" + Call un.CockLogWrite +!macroend + +Function un.CockLogWrite + !insertmacro CockLogWriteBody FunctionEnd ; Logs every file matching $INSTDIR\. Used to record the DLL inventory @@ -148,6 +218,11 @@ StrCpy $LooseDeckNoticeShown "" ; No old install location resolved yet; Backup/Restore fall back to $INSTDIR ; when this is empty (the /R path where $INSTDIR already is the old dir). StrCpy $OldInstallDir "" +StrCpy $OldUninstallExit "" +; No purge has run and nothing has been measured yet. The section sets both +; after a purge, or measures the directory itself when neither purge applies. +StrCpy $StaleRuntimeAfterPurge 0 +StrCpy $DllPurgeRan 0 ; Start each run from a clean file so the log that gets sent back is the last ; run, not every past run glued together. FileOpen $LogHandle "$InstallLogPath" w @@ -214,10 +289,10 @@ ${EndIf} ; /PID= carries the process id of the application that started this installer. ; The in-app updater launches the installer and only then quits itself, so ; without it the installer has to guess whether the launching process is still -; alive by polling powershell.exe for an image name - a guess that can come back -; "not running" or "could not determine" while the process is very much alive and -; still has its Qt DLLs mapped. An older client does not pass /PID= and an older -; installer ignores it, so both sides of the pair have to keep working. +; alive from the files it still has open - a guess that can come back "nothing +; is holding them" while the client is very much alive and still has its Qt DLLs +; mapped. An older client does not pass /PID= and an older installer ignores it, +; so both sides of the pair have to keep working. ; Note that /D= must stay the last parameter on the command line, which is why ; the client puts it last and this one anywhere. StrCpy $WaitPid "" @@ -255,18 +330,16 @@ ${Else} ${EndIf} ; Wait for the process that launched this installer to exit before anything is -; replaced or deleted. It goes first because it is the one check here that needs -; no powershell.exe: on a machine where the process lookup cannot run at all, the -; launcher is still proven gone here instead of the whole upgrade being cancelled -; by a check that was only ever a proxy for this one. +; replaced or deleted. It goes first because a real handle on that process is +; authority: the holder probe that follows asks the same question indirectly, by +; looking at which files are still open. ; ; Then make sure no *other* application instance is still running (and holding -; file locks) before the previous version is uninstalled. On a silent update -; (/R /S) the running processes are asked to close gracefully and waited for; -; Cockatrice itself is never force-killed (data loss, e.g. an unsaved deck) so the -; update is aborted instead, while the console applications Oracle and Servatrice -; are force-stopped by PID once the grace period is over. On interactive installs -; the user is prompted to close them instead. +; file locks) before the previous version is uninstalled. The probe names them +; and the user is asked to close them; nothing is force-stopped without that +; answer, because Cockatrice may hold an unsaved deck and Oracle writes the card +; database in place. On /R the old version is about to be removed, so an answer +; the probe cannot give cancels the upgrade before it starts rather than after it. ; Only needed this early for /R, where AutoUninstallIfNeeded runs below; for ; interactive installs the section re-checks after the directory page, when ; $INSTDIR is the directory the user actually picked. @@ -290,6 +363,14 @@ ${If} ${NSIS_IS_64_BIT} == 1 SetRegView 64 ${EndIf} +; The uninstaller logs to its own file: the installer truncates its log on every run, so +; sharing one would mean either side erasing the other's account of a failed upgrade. +StrCpy $InstallLogPath "$TEMP\Cockatrice-uninstaller.log" +FileOpen $LogHandle "$InstallLogPath" w +FileClose $LogHandle +!insertmacro CockLogUn "==================================================================" +!insertmacro CockLogUn "uninstaller start: @CPACK_PACKAGE_NAME@ @CPACK_PACKAGE_VERSION@ dir=$INSTDIR" + ; Ensure no application instance is still running before removing files. Call un.EnsureAppsNotRunning @@ -320,6 +401,61 @@ ${If} $ReinstallMode = 1 ${EndIf} FunctionEnd +; Sets $OldUninstallExe to the executable named in $OldUninstallString (everything up to +; and including the first ".exe", without a leading quote), or "" when there is none. +Function ResolveOldUninstallExe + Push $R7 + Push $R8 + StrLen $R8 $OldUninstallString + StrCpy $R7 0 + roue_loop: + ${If} $R7 >= $R8 + StrCpy $OldUninstallExe "" + Goto roue_out + ${EndIf} + StrCpy $OldUninstallExe $OldUninstallString 4 $R7 + ${If} $OldUninstallExe == ".exe" + IntOp $R7 $R7 + 4 + StrCpy $OldUninstallExe $OldUninstallString $R7 + StrCpy $R8 $OldUninstallExe 1 + ${If} $R8 == '"' + StrCpy $OldUninstallExe $OldUninstallExe "" 1 + ${EndIf} + Goto roue_out + ${EndIf} + IntOp $R7 $R7 + 1 + Goto roue_loop + roue_out: + Pop $R8 + Pop $R7 +FunctionEnd + +; Runs "$OldUninstallString _?=$OldInstallDir" in place and sets $OldUninstallExit: +; the uninstaller's own exit code when it ran, "missing" when the executable the +; registry names does not exist, "launch-failed" when ExecWait could not start it. +; The last two used to leave the code empty, which WaitForOldPayloadToDisappear read +; as a cancelled uninstall and aborted on, although nobody had cancelled anything. +; Uses $R5 for the exit code, as the callers did before. +Function RunOldUninstaller + Call ResolveOldUninstallExe + StrCpy $R5 "" + ${If} $OldUninstallExe == "" + ${OrIfNot} ${FileExists} "$OldUninstallExe" + StrCpy $OldUninstallExit "missing" + !insertmacro CockLog "old uninstall: uninstaller '$OldUninstallExe' does not exist (command '$OldUninstallString')" + Return + ${EndIf} + ClearErrors + ExecWait '$OldUninstallString _?=$OldInstallDir' $R5 + ${If} ${Errors} + ${OrIf} $R5 == "" + StrCpy $OldUninstallExit "launch-failed" + !insertmacro CockLog "old uninstall: could not start '$OldUninstallString _?=$OldInstallDir'" + Return + ${EndIf} + StrCpy $OldUninstallExit $R5 +FunctionEnd + Function AutoUninstallIfNeeded SetShellVarContext all @@ -344,7 +480,10 @@ StrCpy $OldInstallDir $INSTDIR StrCmp $R0 "" done32 DetailPrint "Removing previous version (32-bit)..." Call BackupUserDataBeforeUninstall -ExecWait '$R0 _?=$OldInstallDir' +StrCpy $OldUninstallString $R0 +Call RunOldUninstaller +!insertmacro CockLog "uninstall (32-bit): exit=$OldUninstallExit" +Call WaitForOldPayloadToDisappear Call RestoreUserDataAfterUninstall done32: @@ -359,7 +498,10 @@ ${If} ${RunningX64} StrCmp $R0 "" done64 DetailPrint "Removing previous version (64-bit)..." Call BackupUserDataBeforeUninstall - ExecWait '$R0 _?=$OldInstallDir' + StrCpy $OldUninstallString $R0 + Call RunOldUninstaller + !insertmacro CockLog "uninstall (64-bit): exit=$OldUninstallExit" + Call WaitForOldPayloadToDisappear Call RestoreUserDataAfterUninstall done64: @@ -367,6 +509,192 @@ ${EndIf} FunctionEnd +; Waits for the previous installation's payload to be gone, so the sections below never +; write into a directory an old uninstaller is still emptying. +; +; ExecWait only waits for as long as the uninstaller runs in place: an NSIS uninstaller +; that did not get a usable _?= copies itself to %TEMP%, starts that copy and exits, so +; ExecWait returns at once while the copy keeps deleting - and it finishes after this +; window has closed, removing the version that was just installed. Waiting for +; cockatrice.exe to disappear covers both cases, and stops with a message instead of +; +; When the payload is still there after the wait, $OldUninstallExit decides: a non-zero +; code means the uninstaller was cancelled or failed, and the install aborts saying so. +; A zero code means it finished but skipped files someone was holding (NSIS Delete does +; that silently), so RemoveOldPayloadLeftovers frees and removes them, and the install +; only aborts - naming the holders - when even that fails. +; installing over a directory that is still being emptied. +Function WaitForOldPayloadToDisappear +${If} $OldInstallDir == "" + Return +${EndIf} +; The registered uninstaller could not be run at all (missing, or ExecWait could not +; start it): nothing is going to empty the directory, so waiting is pointless and +; aborting as "cancelled" was wrong. Remove the previous payload the same way the +; skipped-files recovery below does, and abort - naming why - only if that fails. +${If} $OldUninstallExit == "missing" +${OrIf} $OldUninstallExit == "launch-failed" + ${IfNot} ${FileExists} "$OldInstallDir\*.*" + !insertmacro CockLog "old uninstall: uninstaller $OldUninstallExit and $OldInstallDir is already gone" + Return + ${EndIf} + !insertmacro CockLog "old uninstall: uninstaller $OldUninstallExit ('$OldUninstallString'); removing $OldInstallDir payload directly" + Call AppendUninstallerLog + Call RemoveOldPayloadLeftovers + ${If} $OldPayloadRemoved != "1" + StrCpy $AbortReason \ + "The previous version in $OldInstallDir could not be removed: its uninstaller ($OldUninstallExe) could not be run ($OldUninstallExit), and removing the files directly failed.$\r$\n$\r$\nStill in use by: $HolderOutput$\r$\n$\r$\nClose anything still using those files and run this installer again." + Call RestoreUserDataAfterUninstall + Call AbortInstall + ${EndIf} + !insertmacro CockLog "old uninstall: continuing after removing the payload of an uninstaller that could not run" + Return +${EndIf} +StrCpy $R2 0 +old_uninstall_wait: +IfFileExists "$OldInstallDir\cockatrice.exe" 0 old_uninstall_gone +IntOp $R2 $R2 + 1 +${If} $R2 > 60 + !insertmacro CockLog "old uninstall: $OldInstallDir still holds cockatrice.exe after 30 s (uninstaller exit=$OldUninstallExit)" + ; The uninstaller's own account of what it skipped or why it stopped lives in its + ; separate log; copy it here so the report sent from AbortInstall carries it. + Call AppendUninstallerLog + ${If} $OldUninstallExit != "0" + ; Cancelled or failed with a non-zero exit code (an uninstaller that could not be + ; started is handled above): the old version was not meant to be removed, so + ; nothing is deleted on its behalf. + StrCpy $AbortReason \ + "The previous version in $OldInstallDir is still there because its uninstaller was cancelled or failed (exit code '$OldUninstallExit').$\r$\n$\r$\nClose anything still using those files and run this installer again." + ; The backup was taken before the uninstaller ran; an abort must not leave the + ; only copy of the user data in %TEMP%, where the next run clears it. + Call RestoreUserDataAfterUninstall + Call AbortInstall + ${EndIf} + ; The uninstaller finished but skipped files - a running instance or a scanner holding + ; them, which NSIS Delete passes over silently. Free them and remove what is left. + Call RemoveOldPayloadLeftovers + ; The removal reports success only when every payload executable is gone and the + ; DLL/Plugins purge came back clean; cockatrice.exe alone would hide a locked + ; oracle.exe or a stale DLL the new payload is about to be written over. + ${If} $OldPayloadRemoved != "1" + StrCpy $AbortReason \ + "The previous version in $OldInstallDir could not be fully removed after its uninstaller ran.$\r$\n$\r$\nStill in use by: $HolderOutput$\r$\n$\r$\nClose anything still using those files and run this installer again." + Call RestoreUserDataAfterUninstall + Call AbortInstall + ${EndIf} + ; The payload is gone and ExecWait already waited for the uninstaller; keep the + ; short settle the other paths use, then hand back to the caller's restore. + Sleep 1000 + !insertmacro CockLog "old uninstall: continuing after removing the leftovers the uninstaller skipped" + Return +${EndIf} +Sleep 500 +Goto old_uninstall_wait +old_uninstall_gone: +${If} $R2 > 0 + ; ExecWait returned while the old payload was still there: that uninstaller + ; copied itself to %TEMP%, and the copy is still working through its file list. + ; cockatrice.exe is gone before the rest of it, so let the copy settle before + ; the sections below start writing files it would otherwise remove. + Sleep 1000 + !insertmacro CockLog "old uninstall: ExecWait returned early, settled 1 s after $R2 * 500 ms" +${Else} + !insertmacro CockLog "old uninstall: $OldInstallDir was already clear when ExecWait returned" +${EndIf} +Return +FunctionEnd + +; Called when the old uninstaller exited successfully but left its payload behind, or +; could not be run at all (missing or not startable) and the payload is still there. +; Frees the files the same way the guard does (close, then offer to end), deletes what +; the uninstaller skipped, and on failure records which files survived and who holds +; them in $HolderOutput, for the abort message. Sets $OldPayloadRemoved to 1 only when +; every payload executable is gone and the DLL/Plugins purge is clean. +Function RemoveOldPayloadLeftovers + !insertmacro CockLog "old uninstall: removing leftovers of $OldInstallDir" + StrCpy $OldPayloadRemoved 0 + StrCpy $HolderOutput "nothing could be named" + StrCpy $GuardDir $OldInstallDir + Call GuardAppsNotRunning + ; The purge helpers work on $INSTDIR; point them at the old directory for the + ; duration (on /R the two are the same anyway). + Push $INSTDIR + StrCpy $INSTDIR $OldInstallDir + StrCpy $R2 0 + rop_loop: + SetFileAttributes "$INSTDIR\cockatrice.exe" NORMAL + SetFileAttributes "$INSTDIR\oracle.exe" NORMAL + SetFileAttributes "$INSTDIR\servatrice.exe" NORMAL + Delete "$INSTDIR\cockatrice.exe" + Delete "$INSTDIR\oracle.exe" + Delete "$INSTDIR\servatrice.exe" + Delete "$INSTDIR\qt.conf" + Call RetryDllPurge + ; $R0 is RetryDllPurge's verdict (1 = a DLL or the Plugins directory survived). + ; Every payload executable has to be gone as well: a locked oracle.exe or + ; servatrice.exe is just as fatal to the install that follows as cockatrice.exe. + IfFileExists "$INSTDIR\cockatrice.exe" rop_retry + IfFileExists "$INSTDIR\oracle.exe" rop_retry + IfFileExists "$INSTDIR\servatrice.exe" rop_retry + ${If} $R0 == 0 + Goto rop_done + ${EndIf} + rop_retry: + IntOp $R2 $R2 + 1 + ${If} $R2 < 3 + !insertmacro CockLog "old uninstall: leftovers of $OldInstallDir survived removal attempt $R2, retrying in 2 s" + Sleep 2000 + Goto rop_loop + ${EndIf} + !insertmacro CockLog "old uninstall: leftovers of $OldInstallDir still present after 3 removal attempts" + !insertmacro CockLogListing "old-leftovers" "*.*" + ; Per-file Windows codes for the DLLs plus the holder probe's line ($HolderOutput). + Call LogStaleRuntimeDiagnostics + Pop $INSTDIR + Return + rop_done: + StrCpy $OldPayloadRemoved 1 + ; Only now that the recovery has succeeded: deleting the old uninstaller on a + ; failed attempt would leave a directory whose UninstallString points at nothing, + ; so no later run could remove it. WriteUninstaller recreates it on success anyway. + SetFileAttributes "$INSTDIR\uninstall.exe" NORMAL + Delete "$INSTDIR\uninstall.exe" + !insertmacro CockLog "old uninstall: leftovers of $OldInstallDir removed" + !insertmacro CockLogListing "old-leftovers-after-removal" "*.*" + Pop $INSTDIR +FunctionEnd + +; Copies the old uninstaller's log ($TEMP\Cockatrice-uninstaller.log) into this +; installer's log, so the report AbortInstall sends shows what the uninstaller did. +; Uninstallers older than this log simply have no file; that is logged too. +Function AppendUninstallerLog + ClearErrors + FileOpen $UninstLogHandle "$TEMP\Cockatrice-uninstaller.log" r + ${If} ${Errors} + ${OrIf} $UninstLogHandle == "" + !insertmacro CockLog "uninstaller log: not found at $TEMP\Cockatrice-uninstaller.log" + Return + ${EndIf} + !insertmacro CockLog "uninstaller log begin ----------------------------------------" + aul_loop: + ClearErrors + FileRead $UninstLogHandle $UninstLogLine + IfErrors aul_done + ; Strip the line break FileRead keeps, so CockLog does not double it. + aul_trim: + StrCpy $R9 $UninstLogLine 1 -1 + ${If} $R9 == "$\n" + ${OrIf} $R9 == "$\r" + StrCpy $UninstLogLine $UninstLogLine -1 + Goto aul_trim + ${EndIf} + !insertmacro CockLog "uninstaller: $UninstLogLine" + Goto aul_loop + aul_done: + FileClose $UninstLogHandle + !insertmacro CockLog "uninstaller log end ------------------------------------------" +FunctionEnd + ; BackupUserDataBeforeUninstall / RestoreUserDataAfterUninstall ; ; The uninstallers shipped in 3.1.0/3.1.1 run a blanket "RMDir /r $INSTDIR". @@ -668,222 +996,337 @@ FunctionEnd ; "The procedure entry point X could not be located in the dynamic link ; library ...Qt6Network.dll" on the next start. ; -; On a silent update (/R /S) matching processes are asked to close gracefully -; (WM_CLOSE) and waited for up to 60 s. Cockatrice is never force-stopped: it -; may be holding an unsaved deck or showing a "really leave this game?" -; confirmation, so if it refuses to close the install is aborted - nothing has -; been replaced yet, so the update is simply deferred. Oracle and Servatrice -; are console applications without a main window, so CloseMainWindow() does -; nothing for them and waiting cannot help; they hold no unsaved user data and -; are force-stopped by PID once the grace period is over. On interactive -; installs and uninstalls the user is prompted to close them instead. +; Both questions this installer has about running processes - "may I replace +; the payload?" and "what is holding this file I could not delete?" - are +; answered by tools/installer-holder-probe, a small executable built from this +; repository and embedded in the installer and the uninstaller alike. It names +; every process and service that has one of the payload files open or mapped +; (the Restart Manager, RmRegisterResources/RmGetList) and, separately, every +; payload program whose image lies in the directory being replaced rather than +; in a folder below it. ; -; Processes are matched by image name AND by their executable path living -; under the guarded directory ($GuardDir, normally $INSTDIR), so unrelated -; processes that merely share an image name (e.g. the Oracle DB instance -; "oracle.exe") are never touched. +; The second lookup is not redundant. Restart Manager answers "who has this +; file open", and a running instance does not have to have one open: its own +; image and the modules it loaded are mapped into it rather than opened by it, +; and a mapped image is not reliably reported. 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. A guard built on file handles alone therefore misses exactly the +; instance it exists to find; it would pass, the purge would fail, and the +; install would end in a warning with no offer to close and try again. +; +; Why not enumerate processes from this script: the NSIS stub is a 32-bit +; process, so a bare "powershell" started from it is the 32-bit Windows +; PowerShell, whose Get-Process cannot read the path of a 64-bit process. A +; guard built on that misses exactly the running instance it exists to find. +; The probe opens each process with PROCESS_QUERY_LIMITED_INFORMATION and calls +; QueryFullProcessImageNameW, which has no bitness mismatch, and Restart +; Manager is a system API rather than a view of this process's architecture. +; +; The files that count are the payload the purge removes and the installer +; replaces: the three executables, the top-level DLLs, qt.conf 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 and cannot block an upgrade; +; the image lookup matches on the directory a process runs from, so it does not +; widen that set. +; +; Nothing is force-stopped without the user asking for it: Cockatrice may be holding +; an unsaved deck or showing a "really leave this game?" confirmation, Oracle writes +; the card database in place and can be killed mid-write, and Servatrice may be serving +; a game. A close is a request, made automatically because that is what the user started +; the installer to do: the probe first asks every instance over its local socket, which +; reaches a hidden window and an instance sitting on its own confirmation, and falls back +; to WM_CLOSE for an older client. The automatic close is retried while the set of holders +; changes, so one instance exiting does not leave the next one unasked. When a close is +; still not answered the user is told which program is in the way and is offered to force +; close all three, check again after closing them by hand, or abort - the last resort, with +; what it costs spelled out in the prompt. -; usage: set $R2 to the base image name (without extension, e.g. "cockatrice", -; as accepted by Get-Process -Name), call this, result in $R0 -; (1 = running from $GuardDir, 0 = not running, 2 = could not determine) -Function IsAppRunning - ; Every caller has to set $GuardDir; an empty one would make the path check - ; below match every process that merely shares the image name, which is the - ; opposite of what this guard is for. - ${If} $GuardDir == "" - StrCpy $R0 2 - Return - ${EndIf} - ; Pass the directory to guard to PowerShell via an environment variable so - ; it never has to be embedded into the -Command string: apostrophes in a - ; path would terminate the command early, and the path in a -like pattern - ; would treat [ and ] as wildcard characters. - System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$GuardDir")' - ; Exit the process explicitly so nsExec's pop reflects whether a matching - ; process was found: powershell.exe exits 0 whether or not the pipeline - ; produced objects. 0 = not running, 1 = running, anything else = could not - ; be determined (see IsProcessLookupAvailable). - ; A process is only a match when its path is readable and lives under - ; $GuardDir. A process we cannot inspect is not one of ours: the updater is - ; always started by the application it updates, so it runs in the same user - ; session, and an interactive install is elevated and can read the paths of - ; the user's own processes. Assuming a match instead would be a hard stop - ; rather than a fail-closed guard - an Oracle database instance running as - ; SYSTEM has a process named "oracle" whose path raises Access denied, and - ; that would make every silent update abort and every interactive install - ; loop on the Retry prompt with no way out but Cancel. - nsExec::ExecToLog 'powershell -NoProfile -Command "if (Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { try { $$p = $$_.Path; if ($$p) { $$p.StartsWith($$env:COCKATRICE_GUARD_DIR, [StringComparison]::OrdinalIgnoreCase) } else { $$false } } catch { $$false } } | Select-Object -First 1) { exit 1 } else { exit 0 }"' - Pop $R0 - ${If} $R0 == "0" - StrCpy $R0 0 - ${ElseIf} $R0 == "1" - StrCpy $R0 1 - ${Else} - ; powershell.exe could not be run at all (nsExec pushes the string - ; "error"), or died with an unexpected code. That is "could not - ; determine", which has to stay distinguishable from "definitely - ; running": a state that can never clear would block the installer - ; forever. IsProcessLookupAvailable warns about that case once. - StrCpy $R0 2 - ${EndIf} +; One sentence for every prompt whose Yes ends the whole set, so that answer reads the same +; wherever the user meets it: this guard before anything is replaced, the uninstaller's, and +; the recovery prompt after a failed purge. +!define END_COST "An unsaved deck, a running game or an unwritten setting is lost, Servatrice drops its players, and an Oracle that is mid-write can damage the card database." + +; What the user does instead when they say no: the prompt's other answer stops the run, so they +; are told how to get past the same wall next time. Also used by the abort dialogs, which is +; where the answer to "why did it stop" ends up. +!define AFTER_CLOSING_GUIDANCE "Answer the confirmation Cockatrice may be showing - open it from the notification area if it is hidden there - and use Task Manager for a program that would not end." + +; The escape hatch for a user who would rather not force-close anything: the same release, +; downloaded and run by hand once Cockatrice is closed. Every abort dialog shows this and +; offers to open the page, so an update that cannot free the files still leaves the user a +; way forward that does not end in Task Manager. +!define LATEST_RELEASE_URL "https://github.com/Cockatrice/Cockatrice/releases/latest" +!define MANUAL_UPDATE_GUIDANCE "To update safely instead, close Cockatrice and download the latest installer from:$\r$\n${LATEST_RELEASE_URL}$\r$\nThen run it manually." + +; usage: set the variable named by the second argument to the probe's listing, then call +; this. Sets $GuardPrompt to the question the guard and the recovery path ask. +; +; One macro rather than three copies, because the wording is a promise: Yes ends the +; programs the payload installs - which is exactly the set EndHolders acts on - and anything +; else the probe named has to be closed by hand, so a listing that includes a scanner or a +; service hosted elsewhere does not come with an answer that cannot do what it says. +; +; Two answers, one of each: the automatic close already ran when this is asked, so Yes is the +; only thing left that can free the files and No stops the run. The guard itself uses +; CockEndRecheckPrompt instead, whose No checks again; this one stays for the recovery path, +; where a retry cannot change anything because the purge was already retried. +; +; The assembled string has to fit the characters one NSIS string holds, which is what +; bounds HOLDER_LINE_CHAR_MAX in tools\installer-holder-probe\holder_line.h: with the +; longest listing that can arrive it stays inside that budget instead of being cut off at +; the tail, where the cancel line is. +; +; @param header the sentence that introduces the listing, with its trailing colon +; @param listing the variable holding the probe's line, $GuardOutput or $HolderOutput +; @param cancelWord what No gives up, "installation" or "uninstallation" +!macro CockEndPrompt header listing cancelWord + StrCpy $GuardPrompt "${header}$\r$\n$\r$\n${listing}" + StrCpy $GuardPrompt "$GuardPrompt$\r$\n$\r$\nChoose Yes to force close the Cockatrice, Oracle and Servatrice programs listed above. ${END_COST}$\r$\n$\r$\nChoose No to cancel the ${cancelWord}. ${AFTER_CLOSING_GUIDANCE}" +!macroend + +; usage: like CockEndPrompt, for a MB_YESNOCANCEL question whose No checks again. +; +; The automatic close has already run, so the three answers are what is left: force close +; the programs the payload installs (Yes), close them by hand and check again (No), or stop +; the run (Cancel). No used to be the only alternative to ending them, which meant a user who +; would rather close the instance themselves had to abandon the update and start the +; installer again. The listing the question is asked with is refreshed on every pass, so the +; question changes as programs go away instead of repeating itself word for word. +; +; The manual-update guidance lives in the abort dialog, which is where a Cancel ends up, not +; in this prompt. With the longest listing HOLDER_LINE_CHAR_MAX allows, the assembled string +; stays under 900 characters, inside the 1024 one NSIS string holds, so the Cancel line is +; never the part that gets cut off. +; +; @param header the sentence that introduces the listing, with its trailing colon +; @param listing the variable holding the probe's line, $GuardOutput +; @param cancelWord what Cancel gives up, "installation" or "uninstallation" +!macro CockEndRecheckPrompt header listing cancelWord + StrCpy $GuardPrompt "${header}$\r$\n$\r$\n${listing}" + StrCpy $GuardPrompt "$GuardPrompt$\r$\n$\r$\nChoose Yes to force close the Cockatrice, Oracle and Servatrice programs listed above. ${END_COST}$\r$\n$\r$\nChoose No to close them yourself, then check again - answer the confirmation Cockatrice may be showing, and open it from the notification area if it is hidden there.$\r$\n$\r$\nChoose Cancel to abort the ${cancelWord}." +!macroend + +; Emitted once for the installer and once for the uninstaller: the uninstaller +; cannot call installer functions, and its own copy of the probe keeps a running +; 64-bit instance from being missed there too. +!macro CockHolderProbeFunctions prefix tag +Function ${prefix}ExtractHolderProbe + InitPluginsDir + IfFileExists "$PLUGINSDIR\installer-holder-probe.exe" ck_probe_ready_${tag} + File "/oname=$PLUGINSDIR\installer-holder-probe.exe" "..\..\..\installer-holder-probe.exe" + ck_probe_ready_${tag}: FunctionEnd -; Sets $R0 to 1 if PowerShell can be started at all, 0 if it cannot. The -; running instance check is built on top of it, so a machine where -; powershell.exe is missing, blocked by policy or refused by antivirus is -; reported once instead of being reported as "Cockatrice is running" on every -; single check. -Function IsProcessLookupAvailable - nsExec::ExecToLog 'powershell -NoProfile -Command "exit 0"' - Pop $R0 - ${If} $R0 == "0" - StrCpy $R0 1 - ${Else} - StrCpy $R0 0 - ${EndIf} +; usage: set $GuardDir to the directory to check, then call this. Sets +; $GuardExit to the probe's exit code and $GuardOutput to its one-line answer. +; 0 = nothing holds the payload and no payload program runs from the directory, 1 = the +; line names what does, anything else = the probe could not answer (2 = no +; directory was given, 3 = no payload program was named but one refuses to be replaced, or +; the Restart Manager call failed and no payload program runs from the directory either, +; "timeout" = it did not return in time). +Function ${prefix}RunHolderProbe + Call ${prefix}ExtractHolderProbe + ; The directory travels in an environment variable, never on the command + ; line: a path with spaces, quotes or a trailing backslash then does not + ; have to survive NSIS quoting. + System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$GuardDir")' + ; /TIMEOUT because this can run before the first page: a Restart Manager + ; that does not answer must not leave the installer with no window and no + ; way out. + nsExec::ExecToStack /TIMEOUT=20000 '"$PLUGINSDIR\installer-holder-probe.exe"' + Pop $GuardExit + Pop $GuardOutput FunctionEnd -Function CloseMatchingApps - ; gracefully ask every matching instance to close (sends WM_CLOSE) - ; - ; The filter here is deliberately the strict one: a process is only asked to - ; close when its path is readable and lives under $GuardDir. Treating a - ; process whose path cannot be read (an unrelated oracle.exe / servatrice.exe, - ; or one owned by another account running as SYSTEM) as a match would make - ; the installer close a third party application, and it runs elevated, so - ; CloseMainWindow() would succeed on it. +; usage: set $GuardDir to the directory being replaced, then call this. Asks the running +; Cockatrice to close, and waits for it to exit. +; +; The set is what the probe names: the processes holding a file of the version being +; replaced, plus the payload programs running from the directory itself. The probe +; closes that set by pid, which is why nothing here has to match an executable path. Only +; cockatrice.exe is asked: Oracle writes the card database in place and Servatrice has no +; window to ask, so both are left alone here and only ended by the explicit answer. +; +; Each instance is asked to close over the local socket it listens on, which is the same +; request as clicking its close button and reaches 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, and a visible window is brought forward +; first. Either way the close is answered, not obeyed - Cockatrice asks before it quits while +; a card database update is running, a game is open or a deck is unsaved - and that question +; is only useful if it is in front of the installer instead of behind it. +; +; The probe waits up to 30 s for the processes it asked to exit, so the check that follows +; measures a settled directory rather than sleeping for a fixed time. Its line goes into the +; log as it stands: it names every holder, including the ones left to the user, so a close +; that did nothing can be told apart from one that was never sent. +; +; Never a force-close here: the application decides whether to quit. Ending is a separate +; answer the guard prompt offers when a close did not free the files, spelled out with what +; it costs - see EndHolders - and it ends every program the payload installs. +Function ${prefix}CloseHolders + Call ${prefix}ExtractHolderProbe System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$GuardDir")' - nsExec::ExecToLog 'powershell -NoProfile -Command "Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { try { $$p = $$_.Path; if ($$p) { $$p.StartsWith($$env:COCKATRICE_GUARD_DIR, [StringComparison]::OrdinalIgnoreCase) } else { $$false } } catch { $$false } } | ForEach-Object { $$null = $$_.CloseMainWindow() }"' + ; The probe waits 30 s itself. The timeout here only bounds a probe that never returns; + ; the Restart Manager call inside it is the same one the holder listing makes. + nsExec::ExecToStack /TIMEOUT=40000 '"$PLUGINSDIR\installer-holder-probe.exe" --close-holders' Pop $R3 + Pop $R4 + ; Not the CockLog macro: this function exists twice and the uninstaller copy may only + ; Call un. functions, so the macro's two lines are spelled with the prefix here. + Push "close-holders: probe exit=$R3 [$R4]" + Call ${prefix}CockLogWrite + DetailPrint "Closing Cockatrice: $R4" FunctionEnd -Function ForceCloseApps - ; stop the matching instances by PID once the grace period is over. Only - ; used for the console applications ($AllowForceClose), which have no window - ; to close and hold no unsaved user data. Same strict filter as above. +; usage: set $GuardDir to the directory being replaced, then call this. Ends the running +; Cockatrice, Oracle and Servatrice instances the probe names. +; +; This is the only place anything is stopped without being asked to stop, so it is only +; reached after the user picked it from a prompt that says what it costs: a client that is +; still running may have an unsaved deck, a game or a settings write in flight, Oracle may +; be part-way through writing the card database, and Servatrice may be serving a game. The +; probe ends the three programs the payload installs, services included, and nothing else: +; a scanner or a program the user keeps in the directory is named and left to the user. +Function ${prefix}EndHolders + Call ${prefix}ExtractHolderProbe System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$GuardDir")' - nsExec::ExecToLog 'powershell -NoProfile -Command "Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { try { $$p = $$_.Path; if ($$p) { $$p.StartsWith($$env:COCKATRICE_GUARD_DIR, [StringComparison]::OrdinalIgnoreCase) } else { $$false } } catch { $$false } } | ForEach-Object { Stop-Process -Id $$_.Id -Force -ErrorAction SilentlyContinue }"' + ; The probe waits up to 5 s for what it ended to be gone, so the check that follows + ; measures a settled directory instead of racing the processes it just terminated. + nsExec::ExecToStack /TIMEOUT=20000 '"$PLUGINSDIR\installer-holder-probe.exe" --end-holders' Pop $R3 + Pop $R4 + Push "end-holders: probe exit=$R3 [$R4]" + Call ${prefix}CockLogWrite + DetailPrint "Ending Cockatrice, Oracle and Servatrice: $R4" FunctionEnd +!macroend -Function WaitForAppToClose - ; usage: set $R1 to the display name (e.g. "cockatrice.exe") and $R2 to the - ; base image name (e.g. "cockatrice") - Call IsAppRunning - !insertmacro CockLog "guard $R1: state=$R0 (dir=$GuardDir)" - ; Only a definite "running" is worth waiting for. "Not running" is done, and - ; "could not determine" must not keep the installer waiting on an answer it - ; will never get - but it cannot simply be waved through either, see - ; HandleUnknownAppState. - ${If} $R0 = 0 +!insertmacro CockHolderProbeFunctions "" install +!insertmacro CockHolderProbeFunctions "un." uninstall + +; usage: set $GuardDir to the directory to guard, then call this. +; +; Asks Cockatrice to close once, then asks one question with one answer that can free the +; payload. The automatic close is the same request clicking the window's close button makes, +; so the common case - the client was still shutting down - never turns into a question. The +; prompt is what remains when the close cannot reach everything: Oracle writes the card +; database in place, Servatrice has no window, and a client that is sitting on its own +; confirmation needs an answer nobody can give it from here. It offers to end the programs +; the payload installs - and only those, which the text says - with what that costs (Yes), +; and its other answer stops the install (No) instead of repeating the same dialog. What +; survives an end is reported in the abort, never asked about again - ending is the last +; resort, see EndHolders. +; +; An error is always shown, in every mode. A /S install without /R has nobody at +; the keyboard, but this installer does not promise unattended runs: when an +; error needs a decision, asking for one is better than silently leaving a +; half-updated install behind. The happy path has no dialog, so a /S reinstall +; that has nothing to report stays silent. +Function GuardAppsNotRunning + !insertmacro CockLog "guard: checking $GuardDir (launcherExited=$LauncherExited)" + StrCpy $GuardCloseRounds 0 + StrCpy $GuardRecheckCount 0 + StrCpy $GuardLastHolders "" + guard_check: + Call RunHolderProbe + ${If} $GuardExit == "0" + !insertmacro CockLog "guard: nothing is holding the payload" + DetailPrint "No running Cockatrice, Oracle or Servatrice instance was found." Return ${EndIf} - ${If} $R0 = 2 - Call HandleUnknownAppState - Return - ${EndIf} - - ${If} ${Silent} - ; ask the application to close gracefully (WM_CLOSE), then wait for it to exit - DetailPrint "Closing $R1 ..." - Call CloseMatchingApps - ; wall-clock deadline: each poll has to spawn a fresh powershell.exe, so - ; a bare loop counter would not bound the real wait time to 60 s. - System::Call 'kernel32::GetTickCount()i.s' - Pop $R8 - ck_wait_loop: - Sleep 500 - Call IsAppRunning - ${If} $R0 = 0 - !insertmacro CockLog "guard $R1: closed gracefully" - DetailPrint "$R1 closed." + ${If} $GuardExit == "1" + !insertmacro CockLog "guard: still in use by $GuardOutput" + ; Close it for the user first. The probe asks every instance over its local socket + ; and falls back to WM_CLOSE for an older client, so this is the same request as + ; clicking the window's close button: the application still answers its own "really + ; quit?" prompt, so nothing is lost. A second round is spent only when the listing + ; changed, which means something exited and the rest is worth asking again; repeating + ; the same request against the same processes cannot change anything. + ${If} $GuardCloseRounds < 2 + ${AndIf} $GuardOutput != $GuardLastHolders + StrCpy $GuardLastHolders $GuardOutput + IntOp $GuardCloseRounds $GuardCloseRounds + 1 + !insertmacro CockLog "guard: asking the payload programs to close (round $GuardCloseRounds)" + Call CloseHolders + Goto guard_check + ${EndIf} + DetailPrint "The install directory is in use by: $GuardOutput" + ; The close ran and did not free the files. Force-closing them is what the installer + ; can do (Yes); closing them by hand is what the user can do (No checks again, with + ; the fresh listing); Cancel stops the run and the abort dialog offers the same + ; update from the official download page. Without the middle answer a user who did + ; not want anything killed had no way forward but to abandon the update. + guard_ask: + !insertmacro CockEndRecheckPrompt "Cockatrice, Oracle or Servatrice is still using files of the installation:" $GuardOutput "installation" + MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON2 "$GuardPrompt" IDYES guard_end IDNO guard_recheck + Goto guard_cancel + guard_recheck: + IntOp $GuardRecheckCount $GuardRecheckCount + 1 + !insertmacro CockLog "guard: user closed programs by hand, checking again (recheck $GuardRecheckCount)" + ; A fresh set gets a fresh automatic close, exactly as if the guard had just started: + ; what the user closed by hand may have been one instance of several. + StrCpy $GuardCloseRounds 0 + StrCpy $GuardLastHolders "" + Goto guard_check + guard_cancel: + !insertmacro CockLog "guard: cancelled by user after $GuardRecheckCount recheck(s)" + StrCpy $AbortReason \ + "The installation was cancelled because Cockatrice, Oracle or Servatrice is still running and holding files of the previous version.$\r$\n$\r$\n${AFTER_CLOSING_GUIDANCE}$\r$\n$\r$\nClose them and run the installer again." + ; RemoveOldPayloadLeftovers runs with the pre-uninstall backup already taken; + ; restoring here keeps this abort from stranding the only copy in %TEMP%. + Call RestoreUserDataAfterUninstall + Call AbortInstall + ; Only reached through Yes: AbortInstall does not return. + guard_end: + !insertmacro CockLog "guard: ending the payload programs on request, then checking again" + Call EndHolders + ; Measure once and report, rather than asking the same question again. Everything the + ; payload installs was ended, so what survived is a program this installer may not + ; open or does not own - another dialog offering to end it again would only repeat + ; itself. + Call RunHolderProbe + ${If} $GuardExit == "0" + !insertmacro CockLog "guard: nothing is holding the payload" Return ${EndIf} - ${If} $R0 = 2 - Call HandleUnknownAppState - Return - ${EndIf} - System::Call 'kernel32::GetTickCount()i.s' - Pop $R9 - IntOp $R9 $R9 - $R8 - ${If} $R9 < 60000 - Goto ck_wait_loop - ${EndIf} - ; The grace period is over. Cockatrice is never force-stopped: it may be - ; showing a "really leave this game?" confirmation or holding an unsaved - ; deck, and nothing has been replaced yet, so aborting just defers the - ; update and preserves the user's data. - ${If} $AllowForceClose = 0 - !insertmacro CockLog "guard $R1: still running after 60s, aborting (no force close)" - DetailPrint "$R1 is still running after 60 s; aborting the update so that no data is lost." + !insertmacro CockLog "guard: still in use after ending them ($GuardExit): $GuardOutput" + ${If} $GuardExit == "1" StrCpy $AbortReason \ - "$R1 did not close within 60 seconds, so the installation was aborted to avoid losing unsaved data.$\r$\n$\r$\nPlease close $R1 and run the installer again." - Call AbortInstall + "The installation was cancelled because these programs still hold files of the previous version and could not be ended:$\r$\n$\r$\n$GuardOutput$\r$\n$\r$\n${AFTER_CLOSING_GUIDANCE}$\r$\n$\r$\nClose them and run the installer again." + ${Else} + StrCpy $AbortReason \ + "The installation was cancelled because, after ending Cockatrice, Oracle and Servatrice, the installer could not check whether they still hold files of the previous version.$\r$\n$\r$\nThe check reported: $GuardOutput$\r$\n$\r$\nClose them and run the installer again." ${EndIf} - ; Oracle and Servatrice are console applications: CloseMainWindow() - ; returned false for them and waiting longer cannot help. They hold no - ; unsaved user data, so stop them by PID. - !insertmacro CockLog "guard $R1: still running after 60s, force-stopping" - DetailPrint "Force-stopping $R1 ..." - Call ForceCloseApps - Sleep 2000 - Call IsAppRunning - ${If} $R0 = 0 - !insertmacro CockLog "guard $R1: force-stopped" - DetailPrint "$R1 closed." - Return - ${EndIf} - ${If} $R0 = 2 - Call HandleUnknownAppState - Return - ${EndIf} - !insertmacro CockLog "guard $R1: could not be stopped, aborting" - DetailPrint "$R1 is still running after 60 s; aborting the update." - StrCpy $AbortReason \ - "$R1 could not be stopped, so the installation was aborted.$\r$\n$\r$\nPlease close $R1 and run the installer again." - Call AbortInstall - ${Else} - ck_wait_prompt: - MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ - "$R1 is still running.$\r$\n$\r$\nPlease close it, then click Retry.$\r$\nClick Cancel to abort." \ - IDCANCEL ck_abort_install - Call IsAppRunning - ${If} $R0 = 0 - Return - ${EndIf} - ${If} $R0 = 2 - Call HandleUnknownAppState - Return - ${EndIf} - Goto ck_wait_prompt - ck_abort_install: - Abort - ${EndIf} -FunctionEnd - -; A process lookup came back "could not determine": powershell.exe ran but the -; answer was not usable, e.g. the process could not be inspected at all. That -; state is fine to carry on from when the user is at the keyboard - they can -; close the applications by hand and the stale-runtime check still stands -; between a failed guard and a mismatched install. -; -; It is not fine on a silent upgrade. There the whole point of this guard is to -; know that nothing holds locks on the files about to be replaced, and this is -; exactly the state where that is unknown, with no one to click a dialog: the -; old code returned and let the install carry on into the mixed Qt DLL set that -; made the next start fail. Cancel the update instead; the next attempt runs -; with the applications closed and succeeds. -Function HandleUnknownAppState - ${If} $ReinstallMode = 1 - StrCpy $AbortReason \ - "The installation was cancelled because it could not determine whether Cockatrice is still running.$\r$\n$\r$\nPlease close Cockatrice, Oracle and Servatrice and run the installer again." + Call RestoreUserDataAfterUninstall Call AbortInstall ${EndIf} - DetailPrint "Could not determine whether $R1 is running; close Cockatrice, Oracle and Servatrice manually if the installation reports a problem." - ${IfNot} ${Silent} - MessageBox MB_OK|MB_ICONEXCLAMATION \ - "This installer could not determine whether $R1 is still running.$\r$\n$\r$\nPlease close Cockatrice, Oracle and Servatrice manually before continuing." - ${EndIf} + ; Not an answer at all: the probe was not configured, the Restart Manager + ; call failed and no payload program runs from the directory, a payload + ; program refuses to be replaced but no lookup could name it (it may run + ; under another account), or the probe timed out. Carrying on from that is a + ; decision, so it is asked for rather than assumed - the stale-runtime check + ; after the purge still stands between a failed guard and a mismatched + ; install, but it only fires after files have been replaced, and on an + ; upgrade the launcher asked for nobody is watching the installer's exit + ; code. Retry asks the probe again (a Restart Manager that was busy answers + ; the second time, and a program the user has closed frees its file), Ignore + ; installs anyway, Abort cancels instead of writing a payload next to files + ; the guard could not rule out. + !insertmacro CockLog "guard: the holder probe could not answer ($GuardExit): $GuardOutput" + DetailPrint "Could not confirm whether Cockatrice is still running: $GuardOutput" + MessageBox MB_ABORTRETRYIGNORE|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ + "This installer could not confirm whether Cockatrice, Oracle or Servatrice is still running.$\r$\n$\r$\nThe check reported: $GuardOutput$\r$\n$\r$\nClose them, then choose Retry to check again. Choose Ignore to install anyway, or Abort to cancel the installation." \ + IDRETRY guard_check IDABORT guard_unknown_abort + !insertmacro CockLog "guard: continuing without an answer from the probe" + Return + guard_unknown_abort: + StrCpy $AbortReason \ + "The installation was cancelled because it could not confirm whether Cockatrice, Oracle or Servatrice is still running.$\r$\n$\r$\nThe check reported: $GuardOutput$\r$\n$\r$\nPlease close them and run the installer again." + ; Same reason as the cancel path above: this abort can be reached from the + ; leftover-removal guard, after the backup was taken. + Call RestoreUserDataAfterUninstall + Call AbortInstall FunctionEnd Function EnsureAppsNotRunning @@ -894,103 +1337,155 @@ FunctionEnd ; Aborts the installation, showing $AbortReason to the user. ; -; Every abort goes through here so the unattended case is handled the same way -; everywhere: the in-app updater runs this installer silent and detached and -; never looks at its exit code, so a plain Abort would leave the user with no -; update and no explanation, while a /S install without /R is unattended - by a -; package manager, for instance - and a message box there would block forever. -; Only the first of those two gets a dialog; the second reports through the log -; and the exit code. +; Every abort goes through here so no failure ends silently: the in-app updater +; runs this installer silent and detached and never looks at its exit code, and +; a /S install without /R has nobody watching its exit code either, so a plain +; Abort would leave the user with no update and no explanation. An error always +; shows its dialog, in every mode; a silent run stays silent only for as long +; as nothing goes wrong. The log path is part of the dialog, so a report can be +; acted on without asking the user to find the file. Function AbortInstall !insertmacro CockLog "ABORT: $AbortReason" !insertmacro CockLog "full installer log: $InstallLogPath" - ; Show a dialog whenever someone is at the keyboard to see it: an interactive - ; install, or the in-app updater's /R upgrade. The /R upgrade is silent, but - ; the client that launched the installer has already quit, so the user is - ; still there. Only a /S install without /R is attended by a package manager - ; and must go straight to the exit code. - ${If} $ReinstallMode = 1 - ${OrIfNot} ${Silent} - MessageBox MB_OK|MB_ICONEXCLAMATION "$AbortReason$\r$\n$\r$\nA detailed installer log was written to:$\r$\n$InstallLogPath" - ; Offer to send the installer log to Sentry so aborted installs can be diagnosed. This block already runs - ; only when someone is at the keyboard (attended install, or the updater's /R upgrade whose launcher has - ; quit), so a silent or package-manager install never reaches the prompt and its behavior is unchanged. - ; Only a package built with a baked DSN offers to send, so the prompt can only appear when a send - ; can actually go out. A preprocessor guard rather than a runtime test: a package built without a - ; DSN compiles the block out entirely, so it cannot even carry the helper or its baked endpoint. - ; No /SD here: a /SD would suppress this prompt on the silent /R upgrade, which is exactly the - ; attended-update case this offer exists for. The runtime gate above (ReinstallMode/Silent) is the - ; hang protection — a package-manager /S install without /R never reaches this box, matching the - ; two sibling MessageBox sites in this file. - !if "@CPACK_SENTRY_ENABLED@" == "1" - MessageBox MB_YESNO|MB_ICONQUESTION "Send the installer log to the developers to help fix this? The log contains file paths from this machine, which may include your Windows user name." IDYES sentry_send - !insertmacro CockLog "sentry send: declined by user" - Goto sentry_done - sentry_send: - ; The abort reason is already in the log file (CockLog writes "ABORT: ..." as the - ; first thing AbortInstall does), so the helper reads it from there. Nothing - ; reason-shaped crosses the command line, which keeps quoting trivial and needs - ; no NSIS string functions. - InitPluginsDir - ; Embedded by the same relative path that resolves NSIS.definitions.nsh above (both are - ; configure_file'd into the build root, which is three levels up from project.nsi). An - ; absolute CMake path here carries forward slashes, which makensis does not treat as - ; absolute and so fails to find the file at package time. - File "/oname=$PLUGINSDIR\send-installer-log.ps1" "..\..\..\SendInstallerLog.ps1" - ; The send can take up to the /TIMEOUT below, so put something on the details pane - ; the moment the user says yes rather than leaving the window blank until it returns. - DetailPrint "Sending installer log..." - nsExec::ExecToStack /TIMEOUT=60000 'powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$PLUGINSDIR\send-installer-log.ps1" -LogPath "$InstallLogPath"' - Pop $SentryResult - Pop $SentryOutput - ; $SentryOutput is a single line by construction: the helper flattens its message - ; and this log file is uploaded verbatim, so a stray newline must not be able to - ; forge extra lines into the artifact we send. - !insertmacro CockLog "sentry send: result=$SentryResult output=$SentryOutput" - ${If} $SentryResult == "0" - MessageBox MB_OK|MB_ICONINFORMATION "The installer log was sent. Thank you for helping improve Cockatrice!" - ${Else} - MessageBox MB_OK|MB_ICONEXCLAMATION "The installer log could not be sent. You can still share it manually; it was written to:$\r$\n$InstallLogPath" - ${EndIf} - sentry_done: - !endif - ${EndIf} + !insertmacro CockLog "manual update: ${LATEST_RELEASE_URL}" + ; The reason names what stopped the run; the manual-update paragraph is the way forward + ; that does not need this installer to free anything. Shown in every mode, exactly like + ; the reason, because a /R upgrade has nobody watching its exit code. + MessageBox MB_OK|MB_ICONEXCLAMATION "$AbortReason$\r$\n$\r$\n${MANUAL_UPDATE_GUIDANCE}$\r$\n$\r$\nA detailed installer log was written to:$\r$\n$InstallLogPath" + ; Offer the page as well: copying a URL out of a message box is a step users skip. + ; explorer.exe hands the URL to the already-running shell, so the browser opens at the + ; user's integrity level rather than as the elevated installer. + MessageBox MB_YESNO|MB_ICONQUESTION|MB_DEFBUTTON2 "Open the download page now?$\r$\n$\r$\n${LATEST_RELEASE_URL}" IDNO abort_no_link + !insertmacro CockLog "manual update: opening the download page" + Exec '"$WINDIR\explorer.exe" "${LATEST_RELEASE_URL}"' + abort_no_link: + ; Offer to send the installer log to Sentry so aborted installs can be diagnosed. + ; Only a package built with a baked DSN offers to send, so the prompt can only appear + ; when a send can actually go out. A preprocessor guard rather than a runtime test: a + ; package built without a DSN compiles the block out entirely, so it cannot even carry + ; the helper or its baked endpoint. No /SD: the abort is exactly the case the offer + ; exists for, including on the silent /R upgrade. + !if "@CPACK_SENTRY_ENABLED@" == "1" + MessageBox MB_YESNO|MB_ICONQUESTION "Send the installer log to the developers to help fix this? The log contains file paths from this machine, which may include your Windows user name." IDYES sentry_send + !insertmacro CockLog "sentry send: declined by user" + Goto sentry_done + sentry_send: + ; The abort reason is already in the log file (CockLog writes "ABORT: ..." as the + ; first thing AbortInstall does), so the helper reads it from there. Nothing + ; reason-shaped crosses the command line, which keeps quoting trivial and needs + ; no NSIS string functions. + InitPluginsDir + ; Embedded by the same relative path that resolves NSIS.definitions.nsh above (both are + ; configure_file'd into the build root, which is three levels up from project.nsi). An + ; absolute CMake path here carries forward slashes, which makensis does not treat as + ; absolute and so fails to find the file at package time. + File "/oname=$PLUGINSDIR\send-installer-log.ps1" "..\..\..\SendInstallerLog.ps1" + ; The send can take up to the /TIMEOUT below, so put something on the details pane + ; the moment the user says yes rather than leaving the window blank until it returns. + DetailPrint "Sending installer log..." + nsExec::ExecToStack /TIMEOUT=60000 'powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$PLUGINSDIR\send-installer-log.ps1" -LogPath "$InstallLogPath"' + Pop $SentryResult + Pop $SentryOutput + ; $SentryOutput is a single line by construction: the helper flattens its message + ; and this log file is uploaded verbatim, so a stray newline must not be able to + ; forge extra lines into the artifact we send. + !insertmacro CockLog "sentry send: result=$SentryResult output=$SentryOutput" + ${If} $SentryResult == "0" + MessageBox MB_OK|MB_ICONINFORMATION "The installer log was sent. Thank you for helping improve Cockatrice!" + ${Else} + MessageBox MB_OK|MB_ICONEXCLAMATION "The installer log could not be sent. You can still share it manually; it was written to:$\r$\n$InstallLogPath" + ${EndIf} + sentry_done: + !endif SetErrorLevel 1 Abort FunctionEnd +; usage: set $GuardDir and $WaitPid, then call this. Asks the one process the installer +; waits on to close and sets $R3 to what it could do: 1 = a window exists and was asked to +; close (or is waiting on a question of its own), 4 = it has no window a user could answer, +; anything else = the probe could not answer. Only 4 lets the wait act on its own; every +; other code keeps the user in the decision, because the installer holds a live handle and +; "the probe did not find it" is not the same answer as "it is gone". +; +; The guard's CloseHolders answers the same question for every holder and leaves a +; windowless instance to the user, which is right there: the prompt names it. The launcher +; is the one process nobody can be asked about - the in-app updater runs the installer +; silent and detached, and a client that has already hidden its window has answered its +; own prompts - so this answer has to say whether there was a window to ask at all. +Function CloseLauncher + Call ExtractHolderProbe + System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$GuardDir")' + System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_LAUNCHER_PID", "$WaitPid")' + ; The probe waits 30 s itself when it posts a close. The timeout here only bounds a + ; probe that never returns; the Restart Manager call the other modes make is not + ; reached in this one. + nsExec::ExecToStack /TIMEOUT=40000 '"$PLUGINSDIR\installer-holder-probe.exe" --close-launcher' + Pop $R3 + Pop $R4 + Push "close-launcher: probe exit=$R3 [$R4]" + Call CockLogWrite + DetailPrint "Closing Cockatrice: $R4" +FunctionEnd + ; Waits for the application that launched this installer to exit, using the /PID= ; it passed. Sets $LauncherExited to 1 when that process is provably gone. ; ; This is the authority for the one question the rest of the installer can only ; approximate: is the process that started us still holding its Qt DLLs mapped? -; A mapped module cannot be deleted, so an answer of "not running" obtained by -; enumerating process names is what allows the update to write a new payload on +; A mapped module cannot be deleted, so an answer of "nothing is holding them" +; obtained from file handles is what allows the update to write a new payload on ; top of files it could not remove. Waiting on a real handle has no such gap, -; needs no powershell.exe, and is unaffected by a process whose path cannot be -; read - the case the name based check has to treat as "not ours" so it never -; closes a third party application. +; needs no process enumeration at all, and is unaffected by a process the holder +; probe cannot inspect. ; ; Sets $LauncherExited to 0 when no usable /PID= was given, which is the older ; client that does not pass one; the caller then has no authority available and -; falls back to the name based guard. +; falls back to the holder probe. +; +; A launcher that survives the first grace is asked to close by the probe, and the +; probe also answers whether there was a window a close could reach. A client hides +; its window the moment its own close is accepted, so a launcher without one has +; already answered its own prompts and is stuck in shutdown: no dialog can reach it +; there, and the wait ends it after one more grace instead of asking the user a +; question whose "keep waiting" answer can never change anything. Function WaitForLauncherToExit StrCpy $LauncherExited 0 + StrCpy $WaitWindowless 0 !insertmacro CockLog "wait-launcher: pid='$WaitPid'" ${If} $WaitPid == "" !insertmacro CockLog "wait-launcher: no pid given, falling back to name check" Return ${EndIf} - ; PROCESS_SYNCHRONIZE is the only access this needs - enough to wait on the - ; handle, and the least likely to be refused for a reason that has nothing to - ; do with the upgrade. - ; ; System.dll maps its output registers so that .rN writes $N and .RN writes ; $RN. The caller reads $R0/$R1, so the uppercase .RN form is required; the ; original .r0/.r1 wrote $0/$1 and left $R1 empty, which made the "did not ; exit" test below compare "" against "0" and abort on every /PID= upgrade. + ; Reopened on every round below, so a launcher that exited in the meantime answers + ; "already exited" instead of being waited on again. + ; + ; PROCESS_TERMINATE rides along with PROCESS_SYNCHRONIZE so that the "end it" + ; answer below acts on this very handle: the pid looked up again after a dialog + ; that can stay open as long as the user takes would name whatever process holds + ; that pid by then, and a handle cannot be handed to another process. + ; + ; When those rights are refused - a launcher started by another account, a + ; protected process - the wait still has to happen, so the ask is repeated with + ; synchronize access alone and $WaitCanEnd records that ending is not on offer. + ; Reporting "already exited" instead would be the worse answer: the payload would + ; be written over a live process, which is what this wait exists to prevent. + ; + ; Ten seconds, not the full grace: a client that started an update exits in well + ; under a second, so at ten seconds it is either flushing a card database or - far + ; more often - sitting on its own "quit anyway?" confirmation with nobody looking at + ; it. Silence for a minute is what makes that read as a hung installer. The full + ; grace is spent after the first question, so a user who answers Cockatrice's + ; confirmation is not asked again while they do it. + StrCpy $WaitSlice 10000 + wait_launcher_open: ClearErrors - System::Call 'kernel32::OpenProcess(i 0x00100000, i 0, i $WaitPid) p.R0' + StrCpy $WaitCanEnd 1 + System::Call 'kernel32::OpenProcess(i 0x00100001, i 0, i $WaitPid) p.R0' ; OpenProcess returns a null handle for a process that is already gone. The ; System plugin does not raise the NSIS error flag from a called function's ; return value, so ${Errors} alone never saw that case and the null handle was @@ -999,27 +1494,118 @@ Function WaitForLauncherToExit ${If} ${Errors} StrCpy $R0 0 ${EndIf} + ${If} $R0 == 0 + StrCpy $WaitCanEnd 0 + ClearErrors + System::Call 'kernel32::OpenProcess(i 0x00100000, i 0, i $WaitPid) p.R0' + ${If} ${Errors} + StrCpy $R0 0 + ${EndIf} + ${EndIf} ${If} $R0 == 0 ; The launcher is gone - OpenProcess cannot open a dead process - which is - ; exactly the state the wait was for. + ; exactly the state the wait was for. A handle refused for want of the terminate + ; right is not this case; the synchronize-only ask above covers it. !insertmacro CockLog "wait-launcher: already exited" DetailPrint "The application that started this installer has already exited." StrCpy $LauncherExited 1 Return ${EndIf} DetailPrint "Waiting for the application that started this installer to exit ..." - ; Same 60 s grace period the name based check uses, so a client that is slow - ; to flush its card database does not get less room than it used to. ; WAIT_OBJECT_0 is 0; WAIT_TIMEOUT is 258 and a failed wait reports 0xFFFFFFFF. - System::Call 'kernel32::WaitForSingleObject(p R0, i 60000) i.R1' - System::Call 'kernel32::CloseHandle(p R0)' + System::Call 'kernel32::WaitForSingleObject(p R0, i $WaitSlice) i.R1' ${If} $R1 != 0 - !insertmacro CockLog "wait-launcher: did not exit within 60s (wait=$R1)" - DetailPrint "The application that started this installer did not exit within 60 s." + ; $R0 is deliberately still open: it is the process that timed out, and the answer + ; below has to act on that process and not on whatever $WaitPid names by then. + !insertmacro CockLog "wait-launcher: did not exit within $WaitSlice ms (wait=$R1, canEnd=$WaitCanEnd)" + DetailPrint "The application that started this installer did not exit in time." + ; Ask the launcher itself before asking the user. A client that has already hidden + ; its window has answered its own prompts and is stuck in shutdown: there is nothing + ; left a dialog could reach, and the "ask it to close again" answer the dialog used + ; to offer could only repeat itself until the user cancelled. + StrCpy $GuardDir $INSTDIR + Call CloseLauncher + ; $R3 = what the probe could do (see CloseLauncher), $R4 = its line, already logged. + ; A process that answered the close during the probe's own wait is gone by now. + System::Call 'kernel32::WaitForSingleObject(p R0, i 0) i.R1' + ${If} $R1 == 0 + System::Call 'kernel32::CloseHandle(p R0)' + StrCpy $R0 0 + Goto wait_launcher_open + ${EndIf} + ${If} $R3 == "4" + ; Nothing a user could answer: the window is gone, so the launcher is past its + ; own prompts and stuck in shutdown. It cannot be asked, and it is not going to + ; ask the user anything either, so the dialog below would be unanswerable. + ${If} $WaitCanEnd == 1 + ${If} $WaitWindowless == 0 + ; Hiding the window is what the client does when its close is accepted, + ; so what remains is the shutdown it already started: one full grace for + ; that, without a question nobody could act on. + StrCpy $WaitWindowless 1 + StrCpy $WaitSlice 60000 + !insertmacro CockLog "wait-launcher: no window to ask, giving it one more minute" + System::Call 'kernel32::CloseHandle(p R0)' + StrCpy $R0 0 + Goto wait_launcher_open + ${EndIf} + ${If} $WaitWindowless == 1 + ; It did not use that minute either. Ending it is what the dialog's Yes + ; answer would have done, and there is no question left to put to the + ; user: the instance lost its window before this round started. + StrCpy $WaitWindowless 2 + !insertmacro CockLog "wait-launcher: still no window to ask; ending the launcher" + DetailPrint "The application that started this installer could not be asked to close; ending it." + System::Call 'kernel32::TerminateProcess(p R0, i 1) i.R2' + !insertmacro CockLog "wait-launcher: ending the launcher returned $R2" + System::Call 'kernel32::CloseHandle(p R0)' + StrCpy $R0 0 + Goto wait_launcher_open + ${EndIf} + ${EndIf} + ${EndIf} + ; A window exists, or the launcher cannot be ended from here: the three answers + ; below. "Carry on anyway" is not one of them - a live process has its image and its + ; Qt DLLs mapped, so writing the payload over them is the broken install this wait + ; exists to prevent. Yes ends the launcher, Cancel cancels, and No keeps waiting: + ; the close and the bring-forward just happened above. + MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON2 \ + "Cockatrice has not finished closing, so the installation is paused rather than replace files it is still using.$\r$\n$\r$\nIt may be waiting for you to answer its own question about an unsaved deck, an open game or a card database update.$\r$\n$\r$\nChoose Yes to end it now - an unsaved deck, a running game or an unwritten setting is lost.$\r$\n$\r$\nChoose No to keep waiting, or Cancel to cancel the installation." \ + IDYES wait_launcher_end IDNO wait_launcher_retry + !insertmacro CockLog "wait-launcher: cancelled by user" StrCpy $AbortReason \ "The application that started this update has not exited yet, so the installation was cancelled rather than replace files it is still using.$\r$\n$\r$\nPlease close Cockatrice and run the installer again." Call AbortInstall + ; Only reached through Yes or No: AbortInstall does not return. + wait_launcher_end: + ; End it through the handle waited on above, and through nothing else: no pid is + ; looked up again, so a process that inherited the number in the meantime - or a + ; /PID= that was not the launcher to begin with - cannot be hit by this. + !insertmacro CockLog "wait-launcher: ending the launcher on request" + ${If} $WaitCanEnd == 0 + !insertmacro CockLog "wait-launcher: the launcher could not be opened to end it, asking it to close instead" + Goto wait_launcher_retry + ${EndIf} + System::Call 'kernel32::TerminateProcess(p R0, i 1) i.R3' + System::Call 'kernel32::CloseHandle(p R0)' + StrCpy $R0 0 + !insertmacro CockLog "wait-launcher: ending the launcher returned $R3" + Goto wait_launcher_open + wait_launcher_retry: + System::Call 'kernel32::CloseHandle(p R0)' + StrCpy $R0 0 + ; The question has been asked and answered once; from here the wait is the full + ; grace again, so a slow flush or a confirmation being answered does not turn + ; into a stream of prompts. The close itself and the bring-forward already + ; happened just before the dialog, so this only waits. The windowless grace starts + ; over as well: "keep waiting" must not be answered by ending the process a minute + ; later without asking again. + StrCpy $WaitWindowless 0 + StrCpy $WaitSlice 60000 + Goto wait_launcher_open ${EndIf} + System::Call 'kernel32::CloseHandle(p R0)' + StrCpy $R0 0 !insertmacro CockLog "wait-launcher: launcher exited" DetailPrint "The application that started this installer has exited." StrCpy $LauncherExited 1 @@ -1043,57 +1629,6 @@ Function EnsureOldInstallNotRunning Call GuardAppsNotRunning FunctionEnd -Function GuardAppsNotRunning - ; usage: set $GuardDir to the directory to guard, then call this - Call IsProcessLookupAvailable - !insertmacro CockLog "guard: powershell available=$R0 dir=$GuardDir launcherExited=$LauncherExited" - ${If} $R0 = 0 - ; Without PowerShell we cannot tell whether a running instance is - ; holding locks on the files we are about to replace. - !insertmacro CockLog "guard: powershell unavailable, skipping instance check" - DetailPrint "Could not start powershell.exe; skipping the running instance check. Close Cockatrice, Oracle and Servatrice manually if the installation reports a problem." - ; On a silent upgrade that used to be a warning and nothing more, which - ; left the very process that launched the installer free to go on - ; replacing files it still had mapped - the one outcome this guard exists - ; to prevent - and then writing the new payload on top of them. Cancel - ; instead. An interactive install can still continue: the user is at the - ; keyboard, and refusing outright would leave them with an installer that - ; can never complete. - ; - ; Unless the launcher has already been waited on directly: WaitForLauncherToExit - ; needs no powershell.exe, so when it has run, Cockatrice is known to be - ; gone and the lookup failing to run costs nothing but the Oracle and - ; Servatrice checks. An older client that passes no /PID= leaves no such - ; authority, and there the upgrade is cancelled as before. - ${If} $LauncherExited != 1 - ${AndIf} $ReinstallMode = 1 - StrCpy $AbortReason \ - "The installation was cancelled because it could not check whether Cockatrice is still running, because powershell.exe could not be started.$\r$\n$\r$\nPlease close Cockatrice, Oracle and Servatrice and run the installer again." - Call AbortInstall - ${EndIf} - ; The user has to be told to close the applications by hand, but a /S - ; install without /R is unattended and would block on this forever. - ${If} $ReinstallMode = 1 - ${OrIfNot} ${Silent} - MessageBox MB_OK|MB_ICONEXCLAMATION "This installer could not check whether Cockatrice is still running, because powershell.exe could not be started.$\r$\n$\r$\nPlease close Cockatrice, Oracle and Servatrice manually before continuing." - ${EndIf} - Return - ${EndIf} - StrCpy $R1 "cockatrice.exe" - StrCpy $R2 "cockatrice" - ; a GUI application with unsaved user data: graceful close only - StrCpy $AllowForceClose 0 - Call WaitForAppToClose - StrCpy $R1 "oracle.exe" - StrCpy $R2 "oracle" - StrCpy $AllowForceClose 1 - Call WaitForAppToClose - StrCpy $R1 "servatrice.exe" - StrCpy $R2 "servatrice" - StrCpy $AllowForceClose 1 - Call WaitForAppToClose -FunctionEnd - ; Sets $R0 to 1 if $INSTDIR looks like an existing Cockatrice install. Used to ; guard the stale-runtime-DLL purge so a first-time install into a pre-existing ; directory with an unrelated "Plugins" folder is never touched, while an @@ -1171,142 +1706,351 @@ Function CheckStaleRuntimeMask ${EndIf} FunctionEnd -; Uninstaller copies of the same routines (the uninstaller gets its own -; function set compiled in, it cannot call the installer functions). -Function un.IsAppRunning - ; An unset $GuardDir would match every process that merely shares the image - ; name, see IsAppRunning in the installer. - ${If} $GuardDir == "" - StrCpy $R0 2 +; Clears the read-only attribute of everything the purge is about to delete. +; +; Delete and RMDir only set the error flag: a read-only file is left on disk +; untouched, and the retry loop then cannot make progress however long it waits, +; because the file is not held - it is protected. The abort diagnostics have a +; branch for exactly that ("read-only, which stops Delete"), so a survivor that +; is both free and read-only was a dead end until now. This runs on every +; attempt, so an attribute set again between attempts is cleared as well. +; SetFileAttributes has no wildcard support, hence the walks. +Function ClearReadOnlyPurgeTargets + FindFirst $StaleFindHandle $StaleFindName "$INSTDIR\*.dll" + ${If} $StaleFindHandle != "" + crp_dll_loop: + SetFileAttributes "$INSTDIR\$StaleFindName" NORMAL + ClearErrors + FindNext $StaleFindHandle $StaleFindName + IfErrors 0 crp_dll_loop + FindClose $StaleFindHandle + ${EndIf} + StrCpy $R6 "$INSTDIR\Plugins" + Call ClearReadOnlyTree +FunctionEnd + +; usage: set $R6 to a directory. Clears the read-only attribute of every entry +; below it, so RMDir /r is not stopped by a single file. Saves and restores the +; registers it works in, because it recurses into itself. +Function ClearReadOnlyTree + Push $R6 + Push $R7 + Push $R8 + FindFirst $R7 $R8 "$R6\*.*" + ${If} $R7 != "" + crt_loop: + ${If} $R8 != "." + ${AndIf} $R8 != ".." + SetFileAttributes "$R6\$R8" NORMAL + ; Only an entry with contents has anything below it that the purge + ; would fail on; an empty directory's own attribute is already + ; cleared above and there is nothing left to descend into. + ${If} ${FileExists} "$R6\$R8\*.*" + Push $R6 + StrCpy $R6 "$R6\$R8" + Call ClearReadOnlyTree + Pop $R6 + ${EndIf} + ${EndIf} + ClearErrors + FindNext $R7 $R8 + IfErrors 0 crt_loop + FindClose $R7 + ${EndIf} + Pop $R8 + Pop $R7 + Pop $R6 +FunctionEnd + +; Removes the Plugins directory and all top-level DLLs from $INSTDIR, then +; retries up to two more times (2 s apart) when files remain locked, so that +; transient holders such as antivirus scanners have a chance to release their +; handles before the installer gives up. Three attempts total. The read-only +; attribute is cleared before each attempt: a protected file is not a held one +; and would never clear on its own. +; Sets $R0 to 0 when the purge is clean, or to 1 when stale DLLs remain +; after all attempts. +Function RetryDllPurge + StrCpy $R5 0 ; counts retries after the initial attempt + rdp_loop: + Call ClearReadOnlyPurgeTargets + RMDir /r "$INSTDIR\Plugins" + Delete "$INSTDIR\*.dll" + Call HasStaleRuntimeFiles + ; $R0 = 0: all clear, nothing left to do. + ${If} $R0 = 0 Return ${EndIf} - ; Pass the directory to guard to PowerShell via an environment variable so - ; it never has to be embedded into the -Command string: apostrophes in a - ; path would terminate the command early, and the path in a -like pattern - ; would treat [ and ] as wildcard characters. - System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$GuardDir")' - ; Exit the process explicitly so nsExec's pop reflects whether a matching - ; process was found: powershell.exe exits 0 whether or not the pipeline - ; produced objects. 0 = not running, 1 = running, anything else = could not - ; be determined (see un.IsProcessLookupAvailable). - ; Strict match: a readable path under $GuardDir, same as in the installer. - nsExec::ExecToLog 'powershell -NoProfile -Command "if (Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { try { $$p = $$_.Path; if ($$p) { $$p.StartsWith($$env:COCKATRICE_GUARD_DIR, [StringComparison]::OrdinalIgnoreCase) } else { $$false } } catch { $$false } } | Select-Object -First 1) { exit 1 } else { exit 0 }"' - Pop $R0 - ${If} $R0 == "0" - StrCpy $R0 0 - ${ElseIf} $R0 == "1" - StrCpy $R0 1 + IntOp $R5 $R5 + 1 + ; Allow two retries after the initial attempt ($R5 is the retry counter, + ; so the loop runs at most 3 times: attempt + retry 1 + retry 2). + ${If} $R5 > 2 + !insertmacro CockLog "RetryDllPurge: stale files remain after 3 attempts, giving up" + Return ; $R0 is already 1 + ${EndIf} + !insertmacro CockLog "RetryDllPurge: retry $R5 of 2 after stale purge, sleeping 2 s" + Sleep 2000 + Goto rdp_loop +FunctionEnd ; RetryDllPurge +; Turns a Win32 error code into the wording that goes into the log. +; usage: set $0 to the code, result in $R0. +; +; These are Windows' own names for the codes and deliberately nothing more. A +; code on its own cannot say whether an upgrade was blocked by a running instance +; or by a permission: deleting a file whose image is mapped by another process +; surfaces as ACCESS_DENIED, exactly like a file whose ACL denies delete does. +; LogProcessesUsingInstallDir is what separates the two, which is why it runs +; right after this and why the two are read together. +Function GetWin32ErrorText + ${If} $0 = 2 + StrCpy $R0 "already gone" + ${ElseIf} $0 = 3 + StrCpy $R0 "path not found" + ${ElseIf} $0 = 5 + StrCpy $R0 "access denied" + ${ElseIf} $0 = 19 + StrCpy $R0 "write-protected media" + ${ElseIf} $0 = 32 + StrCpy $R0 "sharing violation" + ${ElseIf} $0 = 33 + StrCpy $R0 "lock violation" + ${ElseIf} $0 = 1224 + StrCpy $R0 "file is mapped in memory" ${Else} - ; powershell.exe could not be run at all (nsExec pushes the string - ; "error"), or died with an unexpected code. That is "could not - ; determine", which has to stay distinguishable from "definitely - ; running": a state that can never clear would block the uninstaller - ; forever. un.IsProcessLookupAvailable warns about that case once. - StrCpy $R0 2 + StrCpy $R0 "windows error" ${EndIf} FunctionEnd -Function un.IsProcessLookupAvailable - nsExec::ExecToLog 'powershell -NoProfile -Command "exit 0"' - Pop $R0 - ${If} $R0 == "0" - StrCpy $R0 1 - ${Else} - StrCpy $R0 0 +; Records which runtime files the purge left behind, what Windows said about each +; one, and which processes are using the install directory. +; +; Diagnostic only, and only on the abort path: it deletes nothing, so it can never +; turn a failed upgrade into a half-removed old install, and it decides nothing - +; the abort that called it is already decided. It exists because NSIS Delete/RMDir +; only set the error flag: without this, a report of "files of a previous version +; survived the purge" said which files were present but never which one was held, +; and never whether the cause was a lock or a permission. +; +; Each survivor is opened for DELETE access and then asked the question DeleteFile +; asks: SetFileInformationByHandle(FileDispositionInfo) marks it for deletion on +; close, and the mark is cleared again right away, so nothing is deleted (and if +; that cancel ever failed, the file would go when the handle closes - a file the +; purge already tried to delete, and the line says so). That separates the three +; causes a failed Delete cannot tell apart: another handle that does not share +; delete refuses the mark, a read-only attribute refuses it, and a file nobody +; holds accepts it and would have been deleted. The mark is read through ?e, so a +; refusal reports Windows' own code; clearing LastError first keeps a zero code +; meaning "accepted". The wording below reports what Windows said and never claims +; a cause the code cannot prove. Which process is holding a survivor, if any, is +; what the holders line after the loop answers. +Function LogStaleRuntimeDiagnostics + FindFirst $StaleFindHandle $StaleFindName "$INSTDIR\*.dll" + ${If} $StaleFindHandle != "" + stale_diag_loop: + ; ClearErrors tells a plugin failure apart from an API failure: the System + ; plugin sets the error flag only when it could not make the call at all, + ; in which case it pushes its own message instead of the call's value and + ; the pop below must not run. + ClearErrors + System::Call 'kernel32::SetLastError(i 0)' + System::Call 'kernel32::CreateFileW(w "$INSTDIR\$StaleFindName", i 0x00010000, i 0, p 0, i 3, i 0, p 0) p.R1 ?e' + ${If} ${Errors} + StrCpy $R0 "the delete probe could not be run" + ${Else} + Pop $R2 + ${If} $R2 = 0 + ; The open worked, which says nothing about Delete: a DLL a running + ; process has mapped opens for DELETE and still refuses DeleteFile, and + ; a read-only file does the same until its attribute is cleared. Ask + ; Windows the question the purge actually asks - mark the file for + ; deletion on close, then cancel that again. This is the same request + ; DeleteFile makes on a temporary handle, so its answer says whether a + ; delete would succeed now. + ClearErrors + System::Call '*(&i1 1) p.R4' + System::Call 'kernel32::SetFileInformationByHandle(p R1, i 4, p R4, i 1) i.R5 ?e' + ${If} ${Errors} + StrCpy $R0 "a delete-access open now succeeds, but Windows could not be asked whether it would delete it" + ${Else} + Pop $R6 + ${If} $R5 != 0 + ; Accepted: cancel the mark again, so this stays a question and + ; does not turn into the deletion it is asking about. A cancel + ; that fails leaves the file to go when the handle closes, which + ; is what the purge wanted anyway; the line says so. + ClearErrors + System::Call '*(&i1 0) p.R4' + System::Call 'kernel32::SetFileInformationByHandle(p R1, i 4, p R4, i 1) i.R5 ?e' + ${If} ${Errors} + StrCpy $R0 "a delete would succeed now, but the check could not undo the mark it set" + ${Else} + Pop $R6 + ${If} $R5 != 0 + StrCpy $R0 "a delete would succeed now - the earlier failure was transient" + ${Else} + StrCpy $R0 "a delete would succeed now, and it is marked for deletion from here on" + ${EndIf} + ${EndIf} + ${Else} + StrCpy $0 $R6 + Call GetWin32ErrorText + StrCpy $R0 "a delete-access open now succeeds, but Windows refused to mark it for deletion: win32 error $R6: $R0" + ${EndIf} + ${EndIf} + System::Call 'kernel32::CloseHandle(p R1)' + ; The read-only attribute is a cause on its own, and the purge clears it + ; before every attempt, so a survivor that is read-only had it set again. + System::Call 'kernel32::GetFileAttributesW(w "$INSTDIR\$StaleFindName") i.R3' + ${If} $R3 != -1 + IntOp $R3 $R3 & 1 + ${If} $R3 = 1 + StrCpy $R0 "$R0; it is read-only again" + ${EndIf} + ${EndIf} + ${Else} + StrCpy $0 $R2 + Call GetWin32ErrorText + StrCpy $R0 "win32 error $R2: $R0" + ${EndIf} + ${EndIf} + !insertmacro CockLog "stale-runtime: '$StaleFindName' is still present: $R0" + ClearErrors + FindNext $StaleFindHandle $StaleFindName + IfErrors 0 stale_diag_loop + FindClose $StaleFindHandle ${EndIf} + Call LogProcessesUsingInstallDir FunctionEnd -Function un.CloseMatchingApps - ; gracefully ask every matching instance to close (sends WM_CLOSE) - ; - ; Strict filter, same as in the installer: never close a process whose path - ; cannot be read, it is not necessarily one of ours. - System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$GuardDir")' - nsExec::ExecToLog 'powershell -NoProfile -Command "Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { try { $$p = $$_.Path; if ($$p) { $$p.StartsWith($$env:COCKATRICE_GUARD_DIR, [StringComparison]::OrdinalIgnoreCase) } else { $$false } } catch { $$false } } | ForEach-Object { $$null = $$_.CloseMainWindow() }"' - Pop $R3 +; Names the applications and services that still have the payload open or mapped. +; Writes one line to the log and sets $HolderFound (1 when anything was named), so the +; abort message can repeat that line to the user. +; +; The lookup is tools/installer-holder-probe, the same executable the guard runs before +; anything is replaced; here it answers the other question, about files a purge could not +; remove. It asks the Restart Manager (RmRegisterResources/RmGetList), which names a +; process holding a plain handle on a file - an antivirus scanner - and names services as +; well as user programs, and it adds every payload program running from the directory +; itself - not one in a folder below it, which is another application the user installed +; there - because a running instance holds its payload as mapped images rather than as open +; handles and the Restart Manager does not reliably report those. Its remaining blind spot +; is a handle taken in kernel mode, where there is no process to name; a payload executable +; among the files then still refuses to be replaced, and the probe answers with that file +; instead of naming nothing. +; +; $INSTDIR travels through the same COCKATRICE_GUARD_DIR environment variable the guard +; uses, so a path with spaces or apostrophes never has to be embedded in a command line. +Function LogProcessesUsingInstallDir + StrCpy $HolderExit 0 + StrCpy $HolderOutput "" + StrCpy $HolderFound 0 + ; The directory that just failed the purge, not whatever a check before the directory + ; page looked at: a portable install into a custom folder reaches here with the two + ; being different. + StrCpy $GuardDir $INSTDIR + Call RunHolderProbe + StrCpy $HolderExit $GuardExit + StrCpy $HolderOutput $GuardOutput + ; The exit code, not the text, decides what happened: it cannot be mangled by what + ; nsExec leaves on the end of the captured output. 1 = the line below names what is + ; holding the files, 0 = nothing holds them any more, anything else = the probe could + ; not answer, and says why in its own output. + ${If} $HolderExit == "1" + StrCpy $HolderFound 1 + ${ElseIf} $HolderExit == "0" + StrCpy $HolderOutput "nothing is holding them and no payload program is running from the directory" + ${Else} + StrCpy $HolderOutput "the holder probe could not name what holds them: $HolderOutput" + ${EndIf} + !insertmacro CockLog "holders: $HolderOutput" FunctionEnd -Function un.ForceCloseApps - ; stop the matching instances by PID once the grace period is over, see - ; ForceCloseApps in the installer. - System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$GuardDir")' - nsExec::ExecToLog 'powershell -NoProfile -Command "Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { try { $$p = $$_.Path; if ($$p) { $$p.StartsWith($$env:COCKATRICE_GUARD_DIR, [StringComparison]::OrdinalIgnoreCase) } else { $$false } } catch { $$false } } | ForEach-Object { Stop-Process -Id $$_.Id -Force -ErrorAction SilentlyContinue }"' - Pop $R3 -FunctionEnd - -Function un.WaitForAppToClose - Call un.IsAppRunning - ; Only a definite "running" is worth waiting for, see WaitForAppToClose. - ${If} $R0 != 1 +; Called after a purge left files behind and LogStaleRuntimeDiagnostics named who is +; holding them. Instead of aborting straight away - which on /R strands the user, since +; the old version is already uninstalled - it closes once, purges again, and only then +; asks: end what is still holding the files, or try the removal again by hand. Sets +; $StaleRuntimeAfterPurge to the last purge's verdict, so the caller still aborts when the +; files survive. +; +; The shape is the guard's, deliberately: one automatic attempt at a graceful close, then a +; prompt whose Yes ends the payload programs and whose No retries. A client that was still +; shutting down releases the files in the automatic round and never sees a question, and an +; Oracle or Servatrice - which a close cannot reach - is what the prompt exists for, so the +; user is never left with a dialog whose answers cannot free anything. +; +; The automatic close is a request (WM_CLOSE) and the client keeps the last word: it may be +; waiting for an answer to its own "really quit?" confirmation (a running card database +; update, an open game, an unsaved deck), and that stays its decision. The instance is +; brought forward first, so that confirmation is on screen rather than behind this +; installer's prompt. +; +; Ending is the same last resort the guard offers, with the same cost spelled out, and only +; ever the programs the payload installs - see EndHolders. +; +; Restart Manager's RmShutdown was the alternative and was rejected: it shuts down +; services as well as applications, and the only way to bring them back is RmRestart, +; which would restart this very application into files that are about to be replaced. +; +; Always offered, in every mode: a /S install without /R is the only case without someone +; at the keyboard, and this installer does not promise unattended runs - if an error needs +; a decision, it asks for one. No offer when the probe named nobody: there is nothing to +; end, and the abort's per-file diagnostics already record what Windows said. +; +; One question after the automatic close, and never a loop: Yes ends the programs the +; payload installs - and says so, because anything else the probe named is left to the user - +; and purges again; the other answer gives up, and the caller's abort names what survived. +; The listing is re-read before the question, so it says what is left now rather than +; repeating the first one. +Function RecoverFromHolders + ${If} $HolderFound != 1 + !insertmacro CockLog "recover-holders: no holder was named, nothing to offer" Return ${EndIf} - - ${If} ${Silent} - DetailPrint "Closing $R1 ..." - Call un.CloseMatchingApps - ; wall-clock deadline: each poll has to spawn a fresh powershell.exe, so - ; a bare loop counter would not bound the real wait time to 60 s. - System::Call 'kernel32::GetTickCount()i.s' - Pop $R8 - un_ck_wait_loop: - Sleep 500 - Call un.IsAppRunning - ${If} $R0 = 0 - DetailPrint "$R1 closed." - Return - ${EndIf} - ${If} $R0 != 1 - Return - ${EndIf} - System::Call 'kernel32::GetTickCount()i.s' - Pop $R9 - IntOp $R9 $R9 - $R8 - ${If} $R9 < 60000 - Goto un_ck_wait_loop - ${EndIf} - ; The grace period is over. Cockatrice is never force-stopped, see - ; WaitForAppToClose in the installer. A silent uninstall only reports - ; through the log and its exit code: it is also run unattended (the /R - ; updater uses it via QuietUninstallString, package managers use it - ; directly), and a message box would block there. The installer that ran - ; us reports the incomplete result to the user itself. - ${If} $AllowForceClose = 0 - DetailPrint "$R1 is still running after 60 s; aborting so that no data is lost." - SetErrorLevel 1 - Abort - ${EndIf} - DetailPrint "Force-stopping $R1 ..." - Call un.ForceCloseApps - Sleep 2000 - Call un.IsAppRunning - ${If} $R0 = 0 - DetailPrint "$R1 closed." - Return - ${EndIf} - ${If} $R0 != 1 - Return - ${EndIf} - DetailPrint "$R1 is still running after 60 s; aborting." - SetErrorLevel 1 - Abort - ${Else} - un_ck_wait_prompt: - MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ - "$R1 is still running.$\r$\n$\r$\nPlease close it, then click Retry.$\r$\nClick Cancel to abort." \ - IDCANCEL un_ck_abort_install - Call un.IsAppRunning - ${If} $R0 = 0 - Return - ${EndIf} - ${If} $R0 != 1 - Return - ${EndIf} - Goto un_ck_wait_prompt - un_ck_abort_install: - Abort + ; The automatic attempt, the same one the guard makes before it asks anything. + !insertmacro CockLog "recover-holders: closing cockatrice, then trying the removal again" + StrCpy $GuardDir $INSTDIR + Call CloseHolders + Call RetryDllPurge + StrCpy $StaleRuntimeAfterPurge $R0 + !insertmacro CockLogListing "after-retry-purge" "*.dll" + !insertmacro CockLogListing "after-retry-plugins" "Plugins\*.*" + !insertmacro CockLog "recover-holders: stale-runtime after retry=$StaleRuntimeAfterPurge" + ${If} $StaleRuntimeAfterPurge = 0 + DetailPrint "Files of the previous version were removed; continuing the installation." + Return ${EndIf} + ; Still stuck: refresh the names, so the prompt and the log describe what survived the + ; last attempt and not what was there before it. + Call LogStaleRuntimeDiagnostics + ${If} $HolderFound != 1 + Return + ${EndIf} + ; One question, the same one the guard asks and with the same two answers. No retry loop: + ; the purge has already been tried after the close, so the only thing that can still free + ; the files is ending them, and the caller's abort names whatever survives that. + !insertmacro CockEndPrompt "Files of the previous version of Cockatrice are still in use by:" $HolderOutput "installation" + MessageBox MB_YESNO|MB_ICONEXCLAMATION|MB_DEFBUTTON2 "$GuardPrompt" IDYES rfh_end + !insertmacro CockLog "recover-holders: cancelled by user" + Return + ; Only reached through Yes: Return does not come back here. + rfh_end: + !insertmacro CockLog "recover-holders: ending the payload programs on request, then trying the removal again" + Call EndHolders + Call RetryDllPurge + StrCpy $StaleRuntimeAfterPurge $R0 + !insertmacro CockLog "recover-holders: stale-runtime after end=$StaleRuntimeAfterPurge" + ${If} $StaleRuntimeAfterPurge = 0 + DetailPrint "Files of the previous version were removed; continuing the installation." + Return + ${EndIf} + ; Refresh the names one last time so the abort that follows describes what survived the + ; end, and then let it stop the run: the same dialog again would only repeat itself. + Call LogStaleRuntimeDiagnostics FunctionEnd +; Uninstaller copy of the guard (the uninstaller gets its own function set compiled in, it +; cannot call the installer functions). ExtractHolderProbe and RunHolderProbe come from +; CockHolderProbeFunctions above; only the policy differs. Function un.EnsureAppsNotRunning ; guards the install directory; the uninstaller only ever has to check this one StrCpy $GuardDir $INSTDIR @@ -1314,29 +2058,80 @@ Function un.EnsureAppsNotRunning FunctionEnd Function un.GuardAppsNotRunning - Call un.IsProcessLookupAvailable - ${If} $R0 = 0 - ; Same reasoning as in the installer: without PowerShell we cannot tell - ; whether a running instance still holds file locks, and refusing to - ; uninstall would leave the user with an uninstaller that never - ; completes. Warn once and continue. - DetailPrint "Could not start powershell.exe; skipping the running instance check. Close Cockatrice, Oracle and Servatrice manually if the uninstall reports a problem." - MessageBox MB_OK|MB_ICONEXCLAMATION "This uninstaller could not check whether Cockatrice is still running, because powershell.exe could not be started.$\r$\n$\r$\nPlease close Cockatrice, Oracle and Servatrice manually before continuing." + StrCpy $GuardCloseRounds 0 + StrCpy $GuardRecheckCount 0 + StrCpy $GuardLastHolders "" + un_guard_check: + !insertmacro CockLogUn "uninst-guard: checking $GuardDir" + Call un.RunHolderProbe + ${If} $GuardExit == "0" + !insertmacro CockLogUn "uninst-guard: nothing is holding the payload" Return ${EndIf} - StrCpy $R1 "cockatrice.exe" - StrCpy $R2 "cockatrice" - ; a GUI application with unsaved user data: graceful close only - StrCpy $AllowForceClose 0 - Call un.WaitForAppToClose - StrCpy $R1 "oracle.exe" - StrCpy $R2 "oracle" - StrCpy $AllowForceClose 1 - Call un.WaitForAppToClose - StrCpy $R1 "servatrice.exe" - StrCpy $R2 "servatrice" - StrCpy $AllowForceClose 1 - Call un.WaitForAppToClose + ${If} $GuardExit == "1" + !insertmacro CockLogUn "uninst-guard: still in use by $GuardOutput" + ; Same convenience as in the installer: ask every instance to close, repeat only + ; while the set changed, then report what the close could not reach. + ${If} $GuardCloseRounds < 2 + ${AndIf} $GuardOutput != $GuardLastHolders + StrCpy $GuardLastHolders $GuardOutput + IntOp $GuardCloseRounds $GuardCloseRounds + 1 + !insertmacro CockLogUn "uninst-guard: asking the payload programs to close (round $GuardCloseRounds)" + Call un.CloseHolders + Goto un_guard_check + ${EndIf} + DetailPrint "The install directory is in use by: $GuardOutput" + ; Same question and the same three answers as the installer's guard: force close, + ; close by hand and check again, or abort. CockEndRecheckPrompt words it, so the two + ; copies promise the same thing. + !insertmacro CockEndRecheckPrompt "Cockatrice, Oracle or Servatrice is still using files of the installation:" $GuardOutput "uninstallation" + MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON2 "$GuardPrompt" IDYES un_guard_end IDNO un_guard_recheck + !insertmacro CockLogUn "uninst-guard: cancelled by user after $GuardRecheckCount recheck(s)" + ; The installer's Cancel lands in AbortInstall, which explains what to do next. The + ; uninstaller has no such dialog, so say the same thing here: a bare Abort would + ; close the window and leave the user with no reason and no next step. + MessageBox MB_OK|MB_ICONEXCLAMATION \ + "The uninstallation was cancelled because Cockatrice, Oracle or Servatrice is still running and holding files of the installation.$\r$\n$\r$\n${AFTER_CLOSING_GUIDANCE}$\r$\n$\r$\nClose them and run the uninstaller again." + SetErrorLevel 1 + Abort + un_guard_recheck: + IntOp $GuardRecheckCount $GuardRecheckCount + 1 + !insertmacro CockLogUn "uninst-guard: user closed programs by hand, checking again (recheck $GuardRecheckCount)" + StrCpy $GuardCloseRounds 0 + StrCpy $GuardLastHolders "" + Goto un_guard_check + ; Only reached through Yes: Abort does not return. + un_guard_end: + !insertmacro CockLogUn "uninst-guard: ending the payload programs on request, then checking again" + Call un.EndHolders + ; One measurement and a stop, not another question: what survived a terminate is a + ; program this uninstaller may not open or does not own, and ending it again cannot + ; change that. + Call un.RunHolderProbe + ${If} $GuardExit == "0" + !insertmacro CockLogUn "uninst-guard: nothing is holding the payload" + Return + ${EndIf} + !insertmacro CockLogUn "uninst-guard: still in use after ending them ($GuardExit): $GuardOutput" + SetErrorLevel 1 + Abort + ${EndIf} + ; Same reasoning as in the installer: without an answer from the probe we cannot tell + ; whether a running instance still holds file locks, and refusing outright would leave + ; the user with an uninstaller that never completes. Same three answers too, so an + ; uninstall that hits a Restart Manager that was busy, or a payload program the user has + ; not closed yet, can simply be retried. + !insertmacro CockLogUn "uninst-guard: the holder probe could not answer ($GuardExit): $GuardOutput" + DetailPrint "Could not confirm whether Cockatrice is still running: $GuardOutput" + MessageBox MB_ABORTRETRYIGNORE|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ + "This uninstaller could not confirm whether Cockatrice, Oracle or Servatrice is still running.$\r$\n$\r$\nThe check reported: $GuardOutput$\r$\n$\r$\nClose them, then choose Retry to check again. Choose Ignore to uninstall anyway, or Abort to cancel the uninstallation." \ + IDRETRY un_guard_check IDABORT un_guard_unknown_abort + !insertmacro CockLogUn "uninst-guard: continuing without an answer from the probe" + Return + un_guard_unknown_abort: + !insertmacro CockLogUn "uninst-guard: cancelled after the probe could not answer" + SetErrorLevel 1 + Abort FunctionEnd Function PortableModePageCreate @@ -1392,7 +2187,10 @@ ${If} $PortableMode = 0 ${If} $ReinstallMode = 0 MessageBox MB_OKCANCEL|MB_ICONEXCLAMATION "A previous version of Cockatrice must be uninstalled before installing the new one." IDOK uninst32 - Abort + ; Abort would only skip this page and install over the old version anyway; + ; the message said the old one must go first, so Cancel means cancel. + !insertmacro CockLog "componentsPagePre: cancelled at the previous-version prompt" + Quit ${Else} Goto uninst32 ${EndIf} @@ -1409,10 +2207,24 @@ ${If} $PortableMode = 0 StrCpy $OldUninstallString $R0 Call EnsureOldInstallNotRunning StrCpy $OldInstallDir $GuardDir + ; Run it silently. The question above is the one the user answered; the + ; uninstaller's own confirmation page would be a second one, and that one opens + ; behind this window, where nobody can answer it. The installer then looks hung, + ; the user clicks Next on it, the new version is installed, and the uninstaller + ; removes everything once this window closes. /S leaves no window to be behind, + ; so ExecWait is waiting for something that cannot outlive it. + ReadRegStr $R1 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "QuietUninstallString" + ${If} $R1 == "" + StrCpy $OldUninstallString "$OldUninstallString /S" + ${Else} + StrCpy $OldUninstallString $R1 + ${EndIf} !insertmacro CockLog "componentsPagePre: old 32-bit uninstall '$OldUninstallString _?=$OldInstallDir' (oldDir=$OldInstallDir InstDir=$INSTDIR)" ClearErrors Call BackupUserDataBeforeUninstall - ExecWait "$OldUninstallString _?=$OldInstallDir" + Call RunOldUninstaller + !insertmacro CockLog "componentsPagePre: old 32-bit uninstall exit=$OldUninstallExit" + Call WaitForOldPayloadToDisappear Call RestoreUserDataAfterUninstall done32: @@ -1426,7 +2238,8 @@ ${If} $PortableMode = 0 ${If} $ReinstallMode = 0 MessageBox MB_OKCANCEL|MB_ICONEXCLAMATION "A previous version of Cockatrice must be uninstalled before installing the new one." IDOK uninst64 - Abort + !insertmacro CockLog "componentsPagePre: cancelled at the previous-version prompt" + Quit ${Else} Goto uninst64 ${EndIf} @@ -1435,10 +2248,19 @@ ${If} $PortableMode = 0 StrCpy $OldUninstallString $R0 Call EnsureOldInstallNotRunning StrCpy $OldInstallDir $GuardDir + ; Silent, for the reason given in the 32-bit branch above. + ReadRegStr $R1 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "QuietUninstallString" + ${If} $R1 == "" + StrCpy $OldUninstallString "$OldUninstallString /S" + ${Else} + StrCpy $OldUninstallString $R1 + ${EndIf} !insertmacro CockLog "componentsPagePre: old 64-bit uninstall '$OldUninstallString _?=$OldInstallDir' (oldDir=$OldInstallDir InstDir=$INSTDIR)" ClearErrors Call BackupUserDataBeforeUninstall - ExecWait "$OldUninstallString _?=$OldInstallDir" + Call RunOldUninstaller + !insertmacro CockLog "componentsPagePre: old 64-bit uninstall exit=$OldUninstallExit" + Call WaitForOldPayloadToDisappear Call RestoreUserDataAfterUninstall done64: @@ -1478,6 +2300,23 @@ ${If} $ReinstallMode = 0 Call EnsureAppsNotRunning ${EndIf} +; Measured before either purge block, because it has to describe the directory as +; it arrived here and not what a purge left of it: IsExistingCockatriceInstall +; looks for cockatrice.exe, oracle.exe, servatrice.exe, uninstall.exe, Qt6*.dll +; and qt.conf, and the portable purge below deletes exactly those. Taken after +; that purge it answered "not an install" as soon as the only survivor was a file +; it does not look for - a locked abseil_dll.dll or libssl-3-x64.dll, say - and +; the abort further down was then skipped for the very directory the purge had +; just failed in. +; Remembered because the stale-runtime check below must only ever fire for a +; directory this installer recognised as a Cockatrice install. Scanning for +; leftovers in an unrelated directory (a first-time install into a folder that +; happens to hold some Qt5 DLLs, say) would otherwise turn those files into an +; abort reason even though the payload is about to overwrite nothing of theirs. +Call IsExistingCockatriceInstall +!insertmacro CockLog "section: IsExistingCockatriceInstall=$R0" +StrCpy $IsUpgrade $R0 + ${If} $PortableMode = 1 ${AndIf} ${FileExists} "$INSTDIR\portable.dat" ; upgrade portable mode @@ -1501,7 +2340,12 @@ ${AndIf} ${FileExists} "$INSTDIR\portable.dat" ; what this has to guarantee is that nothing from the previous build is left ; to be loaded next to the new executables. Naming the families by hand is ; what let abseil_dll.dll through; see HasStaleRuntimeFiles. - Delete "$INSTDIR\*.dll" + Call RetryDllPurge + ; The purge's own verdict: HasStaleRuntimeFiles measures *.dll + Plugins\*.*, + ; the set that catches any leftover runtime DLL, Qt or otherwise. Held in a + ; variable because $R0 is reused by the next function call in this section. + StrCpy $StaleRuntimeAfterPurge $R0 + StrCpy $DllPurgeRan 1 Delete "$INSTDIR\qt.conf" Delete "$INSTDIR\qdebug.txt" Delete "$INSTDIR\servatrice.sql" @@ -1512,20 +2356,33 @@ ${EndIf} ; Remove any runtime DLLs left over from older versions, so a mismatched ; Qt/OpenSSL/protobuf set cannot survive next to the new executables. -Call IsExistingCockatriceInstall -!insertmacro CockLog "section: IsExistingCockatriceInstall=$R0" -; Remembered because the stale-runtime check below must only ever fire for a -; directory this installer recognised as a Cockatrice install. Scanning for -; leftovers in an unrelated directory (a first-time install into a folder that -; happens to hold some Qt5 DLLs, say) would otherwise turn those files into an -; abort reason even though the payload is about to overwrite nothing of theirs. -StrCpy $IsUpgrade $R0 +; +; A purge that fails is reported with evidence, not only as a survivor list: the +; abort below logs which files survived, what Windows said about each of them and +; which processes still have the install directory mapped (see +; LogStaleRuntimeDiagnostics). +; +; Re-running the running-instance check here was considered and dropped: it would only +; narrow the window the uninstaller ExecWait and the backup/restore copy open between +; .onInit and this point, and on /R it would now abort after the old version has already +; been removed - leaving no install at all. The surviving files are the authority on +; whether anything still holds the directory, and they are measured anyway. +; +; The holder list comes from Restart Manager (RmRegisterResources/RmGetList), the API +; that names every process and service with one of these files open or mapped. It is +; called from tools/installer-holder-probe rather than from this script, because the +; RM_PROCESS_INFO array is a table of hand-computed offsets when it is marshalled +; through the System plugin, and that table is the one thing here that cannot be +; checked without a Windows toolchain. Compiled by the same build that compiles this +; installer, the layout belongs to the Windows SDK, and .ci/compile.sh runs the probe +; on the Windows runners instead of trusting that it compiles. ${If} $PortableMode = 0 -${AndIf} $R0 = 1 +${AndIf} $IsUpgrade = 1 !insertmacro CockLog "section: non-portable upgrade purge" !insertmacro CockLogListing "before-purge" "*.dll" - RMDir /r "$INSTDIR\Plugins" - Delete "$INSTDIR\*.dll" + Call RetryDllPurge + StrCpy $StaleRuntimeAfterPurge $R0 + StrCpy $DllPurgeRan 1 !insertmacro CockLogListing "after-purge" "*.dll" !insertmacro CockLogListing "after-plugins" "Plugins\*.*" ${EndIf} @@ -1540,22 +2397,78 @@ ${EndIf} ; run on Windows or it contains an error", so stop here instead of compounding ; it. Nothing of the new payload has been written at this point, so the next run, ; with the applications closed, gets a clean install. +; $StaleRuntimeAfterPurge is RetryDllPurge's verdict, measured with +; HasStaleRuntimeFiles (*.dll + Plugins\*.*) - the set that catches any leftover +; runtime DLL, Qt or otherwise. It exists only when a purge ran, and the two +; purge blocks above cover every upgrade except one: a portable install into a +; directory without portable.dat is neither of them, so that directory is +; measured here instead. A test of $IsUpgrade alone could not stand in for it - +; that only says the directory was ours before the purge, not what the purge +; left behind. +; A directory this installer did not recognise as an install never aborts: the +; unrelated DLLs already in it (a first-time install into a folder that happens +; to hold some Qt5 DLLs, say) are not leftovers, and the payload overwrites +; nothing of theirs. ${If} $IsUpgrade = 1 - Call HasStaleRuntimeFiles - !insertmacro CockLog "section: HasStaleRuntimeFiles=$R0" + ${If} $DllPurgeRan = 0 + !insertmacro CockLog "section: no purge applies, measuring the directory instead" + Call HasStaleRuntimeFiles + StrCpy $StaleRuntimeAfterPurge $R0 + ${EndIf} + !insertmacro CockLog "section: stale-runtime after purge=$StaleRuntimeAfterPurge" ${Else} - StrCpy $R0 0 + StrCpy $StaleRuntimeAfterPurge 0 ${EndIf} -${If} $R0 = 1 +${If} $StaleRuntimeAfterPurge = 1 + !insertmacro CockLog "section: files of a previous version survived the purge" + ; Who is holding the payload is recorded first, so the log that reaches us + ; answers the question the message can only guess at, and so the prompt can + ; name them and offer to close the ones this installer owns. + Call LogStaleRuntimeDiagnostics + ${If} $HolderFound = 1 + ; On /R the previous version is already uninstalled at this point, so an abort + ; leaves the user with no working Cockatrice. It closes once, then offers to end + ; what is left; this updates $StaleRuntimeAfterPurge with the verdict after that + ; close, or after the end when the user asked for one. + Call RecoverFromHolders + ${Else} + ; Nothing is holding them. The three attempts in RetryDllPurge were two seconds + ; apart and all failed with no process to blame, which is the transient case a + ; virus scanner or a background indexer creates - or a permission problem that + ; will not clear. Give the delete one last chance before giving up: it costs a + ; second on a path that is about to abort anyway, and it keeps a lock that was + ; released in the meantime from turning into a failed upgrade. + !insertmacro CockLog "section: no holder named, one final purge attempt" + Sleep 1000 + Call RetryDllPurge + StrCpy $StaleRuntimeAfterPurge $R0 + !insertmacro CockLog "section: final-purge stale-runtime=$StaleRuntimeAfterPurge" + ${EndIf} +${EndIf} +${If} $StaleRuntimeAfterPurge = 1 !insertmacro CockLog "section: aborting, files of a previous version survived the purge" DetailPrint "Files of a previous version could not be removed; aborting the installation." - ; The in-app updater runs this installer silent and detached and never looks - ; at its exit code, so the user has to be told what happened. A /S install - ; without /R is unattended (a package manager, for instance) and a message - ; box there would block forever, so that case only reports through the log - ; and the exit code. AbortInstall owns that decision. + ; Say what is known and nothing more: the guard ran before anything was replaced, + ; so "most likely still running" is no longer a fair guess and would send the user + ; to close programs that are already closed. StrCpy $AbortReason \ - "The installation could not remove all files of a previous version of Cockatrice, most likely because it is still running or was started as administrator, and was cancelled rather than leave a mismatched install behind.$\r$\n$\r$\nPlease close Cockatrice, Oracle and Servatrice and run the installer again." + "The installation could not remove all files of a previous version of Cockatrice, and was cancelled rather than leave a mismatched installation behind." + ${If} $HolderFound = 1 + StrCpy $AbortReason "$AbortReason$\r$\n$\r$\nStill using the install directory:$\r$\n$HolderOutput$\r$\n$\r$\nClose them and run the installer again." + ${ElseIf} $HolderExit == "0" + StrCpy $AbortReason "$AbortReason$\r$\n$\r$\nNo running program was found using those files. A file permission or a virus scanner may be preventing their removal; the installer log records what Windows reported for each file." + ${Else} + ; The check itself failed, so "no running program was found" would be a claim this + ; installer cannot make. Name the failure instead, and leave the per-file codes to + ; the log, where they already are. + StrCpy $AbortReason "$AbortReason$\r$\n$\r$\nThe check for a running program could not be completed: $HolderOutput$\r$\n$\r$\nClose Cockatrice, Oracle and Servatrice and run the installer again; the installer log records what Windows reported for each file." + ${EndIf} + ; "Run the installer again" is not enough on an in-app update: the old version + ; was uninstalled before the purge, so point at the installer that was downloaded. + ${If} $ReinstallMode = 1 + ${AndIf} $PortableMode = 0 + StrCpy $AbortReason "$AbortReason$\r$\n$\r$\nThe previous version has already been removed. To finish the update, run this installer again:$\r$\n$EXEPATH" + ${EndIf} Call AbortInstall ${EndIf} diff --git a/cmake/SendInstallerLog.ps1.in b/cmake/SendInstallerLog.ps1.in index dfb8c3d63..585c726dd 100644 --- a/cmake/SendInstallerLog.ps1.in +++ b/cmake/SendInstallerLog.ps1.in @@ -57,6 +57,29 @@ try { } } + # --- What was holding the files, read from the log as well ------------------- + # The guard and the close attempt each log one line naming every holder and what + # happened to it. Carrying the last of each as event data keeps an abort caused by + # a running instance actionable without opening the attachment. Kept out of the + # message so the issue still groups on the reason alone. + $Holders = '' + $CloseResult = '' + $EndResult = '' + if (Test-Path -LiteralPath $LogPath) { + $holderLine = Select-String -LiteralPath $LogPath -Pattern 'guard: still in use by (.*)$' | Select-Object -Last 1 + if ($holderLine) { + $Holders = $holderLine.Matches[0].Groups[1].Value + } + $closeLine = Select-String -LiteralPath $LogPath -Pattern '(close-holders: .*)$' | Select-Object -Last 1 + if ($closeLine) { + $CloseResult = $closeLine.Matches[0].Groups[1].Value + } + $endLine = Select-String -LiteralPath $LogPath -Pattern '(end-holders: .*)$' | Select-Object -Last 1 + if ($endLine) { + $EndResult = $endLine.Matches[0].Groups[1].Value + } + } + # --- Event --------------------------------------------------------------- $eventId = [guid]::NewGuid().ToString('N') $now = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') @@ -75,6 +98,9 @@ try { extra = @{ reason = $Reason os = [System.Environment]::OSVersion.VersionString + holders = $Holders + close_result = $CloseResult + end_result = $EndResult } } $eventJson = $sentryEvent | ConvertTo-Json -Compress -Depth 6 diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index bec858dcb..ade7c3976 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -25,9 +25,7 @@ set(cockatrice_SOURCES src/client/settings/shortcut_treeview.cpp src/client/settings/shortcuts_settings.cpp src/interface/deck_loader/card_node_function.cpp - src/interface/deck_loader/deck_file_format.cpp src/interface/deck_loader/deck_loader.cpp - src/interface/deck_loader/loaded_deck.cpp src/interface/widgets/dialogs/dlg_connect.cpp src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.cpp src/interface/widgets/dialogs/dlg_create_game.cpp @@ -71,36 +69,27 @@ set(cockatrice_SOURCES src/filters/filter_builder.cpp src/filters/filter_tree_model.cpp src/filters/syntax_help.cpp - src/game/abstract_game.cpp - src/game/arrow_registry.cpp src/game_graphics/board/abstract_card_drag_item.cpp src/game_graphics/board/abstract_card_item.cpp src/game_graphics/board/abstract_counter.cpp - src/game/board/arrow_data.cpp src/game_graphics/board/arrow_item.cpp + src/game_graphics/board/arrow_registry.cpp src/game_graphics/board/arrow_target.cpp src/game_graphics/board/card_drag_item.cpp src/game_graphics/board/card_item.cpp - src/game/board/card_list.cpp - src/game/board/card_state.cpp src/game_graphics/board/counter_general.cpp - src/game/board/counter_state.cpp src/game_graphics/board/translate_counter_name.cpp src/game_graphics/deckview/deck_view.cpp src/game_graphics/deckview/deck_view_container.cpp src/game_graphics/deckview/tabbed_deck_view_container.cpp + src/game_graphics/dialogs/dlg_annotation.cpp src/game_graphics/dialogs/dlg_create_token.cpp src/game_graphics/dialogs/dlg_move_top_cards_until.cpp src/game_graphics/dialogs/dlg_roll_dice.cpp - src/game/game.cpp - src/game/game_event_handler.cpp - src/game/game_meta_info.cpp src/game_graphics/game_scene.cpp - src/game/game_state.cpp src/game_graphics/game_view.cpp src/game_graphics/hand_counter.cpp src/game_graphics/log/message_log_widget.cpp - src/game/phase.cpp src/game_graphics/phases_toolbar.cpp src/game_graphics/player/menu/card_menu.cpp src/game_graphics/player/menu/custom_zone_menu.cpp @@ -118,23 +107,11 @@ set(cockatrice_SOURCES src/game_graphics/tally/stats_tally.cpp src/game_graphics/tally/subtype_tally.cpp src/game_graphics/tally/tally.cpp - src/game/player/player_actions.cpp src/game_graphics/player/player_area.cpp src/game_graphics/player/player_dialogs.cpp - src/game/player/player_event_handler.cpp src/game_graphics/player/player_graphics_item.cpp - src/game/player/player_info.cpp src/game_graphics/player/player_list_widget.cpp - src/game/player/player_logic.cpp - src/game/player/player_manager.cpp src/game_graphics/player/player_target.cpp - src/game/replay.cpp - src/game/zones/card_zone_logic.cpp - src/game/zones/hand_zone_logic.cpp - src/game/zones/pile_zone_logic.cpp - src/game/zones/stack_zone_logic.cpp - src/game/zones/table_zone_logic.cpp - src/game/zones/view_zone_logic.cpp src/game_graphics/zones/card_zone.cpp src/game_graphics/zones/hand_zone.cpp src/game_graphics/zones/pile_zone.cpp @@ -172,6 +149,7 @@ set(cockatrice_SOURCES src/interface/widgets/utility/reversed_completer_model.cpp src/interface/pixel_map_generator.cpp src/interface/theme_config.cpp + src/interface/theme_icon.cpp src/interface/theme_manager.cpp src/interface/palette_editor/color_button.cpp src/interface/palette_editor/palette_generator.cpp @@ -194,6 +172,7 @@ set(cockatrice_SOURCES src/interface/widgets/cards/card_info_picture_widget.cpp src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.cpp src/interface/widgets/cards/card_info_text_widget.cpp + src/interface/widgets/cards/card_picture_delegate.cpp src/interface/widgets/cards/card_size_widget.cpp src/interface/widgets/cards/deck_card_zone_display_widget.cpp src/interface/widgets/cards/deck_preview_card_picture_widget.cpp @@ -313,6 +292,7 @@ set(cockatrice_SOURCES src/interface/widgets/utility/sequence_edit.cpp src/interface/widgets/utility/visibility_change_listener.cpp src/interface/widgets/utility/visibility_change_listener.h + src/interface/widgets/visual_database_display/card_database_visual_view.cpp src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.cpp src/interface/widgets/visual_database_display/visual_database_display_filter_save_load_widget.cpp src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp @@ -345,6 +325,7 @@ set(cockatrice_SOURCES src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.cpp src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp src/interface/window_main.cpp + src/installer_close_server.cpp src/main.cpp src/single_instance_manager.cpp src/interface/widgets/tabs/abstract_tab_deck_editor.cpp @@ -450,6 +431,7 @@ set(cockatrice_SOURCES src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.h src/interface/widgets/utility/compact_push_button.cpp src/interface/widgets/utility/compact_push_button.h + src/installer_close_server.h src/single_instance_manager.h src/client/url_scheme_event_filter.h src/interface/intents/intent_connect_to_server.cpp @@ -511,7 +493,7 @@ if(APPLE) set(cockatrice_SOURCES ${cockatrice_SOURCES} ${CMAKE_CURRENT_SOURCE_DIR}/resources/appicon.icns) endif(APPLE) -qt6_add_resources(cockatrice_RESOURCES_RCC ${cockatrice_RESOURCES}) +qt_add_resources(cockatrice_RESOURCES_RCC ${cockatrice_RESOURCES}) # Declare path variables set(ICONDIR @@ -532,7 +514,7 @@ set(COCKATRICE_MAC_QM_INSTALL_DIR "cockatrice.app/Contents/Resources/translation set(COCKATRICE_UNIX_QM_INSTALL_DIR "share/cockatrice/translations") set(COCKATRICE_WIN32_QM_INSTALL_DIR "translations") -qt6_add_executable( +qt_add_executable( cockatrice WIN32 MACOSX_BUNDLE @@ -544,7 +526,7 @@ qt6_add_executable( target_precompile_headers(cockatrice PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtwidgets_pch.h") -qt6_add_shaders( +qt_add_shaders( cockatrice "onboarding_shaders" PREFIX @@ -556,7 +538,7 @@ qt6_add_shaders( src/interface/widgets/onboarding/shaders/brand_plate.frag ) -qt6_add_resources( +qt_add_resources( cockatrice "onboarding_qml" PREFIX @@ -572,6 +554,7 @@ target_link_libraries( PUBLIC libcockatrice_card libcockatrice_deck_list libcockatrice_filters + libcockatrice_game libcockatrice_utility libcockatrice_network libcockatrice_models @@ -788,7 +771,7 @@ endif() if(Qt6LinguistTools_FOUND) #Qt6 Translations happen after the executable is built up if(UPDATE_TRANSLATIONS) - qt6_add_translations( + qt_add_translations( cockatrice TS_FILES ${cockatrice_TS} @@ -798,7 +781,7 @@ if(Qt6LinguistTools_FOUND) cockatrice_QM ) else() - qt6_add_translations(cockatrice TS_FILES ${cockatrice_TS} QM_FILES_OUTPUT_VARIABLE cockatrice_QM) + qt_add_translations(cockatrice TS_FILES ${cockatrice_TS} QM_FILES_OUTPUT_VARIABLE cockatrice_QM) endif() if(UNIX) @@ -824,4 +807,4 @@ if(Qt6LinguistTools_FOUND) endforeach() endif() -qt6_finalize_target(cockatrice) +qt_finalize_target(cockatrice) diff --git a/cockatrice/cockatrice.qrc b/cockatrice/cockatrice.qrc index 4ec3d2385..229cdd125 100644 --- a/cockatrice/cockatrice.qrc +++ b/cockatrice/cockatrice.qrc @@ -6,19 +6,18 @@ resources/hand.svg resources/hr.jpg - resources/icons/arrow_bottom_green.svg - resources/icons/arrow_down_green.svg + resources/icons/arrow_down.svg resources/icons/arrow_history.svg - resources/icons/arrow_left_green.svg + resources/icons/arrow_left.svg resources/icons/arrow_redo.svg - resources/icons/arrow_right_blue.svg - resources/icons/arrow_right_green.svg - resources/icons/arrow_top_green.svg - resources/icons/arrow_up_green.svg + resources/icons/arrow_right.svg + resources/icons/arrow_right_sideboard.svg + resources/icons/arrow_to_bottom.svg + resources/icons/arrow_to_top.svg + resources/icons/arrow_up.svg resources/icons/arrow_undo.svg resources/icons/circle_half_stroke.svg resources/icons/clearsearch.svg - resources/icons/cogwheel.svg resources/icons/conceded.svg resources/icons/decrement.svg resources/icons/delete.svg @@ -28,7 +27,6 @@ resources/icons/filter.svg resources/icons/floppy_disk.svg resources/icons/forgot_password.svg - resources/icons/gear.svg resources/icons/increment.svg resources/icons/info.svg resources/icons/lock.svg @@ -41,7 +39,6 @@ resources/icons/reload.svg resources/icons/remove_row.svg resources/icons/rename.svg - resources/icons/scale_balanced.svg resources/icons/scales.svg resources/icons/scroll.svg resources/icons/search.svg @@ -52,7 +49,6 @@ resources/icons/swap.svg resources/icons/sync.svg resources/icons/tab_changed.svg - resources/icons/update.png resources/icons/view.svg resources/icons/mana/B.svg diff --git a/cockatrice/resources/icons/arrow_bottom_green.svg b/cockatrice/resources/icons/arrow_bottom_green.svg deleted file mode 100644 index 3a45053ad..000000000 --- a/cockatrice/resources/icons/arrow_bottom_green.svg +++ /dev/null @@ -1,793 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - diff --git a/cockatrice/resources/icons/arrow_down.svg b/cockatrice/resources/icons/arrow_down.svg new file mode 100644 index 000000000..0ff237f52 --- /dev/null +++ b/cockatrice/resources/icons/arrow_down.svg @@ -0,0 +1,4 @@ + + + + diff --git a/cockatrice/resources/icons/arrow_down_green.svg b/cockatrice/resources/icons/arrow_down_green.svg deleted file mode 100644 index 574c12060..000000000 --- a/cockatrice/resources/icons/arrow_down_green.svg +++ /dev/null @@ -1,793 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - diff --git a/cockatrice/resources/icons/arrow_history.svg b/cockatrice/resources/icons/arrow_history.svg index d130758e6..29374d23a 100644 --- a/cockatrice/resources/icons/arrow_history.svg +++ b/cockatrice/resources/icons/arrow_history.svg @@ -1,8 +1,5 @@ - - - - history - - - - \ No newline at end of file + + + + + diff --git a/cockatrice/resources/icons/arrow_left.svg b/cockatrice/resources/icons/arrow_left.svg new file mode 100644 index 000000000..7a88deddb --- /dev/null +++ b/cockatrice/resources/icons/arrow_left.svg @@ -0,0 +1,4 @@ + + + + diff --git a/cockatrice/resources/icons/arrow_left_green.svg b/cockatrice/resources/icons/arrow_left_green.svg deleted file mode 100644 index 1b9eb0684..000000000 --- a/cockatrice/resources/icons/arrow_left_green.svg +++ /dev/null @@ -1,791 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - diff --git a/cockatrice/resources/icons/arrow_redo.svg b/cockatrice/resources/icons/arrow_redo.svg index dd89e3314..141260269 100644 --- a/cockatrice/resources/icons/arrow_redo.svg +++ b/cockatrice/resources/icons/arrow_redo.svg @@ -1,40 +1,4 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + diff --git a/cockatrice/resources/icons/arrow_right.svg b/cockatrice/resources/icons/arrow_right.svg new file mode 100644 index 000000000..f0ac5d5bf --- /dev/null +++ b/cockatrice/resources/icons/arrow_right.svg @@ -0,0 +1,4 @@ + + + + diff --git a/cockatrice/resources/icons/arrow_right_blue.svg b/cockatrice/resources/icons/arrow_right_blue.svg deleted file mode 100644 index 334cd68fd..000000000 --- a/cockatrice/resources/icons/arrow_right_blue.svg +++ /dev/null @@ -1,789 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - diff --git a/cockatrice/resources/icons/arrow_right_green.svg b/cockatrice/resources/icons/arrow_right_green.svg deleted file mode 100644 index db491985d..000000000 --- a/cockatrice/resources/icons/arrow_right_green.svg +++ /dev/null @@ -1,789 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - diff --git a/cockatrice/resources/icons/arrow_right_sideboard.svg b/cockatrice/resources/icons/arrow_right_sideboard.svg new file mode 100644 index 000000000..79a8cd5d3 --- /dev/null +++ b/cockatrice/resources/icons/arrow_right_sideboard.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/cockatrice/resources/icons/arrow_to_bottom.svg b/cockatrice/resources/icons/arrow_to_bottom.svg new file mode 100644 index 000000000..291a527d2 --- /dev/null +++ b/cockatrice/resources/icons/arrow_to_bottom.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/cockatrice/resources/icons/arrow_to_top.svg b/cockatrice/resources/icons/arrow_to_top.svg new file mode 100644 index 000000000..aac38cc33 --- /dev/null +++ b/cockatrice/resources/icons/arrow_to_top.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/cockatrice/resources/icons/arrow_top_green.svg b/cockatrice/resources/icons/arrow_top_green.svg deleted file mode 100644 index c515c6a1d..000000000 --- a/cockatrice/resources/icons/arrow_top_green.svg +++ /dev/null @@ -1,793 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - diff --git a/cockatrice/resources/icons/arrow_undo.svg b/cockatrice/resources/icons/arrow_undo.svg index 2e182ffb2..c748b37b8 100644 --- a/cockatrice/resources/icons/arrow_undo.svg +++ b/cockatrice/resources/icons/arrow_undo.svg @@ -1,40 +1,4 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + diff --git a/cockatrice/resources/icons/arrow_up.svg b/cockatrice/resources/icons/arrow_up.svg new file mode 100644 index 000000000..ac32b5c92 --- /dev/null +++ b/cockatrice/resources/icons/arrow_up.svg @@ -0,0 +1,4 @@ + + + + diff --git a/cockatrice/resources/icons/arrow_up_green.svg b/cockatrice/resources/icons/arrow_up_green.svg deleted file mode 100644 index 1d0f61112..000000000 --- a/cockatrice/resources/icons/arrow_up_green.svg +++ /dev/null @@ -1,792 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - diff --git a/cockatrice/resources/icons/circle_half_stroke.svg b/cockatrice/resources/icons/circle_half_stroke.svg index 42e4dbca2..c16f84858 100644 --- a/cockatrice/resources/icons/circle_half_stroke.svg +++ b/cockatrice/resources/icons/circle_half_stroke.svg @@ -1 +1,4 @@ - \ No newline at end of file + + + + diff --git a/cockatrice/resources/icons/clearsearch.svg b/cockatrice/resources/icons/clearsearch.svg index be50d68d6..49d883c68 100644 --- a/cockatrice/resources/icons/clearsearch.svg +++ b/cockatrice/resources/icons/clearsearch.svg @@ -1,276 +1,4 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - + + + diff --git a/cockatrice/resources/icons/cogwheel.svg b/cockatrice/resources/icons/cogwheel.svg deleted file mode 100644 index 8c7aad617..000000000 --- a/cockatrice/resources/icons/cogwheel.svg +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/cockatrice/resources/icons/conceded.svg b/cockatrice/resources/icons/conceded.svg index a487f18bb..b9faf047c 100644 --- a/cockatrice/resources/icons/conceded.svg +++ b/cockatrice/resources/icons/conceded.svg @@ -1,103 +1,4 @@ - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - - + + + diff --git a/cockatrice/resources/icons/decrement.svg b/cockatrice/resources/icons/decrement.svg index 0ceddf69f..033245f85 100644 --- a/cockatrice/resources/icons/decrement.svg +++ b/cockatrice/resources/icons/decrement.svg @@ -1,205 +1,3 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - + + diff --git a/cockatrice/resources/icons/delete.svg b/cockatrice/resources/icons/delete.svg index 008d8d429..d79312f1e 100644 --- a/cockatrice/resources/icons/delete.svg +++ b/cockatrice/resources/icons/delete.svg @@ -1,357 +1,5 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - + + + + diff --git a/cockatrice/resources/icons/dragon.svg b/cockatrice/resources/icons/dragon.svg index f45af39ef..cc48d1f5c 100644 --- a/cockatrice/resources/icons/dragon.svg +++ b/cockatrice/resources/icons/dragon.svg @@ -1 +1,6 @@ - \ No newline at end of file + + + + + + diff --git a/cockatrice/resources/icons/dropdown_collapsed.svg b/cockatrice/resources/icons/dropdown_collapsed.svg index 52e833e65..429c24974 100644 --- a/cockatrice/resources/icons/dropdown_collapsed.svg +++ b/cockatrice/resources/icons/dropdown_collapsed.svg @@ -1,10 +1,3 @@ - - - - - ]> - - + + diff --git a/cockatrice/resources/icons/dropdown_expanded.svg b/cockatrice/resources/icons/dropdown_expanded.svg index fa3ee205c..0b24aeb48 100644 --- a/cockatrice/resources/icons/dropdown_expanded.svg +++ b/cockatrice/resources/icons/dropdown_expanded.svg @@ -1,10 +1,3 @@ - - - - - ]> - - + + diff --git a/cockatrice/resources/icons/filter.svg b/cockatrice/resources/icons/filter.svg index d53d9ba29..d9cdf879c 100644 --- a/cockatrice/resources/icons/filter.svg +++ b/cockatrice/resources/icons/filter.svg @@ -1,12 +1,3 @@ - - - - - - - - \ No newline at end of file + + + diff --git a/cockatrice/resources/icons/floppy_disk.svg b/cockatrice/resources/icons/floppy_disk.svg index 3c5b5054d..10b9e8172 100644 --- a/cockatrice/resources/icons/floppy_disk.svg +++ b/cockatrice/resources/icons/floppy_disk.svg @@ -1 +1,5 @@ - \ No newline at end of file + + + + + diff --git a/cockatrice/resources/icons/forgot_password.svg b/cockatrice/resources/icons/forgot_password.svg index f938ca3f4..3bfb3bdb1 100644 --- a/cockatrice/resources/icons/forgot_password.svg +++ b/cockatrice/resources/icons/forgot_password.svg @@ -1,6 +1,6 @@ - - - - - + + + + + diff --git a/cockatrice/resources/icons/gear.svg b/cockatrice/resources/icons/gear.svg deleted file mode 100644 index fdee3a297..000000000 --- a/cockatrice/resources/icons/gear.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/cockatrice/resources/icons/increment.svg b/cockatrice/resources/icons/increment.svg index 6d5f7a85a..52dbdb2aa 100644 --- a/cockatrice/resources/icons/increment.svg +++ b/cockatrice/resources/icons/increment.svg @@ -1,199 +1,4 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - + + + diff --git a/cockatrice/resources/icons/info.svg b/cockatrice/resources/icons/info.svg index 00b23cc2c..1305f6b3d 100644 --- a/cockatrice/resources/icons/info.svg +++ b/cockatrice/resources/icons/info.svg @@ -1,43 +1,5 @@ - - - - - - - - - - + + + + diff --git a/cockatrice/resources/icons/lock.svg b/cockatrice/resources/icons/lock.svg index b7ebf1a53..f150ece49 100644 --- a/cockatrice/resources/icons/lock.svg +++ b/cockatrice/resources/icons/lock.svg @@ -1,750 +1,4 @@ - - - - - - - - - padlock - - - - icon - - lock - security - secure - padlock - signs_and_symbols - - - - - AJ Ashton - - - - - AJ Ashton - - - - - AJ Ashton - - - - image/svg+xml - - - en - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + diff --git a/cockatrice/resources/icons/not_ready_start.svg b/cockatrice/resources/icons/not_ready_start.svg index 73afda9db..c20a5cfe6 100644 --- a/cockatrice/resources/icons/not_ready_start.svg +++ b/cockatrice/resources/icons/not_ready_start.svg @@ -1,368 +1,4 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - + + + diff --git a/cockatrice/resources/icons/pen_to_square.svg b/cockatrice/resources/icons/pen_to_square.svg index 60ff1d9c4..7d3e161ac 100644 --- a/cockatrice/resources/icons/pen_to_square.svg +++ b/cockatrice/resources/icons/pen_to_square.svg @@ -1 +1,4 @@ - \ No newline at end of file + + + + diff --git a/cockatrice/resources/icons/pencil.svg b/cockatrice/resources/icons/pencil.svg index c8b81f698..d274e98db 100644 --- a/cockatrice/resources/icons/pencil.svg +++ b/cockatrice/resources/icons/pencil.svg @@ -1,123 +1,4 @@ - - - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - - - + + + diff --git a/cockatrice/resources/icons/pin.svg b/cockatrice/resources/icons/pin.svg index 71d4c9c9c..9c173286b 100644 --- a/cockatrice/resources/icons/pin.svg +++ b/cockatrice/resources/icons/pin.svg @@ -1,24 +1,4 @@ - - - - - + + + - diff --git a/cockatrice/resources/icons/player.svg b/cockatrice/resources/icons/player.svg index e83b24654..9fbe39a11 100644 --- a/cockatrice/resources/icons/player.svg +++ b/cockatrice/resources/icons/player.svg @@ -1,142 +1,8 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - + + + + + + + diff --git a/cockatrice/resources/icons/ready_start.svg b/cockatrice/resources/icons/ready_start.svg index 238d2aa46..4c4ea5d2d 100644 --- a/cockatrice/resources/icons/ready_start.svg +++ b/cockatrice/resources/icons/ready_start.svg @@ -1,81 +1,3 @@ - - - - - - - - - - - - image/svg+xml - - - - - - - - - + + diff --git a/cockatrice/resources/icons/reload.svg b/cockatrice/resources/icons/reload.svg index 102995876..e09b1b7ef 100644 --- a/cockatrice/resources/icons/reload.svg +++ b/cockatrice/resources/icons/reload.svg @@ -1,7 +1,4 @@ - - - - Svg Vector Icons : http://www.onlinewebfonts.com/icon - - \ No newline at end of file + + + + diff --git a/cockatrice/resources/icons/remove_row.svg b/cockatrice/resources/icons/remove_row.svg index 6f133cdb6..5d00ed57c 100644 --- a/cockatrice/resources/icons/remove_row.svg +++ b/cockatrice/resources/icons/remove_row.svg @@ -1,356 +1,4 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - + + + diff --git a/cockatrice/resources/icons/rename.svg b/cockatrice/resources/icons/rename.svg index c68a00246..bab850808 100644 --- a/cockatrice/resources/icons/rename.svg +++ b/cockatrice/resources/icons/rename.svg @@ -1,10 +1,7 @@ - - - - - - - + + + + + + diff --git a/cockatrice/resources/icons/scale_balanced.svg b/cockatrice/resources/icons/scale_balanced.svg deleted file mode 100644 index fae468d38..000000000 --- a/cockatrice/resources/icons/scale_balanced.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/cockatrice/resources/icons/scales.svg b/cockatrice/resources/icons/scales.svg index a410921dd..4b2e16fcc 100644 --- a/cockatrice/resources/icons/scales.svg +++ b/cockatrice/resources/icons/scales.svg @@ -1,6 +1,7 @@ - - - - - + + + + + + diff --git a/cockatrice/resources/icons/scroll.svg b/cockatrice/resources/icons/scroll.svg index f4828e679..edcf29d11 100644 --- a/cockatrice/resources/icons/scroll.svg +++ b/cockatrice/resources/icons/scroll.svg @@ -1 +1,6 @@ - \ No newline at end of file + + + + + + diff --git a/cockatrice/resources/icons/search.svg b/cockatrice/resources/icons/search.svg index 05439a12e..be4a87f00 100644 --- a/cockatrice/resources/icons/search.svg +++ b/cockatrice/resources/icons/search.svg @@ -1,198 +1,4 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - - - \ No newline at end of file + + + + diff --git a/cockatrice/resources/icons/settings.svg b/cockatrice/resources/icons/settings.svg index 77a2ccfa9..3942a0916 100644 --- a/cockatrice/resources/icons/settings.svg +++ b/cockatrice/resources/icons/settings.svg @@ -1,280 +1,4 @@ - - - - - - - - Ruota dentata grigia - - - - unsorted - - - - - Open Clip Art Library - - - - - Architetto Francesco Rollandin - - - - - Architetto Francesco Rollandin - - - - image/svg+xml - - - en - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + diff --git a/cockatrice/resources/icons/share.svg b/cockatrice/resources/icons/share.svg index a84f55847..9d6ff21a0 100644 --- a/cockatrice/resources/icons/share.svg +++ b/cockatrice/resources/icons/share.svg @@ -1,25 +1,7 @@ - - - - - - - - - + + + + + + diff --git a/cockatrice/resources/icons/sort_arrow_down.svg b/cockatrice/resources/icons/sort_arrow_down.svg index adf10f9f0..e10b401f2 100644 --- a/cockatrice/resources/icons/sort_arrow_down.svg +++ b/cockatrice/resources/icons/sort_arrow_down.svg @@ -1 +1,7 @@ - \ No newline at end of file + + + + + + + diff --git a/cockatrice/resources/icons/spectator.svg b/cockatrice/resources/icons/spectator.svg index c580c448d..19242f058 100644 --- a/cockatrice/resources/icons/spectator.svg +++ b/cockatrice/resources/icons/spectator.svg @@ -1,106 +1,4 @@ - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - - + + + diff --git a/cockatrice/resources/icons/swap.svg b/cockatrice/resources/icons/swap.svg index a18ca2f06..4eddb8c08 100644 --- a/cockatrice/resources/icons/swap.svg +++ b/cockatrice/resources/icons/swap.svg @@ -1,12 +1,6 @@ - - - - - - - - \ No newline at end of file + + + + + + diff --git a/cockatrice/resources/icons/sync.svg b/cockatrice/resources/icons/sync.svg index e287b1993..fa00b3024 100644 --- a/cockatrice/resources/icons/sync.svg +++ b/cockatrice/resources/icons/sync.svg @@ -1,6 +1,6 @@ - - - - - + + + + + diff --git a/cockatrice/resources/icons/tab_changed.svg b/cockatrice/resources/icons/tab_changed.svg index ae09ef8fd..1d9b8d09c 100644 --- a/cockatrice/resources/icons/tab_changed.svg +++ b/cockatrice/resources/icons/tab_changed.svg @@ -1,116 +1,4 @@ - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - - - + + + diff --git a/cockatrice/resources/icons/update.png b/cockatrice/resources/icons/update.png deleted file mode 100644 index 825c0aee7..000000000 Binary files a/cockatrice/resources/icons/update.png and /dev/null differ diff --git a/cockatrice/resources/icons/view.svg b/cockatrice/resources/icons/view.svg index e5676d825..19242f058 100644 --- a/cockatrice/resources/icons/view.svg +++ b/cockatrice/resources/icons/view.svg @@ -1,29 +1,4 @@ - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - \ No newline at end of file + + + + diff --git a/cockatrice/src/client/latency_status_widget.cpp b/cockatrice/src/client/latency_status_widget.cpp index 940074581..907a50668 100644 --- a/cockatrice/src/client/latency_status_widget.cpp +++ b/cockatrice/src/client/latency_status_widget.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include LatencyStatusWidget::LatencyStatusWidget(QWidget *parent) : QWidget(parent) @@ -24,9 +25,7 @@ LatencyStatusWidget::LatencyStatusWidget(QWidget *parent) : QWidget(parent) latencyGraph = new LatencyGraphWidget(this); latencyGraph->setFixedSize(90, 14); - auto *layout = new QHBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); - layout->setSpacing(4); + auto *layout = QtUtils::makeLayout(this, 4); layout->addWidget(latencyGraph); layout->addWidget(pingLabel); diff --git a/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp b/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp index 037565c6a..c702f91f1 100644 --- a/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp +++ b/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp @@ -3,6 +3,7 @@ #include "../../../interface/pixel_map_generator.h" #include "../../settings/cache_settings.h" #include "../interface/widgets/dialogs/dlg_connect.h" +#include "../interface/widgets/dialogs/dlg_edit_password.h" #include "../interface/widgets/dialogs/dlg_forgot_password_challenge.h" #include "../interface/widgets/dialogs/dlg_forgot_password_request.h" #include "../interface/widgets/dialogs/dlg_forgot_password_reset.h" @@ -24,6 +25,7 @@ #include #include #include +#include #include class QWidget; @@ -98,6 +100,10 @@ void ConnectionController::wireClientSignals() connect(remoteClient, &RemoteClient::sigPasswordVerifierReady, this, &ConnectionController::onPasswordVerifierReady); + + connect(remoteClient, &RemoteClient::sigPasswordChangeSuccess, this, + &ConnectionController::onPasswordChangeSuccess); + connect(remoteClient, &RemoteClient::sigPasswordChangeError, this, &ConnectionController::onPasswordChangeError); } void ConnectionController::connectToServer() @@ -243,17 +249,27 @@ void ConnectionController::onLoginError(int r, { switch (static_cast(r)) { case Response::RespClientUpdateRequired: { - QString formatted = "Missing Features: "; - for (int i = 0; i < missingFeatures.size(); ++i) { - formatted.append(QString("\n %1").arg(QChar(0x2022)) + " " + missingFeatures.value(i)); - } - QMessageBox msgBox(dialogParent); msgBox.setIcon(QMessageBox::Critical); msgBox.setWindowTitle(tr("Failed Login")); - msgBox.setText(tr("Your client seems to be missing features this server requires for connection.") + - "\n\n" + tr("To update your client, go to 'Help -> Check for Client Updates'.")); - msgBox.setDetailedText(formatted); + + if (missingFeatures.isEmpty()) { + // Without a feature list this is either a login the client stopped itself or a + // strict server asking for a password; the reason string already says which and + // what to do. No client update can fix any of those causes, so show it instead of + // the update advice. + msgBox.setText(reasonStr.isEmpty() ? tr("The login did not complete.") : reasonStr); + } else { + QString formatted = tr("Missing Features:"); + for (const QString &feature : missingFeatures) { + formatted.append(QString("\n %1 ").arg(QChar(0x2022)) + feature); + } + + msgBox.setText(tr("Your client seems to be missing features this server requires for connection.") + + "\n\n" + tr("To update your client, go to 'Help -> Check for Client Updates'.")); + msgBox.setDetailedText(formatted); + } + msgBox.exec(); break; } @@ -300,9 +316,8 @@ void ConnectionController::onLoginError(int r, } case Response::RespRegistrationRequired: { - if (QMessageBox::question(dialogParent, tr("Error"), - tr("This server requires user registration. Do you want to register now?"), - QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) { + if (QtUtils::confirm(dialogParent, tr("Error"), + tr("This server requires user registration. Do you want to register now?"))) { registerToServer(); } return; // don't re-prompt connect @@ -341,10 +356,16 @@ void ConnectionController::onLoginError(int r, } case Response::RespPasswordChangeRequired: { - QMessageBox::information( - dialogParent, tr("Password Change Required"), - tr("An administrator has reset your password. Please contact your server administrator to obtain " - "your temporary password, then log in and change it via Account -> Change Password.")); + // The credentials were right, but the account may not be used until a new password + // is chosen. Let the user complete the change here: sending them off to "ask the + // administrator for your temporary password" sends them in circles, since they just + // logged in with it and the session ends without a way to comply. + DlgEditPassword dlg(dialogParent); + dlg.setWindowTitle(tr("Password Change Required")); + if (dlg.exec()) { + remoteClient->changePasswordToServer(dlg.getOldPassword(), dlg.getNewPassword()); + return; // onPasswordChangeSuccess/onPasswordChangeError decides the next step + } remoteClient->disconnectFromServer(); return; } @@ -507,6 +528,43 @@ void ConnectionController::onNotifyUserAboutUpdate() "To update your client, go to Help -> Check for Updates.")); } +void ConnectionController::onPasswordChangeSuccess() +{ + // The credential saved for this profile is stale now: drop it so the next login asks for + // the new password instead of replaying the old verifier. + if (!pendingSaveName.isEmpty()) { + SettingsCache::instance().servers().setServerPassword(pendingSaveName, ""); + } + + QMessageBox::information(dialogParent, tr("Password Changed"), + tr("Your password has been changed. Please log in again with your new password.")); + remoteClient->disconnectFromServer(); + connectToServer(); +} + +void ConnectionController::onPasswordChangeError(int responseCode) +{ + QString reason; + switch (static_cast(responseCode)) { + case Response::RespWrongPassword: + reason = tr("The current password is incorrect."); + break; + case Response::RespPasswordTooShort: + reason = tr("The new password is too short."); + break; + case Response::RespClientUpdateRequired: + reason = tr("The server does not accept the new password format."); + break; + default: + reason = tr("The password could not be changed (error %1).").arg(responseCode); + break; + } + + QMessageBox::critical(dialogParent, tr("Password Change Failed"), reason); + remoteClient->disconnectFromServer(); + connectToServer(); +} + void ConnectionController::onForgotPasswordSuccess() { QMessageBox::information( @@ -517,14 +575,38 @@ void ConnectionController::onForgotPasswordSuccess() SettingsCache::instance().servers().setFPPlayerName(""); } -void ConnectionController::onForgotPasswordError() +void ConnectionController::onForgotPasswordError(int responseCode) { - QMessageBox::warning( - dialogParent, tr("Reset Password"), - tr("Failed to reset user account password, please contact the server operator to reset your password.")); - SettingsCache::instance().servers().setFPHostName(""); - SettingsCache::instance().servers().setFPPort(""); - SettingsCache::instance().servers().setFPPlayerName(""); + // Rate limiting is retryable, so it must not clear the prefill. + bool keepPrefill = false; + QString message; + switch (static_cast(responseCode)) { + case Response::RespTooManyRequests: + // Retryable: keep the host/port/user prefill so the user can try again. The window + // is server-configured (an hour by default), so do not promise a shorter wait. + keepPrefill = true; + message = tr("Too many password reset attempts from your address. Please wait before " + "requesting a new reset."); + break; + case Response::RespFunctionNotAllowed: + // Also returned for an unknown user, a banned user, and an expired token or + // challenge, so the message must not claim a single cause. + message = tr("The server did not accept the reset request. Check the user name, or contact " + "the server operator to reset your password."); + break; + default: + message = tr("Failed to reset user account password, please contact the server operator to reset your " + "password."); + break; + } + + QMessageBox::warning(dialogParent, tr("Reset Password"), message); + + if (!keepPrefill) { + SettingsCache::instance().servers().setFPHostName(""); + SettingsCache::instance().servers().setFPPort(""); + SettingsCache::instance().servers().setFPPlayerName(""); + } } void ConnectionController::onPromptForgotPasswordReset() diff --git a/cockatrice/src/client/network/connection_controller/remote_connection_controller.h b/cockatrice/src/client/network/connection_controller/remote_connection_controller.h index 150ec9e5b..2987a8d99 100644 --- a/cockatrice/src/client/network/connection_controller/remote_connection_controller.h +++ b/cockatrice/src/client/network/connection_controller/remote_connection_controller.h @@ -87,13 +87,17 @@ private slots: // Forgot-password flow void onForgotPasswordSuccess(); - void onForgotPasswordError(); + void onForgotPasswordError(int responseCode); void onPromptForgotPasswordReset(); void onPromptForgotPasswordChallenge(); // Persists the derived scrypt verifier after a successful challenge-response login void onPasswordVerifierReady(const QString &verifier); + // Completes the forced password change the server demanded at login + void onPasswordChangeSuccess(); + void onPasswordChangeError(int responseCode); + private: void wireClientSignals(); void updateWindowTitle(); diff --git a/cockatrice/src/client/network/interfaces/deck_stats_interface.cpp b/cockatrice/src/client/network/interfaces/deck_stats_interface.cpp index d5cfdbbc9..508f0bdc9 100644 --- a/cockatrice/src/client/network/interfaces/deck_stats_interface.cpp +++ b/cockatrice/src/client/network/interfaces/deck_stats_interface.cpp @@ -20,11 +20,12 @@ #include #include #include +#include #include DeckStatsInterface::DeckStatsInterface(QObject *parent) : QObject(parent) { - manager = new QNetworkAccessManager(this); + manager = NetworkManagerFactory::create(this); connect(manager, &QNetworkAccessManager::finished, this, &DeckStatsInterface::queryFinished); } diff --git a/cockatrice/src/client/network/interfaces/tapped_out_interface.cpp b/cockatrice/src/client/network/interfaces/tapped_out_interface.cpp index 30044c2b6..34dd03765 100644 --- a/cockatrice/src/client/network/interfaces/tapped_out_interface.cpp +++ b/cockatrice/src/client/network/interfaces/tapped_out_interface.cpp @@ -26,11 +26,12 @@ #include #include #include +#include #include TappedOutInterface::TappedOutInterface(QObject *parent) : QObject(parent) { - manager = new QNetworkAccessManager(this); + manager = NetworkManagerFactory::create(this); connect(manager, &QNetworkAccessManager::finished, this, &TappedOutInterface::queryFinished); } diff --git a/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.cpp b/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.cpp index 192029b23..62668c4d9 100644 --- a/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.cpp +++ b/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.cpp @@ -27,6 +27,7 @@ #include #include #include +#include #include #include #include @@ -56,7 +57,7 @@ void SpoilerBackgroundUpdater::startSpoilerDownloadProcess(QString url, bool sav void SpoilerBackgroundUpdater::downloadFromURL(QUrl url, bool saveResults) { - auto *nam = new QNetworkAccessManager(this); + auto *nam = NetworkManagerFactory::create(this); auto request = QNetworkRequest(url); request.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING)); QNetworkReply *reply = nam->get(request); diff --git a/cockatrice/src/client/network/update/client/release_channel.cpp b/cockatrice/src/client/network/update/client/release_channel.cpp index 45c503623..ce80a2a76 100644 --- a/cockatrice/src/client/network/update/client/release_channel.cpp +++ b/cockatrice/src/client/network/update/client/release_channel.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -62,7 +63,7 @@ std::optional getProductVersionMajor() } // namespace -ReleaseChannel::ReleaseChannel() : netMan(new QNetworkAccessManager(this)), response(nullptr), lastRelease(nullptr) +ReleaseChannel::ReleaseChannel() : netMan(NetworkManagerFactory::create(this)), response(nullptr), lastRelease(nullptr) { } diff --git a/cockatrice/src/client/network/update/client/update_downloader.cpp b/cockatrice/src/client/network/update/client/update_downloader.cpp index c86972d60..8868876fa 100644 --- a/cockatrice/src/client/network/update/client/update_downloader.cpp +++ b/cockatrice/src/client/network/update/client/update_downloader.cpp @@ -9,10 +9,11 @@ #include #include #include +#include UpdateDownloader::UpdateDownloader(QObject *parent) : QObject(parent), response(nullptr) { - netMan = new QNetworkAccessManager(this); + netMan = NetworkManagerFactory::create(this); } void UpdateDownloader::beginDownload(QUrl downloadUrl) diff --git a/cockatrice/src/client/settings/cache_settings.h b/cockatrice/src/client/settings/cache_settings.h index 5f699247d..9aeb88a18 100644 --- a/cockatrice/src/client/settings/cache_settings.h +++ b/cockatrice/src/client/settings/cache_settings.h @@ -19,7 +19,6 @@ inline Q_LOGGING_CATEGORY(SettingsCacheLog, "settings_cache"); class ReleaseChannel; -#define DEFAULT_LANG_NAME "English" #define CLIENT_INFO_NOT_SET "notset" #define DEFAULT_FONT_SIZE 12 diff --git a/cockatrice/src/database/interface/settings_card_preference_provider.h b/cockatrice/src/database/interface/settings_card_preference_provider.h index 1feb2006b..f4bf23141 100644 --- a/cockatrice/src/database/interface/settings_card_preference_provider.h +++ b/cockatrice/src/database/interface/settings_card_preference_provider.h @@ -2,13 +2,18 @@ #define COCKATRICE_SETTINGS_CARD_PREFERENCE_PROVIDER_H #include "../../client/settings/cache_settings.h" +#include #include #include #include -class SettingsCardPreferenceProvider : public ICardPreferenceProvider +class SettingsCardPreferenceProvider : public QObject, public ICardPreferenceProvider { public: + explicit SettingsCardPreferenceProvider(QObject *parent = nullptr) : QObject(parent) + { + } + [[nodiscard]] QString getCardPreferenceOverride(const QString &cardName) const override { return SettingsCache::instance().cardOverrides().getCardPreferenceOverride(cardName); diff --git a/cockatrice/src/filters/deck_filter_string.cpp b/cockatrice/src/filters/deck_filter_string.cpp index 616311750..f0df897fc 100644 --- a/cockatrice/src/filters/deck_filter_string.cpp +++ b/cockatrice/src/filters/deck_filter_string.cpp @@ -1,6 +1,5 @@ #include "deck_filter_string.h" -#include "../interface/deck_loader/loaded_deck.h" #include "libcockatrice/card/card_localization.h" #include "libcockatrice/card/database/card_database_querier.h" #include "libcockatrice/deck_list/deck_list.h" @@ -15,6 +14,7 @@ #include #include #include +#include #include #include #include diff --git a/cockatrice/src/filters/deck_filter_string.h b/cockatrice/src/filters/deck_filter_string.h index 76b891b59..b27cf87ca 100644 --- a/cockatrice/src/filters/deck_filter_string.h +++ b/cockatrice/src/filters/deck_filter_string.h @@ -11,6 +11,7 @@ #include #include #include +#include struct CardSearchLanguage; struct LoadedDeck; diff --git a/cockatrice/src/filters/filter_builder.cpp b/cockatrice/src/filters/filter_builder.cpp index 6cc893224..9c2a91a5b 100644 --- a/cockatrice/src/filters/filter_builder.cpp +++ b/cockatrice/src/filters/filter_builder.cpp @@ -1,6 +1,6 @@ #include "filter_builder.h" -#include "../interface/pixel_map_generator.h" +#include "../interface/theme_icon.h" #include "../interface/widgets/utility/custom_line_edit.h" #include @@ -13,6 +13,7 @@ #include #include #include +#include #include FilterBuilder::FilterBuilder(QWidget *parent) : QWidget(parent) @@ -29,7 +30,7 @@ FilterBuilder::FilterBuilder(QWidget *parent) : QWidget(parent) typeCombo->addItem(CardFilter::typeName(static_cast(i)), QVariant(i)); } - QPushButton *ok = new QPushButton(themePixmap(QStringLiteral("icons/increment")), QString()); + QPushButton *ok = new QPushButton(loadThemedIcon(QStringLiteral("icons/increment")), QString()); ok->setObjectName("ok"); ok->setMaximumSize(20, 20); @@ -39,9 +40,8 @@ FilterBuilder::FilterBuilder(QWidget *parent) : QWidget(parent) edit->setClearButtonEnabled(true); edit->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); - QGridLayout *layout = new QGridLayout; + QGridLayout *layout = QtUtils::makeLayout(); layout->setObjectName("layout"); - layout->setContentsMargins(0, 0, 0, 0); layout->addWidget(typeCombo, 0, 0, 1, 2); layout->addWidget(filterCombo, 0, 2, 1, 2); diff --git a/cockatrice/src/filters/filter_tree_model.cpp b/cockatrice/src/filters/filter_tree_model.cpp index b3bf307f1..9b55ce2fd 100644 --- a/cockatrice/src/filters/filter_tree_model.cpp +++ b/cockatrice/src/filters/filter_tree_model.cpp @@ -11,6 +11,27 @@ class QObject; +namespace +{ +/** + * @brief Visits every filter item below @p node, depth first. + */ +template void forEachFilter(const FilterTreeNode *node, const Callback &callback) +{ + if (node == nullptr) { + return; + } + + if (const auto *filterItem = dynamic_cast(node)) { + callback(filterItem); + } + + for (int i = 0; i < node->childCount(); ++i) { + forEachFilter(node->nodeAt(i), callback); + } +} +} // namespace + FilterTreeModel::FilterTreeModel(QObject *parent) : QAbstractItemModel(parent) { fTree = new FilterTree; @@ -110,46 +131,31 @@ void FilterTreeModel::clearFiltersOfType(CardFilter::Attr filterType) QList FilterTreeModel::getFiltersOfType(CardFilter::Attr filterType) const { QList filters; - if (!fTree) { - return filters; - } - - std::function traverse; - traverse = [&](const FilterTreeNode *node) { - if (const auto *filterItem = dynamic_cast(node)) { - if (filterItem->attr() == filterType) { - QString text = filterItem->text(); - filters.append(new CardFilter(text, filterItem->type(), filterItem->attr())); - } + forEachFilter(fTree, [&](const FilterItem *filterItem) { + if (filterItem->attr() == filterType) { + filters.append(new CardFilter(filterItem->text(), filterItem->type(), filterItem->attr())); } - for (int i = 0; i < node->childCount(); ++i) { - traverse(node->nodeAt(i)); - } - }; - - traverse(fTree); + }); return filters; } +QStringList FilterTreeModel::getFilterTermsOfType(CardFilter::Attr filterType) const +{ + QStringList terms; + forEachFilter(fTree, [&](const FilterItem *filterItem) { + if (filterItem->attr() == filterType) { + terms.append(filterItem->text()); + } + }); + return terms; +} + QList FilterTreeModel::allFilters() const { QList filters; - if (!fTree) { - return filters; - } - - std::function traverse; - traverse = [&](const FilterTreeNode *node) { - if (const auto *filterItem = dynamic_cast(node)) { - QString text = filterItem->text(); - filters.append(new CardFilter(text, filterItem->type(), filterItem->attr())); - } - for (int i = 0; i < node->childCount(); ++i) { - traverse(node->nodeAt(i)); - } - }; - - traverse(fTree); + forEachFilter(fTree, [&](const FilterItem *filterItem) { + filters.append(new CardFilter(filterItem->text(), filterItem->type(), filterItem->attr())); + }); return filters; } diff --git a/cockatrice/src/filters/filter_tree_model.h b/cockatrice/src/filters/filter_tree_model.h index 826fcc941..05f34f508 100644 --- a/cockatrice/src/filters/filter_tree_model.h +++ b/cockatrice/src/filters/filter_tree_model.h @@ -28,7 +28,14 @@ public slots: void addFilter(const CardFilter *f); void removeFilter(const CardFilter *f); void clearFiltersOfType(CardFilter::Attr filterType); + /** + * @brief Copies every active filter of one attribute into new CardFilter objects. + * + * The caller owns the returned filters. Prefer getFilterTermsOfType() when only + * the terms are needed. + */ [[nodiscard]] QList getFiltersOfType(CardFilter::Attr filterType) const; + /// @brief Copies every active filter into new CardFilter objects. The caller owns them. [[nodiscard]] QList allFilters() const; private slots: @@ -48,6 +55,13 @@ public: { return fTree; } + + /** + * @brief The texts of every active filter of one attribute, without allocating filters. + * + * Prefer this over getFiltersOfType() when only the filter terms are needed. + */ + [[nodiscard]] QStringList getFilterTermsOfType(CardFilter::Attr filterType) const; [[nodiscard]] int rowCount(const QModelIndex &parent = QModelIndex()) const override; [[nodiscard]] int columnCount(const QModelIndex & /*parent*/ = QModelIndex()) const override; [[nodiscard]] QVariant data(const QModelIndex &index, int role) const override; diff --git a/cockatrice/src/game/board/card_state.cpp b/cockatrice/src/game/board/card_state.cpp deleted file mode 100644 index 3f879a849..000000000 --- a/cockatrice/src/game/board/card_state.cpp +++ /dev/null @@ -1,113 +0,0 @@ -#include "card_state.h" - -#include - -void CardState::resetState(bool keepAnnotations) -{ - attacking = false; - counters.clear(); - pt.clear(); - if (!keepAnnotations) { - annotation.clear(); - } - attachedTo = nullptr; -} - -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(CardItem *_attachedTo) -{ - if (attachedTo == _attachedTo) { - return; - } - attachedTo = _attachedTo; - emit attachedToChanged(_attachedTo); - emit stateChanged(); -} \ No newline at end of file diff --git a/cockatrice/src/game/board/card_state.h b/cockatrice/src/game/board/card_state.h deleted file mode 100644 index ca7aaf927..000000000 --- a/cockatrice/src/game/board/card_state.h +++ /dev/null @@ -1,106 +0,0 @@ -#ifndef COCKATRICE_CARD_STATE_H -#define COCKATRICE_CARD_STATE_H - -#include -#include -#include -#include - -class CardZoneLogic; -class CardItem; - -class CardState : public QObject -{ - Q_OBJECT - -private: - bool attacking = false; - QMap counters; - QString annotation; - QString pt; - bool doesntUntap = false; - bool destroyOnZoneChange = false; - - CardItem *attachedTo = nullptr; - CardZoneLogic *zone = nullptr; - -signals: - void stateChanged(); - - 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(CardItem *newAttachedTo); - void zoneChanged(CardState *changedCard, CardZoneLogic *newZone); - -public: - explicit CardState(QObject *parent, CardZoneLogic *_zone) : QObject(parent), zone(_zone) - { - } - - void resetState(bool keepAnnotations); - - 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); - - bool getDoesntUntap() const - { - return doesntUntap; - } - - void setDoesntUntap(bool _doesntUntap); - - bool getDestroyOnZoneChange() const - { - return destroyOnZoneChange; - } - - void setDestroyOnZoneChange(bool _destroyOnZoneChange); - - CardItem *getAttachedTo() const - { - return attachedTo; - } - - void setAttachedTo(CardItem *_attachedTo); -}; - -#endif // COCKATRICE_CARD_STATE_H diff --git a/cockatrice/src/game/replay.h b/cockatrice/src/game/replay.h deleted file mode 100644 index cc995dbac..000000000 --- a/cockatrice/src/game/replay.h +++ /dev/null @@ -1,26 +0,0 @@ -/** - * @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 Replay : public AbstractGame -{ - Q_OBJECT - -public: - explicit Replay(QObject *_parent, const GameReplay *_replay, bool isLocalGame); -}; - -#endif // COCKATRICE_REPLAY_H diff --git a/cockatrice/src/game_graphics/board/abstract_card_drag_item.cpp b/cockatrice/src/game_graphics/board/abstract_card_drag_item.cpp index 2e18fca2d..c707b4022 100644 --- a/cockatrice/src/game_graphics/board/abstract_card_drag_item.cpp +++ b/cockatrice/src/game_graphics/board/abstract_card_drag_item.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -51,7 +52,20 @@ AbstractCardDragItem::AbstractCardDragItem(AbstractCardItem *_item, update(); }); - connect(item, &QObject::destroyed, this, &AbstractCardDragItem::deleteLater); + // The source card can be destroyed mid-drag (e.g. moved by a server event). deleteLater() alone is not enough: + // a scene repaint can run before the deferred delete and paint the dead card. Detach from the scene immediately. + connect(_item, &QObject::destroyed, this, [this]() { + hide(); + if (QGraphicsScene *sc = scene()) { + sc->removeItem(this); + } + deleteLater(); + }); +} + +AbstractCardItem *AbstractCardDragItem::getItem() const +{ + return item.data(); } QPainterPath AbstractCardDragItem::shape() const @@ -65,6 +79,10 @@ QPainterPath AbstractCardDragItem::shape() const void AbstractCardDragItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) { + if (item.isNull()) { + return; + } + item->paint(painter, option, widget); // adds a mask to the card so it looks like the card hasnt been placed yet diff --git a/cockatrice/src/game_graphics/board/abstract_card_drag_item.h b/cockatrice/src/game_graphics/board/abstract_card_drag_item.h index 18afdcbb8..80e40ff1d 100644 --- a/cockatrice/src/game_graphics/board/abstract_card_drag_item.h +++ b/cockatrice/src/game_graphics/board/abstract_card_drag_item.h @@ -14,6 +14,7 @@ #include #include #include +#include #include #include @@ -24,7 +25,8 @@ class AbstractCardDragItem : public QObject, public QGraphicsItem Q_OBJECT Q_INTERFACES(QGraphicsItem) protected: - AbstractCardItem *item; + /// Guarded pointer: the source card may be destroyed while this drag item still exists. + QPointer item; QPointF hotSpot; QList childDrags; @@ -44,10 +46,8 @@ public: } [[nodiscard]] QPainterPath shape() const override; void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override; - [[nodiscard]] AbstractCardItem *getItem() const - { - return item; - } + /// @return the source card, or nullptr if it has already been destroyed. + [[nodiscard]] AbstractCardItem *getItem() const; [[nodiscard]] QPointF getHotSpot() const { return hotSpot; diff --git a/cockatrice/src/game_graphics/board/abstract_card_item.cpp b/cockatrice/src/game_graphics/board/abstract_card_item.cpp index cc121cfb2..0e8b6ced3 100644 --- a/cockatrice/src/game_graphics/board/abstract_card_item.cpp +++ b/cockatrice/src/game_graphics/board/abstract_card_item.cpp @@ -34,7 +34,6 @@ #include #include #include -#include #include #include #include @@ -43,13 +42,23 @@ class QSizeF; AbstractCardItem::AbstractCardItem(QGraphicsItem *parent, const CardRef &cardRef, PlayerLogic *_owner, int _id) - : ArrowTarget(_owner, parent), id(_id), cardRef(cardRef), tapped(false), facedown(false), tapAngle(0), - bgColor(Qt::transparent), isHovered(false), realZValue(0), pendingSelectionCollapse(false) + : ArrowTarget(_owner, parent), state(new CardState(this, nullptr, cardRef, _id)), tapAngle(0), + bgColor(Qt::transparent), isHovered(false), realZValue(0) { setCursor(Qt::OpenHandCursor); setFlag(ItemIsSelectable); setCacheMode(DeviceCoordinateCache); + connect(state, &CardState::cardPixmapUpdated, this, &AbstractCardItem::pixmapUpdated); + connect(state, &CardState::cardInfoChanged, this, &AbstractCardItem::onCardInfoChanged); + connect(state, &CardState::cardRefChanged, this, &AbstractCardItem::onCardRefChanged); + connect(state, &CardState::tappedChanged, this, &AbstractCardItem::onTappedChanged); + connect(state, &CardState::facedownChanged, this, [this] { update(); }); + connect(state, &CardState::colorChanged, this, [this] { + cacheBgColor(); + update(); + }); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::displayCardNamesChanged, this, [this] { update(); }); connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this, @@ -67,7 +76,7 @@ AbstractCardItem::AbstractCardItem(QGraphicsItem *parent, const CardRef &cardRef AbstractCardItem::~AbstractCardItem() { - emit deleteCardInfoPopup(cardRef.name); + emit deleteCardInfoPopup(state->getCardRef().name); } QRectF AbstractCardItem::boundingRect() const @@ -90,30 +99,29 @@ void AbstractCardItem::pixmapUpdated() emit sigPixmapUpdated(); } -void AbstractCardItem::refreshCardInfo() +void AbstractCardItem::onCardInfoChanged() { - 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, &AbstractCardItem::pixmapUpdated); - } - cacheBgColor(); update(); } +void AbstractCardItem::onCardRefChanged(const CardRef &oldCardRef, const CardRef & /*newCardRef*/) +{ + emit deleteCardInfoPopup(oldCardRef.name); +} + +void AbstractCardItem::refreshCardInfo() +{ + state->refreshCardInfo(); +} + /** * Convenience method to get the CardInfo of the exactCard * @return A const reference to the CardInfo, or an empty CardInfo if card was null */ const CardInfo &AbstractCardItem::getCardInfo() const { - return exactCard.getInfo(); + return state->getCardInfo(); } void AbstractCardItem::setRealZValue(qreal _zValue) @@ -158,13 +166,13 @@ void AbstractCardItem::paintPicture(QPainter *painter, const QSizeF &translatedS QPixmap translatedPixmap; bool paintImage = true; - if (facedown || cardRef.name.isEmpty()) { + if (state->getFaceDown() || state->getCardRef().name.isEmpty()) { // never reveal card color, always paint the card back CardPictureLoader::getCardBackPixmap(translatedPixmap, translatedSize.toSize()); } else { // don't even spend time trying to load the picture if our size is too small if (translatedSize.width() > 10) { - CardPictureLoader::getPixmap(translatedPixmap, exactCard, translatedSize.toSize()); + CardPictureLoader::getPixmap(translatedPixmap, state->getCard(), translatedSize.toSize()); if (translatedPixmap.isNull()) { paintImage = false; } @@ -185,19 +193,20 @@ void AbstractCardItem::paintPicture(QPainter *painter, const QSizeF &translatedS painter->drawPath(shape()); } - if (translatedPixmap.isNull() || SettingsCache::instance().cardsDisplay().getDisplayCardNames() || facedown) { + if (translatedPixmap.isNull() || SettingsCache::instance().cardsDisplay().getDisplayCardNames() || + state->getFaceDown()) { painter->save(); transformPainter(painter, translatedSize, angle); painter->setPen(Qt::white); painter->setBackground(Qt::black); painter->setBackgroundMode(Qt::OpaqueMode); QString nameStr; - if (facedown) { - nameStr = "# " + QString::number(id); + if (state->getFaceDown()) { + nameStr = "# " + QString::number(state->getId()); } else { QString prefix = ""; if (SettingsCache::instance().debug().getShowCardId()) { - prefix = "#" + QString::number(id) + " "; + prefix = "#" + QString::number(state->getId()) + " "; } nameStr = prefix + CardLocalization::displayName(getCardInfo()); } @@ -235,21 +244,6 @@ void AbstractCardItem::paint(QPainter *painter, const QStyleOptionGraphicsItem * painter->restore(); } -void AbstractCardItem::setCardRef(const CardRef &_cardRef) -{ - if (cardRef == _cardRef) { - return; - } - - emit deleteCardInfoPopup(cardRef.name); - if (exactCard) { - disconnect(exactCard.getCardPtr().data(), nullptr, this, nullptr); - } - cardRef = _cardRef; - - refreshCardInfo(); -} - void AbstractCardItem::setHovered(bool _hovered) { if (isHovered == _hovered) { @@ -275,18 +269,17 @@ void AbstractCardItem::setHovered(bool _hovered) void AbstractCardItem::setColor(const QString &_color) { - color = _color; - cacheBgColor(); - update(); + state->setColor(_color); } void AbstractCardItem::cacheBgColor() { QChar colorChar; - if (color.isEmpty()) { - colorChar = exactCard.getInfo().getColorChar(); + const QString cardColor = state->getColor(); + if (cardColor.isEmpty()) { + colorChar = state->getCardInfo().getColorChar(); } else { - colorChar = color.at(0); + colorChar = cardColor.at(0); } switch (colorChar.toLower().toLatin1()) { @@ -314,17 +307,12 @@ void AbstractCardItem::cacheBgColor() } } -void AbstractCardItem::setTapped(bool _tapped, bool canAnimate) +void AbstractCardItem::onTappedChanged(bool newTapped, bool canAnimate) { - if (tapped == _tapped) { - return; - } - - tapped = _tapped; if (SettingsCache::instance().cardsDisplay().getTapAnimation() && canAnimate) { static_cast(scene())->registerAnimationItem(this); } else { - tapAngle = tapped ? 90 : 0; + tapAngle = newTapped ? 90 : 0; setTransform(QTransform() .translate(CardDimensions::WIDTH_HALF_F, CardDimensions::HEIGHT_HALF_F) .rotate(tapAngle) @@ -338,12 +326,6 @@ bool AbstractCardItem::animationEvent() return false; } -void AbstractCardItem::setFaceDown(bool _facedown) -{ - facedown = _facedown; - update(); -} - void AbstractCardItem::mousePressEvent(QGraphicsSceneMouseEvent *event) { cancelPendingSelectionCollapse(); @@ -357,7 +339,7 @@ void AbstractCardItem::mousePressEvent(QGraphicsSceneMouseEvent *event) } } if ((event->modifiers() & Qt::AltModifier) && event->button() == Qt::LeftButton) { - emit cardShiftClicked(cardRef.name); + emit cardShiftClicked(state->getCardRef().name); } else if ((event->modifiers() & Qt::ControlModifier)) { setSelected(!isSelected()); } else if (!isSelected()) { @@ -371,7 +353,7 @@ void AbstractCardItem::mousePressEvent(QGraphicsSceneMouseEvent *event) if (event->button() == Qt::LeftButton) { setCursor(Qt::ClosedHandCursor); } else if (event->button() == Qt::MiddleButton) { - emit showCardInfoPopup(event->screenPos(), cardRef); + emit showCardInfoPopup(event->screenPos(), state->getCardRef()); } event->accept(); } @@ -405,7 +387,7 @@ void AbstractCardItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) } if (event->button() == Qt::MiddleButton) { - emit deleteCardInfoPopup(cardRef.name); + emit deleteCardInfoPopup(state->getCardRef().name); } // This function ensures the parent function doesn't mess around with our selection. diff --git a/cockatrice/src/game_graphics/board/abstract_card_item.h b/cockatrice/src/game_graphics/board/abstract_card_item.h index e2b150ce8..eee8ec743 100644 --- a/cockatrice/src/game_graphics/board/abstract_card_item.h +++ b/cockatrice/src/game_graphics/board/abstract_card_item.h @@ -16,8 +16,7 @@ #include #include #include -#include -#include +#include #include class PlayerLogic; @@ -29,13 +28,8 @@ class AbstractCardItem : public ArrowTarget, public IAnimatedItem { Q_OBJECT protected: - ExactCard exactCard; - int id; - CardRef cardRef; - bool tapped; - bool facedown; + CardState *state; int tapAngle; - QString color; QColor bgColor; private: @@ -45,6 +39,9 @@ private: int selectionCollapseGeneration = 0; ///< Guards the deferred collapse timer against stale firings private slots: void pixmapUpdated(); + void onCardInfoChanged(); + void onCardRefChanged(const CardRef &oldCardRef, const CardRef &newCardRef); + void onTappedChanged(bool newTapped, bool canAnimate); public slots: void refreshCardInfo(); @@ -80,31 +77,38 @@ public: QSizeF getTranslatedSize(QPainter *painter) const; void paintPicture(QPainter *painter, const QSizeF &translatedSize, int angle); void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override; + [[nodiscard]] CardState *getState() const + { + return state; + } ExactCard getCard() const { - return exactCard; + return state->getCard(); } const CardInfo &getCardInfo() const; int getId() const { - return id; + return state->getId(); } void setId(int _id) { - id = _id; + state->setId(_id); } QString getName() const { - return cardRef.name; + return state->getCardRef().name; } QString getProviderId() const { - return cardRef.providerId; + return state->getCardRef().providerId; + } + void setCardRef(const CardRef &_cardRef) + { + state->setCardRef(_cardRef); } - void setCardRef(const CardRef &_cardRef); CardRef getCardRef() const { - return cardRef; + return state->getCardRef(); } qreal getRealZValue() const { @@ -116,27 +120,33 @@ public: { return isHovered; } - QString getColor() const + [[nodiscard]] QString getColor() const { - return color; + return state->getColor(); } void setColor(const QString &_color); bool getTapped() const { - return tapped; + return state->getTapped(); + } + void setTapped(bool _tapped, bool canAnimate = false) + { + state->setTapped(_tapped, canAnimate); } - void setTapped(bool _tapped, bool canAnimate = false); bool getFaceDown() const { - return facedown; + return state->getFaceDown(); + } + void setFaceDown(bool _facedown) + { + state->setFaceDown(_facedown); } - void setFaceDown(bool _facedown); void processHoverEvent(); /** @brief Drop a pending collapse and disarm any deferred collapse timer. */ void cancelPendingSelectionCollapse(); void deleteCardInfoPopup() { - emit deleteCardInfoPopup(cardRef.name); + emit deleteCardInfoPopup(state->getCardRef().name); } /** @brief Default: no per-tick animation. Subclasses override to animate. */ diff --git a/cockatrice/src/game_graphics/board/abstract_counter.cpp b/cockatrice/src/game_graphics/board/abstract_counter.cpp index 8500b9c51..30ffde320 100644 --- a/cockatrice/src/game_graphics/board/abstract_counter.cpp +++ b/cockatrice/src/game_graphics/board/abstract_counter.cpp @@ -2,10 +2,6 @@ #include "../../client/settings/cache_settings.h" #include "../../client/settings/shortcuts_settings.h" -#include "../../game/board/counter_state.h" -#include "../../game/player/player_actions.h" -#include "../../game/player/player_info.h" -#include "../../game/player/player_logic.h" #include "../../game_graphics/board/translate_counter_name.h" #include "../../interface/widgets/menus/tearoff_menu.h" @@ -23,8 +19,10 @@ #include #include #include -#include -#include +#include +#include +#include +#include #include #include @@ -151,10 +149,7 @@ void AbstractCounter::mousePressEvent(QGraphicsSceneMouseEvent *event) menu->exec(event->screenPos()); } } else { - Command_IncCounter cmd; - cmd.set_counter_id(id); - cmd.set_delta(event->button() == Qt::LeftButton ? 1 : -1); - player->getPlayerActions()->sendGameCommand(cmd); + player->getPlayerActions()->actIncrementCounter(id, event->button() == Qt::LeftButton ? 1 : -1); } event->accept(); } @@ -172,10 +167,7 @@ void AbstractCounter::hoverLeaveEvent(QGraphicsSceneHoverEvent *) void AbstractCounter::incrementCounter() { - Command_IncCounter cmd; - cmd.set_counter_id(id); - cmd.set_delta(static_cast(sender())->data().toInt()); - player->getPlayerActions()->sendGameCommand(cmd); + player->getPlayerActions()->actIncrementCounter(id, static_cast(sender())->data().toInt()); } void AbstractCounter::setCounter() @@ -199,10 +191,7 @@ void AbstractCounter::setCounter() } Expression exp(value); - Command_SetCounter cmd; - cmd.set_counter_id(id); - cmd.set_value(static_cast(exp.parse(dlg.textValue()))); - player->getPlayerActions()->sendGameCommand(cmd); + player->getPlayerActions()->actSetCounter(id, static_cast(exp.parse(dlg.textValue()))); } AbstractCounterDialog::AbstractCounterDialog(const QString &name, const QString &value, QWidget *parent) diff --git a/cockatrice/src/game_graphics/board/arrow_item.cpp b/cockatrice/src/game_graphics/board/arrow_item.cpp index c5a9b9c5e..f7df98ab5 100644 --- a/cockatrice/src/game_graphics/board/arrow_item.cpp +++ b/cockatrice/src/game_graphics/board/arrow_item.cpp @@ -2,11 +2,6 @@ #include "arrow_item.h" #include "../../client/settings/cache_settings.h" -#include "../../game/board/arrow_data.h" -#include "../../game/player/player_actions.h" -#include "../../game/player/player_info.h" -#include "../../game/player/player_logic.h" -#include "../../game/zones/card_zone_logic.h" #include "../game_scene.h" #include "../player/player_target.h" #include "../z_values.h" @@ -27,12 +22,12 @@ #include #include #include -#include -#include -#include +#include +#include +#include +#include +#include #include -#include -#include #include #include #include @@ -426,19 +421,19 @@ void ArrowDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) CardZoneLogic *startZone = startCard->getZone(); - Command_CreateArrow cmd; - cmd.mutable_arrow_color()->CopyFrom(convertQColorToColor(data->color)); - cmd.set_start_player_id(startZone->getPlayer()->getPlayerInfo()->getId()); - cmd.set_start_zone(startZone->getName().toStdString()); - cmd.set_start_card_id(startCard->getId()); + ArrowData arrow; + arrow.color = data->color; + arrow.startPlayerId = startZone->getPlayer()->getPlayerInfo()->getId(); + arrow.startZone = startZone->getName(); + arrow.startCardId = startCard->getId(); if (auto *targetCard = qgraphicsitem_cast(targetItem)) { CardZoneLogic *targetZone = targetCard->getZone(); - cmd.set_target_player_id(targetZone->getPlayer()->getPlayerInfo()->getId()); - cmd.set_target_zone(targetZone->getName().toStdString()); - cmd.set_target_card_id(targetCard->getId()); + arrow.targetPlayerId = targetZone->getPlayer()->getPlayerInfo()->getId(); + arrow.targetZone = targetZone->getName(); + arrow.targetCardId = targetCard->getId(); } else if (auto *targetPlayer = qgraphicsitem_cast(targetItem)) { - cmd.set_target_player_id(targetPlayer->getOwner()->getPlayerInfo()->getId()); + arrow.targetPlayerId = targetPlayer->getOwner()->getPlayerInfo()->getId(); } else { delArrow(); return; @@ -447,22 +442,9 @@ void ArrowDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) // if the card is in hand then we will move the card to stack or table as part of drawing the arrow if (startZone->getName() == ZoneNames::HAND) { startCard->playCard(false); - CardInfoPtr ci = startCard->getCard().getCardPtr(); - bool playToStack = SettingsCache::instance().userInterface().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); - } - - player->getPlayerActions()->sendGameCommand(cmd); + player->getPlayerActions()->createArrow(startCard->getState(), arrow, deleteInPhase); } delArrow(); @@ -554,7 +536,7 @@ void ArrowAttachItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) auto *startCard = qgraphicsitem_cast(startItem); auto *targetCard = qgraphicsitem_cast(targetItem); if (startCard && targetCard) { - attachCards(startCard, targetCard); + player->getPlayerActions()->attachCards(startCard->getState(), targetCard->getState()); } } @@ -563,23 +545,3 @@ void ArrowAttachItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) child->mouseReleaseEvent(event); } } - -void ArrowAttachItem::attachCards(CardItem *startCard, const CardItem *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) { - player->getPlayerActions()->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()); - player->getPlayerActions()->sendGameCommand(cmd); -} \ No newline at end of file diff --git a/cockatrice/src/game_graphics/board/arrow_item.h b/cockatrice/src/game_graphics/board/arrow_item.h index 2395165fc..64a48cd14 100644 --- a/cockatrice/src/game_graphics/board/arrow_item.h +++ b/cockatrice/src/game_graphics/board/arrow_item.h @@ -1,7 +1,6 @@ #ifndef ARROWITEM_H #define ARROWITEM_H -#include "../../game/board/arrow_data.h" #include "../animated_item.h" #include "arrow_target.h" #include "graphics_item_type.h" @@ -18,6 +17,7 @@ #include #include #include +#include #include class CardItem; @@ -131,7 +131,6 @@ private: PlayerLogic *player; QList childArrows; QMetaObject::Connection positionConnection; - void attachCards(CardItem *startCard, const CardItem *targetCard); public: explicit ArrowAttachItem(ArrowTarget *_startItem); diff --git a/cockatrice/src/game/arrow_registry.cpp b/cockatrice/src/game_graphics/board/arrow_registry.cpp similarity index 93% rename from cockatrice/src/game/arrow_registry.cpp rename to cockatrice/src/game_graphics/board/arrow_registry.cpp index c05134a75..10b3cde2f 100644 --- a/cockatrice/src/game/arrow_registry.cpp +++ b/cockatrice/src/game_graphics/board/arrow_registry.cpp @@ -1,9 +1,9 @@ #include "arrow_registry.h" -#include "../game_graphics/board/arrow_item.h" -#include "board/arrow_data.h" +#include "arrow_item.h" #include +#include void ArrowRegistry::insert(QSharedPointer data, ArrowItem *arrow) { diff --git a/cockatrice/src/game/arrow_registry.h b/cockatrice/src/game_graphics/board/arrow_registry.h similarity index 94% rename from cockatrice/src/game/arrow_registry.h rename to cockatrice/src/game_graphics/board/arrow_registry.h index ca3b04090..7103747e8 100644 --- a/cockatrice/src/game/arrow_registry.h +++ b/cockatrice/src/game_graphics/board/arrow_registry.h @@ -4,6 +4,7 @@ #include #include #include +#include class ArrowItem; struct ArrowData; diff --git a/cockatrice/src/game_graphics/board/card_drag_item.cpp b/cockatrice/src/game_graphics/board/card_drag_item.cpp index 265ea759c..b263e8c92 100644 --- a/cockatrice/src/game_graphics/board/card_drag_item.cpp +++ b/cockatrice/src/game_graphics/board/card_drag_item.cpp @@ -130,8 +130,9 @@ void CardDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) sc->removeItem(this); QList dragItemList; - CardZoneLogic *startZone = static_cast(item)->getZone(); - if (currentZone && !(static_cast(item)->getAttachedTo() && (startZone == currentZone->getLogic()))) { + CardZoneLogic *startZone = static_cast(item.data())->getZone(); + if (currentZone && + !(static_cast(item.data())->getAttachedTo() && (startZone == currentZone->getLogic()))) { if (!occupied) { dragItemList.append(this); } @@ -139,7 +140,7 @@ void CardDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) for (int i = 0; i < childDrags.size(); i++) { CardDragItem *c = static_cast(childDrags[i]); if (!occupied && - !(static_cast(c->item)->getAttachedTo() && (startZone == currentZone->getLogic())) && + !(static_cast(c->item.data())->getAttachedTo() && (startZone == currentZone->getLogic())) && !c->occupied) { dragItemList.append(c); } diff --git a/cockatrice/src/game_graphics/board/card_item.cpp b/cockatrice/src/game_graphics/board/card_item.cpp index 1901a634f..acd0764c9 100644 --- a/cockatrice/src/game_graphics/board/card_item.cpp +++ b/cockatrice/src/game_graphics/board/card_item.cpp @@ -1,17 +1,6 @@ #include "card_item.h" #include "../../client/settings/cache_settings.h" -#include "../../game/abstract_game.h" -#include "../../game/board/card_state.h" -#include "../../game/game_state.h" -#include "../../game/phase.h" -#include "../../game/player/player_actions.h" -#include "../../game/player/player_info.h" -#include "../../game/player/player_logic.h" -#include "../../game/player/player_manager.h" -#include "../../game/zones/card_zone_logic.h" -#include "../../game/zones/table_zone_logic.h" -#include "../../game/zones/view_zone_logic.h" #include "../card_dimensions.h" #include "../game_scene.h" #include "arrow_item.h" @@ -41,6 +30,17 @@ #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include #include #include @@ -53,8 +53,9 @@ CardItem::CardItem(PlayerLogic *_owner, const CardRef &cardRef, int _cardid, CardZoneLogic *_zone) - : AbstractCardItem(parent, cardRef, _owner, _cardid), state(new CardState(this, _zone)), dragItem(nullptr) + : AbstractCardItem(parent, cardRef, _owner, _cardid), dragItem(nullptr) { + state->setZone(_zone); owner->addCard(this); connect(&SettingsCache::instance().cardCounters(), &CardCounterSettings::colorChanged, this, [this](int counterId) { @@ -62,25 +63,44 @@ CardItem::CardItem(PlayerLogic *_owner, update(); } }); + + connect(state, &CardState::stateReset, this, [this] { + if (scene()) { + static_cast(scene())->unregisterAnimationItem(this); + } + update(); + }); + + // The retyped logic layer drives CardState setters directly (bypassing the CardItem wrappers that called + // update()), so repaint on every state change: attacking, annotation, doesntUntap, P/T and counters. + connect(state, &CardState::stateChanged, this, [this] { update(); }); + + connect(state, &CardState::viewDeleteRequested, this, &CardItem::deleteLater); } void CardItem::prepareDelete() { if (owner != nullptr) { - if (owner->getGame()->getActiveCard() == this) { + auto *gameScene = qobject_cast(scene()); + if (gameScene && gameScene->getActiveCard() == this) { emit owner->requestCardMenuUpdate(nullptr); - owner->getGame()->setActiveCard(nullptr); + gameScene->setActiveCard(nullptr); } owner = nullptr; } - while (!attachedCards.isEmpty()) { - attachedCards.first()->setZone(nullptr); // so that it won't try to call reorganizeCards() - attachedCards.first()->setAttachedTo(nullptr); + const QList &attachedStates = state->getAttachedCards(); + while (!attachedStates.isEmpty()) { + auto *attachedItem = qobject_cast(attachedStates.first()->parent()); + if (attachedItem == nullptr) { + break; + } + attachedItem->setZone(nullptr); // so that it won't try to call reorganizeCards() + attachedItem->setAttachedTo(nullptr); } if (state->getAttachedTo() != nullptr) { - state->getAttachedTo()->removeAttachedCard(this); + state->getAttachedTo()->removeAttachedCard(state); state->setAttachedTo(nullptr); } } @@ -127,7 +147,7 @@ void CardItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, painter->save(); transformPainter(painter, translatedSize, tapAngle); - if (!getFaceDown() && state->getPT() == exactCard.getInfo().getPowTough()) { + if (!getFaceDown() && state->getPT() == state->getCardInfo().getPowTough()) { painter->setPen(Qt::white); } else { painter->setPen(QColor(255, 150, 0)); // dark orange @@ -209,36 +229,13 @@ void CardItem::setPT(const QString &_pt) void CardItem::setAttachedTo(CardItem *_attachedTo) { - if (state->getAttachedTo() != nullptr) { - state->getAttachedTo()->removeAttachedCard(this); - } + CardState *newParent = _attachedTo == nullptr ? nullptr : _attachedTo->getState(); + state->setAttachedTo(newParent); - gridPoint.setX(-1); - state->setAttachedTo(_attachedTo); - if (state->getAttachedTo() != 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 - if (state->getAttachedTo()->getZone() == nullptr) { - deleteLater(); - } else { - emit state->getAttachedTo()->getZone()->cardAdded(this); - state->getAttachedTo()->addAttachedCard(this); - if (state->getZone() != state->getAttachedTo()->getZone()) { - state->getAttachedTo()->getZone()->reorganizeCards(); - } - } - } else { - // 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 - if (state->getZone() == nullptr) { - deleteLater(); - } else { - emit state->getZone()->cardAdded(this); - } - } - - if (state->getZone() != nullptr) { - state->getZone()->reorganizeCards(); + // 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 + if (newParent != nullptr ? newParent->getZone() == nullptr : state->getZone() == nullptr) { + deleteLater(); } } @@ -248,13 +245,6 @@ void CardItem::setAttachedTo(CardItem *_attachedTo) void CardItem::resetState(bool keepAnnotations) { state->resetState(keepAnnotations); - attachedCards.clear(); - setTapped(false, false); - setDoesntUntap(false); - if (scene()) { - static_cast(scene())->unregisterAnimationItem(this); - } - update(); } void CardItem::processCardInfo(const ServerInfo_Card &_info) @@ -390,7 +380,7 @@ void CardItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event) // Use the buttonDownPos to align the hot spot with the position when // the user originally clicked - createDragItem(id, event->buttonDownPos(Qt::LeftButton), event->scenePos(), forceFaceDown); + createDragItem(state->getId(), event->buttonDownPos(Qt::LeftButton), event->scenePos(), forceFaceDown); dragItem->grabMouse(); int childIndex = 0; @@ -433,42 +423,11 @@ void CardItem::playCard(bool faceDown) emit playSelected(this); } } else { - state->getZone()->getPlayer()->getPlayerActions()->playCard(this, faceDown); + state->getZone()->getPlayer()->getPlayerActions()->playCard(state, faceDown); } } } -QVariantList CardItem::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; -} - /** * @brief returns true if the zone is a unwritable reveal zone view (eg a card reveal window). Will return false if zone * is nullptr. @@ -493,7 +452,7 @@ void CardItem::handleClickedToPlay(bool shiftHeld) if (SettingsCache::instance().userInterface().getClickPlaysAllSelected()) { emit hideSelected(this); } else { - state->getZone()->removeCard(this); + state->getZone()->removeCard(state); } } else { playCard(shiftHeld); @@ -537,16 +496,16 @@ bool CardItem::animationEvent() { int rotation = ROTATION_DEGREES_PER_FRAME; bool animationIncomplete = true; - if (!tapped) { + if (!state->getTapped()) { rotation *= -1; } tapAngle += rotation; - if (tapped && (tapAngle > 90)) { + if (state->getTapped() && (tapAngle > 90)) { tapAngle = 90; animationIncomplete = false; } - if (!tapped && (tapAngle < 0)) { + if (!state->getTapped() && (tapAngle < 0)) { tapAngle = 0; animationIncomplete = false; } @@ -567,7 +526,9 @@ QVariant CardItem::itemChange(GraphicsItemChange change, const QVariant &value) bool selected = value.toBool(); if (selected) { - owner->getGame()->setActiveCard(this); + if (auto *gameScene = qobject_cast(scene())) { + gameScene->setActiveCard(this); + } } emit selectionChanged(this, selected); diff --git a/cockatrice/src/game_graphics/board/card_item.h b/cockatrice/src/game_graphics/board/card_item.h index 0737d7f9c..01dacc021 100644 --- a/cockatrice/src/game_graphics/board/card_item.h +++ b/cockatrice/src/game_graphics/board/card_item.h @@ -7,7 +7,6 @@ #ifndef CARDITEM_H #define CARDITEM_H -#include "../../game/board/card_state.h" #include "abstract_card_item.h" #include "graphics_item_type.h" @@ -16,6 +15,7 @@ #include #include #include +#include #include class CardDragItem; @@ -33,11 +33,7 @@ class CardItem : public AbstractCardItem { Q_OBJECT private: - CardState *state; - - QPoint gridPoint; CardDragItem *dragItem; - QList attachedCards; void prepareDelete(); void handleClickedToPlay(bool shiftHeld); @@ -60,10 +56,6 @@ public: CardZoneLogic *_zone = nullptr); void retranslateUi(); - [[nodiscard]] CardState *getState() const - { - return state; - } [[nodiscard]] CardZoneLogic *getZone() const { return state->getZone(); @@ -72,15 +64,15 @@ public: void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override; [[nodiscard]] QPoint getGridPoint() const { - return gridPoint; + return state->getGridPoint(); } void setGridPoint(const QPoint &_gridPoint) { - gridPoint = _gridPoint; + state->setGridPoint(_gridPoint); } [[nodiscard]] QPoint getGridPos() const { - return gridPoint; + return state->getGridPoint(); } [[nodiscard]] PlayerLogic *getOwner() const { @@ -123,22 +115,22 @@ public: { state->setDestroyOnZoneChange(_destroy); } - [[nodiscard]] CardItem *getAttachedTo() const + [[nodiscard]] CardState *getAttachedTo() const { return state->getAttachedTo(); } void setAttachedTo(CardItem *_attachedTo); - void addAttachedCard(CardItem *card) + void addAttachedCard(CardState *card) { - attachedCards.append(card); + state->addAttachedCard(card); } - void removeAttachedCard(CardItem *card) + void removeAttachedCard(CardState *card) { - attachedCards.removeOne(card); + state->removeAttachedCard(card); } - [[nodiscard]] const QList &getAttachedCards() const + [[nodiscard]] const QList &getAttachedCards() const { - return attachedCards; + return state->getAttachedCards(); } void resetState(bool keepAnnotations = false); void processCardInfo(const ServerInfo_Card &_info); @@ -150,26 +142,6 @@ public: void drawAttachArrow(); void playCard(bool faceDown); - /** - * @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); - protected: void mouseMoveEvent(QGraphicsSceneMouseEvent *event) override; void mouseReleaseEvent(QGraphicsSceneMouseEvent *event) override; diff --git a/cockatrice/src/game_graphics/deckview/deck_view.cpp b/cockatrice/src/game_graphics/deckview/deck_view.cpp index 630530792..6e0b0eaaa 100644 --- a/cockatrice/src/game_graphics/deckview/deck_view.cpp +++ b/cockatrice/src/game_graphics/deckview/deck_view.cpp @@ -77,7 +77,7 @@ void DeckViewCardDragItem::updatePosition(const QPointF &cursorScenePos) void DeckViewCardDragItem::handleDrop(DeckViewCardContainer *target) { - auto *card = static_cast(item); + auto *card = static_cast(item.data()); auto *start = static_cast(item->parentItem()); start->removeCard(card); target->addCard(card); diff --git a/cockatrice/src/game_graphics/deckview/deck_view_container.cpp b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp index a6e541fca..3bef9e59e 100644 --- a/cockatrice/src/game_graphics/deckview/deck_view_container.cpp +++ b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp @@ -2,13 +2,8 @@ #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_state.h" #include "../../interface/card_picture_loader/card_picture_loader.h" -#include "../../interface/deck_loader/deck_file_format.h" #include "../../interface/deck_loader/deck_loader.h" -#include "../../interface/deck_loader/loaded_deck.h" #include "../../interface/widgets/dialogs/dlg_load_deck.h" #include "../../interface/widgets/dialogs/dlg_load_deck_from_clipboard.h" #include "../../interface/widgets/dialogs/dlg_load_deck_from_website.h" @@ -33,16 +28,17 @@ #include #include #include +#include +#include #include -#include -#include -#include -#include -#include +#include +#include +#include #include #include #include #include +#include #include #include #include @@ -111,10 +107,9 @@ DeckViewContainer::DeckViewContainer(int _playerId, TabGame *parent) connect(deckView, &DeckView::newCardAdded, this, &DeckViewContainer::newCardAdded); connect(deckView, &DeckView::sideboardPlanChanged, this, &DeckViewContainer::sideboardPlanChanged); - deckViewLayout = new QVBoxLayout; + deckViewLayout = QtUtils::makeLayout(); deckViewLayout->addLayout(buttonHBox); deckViewLayout->addWidget(deckView); - deckViewLayout->setContentsMargins(0, 0, 0, 0); setLayout(deckViewLayout); retranslateUi(); @@ -314,9 +309,7 @@ void DeckViewContainer::loadDeckFromDeckList(const DeckList &deck) return; } - Command_DeckSelect cmd; - cmd.set_deck(deckString.toStdString()); - PendingCommand *pend = parentGame->getGame()->getGameEventHandler()->prepareGameCommand(cmd); + PendingCommand *pend = parentGame->getGame()->getGameEventHandler()->prepareDeckSelect(deckString); connect(pend, &PendingCommand::finished, this, &DeckViewContainer::deckSelectFinished); parentGame->getGame()->getGameEventHandler()->sendGameCommand(pend, playerId); @@ -345,15 +338,7 @@ void DeckViewContainer::resolveAndSendPlaymat() lastResolvedPlaymat = resolved; - Command_SetPlaymat playmatCmd; - auto *pp = playmatCmd.mutable_playmat_params(); - pp->set_card_name(resolved.card.name.toStdString()); - pp->set_card_provider_id(resolved.card.providerId.toStdString()); - pp->set_margin_pct_l(resolved.params.marginPctL); - pp->set_margin_pct_r(resolved.params.marginPctR); - pp->set_vertical_offset(resolved.params.verticalOffset); - pp->set_zoom(resolved.params.zoom); - PendingCommand *playmatPend = parentGame->getGame()->getGameEventHandler()->prepareGameCommand(playmatCmd); + PendingCommand *playmatPend = parentGame->getGame()->getGameEventHandler()->prepareSetPlaymat(resolved); const QString sentName = resolved.card.name; connect(playmatPend, &PendingCommand::finished, this, [this, sentName](const Response &r) { // Only RespFunctionNotAllowed signals a card art DENY rule. Older servers that predate @@ -384,9 +369,7 @@ void DeckViewContainer::loadRemoteDeck() { DlgLoadRemoteDeck dlg(parentGame->getGame()->getClientForPlayer(playerId), this); if (dlg.exec()) { - Command_DeckSelect cmd; - cmd.set_deck_id(dlg.getDeckId()); - PendingCommand *pend = parentGame->getGame()->getGameEventHandler()->prepareGameCommand(cmd); + PendingCommand *pend = parentGame->getGame()->getGameEventHandler()->prepareDeckSelectId(dlg.getDeckId()); connect(pend, &PendingCommand::finished, this, &DeckViewContainer::deckSelectFinished); parentGame->getGame()->getGameEventHandler()->sendGameCommand(pend, playerId); } @@ -442,34 +425,21 @@ void DeckViewContainer::readyStart() void DeckViewContainer::forceStart() { const auto msg = tr("Are you sure you want to force start?\nThis will kick all non-ready players from the game."); - const auto res = - QMessageBox::question(this, tr("Cockatrice"), msg, QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes); - if (res == QMessageBox::No) { + if (!QtUtils::confirm(this, tr("Cockatrice"), msg, QMessageBox::Yes)) { return; } - Command_ReadyStart cmd; - cmd.set_force_start(true); - cmd.set_ready(true); - parentGame->getGame()->getGameEventHandler()->sendGameCommand(cmd, playerId); + parentGame->getGame()->getGameEventHandler()->sendForceStart(playerId); } void DeckViewContainer::sideboardLockButtonClicked() { - Command_SetSideboardLock cmd; - cmd.set_locked(sideboardLockButton->getState()); - - parentGame->getGame()->getGameEventHandler()->sendGameCommand(cmd, playerId); + parentGame->getGame()->getGameEventHandler()->sendSetSideboardLock(sideboardLockButton->getState(), playerId); } void DeckViewContainer::sideboardPlanChanged() { - Command_SetSideboardPlan cmd; - const QList &newPlan = deckView->getSideboardPlan(); - for (const auto &i : newPlan) { - cmd.add_move_list()->CopyFrom(i); - } - parentGame->getGame()->getGameEventHandler()->sendGameCommand(cmd, playerId); + parentGame->getGame()->getGameEventHandler()->sendSetSideboardPlan(deckView->getSideboardPlan(), playerId); } /** @@ -481,9 +451,7 @@ void DeckViewContainer::sendReadyStartCommand(bool ready) resolveAndSendPlaymat(); } - Command_ReadyStart cmd; - cmd.set_ready(ready); - parentGame->getGame()->getGameEventHandler()->sendGameCommand(cmd, playerId); + parentGame->getGame()->getGameEventHandler()->sendReadyStart(ready, playerId); } /** diff --git a/cockatrice/src/game_graphics/dialogs/dlg_annotation.cpp b/cockatrice/src/game_graphics/dialogs/dlg_annotation.cpp new file mode 100644 index 000000000..c812f9c74 --- /dev/null +++ b/cockatrice/src/game_graphics/dialogs/dlg_annotation.cpp @@ -0,0 +1,13 @@ +#include "dlg_annotation.h" + +#include + +void AnnotationDialog::keyPressEvent(QKeyEvent *event) +{ + if (event->key() == Qt::Key_Return && event->modifiers() & Qt::ControlModifier) { + event->accept(); + accept(); + return; + } + QInputDialog::keyPressEvent(event); +} diff --git a/cockatrice/src/game_graphics/dialogs/dlg_annotation.h b/cockatrice/src/game_graphics/dialogs/dlg_annotation.h new file mode 100644 index 000000000..686377d3c --- /dev/null +++ b/cockatrice/src/game_graphics/dialogs/dlg_annotation.h @@ -0,0 +1,22 @@ +/** + * @file dlg_annotation.h + * @ingroup GameDialogs + */ + +#ifndef COCKATRICE_DLG_ANNOTATION_H +#define COCKATRICE_DLG_ANNOTATION_H + +#include + +class AnnotationDialog : public QInputDialog +{ + Q_OBJECT + void keyPressEvent(QKeyEvent *e) override; + +public: + explicit AnnotationDialog(QWidget *parent = nullptr) : QInputDialog(parent) + { + } +}; + +#endif // COCKATRICE_DLG_ANNOTATION_H diff --git a/cockatrice/src/game_graphics/dialogs/dlg_create_token.h b/cockatrice/src/game_graphics/dialogs/dlg_create_token.h index a03a1046b..323e61829 100644 --- a/cockatrice/src/game_graphics/dialogs/dlg_create_token.h +++ b/cockatrice/src/game_graphics/dialogs/dlg_create_token.h @@ -11,6 +11,7 @@ #include #include #include +#include #include class QLabel; @@ -25,17 +26,6 @@ class CardInfoPictureWidget; class QModelIndex; class QWidget; -struct TokenInfo -{ - QString name; - QString color; - QString pt; - QString annotation; - bool destroy = true; - bool faceDown = false; - QString providerId; -}; - class DlgCreateToken : public QDialog { Q_OBJECT diff --git a/cockatrice/src/game_graphics/dialogs/dlg_move_top_cards_until.h b/cockatrice/src/game_graphics/dialogs/dlg_move_top_cards_until.h index b9080bcc1..da2e70b0a 100644 --- a/cockatrice/src/game_graphics/dialogs/dlg_move_top_cards_until.h +++ b/cockatrice/src/game_graphics/dialogs/dlg_move_top_cards_until.h @@ -11,6 +11,7 @@ #include #include #include +#include #include class FilterString; @@ -21,13 +22,6 @@ class QLabel; class QSpinBox; class QWidget; -struct MoveTopCardsUntilOptions -{ - QStringList exprs = {}; - int numberOfHits = 1; - bool autoPlay = false; -}; - class DlgMoveTopCardsUntil : public QDialog { Q_OBJECT diff --git a/cockatrice/src/game_graphics/game_scene.cpp b/cockatrice/src/game_graphics/game_scene.cpp index cc3f9f1d3..db1040006 100644 --- a/cockatrice/src/game_graphics/game_scene.cpp +++ b/cockatrice/src/game_graphics/game_scene.cpp @@ -1,18 +1,10 @@ #include "game_scene.h" #include "../client/settings/cache_settings.h" -#include "../game/abstract_game.h" -#include "../game/arrow_registry.h" -#include "../game/board/arrow_data.h" -#include "../game/player/player_actions.h" -#include "../game/player/player_event_handler.h" -#include "../game/player/player_info.h" -#include "../game/player/player_logic.h" -#include "../game/zones/card_zone_logic.h" -#include "../game/zones/view_zone_logic.h" #include "animated_item.h" #include "board/abstract_card_item.h" #include "board/arrow_item.h" +#include "board/arrow_registry.h" #include "board/card_item.h" #include "phases_toolbar.h" #include "player/menu/player_menu.h" @@ -40,6 +32,15 @@ #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include #include #include @@ -112,6 +113,15 @@ void GameScene::retranslateUi() } } +QList GameScene::selectedCardStates() const +{ + QList selectedStates; + for (auto *card : selectedCards()) { + selectedStates.append(card->getState()); + } + return selectedStates; +} + QList GameScene::selectedCards() const { QList selectedCards; @@ -124,6 +134,67 @@ QList GameScene::selectedCards() const return selectedCards; } +/** + * @brief Selects every card of @p zone for which @p filter holds. + * @param filter A predicate to filter which cards are selected. + */ +static void selectCardsInZone( + const CardZoneLogic *zone, + std::function filter = [](const CardItem *) { return true; }) +{ + if (!zone) { + return; + } + + for (auto *cardState : zone->getCards()) { + auto *cardItem = cardState == nullptr ? nullptr : qobject_cast(cardState->parent()); + if (cardItem && filter(cardItem)) { + cardItem->setSelected(true); + } + } +} + +void GameScene::selectAllInZone(const CardItem *card) +{ + if (!card) { + return; + } + + selectCardsInZone(card->getZone()); +} + +void GameScene::selectSameRow(const CardItem *card) +{ + if (!card) { + return; + } + + auto isSameRow = [card](const CardItem *cardItem) { + return qAbs(card->scenePos().y() - cardItem->scenePos().y()) < 50; + }; + selectCardsInZone(card->getZone(), isSameRow); +} + +void GameScene::selectSameColumn(const CardItem *card) +{ + if (!card) { + return; + } + + auto isSameColumn = [card](const CardItem *cardItem) { return cardItem->x() == card->x(); }; + selectCardsInZone(card->getZone(), isSameColumn); +} + +CardItem *GameScene::getActiveCard() const +{ + return activeCard; +} + +void GameScene::setActiveCard(CardItem *card) +{ + activeCard = card; +} + void GameScene::onCardSelectionChanged(AbstractCardItem *abstractCard, bool selected) { CardItem *card = qobject_cast(abstractCard); @@ -134,12 +205,12 @@ void GameScene::onCardSelectionChanged(AbstractCardItem *abstractCard, bool sele auto *owner = card->getOwner(); if (selected) { - owner->requestCardMenuUpdate(card); + owner->requestCardMenuUpdate(card->getState()); return; } if (selectedItems().isEmpty()) { - owner->getGame()->setActiveCard(nullptr); + setActiveCard(nullptr); owner->requestCardMenuUpdate(nullptr); } } @@ -158,9 +229,9 @@ void GameScene::onCardRightClicked(AbstractCardItem *abstractCard, QPoint screen return; } - card->getOwner()->getGame()->setActiveCard(card); + setActiveCard(card); - if (auto *menu = view->getPlayerMenu()->updateCardMenu(card)) { + if (auto *menu = view->getPlayerMenu()->updateCardMenu(card->getState())) { menu->popup(screenPos); } } @@ -173,7 +244,7 @@ void GameScene::playSelected(AbstractCardItem *card) if (!card->getOwner()) { return; } - card->getOwner()->getPlayerActions()->actPlay(selectedCards()); + card->getOwner()->getPlayerActions()->actPlay(selectedCardStates()); } void GameScene::playSelectedFaceDown(AbstractCardItem *card) @@ -184,7 +255,7 @@ void GameScene::playSelectedFaceDown(AbstractCardItem *card) if (!card->getOwner()) { return; } - card->getOwner()->getPlayerActions()->actPlayFacedown(selectedCards()); + card->getOwner()->getPlayerActions()->actPlayFacedown(selectedCardStates()); } void GameScene::hideSelected(AbstractCardItem *card) @@ -195,7 +266,7 @@ void GameScene::hideSelected(AbstractCardItem *card) if (!card->getOwner()) { return; } - card->getOwner()->getPlayerActions()->actHide(selectedCards()); + card->getOwner()->getPlayerActions()->actHide(selectedCardStates()); } /** @@ -230,6 +301,8 @@ void GameScene::addPlayer(PlayerLogic *player) [this, id = player->getPlayerInfo()->getId()]() { clearArrowsForPlayerLocally(id); }); connect(player->getPlayerEventHandler(), &PlayerEventHandler::cardZoneChanged, this, &GameScene::onCardZoneChanged); + connect(player->getPlayerEventHandler(), &PlayerEventHandler::cardViewRefreshRequested, this, + &GameScene::onCardViewRefreshRequested); rearrange(); } @@ -533,7 +606,11 @@ void GameScene::addArrow(QSharedPointer data) return; } - CardItem *startCard = startZone->getCard(data->startCardId); + CardState *startCardState = startZone->getCard(data->startCardId); + if (!startCardState) { + return; + } + CardItem *startCard = qobject_cast(startCardState->parent()); if (!startCard) { return; } @@ -544,7 +621,8 @@ void GameScene::addArrow(QSharedPointer data) } else { auto *zone = targetView->getLogic()->getZones().value(data->targetZone); if (zone) { - targetItem = zone->getCard(data->targetCardId); + CardState *targetCardState = zone->getCard(data->targetCardId); + targetItem = targetCardState == nullptr ? nullptr : qobject_cast(targetCardState->parent()); } } if (!targetItem) { @@ -572,11 +650,15 @@ void GameScene::requestArrowDeletion(int playerId, int arrowId) } } -void GameScene::onCardZoneChanged(CardItem *card, bool sameZone) +void GameScene::onCardZoneChanged(CardState *card, bool sameZone) { + auto *item = qobject_cast(card->parent()); + if (!item) { + return; + } QList toDelete; for (auto *arrow : arrowRegistry.all()) { - if (arrow->getStartItem() == card || arrow->getTargetItem() == card) { + if (arrow->getStartItem() == item || arrow->getTargetItem() == item) { if (sameZone) { arrow->updatePath(); } else { @@ -589,6 +671,25 @@ void GameScene::onCardZoneChanged(CardItem *card, bool sameZone) } } +void GameScene::onCardViewRefreshRequested(CardState *card, bool zoneChanged, PlayerLogic *newOwner) +{ + auto *item = qobject_cast(card->parent()); + if (!item) { + return; + } + if (zoneChanged) { + item->deleteCardInfoPopup(); + } + item->deleteDragItem(); + if (zoneChanged) { + item->setBeingPointedAt(false); + item->setHovered(false); + } + if (newOwner) { + item->setOwner(newOwner); + } +} + void GameScene::clearArrowsForPlayer(int playerId) { for (int arrowId : arrowRegistry.idsForPlayer(playerId)) { diff --git a/cockatrice/src/game_graphics/game_scene.h b/cockatrice/src/game_graphics/game_scene.h index c7cae1e76..0d4db70a8 100644 --- a/cockatrice/src/game_graphics/game_scene.h +++ b/cockatrice/src/game_graphics/game_scene.h @@ -1,7 +1,9 @@ #ifndef GAMESCENE_H #define GAMESCENE_H -#include "../game/arrow_registry.h" +#include "animated_item.h" +#include "board/arrow_item.h" +#include "board/arrow_registry.h" #include #include @@ -16,6 +18,8 @@ #include #include #include +#include +#include #include // IWYU pragma: keep @@ -39,6 +43,7 @@ class ZoneViewWidget; class CardZone; class AbstractCardItem; class CardItem; +class CardState; class ServerInfo_Card; class PhasesToolbar; class QBasicTimer; @@ -69,6 +74,7 @@ private: QList zoneViews; ///< Active zone view widgets QSize viewSize; ///< Current view size QPointer hoveredCard; ///< Currently hovered card + QPointer activeCard; ///< Card the currently open context menu targets QBasicTimer *animationTimer; ///< Timer for scene animations QHash animatedItems; ///< Items currently animating QHash @@ -107,6 +113,22 @@ public: /** @brief Gets all selected CardItems. */ QList selectedCards() const; + QList selectedCardStates() const; + + /** @brief Selects every card sharing the zone of @p card. */ + void selectAllInZone(const CardItem *card); + + /** @brief Selects the cards laid out in the same visual row as @p card. */ + void selectSameRow(const CardItem *card); + + /** @brief Selects the cards laid out in the same visual column as @p card. */ + void selectSameColumn(const CardItem *card); + + /** @brief Card the currently open context menu targets, or null. */ + CardItem *getActiveCard() const; + + /** @brief Sets the card the currently open context menu targets. */ + void setActiveCard(CardItem *card); /** * @brief Adds a player to the scene and stores their graphics item. @@ -261,7 +283,15 @@ public slots: /// Queues up arrow deletion but doesn't directly modify the scene void requestArrowDeletion(int playerId, int arrowId); - void onCardZoneChanged(CardItem *card, bool sameZone); + void onCardZoneChanged(CardState *card, bool sameZone); + /** + * @brief Refreshes the view of a card that the server just moved. + * + * @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 onCardViewRefreshRequested(CardState *card, bool zoneChanged, PlayerLogic *newOwner); protected: /** @brief Handles hover updates. */ diff --git a/cockatrice/src/game_graphics/hand_counter.cpp b/cockatrice/src/game_graphics/hand_counter.cpp index 9320c1295..b9ea2d554 100644 --- a/cockatrice/src/game_graphics/hand_counter.cpp +++ b/cockatrice/src/game_graphics/hand_counter.cpp @@ -1,7 +1,5 @@ #include "hand_counter.h" -#include "../game/board/card_list.h" -#include "../game/zones/card_zone_logic.h" #include "../interface/pixel_map_generator.h" #include @@ -14,6 +12,8 @@ #include #include #include +#include +#include #include class QGraphicsItem; diff --git a/cockatrice/src/game_graphics/log/message_log_widget.cpp b/cockatrice/src/game_graphics/log/message_log_widget.cpp index 4a9750fca..046a1ae90 100644 --- a/cockatrice/src/game_graphics/log/message_log_widget.cpp +++ b/cockatrice/src/game_graphics/log/message_log_widget.cpp @@ -3,16 +3,6 @@ #include "../../client/settings/cache_settings.h" #include "../../client/settings/card_counter_settings.h" #include "../../client/sound_engine.h" -#include "../../client/translation.h" -#include "../../game/abstract_game.h" -#include "../../game/board/card_list.h" -#include "../../game/game_state.h" -#include "../../game/phase.h" -#include "../../game/player/player_event_handler.h" -#include "../../game/player/player_info.h" -#include "../../game/player/player_logic.h" -#include "../../game/player/player_manager.h" -#include "../../game/zones/card_zone_logic.h" #include "../../interface/card_localization.h" #include "../../interface/widgets/server/chat_view/chat_view.h" #include "../board/card_item.h" @@ -24,6 +14,16 @@ #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include #include #include @@ -283,7 +283,7 @@ void MessageLogWidget::logDestroyCard(PlayerLogic *player, QString cardName) } void MessageLogWidget::logMoveCard(PlayerLogic *player, - CardItem *card, + CardState *card, CardZoneLogic *startZone, int oldX, CardZoneLogic *targetZone, @@ -662,7 +662,7 @@ void MessageLogWidget::logSetActivePlayer(PlayerLogic *player) QString(tr("%1's turn.")).arg(player->getPlayerInfo()->getName()) + "
"); } -void MessageLogWidget::logSetAnnotation(PlayerLogic *player, CardItem *card, QString newAnnotation) +void MessageLogWidget::logSetAnnotation(PlayerLogic *player, CardState *card, QString newAnnotation) { appendHtmlServerMessage( QString(tr("%1 sets annotation of %2 to %3.")) @@ -706,7 +706,7 @@ void MessageLogWidget::logSetCounter(PlayerLogic *player, QString counterName, i .arg(value - oldValue)); } -void MessageLogWidget::logSetDoesntUntap(PlayerLogic *player, CardItem *card, bool doesntUntap) +void MessageLogWidget::logSetDoesntUntap(PlayerLogic *player, CardState *card, bool doesntUntap) { QString str; if (doesntUntap) { @@ -717,7 +717,7 @@ void MessageLogWidget::logSetDoesntUntap(PlayerLogic *player, CardItem *card, bo appendHtmlServerMessage(str.arg(sanitizeHtml(player->getPlayerInfo()->getName())).arg(cardLink(card->getName()))); } -void MessageLogWidget::logSetPT(PlayerLogic *player, CardItem *card, QString newPT) +void MessageLogWidget::logSetPT(PlayerLogic *player, CardState *card, QString newPT) { if (currentContext == MessageContext_MoveCard) { return; @@ -755,7 +755,7 @@ void MessageLogWidget::logSetSideboardLock(PlayerLogic *player, bool locked) } } -void MessageLogWidget::logSetTapped(PlayerLogic *player, CardItem *card, bool tapped) +void MessageLogWidget::logSetTapped(PlayerLogic *player, CardState *card, bool tapped) { if (currentContext == MessageContext_MoveCard) { return; diff --git a/cockatrice/src/game_graphics/log/message_log_widget.h b/cockatrice/src/game_graphics/log/message_log_widget.h index 6800ba8ed..5642a76e9 100644 --- a/cockatrice/src/game_graphics/log/message_log_widget.h +++ b/cockatrice/src/game_graphics/log/message_log_widget.h @@ -14,6 +14,7 @@ #include #include +class CardState; class AbstractGame; class CardItem; class GameEventContext; @@ -76,7 +77,7 @@ public slots: void logLeaveSpectator(QString name, QString reason); void logNotReadyStart(PlayerLogic *player); void logMoveCard(PlayerLogic *player, - CardItem *card, + CardState *card, CardZoneLogic *startZone, int oldX, CardZoneLogic *targetZone, @@ -97,13 +98,13 @@ public slots: void logSay(PlayerLogic *player, QString message); void logSetActivePhase(int phase); void logSetActivePlayer(PlayerLogic *player); - void logSetAnnotation(PlayerLogic *player, CardItem *card, QString newAnnotation); + void logSetAnnotation(PlayerLogic *player, CardState *card, QString newAnnotation); void logSetCardCounter(PlayerLogic *player, QString cardName, int counterId, int value, int oldValue); 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 logSetDoesntUntap(PlayerLogic *player, CardState *card, bool doesntUntap); + void logSetPT(PlayerLogic *player, CardState *card, QString newPT); void logSetSideboardLock(PlayerLogic *player, bool locked); - void logSetTapped(PlayerLogic *player, CardItem *card, bool tapped); + void logSetTapped(PlayerLogic *player, CardState *card, bool tapped); void logShuffle(PlayerLogic *player, CardZoneLogic *zone, int start, int end); void logSpectatorSay(const ServerInfo_User &spectator, QString message); void logUnattachCard(PlayerLogic *player, QString cardName); diff --git a/cockatrice/src/game_graphics/phases_toolbar.cpp b/cockatrice/src/game_graphics/phases_toolbar.cpp index d0a3f74fd..bc05e8901 100644 --- a/cockatrice/src/game_graphics/phases_toolbar.cpp +++ b/cockatrice/src/game_graphics/phases_toolbar.cpp @@ -14,11 +14,6 @@ #include #include #include -#include -#include -#include -#include -#include #include #include @@ -120,9 +115,9 @@ PhasesToolbar::PhasesToolbar(QGraphicsItem *parent) : QGraphicsItem(parent), width(100), height(100), ySpacing(1), symbolSize(8) { auto *aUntapAll = new QAction(this); - connect(aUntapAll, &QAction::triggered, this, &PhasesToolbar::actUntapAll); + connect(aUntapAll, &QAction::triggered, this, &PhasesToolbar::untapAllRequested); auto *aDrawCard = new QAction(this); - connect(aDrawCard, &QAction::triggered, this, &PhasesToolbar::actDrawCard); + connect(aDrawCard, &QAction::triggered, this, &PhasesToolbar::drawCardRequested); PhaseButton *untapButton = new PhaseButton("untap", this, aUntapAll); PhaseButton *upkeepButton = new PhaseButton("upkeep", this); @@ -144,7 +139,7 @@ PhasesToolbar::PhasesToolbar(QGraphicsItem *parent) } nextTurnButton = new PhaseButton("nextturn", this, nullptr, false); - connect(nextTurnButton, &PhaseButton::clicked, this, &PhasesToolbar::actNextTurn); + connect(nextTurnButton, &PhaseButton::clicked, this, &PhasesToolbar::nextTurnRequested); rearrangeButtons(); @@ -265,31 +260,5 @@ void PhasesToolbar::phaseButtonClicked() button->triggerDoubleClickAction(); } - Command_SetActivePhase cmd; - cmd.set_phase(static_cast(buttonList.indexOf(button))); - - emit sendGameCommand(cmd, -1); -} - -void PhasesToolbar::actNextTurn() -{ - emit sendGameCommand(Command_NextTurn(), -1); -} - -void PhasesToolbar::actUntapAll() -{ - Command_SetCardAttr cmd; - cmd.set_zone(ZoneNames::TABLE); - cmd.set_attribute(AttrTapped); - cmd.set_attr_value("0"); - - emit sendGameCommand(cmd, -1); -} - -void PhasesToolbar::actDrawCard() -{ - Command_DrawCards cmd; - cmd.set_number(1); - - emit sendGameCommand(cmd, -1); + emit setActivePhaseRequested(buttonList.indexOf(button)); } diff --git a/cockatrice/src/game_graphics/phases_toolbar.h b/cockatrice/src/game_graphics/phases_toolbar.h index 376775284..0565f1351 100644 --- a/cockatrice/src/game_graphics/phases_toolbar.h +++ b/cockatrice/src/game_graphics/phases_toolbar.h @@ -17,14 +17,6 @@ class QAction; class QTimer; -namespace google -{ -namespace protobuf -{ -class Message; -} -} // namespace google - class PhaseButton : public QObject, public QGraphicsItem { Q_OBJECT @@ -95,11 +87,16 @@ public slots: void triggerPhaseAction(int phase); private slots: void phaseButtonClicked(); - void actNextTurn(); - void actUntapAll(); - void actDrawCard(); signals: - void sendGameCommand(const ::google::protobuf::Message &command, int playerId); + /** @name Intent signals + * The view reports what the user asked for; GameEventHandler builds the commands. + * @{ + */ + void setActivePhaseRequested(int phase); + void nextTurnRequested(); + void untapAllRequested(); + void drawCardRequested(); + /** @} */ protected: void paint(QPainter *painter, const QStyleOptionGraphicsItem * /*option*/, QWidget * /*widget*/) override; diff --git a/cockatrice/src/game_graphics/player/menu/card_menu.cpp b/cockatrice/src/game_graphics/player/menu/card_menu.cpp index 137ec2ed1..9f00c0cba 100644 --- a/cockatrice/src/game_graphics/player/menu/card_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/card_menu.cpp @@ -3,16 +3,8 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/card_counter_settings.h" #include "../../../client/settings/shortcuts_settings.h" -#include "../../../game/abstract_game.h" -#include "../../../game/player/player_info.h" -#include "../../../game/player/player_manager.h" -#include "../../../game/zones/card_zone_logic.h" #include "../../board/card_item.h" -#include "../../game/player/player_actions.h" -#include "../../game/player/player_logic.h" -#include "../../game/zones/view_zone_logic.h" #include "../../game_scene.h" -#include "../card_menu_action_type.h" #include "../player_graphics_item.h" #include "libcockatrice/card/card_info.h" #include "libcockatrice/card/database/card_database_querier.h" @@ -35,6 +27,14 @@ #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include #include #include #include @@ -67,7 +67,7 @@ static QAction *makeAction(QObject *parent, Slot &&slot, bool checkable = false, return a; } -CardMenu::CardMenu(PlayerGraphicsItem *_player, const CardItem *_card, bool _shortcutsActive) +CardMenu::CardMenu(PlayerGraphicsItem *_player, CardItem *_card, bool _shortcutsActive) : player(_player), card(_card), shortcutsActive(_shortcutsActive) { const QList &players = player->getLogic()->getGame()->getPlayerManager()->getPlayers().values(); @@ -86,7 +86,7 @@ CardMenu::CardMenu(PlayerGraphicsItem *_player, const CardItem *_card, bool _sho auto *gameScene = player->getGameScene(); // Single selection resolver used by all lambdas — called at trigger time - auto sel = [gameScene]() { return gameScene->selectedCards(); }; + auto sel = [gameScene]() { return gameScene->selectedCardStates(); }; // Unified dispatcher for card menu actions auto invoke = [actions, sel](CardMenuActionType type) { @@ -108,18 +108,18 @@ CardMenu::CardMenu(PlayerGraphicsItem *_player, const CardItem *_card, bool _sho aHide = makeAction(this, [actions, sel]() { actions->actHide(sel()); }); aReduceLifeByPower = makeAction(this, [actions, sel]() { actions->actReduceLifeByPower(sel()); }); - // Actions that use activeCard, not selection — direct connection + // Actions bound to the card this menu was opened for, not to the current selection aAttach = new QAction(this); aDrawArrow = new QAction(this); aSelectAll = new QAction(this); aSelectRow = new QAction(this); aSelectColumn = new QAction(this); - connect(aAttach, &QAction::triggered, actions, &PlayerActions::actAttach); - connect(aDrawArrow, &QAction::triggered, actions, &PlayerActions::actDrawArrow); - connect(aSelectAll, &QAction::triggered, actions, &PlayerActions::actSelectAll); - connect(aSelectRow, &QAction::triggered, actions, &PlayerActions::actSelectRow); - connect(aSelectColumn, &QAction::triggered, actions, &PlayerActions::actSelectColumn); + connect(aAttach, &QAction::triggered, this, &CardMenu::attachCard); + connect(aDrawArrow, &QAction::triggered, this, &CardMenu::drawArrow); + connect(aSelectAll, &QAction::triggered, this, [this, gameScene]() { gameScene->selectAllInZone(card); }); + connect(aSelectRow, &QAction::triggered, this, [this, gameScene]() { gameScene->selectSameRow(card); }); + connect(aSelectColumn, &QAction::triggered, this, [this, gameScene]() { gameScene->selectSameColumn(card); }); aRevealToAll = new QAction(this); @@ -337,7 +337,7 @@ void CardMenu::createHandOrCustomZoneMenu(bool canModifyCard) initContextualPlayersMenu(revealMenu, aRevealToAll); connect(revealMenu, &QMenu::triggered, this, [this](QAction *action) { - player->getLogic()->getPlayerActions()->actReveal(player->getGameScene()->selectedCards(), action); + player->getLogic()->getPlayerActions()->actReveal(player->getGameScene()->selectedCardStates(), action); }); addSeparator(); @@ -441,6 +441,7 @@ void CardMenu::addRelatedCardActions() if (relatedCards.isEmpty()) { return; } + auto *playerActions = player->getLogic()->getPlayerActions(); addSeparator(); int index = 0; @@ -484,9 +485,10 @@ void CardMenu::addRelatedCardActions() } auto *createRelated = new QAction(text, this); - createRelated->setData(QVariant(index++)); - connect(createRelated, &QAction::triggered, player->getLogic()->getPlayerActions(), - &PlayerActions::actCreateRelatedCard); + const int relatedIndex = index++; + connect(createRelated, &QAction::triggered, this, [this, playerActions, relatedIndex] { + playerActions->actCreateRelatedCard(card->getState(), relatedIndex); + }); addAction(createRelated); } @@ -495,12 +497,26 @@ void CardMenu::addRelatedCardActions() createRelatedCards->setShortcuts( SettingsCache::instance().shortcuts().getShortcut("Player/aCreateRelatedTokens")); } - connect(createRelatedCards, &QAction::triggered, player->getLogic()->getPlayerActions(), - &PlayerActions::actCreateAllRelatedCards); + connect(createRelatedCards, &QAction::triggered, this, + [this, playerActions] { playerActions->actCreateAllRelatedCards(card->getState()); }); addAction(createRelatedCards); } } +void CardMenu::attachCard() +{ + if (card) { + card->drawAttachArrow(); + } +} + +void CardMenu::drawArrow() +{ + if (card) { + card->drawArrow(Qt::red); + } +} + void CardMenu::retranslateUi() { aSelectAll->setText(tr("&Select All")); diff --git a/cockatrice/src/game_graphics/player/menu/card_menu.h b/cockatrice/src/game_graphics/player/menu/card_menu.h index 337d77b3e..2c80483b1 100644 --- a/cockatrice/src/game_graphics/player/menu/card_menu.h +++ b/cockatrice/src/game_graphics/player/menu/card_menu.h @@ -27,7 +27,7 @@ signals: void cardInfoRequested(const CardRef &cardRef); public: - explicit CardMenu(PlayerGraphicsItem *player, const CardItem *card, bool shortcutsActive); + explicit CardMenu(PlayerGraphicsItem *player, CardItem *card, bool shortcutsActive); void removePlayer(PlayerLogic *playerToRemove); void createTableMenu(bool canModifyCard); void createStackMenu(bool canModifyCard); @@ -53,11 +53,13 @@ public: private: PlayerGraphicsItem *player; - const CardItem *card; + CardItem *card; QList> playersInfo; bool shortcutsActive; void addRelatedCardActions(); + void attachCard(); + void drawArrow(); void retranslateUi(); void initContextualPlayersMenu(QMenu *menu, QAction *allPlayersAction); void setShortcutsActive(); diff --git a/cockatrice/src/game_graphics/player/menu/custom_zone_menu.cpp b/cockatrice/src/game_graphics/player/menu/custom_zone_menu.cpp index e16ebc112..87a0d1540 100644 --- a/cockatrice/src/game_graphics/player/menu/custom_zone_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/custom_zone_menu.cpp @@ -1,13 +1,13 @@ #include "custom_zone_menu.h" -#include "../../../game/player/player_info.h" -#include "../../game/player/player_logic.h" #include "../../game_scene.h" #include "../player_graphics_item.h" #include #include #include +#include +#include CustomZoneMenu::CustomZoneMenu(PlayerGraphicsItem *_player) : player(_player) { diff --git a/cockatrice/src/game_graphics/player/menu/grave_menu.cpp b/cockatrice/src/game_graphics/player/menu/grave_menu.cpp index 580d2642e..001a4d0cf 100644 --- a/cockatrice/src/game_graphics/player/menu/grave_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/grave_menu.cpp @@ -2,13 +2,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" -#include "../../../game/player/player_info.h" -#include "../../../game/player/player_manager.h" -#include "../../../game/zones/pile_zone_logic.h" #include "../../../interface/widgets/menus/tearoff_menu.h" -#include "../../game/abstract_game.h" -#include "../../game/player/player_actions.h" -#include "../../game/player/player_logic.h" #include "../player_graphics_item.h" #include @@ -19,6 +13,12 @@ #include #include #include +#include +#include +#include +#include +#include +#include #include class QWidget; diff --git a/cockatrice/src/game_graphics/player/menu/hand_menu.cpp b/cockatrice/src/game_graphics/player/menu/hand_menu.cpp index 141542cb4..e992f3ff5 100644 --- a/cockatrice/src/game_graphics/player/menu/hand_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/hand_menu.cpp @@ -2,14 +2,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" -#include "../../../game/board/card_list.h" -#include "../../../game/player/player_info.h" -#include "../../../game/player/player_manager.h" -#include "../../../game/zones/hand_zone_logic.h" #include "../../../interface/widgets/menus/tearoff_menu.h" -#include "../../game/abstract_game.h" -#include "../../game/player/player_actions.h" -#include "../../game/player/player_logic.h" #include "../player_graphics_item.h" #include @@ -20,6 +13,13 @@ #include #include #include +#include +#include +#include +#include +#include +#include +#include #include class QWidget; diff --git a/cockatrice/src/game_graphics/player/menu/library_menu.cpp b/cockatrice/src/game_graphics/player/menu/library_menu.cpp index aa389006e..5f90c3760 100644 --- a/cockatrice/src/game_graphics/player/menu/library_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/library_menu.cpp @@ -2,14 +2,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" -#include "../../../game/board/card_list.h" -#include "../../../game/player/player_info.h" -#include "../../../game/player/player_manager.h" -#include "../../../game/zones/pile_zone_logic.h" #include "../../../interface/widgets/menus/tearoff_menu.h" -#include "../../game/abstract_game.h" -#include "../../game/player/player_actions.h" -#include "../../game/player/player_logic.h" #include "../player_graphics_item.h" #include @@ -23,6 +16,13 @@ #include #include #include +#include +#include +#include +#include +#include +#include +#include class QWidget; diff --git a/cockatrice/src/game_graphics/player/menu/move_menu.cpp b/cockatrice/src/game_graphics/player/menu/move_menu.cpp index 2991100ec..b81c4619b 100644 --- a/cockatrice/src/game_graphics/player/menu/move_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/move_menu.cpp @@ -2,13 +2,13 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" -#include "../../game/player/player_actions.h" -#include "../../game/player/player_logic.h" #include "../../game_scene.h" -#include "../card_menu_action_type.h" #include "../player_graphics_item.h" #include +#include +#include +#include MoveMenu::MoveMenu(PlayerGraphicsItem *player) : QMenu(tr("Move to")) { @@ -30,7 +30,7 @@ MoveMenu::MoveMenu(PlayerGraphicsItem *player) : QMenu(tr("Move to")) auto invoke = [player](CardMenuActionType type) { return [type, player]() { - player->getLogic()->getPlayerActions()->cardMenuAction(player->getGameScene()->selectedCards(), type); + player->getLogic()->getPlayerActions()->cardMenuAction(player->getGameScene()->selectedCardStates(), type); }; }; diff --git a/cockatrice/src/game_graphics/player/menu/player_menu.cpp b/cockatrice/src/game_graphics/player/menu/player_menu.cpp index 44b184cfe..582586ac2 100644 --- a/cockatrice/src/game_graphics/player/menu/player_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/player_menu.cpp @@ -2,10 +2,6 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" -#include "../../../game/abstract_game.h" -#include "../../../game/player/player_info.h" -#include "../../../game/player/player_logic.h" -#include "../../../game/player/player_manager.h" #include "../../../game_graphics/zones/hand_zone.h" #include "../../../game_graphics/zones/pile_zone.h" #include "../../../game_graphics/zones/table_zone.h" @@ -26,6 +22,10 @@ #include #include +#include +#include +#include +#include PlayerMenu::PlayerMenu(PlayerGraphicsItem *_player) : QObject(_player), player(_player) { @@ -87,22 +87,24 @@ void PlayerMenu::setMenusForGraphicItems() } } -QMenu *PlayerMenu::updateCardMenu(const CardItem *card) +QMenu *PlayerMenu::updateCardMenu(CardState *card) { if (!card) { emit cardMenuUpdated(nullptr); return nullptr; } + auto *cardItem = qobject_cast(card->parent()); + // If is spectator (as spectators don't need card menus), return // only update the menu if the card is actually selected if ((player->getLogic()->getGame()->getPlayerManager()->isSpectator() && !player->getLogic()->getGame()->getPlayerManager()->isJudge()) || - player->getLogic()->getGame()->getActiveCard() != card) { + cardItem == nullptr || player->getGameScene()->getActiveCard() != cardItem) { return nullptr; } - CardMenu *menu = new CardMenu(player, card, shortcutsActive); + CardMenu *menu = new CardMenu(player, cardItem, shortcutsActive); connect(menu, &CardMenu::cardInfoRequested, this, &PlayerMenu::cardInfoRequested); emit cardMenuUpdated(menu); diff --git a/cockatrice/src/game_graphics/player/menu/player_menu.h b/cockatrice/src/game_graphics/player/menu/player_menu.h index 97dd2fc56..e21a689de 100644 --- a/cockatrice/src/game_graphics/player/menu/player_menu.h +++ b/cockatrice/src/game_graphics/player/menu/player_menu.h @@ -14,7 +14,7 @@ #include #include -class CardItem; +class CardState; class CardMenu; class PlayerGraphicsItem; class AbstractPlayerComponent; @@ -43,7 +43,7 @@ signals: public slots: void setMenusForGraphicItems(); - QMenu *updateCardMenu(const CardItem *card); + QMenu *updateCardMenu(CardState *card); private slots: void refreshShortcuts(); diff --git a/cockatrice/src/game_graphics/player/menu/pt_menu.cpp b/cockatrice/src/game_graphics/player/menu/pt_menu.cpp index b4f852b43..e6c65fcfb 100644 --- a/cockatrice/src/game_graphics/player/menu/pt_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/pt_menu.cpp @@ -2,12 +2,12 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" -#include "../../game/player/player_actions.h" -#include "../../game/player/player_logic.h" #include "../../game_scene.h" #include "../player_graphics_item.h" #include +#include +#include PtMenu::PtMenu(PlayerGraphicsItem *player) : QMenu(tr("Power / toughness")) { @@ -15,34 +15,35 @@ PtMenu::PtMenu(PlayerGraphicsItem *player) : QMenu(tr("Power / toughness")) aIncP = new QAction(this); connect(aIncP, &QAction::triggered, playerActions, - [player, playerActions] { playerActions->actIncP(player->getGameScene()->selectedCards()); }); + [player, playerActions] { playerActions->actIncP(player->getGameScene()->selectedCardStates()); }); aDecP = new QAction(this); connect(aDecP, &QAction::triggered, playerActions, - [player, playerActions] { playerActions->actDecP(player->getGameScene()->selectedCards()); }); + [player, playerActions] { playerActions->actDecP(player->getGameScene()->selectedCardStates()); }); aIncT = new QAction(this); connect(aIncT, &QAction::triggered, playerActions, - [player, playerActions] { playerActions->actIncT(player->getGameScene()->selectedCards()); }); + [player, playerActions] { playerActions->actIncT(player->getGameScene()->selectedCardStates()); }); aDecT = new QAction(this); connect(aDecT, &QAction::triggered, playerActions, - [player, playerActions] { playerActions->actDecT(player->getGameScene()->selectedCards()); }); + [player, playerActions] { playerActions->actDecT(player->getGameScene()->selectedCardStates()); }); aIncPT = new QAction(this); connect(aIncPT, &QAction::triggered, playerActions, - [player, playerActions] { playerActions->actIncPT(player->getGameScene()->selectedCards()); }); + [player, playerActions] { playerActions->actIncPT(player->getGameScene()->selectedCardStates()); }); aDecPT = new QAction(this); connect(aDecPT, &QAction::triggered, playerActions, - [player, playerActions] { playerActions->actDecPT(player->getGameScene()->selectedCards()); }); + [player, playerActions] { playerActions->actDecPT(player->getGameScene()->selectedCardStates()); }); aFlowP = new QAction(this); connect(aFlowP, &QAction::triggered, playerActions, - [player, playerActions] { playerActions->actFlowP(player->getGameScene()->selectedCards()); }); + [player, playerActions] { playerActions->actFlowP(player->getGameScene()->selectedCardStates()); }); aFlowT = new QAction(this); connect(aFlowT, &QAction::triggered, playerActions, - [player, playerActions] { playerActions->actFlowT(player->getGameScene()->selectedCards()); }); + [player, playerActions] { playerActions->actFlowT(player->getGameScene()->selectedCardStates()); }); aSetPT = new QAction(this); - connect(aSetPT, &QAction::triggered, playerActions, - [player, playerActions] { playerActions->actRequestSetPTDialog(player->getGameScene()->selectedCards()); }); + connect(aSetPT, &QAction::triggered, playerActions, [player, playerActions] { + playerActions->actRequestSetPTDialog(player->getGameScene()->selectedCardStates()); + }); aResetPT = new QAction(this); connect(aResetPT, &QAction::triggered, playerActions, - [player, playerActions] { playerActions->actResetPT(player->getGameScene()->selectedCards()); }); + [player, playerActions] { playerActions->actResetPT(player->getGameScene()->selectedCardStates()); }); addAction(aIncP); addAction(aDecP); diff --git a/cockatrice/src/game_graphics/player/menu/rfg_menu.cpp b/cockatrice/src/game_graphics/player/menu/rfg_menu.cpp index 98514280f..415d4a2ef 100644 --- a/cockatrice/src/game_graphics/player/menu/rfg_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/rfg_menu.cpp @@ -1,10 +1,6 @@ #include "rfg_menu.h" -#include "../../../game/player/player_info.h" -#include "../../../game/zones/pile_zone_logic.h" #include "../../../interface/widgets/menus/tearoff_menu.h" -#include "../../game/player/player_actions.h" -#include "../../game/player/player_logic.h" #include "../player_graphics_item.h" #include @@ -12,6 +8,10 @@ #include #include #include +#include +#include +#include +#include #include class QWidget; diff --git a/cockatrice/src/game_graphics/player/menu/say_menu.cpp b/cockatrice/src/game_graphics/player/menu/say_menu.cpp index d467c6469..486508da9 100644 --- a/cockatrice/src/game_graphics/player/menu/say_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/say_menu.cpp @@ -1,14 +1,14 @@ #include "say_menu.h" #include "../../../client/settings/cache_settings.h" -#include "../../game/player/player_actions.h" -#include "../../game/player/player_logic.h" #include "../player_graphics_item.h" #include #include #include #include +#include +#include #include SayMenu::SayMenu(PlayerGraphicsItem *_player) : player(_player) diff --git a/cockatrice/src/game_graphics/player/menu/sideboard_menu.cpp b/cockatrice/src/game_graphics/player/menu/sideboard_menu.cpp index 1eef3604f..e601a0e09 100644 --- a/cockatrice/src/game_graphics/player/menu/sideboard_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/sideboard_menu.cpp @@ -2,13 +2,13 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" -#include "../../../game/player/player_info.h" -#include "../../game/player/player_actions.h" -#include "../../game/player/player_logic.h" #include "../player_graphics_item.h" #include #include +#include +#include +#include SideboardMenu::SideboardMenu(PlayerGraphicsItem *player, QMenu *playerMenu) : QMenu(playerMenu) { diff --git a/cockatrice/src/game_graphics/player/menu/utility_menu.cpp b/cockatrice/src/game_graphics/player/menu/utility_menu.cpp index 7c18b3f4c..3b8affff5 100644 --- a/cockatrice/src/game_graphics/player/menu/utility_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/utility_menu.cpp @@ -2,9 +2,6 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" -#include "../../../game/player/player_info.h" -#include "../../game/player/player_actions.h" -#include "../../game/player/player_logic.h" #include "../../game_scene.h" #include "../player_graphics_item.h" #include "libcockatrice/card/card_info.h" @@ -14,6 +11,9 @@ #include #include #include +#include +#include +#include UtilityMenu::UtilityMenu(PlayerGraphicsItem *_player, QMenu *playerMenu) : QMenu(playerMenu), player(_player) { @@ -44,7 +44,7 @@ UtilityMenu::UtilityMenu(PlayerGraphicsItem *_player, QMenu *playerMenu) : QMenu aIncrementAllCardCounters = new QAction(this); connect(aIncrementAllCardCounters, &QAction::triggered, playerActions, [this]() { player->getLogic()->getPlayerActions()->actIncrementAllCardCounters( - player->getGameScene()->selectedCards()); + player->getGameScene()->selectedCardStates()); }); createPredefinedTokenMenu = new QMenu(QString()); diff --git a/cockatrice/src/game_graphics/player/player_dialogs.cpp b/cockatrice/src/game_graphics/player/player_dialogs.cpp index 74f5de601..a5baa8ca8 100644 --- a/cockatrice/src/game_graphics/player/player_dialogs.cpp +++ b/cockatrice/src/game_graphics/player/player_dialogs.cpp @@ -2,11 +2,10 @@ #include "../../client/settings/cache_settings.h" #include "../../client/settings/card_counter_settings.h" -#include "../../game/player/player_actions.h" -#include "../../game/player/player_logic.h" #include "../../interface/widgets/utility/get_text_with_max.h" #include "../board/abstract_counter.h" #include "../board/card_item.h" +#include "../dialogs/dlg_annotation.h" #include "../dialogs/dlg_create_token.h" #include "../dialogs/dlg_move_top_cards_until.h" #include "../dialogs/dlg_roll_dice.h" @@ -17,6 +16,8 @@ #include #include #include +#include +#include #include PlayerDialogs::PlayerDialogs(PlayerGraphicsItem *_player, PlayerActions *_playerActions) @@ -34,7 +35,7 @@ PlayerDialogs::PlayerDialogs(PlayerGraphicsItem *_player, PlayerActions *_player &PlayerDialogs::onShuffleBottomDialogRequested); connect(playerActions, &PlayerActions::requestMulliganDialog, this, &PlayerDialogs::onMulliganDialogRequested); - + connect(playerActions, &PlayerActions::startingHandSizeChosen, this, &PlayerDialogs::onStartingHandSizeChosen); connect(playerActions, &PlayerActions::requestDrawCardsDialog, this, &PlayerDialogs::onDrawCardsDialogRequested); connect(playerActions, &PlayerActions::requestMoveTopCardsToDialog, this, @@ -126,6 +127,11 @@ void PlayerDialogs::onMulliganDialogRequested(int startSize, int handSize, int d } } +void PlayerDialogs::onStartingHandSizeChosen(int number) +{ + SettingsCache::instance().userInterface().setStartingHandSize(number); +} + void PlayerDialogs::onDrawCardsDialogRequested(int defaultNumberTopCards, int deckSize) { bool ok; @@ -196,7 +202,7 @@ void PlayerDialogs::onRollDieDialogRequested() playerActions->actRollDie(dlg.getDieSideCount(), dlg.getDiceToRollCount()); } -void PlayerDialogs::onCreateRelatedFromRelationDialogRequested(const CardItem *sourceCard, +void PlayerDialogs::onCreateRelatedFromRelationDialogRequested(const CardState *sourceCard, const CardRelation *cardRelation) { if (sourceCard == nullptr || cardRelation == nullptr) { @@ -251,14 +257,14 @@ void PlayerDialogs::onMoveCardXCardsFromTopDialogRequested(int defaultNumberTopC number -= 1; // indexes start at 0 if (ok) { - playerActions->actMoveCardXCardsFromTop(player->getGameScene()->selectedCards(), number); + playerActions->actMoveCardXCardsFromTop(player->getGameScene()->selectedCardStates(), number); } } void PlayerDialogs::onSetPTDialogRequested(const QString &oldPT) { bool ok; - auto cards = player->getGameScene()->selectedCards(); + auto cards = player->getGameScene()->selectedCardStates(); emit requestDialogSemaphore(true); QString pt = getTextWithMax(dialogParent(), tr("Change power/toughness"), tr("Change stats to:"), QLineEdit::Normal, oldPT, &ok); @@ -273,7 +279,7 @@ void PlayerDialogs::onSetPTDialogRequested(const QString &oldPT) void PlayerDialogs::onSetAnnotationDialogRequested(const QString &oldAnnotation) { - auto cards = player->getGameScene()->selectedCards(); + auto cards = player->getGameScene()->selectedCardStates(); emit requestDialogSemaphore(true); AnnotationDialog *dialog = new AnnotationDialog(dialogParent()); dialog->setOptions(QInputDialog::UsePlainTextEditForTextInput); @@ -291,7 +297,7 @@ void PlayerDialogs::onSetAnnotationDialogRequested(const QString &oldAnnotation) void PlayerDialogs::onSetCardCounterDialogRequested(int counterId, const QString &oldValueForDlg) { - auto cards = player->getGameScene()->selectedCards(); + auto cards = player->getGameScene()->selectedCardStates(); emit requestDialogSemaphore(true); auto &cardCounterSettings = SettingsCache::instance().cardCounters(); diff --git a/cockatrice/src/game_graphics/player/player_dialogs.h b/cockatrice/src/game_graphics/player/player_dialogs.h index 86d7282a2..4ac443f3c 100644 --- a/cockatrice/src/game_graphics/player/player_dialogs.h +++ b/cockatrice/src/game_graphics/player/player_dialogs.h @@ -17,6 +17,7 @@ class CardItem; class CardRelation; +class CardState; class PlayerActions; class QWidget; struct MoveTopCardsUntilOptions; @@ -52,12 +53,13 @@ public slots: bool faceDown); void onDrawBottomCardsDialogRequested(int defaultNumberBottomCards, int maxCards); void onRollDieDialogRequested(); - void onCreateRelatedFromRelationDialogRequested(const CardItem *sourceCard, const CardRelation *cardRelation); + void onCreateRelatedFromRelationDialogRequested(const CardState *sourceCard, const CardRelation *cardRelation); void onCreateTokenDialogRequested(const QStringList &predefinedTokens); void onMoveCardXCardsFromTopDialogRequested(int defaultNumberTopCardsToPlaceBelow, int deckSize); void onSetPTDialogRequested(const QString &oldPT); void onSetAnnotationDialogRequested(const QString &oldAnnotation); void onSetCardCounterDialogRequested(int counterId, const QString &oldValueForDlg); + void onStartingHandSizeChosen(int number); private: PlayerGraphicsItem *player; diff --git a/cockatrice/src/game_graphics/player/player_graphics_item.cpp b/cockatrice/src/game_graphics/player/player_graphics_item.cpp index 419816087..0805dbb5b 100644 --- a/cockatrice/src/game_graphics/player/player_graphics_item.cpp +++ b/cockatrice/src/game_graphics/player/player_graphics_item.cpp @@ -1,15 +1,6 @@ #include "player_graphics_item.h" #include "../../client/settings/cache_settings.h" -#include "../../game/board/counter_state.h" -#include "../../game/player/player_actions.h" -#include "../../game/player/player_info.h" -#include "../../game/player/player_logic.h" -#include "../../game/zones/card_zone_logic.h" -#include "../../game/zones/hand_zone_logic.h" -#include "../../game/zones/pile_zone_logic.h" -#include "../../game/zones/stack_zone_logic.h" -#include "../../game/zones/table_zone_logic.h" #include "../../interface/card_picture_loader/card_picture_loader.h" #include "../../interface/widgets/cards/art_crop_attribution.h" #include "../../interface/widgets/cards/card_art_utils.h" @@ -45,6 +36,15 @@ #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include #include #include @@ -177,6 +177,22 @@ void PlayerGraphicsItem::initializeZones() void PlayerGraphicsItem::onCustomZoneAdded(QString customZoneName) { + // Custom zones have no board view, but their cards still need a graphics owner. + // Without one, PileZone::onCardAdded never runs and cards moved from the table + // or hand into a custom zone stay drawn at their old spot. Give them a hidden + // pile zone so added cards are reparented to (0, 0) and hidden. + CardZoneLogic *logic = player->getZone(customZoneName); + if (auto *pileLogic = qobject_cast(logic)) { + auto *hiddenZone = new PileZone(pileLogic, this); + hiddenZone->setVisible(false); + hiddenZone->setPos(0, 0); + zoneGraphicsItems.insert(customZoneName, hiddenZone); + // Hide any cards that arrived before the graphics item existed. + for (CardState *state : pileLogic->getCards()) { + hiddenZone->onCardAdded(state); + } + return; + } zoneGraphicsItems.insert(customZoneName, nullptr); // Custom zone view goes here, if we ever implement it. } diff --git a/cockatrice/src/game_graphics/player/player_list_widget.cpp b/cockatrice/src/game_graphics/player/player_list_widget.cpp index 7b358010f..6895e7db8 100644 --- a/cockatrice/src/game_graphics/player/player_list_widget.cpp +++ b/cockatrice/src/game_graphics/player/player_list_widget.cpp @@ -1,6 +1,7 @@ #include "player_list_widget.h" #include "../../interface/pixel_map_generator.h" +#include "../../interface/theme_icon.h" #include "../../interface/widgets/server/user/user_context_menu.h" #include "libcockatrice/protocol/pb/serverinfo_user.pb.h" #include "user_level.h" @@ -67,13 +68,13 @@ PlayerListWidget::PlayerListWidget(TabSupervisor *_tabSupervisor, QWidget *parent) : QTreeWidget(parent), tabSupervisor(_tabSupervisor), client(_client), game(_game), gameStarted(false) { - readyIcon = themePixmap(QStringLiteral("icons/ready_start")); - notReadyIcon = themePixmap(QStringLiteral("icons/not_ready_start")); - concededIcon = themePixmap(QStringLiteral("icons/conceded")); - playerIcon = loadColorAdjustedPixmap("theme:icons/player"); - judgeIcon = loadColorAdjustedPixmap("theme:icons/scales"); - spectatorIcon = loadColorAdjustedPixmap("theme:icons/spectator"); - lockIcon = themePixmap(QStringLiteral("icons/lock")); + readyIcon = loadThemedIcon(QStringLiteral("icons/ready_start")); + notReadyIcon = loadThemedIcon(QStringLiteral("icons/not_ready_start")); + concededIcon = loadThemedIcon(QStringLiteral("icons/conceded")); + playerIcon = loadThemedIcon(QStringLiteral("icons/player")); + judgeIcon = loadThemedIcon(QStringLiteral("icons/scales")); + spectatorIcon = loadThemedIcon(QStringLiteral("icons/spectator")); + lockIcon = loadThemedIcon(QStringLiteral("icons/lock")); if (tabSupervisor) { itemDelegate = new PlayerListItemDelegate(this); diff --git a/cockatrice/src/game_graphics/player/player_target.cpp b/cockatrice/src/game_graphics/player/player_target.cpp index 24734cd42..1cd97e7b5 100644 --- a/cockatrice/src/game_graphics/player/player_target.cpp +++ b/cockatrice/src/game_graphics/player/player_target.cpp @@ -1,8 +1,6 @@ #include "player_target.h" #include "../../client/settings/cache_settings.h" -#include "../../game/player/player_info.h" -#include "../../game/player/player_logic.h" #include "../../interface/pixel_map_generator.h" #include "../board/abstract_counter.h" #include "../board/abstract_graphics_item.h" @@ -30,6 +28,8 @@ #include #include #include +#include +#include #include #include #include diff --git a/cockatrice/src/game_graphics/tally/stats_tally.cpp b/cockatrice/src/game_graphics/tally/stats_tally.cpp index 28e280c02..2bce9fdfd 100644 --- a/cockatrice/src/game_graphics/tally/stats_tally.cpp +++ b/cockatrice/src/game_graphics/tally/stats_tally.cpp @@ -16,7 +16,7 @@ static int sumPowers(const QList &cards) // calculate total power; int total = 0; for (auto card : cards) { - 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); @@ -44,7 +44,7 @@ static int sumToughness(const QList &cards) { int total = 0; for (auto card : cards) { - QVariantList parsed = CardItem::parsePT(card->getPT()); + QVariantList parsed = CardState::parsePT(card->getPT()); if (parsed.size() == 2) { int toughness = parsed.at(1).toInt(); // toInt will default to 0 if it's not an int total += qMax(toughness, 0); diff --git a/cockatrice/src/game_graphics/zones/card_zone.cpp b/cockatrice/src/game_graphics/zones/card_zone.cpp index d09bb588c..fd3b5849f 100644 --- a/cockatrice/src/game_graphics/zones/card_zone.cpp +++ b/cockatrice/src/game_graphics/zones/card_zone.cpp @@ -1,13 +1,13 @@ #include "card_zone.h" -#include "../../game/board/card_list.h" -#include "../../game/zones/card_zone_logic.h" #include "../board/abstract_graphics_item.h" #include "../board/card_item.h" #include #include #include +#include +#include #include class QGraphicsItem; @@ -18,23 +18,60 @@ CardZone::CardZone(CardZoneLogic *_logic, QGraphicsItem *parent) : AbstractGraphicsItem(parent), menu(nullptr), doubleClickAction(0), logic(_logic) { connect(logic, &CardZoneLogic::retranslateUi, this, &CardZone::retranslateUi); + // Invalidation is connected before any consumer so a handler that reads cardItems() + // during the same signal emission always sees a stale flag and rebuilds. + connect(logic, &CardZoneLogic::cardAdded, this, &CardZone::invalidateItems); + connect(logic, &CardZoneLogic::cardCountChanged, this, &CardZone::invalidateItems); + connect(logic, &CardZoneLogic::reorganizeCards, this, &CardZone::invalidateItems); connect(logic, &CardZoneLogic::cardAdded, this, &CardZone::onCardAdded); + connect(logic, &CardZoneLogic::createCardRequested, this, &CardZone::onCreateCardRequested); connect(logic, &CardZoneLogic::setGraphicsVisibility, this, [this](bool v) { this->setVisible(v); }); connect(logic, &CardZoneLogic::updateGraphics, this, [this]() { update(); }); connect(logic, &CardZoneLogic::reorganizeCards, this, &CardZone::reorganizeCards); } -void CardZone::onCardAdded(CardItem *addedCard) +void CardZone::invalidateItems() { - addedCard->setParentItem(this); - addedCard->setVisible(true); - addedCard->update(); + itemsDirty = true; +} + +const QList &CardZone::cardItems() const +{ + if (itemsDirty) { + items.clear(); + for (auto *cardState : getLogic()->getCards()) { + if (auto *cardItem = qobject_cast(cardState->parent())) { + items.append(cardItem); + } + } + itemsDirty = false; + } + return items; +} + +void CardZone::onCardAdded(CardState *addedCard) +{ + auto *addedItem = addedCard == nullptr ? nullptr : qobject_cast(addedCard->parent()); + if (addedItem == nullptr) { + return; + } + addedItem->setParentItem(this); + addedItem->setVisible(true); + addedItem->update(); +} + +void CardZone::onCreateCardRequested(const ServerInfo_Card &cardInfo, bool reorganize) +{ + CardRef cardRef = {QString::fromStdString(cardInfo.name()), QString::fromStdString(cardInfo.provider_id())}; + auto *card = new CardItem(getLogic()->getPlayer(), nullptr, cardRef, cardInfo.id()); + card->processCardInfo(cardInfo); + getLogic()->addCard(card->getState(), reorganize, cardInfo.x(), cardInfo.y()); } void CardZone::retranslateUi() { - for (int i = 0; i < getLogic()->getCards().size(); ++i) { - getLogic()->getCards()[i]->retranslateUi(); + for (auto *card : cardItems()) { + card->retranslateUi(); } } diff --git a/cockatrice/src/game_graphics/zones/card_zone.h b/cockatrice/src/game_graphics/zones/card_zone.h index def3eee14..ea77cb057 100644 --- a/cockatrice/src/game_graphics/zones/card_zone.h +++ b/cockatrice/src/game_graphics/zones/card_zone.h @@ -16,13 +16,17 @@ class CardDragItem; class CardItem; +class CardState; class CardZoneLogic; class QAction; class QGraphicsItem; class QMenu; class QPoint; +class ServerInfo_Card; template class QList; +class CardItem; + inline Q_LOGGING_CATEGORY(CardZoneLog, "card_zone"); /** @@ -55,7 +59,9 @@ public slots: * Virtual so subclasses (e.g. SelectZone) can override parenting behavior — the Qt signal * connection in CardZone's constructor dispatches through the vtable. */ - virtual void onCardAdded(CardItem *addedCard); + virtual void onCardAdded(CardState *addedCard); + /** @brief Builds the card item for @p cardInfo and hands it to this zone's logic. */ + void onCreateCardRequested(const ServerInfo_Card &cardInfo, bool reorganize); public: enum @@ -76,6 +82,15 @@ public: return logic; } + /** + * @brief The CardItems backing getLogic()->getCards(), in zone order. + * + * Game logic talks about cards as states; this is the graphics side's view of the same list. + * It is rebuilt on demand after any content change, so views never reach into the logic's + * container themselves and never observe a stale one. + */ + [[nodiscard]] const QList &cardItems() const; + void setMenu(QMenu *_menu, QAction *_doubleClickAction = 0) { menu = _menu; @@ -83,7 +98,12 @@ public: } private: + /** @brief Marks the cached card items stale. Connected to every content-changing signal. */ + void invalidateItems(); + CardZoneLogic *logic; + mutable QList items; + mutable bool itemsDirty = true; }; #endif diff --git a/cockatrice/src/game_graphics/zones/hand_zone.cpp b/cockatrice/src/game_graphics/zones/hand_zone.cpp index 3e527c081..31758c455 100644 --- a/cockatrice/src/game_graphics/zones/hand_zone.cpp +++ b/cockatrice/src/game_graphics/zones/hand_zone.cpp @@ -1,11 +1,6 @@ #include "hand_zone.h" #include "../../client/settings/cache_settings.h" -#include "../../game/player/player_actions.h" -#include "../../game/player/player_info.h" -#include "../../game/player/player_logic.h" -#include "../../game/zones/card_zone_logic.h" -#include "../../game/zones/hand_zone_logic.h" #include "../../interface/theme_manager.h" #include "../board/card_drag_item.h" #include "../board/card_item.h" @@ -18,7 +13,11 @@ #include #include #include -#include +#include +#include +#include +#include +#include #include #include @@ -48,8 +47,8 @@ void HandZone::handleDropEvent(const QList &dragItems, QPoint point = dropPoint + scenePos().toPoint(); int x = -1; if (SettingsCache::instance().userInterface().getHorizontalHand()) { - for (x = 0; x < getLogic()->getCards().size(); x++) { - if (point.x() < static_cast(getLogic()->getCards().at(x))->scenePos().x()) { + for (x = 0; x < cardItems().size(); x++) { + if (point.x() < cardItems().at(x)->scenePos().x()) { break; } } @@ -58,19 +57,13 @@ void HandZone::handleDropEvent(const QList &dragItems, x = calcDropIndexFromY(dropPoint.y(), !sameZone); } - Command_MoveCard cmd; - cmd.set_start_player_id(startZone->getPlayer()->getPlayerInfo()->getId()); - cmd.set_start_zone(startZone->getName().toStdString()); - cmd.set_target_player_id(getLogic()->getPlayer()->getPlayerInfo()->getId()); - cmd.set_target_zone(getLogic()->getName().toStdString()); - cmd.set_x(x); - cmd.set_y(-1); - - for (int i = 0; i < dragItems.size(); ++i) { - cmd.mutable_cards_to_move()->add_card()->set_card_id(dragItems[i]->getId()); + QList cards; + cards.reserve(dragItems.size()); + for (const CardDragItem *item : dragItems) { + cards.append(CardMoveRequest{item->getId()}); } - getLogic()->getPlayer()->getPlayerActions()->sendGameCommand(cmd); + getLogic()->getPlayer()->getPlayerActions()->moveCards(startZone, getLogic(), x, -1, cards); } QRectF HandZone::boundingRect() const @@ -90,23 +83,23 @@ void HandZone::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*optio void HandZone::reorganizeCards() { - if (!getLogic()->getCards().isEmpty()) { - const int cardCount = getLogic()->getCards().size(); + if (!cardItems().isEmpty()) { + const int cardCount = cardItems().size(); if (SettingsCache::instance().userInterface().getHorizontalHand()) { bool leftJustified = SettingsCache::instance().userInterface().getLeftJustified(); - qreal cardWidth = getLogic()->getCards().at(0)->boundingRect().width(); + qreal cardWidth = cardItems().at(0)->boundingRect().width(); const int xPadding = leftJustified ? cardWidth * 1.4 : 5; qreal totalWidth = leftJustified ? boundingRect().width() - (1 * xPadding) - 5 : boundingRect().width() - 2 * xPadding; if (cardCount == 1) { - CardItem *c = getLogic()->getCards().at(0); + CardItem *c = cardItems().at(0); qreal xPosition = leftJustified ? xPadding : xPadding + (totalWidth - cardWidth) / 2; c->setPos(xPosition, 5); c->setRealZValue(0); } else { for (int i = 0; i < cardCount; i++) { - CardItem *c = getLogic()->getCards().at(i); + CardItem *c = cardItems().at(i); // If the total width of the cards is smaller than the available width, // the cards do not need to overlap and are displayed in the center of the area. if (cardWidth * cardCount > totalWidth) { @@ -131,7 +124,7 @@ void HandZone::reorganizeCards() void HandZone::sortHand(const QList &options) { - if (getLogic()->getCards().isEmpty()) { + if (cardItems().isEmpty()) { return; } diff --git a/cockatrice/src/game_graphics/zones/hand_zone.h b/cockatrice/src/game_graphics/zones/hand_zone.h index 5c91d684d..ed8f1e8d5 100644 --- a/cockatrice/src/game_graphics/zones/hand_zone.h +++ b/cockatrice/src/game_graphics/zones/hand_zone.h @@ -7,10 +7,10 @@ #ifndef HANDZONE_H #define HANDZONE_H -#include "../../game/board/card_list.h" #include "select_zone.h" #include +#include #include class HandZoneLogic; diff --git a/cockatrice/src/game_graphics/zones/pile_zone.cpp b/cockatrice/src/game_graphics/zones/pile_zone.cpp index 77a87a4af..08af241b6 100644 --- a/cockatrice/src/game_graphics/zones/pile_zone.cpp +++ b/cockatrice/src/game_graphics/zones/pile_zone.cpp @@ -1,12 +1,6 @@ #include "pile_zone.h" #include "../../client/settings/cache_settings.h" -#include "../../game/board/card_list.h" -#include "../../game/player/player_actions.h" -#include "../../game/player/player_info.h" -#include "../../game/player/player_logic.h" -#include "../../game/zones/card_zone_logic.h" -#include "../../game/zones/pile_zone_logic.h" #include "../board/card_drag_item.h" #include "../board/card_item.h" #include "../card_dimensions.h" @@ -24,7 +18,12 @@ #include #include #include -#include +#include +#include +#include +#include +#include +#include #include #include #include @@ -67,36 +66,35 @@ void PileZone::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*optio { painter->drawPath(shape()); - if (!getLogic()->getCards().isEmpty()) { - getLogic()->getCards().at(0)->paintPicture(painter, getLogic()->getCards().at(0)->getTranslatedSize(painter), - 90); + if (!cardItems().isEmpty()) { + cardItems().at(0)->paintPicture(painter, cardItems().at(0)->getTranslatedSize(painter), 90); } painter->translate(CardDimensions::WIDTH_HALF_F, CardDimensions::HEIGHT_HALF_F); painter->rotate(-90); painter->translate(-CardDimensions::WIDTH_HALF_F, -CardDimensions::HEIGHT_HALF_F); - paintNumberEllipse(getLogic()->getCards().size(), 28, Qt::white, -1, -1, painter); + paintNumberEllipse(cardItems().size(), 28, Qt::white, -1, -1, painter); } void PileZone::handleDropEvent(const QList &dragItems, CardZoneLogic *startZone, const QPoint &) { - Command_MoveCard cmd; - cmd.set_start_player_id(startZone->getPlayer()->getPlayerInfo()->getId()); - cmd.set_start_zone(startZone->getName().toStdString()); - cmd.set_target_player_id(getLogic()->getPlayer()->getPlayerInfo()->getId()); - cmd.set_target_zone(getLogic()->getName().toStdString()); - cmd.set_x(0); - cmd.set_y(0); - - for (int i = 0; i < dragItems.size(); ++i) { - auto cardToMove = cmd.mutable_cards_to_move()->add_card(); - cardToMove->set_card_id(dragItems[i]->getId()); - if (dragItems[i]->isForceFaceDown()) { - cardToMove->set_face_down(true); - } + QList cards; + cards.reserve(dragItems.size()); + for (const CardDragItem *item : dragItems) { + cards.append(CardMoveRequest{item->getId(), item->isForceFaceDown()}); } - getLogic()->getPlayer()->getPlayerActions()->sendGameCommand(cmd); + getLogic()->getPlayer()->getPlayerActions()->moveCards(startZone, getLogic(), 0, 0, cards); +} + +void PileZone::onCardAdded(CardState *addedCard) +{ + CardZone::onCardAdded(addedCard); + auto *addedItem = addedCard == nullptr ? nullptr : qobject_cast(addedCard->parent()); + if (addedItem) { + addedItem->setPos(0, 0); + addedItem->setVisible(false); + } } void PileZone::reorganizeCards() @@ -126,15 +124,14 @@ void PileZone::mouseMoveEvent(QGraphicsSceneMouseEvent *event) return; } - if (getLogic()->getCards().isEmpty()) { + if (cardItems().isEmpty()) { return; } bool forceFaceDown = event->modifiers().testFlag(Qt::ShiftModifier); bool bottomCard = event->modifiers().testFlag(Qt::ControlModifier); - CardItem *card = bottomCard ? getLogic()->getCards().last() : getLogic()->getCards().first(); - const int cardid = - getLogic()->contentsKnown() ? card->getId() : (bottomCard ? getLogic()->getCards().size() - 1 : 0); + CardItem *card = bottomCard ? cardItems().last() : cardItems().first(); + const int cardid = getLogic()->contentsKnown() ? card->getId() : (bottomCard ? cardItems().size() - 1 : 0); CardDragItem *drag = card->createDragItem(cardid, event->pos(), event->scenePos(), forceFaceDown); drag->grabMouse(); setCursor(Qt::OpenHandCursor); @@ -147,8 +144,8 @@ void PileZone::mouseReleaseEvent(QGraphicsSceneMouseEvent * /*event*/) void PileZone::hoverEnterEvent(QGraphicsSceneHoverEvent *event) { - if (!getLogic()->getCards().isEmpty()) { - getLogic()->getCards()[0]->processHoverEvent(); + if (!cardItems().isEmpty()) { + cardItems()[0]->processHoverEvent(); } QGraphicsItem::hoverEnterEvent(event); } diff --git a/cockatrice/src/game_graphics/zones/pile_zone.h b/cockatrice/src/game_graphics/zones/pile_zone.h index b2d8d0cd2..fa510ad52 100644 --- a/cockatrice/src/game_graphics/zones/pile_zone.h +++ b/cockatrice/src/game_graphics/zones/pile_zone.h @@ -33,6 +33,7 @@ public: [[nodiscard]] QPainterPath shape() const override; void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override; void reorganizeCards() override; + void onCardAdded(CardState *addedCard) override; void handleDropEvent(const QList &dragItems, CardZoneLogic *startZone, const QPoint &dropPoint) override; diff --git a/cockatrice/src/game_graphics/zones/select_zone.cpp b/cockatrice/src/game_graphics/zones/select_zone.cpp index d69ec32f5..ccac9decc 100644 --- a/cockatrice/src/game_graphics/zones/select_zone.cpp +++ b/cockatrice/src/game_graphics/zones/select_zone.cpp @@ -1,8 +1,6 @@ #include "select_zone.h" #include "../../client/settings/cache_settings.h" -#include "../../game/board/card_list.h" -#include "../../game/zones/card_zone_logic.h" #include "../board/card_item.h" #include "../game_scene.h" @@ -17,6 +15,8 @@ #include #include #include +#include +#include #include #include #include @@ -85,7 +85,7 @@ SelectZone *SelectZone::findOwningSelectZone(const QGraphicsItem *card) SelectZone::StackLayoutParams SelectZone::buildStackParams(qreal minOffset) const { - const auto &cards = getLogic()->getCards(); + const auto &cards = cardItems(); if (cards.isEmpty()) { return {0, boundingRect().height(), 0.0, 0.0, minOffset}; } @@ -97,7 +97,7 @@ SelectZone::StackLayoutParams SelectZone::buildStackParams(qreal minOffset) cons int SelectZone::calcDropIndexFromY(qreal dropY, bool allowCountExpand, qreal minOffset) const { - const auto &cards = getLogic()->getCards(); + const auto &cards = cardItems(); if (cards.isEmpty()) { return 0; } @@ -115,7 +115,7 @@ void SelectZone::restoreStaleEscapedCards() if (!cardClipContainer) { return; } - for (auto *card : getLogic()->getCards()) { + for (auto *card : cardItems()) { // A card parented to the zone (instead of the clip container) should // only occur while it is actively hovered. If hover cleanup was // missed, reparent it back so clipping resumes. @@ -127,7 +127,7 @@ void SelectZone::restoreStaleEscapedCards() void SelectZone::layoutCardsVertically(const StackLayoutParams ¶ms) { - const auto &cards = getLogic()->getCards(); + const auto &cards = cardItems(); if (cards.isEmpty() || params.cardCount <= 0) { return; } @@ -162,9 +162,9 @@ SelectZone::~SelectZone() if (cardClipContainer) { // Reparent any hover-escaped cards back to the clip container so Qt's // parent-child tree is consistent for destruction. setParentItem() does - // not invalidate getLogic()->getCards() (it modifies the graphics tree, - // not the zone's logical card list). - for (auto *card : getLogic()->getCards()) { + // not invalidate cardItems() — it only moves the item in the graphics + // tree, it never changes the zone's contents. + for (auto *card : cardItems()) { if (card && card->parentItem() == this) { card->setParentItem(cardClipContainer); } @@ -172,12 +172,13 @@ SelectZone::~SelectZone() } } -void SelectZone::onCardAdded(CardItem *addedCard) +void SelectZone::onCardAdded(CardState *addedCard) { - if (cardClipContainer && addedCard) { - addedCard->setParentItem(cardClipContainer); - addedCard->setVisible(true); - addedCard->update(); + auto *addedItem = addedCard == nullptr ? nullptr : qobject_cast(addedCard->parent()); + if (cardClipContainer && addedItem) { + addedItem->setParentItem(cardClipContainer); + addedItem->setVisible(true); + addedItem->update(); } else { CardZone::onCardAdded(addedCard); } @@ -247,7 +248,7 @@ void SelectZone::mouseMoveEvent(QGraphicsSceneMouseEvent *event) } QRectF selectionRect = QRectF(selectionOrigin, pos).normalized(); - for (auto card : getLogic()->getCards()) { + for (auto card : cardItems()) { if (card->getAttachedTo() && card->getAttachedTo()->getZone() != getLogic()) { continue; } diff --git a/cockatrice/src/game_graphics/zones/select_zone.h b/cockatrice/src/game_graphics/zones/select_zone.h index 8a56949e3..8c81f0dbf 100644 --- a/cockatrice/src/game_graphics/zones/select_zone.h +++ b/cockatrice/src/game_graphics/zones/select_zone.h @@ -35,7 +35,7 @@ public: SelectZone(CardZoneLogic *logic, QGraphicsItem *parent = nullptr); ~SelectZone() override; - void onCardAdded(CardItem *addedCard) override; + void onCardAdded(CardState *addedCard) override; /** * @brief Temporarily reparents a card from the clip container to this zone so hover scaling is visible beyond clip diff --git a/cockatrice/src/game_graphics/zones/stack_zone.cpp b/cockatrice/src/game_graphics/zones/stack_zone.cpp index de1ff8ba9..d46a4bd94 100644 --- a/cockatrice/src/game_graphics/zones/stack_zone.cpp +++ b/cockatrice/src/game_graphics/zones/stack_zone.cpp @@ -1,11 +1,5 @@ #include "stack_zone.h" -#include "../../game/board/card_list.h" -#include "../../game/player/player_actions.h" -#include "../../game/player/player_info.h" -#include "../../game/player/player_logic.h" -#include "../../game/zones/card_zone_logic.h" -#include "../../game/zones/stack_zone_logic.h" #include "../../interface/theme_manager.h" #include "../board/card_drag_item.h" #include "../board/card_item.h" @@ -19,7 +13,12 @@ #include #include #include -#include +#include +#include +#include +#include +#include +#include #include #include @@ -73,31 +72,21 @@ void StackZone::handleDropEvent(const QList &dragItems, int index = calcDropIndexFromY(dropPoint.y(), !sameZone, MIN_CARD_VISIBLE); if (sameZone) { // Same-zone no-op: don't move a card onto itself - const auto &cards = getLogic()->getCards(); + const auto &cards = cardItems(); if (!cards.isEmpty() && cards.at(index)->getId() == dragItems.at(0)->getId()) { 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(getLogic()->getPlayer()->getPlayerInfo()->getId()); - cmd.set_target_zone(getLogic()->getName().toStdString()); - cmd.set_x(index); - cmd.set_y(0); - + QList cards; + cards.reserve(dragItems.size()); for (const CardDragItem *item : dragItems) { if (item) { - auto *cardToMove = cmd.mutable_cards_to_move()->add_card(); - cardToMove->set_card_id(item->getId()); - if (item->isForceFaceDown()) { - cardToMove->set_face_down(true); - } + cards.append(CardMoveRequest{item->getId(), item->isForceFaceDown()}); } } - getLogic()->getPlayer()->getPlayerActions()->sendGameCommand(cmd); + getLogic()->getPlayer()->getPlayerActions()->moveCards(startZone, getLogic(), index, 0, cards); } void StackZone::setHeight(qreal newHeight) @@ -113,7 +102,7 @@ void StackZone::setHeight(qreal newHeight) void StackZone::reorganizeCards() { - if (!getLogic()->getCards().isEmpty()) { + if (!cardItems().isEmpty()) { const auto params = buildStackParams(MIN_CARD_VISIBLE); layoutCardsVertically(params); } diff --git a/cockatrice/src/game_graphics/zones/table_zone.cpp b/cockatrice/src/game_graphics/zones/table_zone.cpp index b704b79e0..0dffab353 100644 --- a/cockatrice/src/game_graphics/zones/table_zone.cpp +++ b/cockatrice/src/game_graphics/zones/table_zone.cpp @@ -1,12 +1,6 @@ #include "table_zone.h" #include "../../client/settings/cache_settings.h" -#include "../../game/board/card_list.h" -#include "../../game/player/player_actions.h" -#include "../../game/player/player_info.h" -#include "../../game/player/player_logic.h" -#include "../../game/zones/card_zone_logic.h" -#include "../../game/zones/table_zone_logic.h" #include "../../interface/theme_manager.h" #include "../board/abstract_card_item.h" #include "../board/card_drag_item.h" @@ -32,8 +26,12 @@ #include #include #include -#include -#include +#include +#include +#include +#include +#include +#include #include #include #include @@ -62,7 +60,8 @@ TableZone::TableZone(TableZoneLogic *_logic, bool _mirrored, QGraphicsItem *pare updateBg(); - height = MARGIN_TOP + MARGIN_BOTTOM + TABLEROWS * CardDimensions::HEIGHT + (TABLEROWS - 1) * PADDING_Y; + height = MARGIN_TOP + MARGIN_BOTTOM + TableZoneLogic::ROW_COUNT * CardDimensions::HEIGHT + + (TableZoneLogic::ROW_COUNT - 1) * PADDING_Y; width = MIN_WIDTH; currentMinimumWidth = width; @@ -208,29 +207,20 @@ void TableZone::handleDropEventByGrid(const QList &dragItems, CardZoneLogic *startZone, const QPoint &gridPoint) { - Command_MoveCard cmd; - cmd.set_start_player_id(startZone->getPlayer()->getPlayerInfo()->getId()); - cmd.set_start_zone(startZone->getName().toStdString()); - cmd.set_target_player_id(getLogic()->getPlayer()->getPlayerInfo()->getId()); - cmd.set_target_zone(getLogic()->getName().toStdString()); - cmd.set_x(gridPoint.x()); - cmd.set_y(gridPoint.y()); - - for (const auto &item : dragItems) { - CardToMove *ctm = cmd.mutable_cards_to_move()->add_card(); - ctm->set_card_id(item->getId()); - if (item->isForceFaceDown()) { - ctm->set_face_down(true); - } + QList cards; + cards.reserve(dragItems.size()); + for (const CardDragItem *item : dragItems) { + CardMoveRequest card{item->getId(), item->isForceFaceDown()}; if (startZone->getName() != getLogic()->getName() && !item->isForceFaceDown()) { - const auto &card = item->getItem()->getCard(); - if (card) { - ctm->set_pt(card.getInfo().getPowTough().toStdString()); + const auto &cardInfo = item->getItem()->getCard(); + if (cardInfo) { + card.pt = cardInfo.getInfo().getPowTough(); } } + cards.append(card); } - startZone->getPlayer()->getPlayerActions()->sendGameCommand(cmd); + startZone->getPlayer()->getPlayerActions()->moveCards(startZone, getLogic(), gridPoint.x(), gridPoint.y(), cards); } void TableZone::reorganizeCards() @@ -238,8 +228,8 @@ void TableZone::reorganizeCards() // Calculate card stack widths so mapping functions work properly computeCardStackWidths(); - for (int i = 0; i < getLogic()->getCards().size(); ++i) { - QPoint gridPoint = getLogic()->getCards()[i]->getGridPos(); + for (int i = 0; i < cardItems().size(); ++i) { + QPoint gridPoint = cardItems()[i]->getGridPos(); if (gridPoint.x() == -1) { continue; } @@ -248,21 +238,24 @@ void TableZone::reorganizeCards() qreal x = mapPoint.x(); qreal y = mapPoint.y(); - int numberAttachedCards = getLogic()->getCards()[i]->getAttachedCards().size(); + int numberAttachedCards = cardItems()[i]->getAttachedCards().size(); qreal actualX = x + numberAttachedCards * STACKED_CARD_OFFSET_X; qreal actualY = y; if (numberAttachedCards) { actualY += 15; } - getLogic()->getCards()[i]->setPos(actualX, actualY); - getLogic()->getCards()[i]->setRealZValue(ZValues::tableCardZValue(actualX, actualY)); + cardItems()[i]->setPos(actualX, actualY); + cardItems()[i]->setRealZValue(ZValues::tableCardZValue(actualX, actualY)); - QListIterator attachedCardIterator(getLogic()->getCards()[i]->getAttachedCards()); + QListIterator attachedCardIterator(cardItems()[i]->getAttachedCards()); int j = 0; while (attachedCardIterator.hasNext()) { ++j; - CardItem *attachedCard = attachedCardIterator.next(); + CardItem *attachedCard = qobject_cast(attachedCardIterator.next()->parent()); + if (attachedCard == nullptr) { + continue; + } qreal childX = actualX - j * STACKED_CARD_OFFSET_X; qreal childY = y + 5; attachedCard->setPos(childX, childY); @@ -276,35 +269,18 @@ void TableZone::reorganizeCards() void TableZone::toggleTapped() { - QList selectedItemsRaw = scene()->selectedItems(); - QList selectedItems; - - auto isCardOnTable = [](const QGraphicsItem *item) { - if (auto card = qgraphicsitem_cast(item)) { - return card->getZone()->getName() == ZoneNames::TABLE; - } - return false; - }; - - std::copy_if(selectedItemsRaw.begin(), selectedItemsRaw.end(), std::back_inserter(selectedItems), isCardOnTable); - - bool tapAll = std::any_of(selectedItems.begin(), selectedItems.end(), [](const QGraphicsItem *item) { - return !qgraphicsitem_cast(item)->getTapped(); - }); - QList cmdList; - for (const auto &selectedItem : selectedItems) { - CardItem *temp = qgraphicsitem_cast(selectedItem); - if (temp->getTapped() != tapAll) { - Command_SetCardAttr *cmd = new Command_SetCardAttr; - cmd->set_zone(getLogic()->getName().toStdString()); - cmd->set_card_id(temp->getId()); - cmd->set_attribute(AttrTapped); - cmd->set_attr_value(tapAll ? "1" : "0"); - cmdList.append(cmd); + QList selectedCards; + for (auto *item : scene()->selectedItems()) { + auto *card = qgraphicsitem_cast(item); + if (card && card->getZone()->getName() == ZoneNames::TABLE) { + selectedCards.append(card->getState()); } } - getLogic()->getPlayer()->getPlayerActions()->sendGameCommand( - getLogic()->getPlayer()->getPlayerActions()->prepareGameCommand(cmdList)); + + const bool tapAll = std::any_of(selectedCards.begin(), selectedCards.end(), + [](const CardState *card) { return !card->getTapped(); }); + + getLogic()->getPlayer()->getPlayerActions()->actToggleTapped(getLogic()->getName(), selectedCards, tapAll); } void TableZone::resizeToContents() @@ -312,9 +288,9 @@ void TableZone::resizeToContents() int xMax = 0; // Find rightmost card position, which includes the left margin amount. - for (int i = 0; i < getLogic()->getCards().size(); ++i) { - if (getLogic()->getCards()[i]->pos().x() > xMax) { - xMax = (int)getLogic()->getCards()[i]->pos().x(); + for (int i = 0; i < cardItems().size(); ++i) { + if (cardItems()[i]->pos().x() > xMax) { + xMax = (int)cardItems()[i]->pos().x(); } } @@ -335,9 +311,9 @@ void TableZone::resizeToContents() CardItem *TableZone::getCardFromGrid(const QPoint &gridPoint) const { - for (int i = 0; i < getLogic()->getCards().size(); i++) { - if (getLogic()->getCards().at(i)->getGridPoint() == gridPoint) { - return getLogic()->getCards().at(i); + for (int i = 0; i < cardItems().size(); i++) { + if (cardItems().at(i)->getGridPoint() == gridPoint) { + return cardItems().at(i); } } return 0; @@ -354,8 +330,8 @@ void TableZone::computeCardStackWidths() // Each card stack is three grid points worth of card locations. // First pass: compute the number of cards at each card stack. QMap cardStackCount; - for (int i = 0; i < getLogic()->getCards().size(); ++i) { - const QPoint &gridPoint = getLogic()->getCards()[i]->getGridPos(); + for (int i = 0; i < cardItems().size(); ++i) { + const QPoint &gridPoint = cardItems()[i]->getGridPos(); if (gridPoint.x() == -1) { continue; } @@ -366,8 +342,8 @@ void TableZone::computeCardStackWidths() // Second pass: compute the width at each card stack. cardStackWidth.clear(); - for (int i = 0; i < getLogic()->getCards().size(); ++i) { - const QPoint &gridPoint = getLogic()->getCards()[i]->getGridPos(); + for (int i = 0; i < cardItems().size(); ++i) { + const QPoint &gridPoint = cardItems()[i]->getGridPos(); if (gridPoint.x() == -1) { continue; } @@ -375,8 +351,8 @@ void TableZone::computeCardStackWidths() const int key = getCardStackMapKey(gridPoint.x() / 3, gridPoint.y()); const int stackCount = cardStackCount.value(key, 0); if (stackCount == 1) { - cardStackWidth.insert(key, CardDimensions::WIDTH + getLogic()->getCards()[i]->getAttachedCards().size() * - STACKED_CARD_OFFSET_X); + cardStackWidth.insert(key, CardDimensions::WIDTH + + cardItems()[i]->getAttachedCards().size() * STACKED_CARD_OFFSET_X); } else { cardStackWidth.insert(key, CardDimensions::WIDTH + (stackCount - 1) * STACKED_CARD_OFFSET_X); } @@ -397,7 +373,7 @@ QPointF TableZone::mapFromGrid(QPoint gridPoint) const } if (isInverted()) { - gridPoint.setY(TABLEROWS - 1 - gridPoint.y()); + gridPoint.setY(TableZoneLogic::ROW_COUNT - 1 - gridPoint.y()); } // Start with margin plus stacked card offset @@ -423,10 +399,10 @@ QPoint TableZone::mapToGrid(const QPointF &mapPoint) const const int gridPointHeight = CardDimensions::HEIGHT + PADDING_Y; int gridPointY = (y + PADDING_Y / 2) / gridPointHeight; - gridPointY = clampValidTableRow(gridPointY); + gridPointY = TableZoneLogic::clampValidTableRow(gridPointY); if (isInverted()) { - gridPointY = TABLEROWS - 1 - gridPointY; + gridPointY = TableZoneLogic::ROW_COUNT - 1 - gridPointY; } // Calculating the x-coordinate of the grid space requires adding up the @@ -471,22 +447,3 @@ QPointF TableZone::closestGridPoint(const QPointF &point) } return mapFromGrid(gridPoint); } - -int TableZone::clampValidTableRow(const int row) -{ - if (row < 0) { - return 0; - } - if (row >= TABLEROWS) { - return TABLEROWS - 1; - } - return row; -} - -int TableZone::tableRowToGridY(int tableRow) -{ - if (tableRow > 2) { - tableRow = 1; - } - return clampValidTableRow(2 - tableRow); -} diff --git a/cockatrice/src/game_graphics/zones/table_zone.h b/cockatrice/src/game_graphics/zones/table_zone.h index ccd1d8e81..8aa4936ad 100644 --- a/cockatrice/src/game_graphics/zones/table_zone.h +++ b/cockatrice/src/game_graphics/zones/table_zone.h @@ -17,6 +17,7 @@ #include #include #include +#include #include class CardDragItem; @@ -25,7 +26,6 @@ class CardZoneLogic; class QColor; class QGraphicsItem; class QPainter; -class TableZoneLogic; template class QList; /** @@ -42,8 +42,6 @@ signals: void sizeChanged(); private: - static const int TABLEROWS = 3; - /* Margins between table edges and cards, paddings between cards */ @@ -181,15 +179,6 @@ public: QPointF closestGridPoint(const QPointF &point) override; - 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); - /** Resizes the TableZone in case CardItems are within or outside of the TableZone constraints. diff --git a/cockatrice/src/game_graphics/zones/view_zone.cpp b/cockatrice/src/game_graphics/zones/view_zone.cpp index a2aa124dc..be2e471d4 100644 --- a/cockatrice/src/game_graphics/zones/view_zone.cpp +++ b/cockatrice/src/game_graphics/zones/view_zone.cpp @@ -1,11 +1,6 @@ #include "view_zone.h" #include "../../client/settings/cache_settings.h" -#include "../../game/player/player_actions.h" -#include "../../game/player/player_info.h" -#include "../../game/player/player_logic.h" -#include "../../game/zones/card_zone_logic.h" -#include "../../game/zones/view_zone_logic.h" #include "../board/card_drag_item.h" #include "../board/card_item.h" #include "../card_dimensions.h" @@ -26,8 +21,11 @@ #include #include #include -#include -#include +#include +#include +#include +#include +#include #include #include #include @@ -42,21 +40,23 @@ class QGraphicsItem; ZoneViewZone::ZoneViewZone(ZoneViewZoneLogic *_logic, QGraphicsItem *parent) : SelectZone(_logic, parent), bRect(QRectF()), minRows(0), groupBy(CardList::NoSort), sortBy(CardList::NoSort) { - if (!(qobject_cast(getLogic())->getRevealZone() && - !qobject_cast(getLogic())->getWriteableRevealZone())) { - qobject_cast(getLogic())->getOriginalZone()->getViews().append(this); + auto *viewLogic = qobject_cast(getLogic()); + if (!(viewLogic->getRevealZone() && !viewLogic->getWriteableRevealZone())) { + viewLogic->getOriginalZone()->getViews().append(viewLogic); } connect(_logic, &ZoneViewZoneLogic::closeView, this, &ZoneViewZone::close); } void ZoneViewZone::addToViews() { - qobject_cast(getLogic())->getOriginalZone()->getViews().append(this); + auto *viewLogic = qobject_cast(getLogic()); + viewLogic->getOriginalZone()->getViews().append(viewLogic); } void ZoneViewZone::removeFromViews() { - qobject_cast(getLogic())->getOriginalZone()->getViews().removeOne(this); + auto *viewLogic = qobject_cast(getLogic()); + viewLogic->getOriginalZone()->getViews().removeOne(viewLogic); } /** @@ -66,9 +66,9 @@ void ZoneViewZone::removeFromViews() void ZoneViewZone::close() { emit closed(); - if (!(qobject_cast(getLogic())->getRevealZone() && - !qobject_cast(getLogic())->getWriteableRevealZone())) { - qobject_cast(getLogic())->getOriginalZone()->getViews().removeOne(this); + auto *viewLogic = qobject_cast(getLogic()); + if (!(viewLogic->getRevealZone() && !viewLogic->getWriteableRevealZone())) { + viewLogic->getOriginalZone()->getViews().removeOne(viewLogic); } deleteLater(); } @@ -95,28 +95,27 @@ void ZoneViewZone::initializeCards(const QList &cardLis auto copy = new CardItem(getLogic()->getPlayer(), this, cardRef, card->id()); copy->setFaceDown(card->face_down()); - getLogic()->addCard(copy, false, i); + getLogic()->addCard(copy->getState(), false, i); } reorganizeCards(); } else if (!qobject_cast(getLogic())->getOriginalZone()->contentsKnown()) { - Command_DumpZone cmd; - cmd.set_player_id(getLogic()->getPlayer()->getPlayerInfo()->getId()); - cmd.set_zone_name(getLogic()->getName().toStdString()); - cmd.set_number_cards(numberCards); - cmd.set_is_reversed(qobject_cast(getLogic())->getIsReversed()); - - PendingCommand *pend = getLogic()->getPlayer()->getPlayerActions()->prepareGameCommand(cmd); - connect(pend, &PendingCommand::finished, this, &ZoneViewZone::zoneDumpReceived); - getLogic()->getPlayer()->getPlayerActions()->sendGameCommand(pend); + auto *viewLogic = qobject_cast(getLogic()); + PendingCommand *pend = viewLogic->prepareZoneDump(); + if (pend != nullptr) { + // A local game answers synchronously, so the handler has to be connected before the + // command is sent or the response would be delivered to nobody. + connect(pend, &PendingCommand::finished, this, &ZoneViewZone::zoneDumpReceived); + viewLogic->sendZoneDump(pend); + } } else { const CardList &c = qobject_cast(getLogic())->getOriginalZone()->getCards(); int number = numberCards == -1 ? c.size() : (numberCards < c.size() ? numberCards : c.size()); for (int i = 0; i < number; i++) { - CardItem *card = c.at(i); + CardState *card = c.at(i); auto copy = new CardItem(getLogic()->getPlayer(), this, card->getCardRef(), card->getId()); copy->setFaceDown(card->getFaceDown()); - getLogic()->addCard(copy, false, i); + getLogic()->addCard(copy->getState(), false, i); } reorganizeCards(); } @@ -132,7 +131,7 @@ void ZoneViewZone::zoneDumpReceived(const Response &r) auto cardProviderId = QString::fromStdString(cardInfo.provider_id()); auto card = new CardItem(getLogic()->getPlayer(), this, {cardName, cardProviderId}, cardInfo.id(), getLogic()); card->setFaceDown(cardInfo.face_down()); - getLogic()->rawInsertCard(card, i); + getLogic()->rawInsertCard(card->getState(), i); } qobject_cast(getLogic())->updateCardIds(ZoneViewZoneLogic::INITIALIZE); @@ -146,11 +145,11 @@ void ZoneViewZone::zoneDumpReceived(const Response &r) void ZoneViewZone::reorganizeCards() { // filter cards - CardList cardsToDisplay = CardList(getLogic()->getCards().getContentsKnown()); - for (auto card : getLogic()->getCards()) { + CardList cardsToDisplay = CardList(getLogic()->contentsKnown()); + for (auto card : cardItems()) { if (filterString.check(card->getCard().getCardPtr())) { card->show(); - cardsToDisplay.append(card); + cardsToDisplay.append(card->getState()); } else { card->hide(); } @@ -218,7 +217,8 @@ ZoneViewZone::GridSize ZoneViewZone::positionCardsForDisplay(CardList &cards, Ca const auto extractor = CardList::getExtractorFor(pileOption); for (int i = 0; i < cardCount; i++) { - CardItem *c = cards.at(i); + CardState *c = cards.at(i); + CardItem *cItem = qobject_cast(c->parent()); QString columnProp = extractor(c); if (i) { // if not the first card @@ -233,8 +233,8 @@ ZoneViewZone::GridSize ZoneViewZone::positionCardsForDisplay(CardList &cards, Ca lastColumnProp = columnProp; qreal x = col * CardDimensions::WIDTH_F; qreal y = row * CardDimensions::HEIGHT_F / 3; - c->setPos(HORIZONTAL_PADDING + x, VERTICAL_PADDING + y); - c->setRealZValue(i); + cItem->setPos(HORIZONTAL_PADDING + x, VERTICAL_PADDING + y); + cItem->setRealZValue(i); longestRow = qMax(row, longestRow); } @@ -259,11 +259,11 @@ ZoneViewZone::GridSize ZoneViewZone::positionCardsForDisplay(CardList &cards, Ca qCDebug(ViewZoneLog) << "reorganizeCards: rows=" << rows << "cols=" << cols; for (int i = 0; i < cardCount; i++) { - CardItem *c = cards.at(i); + auto *cItem = qobject_cast(cards.at(i)->parent()); qreal x = (i / rows) * CardDimensions::WIDTH_F; qreal y = (i % rows) * CardDimensions::HEIGHT_F / 3; - c->setPos(HORIZONTAL_PADDING + x, VERTICAL_PADDING + y); - c->setRealZValue(i); + cItem->setPos(HORIZONTAL_PADDING + x, VERTICAL_PADDING + y); + cItem->setRealZValue(i); } return GridSize{rows, qMax(cols, 1)}; @@ -301,24 +301,13 @@ void ZoneViewZone::handleDropEvent(const QList &dragItems, CardZoneLogic *startZone, const QPoint & /*dropPoint*/) { - Command_MoveCard cmd; - cmd.set_start_player_id(startZone->getPlayer()->getPlayerInfo()->getId()); - cmd.set_start_zone(startZone->getName().toStdString()); - cmd.set_target_player_id(getLogic()->getPlayer()->getPlayerInfo()->getId()); - cmd.set_target_zone(getLogic()->getName().toStdString()); - cmd.set_x(0); - cmd.set_y(0); - cmd.set_is_reversed(qobject_cast(getLogic())->getIsReversed()); - + QList cards; + cards.reserve(dragItems.size()); for (int i = 0; i < dragItems.size(); ++i) { - auto cardToMove = cmd.mutable_cards_to_move()->add_card(); - cardToMove->set_card_id(dragItems[i]->getId()); - if (dragItems[i]->isForceFaceDown()) { - cardToMove->set_face_down(true); - } + cards.append(CardMoveRequest{dragItems[i]->getId(), dragItems[i]->isForceFaceDown()}); } - getLogic()->getPlayer()->getPlayerActions()->sendGameCommand(cmd); + getLogic()->getPlayer()->getPlayerActions()->moveCards(startZone, getLogic(), 0, 0, cards); } void ZoneViewZone::setGeometry(const QRectF &rect) diff --git a/cockatrice/src/game_graphics/zones/view_zone.h b/cockatrice/src/game_graphics/zones/view_zone.h index 4032da760..f91b24834 100644 --- a/cockatrice/src/game_graphics/zones/view_zone.h +++ b/cockatrice/src/game_graphics/zones/view_zone.h @@ -7,7 +7,6 @@ #ifndef ZONEVIEWERZONE_H #define ZONEVIEWERZONE_H -#include "../../game/board/card_list.h" #include "select_zone.h" #include @@ -17,6 +16,7 @@ #include #include #include +#include #include #include diff --git a/cockatrice/src/game_graphics/zones/view_zone_widget.cpp b/cockatrice/src/game_graphics/zones/view_zone_widget.cpp index 49d9955e0..3c6cf9d02 100644 --- a/cockatrice/src/game_graphics/zones/view_zone_widget.cpp +++ b/cockatrice/src/game_graphics/zones/view_zone_widget.cpp @@ -1,17 +1,13 @@ #include "view_zone_widget.h" #include "../../client/settings/cache_settings.h" -#include "../../client/translation.h" #include "../../filters/syntax_help.h" -#include "../../game/board/card_list.h" -#include "../../game/player/player_actions.h" -#include "../../game/player/player_logic.h" -#include "../../game/zones/card_zone_logic.h" -#include "../../game/zones/view_zone_logic.h" #include "../../interface/pixel_map_generator.h" +#include "../../interface/theme_icon.h" #include "../card_dimensions.h" #include "../z_values.h" #include "libcockatrice/utility/macros.h" +#include "libcockatrice/utility/qt_utils.h" #include "view_zone.h" #include @@ -44,7 +40,12 @@ #include #include #include -#include +#include +#include +#include +#include +#include +#include #include #include #include @@ -93,8 +94,8 @@ ZoneViewWidget::ZoneViewWidget(PlayerLogic *_player, searchEdit.setFocusPolicy(Qt::ClickFocus); searchEdit.setPlaceholderText(tr("Search by card name (or search expressions)")); searchEdit.setClearButtonEnabled(true); - searchEdit.addAction(loadColorAdjustedPixmap("theme:icons/search"), QLineEdit::LeadingPosition); - auto help = searchEdit.addAction(themePixmap(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); + searchEdit.addAction(loadThemedIcon(QStringLiteral("icons/search")), QLineEdit::LeadingPosition); + auto help = searchEdit.addAction(loadThemedIcon(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); connect(help, &QAction::triggered, this, [this] { createSearchSyntaxHelpWindow(&searchEdit); }); @@ -177,9 +178,10 @@ ZoneViewWidget::ZoneViewWidget(PlayerLogic *_player, vbox->addItem(zoneHBox); - zone = new ZoneViewZone(new ZoneViewZoneLogic(player, _origZone, numberCards, _revealZone, _writeableRevealZone, - _isReversed, zoneContainer), - zoneContainer); + zone = + new ZoneViewZone(new ZoneViewZoneLogic(player, _origZone, numberCards, _revealZone, _writeableRevealZone, + _isReversed, &SettingsCache::instance().userInterface(), zoneContainer), + zoneContainer); connect(zone, &ZoneViewZone::wheelEventReceived, scrollBarProxy, &ScrollableGraphicsProxyWidget::recieveWheelEvent); retranslateUi(); @@ -356,12 +358,8 @@ QRectF ZoneViewWidget::closeButtonRect(QWidget *styleWidget) const QGraphicsView *ZoneViewWidget::findDragView(QWidget *eventWidget) const { - QWidget *current = eventWidget; - while (current) { - if (auto *view = qobject_cast(current)) { - return view; - } - current = current->parentWidget(); + if (auto *view = QtUtils::findParentOfType(eventWidget, true)) { + return view; } if (scene() && !scene()->views().isEmpty()) { @@ -573,7 +571,7 @@ void ZoneViewWidget::closeEvent(QCloseEvent *event) // manually call zone->close in order to remove it from the origZones views zone->close(); if (shuffleCheckBox.isChecked()) { - player->getPlayerActions()->sendGameCommand(Command_Shuffle()); + player->getPlayerActions()->actShuffle(); } zoneDeleted(); event->accept(); diff --git a/cockatrice/src/installer_close_server.cpp b/cockatrice/src/installer_close_server.cpp new file mode 100644 index 000000000..26f0b2c87 --- /dev/null +++ b/cockatrice/src/installer_close_server.cpp @@ -0,0 +1,81 @@ +#include "installer_close_server.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ +/// The one line the probe writes, from the header both ends include. A QByteArray view of +/// the shared literal, so the client cannot drift from the probe's copy. +const QByteArray CLOSE_COMMAND = QByteArray::fromRawData(INSTALLER_CLOSE_COMMAND, sizeof(INSTALLER_CLOSE_COMMAND) - 1); + +/// How long a connection that never sends the command may stay open. The probe writes and +/// closes at once, so this only reaps a stray client; it must not run while a close request +/// is being handled, which is why it is a timer on the socket and not a disconnected slot. +constexpr int STRAY_CONNECTION_MILLISECONDS = 5000; +} // namespace + +InstallerCloseServer::InstallerCloseServer(QObject *parent) : QObject(parent) +{ +} + +QString InstallerCloseServer::socketNameForUser(qint64 processId, const QString &userName) +{ + return QStringLiteral("CockatriceInstallerClose-%1-%2").arg(processId).arg(userName); +} + +QString InstallerCloseServer::socketName(qint64 processId) +{ + // The same scoping helper the single-instance socket uses, so the probe only has to + // reproduce one rule (USER, then USERNAME, then the home directory name). + return scopedLocalServerName(QStringLiteral("CockatriceInstallerClose-%1").arg(processId)); +} + +bool InstallerCloseServer::listen() +{ + if (server != nullptr) { + return server->isListening(); + } + name = socketName(QCoreApplication::applicationPid()); + server = new QLocalServer(this); + // A crashed instance can leave a Unix socket file behind; on Windows this is a no-op. + QLocalServer::removeServer(name); + if (!server->listen(name)) { + delete server; + server = nullptr; + return false; + } + + connect(server, &QLocalServer::newConnection, this, [this]() { + while (QLocalSocket *socket = server->nextPendingConnection()) { + // No disconnected -> deleteLater() slot: the close request can spin a nested + // event loop, the probe disconnects while it runs, and a deferred delete would + // then free the socket from inside its own readyRead emission. The reaper covers + // a client that never sends the line; the handler below deletes after the emit. + auto *reaper = new QTimer(socket); + reaper->setSingleShot(true); + connect(reaper, &QTimer::timeout, socket, &QObject::deleteLater); + reaper->start(STRAY_CONNECTION_MILLISECONDS); + connect(socket, &QLocalSocket::readyRead, this, [this, socket, reaper]() { + if (!socket->canReadLine()) { + return; + } + if (socket->readLine() != CLOSE_COMMAND) { + return; + } + // Stop the reaper before the emit: the receiver may close the window and run + // a nested event loop for as long as the user takes, and the reaper must not + // free the socket from inside that loop while this handler still holds it. + reaper->stop(); + emit closeRequested(); + socket->disconnectFromServer(); + socket->deleteLater(); + }); + } + }); + return true; +} diff --git a/cockatrice/src/installer_close_server.h b/cockatrice/src/installer_close_server.h new file mode 100644 index 000000000..847c35523 --- /dev/null +++ b/cockatrice/src/installer_close_server.h @@ -0,0 +1,50 @@ +#ifndef COCKATRICE_INSTALLER_CLOSE_SERVER_H +#define COCKATRICE_INSTALLER_CLOSE_SERVER_H + +#include +#include + +class QLocalServer; + +/** + * @brief Listens for the installer's request to close this instance gracefully. + * + * The Windows installer cannot reach an instance whose window is hidden in the notification + * area with WM_CLOSE, and it must not kill a process that may hold an unsaved deck. It + * connects to this local socket instead and writes one line; the instance then closes the + * same way its own close button does - saving settings and answering its own confirmation, + * never bypassing it. + * + * The socket name carries the process id, so every instance has its own and an installer + * that found several can ask each one. It is built by installerCloseSocketName() in + * tools/installer-holder-probe/installer_close_name.h; the two must stay identical. + */ +class InstallerCloseServer : public QObject +{ + Q_OBJECT +public: + explicit InstallerCloseServer(QObject *parent = nullptr); + + /** + * @brief Starts listening. A failure is not fatal: the installer falls back to + * WM_CLOSE, which is what older clients answer. + */ + bool listen(); + + /// The socket name this process listens on. The installer's probe builds the same + /// string from the process id and the account name. + static QString socketName(qint64 processId); + + /// The name for an explicit account, exposed so the probe's name helper and this one + /// can be tested against each other. + static QString socketNameForUser(qint64 processId, const QString &userName); + +signals: + void closeRequested(); + +private: + QLocalServer *server = nullptr; + QString name; +}; + +#endif // COCKATRICE_INSTALLER_CLOSE_SERVER_H diff --git a/cockatrice/src/interface/card_database_update/card_database_update_status_bar.cpp b/cockatrice/src/interface/card_database_update/card_database_update_status_bar.cpp index 721fdd6c8..21e26d073 100644 --- a/cockatrice/src/interface/card_database_update/card_database_update_status_bar.cpp +++ b/cockatrice/src/interface/card_database_update/card_database_update_status_bar.cpp @@ -7,6 +7,7 @@ #include #include #include +#include namespace { @@ -32,8 +33,7 @@ QString stageName(CardUpdateStage stage, const QString &fallback) CardDatabaseUpdateStatusBar::CardDatabaseUpdateStatusBar(QWidget *parent) : QWidget(parent) { - layout = new QHBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); + layout = QtUtils::makeLayout(this); stageLabel = new QLabel(this); progressBar = new QProgressBar(this); diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader.cpp b/cockatrice/src/interface/card_picture_loader/card_picture_loader.cpp index 4f77c7096..2d35538d2 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader.cpp +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader.cpp @@ -238,6 +238,7 @@ void CardPictureLoader::imageLoaded(const ExactCard &card, const QImage &image) }); card.emitPixmapUpdated(); + emit getInstance().pixmapLoaded(card); } void CardPictureLoader::deleteAllLocalOverrides(const ExactCard &card) diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader.h b/cockatrice/src/interface/card_picture_loader/card_picture_loader.h index c6d91570b..0c73e5d59 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader.h +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader.h @@ -146,6 +146,14 @@ signals: /** @brief Emitted after the worker has finished clearing the network and redirect caches. */ void networkCacheCleared(); + /** + * @brief Emitted once an image has been loaded and put into the pixmap cache. + * + * Views that paint cards from the loader without owning a widget per card can + * listen to this to repaint the rows whose picture just became available. + */ + void pixmapLoaded(const ExactCard &card); + private slots: /** * @brief Triggered when the user changes the picture download settings. diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp b/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp index 1437e23f4..fde6830c2 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -19,35 +20,62 @@ #include #include -static constexpr int REFRESH_INTERVAL_MS = 10 * 1000; +static constexpr int REFRESH_DEBOUNCE_MS = 500; +// Watching can fail on network and FUSE mounts; the slow poll still picks those +// changes up without the old ten-second full rescan. +static constexpr int FALLBACK_REFRESH_INTERVAL_MS = 5 * 60 * 1000; CardPictureLoaderLocal::CardPictureLoaderLocal(QObject *parent) - : QObject(parent), picsPath(SettingsCache::instance().paths().getPicsPath()), - customPicsPath(SettingsCache::instance().paths().getCustomPicsPath()) + : CardPictureLoaderLocal(parent, + SettingsCache::instance().paths().getPicsPath(), + SettingsCache::instance().paths().getCustomPicsPath()) { // Hook up signals to settings connect(&SettingsCache::instance().paths(), &PathsSettings::picsPathChanged, this, &CardPictureLoaderLocal::picsPathChanged); +} + +CardPictureLoaderLocal::CardPictureLoaderLocal(QObject *parent, + const QString &_picsPath, + const QString &_customPicsPath) + : QObject(parent), picsPath(_picsPath), customPicsPath(_customPicsPath), + folderWatcher(new QFileSystemWatcher(this)), indexRefreshTimer(new QTimer(this)), + fallbackRefreshTimer(new QTimer(this)) +{ + indexRefreshTimer->setSingleShot(true); + indexRefreshTimer->setInterval(REFRESH_DEBOUNCE_MS); + connect(indexRefreshTimer, &QTimer::timeout, this, &CardPictureLoaderLocal::refreshIndex); + + connect(folderWatcher, &QFileSystemWatcher::directoryChanged, indexRefreshTimer, qOverload<>(&QTimer::start)); + connect(folderWatcher, &QFileSystemWatcher::fileChanged, indexRefreshTimer, qOverload<>(&QTimer::start)); + + fallbackRefreshTimer->setInterval(FALLBACK_REFRESH_INTERVAL_MS); + connect(fallbackRefreshTimer, &QTimer::timeout, this, &CardPictureLoaderLocal::refreshIndex); + fallbackRefreshTimer->start(); refreshIndex(); - - refreshTimer = new QTimer(this); - connect(refreshTimer, &QTimer::timeout, this, &CardPictureLoaderLocal::refreshIndex); - refreshTimer->start(REFRESH_INTERVAL_MS); } void CardPictureLoaderLocal::refreshIndex() { customFolderIndex.clear(); + QSet directories; + + QDir customDir(customPicsPath); + if (customDir.exists()) { + directories.insert(customDir.absolutePath()); + } QDirIterator it(customPicsPath, QDirIterator::Subdirectories | QDirIterator::FollowSymlinks); // Recursively check all subdirectories of the CUSTOM folder while (it.hasNext()) { - QString thisPath(it.next()); - QFileInfo thisFileInfo(thisPath); + const QString thisPath = it.next(); + const QFileInfo thisFileInfo = it.fileInfo(); - if (thisFileInfo.isFile()) { + if (thisFileInfo.isDir()) { + directories.insert(thisFileInfo.absoluteFilePath()); + } else if (thisFileInfo.isFile()) { // We don't know which name is the correctedName because there might be '.'s in the cardName. // Just add all possibilities to be sure. customFolderIndex.insert(thisFileInfo.baseName(), thisFileInfo.absoluteFilePath()); @@ -55,8 +83,34 @@ void CardPictureLoaderLocal::refreshIndex() } } + updateWatchedDirectories(directories); + qCDebug(CardPictureLoaderLocalLog) << "Finished indexing local image folder CUSTOM; map now has" << customFolderIndex.size() << "entries."; + emit indexChanged(); +} + +void CardPictureLoaderLocal::updateWatchedDirectories(const QSet &directories) +{ + const QSet stale = watchedDirectories - directories; + if (!stale.isEmpty()) { + folderWatcher->removePaths(QStringList(stale.begin(), stale.end())); + } + + const QSet added = directories - watchedDirectories; + if (!added.isEmpty()) { + const QStringList failed = folderWatcher->addPaths(QStringList(added.begin(), added.end())); + if (!failed.isEmpty()) { + qCDebug(CardPictureLoaderLocalLog) << "Could not watch custom picture directories:" << failed; + } + } + + watchedDirectories = directories; +} + +QStringList CardPictureLoaderLocal::indexedPaths(const QString &baseName) const +{ + return customFolderIndex.values(baseName); } QImage CardPictureLoaderLocal::tryLoad(const ExactCard &toLoad) const @@ -145,4 +199,5 @@ void CardPictureLoaderLocal::picsPathChanged() { picsPath = SettingsCache::instance().paths().getPicsPath(); customPicsPath = SettingsCache::instance().paths().getCustomPicsPath(); + refreshIndex(); } diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.h b/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.h index 009897acc..35190366d 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.h +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.h @@ -5,10 +5,12 @@ #include #include #include +#include #include #include class ExactCard; +class QFileSystemWatcher; class QTimer; inline Q_LOGGING_CATEGORY(CardPictureLoaderLocalLog, "card_picture_loader.local"); @@ -40,6 +42,18 @@ public: */ explicit CardPictureLoaderLocal(QObject *parent); + /** + * @brief Constructs the loader against explicit folders without SettingsCache. + * + * Used by tests and by callers that already resolved the configured paths. + */ + CardPictureLoaderLocal(QObject *parent, const QString &picsPath, const QString &customPicsPath); + + /** + * @brief Returns every indexed file whose base name matches @p baseName. + */ + [[nodiscard]] QStringList indexedPaths(const QString &baseName) const; + /** * @brief Attempts to load a card image from local disk or custom folders. * @param toLoad ExactCard object representing the card to load. @@ -54,7 +68,10 @@ private: QString customPicsPath; ///< Path to custom card image folder QMultiHash customFolderIndex; ///< Multimap from cardName to file paths in CUSTOM folder - QTimer *refreshTimer; ///< Timer for periodic folder index refresh + QFileSystemWatcher *folderWatcher; ///< Watches the custom folder tree for changes + QTimer *indexRefreshTimer; ///< Debounces bursts of filesystem events + QTimer *fallbackRefreshTimer; ///< Slow poll for filesystems without change notification + QSet watchedDirectories; ///< Directories currently registered with folderWatcher /** * @brief Rebuilds the index of the custom image folder. @@ -65,6 +82,11 @@ private: */ void refreshIndex(); + /** + * @brief Keeps the watcher registered on exactly @p directories. + */ + void updateWatchedDirectories(const QSet &directories); + /** * @brief Attempts to load a card image from disk given its set and name info. * @param setName Corrected short name of the card's set. @@ -82,6 +104,12 @@ private: const QString &collectorNumber, const QString &providerId) const; +signals: + /** + * @brief Emitted after the custom folder index has been rebuilt. + */ + void indexChanged(); + private slots: /** * @brief Updates internal paths when the user changes picture settings. diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp index 150b4c544..3ab5eb684 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp @@ -28,6 +28,7 @@ #include #include #include +#include #include #include #include @@ -46,11 +47,10 @@ CardPictureLoaderWorker::CardPictureLoaderWorker() : QObject(nullptr), picDownload(SettingsCache::instance().downloads().getPicDownload()), hostRequestLimits(SettingsCache::instance().downloads().getHostRequestLimits()) { - networkManager = new QNetworkAccessManager(this); + networkManager = NetworkManagerFactory::create(this); // We need a timeout to ensure requests don't hang indefinitely in case of // cache corruption, see related Qt bug: https://bugreports.qt.io/browse/QTBUG-111397 // Use Qt's default timeout (30s, as of 2023-02-22) - networkManager->setTransferTimeout(); cache = new QNetworkDiskCache(this); cache->setCacheDirectory(SettingsCache::instance().getNetworkCachePath()); cache->setMaximumCacheSize(1024L * 1024L * diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_to_load.cpp b/cockatrice/src/interface/card_picture_loader/card_picture_to_load.cpp index e35c417c8..8e8feb5b5 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_to_load.cpp +++ b/cockatrice/src/interface/card_picture_loader/card_picture_to_load.cpp @@ -21,6 +21,7 @@ #include #include #include +#include CardPictureToLoad::CardPictureToLoad(const ExactCard &_card) : card(_card), urlTemplates(SettingsCache::instance().downloads().getAllURLs()) @@ -74,19 +75,20 @@ QList CardPictureToLoad::extractSetsSorted(const ExactCard &card) //! \todo Check if going through all sets is still necessary after the ExactCard refactor. static PrintingInfo findPrintingForSet(const ExactCard &card, const QString &setName) { - SetToPrintingsMap setsToPrintings = card.getInfo().getSets(); + const auto &setsToPrintings = card.getInfo().getSets(); - if (!setsToPrintings.contains(setName)) { + const auto setPrintings = setsToPrintings.constFind(setName); + if (setPrintings == setsToPrintings.constEnd() || setPrintings.value().isEmpty()) { return PrintingInfo(); } - for (const auto &printing : setsToPrintings[setName]) { + for (const auto &printing : setPrintings.value()) { if (printing.getUuid() == card.getPrinting().getUuid()) { return printing; } } - return setsToPrintings[setName][0]; + return setPrintings.value().first(); } void CardPictureToLoad::populateSetUrls() @@ -183,10 +185,14 @@ static int parse(const QString &urlTemplate, { static const QRegularExpression rxFillWith("^(.+)_fill_with_(.+)$"); static const QRegularExpression rxSubStr("^(.+)_substr_(\\d+)_(\\d+)$"); + static const QRegularExpression rxCardProp("!prop:([^!]+)!"); + static const QRegularExpression rxCardSet("!set:([^!]+)!"); - const QRegularExpression rxCardProp("!" + propType + ":([^!]+)!"); + // The property type only takes two values, so keep the expression that was + // compiled for it instead of rebuilding one per URL template. + const QRegularExpression &rxCardPropSelector = propType == "prop" ? rxCardProp : rxCardSet; - auto matches = rxCardProp.globalMatch(urlTemplate); + auto matches = rxCardPropSelector.globalMatch(urlTemplate); while (matches.hasNext()) { auto match = matches.next(); QString templatePropertyName = match.captured(1); @@ -303,10 +309,10 @@ QString CardPictureToLoad::transformUrl(const QString &urlTemplate) const } QString transformedUrl = urlTemplate; - for (const QString &prop : transformMap.keys()) { + for (const auto &[prop, value] : std::as_const(transformMap).asKeyValueRange()) { if (transformedUrl.contains(prop)) { - if (!transformMap[prop].isEmpty()) { - transformedUrl.replace(prop, QUrl::toPercentEncoding(transformMap[prop])); + if (!value.isEmpty()) { + transformedUrl.replace(prop, QUrl::toPercentEncoding(value)); } else { /* This means the template is requesting information that is not * populated in this card, so it should return an empty string, diff --git a/cockatrice/src/interface/deck_loader/deck_loader.h b/cockatrice/src/interface/deck_loader/deck_loader.h index 16402b14b..39a12bed4 100644 --- a/cockatrice/src/interface/deck_loader/deck_loader.h +++ b/cockatrice/src/interface/deck_loader/deck_loader.h @@ -7,13 +7,14 @@ #ifndef DECK_LOADER_H #define DECK_LOADER_H -#include "deck_file_format.h" -#include "loaded_deck.h" - #include #include +#include #include #include +#include +#include +#include #include #include diff --git a/cockatrice/src/interface/intents/intent_login.cpp b/cockatrice/src/interface/intents/intent_login.cpp index 0d8390015..8cc574cb0 100644 --- a/cockatrice/src/interface/intents/intent_login.cpp +++ b/cockatrice/src/interface/intents/intent_login.cpp @@ -27,9 +27,9 @@ void IntentGetLoginCredentials::onPreconditionSatisfied() ServersSettings &servers = SettingsCache::instance().servers(); const int index = servers.findServerIndex(context->hostname, context->port); - if (index >= 0) { - context->username = servers.getValue(QString("username%1").arg(index), "server", "server_details").toString(); - context->password = servers.getValue(QString("password%1").arg(index), "server", "server_details").toString(); + if (const auto server = servers.getServer(index)) { + context->username = server->username; + context->password = server->password; emitFinished(); } else { emitFailed(tr("No saved credentials for this server")); @@ -69,9 +69,8 @@ void IntentGetLoginCredentials::onPreconditionNotSatisfied() // it with the raw hostname when addNewServer updates the entry in place. QString saveName = context->hostname; const int existingIndex = servers.findServerIndex(context->hostname, context->port); - if (existingIndex >= 0) { - saveName = - servers.getValue(QString("saveName%1").arg(existingIndex), "server", "server_details").toString(); + if (const auto existing = servers.getServer(existingIndex)) { + saveName = existing->saveName; if (saveName.isEmpty()) { saveName = context->hostname; } diff --git a/cockatrice/src/interface/intents/intent_open_local_deck.cpp b/cockatrice/src/interface/intents/intent_open_local_deck.cpp index 5b673d106..20704a551 100644 --- a/cockatrice/src/interface/intents/intent_open_local_deck.cpp +++ b/cockatrice/src/interface/intents/intent_open_local_deck.cpp @@ -1,14 +1,14 @@ #include "intent_open_local_deck.h" -#include "../deck_loader/deck_file_format.h" #include "../deck_loader/deck_loader.h" -#include "../deck_loader/loaded_deck.h" #include "../widgets/tabs/tab_supervisor.h" #include "intent_wait_for_database_load.h" #include "libcockatrice/card/database/card_database.h" #include "libcockatrice/card/database/card_database_loader.h" #include +#include +#include #include IntentOpenLocalDeck::IntentOpenLocalDeck(TabSupervisor *_tabSupervisor, const QString &_file) diff --git a/cockatrice/src/interface/intents/intent_open_shared_deck.cpp b/cockatrice/src/interface/intents/intent_open_shared_deck.cpp index 8abad3d88..2998ff78e 100644 --- a/cockatrice/src/interface/intents/intent_open_shared_deck.cpp +++ b/cockatrice/src/interface/intents/intent_open_shared_deck.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include #include @@ -184,13 +185,11 @@ void IntentOpenSharedDeck::onItemFailure(const QString &reason) } const int downloadedCount = loadedDecks.size(); - const QMessageBox::StandardButton answer = QMessageBox::question( - previewDialog, tr("Open shared decks"), - tr("Could not download the deck \"%1\".\n\n%n deck(s) were already downloaded. Open them?", "", downloadedCount) - .arg(itemNames.value(currentItemId, tr("Unknown deck"))), - QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes); - - if (answer == QMessageBox::Yes) { + if (QtUtils::confirm(previewDialog, tr("Open shared decks"), + tr("Could not download the deck \"%1\".\n\n%n deck(s) were already downloaded. Open them?", "", + downloadedCount) + .arg(itemNames.value(currentItemId, tr("Unknown deck"))), + QMessageBox::Yes)) { finishAll(); } else { previewDialog->deleteLater(); diff --git a/cockatrice/src/interface/intents/intent_open_shared_deck.h b/cockatrice/src/interface/intents/intent_open_shared_deck.h index afce2370f..2d9dc5528 100644 --- a/cockatrice/src/interface/intents/intent_open_shared_deck.h +++ b/cockatrice/src/interface/intents/intent_open_shared_deck.h @@ -1,7 +1,6 @@ #ifndef COCKATRICE_INTENT_OPEN_SHARED_DECK_H #define COCKATRICE_INTENT_OPEN_SHARED_DECK_H -#include "../deck_loader/loaded_deck.h" #include "contexts/context_open_deck.h" #include "intent.h" @@ -9,6 +8,7 @@ #include #include #include +#include #include #include diff --git a/cockatrice/src/interface/intents/url_parser.cpp b/cockatrice/src/interface/intents/url_parser.cpp index 966fd1213..e0b7bb2fb 100644 --- a/cockatrice/src/interface/intents/url_parser.cpp +++ b/cockatrice/src/interface/intents/url_parser.cpp @@ -28,6 +28,7 @@ #include #include #include +#include #include #include #include @@ -115,9 +116,7 @@ Intent *IntentUrlParser::createJoinGameIntent(const QUrlQuery &query, PendingInt const QString gameDescription = query.queryItemValue("game", QUrl::FullyDecoded); const QString message = generateJoinGameMessage(*ctx, gameDescription); - const QMessageBox::StandardButton answer = QMessageBox::question( - mainWindow, tr("Join game"), message, QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes); - if (answer != QMessageBox::Yes) { + if (!QtUtils::confirm(mainWindow, tr("Join game"), message, QMessageBox::Yes)) { return nullptr; } @@ -231,11 +230,10 @@ Intent *IntentUrlParser::createOpenDeckIntent(const QUrlQuery &query, PendingInt if (client->getStatus() == StatusLoggedIn) { const QString current = QStringLiteral("%1:%2").arg(client->serverName(), QString::number(client->serverPort())); - const QMessageBox::StandardButton answer = QMessageBox::question( - mainWindow, tr("Open shared deck"), - tr("Opening this share link connects you to %1 instead of %2.\n\nContinue?").arg(target, current), - QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes); - if (answer != QMessageBox::Yes) { + if (!QtUtils::confirm( + mainWindow, tr("Open shared deck"), + tr("Opening this share link connects you to %1 instead of %2.\n\nContinue?").arg(target, current), + QMessageBox::Yes)) { return nullptr; } chain.migrationTargetHost = ctx->serverContext.hostname; @@ -420,12 +418,10 @@ void IntentUrlParser::restoreToPreviousServer(const PendingIntentChain &chain) ServersSettings &servers = SettingsCache::instance().servers(); const int index = servers.findServerIndex(chain.previousServerHost, chain.previousServerPort); - if (index >= 0 && servers.hasLoginData(chain.previousServerHost, chain.previousServerPort)) { - const QString username = - servers.getValue(QString("username%1").arg(index), "server", "server_details").toString(); - const QString password = - servers.getValue(QString("password%1").arg(index), "server", "server_details").toString(); - client->connectToServer(chain.previousServerHost, chain.previousServerPort.toUInt(), username, password); + const auto server = servers.getServer(index); + if (server && servers.hasLoginData(chain.previousServerHost, chain.previousServerPort)) { + client->connectToServer(chain.previousServerHost, chain.previousServerPort.toUInt(), server->username, + server->password); return; } client->disconnectFromServer(); @@ -440,11 +436,9 @@ void IntentUrlParser::restoreToPreviousServer(const PendingIntentChain &chain) // when credentials are saved, otherwise stay offline. ServersSettings &servers = SettingsCache::instance().servers(); const int index = servers.findServerIndex(chain.previousServerHost, chain.previousServerPort); - if (index >= 0 && servers.hasLoginData(chain.previousServerHost, chain.previousServerPort)) { - const QString username = - servers.getValue(QString("username%1").arg(index), "server", "server_details").toString(); - const QString password = - servers.getValue(QString("password%1").arg(index), "server", "server_details").toString(); - client->connectToServer(chain.previousServerHost, chain.previousServerPort.toUInt(), username, password); + const auto server = servers.getServer(index); + if (server && servers.hasLoginData(chain.previousServerHost, chain.previousServerPort)) { + client->connectToServer(chain.previousServerHost, chain.previousServerPort.toUInt(), server->username, + server->password); } } diff --git a/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp b/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp index 20e16614a..b6271291d 100644 --- a/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp +++ b/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp @@ -25,6 +25,7 @@ #include #include #include +#include #include PaletteEditorDialog::PaletteEditorDialog(const QString &_themeDirPath, const QString &_themeName, QWidget *parent) @@ -57,9 +58,7 @@ PaletteEditorDialog::PaletteEditorDialog(const QString &_themeDirPath, const QSt void PaletteEditorDialog::setupUi() { - auto *root = new QVBoxLayout(this); - root->setSpacing(0); - root->setContentsMargins(0, 0, 0, 0); + auto *root = QtUtils::makeLayout(this, 0, 0); // Header header = new QWidget; diff --git a/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp b/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp index de069bc80..463c4038d 100644 --- a/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp +++ b/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include @@ -65,8 +66,7 @@ static const QMap APP_ROLE_DESCRIPTIONS = { PaletteGridWidget::PaletteGridWidget(QWidget *parent) : QWidget(parent) { - scroll = new QScrollArea(this); - scroll->setWidgetResizable(true); + scroll = QtUtils::makeScrollArea(this, Qt::ScrollBarAsNeeded, Qt::ScrollBarAsNeeded); scroll->setFrameShape(QFrame::NoFrame); gridHost = new QWidget; @@ -74,8 +74,7 @@ PaletteGridWidget::PaletteGridWidget(QWidget *parent) : QWidget(parent) refreshChromePalettes(); scroll->setWidget(gridHost); - layout = new QVBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); + layout = QtUtils::makeLayout(this); layout->addWidget(scroll); } @@ -102,9 +101,7 @@ void PaletteGridWidget::buildGrid(QWidget *host) auto *label = new QLabel(groupHeaders[col], host); label->setAlignment(Qt::AlignCenter); label->setToolTip(groupTips[col]); - QFont f = label->font(); - f.setBold(true); - label->setFont(f); + label->setFont(QtUtils::boldFont(label->font())); label->setAutoFillBackground(true); label->setContentsMargins(4, 4, 4, 4); grid->addWidget(label, 0, col + 1); @@ -149,9 +146,7 @@ void PaletteGridWidget::buildGrid(QWidget *host) auto *appHeader = new QLabel(tr("App colors"), host); appHeader->setToolTip(tr("Application-specific colors layered on top of the Qt palette")); - QFont appHeaderFont = appHeader->font(); - appHeaderFont.setBold(true); - appHeader->setFont(appHeaderFont); + appHeader->setFont(QtUtils::boldFont(appHeader->font())); appHeader->setAutoFillBackground(true); appHeader->setContentsMargins(4, 4, 4, 4); grid->addWidget(appHeader, appHeaderRow, 0, 1, 4); diff --git a/cockatrice/src/interface/pixel_map_generator.cpp b/cockatrice/src/interface/pixel_map_generator.cpp index 2f5c4b6dd..037293af9 100644 --- a/cockatrice/src/interface/pixel_map_generator.cpp +++ b/cockatrice/src/interface/pixel_map_generator.cpp @@ -1,6 +1,7 @@ #include "pixel_map_generator.h" #include "libcockatrice/network/server/remote/user_level.h" +#include "theme_icon.h" #include "theme_manager.h" #include @@ -15,7 +16,6 @@ #include #include #include -#include #include #include #include @@ -436,33 +436,19 @@ QIcon UserLevelPixmapGenerator::generateIconWithColorOverride(int height, QMap UserLevelPixmapGenerator::iconCache; -QPixmap LockPixmapGenerator::generatePixmap(int height) -{ - - int key = height; - if (pmCache.contains(key)) { - return pmCache.value(key); - } - - QPixmap pixmap = tryLoadImage(QStringLiteral("theme:") + themeManager->assetPath(QStringLiteral("icons/lock")), - QSize(height, height), true); - pmCache.insert(key, pixmap); - return pixmap; -} - -QMap LockPixmapGenerator::pmCache; - QPixmap DropdownIconPixmapGenerator::generatePixmap(int height, bool expanded) { - QString key = QString::number(expanded) + ":" + QString::number(height); + const QString key = QString::number(expanded) + ":" + QString::number(height); if (pmCache.contains(key)) { return pmCache.value(key); } - QString name = expanded ? "dropdown_expanded" : "dropdown_collapsed"; - QPixmap pixmap = tryLoadImage(QStringLiteral("theme:") + themeManager->assetPath(QStringLiteral("icons/") + name), - QSize(height, height), true); - + // Render through the themed icon engine: it keeps the glyph's aspect ratio, + // follows the palette, and renders at the device pixel ratio instead of + // handing a DPR-1 bitmap to a label that would upscale and blur it on a 2x + // display. + const QString prefix = QStringLiteral("icons/") + (expanded ? "dropdown_expanded" : "dropdown_collapsed"); + const QPixmap pixmap = loadThemedIcon(prefix).pixmap(QSize(height, height), qApp->devicePixelRatio()); pmCache.insert(key, pixmap); return pixmap; } @@ -520,30 +506,11 @@ QPixmap ManaSymbolPixmapGenerator::generatePixmap(const QString &symbol, const Q QHash ManaSymbolPixmapGenerator::masterCache; QHash ManaSymbolPixmapGenerator::scaledCache; -QPixmap loadColorAdjustedPixmap(const QString &name) -{ - // Prefer an authored scheme-qualified variant when one exists for this asset. - const QString variant = themeManager->schemeVariantPath(QStringView(name).mid(QStringLiteral("theme:").size())); - if (!variant.isEmpty()) { - return QPixmap(QStringLiteral("theme:") + variant); - } - - // Legacy fallback: runtime-invert for dark mode when no authored variant. - if (qApp->palette().windowText().color().lightness() > 200) { - QImage img(name); - img.invertPixels(); - QPixmap result; - result.convertFromImage(img); - return result; - } else { - return QPixmap(name); - } -} - QPixmap themePixmap(QStringView prefix) { const QString resolved = themeManager->assetPath(prefix); - return QPixmap(QStringLiteral("theme:") + resolved); + const QPixmap sentinel = loadSentinelSvgPixmap(resolved); + return sentinel.isNull() ? QPixmap(QStringLiteral("theme:") + resolved) : sentinel; } void clearPixmapGeneratorCaches() @@ -553,7 +520,6 @@ void clearPixmapGeneratorCaches() PingPixmapGenerator::clear(); CountryPixmapGenerator::clear(); UserLevelPixmapGenerator::clear(); - LockPixmapGenerator::clear(); DropdownIconPixmapGenerator::clear(); ManaSymbolPixmapGenerator::clear(); } diff --git a/cockatrice/src/interface/pixel_map_generator.h b/cockatrice/src/interface/pixel_map_generator.h index 7c01bb332..951f4231b 100644 --- a/cockatrice/src/interface/pixel_map_generator.h +++ b/cockatrice/src/interface/pixel_map_generator.h @@ -106,19 +106,6 @@ public: } }; -class LockPixmapGenerator -{ -private: - static QMap pmCache; - -public: - static QPixmap generatePixmap(int height); - static void clear() - { - pmCache.clear(); - } -}; - class DropdownIconPixmapGenerator { private: @@ -160,13 +147,11 @@ public: } }; -QPixmap loadColorAdjustedPixmap(const QString &name); - -// Loads a "theme:" asset (with no file extension in prefix), preferring the +// Returns a "theme:" asset (with no file extension in prefix), preferring the // scheme-qualified variant (prefix-dark / prefix-light, resolved via -// ThemeManager::assetPath) and falling back to the plain asset. Callers load -// the returned path directly. Use for scheme-sensitive pixmaps like -// backgrounds, the card back, and the app logo. +// ThemeManager::assetPath) and falling back to the plain asset. Token-driven +// SVGs resolve their `currentColor` sentinel from the palette. Use for +// scheme-sensitive pixmaps like backgrounds, the card back, and the app logo. QPixmap themePixmap(QStringView prefix); // Clears every PixmapGenerator's static cache so scheme variants are diff --git a/cockatrice/src/interface/theme_icon.cpp b/cockatrice/src/interface/theme_icon.cpp new file mode 100644 index 000000000..161c436ca --- /dev/null +++ b/cockatrice/src/interface/theme_icon.cpp @@ -0,0 +1,300 @@ +#include "theme_icon.h" + +#include "libcockatrice/utility/svg_recolor.h" +#include "theme_manager.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +/** + * @brief Palette color that replaces `currentColor`, following the icon mode. + */ +QByteArray sentinelColor(QIcon::Mode mode, QPalette::ColorRole role) +{ + const QPalette::ColorGroup group = mode == QIcon::Disabled ? QPalette::Disabled : QPalette::Active; + const QPalette::ColorRole effective = + mode == QIcon::Selected && role == QPalette::WindowText ? QPalette::HighlightedText : role; + return qApp->palette().color(group, effective).name(QColor::HexRgb).toLatin1(); +} + +/** + * @brief Reads an SVG through the `theme:` search path. + */ +QByteArray readSvg(const QString &resolvedPath) +{ + QFile file(QStringLiteral("theme:") + resolvedPath); + if (!file.open(QIODevice::ReadOnly)) { + return {}; + } + return file.readAll(); +} + +/** + * @brief Renders SVG bytes, scaled to @p size when one is given. + * + * An empty size means the asset's intrinsic size; @p devicePixelRatio only + * applies in that case so HiDPI callers still get a crisp glyph. + */ +QPixmap renderSvg(const QByteArray &data, const QSize &size, qreal devicePixelRatio) +{ + QSvgRenderer renderer(data); + if (!renderer.isValid()) { + return {}; + } + + QSize renderSize = size; + if (renderSize.isEmpty()) { + renderSize = renderer.defaultSize(); + renderSize = + QSize(qRound(renderSize.width() * devicePixelRatio), qRound(renderSize.height() * devicePixelRatio)); + } + if (!renderSize.isValid() || renderSize.isEmpty()) { + return {}; + } + + QPixmap pixmap(renderSize); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + renderer.render(&painter); + return pixmap; +} + +bool hasSentinel(const QByteArray &svg) +{ + return !svg.isEmpty() && QString::fromUtf8(svg).contains(QStringLiteral("currentColor"), Qt::CaseInsensitive); +} + +QString cacheKey(const QString &path, const QByteArray &variant, const QSize &size, qreal devicePixelRatio) +{ + return path + QLatin1Char('|') + QString::fromLatin1(variant) + QLatin1Char('|') + QString::number(size.width()) + + QLatin1Char('x') + QString::number(size.height()) + QLatin1Char('@') + QString::number(devicePixelRatio); +} + +/** + * @brief QIcon engine rendering a `theme:` SVG with `currentColor` resolved on demand. + * + * Qt's SVG renderer has no ambient color, so the sentinel is substituted from + * the current palette on every pixmap request. Asset resolution, the file bytes + * and the intrinsic size are cached until the theme changes: a repaint then + * does no file I/O at all. The render cache is keyed by resolved path and pixel + * size plus a variant: the palette color for sentinel SVGs, the icon mode for + * assets that carry their own colors. A palette change therefore produces new + * keys instead of reusing art built under the previous palette, and the + * non-sentinel path (raster overrides, plain SVGs) is cached like any other. + */ +class ThemeSvgIconEngine final : public QObject, public QIconEngine +{ +public: + ThemeSvgIconEngine(QString _prefix, QPalette::ColorRole _role) : prefix(std::move(_prefix)), role(_role) + { + if (themeManager) { + // A theme switch changes the resolved asset (scheme-qualified + // variants, raster overrides) and a palette change invalidates + // every cached colour, so both drop the caches instead of + // re-reading theme.cfg and the assets on every repaint. + connect(themeManager, &ThemeManager::themeChanged, this, &ThemeSvgIconEngine::invalidate); + connect(themeManager, &ThemeManager::paletteChanged, this, &ThemeSvgIconEngine::invalidate); + } + } + + QIconEngine *clone() const override + { + return new ThemeSvgIconEngine(prefix, role); + } + + void paint(QPainter *painter, const QRect &rect, QIcon::Mode mode, QIcon::State) override + { + const qreal devicePixelRatio = painter->device() ? painter->device()->devicePixelRatioF() : 1.0; + const QSize logicalSize = fitToSize(rect.size()); + if (logicalSize.isEmpty()) { + return; + } + const QSize pixelSize(qRound(logicalSize.width() * devicePixelRatio), + qRound(logicalSize.height() * devicePixelRatio)); + const QPixmap pixmap = render(pixelSize, devicePixelRatio, mode); + if (!pixmap.isNull()) { + // QRect::center() would floor and drop the last row/column of a + // 16x16 glyph; the center has to be fractional so glyphs stay + // unshifted and unsclipped in item views. + const QSizeF contentSize = pixmap.deviceIndependentSize(); + const QRectF targetRect( + QRectF(rect).center() - QPointF(contentSize.width() / 2.0, contentSize.height() / 2.0), contentSize); + painter->drawPixmap(targetRect, pixmap, QRectF(pixmap.rect())); + } + } + + QPixmap pixmap(const QSize &size, QIcon::Mode mode, QIcon::State) override + { + return render(fitToSize(size), 1.0, mode); + } + + QPixmap scaledPixmap(const QSize &size, QIcon::Mode mode, QIcon::State, qreal scale) override + { + const QSize logicalSize = fitToSize(size); +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) + const QSize pixelSize(qRound(logicalSize.width() * scale), qRound(logicalSize.height() * scale)); +#else + // Qt < 6.8 passes the device-dependent size to the engine. + const QSize pixelSize = logicalSize; +#endif + return render(pixelSize, scale, mode); + } + + QSize actualSize(const QSize &size, QIcon::Mode, QIcon::State) override + { + return fitToSize(size); + } + +private: + void invalidate() + { + hasResolvedAsset = false; + hasIntrinsicSize = false; + cache.clear(); + } + + void ensureResolved() const + { + if (hasResolvedAsset && (!themeManager || themeManager->getCurrentThemePath() == resolvedThemePath)) { + return; + } + resolvedThemePath = themeManager ? themeManager->getCurrentThemePath() : QString(); + resolvedPath = themeManager ? themeManager->assetPath(prefix) : QString(); + resolvedAssetIsSvg = resolvedPath.endsWith(QStringLiteral(".svg"), Qt::CaseInsensitive); + resolvedSvg = resolvedAssetIsSvg ? readSvg(resolvedPath) : QByteArray(); + resolvedHasSentinel = hasSentinel(resolvedSvg); + hasResolvedAsset = true; + hasIntrinsicSize = false; + } + + QSize intrinsicSize() const + { + ensureResolved(); + if (!hasIntrinsicSize) { + if (resolvedAssetIsSvg) { + QSvgRenderer renderer(resolvedSvg); + intrinsicSizeCache = renderer.isValid() ? renderer.defaultSize() : QSize(); + } else { + // A theme may override a glyph with a raster; take its real + // aspect ratio instead of assuming the 24x24 SVG default, so + // actualSize() and the rendered pixmap agree. + QImageReader reader(QStringLiteral("theme:") + resolvedPath); + intrinsicSizeCache = reader.size(); + } + if (intrinsicSizeCache.isEmpty()) { + intrinsicSizeCache = QSize(24, 24); + } + hasIntrinsicSize = true; + } + return intrinsicSizeCache; + } + + QSize fitToSize(const QSize &size) const + { + if (size.isEmpty()) { + return size; + } + return intrinsicSize().scaled(size, Qt::KeepAspectRatio); + } + + QPixmap fromAsset(const QSize &pixelSize, QIcon::Mode mode) const + { + QPixmap pixmap(QStringLiteral("theme:") + resolvedPath); + if (!pixelSize.isEmpty() && pixmap.size() != pixelSize) { + pixmap = pixmap.scaled(pixelSize, Qt::KeepAspectRatio, Qt::SmoothTransformation); + } + if (mode == QIcon::Disabled && !pixmap.isNull()) { + pixmap = QApplication::style()->generatedIconPixmap(QIcon::Disabled, pixmap, nullptr); + } + return pixmap; + } + + QPixmap render(const QSize &pixelSize, qreal devicePixelRatio, QIcon::Mode mode) const + { + ensureResolved(); + + // Same cache for both paths: sentinel art varies by palette color, art + // with its own colors varies by mode (Disabled is generated). A color + // variant is never a bare digit, so the two key spaces cannot collide. + const QByteArray variant = + resolvedHasSentinel ? sentinelColor(mode, role) : QByteArray::number(static_cast(mode)); + const QString key = cacheKey(resolvedPath, variant, pixelSize, devicePixelRatio); + const auto cached = cache.constFind(key); + if (cached != cache.constEnd()) { + return cached.value(); + } + + QPixmap pixmap; + if (resolvedHasSentinel) { + pixmap = renderSvg(resolveSvgColorSentinel(resolvedSvg, variant), pixelSize, devicePixelRatio); + } else { + // Authored art carries its own colors: use Qt's normal loader so + // raster overrides and non-SVG assets keep working. + pixmap = fromAsset(pixelSize, mode); + } + if (!pixmap.isNull()) { + // Set the ratio before caching: assigning it to a cached pixmap + // afterwards detaches (deep-copies) it on every paint. + pixmap.setDevicePixelRatio(devicePixelRatio); + } + if (cache.size() >= 64) { + cache.clear(); + } + cache.insert(key, pixmap); + return pixmap; + } + + QString prefix; + QPalette::ColorRole role; + mutable QHash cache; + mutable QString resolvedPath; + mutable QString resolvedThemePath; + mutable QByteArray resolvedSvg; + mutable QSize intrinsicSizeCache; + mutable bool resolvedAssetIsSvg = false; + mutable bool resolvedHasSentinel = false; + mutable bool hasResolvedAsset = false; + mutable bool hasIntrinsicSize = false; +}; +} // namespace + +QIcon loadThemedIcon(QStringView prefix, QPalette::ColorRole role) +{ + return QIcon(new ThemeSvgIconEngine(prefix.toString(), role)); +} + +QPixmap loadSentinelSvgPixmap(const QString &resolvedPath, QPalette::ColorRole role, const QSize &size) +{ + if (!resolvedPath.endsWith(QStringLiteral(".svg"), Qt::CaseInsensitive)) { + return {}; + } + + const QByteArray svg = readSvg(resolvedPath); + if (!hasSentinel(svg)) { + return {}; + } + + const QByteArray color = qApp->palette().color(role).name(QColor::HexRgb).toLatin1(); + return renderSvg(resolveSvgColorSentinel(svg, color), size, 1.0); +} diff --git a/cockatrice/src/interface/theme_icon.h b/cockatrice/src/interface/theme_icon.h new file mode 100644 index 000000000..e26015ea1 --- /dev/null +++ b/cockatrice/src/interface/theme_icon.h @@ -0,0 +1,50 @@ +/** + * @file theme_icon.h + * @ingroup Interface + */ + +#ifndef THEME_ICON_H +#define THEME_ICON_H + +#include +#include +#include +#include +#include +#include + +/** + * @brief Builds a palette-aware icon from a `theme:` asset. + * + * SVG glyphs may use the `currentColor` sentinel. A plain QPixmap snapshot + * cannot follow palette changes, so this returns a QIcon whose engine resolves + * the sentinel from the current palette every time it is asked for a pixmap. + * Icons therefore stay legible when the user switches between light and dark + * themes without recreating any actions or buttons. + * + * @param prefix Asset prefix relative to the `theme:` search path, with no file + * extension (for example `icons/search`), matching themePixmap(). + * @param role Palette role whose color replaces the sentinel. Selected glyphs + * use HighlightedText unless a role other than WindowText is given. + * @return An icon that follows the application palette. + */ +QIcon loadThemedIcon(QStringView prefix, QPalette::ColorRole role = QPalette::WindowText); + +/** + * @brief Renders a resolved theme SVG when it carries the `currentColor` sentinel. + * + * Non-SVG assets and SVGs without the sentinel return a null pixmap so callers + * can fall back to their usual loading path (including legacy inversion). + * + * @param resolvedPath Asset path relative to the `theme:` search path, as + * returned by ThemeManager::assetPath (includes extension). + * @param role Palette role whose color replaces the sentinel. + * @param size Pixel size to render at; an empty size uses the SVG's intrinsic size. + * @return The recolored pixmap, or a null pixmap when the asset is not a + * sentinel-carrying SVG. + */ +QPixmap loadSentinelSvgPixmap(const QString &resolvedPath, + QPalette::ColorRole role = QPalette::WindowText, + const QSize &size = {}); + +#endif // THEME_ICON_H diff --git a/cockatrice/src/interface/theme_manager.cpp b/cockatrice/src/interface/theme_manager.cpp index 9a96b3e45..893cdc795 100644 --- a/cockatrice/src/interface/theme_manager.cpp +++ b/cockatrice/src/interface/theme_manager.cpp @@ -558,6 +558,9 @@ void ThemeManager::applyStyleAndPalette(const QString &themeName, widget->update(); } + // Palette-only previews do not run themeChangedSlot(), so drop snapshots + // generated under the previous palette here as well. + clearPixmapGeneratorCaches(); emit paletteChanged(); } diff --git a/cockatrice/src/interface/widgets/cards/additional_info/color_identity_widget.cpp b/cockatrice/src/interface/widgets/cards/additional_info/color_identity_widget.cpp index ef7fb0e0e..0f68f3626 100644 --- a/cockatrice/src/interface/widgets/cards/additional_info/color_identity_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/additional_info/color_identity_widget.cpp @@ -23,9 +23,7 @@ ColorIdentityWidget::ColorIdentityWidget(QWidget *parent, const QString &_colorIdentity) : QWidget(parent), colorIdentity(_colorIdentity) { - layout = new QHBoxLayout(this); - layout->setSpacing(5); // Small spacing between icons - layout->setContentsMargins(0, 0, 0, 0); + layout = QtUtils::makeLayout(this, 5); layout->setAlignment(Qt::AlignCenter); // Ensure icons are centered setLayout(layout); diff --git a/cockatrice/src/interface/widgets/cards/additional_info/mana_cost_widget.cpp b/cockatrice/src/interface/widgets/cards/additional_info/mana_cost_widget.cpp index 7da97f88c..f6efd267e 100644 --- a/cockatrice/src/interface/widgets/cards/additional_info/mana_cost_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/additional_info/mana_cost_widget.cpp @@ -11,12 +11,11 @@ #include #include #include +#include ManaCostWidget::ManaCostWidget(QWidget *parent, CardInfoPtr _card) : QWidget(parent), card(_card) { - layout = new QHBoxLayout(this); - layout->setSpacing(5); // Small spacing between icons - layout->setContentsMargins(0, 0, 0, 0); + layout = QtUtils::makeLayout(this, 5); setLayout(layout); setFixedHeight(50); // Fixed height diff --git a/cockatrice/src/interface/widgets/cards/card_info_display_widget.cpp b/cockatrice/src/interface/widgets/cards/card_info_display_widget.cpp index 7ec2ee38e..038f08612 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_display_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_info_display_widget.cpp @@ -17,6 +17,7 @@ #include #include #include +#include class QWidget; @@ -30,9 +31,8 @@ CardInfoDisplayWidget::CardInfoDisplayWidget(const CardRef &cardRef, QWidget *pa text->setObjectName("text"); connect(text, &CardInfoTextWidget::linkActivated, this, [this](const QString &card) { setCard({card}); }); - auto *layout = new QVBoxLayout(); + auto *layout = QtUtils::makeLayout(); layout->setObjectName("layout"); - layout->setContentsMargins(0, 0, 0, 0); layout->setSpacing(0); layout->addWidget(pic, 0, Qt::AlignCenter); layout->addWidget(text, 0, Qt::AlignCenter); diff --git a/cockatrice/src/interface/widgets/cards/card_info_frame_widget.cpp b/cockatrice/src/interface/widgets/cards/card_info_frame_widget.cpp index 0f66eb209..80bc4b9d0 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_frame_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_info_frame_widget.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include struct CardRef; @@ -49,15 +50,13 @@ CardInfoFrameWidget::CardInfoFrameWidget(QWidget *parent) insertTab(ImageAndTextView, tab3, QString()); connect(this, &CardInfoFrameWidget::currentChanged, this, &CardInfoFrameWidget::setViewMode); - tab1Layout = new QVBoxLayout(); + tab1Layout = QtUtils::makeLayout(); tab1Layout->setObjectName("tab1Layout"); - tab1Layout->setContentsMargins(0, 0, 0, 0); tab1Layout->setSpacing(0); tab1->setLayout(tab1Layout); - tab2Layout = new QVBoxLayout(); + tab2Layout = QtUtils::makeLayout(); tab2Layout->setObjectName("tab2Layout"); - tab2Layout->setContentsMargins(0, 0, 0, 0); tab2Layout->setSpacing(0); tab2->setLayout(tab2Layout); @@ -65,9 +64,8 @@ CardInfoFrameWidget::CardInfoFrameWidget(QWidget *parent) splitter->setObjectName("splitter"); splitter->setOrientation(Qt::Vertical); - tab3Layout = new QVBoxLayout(); + tab3Layout = QtUtils::makeLayout(); tab3Layout->setObjectName("tab3Layout"); - tab3Layout->setContentsMargins(0, 0, 0, 0); tab3Layout->setSpacing(0); tab3Layout->addWidget(splitter); tab3->setLayout(tab3Layout); diff --git a/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp b/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp index 3c1e1b77f..8f02ebcde 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp @@ -43,11 +43,6 @@ #include #include -static constexpr qreal MTG_CARD_ASPECT_RATIO = 1.396; -// static constexpr qreal YUGIOH_CARD_ASPECT_RATIO = 1.457; -static constexpr qreal ASPECT_RATIO = MTG_CARD_ASPECT_RATIO; - -static constexpr int BASE_WIDTH = 200; static constexpr int BASE_HEIGHT = 200; static constexpr int ENLARGED_PIXMAP_OFFSET = 10; @@ -162,12 +157,11 @@ void CardInfoPictureWidget::resizeEvent(QResizeEvent *event) */ void CardInfoPictureWidget::setScaleFactor(const int scale) { - const int newWidth = BASE_WIDTH * scale / 100; - const int newHeight = static_cast(newWidth * ASPECT_RATIO); + const QSize newSize = sizeForScaleFactor(scale); scaleFactor = scale; - setFixedSize(newWidth, newHeight); + setFixedSize(newSize); updatePixmap(); emit cardScaleFactorChanged(scale); @@ -266,8 +260,13 @@ void CardInfoPictureWidget::paintEvent(QPaintEvent *event) */ QSize CardInfoPictureWidget::sizeHint() const { - return {static_cast(BASE_WIDTH * scaleFactor / 100.0), - static_cast(BASE_WIDTH * scaleFactor / 100.0 * ASPECT_RATIO)}; + return sizeForScaleFactor(scaleFactor); +} + +QSize CardInfoPictureWidget::sizeForScaleFactor(int scaleFactor) +{ + const int width = BASE_WIDTH * scaleFactor / 100; + return {width, static_cast(width * ASPECT_RATIO)}; } /** @@ -491,7 +490,14 @@ void CardInfoPictureWidget::showEnlargedPixmap() enlargedPixmapWidget->setCardPixmap(exactCard, enlargedSize); const QPoint cursorPos = QCursor::pos(); - const QRect screenGeometry = QGuiApplication::screenAt(cursorPos)->geometry(); + QScreen *screen = QGuiApplication::screenAt(cursorPos); + if (!screen) { + screen = QGuiApplication::primaryScreen(); + } + if (!screen) { + return; + } + const QRect screenGeometry = screen->geometry(); const QSize widgetSize = enlargedPixmapWidget->size(); int newX = cursorPos.x() + ENLARGED_PIXMAP_OFFSET; diff --git a/cockatrice/src/interface/widgets/cards/card_info_picture_widget.h b/cockatrice/src/interface/widgets/cards/card_info_picture_widget.h index 8d2d63736..0eb731d92 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_picture_widget.h +++ b/cockatrice/src/interface/widgets/cards/card_info_picture_widget.h @@ -28,6 +28,11 @@ class CardInfoPictureWidget : public QWidget Q_OBJECT public: + /// Width of a card picture at 100 % scale, in pixels. + static constexpr int BASE_WIDTH = 200; + /// Height / width of a card picture. + static constexpr qreal ASPECT_RATIO = 1.396; + explicit CardInfoPictureWidget(QWidget *parent = nullptr, bool hoverToZoomEnabled = false, bool raiseOnEnter = false); @@ -37,6 +42,12 @@ public: } [[nodiscard]] QSize sizeHint() const override; + /** + * @brief The size a card picture has at @p scaleFactor percent. + * @param scaleFactor Percentage of BASE_WIDTH, as set by the card size slider. + */ + [[nodiscard]] static QSize sizeForScaleFactor(int scaleFactor); + public slots: void setCard(const ExactCard &card); void setScaleFactor(int scale); // New slot for scaling diff --git a/cockatrice/src/interface/widgets/cards/card_info_text_widget.cpp b/cockatrice/src/interface/widgets/cards/card_info_text_widget.cpp index fa6cde311..218d0e964 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_text_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_info_text_widget.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include @@ -33,9 +34,7 @@ CardInfoTextWidget::CardInfoTextWidget(QWidget *parent) : QFrame(parent) textLabel->setReadOnly(true); textLabel->setMinimumSize(35, 35); - propsScroll = new QScrollArea(this); - propsScroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - propsScroll->setWidgetResizable(true); + propsScroll = QtUtils::makeScrollArea(this); propsScroll->setAlignment(Qt::AlignLeft | Qt::AlignTop); propsScroll->setContentsMargins(0, 0, 0, 0); propsScroll->setFrameStyle(QFrame::NoFrame); diff --git a/cockatrice/src/interface/widgets/cards/card_picture_delegate.cpp b/cockatrice/src/interface/widgets/cards/card_picture_delegate.cpp new file mode 100644 index 000000000..781c767ef --- /dev/null +++ b/cockatrice/src/interface/widgets/cards/card_picture_delegate.cpp @@ -0,0 +1,83 @@ +#include "card_picture_delegate.h" + +#include "../../../client/settings/cache_settings.h" +#include "../../card_picture_loader/card_picture_loader.h" +#include "card_art_utils.h" +#include "card_database_visual_display_model.h" +#include "libcockatrice/card/printing/exact_card.h" +#include "libcockatrice/settings/cards_display_settings.h" + +#include +#include +#include +#include +#include +#include + +CardPictureDelegate::CardPictureDelegate(CardDatabaseVisualDisplayModel *_model, QObject *parent) + : QStyledItemDelegate(parent), model(_model) +{ +} + +void CardPictureDelegate::setCardSize(const QSize &size) +{ + if (cardSize == size) { + return; + } + + cardSize = size; +} + +void CardPictureDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const +{ + if (model == nullptr) { + return; + } + + const ExactCard card = model->cardAt(index.row()); + if (!card) { + return; + } + + // The grid cell is the card plus SPACING; give the picture the rest. + const int inset = SPACING / 2; + const QRect cardArea = option.rect.adjusted(inset, inset, -inset, -inset); + + QPixmap pixmap; + CardPictureLoader::getCardBackLoadingInProgressPixmap(pixmap, cardArea.size()); + CardPictureLoader::getPixmap(pixmap, card, cardArea.size()); + + if (pixmap.isNull()) { + return; + } + + if (SettingsCache::instance().cardsDisplay().getAutoRotateSidewaysLayoutCards()) { + pixmap = CardArtUtils::rotateSidewaysLayoutArt(pixmap, card); + } + + // Fit the picture into the cell and center it, like CardInfoPictureWidget does. + const QSize pictureSize = pixmap.deviceIndependentSize().toSize().scaled(cardArea.size(), Qt::KeepAspectRatio); + const QRect pictureRect(cardArea.center().x() - pictureSize.width() / 2, + cardArea.center().y() - pictureSize.height() / 2, pictureSize.width(), + pictureSize.height()); + + const qreal radius = SettingsCache::instance().cardsDisplay().getRoundCardCorners() + ? 0.05 * static_cast(pictureRect.width()) + : 0.0; + + painter->save(); + painter->setRenderHint(QPainter::Antialiasing); + painter->setRenderHint(QPainter::SmoothPixmapTransform); + QPainterPath shape; + shape.addRoundedRect(pictureRect, radius, radius); + painter->setClipPath(shape); + // Source rect is in device pixels, target in logical ones: drawPixmap scales + // between them without the device pixel ratio being applied twice. + painter->drawPixmap(pictureRect, pixmap, QRectF(pixmap.rect())); + painter->restore(); +} + +QSize CardPictureDelegate::sizeHint(const QStyleOptionViewItem & /*option*/, const QModelIndex & /*index*/) const +{ + return cardSize + QSize(SPACING, SPACING); +} diff --git a/cockatrice/src/interface/widgets/cards/card_picture_delegate.h b/cockatrice/src/interface/widgets/cards/card_picture_delegate.h new file mode 100644 index 000000000..31e673624 --- /dev/null +++ b/cockatrice/src/interface/widgets/cards/card_picture_delegate.h @@ -0,0 +1,45 @@ +/** + * @file card_picture_delegate.h + * @ingroup CardWidgets + * @brief Paints a card picture for a visual card grid item. + */ + +#ifndef CARD_PICTURE_DELEGATE_H +#define CARD_PICTURE_DELEGATE_H + +#include +#include +#include + +class CardDatabaseVisualDisplayModel; + +/** + * @brief Draws the card of a CardDatabaseVisualDisplayModel row as a picture. + * + * The delegate only paints rows the view asks for, so a grid of the whole card + * database stays cheap: no widget per card and no pixmap loaded for items that + * are off screen. Card images come from CardPictureLoader, which caches them and + * loads missing ones asynchronously. + */ +class CardPictureDelegate : public QStyledItemDelegate +{ + Q_OBJECT + +public: + /// Gap left around a card picture inside its grid cell, in pixels. + static constexpr int SPACING = 4; + + explicit CardPictureDelegate(CardDatabaseVisualDisplayModel *_model, QObject *parent = nullptr); + + /// The picture size inside a grid cell. The cell is this size plus SPACING. + void setCardSize(const QSize &size); + + void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override; + [[nodiscard]] QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override; + +private: + CardDatabaseVisualDisplayModel *model; + QSize cardSize; +}; + +#endif // CARD_PICTURE_DELEGATE_H diff --git a/cockatrice/src/interface/widgets/cards/card_size_widget.cpp b/cockatrice/src/interface/widgets/cards/card_size_widget.cpp index ab1840591..ac1ab9cb1 100644 --- a/cockatrice/src/interface/widgets/cards/card_size_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_size_widget.cpp @@ -2,6 +2,7 @@ #include "../general/layout_containers/flow_widget.h" +#include #include #include #include @@ -33,8 +34,8 @@ CardSizeWidget::CardSizeWidget(QWidget *parent, FlowWidget *_flowWidget, int def cardSizeLabel->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum); cardSizeSlider = new QSlider(Qt::Horizontal, this); - cardSizeSlider->setRange(50, 250); ///< Slider range for card size adjustment. - cardSizeSlider->setValue(defaultValue); ///< Initial slider value. + cardSizeSlider->setRange(MIN_CARD_SIZE, MAX_CARD_SIZE); ///< Slider range for card size adjustment. + cardSizeSlider->setValue(defaultValue); ///< Initial slider value. cardSizeLayout->addWidget(cardSizeLabel); cardSizeLayout->addWidget(cardSizeSlider); @@ -73,13 +74,26 @@ QSlider *CardSizeWidget::getSlider() const void CardSizeWidget::enableCtrlScrollResize(QWidget *host) { - host->installEventFilter(this); - - const auto scrollAreas = host->findChildren(); - for (QScrollArea *scrollArea : scrollAreas) { - if (QWidget *content = scrollArea->widget()) { - content->installEventFilter(this); + const auto watchScrollArea = [this](QAbstractScrollArea *scrollArea) { + if (scrollArea == nullptr) { + return; } + if (QWidget *viewport = scrollArea->viewport()) { + viewport->installEventFilter(this); + } + if (auto *contentScrollArea = qobject_cast(scrollArea)) { + if (QWidget *content = contentScrollArea->widget()) { + content->installEventFilter(this); + } + } + }; + + host->installEventFilter(this); + watchScrollArea(qobject_cast(host)); + + const auto scrollAreas = host->findChildren(); + for (QAbstractScrollArea *scrollArea : scrollAreas) { + watchScrollArea(scrollArea); } } diff --git a/cockatrice/src/interface/widgets/cards/card_size_widget.h b/cockatrice/src/interface/widgets/cards/card_size_widget.h index 7de97a5e9..fced90d33 100644 --- a/cockatrice/src/interface/widgets/cards/card_size_widget.h +++ b/cockatrice/src/interface/widgets/cards/card_size_widget.h @@ -24,6 +24,10 @@ class CardSizeWidget : public QWidget Q_OBJECT public: + /// Range of the card size slider, as a percentage of the base card width. + static constexpr int MIN_CARD_SIZE = 50; + static constexpr int MAX_CARD_SIZE = 250; + explicit CardSizeWidget(QWidget *parent, FlowWidget *flowWidget = nullptr, int defaultValue = 100); [[nodiscard]] QSlider *getSlider() const; @@ -31,8 +35,9 @@ public: * @brief Resizes the cards when the user Ctrl + scrolls over @p host or any of its descendants. * * Installs this widget as an event filter on the given host widget. If the host contains a - * scroll area the filter is also installed on the scroll area's content widget, so the resize - * intercepts the wheel event before the scroll area would scroll the view. + * scroll area the filter is also installed on the scroll area's content widget, and on the + * viewport of every scroll area base (QListView, QTableView, ...), so the resize intercepts + * the wheel event before the scroll area would scroll the view. */ void enableCtrlScrollResize(QWidget *host); diff --git a/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.cpp b/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.cpp index 911e35285..d7eef6c18 100644 --- a/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.cpp @@ -29,6 +29,7 @@ #include #include #include +#include #include class CardSizeWidget; @@ -179,15 +180,7 @@ void DeckCardZoneDisplayWidget::displayCards() void DeckCardZoneDisplayWidget::refreshDisplayType(const DisplayType &_displayType) { displayType = _displayType; - QLayoutItem *item; - while ((item = cardGroupLayout->takeAt(0)) != nullptr) { - if (item->widget()) { - item->widget()->deleteLater(); - } else if (item->layout()) { - item->layout()->deleteLater(); - } - delete item; - } + QtUtils::clearLayoutRec(cardGroupLayout); indexToWidgetMap.clear(); diff --git a/cockatrice/src/interface/widgets/deck_analytics/abstract_analytics_panel_widget.cpp b/cockatrice/src/interface/widgets/deck_analytics/abstract_analytics_panel_widget.cpp index 730043d95..c8571ab08 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/abstract_analytics_panel_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_analytics/abstract_analytics_panel_widget.cpp @@ -1,6 +1,6 @@ #include "abstract_analytics_panel_widget.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../general/display/banner_widget.h" #include "deck_list_statistics_analyzer.h" @@ -28,7 +28,7 @@ AbstractAnalyticsPanelWidget::AbstractAnalyticsPanelWidget(QWidget *parent, Deck // config button configureButton = new QPushButton(this); - configureButton->setIcon(themePixmap(QStringLiteral("icons/cogwheel"))); + configureButton->setIcon(loadThemedIcon(QStringLiteral("icons/settings"))); configureButton->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum); connect(configureButton, &QPushButton::clicked, this, &AbstractAnalyticsPanelWidget::applyConfigFromDialog); bannerAndSettingsLayout->addWidget(configureButton, 0); diff --git a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_base/mana_base_widget.cpp b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_base/mana_base_widget.cpp index fc7914b94..8215cbdad 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_base/mana_base_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_base/mana_base_widget.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include @@ -47,12 +48,7 @@ ManaBaseWidget::ManaBaseWidget(QWidget *parent, DeckListStatisticsAnalyzer *anal void ManaBaseWidget::updateDisplay() { // Clear previous widgets - while (QLayoutItem *item = barLayout->takeAt(0)) { - if (item->widget()) { - item->widget()->deleteLater(); - } - delete item; - } + QtUtils::clearLayoutRec(barLayout); auto &pipCount = analyzer->getProductionPipCount(); auto &cardCount = analyzer->getProductionCardCount(); diff --git a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_curve/mana_curve_category_widget.cpp b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_curve/mana_curve_category_widget.cpp index 7e15058a4..be81ab97a 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_curve/mana_curve_category_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_curve/mana_curve_category_widget.cpp @@ -16,14 +16,13 @@ #include #include #include +#include constexpr int MIN_ROW_HEIGHT = 100; // Minimum readable height per row ManaCurveCategoryWidget::ManaCurveCategoryWidget(QWidget *parent) : QWidget(parent) { - layout = new QVBoxLayout(this); - layout->setSpacing(4); - layout->setContentsMargins(0, 0, 0, 0); + layout = QtUtils::makeLayout(this, 0, 4); setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); } @@ -95,9 +94,7 @@ void ManaCurveCategoryWidget::updateDisplay(int minCmc, row->setFixedHeight(MIN_ROW_HEIGHT); - QHBoxLayout *rowLayout = new QHBoxLayout(row); - rowLayout->setContentsMargins(0, 0, 0, 0); - rowLayout->setSpacing(4); + QHBoxLayout *rowLayout = QtUtils::makeLayout(row, 4); QLabel *categoryLabel = new QLabel(cat, row); categoryLabel->setFixedWidth(80); diff --git a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_devotion/mana_devotion_widget.cpp b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_devotion/mana_devotion_widget.cpp index 7e1f0c211..b958a733f 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_devotion/mana_devotion_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_devotion/mana_devotion_widget.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include @@ -47,12 +48,7 @@ ManaDevotionWidget::ManaDevotionWidget(QWidget *parent, DeckListStatisticsAnalyz void ManaDevotionWidget::updateDisplay() { // Clear previous widgets - while (QLayoutItem *item = barLayout->takeAt(0)) { - if (item->widget()) { - item->widget()->deleteLater(); - } - delete item; - } + QtUtils::clearLayoutRec(barLayout); auto &pipCount = analyzer->getDevotionPipCount(); auto &cardCount = analyzer->getDevotionCardCount(); diff --git a/cockatrice/src/interface/widgets/deck_analytics/deck_analytics_widget.cpp b/cockatrice/src/interface/widgets/deck_analytics/deck_analytics_widget.cpp index 9f6257930..e0ad7af43 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/deck_analytics_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_analytics/deck_analytics_widget.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include DeckAnalyticsWidget::DeckAnalyticsWidget(QWidget *parent, DeckListStatisticsAnalyzer *_statsAnalyzer) @@ -54,8 +55,7 @@ DeckAnalyticsWidget::DeckAnalyticsWidget(QWidget *parent, DeckListStatisticsAnal connect(includeSideboardCheckBox, &QCheckBox::clicked, this, &DeckAnalyticsWidget::includeSideboardChanged); // Scroll area and container - scrollArea = new QScrollArea(this); - scrollArea->setWidgetResizable(true); + scrollArea = QtUtils::makeScrollArea(this, Qt::ScrollBarAsNeeded, Qt::ScrollBarAsNeeded); scrollArea->setFrameShape(QFrame::NoFrame); panelContainer = new QWidget(scrollArea); diff --git a/cockatrice/src/interface/widgets/deck_analytics/resizable_panel.cpp b/cockatrice/src/interface/widgets/deck_analytics/resizable_panel.cpp index f46679406..85dba6468 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/resizable_panel.cpp +++ b/cockatrice/src/interface/widgets/deck_analytics/resizable_panel.cpp @@ -28,6 +28,7 @@ #include #include #include +#include #include ResizablePanel::ResizablePanel(const QString &_typeId, AbstractAnalyticsPanelWidget *analyticsPanel, QWidget *parent) @@ -35,18 +36,14 @@ ResizablePanel::ResizablePanel(const QString &_typeId, AbstractAnalyticsPanelWid { setAcceptDrops(true); - auto *mainLayout = new QVBoxLayout(this); - mainLayout->setContentsMargins(0, 0, 0, 0); - mainLayout->setSpacing(0); + auto *mainLayout = QtUtils::makeLayout(this, 0); // Frame for selection highlight frame = new QFrame(this); frame->setFrameShape(QFrame::Box); frame->setLineWidth(2); - auto *frameLayout = new QVBoxLayout(frame); - frameLayout->setContentsMargins(0, 0, 0, 0); - frameLayout->setSpacing(0); + auto *frameLayout = QtUtils::makeLayout(frame, 0); // Add the analytics panel frameLayout->addWidget(analyticsPanel); @@ -63,9 +60,7 @@ ResizablePanel::ResizablePanel(const QString &_typeId, AbstractAnalyticsPanelWid // Bottom bar with drag button and resize handle auto *bottomBar = new QWidget(frame); - auto *bottomLayout = new QHBoxLayout(bottomBar); - bottomLayout->setContentsMargins(0, 0, 0, 0); - bottomLayout->setSpacing(0); + auto *bottomLayout = QtUtils::makeLayout(bottomBar, 0); // Drag button on the left dragButton = new QPushButton("☰", bottomBar); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp index 7ed398535..f25e6c0ff 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp @@ -3,7 +3,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../../filters/syntax_help.h" #include "../../key_signals.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../utility/custom_line_edit.h" #include "card_database_display_model.h" #include "card_database_model.h" @@ -40,8 +40,8 @@ DeckEditorDatabaseDisplayWidget::DeckEditorDatabaseDisplayWidget(QWidget *parent searchEdit->setObjectName("searchEdit"); searchEdit->setPlaceholderText(tr("Search by card name (or search expressions)")); searchEdit->setClearButtonEnabled(true); - searchEdit->addAction(loadColorAdjustedPixmap("theme:icons/search"), QLineEdit::LeadingPosition); - auto help = searchEdit->addAction(themePixmap(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); + searchEdit->addAction(loadThemedIcon(QStringLiteral("icons/search")), QLineEdit::LeadingPosition); + auto help = searchEdit->addAction(loadThemedIcon(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); setFocusProxy(searchEdit); setFocusPolicy(Qt::ClickFocus); @@ -83,13 +83,13 @@ DeckEditorDatabaseDisplayWidget::DeckEditorDatabaseDisplayWidget(QWidget *parent &DeckEditorDatabaseDisplayWidget::onRelatedCardClicked); aAddCard = new QAction(QString(), this); - aAddCard->setIcon(themePixmap(QStringLiteral("icons/arrow_right_green"))); + aAddCard->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_right"))); connect(aAddCard, &QAction::triggered, this, &DeckEditorDatabaseDisplayWidget::actAddCardToMainDeck); auto *tbAddCard = new QToolButton(this); tbAddCard->setDefaultAction(aAddCard); aAddCardToSideboard = new QAction(QString(), this); - aAddCardToSideboard->setIcon(themePixmap(QStringLiteral("icons/arrow_right_blue"))); + aAddCardToSideboard->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_right_sideboard"))); connect(aAddCardToSideboard, &QAction::triggered, this, &DeckEditorDatabaseDisplayWidget::actAddCardToSideboard); auto *tbAddCardToSideboard = new QToolButton(this); tbAddCardToSideboard->setDefaultAction(aAddCardToSideboard); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp index 37dd57459..f09d8dd1b 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp @@ -3,7 +3,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" #include "../../key_signals.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../playmat/playmat_settings_dialog.h" #include "../quick_settings/settings_button_widget.h" #include "../tabs/abstract_tab_deck_editor.h" @@ -240,25 +240,25 @@ void DeckEditorDeckDockWidget::createDeckDock() &DeckEditorDeckDockWidget::applyActiveGroupCriteria); aIncrement = new QAction(QString(), this); - aIncrement->setIcon(themePixmap(QStringLiteral("icons/increment"))); + aIncrement->setIcon(loadThemedIcon(QStringLiteral("icons/increment"))); connect(aIncrement, &QAction::triggered, this, &DeckEditorDeckDockWidget::actIncrementSelection); auto *tbIncrement = new QToolButton(this); tbIncrement->setDefaultAction(aIncrement); aDecrement = new QAction(QString(), this); - aDecrement->setIcon(themePixmap(QStringLiteral("icons/decrement"))); + aDecrement->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"))); connect(aDecrement, &QAction::triggered, this, &DeckEditorDeckDockWidget::actDecrementSelection); auto *tbDecrement = new QToolButton(this); tbDecrement->setDefaultAction(aDecrement); aRemoveCard = new QAction(QString(), this); - aRemoveCard->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); + aRemoveCard->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"))); connect(aRemoveCard, &QAction::triggered, this, &DeckEditorDeckDockWidget::actRemoveCard); auto *tbRemoveCard = new QToolButton(this); tbRemoveCard->setDefaultAction(aRemoveCard); aSwapCard = new QAction(QString(), this); - aSwapCard->setIcon(themePixmap(QStringLiteral("icons/swap"))); + aSwapCard->setIcon(loadThemedIcon(QStringLiteral("icons/swap"))); connect(aSwapCard, &QAction::triggered, this, &DeckEditorDeckDockWidget::actSwapSelection); auto *tbSwapCard = new QToolButton(this); tbSwapCard->setDefaultAction(aSwapCard); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp index 0a47333fa..8122d1847 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp @@ -5,7 +5,7 @@ #include "../../../filters/filter_builder.h" #include "../../../filters/filter_tree_model.h" #include "../../key_signals.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../tabs/abstract_tab_deck_editor.h" #include "libcockatrice/filters/filter_card.h" @@ -28,6 +28,7 @@ #include #include #include +#include #include DeckEditorFilterDockWidget::DeckEditorFilterDockWidget(AbstractTabDeckEditor *parent) @@ -63,11 +64,11 @@ void DeckEditorFilterDockWidget::createFiltersDock() connect(filterBuilder, &FilterBuilder::add, filterModel, &FilterTreeModel::addFilter); aClearFilterOne = new QAction(QString(), this); - aClearFilterOne->setIcon(themePixmap(QStringLiteral("icons/decrement"))); + aClearFilterOne->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"))); connect(aClearFilterOne, &QAction::triggered, this, &DeckEditorFilterDockWidget::actClearFilterOne); aClearFilterAll = new QAction(QString(), this); - aClearFilterAll->setIcon(themePixmap(QStringLiteral("icons/clearsearch"))); + aClearFilterAll->setIcon(loadThemedIcon(QStringLiteral("icons/clearsearch"))); connect(aClearFilterAll, &QAction::triggered, this, &DeckEditorFilterDockWidget::actClearFilterAll); auto *filterDelOne = new QToolButton(); @@ -80,9 +81,8 @@ void DeckEditorFilterDockWidget::createFiltersDock() filterDelAll->setDefaultAction(aClearFilterAll); filterDelAll->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); - auto *filterLayout = new QGridLayout; + auto *filterLayout = QtUtils::makeLayout(); filterLayout->setObjectName("filterLayout"); - filterLayout->setContentsMargins(0, 0, 0, 0); filterLayout->addWidget(filterBuilder, 0, 0, 1, 3); filterLayout->addWidget(filterView, 1, 0, 1, 3); filterLayout->addWidget(filterDelOne, 2, 0, 1, 1); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_list_history_manager_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_list_history_manager_widget.cpp index 209dc7273..a1914e1b5 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_list_history_manager_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_list_history_manager_widget.cpp @@ -1,6 +1,6 @@ #include "deck_list_history_manager_widget.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../quick_settings/settings_button_widget.h" #include "deck_state_manager.h" #include "libcockatrice/deck_list/deck_list_history_manager.h" @@ -32,7 +32,7 @@ DeckListHistoryManagerWidget::DeckListHistoryManagerWidget(DeckStateManager *_de layout = new QHBoxLayout(this); aUndo = new QAction(QString(), this); - aUndo->setIcon(themePixmap(QStringLiteral("icons/arrow_undo"))); + aUndo->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_undo"))); aUndo->setShortcut(QKeySequence::Undo); aUndo->setShortcutContext(Qt::ApplicationShortcut); connect(aUndo, &QAction::triggered, this, &DeckListHistoryManagerWidget::doUndo); @@ -41,7 +41,7 @@ DeckListHistoryManagerWidget::DeckListHistoryManagerWidget(DeckStateManager *_de undoButton->setDefaultAction(aUndo); aRedo = new QAction(QString(), this); - aRedo->setIcon(themePixmap(QStringLiteral("icons/arrow_redo"))); + aRedo->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_redo"))); aRedo->setShortcut(QKeySequence::Redo); aRedo->setShortcutContext(Qt::ApplicationShortcut); connect(aRedo, &QAction::triggered, this, &DeckListHistoryManagerWidget::doRedo); @@ -53,7 +53,7 @@ DeckListHistoryManagerWidget::DeckListHistoryManagerWidget(DeckStateManager *_de layout->addWidget(redoButton); historyButton = new SettingsButtonWidget(this); - historyButton->setButtonIcon(themePixmap(QStringLiteral("icons/arrow_history"))); + historyButton->setButtonIcon(loadThemedIcon(QStringLiteral("icons/arrow_history"))); historyLabel = new QLabel(this); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp index d4715c8d5..a7e231ecf 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp @@ -1,7 +1,6 @@ #include "deck_state_manager.h" #include "../../../client/settings/cache_settings.h" -#include "../../deck_loader/loaded_deck.h" #include "deck_list_model.h" #include "libcockatrice/card/card_info.h" #include "libcockatrice/card/database/card_database_querier.h" @@ -18,6 +17,7 @@ #include #include #include +#include #include #include #include diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h index 98876947b..54c59eb79 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h +++ b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h @@ -1,7 +1,7 @@ #ifndef COCKATRICE_DECK_STATE_MANAGER_H #define COCKATRICE_DECK_STATE_MANAGER_H -#include "../../deck_loader/loaded_deck.h" +#include "deck_list_model.h" #include #include @@ -10,6 +10,7 @@ #include #include #include +#include #include class DeckListHistoryManager; diff --git a/cockatrice/src/interface/widgets/deck_share/shared_deck_preview_widget.cpp b/cockatrice/src/interface/widgets/deck_share/shared_deck_preview_widget.cpp index 3bb829089..ae95ce95c 100644 --- a/cockatrice/src/interface/widgets/deck_share/shared_deck_preview_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_share/shared_deck_preview_widget.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include SharedDeckPreviewWidget::SharedDeckPreviewWidget(QWidget *parent, @@ -62,8 +63,7 @@ SharedDeckPreviewWidget::SharedDeckPreviewWidget(QWidget *parent, selectionRow->addWidget(selectionCheckBox); selectionRow->addStretch(1); - auto *layout = new QVBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); + auto *layout = QtUtils::makeLayout(this); layout->addLayout(selectionRow); layout->addWidget(bannerCardDisplayWidget, 0, Qt::AlignHCenter); layout->addWidget(colorIdentityWidget, 0, Qt::AlignHCenter); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp index 43b40bf30..1e0f37971 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp @@ -1,7 +1,7 @@ #include "dlg_connect.h" #include "../../../client/settings/cache_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../server/handle_public_servers.h" #include "../server/user/user_info_connection.h" #include "libcockatrice/utility/macros.h" @@ -37,7 +37,7 @@ DlgConnect::DlgConnect(QWidget *parent) : QDialog(parent) previousHosts = new QComboBox(this); btnDeleteServer = new QPushButton(this); - btnDeleteServer->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); + btnDeleteServer->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"))); btnDeleteServer->setToolTip(tr("Delete the currently selected saved server")); btnDeleteServer->setFixedWidth(30); @@ -45,7 +45,7 @@ DlgConnect::DlgConnect(QWidget *parent) : QDialog(parent) hps = new HandlePublicServers(this); btnRefreshServers = new QPushButton(this); - btnRefreshServers->setIcon(themePixmap(QStringLiteral("icons/sync"))); + btnRefreshServers->setIcon(loadThemedIcon(QStringLiteral("icons/sync"))); btnRefreshServers->setToolTip(tr("Refresh the server list with known public servers")); btnRefreshServers->setFixedWidth(30); @@ -115,7 +115,7 @@ DlgConnect::DlgConnect(QWidget *parent) : QDialog(parent) updateDisplayInfo(previousHosts->currentText()); btnForgotPassword = new QPushButton(this); - btnForgotPassword->setIcon(themePixmap(QStringLiteral("icons/forgot_password"))); + btnForgotPassword->setIcon(loadThemedIcon(QStringLiteral("icons/forgot_password"))); btnForgotPassword->setToolTip(tr("Reset Password")); btnForgotPassword->setFixedWidth(30); connect(btnForgotPassword, &QPushButton::released, this, &DlgConnect::actForgotPassword); @@ -295,9 +295,15 @@ void DlgConnect::updateDisplayInfo(const QString &saveName) const QString stored = _data.at(4); if (stored.startsWith("$")) { storedVerifier = stored; + passwordEdit->clear(); + passwordEdit->setPlaceholderText(tr("Password saved")); } else { passwordEdit->setText(stored); + passwordEdit->setPlaceholderText(QString()); } + } else { + passwordEdit->clear(); + passwordEdit->setPlaceholderText(QString()); } if (!_data.at(6).isEmpty()) { @@ -323,6 +329,7 @@ void DlgConnect::newHostSelected(bool state) portEdit->setDisabled(false); playernameEdit->clear(); passwordEdit->clear(); + passwordEdit->setPlaceholderText(QString()); storedVerifier.clear(); saveEdit->clear(); saveEdit->setPlaceholderText(tr("Unique Server Name")); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.cpp index f2dd8c1e4..5659b3388 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.cpp @@ -1,7 +1,6 @@ #include "dlg_convert_deck_to_cod_format.h" #include "../../../client/settings/cache_settings.h" -#include "../../deck_loader/deck_file_format.h" #include #include @@ -12,7 +11,9 @@ #include #include #include +#include #include +#include DialogConvertDeckToCodFormat::DialogConvertDeckToCodFormat(QWidget *parent) : QDialog(parent) { @@ -57,11 +58,8 @@ bool confirmOverwriteIfExists(QWidget *parent, const QString &filePath) QString newFileName = QDir::toNativeSeparators(fileInfo.path() + "/" + fileInfo.completeBaseName() + ".cod"); if (QFile::exists(newFileName)) { - QMessageBox::StandardButton reply = - QMessageBox::question(parent, QObject::tr("Overwrite Existing File?"), - QObject::tr("A .cod version of this deck already exists. Overwrite it?"), - QMessageBox::Yes | QMessageBox::No); - return reply == QMessageBox::Yes; + return QtUtils::confirm(parent, QObject::tr("Overwrite Existing File?"), + QObject::tr("A .cod version of this deck already exists. Overwrite it?")); } return true; // Safe to proceed } diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_default_tags_editor.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_default_tags_editor.cpp index 9eb064beb..ee71699d2 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_default_tags_editor.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_default_tags_editor.cpp @@ -17,6 +17,7 @@ #include #include #include +#include DlgDefaultTagsEditor::DlgDefaultTagsEditor(QWidget *parent) : QDialog(parent) { @@ -120,8 +121,7 @@ void DlgDefaultTagsEditor::addItem() // Add new tag auto *item = new QListWidgetItem(listWidget); QWidget *widget = new QWidget(this); - QHBoxLayout *layout = new QHBoxLayout(widget); - layout->setContentsMargins(0, 0, 0, 0); + QHBoxLayout *layout = QtUtils::makeLayout(widget); // Editable tag label QLineEdit *lineEdit = new QLineEdit(newTag, this); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_edit_avatar.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_edit_avatar.cpp index 520ece007..7f0141bd5 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_edit_avatar.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_edit_avatar.cpp @@ -71,7 +71,21 @@ void DlgEditAvatar::actBrowse() QImageReader imgReader; imgReader.setDecideFormatFromContent(true); + // Honour the EXIF orientation phone cameras write; otherwise portrait + // photos are shown and stored sideways. + imgReader.setAutoTransform(true); imgReader.setFileName(fileName); + + // Decode straight to the maximum avatar size. Scaling afterwards decodes + // the full-resolution image first, which can be tens of megabytes for a + // modern camera photo. + const QSize sourceSize = imgReader.size(); + if (sourceSize.isValid() && + (sourceSize.width() > MAX_AVATAR_DIMENSION || sourceSize.height() > MAX_AVATAR_DIMENSION)) { + const QSize targetSize = sourceSize.scaled(MAX_AVATAR_DIMENSION, MAX_AVATAR_DIMENSION, Qt::KeepAspectRatio); + imgReader.setScaledSize(targetSize); + } + if (!imgReader.read(&image)) { qCWarning(DlgEditAvatarLog) << "Avatar image loading failed for file:" << fileName; imageLabel->setText(tr("Invalid image chosen.")); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp index cbf61a394..42a7595e5 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp @@ -1,6 +1,6 @@ #include "dlg_edit_tokens.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../interface/widgets/utility/get_text_with_max.h" #include "card_database_display_model.h" #include "libcockatrice/card/card_info.h" @@ -107,10 +107,10 @@ DlgEditTokens::DlgEditTokens(QWidget *parent) : QDialog(parent), currentCard(nul &DlgEditTokens::tokenSelectionChanged); QAction *aAddToken = new QAction(tr("Add token"), this); - aAddToken->setIcon(themePixmap(QStringLiteral("icons/increment"))); + aAddToken->setIcon(loadThemedIcon(QStringLiteral("icons/increment"))); connect(aAddToken, &QAction::triggered, this, &DlgEditTokens::actAddToken); QAction *aRemoveToken = new QAction(tr("Remove token"), this); - aRemoveToken->setIcon(themePixmap(QStringLiteral("icons/decrement"))); + aRemoveToken->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"))); connect(aRemoveToken, &QAction::triggered, this, &DlgEditTokens::actRemoveToken); auto *databaseToolBar = new QToolBar; diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_clipboard.h b/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_clipboard.h index f3cbb7b62..6cf6021b0 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_clipboard.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_clipboard.h @@ -10,8 +10,10 @@ #include "libcockatrice/deck_list/deck_list.h" +#include #include #include +#include #include class QPlainTextEdit; diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_website.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_website.cpp index 88b7e2bb3..1c1ada302 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_website.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_website.cpp @@ -27,6 +27,7 @@ #include #include #include +#include #include #include @@ -34,7 +35,7 @@ class QWidget; DlgLoadDeckFromWebsite::DlgLoadDeckFromWebsite(QWidget *parent) : QDialog(parent) { - nam = new QNetworkAccessManager(this); + nam = NetworkManagerFactory::create(this); layout = new QVBoxLayout(this); setLayout(layout); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp index dbfe38e26..937c7b5be 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp @@ -1,7 +1,7 @@ #include "dlg_manage_sets.h" #include "../../../client/settings/cache_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../interface/card_picture_loader/card_picture_loader.h" #include "../interface/widgets/utility/custom_line_edit.h" #include "libcockatrice/card/database/card_database.h" @@ -57,28 +57,28 @@ WndSets::WndSets(QWidget *parent) : QMainWindow(parent) setsEditToolBar->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum); aTop = new QAction(QString(), this); - aTop->setIcon(themePixmap(QStringLiteral("icons/arrow_top_green"))); + aTop->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_to_top"))); aTop->setToolTip(tr("Move selected set to the top")); aTop->setEnabled(false); connect(aTop, &QAction::triggered, this, &WndSets::actTop); setsEditToolBar->addAction(aTop); aUp = new QAction(QString(), this); - aUp->setIcon(themePixmap(QStringLiteral("icons/arrow_up_green"))); + aUp->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_up"))); aUp->setToolTip(tr("Move selected set up")); aUp->setEnabled(false); connect(aUp, &QAction::triggered, this, &WndSets::actUp); setsEditToolBar->addAction(aUp); aDown = new QAction(QString(), this); - aDown->setIcon(themePixmap(QStringLiteral("icons/arrow_down_green"))); + aDown->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_down"))); aDown->setToolTip(tr("Move selected set down")); aDown->setEnabled(false); connect(aDown, &QAction::triggered, this, &WndSets::actDown); setsEditToolBar->addAction(aDown); aBottom = new QAction(QString(), this); - aBottom->setIcon(themePixmap(QStringLiteral("icons/arrow_bottom_green"))); + aBottom->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_to_bottom"))); aBottom->setToolTip(tr("Move selected set to the bottom")); aBottom->setEnabled(false); connect(aBottom, &QAction::triggered, this, &WndSets::actBottom); @@ -88,7 +88,7 @@ WndSets::WndSets(QWidget *parent) : QMainWindow(parent) searchField = new LineEditUnfocusable; searchField->setObjectName("searchEdit"); searchField->setPlaceholderText(tr("Search by set name, code, type, or release date")); - searchField->addAction(themePixmap(QStringLiteral("icons/search")), LineEditUnfocusable::LeadingPosition); + searchField->addAction(loadThemedIcon(QStringLiteral("icons/search")), LineEditUnfocusable::LeadingPosition); searchField->setClearButtonEnabled(true); setFocusProxy(searchField); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp index 52c9eaef0..2a2758ba5 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp @@ -6,8 +6,6 @@ #include "libcockatrice/protocol/pb/serverinfo_report.pb.h" #include -#include -#include #include #include #include @@ -26,6 +24,7 @@ #include #include #include +#include #include #include @@ -70,13 +69,7 @@ DlgMyReports::DlgMyReports(AbstractClient *_client, QWidget *parent) auto *chatGroup = new QGroupBox(tr("Chat Log Context")); chatLogEdit = new QTextEdit; chatLogEdit->setReadOnly(true); - QFont monoFont("monospace"); - monoFont.setStyleHint(QFont::Monospace); - const int systemPointSize = QFontDatabase::systemFont(QFontDatabase::GeneralFont).pointSize(); - if (systemPointSize > 0) { - monoFont.setPointSize(systemPointSize); - } - chatLogEdit->setFont(monoFont); + chatLogEdit->setFont(QtUtils::monospaceFont()); auto *chatLayout = new QVBoxLayout(chatGroup); chatLayout->setContentsMargins(4, 4, 4, 4); chatLayout->addWidget(chatLogEdit); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp index 9e32e87d1..09afee32d 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp @@ -1,7 +1,7 @@ #include "dlg_register.h" #include "../../../client/settings/cache_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../server/handle_public_servers.h" #include "../server/user/user_info_connection.h" @@ -34,7 +34,7 @@ DlgRegister::DlgRegister(QWidget *parent) : QDialog(parent) previousHosts = new QComboBox(this); btnDeleteServer = new QPushButton(this); - btnDeleteServer->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); + btnDeleteServer->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"))); btnDeleteServer->setToolTip(tr("Delete the currently selected saved server")); btnDeleteServer->setFixedWidth(30); @@ -42,7 +42,7 @@ DlgRegister::DlgRegister(QWidget *parent) : QDialog(parent) hps = new HandlePublicServers(this); btnRefreshServers = new QPushButton(this); - btnRefreshServers->setIcon(themePixmap(QStringLiteral("icons/sync"))); + btnRefreshServers->setIcon(loadThemedIcon(QStringLiteral("icons/sync"))); btnRefreshServers->setToolTip(tr("Refresh the server list with known public servers")); btnRefreshServers->setFixedWidth(30); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_report_user.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_report_user.cpp index b1ae479fe..3ef45020b 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_report_user.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_report_user.cpp @@ -5,8 +5,6 @@ #include #include -#include -#include #include #include #include @@ -21,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -92,13 +91,7 @@ DlgReportUser::DlgReportUser(AbstractClient *_client, auto *chatLayout = new QVBoxLayout(chatGroup); chatLogEdit = new QTextEdit; chatLogEdit->setReadOnly(true); - QFont monoFont("monospace"); - monoFont.setStyleHint(QFont::Monospace); - const int systemPointSize = QFontDatabase::systemFont(QFontDatabase::GeneralFont).pointSize(); - if (systemPointSize > 0) { - monoFont.setPointSize(systemPointSize); - } - chatLogEdit->setFont(monoFont); + chatLogEdit->setFont(QtUtils::monospaceFont()); if (!_autoChatLog.isEmpty()) { chatLogEdit->setPlainText(_autoChatLog); } else { @@ -135,12 +128,8 @@ void DlgReportUser::actSubmit() return; } - QMessageBox::StandardButton reply = - QMessageBox::question(this, tr("Confirm Report"), - tr("Submit report against %1 for %2?").arg(reportedUser, categoryBox->currentText()), - QMessageBox::Yes | QMessageBox::No); - - if (reply != QMessageBox::Yes) { + if (!QtUtils::confirm(this, tr("Confirm Report"), + tr("Submit report against %1 for %2?").arg(reportedUser, categoryBox->currentText()))) { return; } diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_select_set_for_cards.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_select_set_for_cards.cpp index 55c6569f7..1a4f0aeb9 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_select_set_for_cards.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_select_set_for_cards.cpp @@ -50,6 +50,7 @@ #include #include #include +#include #include #include #include @@ -71,10 +72,7 @@ DlgSelectSetForCards::DlgSelectSetForCards(QWidget *parent, DeckStateManager *de QSplitter *splitter = new QSplitter(Qt::Vertical, this); // Top scroll area - scrollArea = new QScrollArea(this); - scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); - scrollArea->setWidgetResizable(true); + scrollArea = QtUtils::makeScrollArea(this); listContainer = new QWidget(scrollArea); listLayout = new QVBoxLayout(listContainer); @@ -86,14 +84,10 @@ DlgSelectSetForCards::DlgSelectSetForCards(QWidget *parent, DeckStateManager *de // Left container QWidget *leftContainer = new QWidget(this); - QVBoxLayout *leftLayout = new QVBoxLayout(leftContainer); - leftLayout->setContentsMargins(0, 0, 0, 0); + QVBoxLayout *leftLayout = QtUtils::makeLayout(leftContainer); uneditedCardsLabel = new QLabel(this); - uneditedCardsArea = new QScrollArea(this); - uneditedCardsArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - uneditedCardsArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); - uneditedCardsArea->setWidgetResizable(true); + uneditedCardsArea = QtUtils::makeScrollArea(this); uneditedCardsFlowWidget = new FlowWidget(uneditedCardsArea, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAsNeeded); @@ -105,14 +99,10 @@ DlgSelectSetForCards::DlgSelectSetForCards(QWidget *parent, DeckStateManager *de // Right container QWidget *rightContainer = new QWidget(this); - QVBoxLayout *rightLayout = new QVBoxLayout(rightContainer); - rightLayout->setContentsMargins(0, 0, 0, 0); + QVBoxLayout *rightLayout = QtUtils::makeLayout(rightContainer); modifiedCardsLabel = new QLabel(this); - modifiedCardsArea = new QScrollArea(this); - modifiedCardsArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - modifiedCardsArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); - modifiedCardsArea->setWidgetResizable(true); + modifiedCardsArea = QtUtils::makeScrollArea(this); modifiedCardsFlowWidget = new FlowWidget(modifiedCardsArea, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAsNeeded); @@ -274,9 +264,9 @@ QMap DlgSelectSetForCards::getSetsForCards() continue; } - SetToPrintingsMap setMap = infoPtr->getSets(); - for (auto &setName : setMap.keys()) { - setCounts[setName]++; + const auto &setMap = infoPtr->getSets(); + for (auto it = setMap.constBegin(); it != setMap.constEnd(); ++it) { + setCounts[it.key()]++; } } @@ -393,8 +383,8 @@ QMap DlgSelectSetForCards::getCardsForSets() continue; } - SetToPrintingsMap setMap = infoPtr->getSets(); - for (auto it = setMap.begin(); it != setMap.end(); ++it) { + const auto &setMap = infoPtr->getSets(); + for (auto it = setMap.constBegin(); it != setMap.constEnd(); ++it) { setCards[it.key()].append(cardName); } } diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp index 8ad91dcaf..d29aee27f 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include class QWidget; @@ -34,8 +35,7 @@ DlgSharedDecksPreview::DlgSharedDecksPreview(QWidget *parent, // QLabels render AutoText and markup would otherwise be shown as rich text. auto *titleLabel = new QLabel(tr("Share: %1").arg((shareName.isEmpty() ? tr("Untitled") : shareName).toHtmlEscaped()), this); - QFont titleFont = titleLabel->font(); - titleFont.setBold(true); + QFont titleFont = QtUtils::boldFont(titleLabel->font()); titleFont.setPointSize(titleFont.pointSize() + 2); titleLabel->setFont(titleFont); mainLayout->addWidget(titleLabel); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp index 76bb13248..ca55c7b41 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp @@ -30,6 +30,7 @@ #include #include #include +#include #include #include @@ -170,16 +171,13 @@ void DlgUpdate::finishedUpdateCheck(bool needToUpdate, bool isCompatible, Releas publishDate = release->getPublishDate().toString(QLocale().dateFormat(QLocale::LongFormat)); if (isCompatible) { - int reply; - reply = QMessageBox::question( - window(), tr("Update Available"), - tr("A new version of Cockatrice is available!") + "

" + "" + tr("New version") + - QString(": %1
").arg(release->getName()) + "" + tr("Released") + - QString(": %1 (").arg(publishDate, release->getDescriptionUrl()) + tr("Changelog") + - ")

" + tr("Do you want to update now?"), - QMessageBox::Yes | QMessageBox::No); + const QString message = tr("A new version of Cockatrice is available!") + "

" + "" + + tr("New version") + QString(": %1
").arg(release->getName()) + "" + + tr("Released") + + QString(": %1 (").arg(publishDate, release->getDescriptionUrl()) + + tr("Changelog") + ")

" + tr("Do you want to update now?"); - if (reply == QMessageBox::Yes) { + if (QtUtils::confirm(window(), tr("Update Available"), message)) { downloadUpdate(release->getName()); } else { closeDialog(); diff --git a/cockatrice/src/interface/widgets/dialogs/override_printing_warning.cpp b/cockatrice/src/interface/widgets/dialogs/override_printing_warning.cpp index 57f7ff7f5..8230dc3bc 100644 --- a/cockatrice/src/interface/widgets/dialogs/override_printing_warning.cpp +++ b/cockatrice/src/interface/widgets/dialogs/override_printing_warning.cpp @@ -7,6 +7,7 @@ #include #include #include +#include class QWidget; @@ -30,10 +31,7 @@ bool OverridePrintingWarning::execMessageBox(QWidget *parent, bool enable) "Are you sure you would like to disable this feature?"); } - QMessageBox::StandardButton result = - QMessageBox::question(parent, QObject::tr("Confirm Change"), message, QMessageBox::Yes | QMessageBox::No); - - if (result == QMessageBox::Yes) { + if (QtUtils::confirm(parent, QObject::tr("Confirm Change"), message)) { SettingsCache::instance().cardsDisplay().setOverrideAllCardArtWithPersonalPreference(enable); // Caches are now invalid. CardPictureLoader::clearPixmapCache(); diff --git a/cockatrice/src/interface/widgets/dialogs/settings_view.cpp b/cockatrice/src/interface/widgets/dialogs/settings_view.cpp index ebac4e22f..6ab754f4e 100644 --- a/cockatrice/src/interface/widgets/dialogs/settings_view.cpp +++ b/cockatrice/src/interface/widgets/dialogs/settings_view.cpp @@ -6,7 +6,7 @@ #include "settings_view.h" #include "../../../client/settings/cache_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../settings_page/abstract_settings_page.h" #include "../settings_page/appearance_settings_page.h" #include "../settings_page/deck_editor_settings_page.h" @@ -60,6 +60,7 @@ #include #include #include +#include #include /** @@ -71,10 +72,8 @@ static QScrollArea *makeScrollable(QWidget *widget) { widget->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Maximum); - auto *scrollArea = new QScrollArea; - scrollArea->setWidgetResizable(true); + auto *scrollArea = QtUtils::makeScrollArea(); scrollArea->setContentsMargins(0, 0, 0, 0); - scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); scrollArea->horizontalScrollBar()->setEnabled(false); scrollArea->setWidget(widget); return scrollArea; @@ -133,7 +132,7 @@ void SettingsView::setupUi() // Search bar searchEdit = new QLineEdit; searchEdit->setClearButtonEnabled(true); - searchEdit->addAction(themePixmap(QStringLiteral("icons/search")), QLineEdit::LeadingPosition); + searchEdit->addAction(loadThemedIcon(QStringLiteral("icons/search")), QLineEdit::LeadingPosition); searchEdit->installEventFilter(this); connect(searchEdit, &QLineEdit::textChanged, this, &SettingsView::onSearchTextChanged); @@ -625,13 +624,8 @@ void SettingsView::scrollToAndFlash(QWidget *target) if (!target) { return; } - QWidget *widget = target; - while (widget) { - if (auto *scrollArea = qobject_cast(widget)) { - scrollArea->ensureWidgetVisible(target); - break; - } - widget = widget->parentWidget(); + if (auto *scrollArea = QtUtils::findParentOfType(target, true)) { + scrollArea->ensureWidgetVisible(target); } flashWidget(target); } diff --git a/cockatrice/src/interface/widgets/general/home_widget.cpp b/cockatrice/src/interface/widgets/general/home_widget.cpp index c77fd76fd..5905b2c40 100644 --- a/cockatrice/src/interface/widgets/general/home_widget.cpp +++ b/cockatrice/src/interface/widgets/general/home_widget.cpp @@ -2,9 +2,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../../interface/widgets/tabs/tab_supervisor.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 "../../theme_config.h" #include "../../theme_manager.h" @@ -57,6 +55,8 @@ #include #include #include +#include +#include #include #include #include @@ -113,6 +113,32 @@ HomeWidget::HomeWidget(QWidget *parent, TabSupervisor *_tabSupervisor) &HomeWidget::updateButtonsToBackgroundColor); } +void HomeWidget::retranslateUi() +{ + visualDeckEditorButton->setText(tr("Create New Deck")); + visualDeckStorageButton->setText(tr("Browse Decks")); + visualDatabaseDisplayButton->setText(tr("Browse Card Database")); + edhrecButton->setText(tr("Browse EDHRec")); + archidektButton->setText(tr("Browse Archidekt")); + replayButton->setText(tr("View Replays")); + if (exitButton) { + exitButton->setText(tr("Quit")); + } + + switch (connectionStatus) { + case StatusDisconnected: + connectButton->setText(tr("Connect")); + break; + case StatusLoggedIn: + connectButton->setText(tr("Play")); + break; + default: + // Connecting, registering, activating, logging in, etc. + connectButton->setText(tr("Connecting...")); + break; + } +} + void HomeWidget::initializeBackgroundFromSource() { // The featured logo is theme/scheme-derived too; reload it alongside the @@ -284,32 +310,32 @@ QGroupBox *HomeWidget::createButtons() updateLogoOverlay(); boxLayout->addSpacing(25); - connectButton = new HomeStyledButton("Connect/Play", gradientColors); + connectButton = new HomeStyledButton(QString(), gradientColors); boxLayout->addWidget(connectButton); - auto visualDeckEditorButton = new HomeStyledButton(tr("Create New Deck"), gradientColors); + visualDeckEditorButton = new HomeStyledButton(QString(), gradientColors); connect(visualDeckEditorButton, &QPushButton::clicked, tabSupervisor, [this] { tabSupervisor->openDeckInNewTab(LoadedDeck()); }); boxLayout->addWidget(visualDeckEditorButton); - auto visualDeckStorageButton = new HomeStyledButton(tr("Browse Decks"), gradientColors); + visualDeckStorageButton = new HomeStyledButton(QString(), gradientColors); connect(visualDeckStorageButton, &QPushButton::clicked, tabSupervisor, [this] { tabSupervisor->actTabVisualDeckStorage(true); }); boxLayout->addWidget(visualDeckStorageButton); - auto visualDatabaseDisplayButton = new HomeStyledButton(tr("Browse Card Database"), gradientColors); + visualDatabaseDisplayButton = new HomeStyledButton(QString(), gradientColors); connect(visualDatabaseDisplayButton, &QPushButton::clicked, tabSupervisor, &TabSupervisor::addVisualDatabaseDisplayTab); boxLayout->addWidget(visualDatabaseDisplayButton); - auto edhrecButton = new HomeStyledButton(tr("Browse EDHRec"), gradientColors); + edhrecButton = new HomeStyledButton(QString(), gradientColors); connect(edhrecButton, &QPushButton::clicked, tabSupervisor, &TabSupervisor::addEdhrecMainTab); boxLayout->addWidget(edhrecButton); - auto archidektButton = new HomeStyledButton(tr("Browse Archidekt"), gradientColors); + archidektButton = new HomeStyledButton(QString(), gradientColors); connect(archidektButton, &QPushButton::clicked, tabSupervisor, &TabSupervisor::addArchidektTab); boxLayout->addWidget(archidektButton); - auto replaybutton = new HomeStyledButton(tr("View Replays"), gradientColors); - connect(replaybutton, &QPushButton::clicked, tabSupervisor, [this] { tabSupervisor->actTabReplays(true); }); - boxLayout->addWidget(replaybutton); + replayButton = new HomeStyledButton(QString(), gradientColors); + connect(replayButton, &QPushButton::clicked, tabSupervisor, [this] { tabSupervisor->actTabReplays(true); }); + boxLayout->addWidget(replayButton); if (qobject_cast(tabSupervisor->parentWidget())) { - auto exitButton = new HomeStyledButton(tr("Quit"), gradientColors); + exitButton = new HomeStyledButton(QString(), gradientColors); connect(exitButton, &QPushButton::clicked, qobject_cast(tabSupervisor->parentWidget()), &MainWindow::actExit); boxLayout->addWidget(exitButton); @@ -321,20 +347,18 @@ QGroupBox *HomeWidget::createButtons() void HomeWidget::updateConnectButton(const ClientStatus status) { + connectionStatus = status; disconnect(connectButton, &QPushButton::clicked, nullptr, nullptr); switch (status) { case StatusConnecting: - connectButton->setText(tr("Connecting...")); connectButton->setEnabled(false); break; case StatusDisconnected: - connectButton->setText(tr("Connect")); connectButton->setEnabled(true); connect(connectButton, &QPushButton::clicked, qobject_cast(tabSupervisor->parentWidget()), &MainWindow::actConnect); break; case StatusLoggedIn: - connectButton->setText(tr("Play")); connectButton->setEnabled(true); connect(connectButton, &QPushButton::clicked, tabSupervisor, &TabSupervisor::switchToFirstAvailableNetworkTab); @@ -342,6 +366,7 @@ void HomeWidget::updateConnectButton(const ClientStatus status) default: break; } + retranslateUi(); } QPair HomeWidget::extractDominantColors(const QPixmap &pixmap) diff --git a/cockatrice/src/interface/widgets/general/home_widget.h b/cockatrice/src/interface/widgets/general/home_widget.h index fb9566cad..aa4745c82 100644 --- a/cockatrice/src/interface/widgets/general/home_widget.h +++ b/cockatrice/src/interface/widgets/general/home_widget.h @@ -33,6 +33,7 @@ class HomeWidget : public QWidget public: HomeWidget(QWidget *parent, TabSupervisor *tabSupervisor); void updateRandomCard(); + void retranslateUi(); static QPair extractDominantColors(const QPixmap &pixmap); public slots: @@ -53,7 +54,15 @@ private: DeckList backgroundSourceDeck; QLabel *logoLabel = nullptr; QPair gradientColors; - HomeStyledButton *connectButton; + HomeStyledButton *connectButton = nullptr; + HomeStyledButton *visualDeckEditorButton = nullptr; + HomeStyledButton *visualDeckStorageButton = nullptr; + HomeStyledButton *visualDatabaseDisplayButton = nullptr; + HomeStyledButton *edhrecButton = nullptr; + HomeStyledButton *archidektButton = nullptr; + HomeStyledButton *replayButton = nullptr; + HomeStyledButton *exitButton = nullptr; + ClientStatus connectionStatus = StatusDisconnected; void setRandomCard(ExactCard &newCard); void loadBackgroundSourceDeck(); diff --git a/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.cpp b/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.cpp index caeecad86..d7260912f 100644 --- a/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.cpp +++ b/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.cpp @@ -19,6 +19,7 @@ #include #include #include +#include /** * @brief Constructs a FlowWidget. @@ -47,21 +48,16 @@ FlowWidget::FlowWidget(QWidget *parent, setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Expanding); } - mainLayout = new QHBoxLayout(this); - mainLayout->setContentsMargins(0, 0, 0, 0); + mainLayout = QtUtils::makeLayout(this); setLayout(mainLayout); const bool useScrollArea = (horizontalPolicy != Qt::ScrollBarAlwaysOff || verticalPolicy != Qt::ScrollBarAlwaysOff); // Scroll area (optional) if (useScrollArea) { - scrollArea = new QScrollArea(this); - scrollArea->setWidgetResizable(true); + scrollArea = QtUtils::makeScrollArea(this, verticalPolicy, horizontalPolicy); scrollArea->setMinimumSize(0, 0); scrollArea->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); - // Set scrollbar policies - scrollArea->setHorizontalScrollBarPolicy(horizontalPolicy); - scrollArea->setVerticalScrollBarPolicy(verticalPolicy); } else { scrollArea = nullptr; } @@ -136,13 +132,7 @@ void FlowWidget::removeWidget(QWidget *widgetToRemove) const void FlowWidget::clearLayout() { if (flowLayout) { - QLayoutItem *item; - while ((item = flowLayout->takeAt(0))) { - if (item->widget()) { - item->widget()->deleteLater(); - } - delete item; - } + QtUtils::clearLayoutRec(flowLayout); } else { // Defensive fallback: recreate the layout if it was deleted externally. flowLayout = new FlowLayout(container, flowDirection); diff --git a/cockatrice/src/interface/widgets/general/layout_containers/overlap_widget.cpp b/cockatrice/src/interface/widgets/general/layout_containers/overlap_widget.cpp index 03240211c..bc2d1cc4d 100644 --- a/cockatrice/src/interface/widgets/general/layout_containers/overlap_widget.cpp +++ b/cockatrice/src/interface/widgets/general/layout_containers/overlap_widget.cpp @@ -4,6 +4,7 @@ #include #include +#include /** * @class OverlapWidget @@ -80,11 +81,7 @@ void OverlapWidget::removeWidget(QWidget *widgetToRemove) const void OverlapWidget::clearLayout() { if (overlapLayout != nullptr) { - QLayoutItem *item; - while ((item = overlapLayout->takeAt(0)) != nullptr) { - item->widget()->deleteLater(); - delete item; - } + QtUtils::clearLayoutRec(overlapLayout); } // If layout is null, create a new layout; otherwise, reuse the existing one diff --git a/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp b/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp index 426811ec2..c4298d6b6 100644 --- a/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp +++ b/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include @@ -31,9 +32,8 @@ FirstRunWizard::FirstRunWizard(QWidget *parent) : QDialog(parent) bannerHost = new BannerHost(this); titleLabel = new QLabel(this); - QFont titleFont = titleLabel->font(); + QFont titleFont = QtUtils::boldFont(titleLabel->font()); titleFont.setPointSizeF(titleFont.pointSizeF() * 1.4); - titleFont.setBold(true); titleLabel->setFont(titleFont); subtitleLabel = new QLabel(this); @@ -51,8 +51,7 @@ FirstRunWizard::FirstRunWizard(QWidget *parent) : QDialog(parent) connect(skipButton, &QPushButton::clicked, this, &FirstRunWizard::skip); connect(nextButton, &QPushButton::clicked, this, &FirstRunWizard::goNext); - auto *headerLayout = new QVBoxLayout; - headerLayout->setContentsMargins(0, 0, 0, 0); + auto *headerLayout = QtUtils::makeLayout(); headerLayout->addWidget(bannerHost); headerLayout->addSpacing(12); headerLayout->addWidget(titleLabel); diff --git a/cockatrice/src/interface/widgets/onboarding/pages/preferences_setup_page.cpp b/cockatrice/src/interface/widgets/onboarding/pages/preferences_setup_page.cpp index da08e3a12..486ada155 100644 --- a/cockatrice/src/interface/widgets/onboarding/pages/preferences_setup_page.cpp +++ b/cockatrice/src/interface/widgets/onboarding/pages/preferences_setup_page.cpp @@ -20,6 +20,7 @@ #include #include #include +#include namespace { @@ -68,9 +69,8 @@ PreferencesSetupPage::PreferencesSetupPage(QWidget *parent) : FirstRunWizardPage contentLayout->addStretch(); - auto *scrollArea = new QScrollArea(this); + auto *scrollArea = QtUtils::makeScrollArea(this, Qt::ScrollBarAsNeeded, Qt::ScrollBarAsNeeded); scrollArea->setWidget(content); - scrollArea->setWidgetResizable(true); scrollArea->setFrameShape(QFrame::NoFrame); auto *layout = new QVBoxLayout(this); diff --git a/cockatrice/src/interface/widgets/onboarding/pages/welcome_page.cpp b/cockatrice/src/interface/widgets/onboarding/pages/welcome_page.cpp index 71d2fa409..b2d5ab1e0 100644 --- a/cockatrice/src/interface/widgets/onboarding/pages/welcome_page.cpp +++ b/cockatrice/src/interface/widgets/onboarding/pages/welcome_page.cpp @@ -5,6 +5,7 @@ #include "../../settings_page/general_settings_page.h" #include "../first_run_wizard_page.h" #include "libcockatrice/settings/personal_settings.h" +#include "libcockatrice/utility/translation_loader.h" #include #include @@ -30,7 +31,7 @@ WelcomePage::WelcomePage(QWidget *parent) : FirstRunWizardPage(parent) languageLabel = new QLabel(this); langCombo = new QComboBox(this); for (const QString &code : GeneralSettingsPage::findQmFiles()) { - langCombo->addItem(GeneralSettingsPage::languageName(code), code); + langCombo->addItem(TranslationLoader::languageDisplayName(code), code); } QString current = SettingsCache::instance().personal().getLang(); diff --git a/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.cpp b/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.cpp index c17399957..1074ff6e5 100644 --- a/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.cpp +++ b/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -151,8 +152,7 @@ BannerHost::BannerHost(QWidget *parent) : QWidget(parent) { setFixedHeight(150); - stack = new QStackedLayout(this); - stack->setContentsMargins(0, 0, 0, 0); + stack = QtUtils::makeLayout(this); fallback = new GradientFallbackWidget(this); stack->addWidget(fallback); diff --git a/cockatrice/src/interface/widgets/printing_selector/card_amount_widget.cpp b/cockatrice/src/interface/widgets/printing_selector/card_amount_widget.cpp index 4b5040741..eae49d7ef 100644 --- a/cockatrice/src/interface/widgets/printing_selector/card_amount_widget.cpp +++ b/cockatrice/src/interface/widgets/printing_selector/card_amount_widget.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include class QModelIndex; @@ -42,9 +43,7 @@ CardAmountWidget::CardAmountWidget(QWidget *parent, : QWidget(parent), deckStateManager(deckStateManager), cardSizeSlider(cardSizeSlider), rootCard(rootCard), zoneName(zoneName), hovered(false) { - layout = new QHBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); - layout->setSpacing(10); + layout = QtUtils::makeLayout(this, 10); this->setLayout(layout); this->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); layout->setAlignment(Qt::AlignHCenter); diff --git a/cockatrice/src/interface/widgets/printing_selector/printing_selector.cpp b/cockatrice/src/interface/widgets/printing_selector/printing_selector.cpp index 27ae61b94..b8438134c 100644 --- a/cockatrice/src/interface/widgets/printing_selector/printing_selector.cpp +++ b/cockatrice/src/interface/widgets/printing_selector/printing_selector.cpp @@ -32,6 +32,7 @@ #include #include #include +#include #include /** @@ -84,9 +85,7 @@ PrintingSelector::PrintingSelector(QWidget *parent, AbstractTabDeckEditor *_deck displayOptionsWidget->addSettingsWidget(cardSizeWidget); sortAndOptionsContainer = new QWidget(this); - sortAndOptionsLayout = new QHBoxLayout(sortAndOptionsContainer); - sortAndOptionsLayout->setSpacing(3); - sortAndOptionsLayout->setContentsMargins(0, 0, 0, 0); + sortAndOptionsLayout = QtUtils::makeLayout(sortAndOptionsContainer, 0, 3); sortAndOptionsContainer->setLayout(sortAndOptionsLayout); searchBar = new PrintingSelectorCardSearchWidget(this); @@ -229,7 +228,7 @@ void PrintingSelector::getAllSetsForCurrentCard() return; } - SetToPrintingsMap setMap = selectedCard->getSets(); + const auto &setMap = selectedCard->getSets(); const QList sortedPrintings = sortToolBar->sortSets(setMap); const QList filteredPrintings = sortToolBar->filterSets(sortedPrintings, searchBar->getSearchText().trimmed().toLower()); diff --git a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.cpp b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.cpp index bec9100a6..94acdbdce 100644 --- a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.cpp +++ b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.cpp @@ -40,6 +40,7 @@ #include #include #include +#include #include class DeckStateManager; @@ -108,9 +109,7 @@ PrintingSelectorCardOverlayWidget::PrintingSelectorCardOverlayWidget(QWidget *pa : QWidget(parent), deckEditor(_deckEditor), rootCard(_rootCard) { // Set up the main layout - auto *mainLayout = new QVBoxLayout(this); - mainLayout->setContentsMargins(0, 0, 0, 0); - mainLayout->setSpacing(0); + auto *mainLayout = QtUtils::makeLayout(this, 0); setLayout(mainLayout); // Add CardInfoPictureWidget diff --git a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_sorting_widget.cpp b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_sorting_widget.cpp index f47ba0746..1cd372259 100644 --- a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_sorting_widget.cpp +++ b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_sorting_widget.cpp @@ -208,7 +208,7 @@ QList PrintingSelectorCardSortingWidget::prependPrintingsInDeck(co return {}; } - SetToPrintingsMap setMap = selectedCard->getSets(); + const auto &setMap = selectedCard->getSets(); QList> countList; // Collect sets with their counts diff --git a/cockatrice/src/interface/widgets/printing_selector/set_name_and_collectors_number_display_widget.cpp b/cockatrice/src/interface/widgets/printing_selector/set_name_and_collectors_number_display_widget.cpp index 9bc92ad90..d8c67c2a9 100644 --- a/cockatrice/src/interface/widgets/printing_selector/set_name_and_collectors_number_display_widget.cpp +++ b/cockatrice/src/interface/widgets/printing_selector/set_name_and_collectors_number_display_widget.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include /** @@ -19,8 +20,7 @@ SetNameAndCollectorsNumberDisplayWidget::SetNameAndCollectorsNumberDisplayWidget : QWidget(parent) { // Set up the layout for the widget - layout = new QVBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); + layout = QtUtils::makeLayout(this); layout->setSpacing(1); setLayout(layout); diff --git a/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.cpp b/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.cpp index 981ed843c..c6a6d989f 100644 --- a/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.cpp +++ b/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.cpp @@ -1,12 +1,12 @@ #include "settings_button_widget.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "settings_popup_widget.h" #include #include +#include #include -#include #include #include #include @@ -15,20 +15,20 @@ #include #include #include +#include #include SettingsButtonWidget::SettingsButtonWidget(QWidget *parent) : QWidget(parent), button(new QToolButton(this)), popup(new SettingsPopupWidget(nullptr)) { - button->setIcon(themePixmap(QStringLiteral("icons/cogwheel"))); + button->setIcon(loadThemedIcon(QStringLiteral("icons/settings"))); button->setCheckable(true); button->setFixedSize(32, 32); connect(button, &QToolButton::clicked, this, &SettingsButtonWidget::togglePopup); connect(popup, &SettingsPopupWidget::aboutToClose, this, &SettingsButtonWidget::onPopupClosed); - layout = new QHBoxLayout(this); + layout = QtUtils::makeLayout(this); layout->addWidget(button); - layout->setContentsMargins(0, 0, 0, 0); setLayout(layout); } @@ -49,9 +49,9 @@ void SettingsButtonWidget::removeSettingsWidget(QWidget *toRemove) const popup->removeSettingsWidget(toRemove); } -void SettingsButtonWidget::setButtonIcon(QPixmap iconMap) +void SettingsButtonWidget::setButtonIcon(const QIcon &icon) { - button->setIcon(iconMap); + button->setIcon(icon); } void SettingsButtonWidget::setButtonText(const QString &text) diff --git a/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.h b/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.h index 015300bf8..56d542d37 100644 --- a/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.h +++ b/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.h @@ -13,7 +13,7 @@ #include class QHBoxLayout; -class QPixmap; +class QIcon; class QToolButton; class SettingsPopupWidget; @@ -28,7 +28,7 @@ public: void addSettingsWidget(QWidget *toAdd) const; void removeSettingsWidget(QWidget *toRemove) const; - void setButtonIcon(QPixmap iconMap); + void setButtonIcon(const QIcon &icon); void setButtonText(const QString &buttonText); void setCompact(bool compact); bool isCompact() const diff --git a/cockatrice/src/interface/widgets/quick_settings/settings_popup_widget.cpp b/cockatrice/src/interface/widgets/quick_settings/settings_popup_widget.cpp index 7ace155a7..351e3732c 100644 --- a/cockatrice/src/interface/widgets/quick_settings/settings_popup_widget.cpp +++ b/cockatrice/src/interface/widgets/quick_settings/settings_popup_widget.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include SettingsPopupWidget::SettingsPopupWidget(QWidget *parent) : QWidget(parent, Qt::Popup | Qt::FramelessWindowHint) @@ -54,11 +55,8 @@ void SettingsPopupWidget::adjustSizeToFitScreen() if (contentHeight > maxHeight) { // Create a scroll area and add the container widget to it if not already created if (!scrollArea) { - scrollArea = new QScrollArea(this); - scrollArea->setWidgetResizable(true); + scrollArea = QtUtils::makeScrollArea(this, Qt::ScrollBarAlwaysOn); scrollArea->setFrameShape(QFrame::NoFrame); - scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); // No horizontal scrollbar - scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOn); // Enable vertical scrollbar when needed } // Set maximum height for the scroll area and show vertical scrollbar diff --git a/cockatrice/src/interface/widgets/replay/replay_manager.cpp b/cockatrice/src/interface/widgets/replay/replay_manager.cpp index f3f1da2c4..8ba0cb653 100644 --- a/cockatrice/src/interface/widgets/replay/replay_manager.cpp +++ b/cockatrice/src/interface/widgets/replay/replay_manager.cpp @@ -1,13 +1,13 @@ #include "replay_manager.h" #include "../../../client/settings/cache_settings.h" -#include "../../../game/player/event_processing_options.h" #include "libcockatrice/protocol/pb/game_event.pb.h" #include "libcockatrice/protocol/pb/game_event_container.pb.h" #include "libcockatrice/protocol/pb/game_replay.pb.h" #include #include +#include #include #include #include diff --git a/cockatrice/src/interface/widgets/replay/replay_manager.h b/cockatrice/src/interface/widgets/replay/replay_manager.h index 3b33deada..3849b86ae 100644 --- a/cockatrice/src/interface/widgets/replay/replay_manager.h +++ b/cockatrice/src/interface/widgets/replay/replay_manager.h @@ -1,11 +1,10 @@ #ifndef COCKATRICE_REPLAY_MANAGER_H #define COCKATRICE_REPLAY_MANAGER_H -#include "../../../game/player/event_processing_options.h" - #include #include #include +#include #include class GameReplay; diff --git a/cockatrice/src/interface/widgets/replay/replay_quick_settings_widget.cpp b/cockatrice/src/interface/widgets/replay/replay_quick_settings_widget.cpp index 5b3829af2..0a64fb4d2 100644 --- a/cockatrice/src/interface/widgets/replay/replay_quick_settings_widget.cpp +++ b/cockatrice/src/interface/widgets/replay/replay_quick_settings_widget.cpp @@ -11,6 +11,7 @@ #include #include #include +#include ReplayQuickSettingsWidget::ReplayQuickSettingsWidget(QWidget *parent) : SettingsButtonWidget(parent) { @@ -28,8 +29,7 @@ ReplayQuickSettingsWidget::ReplayQuickSettingsWidget(QWidget *parent) : Settings // putting it all together auto *widget = new QWidget; - auto *grid = new QGridLayout(widget); - grid->setContentsMargins(0, 0, 0, 0); + auto *grid = QtUtils::makeLayout(widget); grid->addWidget(&fastForwardSpeedLabel, 0, 0, 1, 1); grid->addWidget(&fastForwardSpeedBox, 0, 1, 1, 1); grid->addWidget(&skipEmptyCheckBox, 1, 0, 1, 2); diff --git a/cockatrice/src/interface/widgets/replay/replay_widget.h b/cockatrice/src/interface/widgets/replay/replay_widget.h index 74aace570..0e78442ce 100644 --- a/cockatrice/src/interface/widgets/replay/replay_widget.h +++ b/cockatrice/src/interface/widgets/replay/replay_widget.h @@ -8,9 +8,8 @@ #ifndef REPLAY_WIDGET_H #define REPLAY_WIDGET_H -#include "../../../game/player/event_processing_options.h" - #include +#include #include class ReplayManager; diff --git a/cockatrice/src/interface/widgets/server/game_selector.cpp b/cockatrice/src/interface/widgets/server/game_selector.cpp index 6922bfe64..2c0050afe 100644 --- a/cockatrice/src/interface/widgets/server/game_selector.cpp +++ b/cockatrice/src/interface/widgets/server/game_selector.cpp @@ -1,7 +1,7 @@ #include "game_selector.h" #include "../../../client/settings/cache_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../interface/widgets/dialogs/dlg_create_game.h" #include "../interface/widgets/dialogs/dlg_filter_games.h" #include "../interface/widgets/tabs/tab_room.h" @@ -41,6 +41,7 @@ #include #include #include +#include #include #include #include @@ -129,10 +130,10 @@ GameSelector::GameSelector(AbstractClient *_client, } filterButton = new QPushButton; - filterButton->setIcon(themePixmap(QStringLiteral("icons/search"))); + filterButton->setIcon(loadThemedIcon(QStringLiteral("icons/search"))); connect(filterButton, &QPushButton::clicked, this, &GameSelector::actSetFilter); clearFilterButton = new QPushButton; - clearFilterButton->setIcon(themePixmap(QStringLiteral("icons/clearsearch"))); + clearFilterButton->setIcon(loadThemedIcon(QStringLiteral("icons/clearsearch"))); bool filtersSetToDefault = showFilters && gameListProxyModel->areFilterParametersSetToDefaults(); clearFilterButton->setEnabled(!filtersSetToDefault); connect(clearFilterButton, &QPushButton::clicked, this, &GameSelector::actClearFilter); @@ -420,10 +421,8 @@ void GameSelector::joinGame(const ServerInfo_Game &game, const bool asSpectator, // spectator join, so ask first instead of surprising the player. const bool gameFull = game.player_count() == game.max_players(); if (gameFull && !asSpectator && !asJudge && !overrideRestrictions) { - const QMessageBox::StandardButton answer = - QMessageBox::question(this, tr("Join game"), tr("The game is full. Join as a spectator instead?"), - QMessageBox::Yes | QMessageBox::No, QMessageBox::No); - if (answer != QMessageBox::Yes) { + if (!QtUtils::confirm(this, tr("Join game"), tr("The game is full. Join as a spectator instead?"), + QMessageBox::No)) { return; } } diff --git a/cockatrice/src/interface/widgets/server/game_selector_quick_filter_toolbar.cpp b/cockatrice/src/interface/widgets/server/game_selector_quick_filter_toolbar.cpp index 0fc3d879c..58f9bb8a9 100644 --- a/cockatrice/src/interface/widgets/server/game_selector_quick_filter_toolbar.cpp +++ b/cockatrice/src/interface/widgets/server/game_selector_quick_filter_toolbar.cpp @@ -14,6 +14,7 @@ #include #include #include +#include class TabSupervisor; template class QMap; @@ -24,8 +25,7 @@ GameSelectorQuickFilterToolBar::GameSelectorQuickFilterToolBar(QWidget *parent, const QMap &allGameTypes) : QWidget(parent), tabSupervisor(_tabSupervisor), model(_model) { - mainLayout = new QHBoxLayout(this); - mainLayout->setContentsMargins(0, 0, 0, 0); + mainLayout = QtUtils::makeLayout(this); mainLayout->setSpacing(5); const GameFilterConfigs &filters = model->getFilters(); diff --git a/cockatrice/src/interface/widgets/server/games_model.cpp b/cockatrice/src/interface/widgets/server/games_model.cpp index 315d51d9e..95ea97bd2 100644 --- a/cockatrice/src/interface/widgets/server/games_model.cpp +++ b/cockatrice/src/interface/widgets/server/games_model.cpp @@ -1,6 +1,7 @@ #include "games_model.h" #include "../../../client/settings/cache_settings.h" +#include "../../theme_icon.h" #include "../interface/pixel_map_generator.h" #include "libcockatrice/protocol/pb/serverinfo_user.pb.h" #include "user/user_list_proxy.h" @@ -85,7 +86,8 @@ const QString GamesModel::getGameCreatedString(const int secs) } GamesModel::GamesModel(const QMap &_rooms, const QMap &_gameTypes, QObject *parent) - : QAbstractTableModel(parent), rooms(_rooms), gameTypes(_gameTypes) + : QAbstractTableModel(parent), rooms(_rooms), gameTypes(_gameTypes), + lockIcon(loadThemedIcon(QStringLiteral("icons/lock"))) { } @@ -188,7 +190,9 @@ QVariant GamesModel::data(const QModelIndex &index, int role) const return result.join(", "); } case Qt::DecorationRole: { - return gameentry.with_password() ? QIcon(LockPixmapGenerator::generatePixmap(13)) : QVariant(); + // One engine per model: it is palette- and theme-aware and + // invalidates itself, so a repaint must not build a new one. + return gameentry.with_password() ? lockIcon : QVariant(); case Qt::TextAlignmentRole: return Qt::AlignLeft; default: diff --git a/cockatrice/src/interface/widgets/server/games_model.h b/cockatrice/src/interface/widgets/server/games_model.h index 9bf54b83a..62862417d 100644 --- a/cockatrice/src/interface/widgets/server/games_model.h +++ b/cockatrice/src/interface/widgets/server/games_model.h @@ -5,6 +5,7 @@ #include "game_type_map.h" #include +#include #include #include #include @@ -32,6 +33,7 @@ private: QList gameList; /**< List of games currently displayed. */ QMap rooms; /**< Map of room IDs to room names. */ QMap gameTypes; /**< Map of room IDs to available game types. */ + QIcon lockIcon; /**< Themed lock for password-protected games. */ static const int NUM_COLS = 8; /**< Number of columns in the table. */ diff --git a/cockatrice/src/interface/widgets/server/handle_public_servers.cpp b/cockatrice/src/interface/widgets/server/handle_public_servers.cpp index 08b7837dd..1c0cbf962 100644 --- a/cockatrice/src/interface/widgets/server/handle_public_servers.cpp +++ b/cockatrice/src/interface/widgets/server/handle_public_servers.cpp @@ -13,13 +13,14 @@ #include #include #include +#include #include #include #define PUBLIC_SERVERS_JSON "https://cockatrice.github.io/public-servers.json" HandlePublicServers::HandlePublicServers(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/server/remote/remote_replay_list_tree_widget.cpp b/cockatrice/src/interface/widgets/server/remote/remote_replay_list_tree_widget.cpp index c131c3cbf..c1db6138d 100644 --- a/cockatrice/src/interface/widgets/server/remote/remote_replay_list_tree_widget.cpp +++ b/cockatrice/src/interface/widgets/server/remote/remote_replay_list_tree_widget.cpp @@ -1,6 +1,6 @@ #include "remote_replay_list_tree_widget.h" -#include "../../../pixel_map_generator.h" +#include "../../../theme_icon.h" #include "libcockatrice/protocol/pb/response.pb.h" #include "libcockatrice/protocol/pb/serverinfo_replay_match.pb.h" @@ -50,7 +50,7 @@ RemoteReplayList_TreeModel::RemoteReplayList_TreeModel(AbstractClient *_client, QFileIconProvider fip; dirIcon = fip.icon(QFileIconProvider::Folder); fileIcon = fip.icon(QFileIconProvider::File); - lockIcon = themePixmap(QStringLiteral("icons/lock")); + lockIcon = loadThemedIcon(QStringLiteral("icons/lock")); } RemoteReplayList_TreeModel::~RemoteReplayList_TreeModel() diff --git a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp index c8fe97cda..447e63673 100644 --- a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp @@ -1,8 +1,5 @@ #include "user_context_menu.h" -#include "../../../../game/abstract_game.h" -#include "../../../../game/game_event_handler.h" -#include "../../../../game/game_meta_info.h" #include "../../dialogs/dlg_report_user.h" #include "../../interface/widgets/tabs/tab_supervisor.h" #include "../chat_view/chat_view.h" @@ -37,6 +34,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -49,6 +49,7 @@ #include #include #include +#include #include #include @@ -685,10 +686,8 @@ void UserContextMenu::execRemoveFromIgnore(const QString &userName) void UserContextMenu::execKick(int playerId) { - auto result = QMessageBox::question(static_cast(parent()), tr("Kick Player"), - tr("Are you sure you want to kick this player from the game?"), - QMessageBox::Yes | QMessageBox::No, QMessageBox::No); - if (result == QMessageBox::Yes) { + if (QtUtils::confirm(static_cast(parent()), tr("Kick Player"), + tr("Are you sure you want to kick this player from the game?"), QMessageBox::No)) { Command_KickFromGame cmd; cmd.set_player_id(playerId); diff --git a/cockatrice/src/interface/widgets/server/user/user_info_box.cpp b/cockatrice/src/interface/widgets/server/user/user_info_box.cpp index 60bc97edc..fa7698037 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_box.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_info_box.cpp @@ -32,7 +32,7 @@ #include #include #include -#include +#include #include #include @@ -53,8 +53,7 @@ UserInfoBox::UserInfoBox(AbstractClient *_client, bool _editable, QWidget *paren avatarPic.setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); avatarPic.setAlignment(Qt::AlignCenter); - auto *avatarLayout = new QHBoxLayout; - avatarLayout->setContentsMargins(0, 0, 0, 0); + auto *avatarLayout = QtUtils::makeLayout(); avatarLayout->addStretch(1); avatarLayout->addWidget(&avatarPic, 3); avatarLayout->addStretch(1); @@ -299,17 +298,7 @@ void UserInfoBox::actPassword() void UserInfoBox::changePassword(const QString &oldPassword, const QString &newPassword) { - Command_AccountPassword cmd; - cmd.set_old_password(oldPassword.toStdString()); - if (client->getServerSupportsChallengeResponse()) { - cmd.set_hashed_new_password(PasswordHasher::generatePasswordVerifier(newPassword).toStdString()); - } else if (client->getServerSupportsPasswordHash()) { - auto passwordSalt = PasswordHasher::generateRandomSalt(); - QString hashedPassword = PasswordHasher::computeHash(newPassword, passwordSalt); - cmd.set_hashed_new_password(hashedPassword.toStdString()); - } else { - cmd.set_new_password(newPassword.toStdString()); - } + const Command_AccountPassword cmd = client->makeAccountPasswordCommand(oldPassword, newPassword); PendingCommand *pend = client->prepareSessionCommand(cmd); connect(pend, &PendingCommand::finished, this, &UserInfoBox::processPasswordResponse); diff --git a/cockatrice/src/interface/widgets/server/user/user_info_connection.cpp b/cockatrice/src/interface/widgets/server/user/user_info_connection.cpp index d23ac63ba..2e864824c 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_connection.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_info_connection.cpp @@ -29,19 +29,10 @@ QMap> UserConnection_Inf ServersSettings &servers = SettingsCache::instance().servers(); - int size = servers.getValue("totalServers", "server", "server_details").toInt() + 1; - - for (int i = 0; i < size; i++) { - QString _saveName = servers.getValue(QString("saveName%1").arg(i), "server", "server_details").toString(); - QString serverName = servers.getValue(QString("server%1").arg(i), "server", "server_details").toString(); - QString portNum = servers.getValue(QString("port%1").arg(i), "server", "server_details").toString(); - QString userName = servers.getValue(QString("username%1").arg(i), "server", "server_details").toString(); - QString pass = servers.getValue(QString("password%1").arg(i), "server", "server_details").toString(); - bool savePass = servers.getValue(QString("savePassword%1").arg(i), "server", "server_details").toBool(); - QString _site = servers.getValue(QString("site%1").arg(i), "server", "server_details").toString(); - - UserConnection_Information userInfo(_saveName, serverName, portNum, userName, pass, savePass, _site); - serverList.insert(_saveName, std::make_pair(serverName, userInfo)); + for (const auto &entry : servers.getServers()) { + UserConnection_Information userInfo(entry.saveName, entry.host, entry.port, entry.username, entry.password, + entry.savePassword, entry.site); + serverList.insert(entry.saveName, std::make_pair(entry.host, userInfo)); } return serverList; @@ -53,28 +44,18 @@ QStringList UserConnection_Information::getServerInfo(const QString &find) ServersSettings &servers = SettingsCache::instance().servers(); - int size = servers.getValue("totalServers", "server", "server_details").toInt() + 1; - for (int i = 0; i < size; i++) { - QString _saveName = servers.getValue(QString("saveName%1").arg(i), "server", "server_details").toString(); - - if (find != _saveName) { + for (const auto &entry : servers.getServers()) { + if (find != entry.saveName) { continue; } - QString serverName = servers.getValue(QString("server%1").arg(i), "server", "server_details").toString(); - QString portNum = servers.getValue(QString("port%1").arg(i), "server", "server_details").toString(); - QString userName = servers.getValue(QString("username%1").arg(i), "server", "server_details").toString(); - QString pass = servers.getValue(QString("password%1").arg(i), "server", "server_details").toString(); - bool savePass = servers.getValue(QString("savePassword%1").arg(i), "server", "server_details").toBool(); - QString _site = servers.getValue(QString("site%1").arg(i), "server", "server_details").toString(); - - _server.append(_saveName); - _server.append(serverName); - _server.append(portNum); - _server.append(userName); - _server.append(pass); - _server.append(savePass ? "1" : "0"); - _server.append(_site); + _server.append(entry.saveName); + _server.append(entry.host); + _server.append(entry.port); + _server.append(entry.username); + _server.append(entry.password); + _server.append(entry.savePassword ? "1" : "0"); + _server.append(entry.site); break; } diff --git a/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp b/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp index c3e880084..efb3b2e31 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp @@ -55,6 +55,7 @@ #include #include #include +#include #include #include @@ -402,9 +403,7 @@ UserInfoPopup::UserInfoPopup(TabSupervisor *_ts, void UserInfoPopup::buildUi() { - auto *root = new QVBoxLayout(this); - root->setContentsMargins(0, 0, 0, 0); - root->setSpacing(0); + auto *root = QtUtils::makeLayout(this, 0); // Header header = new UserInfoHeaderWidget(this); diff --git a/cockatrice/src/interface/widgets/server/user/user_list_panel_widget.cpp b/cockatrice/src/interface/widgets/server/user/user_list_panel_widget.cpp index 78d45d120..5a34c852b 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_panel_widget.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_panel_widget.cpp @@ -9,6 +9,7 @@ #include #include #include +#include namespace { @@ -31,9 +32,7 @@ QString sectionKey(UserListWidget::Section section) UserListPanelWidget::UserListPanelWidget(TabSupervisor *_tabSupervisor, AbstractClient *_client, QWidget *parent) : QWidget(parent) { - auto *mainLayout = new QVBoxLayout(this); - mainLayout->setContentsMargins(0, 0, 0, 0); - mainLayout->setSpacing(2); + auto *mainLayout = QtUtils::makeLayout(this, 2); searchBar = new QLineEdit(this); searchBar->setClearButtonEnabled(true); diff --git a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp index b3fd1d1a9..a20b39b82 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp @@ -13,10 +13,10 @@ #include "user_list_painter.h" #include "user_list_proxy.h" -#include #include #include #include +#include #include #include #include @@ -41,7 +41,6 @@ #include #include #include -#include #include #include #include @@ -57,7 +56,9 @@ #include #include #include +#include #include +#include #include class QPixmap; @@ -273,6 +274,11 @@ void UserListTWI::setOnline(bool online) */ bool UserListTWI::operator<(const QTreeWidgetItem &other) const { + // QCollator caches the locale data, unlike QString::localeAwareCompare, + // which rebuilds it for every pair. The tree sorts one list at a time, so a + // single shared, immutable collator is enough. + static const QCollator collator = NaturalSort::makeNaturalSortCollator(); + // Sort by online/offline if (data(0, UserListRoles::Online) != other.data(0, UserListRoles::Online)) { return data(0, UserListRoles::Online).toBool(); @@ -288,8 +294,7 @@ bool UserListTWI::operator<(const QTreeWidgetItem &other) const for (const auto &userLevelEntry : userLevelWithNoOtherPrefOrder) { if (lhsUserLevelFlags.testFlag(userLevelEntry) && lhsUserLevelFlags.testFlag(userLevelEntry) == rhsUserLevelFlags.testFlag(userLevelEntry)) { - return QString::localeAwareCompare(data(2, Qt::UserRole).toString(), - other.data(2, Qt::UserRole).toString()) < 0; + return collator.compare(data(2, Qt::UserRole).toString(), other.data(2, Qt::UserRole).toString()) < 0; } else if (lhsUserLevelFlags.testFlag(userLevelEntry) != rhsUserLevelFlags.testFlag(userLevelEntry)) { return lhsUserLevelFlags.testFlag(userLevelEntry) > rhsUserLevelFlags.testFlag(userLevelEntry); } @@ -314,7 +319,7 @@ bool UserListTWI::operator<(const QTreeWidgetItem &other) const } // Sort by name - return QString::localeAwareCompare(data(2, Qt::UserRole).toString(), other.data(2, Qt::UserRole).toString()) < 0; + return collator.compare(data(2, Qt::UserRole).toString(), other.data(2, Qt::UserRole).toString()) < 0; } UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor, @@ -895,11 +900,7 @@ void UserListWidget::showPopupForUser(UserListTWI *item) return; } - auto *fade = new QPropertyAnimation(userInfoPopup, "windowOpacity", userInfoPopup); - fade->setDuration(120); - fade->setStartValue(0.0); - fade->setEndValue(1.0); - fade->start(QAbstractAnimation::DeleteWhenStopped); + QtUtils::fadeWindow(userInfoPopup, 0.0, 1.0, 120); } void UserListWidget::positionPopup(UserListTWI *item) @@ -976,12 +977,7 @@ void UserListWidget::hidePopup(bool immediate) } // Fade out - auto *fade = new QPropertyAnimation(userInfoPopup, "windowOpacity", userInfoPopup); - fade->setDuration(100); - fade->setStartValue(userInfoPopup->windowOpacity()); - fade->setEndValue(0.0); - connect(fade, &QPropertyAnimation::finished, userInfoPopup, &QWidget::hide); - fade->start(QAbstractAnimation::DeleteWhenStopped); + QtUtils::fadeWindow(userInfoPopup, userInfoPopup->windowOpacity(), 0.0, 100, true); } bool UserListWidget::isPressInsideListUi(const QWidget *widget) const diff --git a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp index be2d5735e..de5f67b32 100644 --- a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp @@ -17,7 +17,6 @@ #include #include #include -#include #include #include #include @@ -30,7 +29,6 @@ #include #include #include -#include #include #include #include @@ -39,6 +37,7 @@ #include #include #include +#include #include AppearanceSettingsPage::AppearanceSettingsPage() @@ -446,7 +445,7 @@ void AppearanceSettingsPage::openThemeLocation() dirDir.cdUp(); // open if dir exists, create if parent dir does exist if (dirDir.exists() && dirDir.mkpath(dir)) { - QDesktopServices::openUrl(QUrl::fromLocalFile(dir)); + QtUtils::openPathInFileManager(dir); } else { QMessageBox::critical(this, tr("Error"), tr("Could not create themes directory at '%1'.").arg(dir)); } diff --git a/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp index 31178ba4c..dbc255ea5 100644 --- a/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp @@ -1,7 +1,7 @@ #include "deck_editor_settings_page.h" #include "../../../client/settings/cache_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../dialogs/path_safety_warning.h" #include "update/card_spoiler/spoiler_background_updater.h" @@ -79,19 +79,19 @@ DeckEditorSettingsPage::DeckEditorSettingsPage() } aAdd = new QAction(this); - aAdd->setIcon(themePixmap(QStringLiteral("icons/increment"))); + aAdd->setIcon(loadThemedIcon(QStringLiteral("icons/increment"))); connect(aAdd, &QAction::triggered, this, &DeckEditorSettingsPage::actAddURL); aEdit = new QAction(this); - aEdit->setIcon(themePixmap(QStringLiteral("icons/pencil"))); + aEdit->setIcon(loadThemedIcon(QStringLiteral("icons/pencil"))); connect(aEdit, &QAction::triggered, this, &DeckEditorSettingsPage::actEditURL); aRemove = new QAction(this); - aRemove->setIcon(themePixmap(QStringLiteral("icons/decrement"))); + aRemove->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"))); connect(aRemove, &QAction::triggered, this, &DeckEditorSettingsPage::actRemoveURL); aRateLimit = new QAction(this); - aRateLimit->setIcon(themePixmap(QStringLiteral("icons/cogwheel"))); + aRateLimit->setIcon(loadThemedIcon(QStringLiteral("icons/settings"))); connect(aRateLimit, &QAction::triggered, this, &DeckEditorSettingsPage::actAdjustRateLimit); auto *urlToolBar = new QToolBar; diff --git a/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp index a19417661..7b89b744d 100644 --- a/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp @@ -8,7 +8,6 @@ #include "update/client/release_channel.h" #include -#include #include #include #include @@ -22,7 +21,6 @@ #include #include #include -#include #include #include #include @@ -35,6 +33,7 @@ #include #include #include +#include #include #include #include @@ -50,18 +49,24 @@ enum startupCardUpdateCheckBehaviorIndex GeneralSettingsPage::GeneralSettingsPage() { // language settings - QStringList languageCodes = findQmFiles(); - for (const QString &code : languageCodes) { - QString langName = languageName(code); - languageBox.addItem(langName, code); + for (const QString &code : findQmFiles()) { + 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 client 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 { + qCWarning(GeneralSettingsPageLog) << "could not find language" << configuredLanguage; } advertiseTranslationPageLabel.setTextInteractionFlags(Qt::LinksAccessibleByMouse); @@ -95,12 +100,12 @@ GeneralSettingsPage::GeneralSettingsPage() auto *languageGrid = new QGridLayout; languageGrid->addWidget(&languageLabel, 0, 0); languageGrid->addWidget(&languageBox, 0, 1); - languageGrid->addWidget(&cardLanguageLabel, 1, 0); - languageGrid->addWidget(&cardLanguageBox, 1, 1); - languageGrid->addWidget(&cardLanguageNoteLabel, 2, 1); - languageGrid->addWidget(&cardSearchLanguageLabel, 3, 0); - languageGrid->addWidget(&cardSearchLanguageBox, 3, 1); - languageGrid->addWidget(&advertiseTranslationPageLabel, 4, 1, Qt::AlignRight); + languageGrid->addWidget(&advertiseTranslationPageLabel, 1, 1, Qt::AlignRight); + languageGrid->addWidget(&cardLanguageLabel, 2, 0); + languageGrid->addWidget(&cardLanguageBox, 2, 1); + languageGrid->addWidget(&cardLanguageNoteLabel, 3, 1); + languageGrid->addWidget(&cardSearchLanguageLabel, 4, 0); + languageGrid->addWidget(&cardSearchLanguageBox, 4, 1); cardLanguageNoteLabel.setWordWrap(true); cardLanguageNoteLabel.setAlignment(Qt::AlignLeft | Qt::AlignVCenter); @@ -383,18 +388,6 @@ QStringList GeneralSettingsPage::findQmFiles() TranslationLoader::applicationTranslationPaths(translationPath)); } -QString GeneralSettingsPage::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 GeneralSettingsPage::deckPathButtonClicked() { QString path = QFileDialog::getExistingDirectory(this, tr("Choose path"), deckPathEdit->text()); @@ -547,12 +540,9 @@ void GeneralSettingsPage::cardLanguageBoxChanged(int index) } message += tr("

Run Oracle now?

"); - const QMessageBox::StandardButton answer = QMessageBox::question( - this, tr("Card text & images language changed"), message, QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes); - // The answer only controls whether Oracle starts right away; the caches stay // cleared so the next import or launch rebuilds them in the new language. - if (answer == QMessageBox::Yes) { + if (QtUtils::confirm(this, tr("Card text & images language changed"), message, QMessageBox::Yes)) { emit cardDatabaseUpdateRequested(); } } @@ -578,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( 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 20564c968..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 diff --git a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp index 6128a7830..09dd9e9b0 100644 --- a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp @@ -1,7 +1,7 @@ #include "messages_settings_page.h" #include "../../../client/settings/cache_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../interface/widgets/utility/get_text_with_max.h" #include "libcockatrice/utility/macros.h" @@ -146,15 +146,15 @@ MessagesSettingsPage::MessagesSettingsPage() } aAdd = new QAction(this); - aAdd->setIcon(themePixmap(QStringLiteral("icons/increment"))); + aAdd->setIcon(loadThemedIcon(QStringLiteral("icons/increment"))); connect(aAdd, &QAction::triggered, this, &MessagesSettingsPage::actAdd); aEdit = new QAction(this); - aEdit->setIcon(themePixmap(QStringLiteral("icons/pencil"))); + aEdit->setIcon(loadThemedIcon(QStringLiteral("icons/pencil"))); connect(aEdit, &QAction::triggered, this, &MessagesSettingsPage::actEdit); aRemove = new QAction(this); - aRemove->setIcon(themePixmap(QStringLiteral("icons/decrement"))); + aRemove->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"))); connect(aRemove, &QAction::triggered, this, &MessagesSettingsPage::actRemove); auto *messageToolBar = new QToolBar; 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..83c2ec585 100644 --- a/cockatrice/src/interface/widgets/settings_page/shortcut_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/shortcut_settings_page.cpp @@ -3,7 +3,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcut_treeview.h" #include "../../../client/settings/shortcuts_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../interface/widgets/utility/custom_line_edit.h" #include "../interface/widgets/utility/sequence_edit.h" @@ -18,6 +18,7 @@ #include #include #include +#include #include ShortcutSettingsPage::ShortcutSettingsPage() @@ -57,8 +58,8 @@ ShortcutSettingsPage::ShortcutSettingsPage() btnResetAll = new QPushButton(this); btnClearAll = new QPushButton(this); - btnResetAll->setIcon(themePixmap(QStringLiteral("icons/update"))); - btnClearAll->setIcon(themePixmap(QStringLiteral("icons/clearsearch"))); + btnResetAll->setIcon(loadThemedIcon(QStringLiteral("icons/reload"))); + btnClearAll->setIcon(loadThemedIcon(QStringLiteral("icons/clearsearch"))); // layout auto *_editLayout = new QGridLayout; @@ -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..5697308a9 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp @@ -1,7 +1,7 @@ #include "tab_archidekt.h" #include "../../../../../client/settings/cache_settings.h" -#include "../../../../pixel_map_generator.h" +#include "../../../../theme_icon.h" #include "../../../cards/additional_info/mana_symbol_widget.h" #include "../../../cards/card_size_widget.h" #include "../../../quick_settings/settings_button_widget.h" @@ -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,16 +220,16 @@ 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); // Format filter (collapsible) formatButton = new SettingsButtonWidget(secondaryToolbar); formatButton->setButtonText(tr("Formats")); - formatButton->setButtonIcon(themePixmap(QStringLiteral("icons/scale_balanced"))); + formatButton->setButtonIcon(loadThemedIcon(QStringLiteral("icons/scales"))); QWidget *formatContainer = new QWidget(secondaryToolbar); QGridLayout *formatLayout = new QGridLayout(formatContainer); @@ -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..7f1727131 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 @@ -1,7 +1,7 @@ #include "commander_bracket_widget.h" #include "../../../../../client/settings/cache_settings.h" -#include "../../../../pixel_map_generator.h" +#include "../../../../theme_icon.h" #include "commander_bracket_service.h" #include "libcockatrice/deck_list/deck_list.h" @@ -21,6 +21,7 @@ #include #include #include +#include #include template class QSharedPointer; @@ -39,13 +40,12 @@ CommanderBracketWidget::CommanderBracketWidget(QWidget *parent) : QWidget(parent bracketInfoButton->setEnabled(false); bracketRefreshButton = new QToolButton(this); - bracketRefreshButton->setIcon(themePixmap(QStringLiteral("icons/reload"))); + bracketRefreshButton->setIcon(loadThemedIcon(QStringLiteral("icons/reload"))); bracketRefreshButton->setAutoRaise(true); 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..8af9d5e6e 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp @@ -1,10 +1,8 @@ #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 "../../theme_icon.h" #include "../cards/additional_info/deck_color_identity.h" #include "../deck_share/deck_share_utils.h" #include "../deck_share/share_bar_widget.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 @@ -188,19 +187,19 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, // Left side actions aOpenLocalDeck = new QAction(this); - aOpenLocalDeck->setIcon(themePixmap(QStringLiteral("icons/pencil"))); + aOpenLocalDeck->setIcon(loadThemedIcon(QStringLiteral("icons/pencil"))); connect(aOpenLocalDeck, &QAction::triggered, this, &TabDeckStorage::actOpenLocalDeck); aRenameLocal = new QAction(this); - aRenameLocal->setIcon(themePixmap(QStringLiteral("icons/rename"))); + aRenameLocal->setIcon(loadThemedIcon(QStringLiteral("icons/rename"))); connect(aRenameLocal, &QAction::triggered, this, &TabDeckStorage::actRenameLocal); aUpload = new QAction(this); - aUpload->setIcon(themePixmap(QStringLiteral("icons/arrow_right_green"))); + aUpload->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_right"))); connect(aUpload, &QAction::triggered, this, &TabDeckStorage::actUpload); aNewLocalFolder = new QAction(this); aNewLocalFolder->setIcon(qApp->style()->standardIcon(QStyle::SP_FileDialogNewFolder)); connect(aNewLocalFolder, &QAction::triggered, this, &TabDeckStorage::actNewLocalFolder); aDeleteLocalDeck = new QAction(this); - aDeleteLocalDeck->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); + aDeleteLocalDeck->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"))); connect(aDeleteLocalDeck, &QAction::triggered, this, &TabDeckStorage::actDeleteLocalDeck); aOpenDecksFolder = new QAction(this); @@ -209,24 +208,24 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, // Right side actions aOpenRemoteDeck = new QAction(this); - aOpenRemoteDeck->setIcon(themePixmap(QStringLiteral("icons/pencil"))); + aOpenRemoteDeck->setIcon(loadThemedIcon(QStringLiteral("icons/pencil"))); connect(aOpenRemoteDeck, &QAction::triggered, this, &TabDeckStorage::actOpenRemoteDeck); aDownload = new QAction(this); - aDownload->setIcon(themePixmap(QStringLiteral("icons/arrow_left_green"))); + aDownload->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_left"))); connect(aDownload, &QAction::triggered, this, &TabDeckStorage::actDownload); aNewFolder = new QAction(this); aNewFolder->setIcon(qApp->style()->standardIcon(QStyle::SP_FileDialogNewFolder)); connect(aNewFolder, &QAction::triggered, this, &TabDeckStorage::actNewFolder); aDeleteRemoteDeck = new QAction(this); - aDeleteRemoteDeck->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); + aDeleteRemoteDeck->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"))); connect(aDeleteRemoteDeck, &QAction::triggered, this, &TabDeckStorage::actDeleteRemoteDeck); aShareDecks = new QAction(this); - aShareDecks->setIcon(themePixmap(QStringLiteral("icons/share"))); + aShareDecks->setIcon(loadThemedIcon(QStringLiteral("icons/share"))); connect(aShareDecks, &QAction::triggered, this, &TabDeckStorage::actShareDecks); aPublishDeck = new QAction(this); - aPublishDeck->setIcon(QPixmap("theme:icons/lock")); + aPublishDeck->setIcon(loadThemedIcon(QStringLiteral("icons/lock"))); connect(aPublishDeck, &QAction::triggered, this, &TabDeckStorage::actPublishDeck); // Add actions to toolbars @@ -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_logs.cpp b/cockatrice/src/interface/widgets/tabs/tab_logs.cpp index 67e076983..9509f48ec 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_logs.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_logs.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -21,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -31,6 +33,17 @@ class TabSupervisor; +namespace +{ +/** @brief Identifies the newest row of a page well enough to recognise a repeated page. */ +QString pageStartKey(const Response_ViewLogHistory &response) +{ + const ServerInfo_ChatMessage &first = response.log_message(0); + return QString::fromStdString(first.time()) + QChar(0x1f) + QString::fromStdString(first.sender_name()) + + QChar(0x1f) + QString::fromStdString(first.message()); +} +} // namespace + TabLog::TabLog(TabSupervisor *_tabSupervisor, AbstractClient *_client, bool _canUseDeveloperCommands) : Tab(_tabSupervisor), client(_client), canUseDeveloperCommands(_canUseDeveloperCommands) { @@ -52,12 +65,20 @@ TabLog::TabLog(TabSupervisor *_tabSupervisor, AbstractClient *_client, bool _can chatTable->setHorizontalHeaderLabels( QString(tr("Time;SenderName;SenderIP;Message;TargetID;TargetName")).split(";")); - QTabWidget *tabManager = new QTabWidget(); + tabManager = new QTabWidget(); tabManager->addTab(roomTable, tr("Room Logs")); tabManager->addTab(gameTable, tr("Game Logs")); tabManager->addTab(chatTable, tr("Chat Logs")); setCentralWidget(tabManager); + connect(roomTable->verticalScrollBar(), &QScrollBar::valueChanged, this, &TabLog::checkFetchMore); + connect(gameTable->verticalScrollBar(), &QScrollBar::valueChanged, this, &TabLog::checkFetchMore); + connect(chatTable->verticalScrollBar(), &QScrollBar::valueChanged, this, &TabLog::checkFetchMore); + // A tab that gains only a few rows has nothing to scroll, so its scrollbar never fires: + // switching to it would leave its next page unfetched until the moderator scrolled + // another tab. Re-check on every tab switch so the visible table can continue the browse. + connect(tabManager, &QTabWidget::currentChanged, this, &TabLog::checkFetchMore); + createDock(); restartLayout(); clearClicked(); @@ -74,12 +95,9 @@ void TabLog::retranslateUi() void TabLog::getClicked() { - if (findUsername->text().isEmpty() && findIPAddress->text().isEmpty() && findGameName->text().isEmpty() && - findGameID->text().isEmpty() && findMessage->text().isEmpty()) { - QMessageBox::critical(this, tr("Error"), tr("You must select at least one filter.")); - return; - } - + // Empty filters are a valid browse: room/game history going back from + // today, paginated. Only require a date range + location, both of which + // get sensible defaults below so a bare "Get" just works. if (!lastHour->isChecked() && !today->isChecked() && !pastDays->isChecked()) { pastDays->setChecked(true); pastXDays->setValue(20); @@ -99,39 +117,71 @@ void TabLog::getClicked() } if (maximumResults->value() == 0) { - maximumResults->setValue(1000); + maximumResults->setValue(200); } + pageSize = qBound(1, maximumResults->value(), 1000); - int dateRange = 0; - if (lastHour->isChecked()) { - dateRange = 1; - } - - if (today->isChecked()) { - dateRange = 24; - } - - if (pastDays->isChecked()) { - dateRange = pastXDays->value() * 24; - } - - Command_ViewLogHistory cmd; - cmd.set_user_name(findUsername->text().toStdString()); - cmd.set_ip_address(findIPAddress->text().toStdString()); - cmd.set_game_name(findGameName->text().toStdString()); - cmd.set_game_id(findGameID->text().toStdString()); - cmd.set_message(findMessage->text().toStdString()); + // Build the command once, here, and let every page copy it: rebuilding it from the + // widgets per page would run page 2+ against filters edited after Get was clicked, + // appending rows selected by a different query under the first page's results. + currentQuery.Clear(); + currentQuery.set_user_name(findUsername->text().toStdString()); + currentQuery.set_ip_address(findIPAddress->text().toStdString()); + currentQuery.set_game_name(findGameName->text().toStdString()); + currentQuery.set_game_id(findGameID->text().toStdString()); + currentQuery.set_message(findMessage->text().toStdString()); if (mainRoom->isChecked()) { - cmd.add_log_location("room"); - }; + currentQuery.add_log_location("room"); + } if (gameRoom->isChecked()) { - cmd.add_log_location("game"); - }; + currentQuery.add_log_location("game"); + } if (privateChat->isChecked()) { - cmd.add_log_location("chat"); - }; - cmd.set_date_range(dateRange); - cmd.set_maximum_results(maximumResults->value()); + currentQuery.add_log_location("chat"); + } + currentQuery.set_date_range(dateRangeForSelection()); + currentQuery.set_maximum_results(pageSize); + hasCurrentQuery = true; + snapshotBefore.clear(); + lastPageStartKey.clear(); + + // Fresh search: reset pagination state and clear previous pages. + currentOffset = 0; + roomCounter = 0; + gameCounter = 0; + chatCounter = 0; + roomTable->setRowCount(0); + gameTable->setRowCount(0); + chatTable->setRowCount(0); + + fetchPage(0); +} + +int TabLog::dateRangeForSelection() const +{ + if (lastHour->isChecked()) { + return 1; + } + if (today->isChecked()) { + return 24; + } + if (pastDays->isChecked()) { + return pastXDays->value() * 24; + } + return 0; +} + +void TabLog::fetchPage(int offset) +{ + if (isFetching || !hasCurrentQuery) { + return; + } + + Command_ViewLogHistory cmd = currentQuery; + cmd.set_offset(offset); + if (!snapshotBefore.isEmpty()) { + cmd.set_before_time(snapshotBefore.toStdString()); + } PendingCommand *pend; if (canUseDeveloperCommands) { @@ -141,10 +191,42 @@ void TabLog::getClicked() pend = client->prepareModeratorCommand(cmd); } + isFetching = true; + getButton->setEnabled(false); + connect(pend, &PendingCommand::finished, this, &TabLog::viewLogHistory_processResponse); client->sendCommand(pend); } +void TabLog::checkFetchMore() +{ + if (isFetching || !hasMoreResults) { + return; + } + + // Only the table the moderator is looking at may pull the next page. Checking all + // three would make an empty or hidden table nearBottom by definition (maximum() and + // value() are both zero), so a room-only browse chained page after page without any + // scrolling - pulling the whole range with a growing OFFSET scan per page, and + // looping forever against a server that ignores `offset`. + auto *table = qobject_cast(tabManager->currentWidget()); + if (table == nullptr || table->rowCount() == 0) { + return; + } + + // ScrollPerItem (the default) counts rows, not pixels: `value` is the top visible row + // and `maximum` the last one that can be scrolled to. Prefetch half a page ahead so + // scrolling never visibly stalls, without the fixed 300-row threshold firing the next + // page the moment a page loads. + const QScrollBar *bar = table->verticalScrollBar(); + const int prefetchRows = qMax(1, pageSize / 2); + if (bar->maximum() - bar->value() > prefetchRows) { + return; + } + + fetchPage(currentOffset); +} + void TabLog::clearClicked() { findUsername->clear(); @@ -166,6 +248,10 @@ void TabLog::clearClicked() pastDays->setAutoExclusive(true); today->setAutoExclusive(true); lastHour->setAutoExclusive(true); + currentOffset = 0; + hasMoreResults = false; + hasCurrentQuery = false; + snapshotBefore.clear(); } void TabLog::createDock() @@ -209,14 +295,15 @@ void TabLog::createDock() pastXDays = new QSpinBox; pastXDays->setMaximum(20); - labelMaximum = new QLabel(tr("Maximum Results: ")); + labelMaximum = new QLabel(tr("Results per page: ")); maximumResults = new QSpinBox; maximumResults->setMaximum(1000); + maximumResults->setToolTip(tr("Page size for smooth scrolling: more rows load automatically as you scroll.")); - labelDescription = new QLabel(tr( - "At least one filter is required.\nThe more information you put in, the more specific your results will be.")); + labelDescription = new QLabel(tr("Leave filters empty to browse recent room and game messages.\n" + "More rows load automatically as you scroll.")); - getButton = new QPushButton(tr("Get User Logs")); + getButton = new QPushButton(tr("Get Logs")); getButton->setAutoDefault(true); connect(getButton, &QPushButton::clicked, this, &TabLog::getClicked); @@ -262,7 +349,7 @@ void TabLog::createDock() maxResultsGrid->addWidget(labelMaximum, 0, 0); maxResultsGrid->addWidget(maximumResults, 0, 1); - maxResultsGroupBox = new QGroupBox(tr("Maximum Results")); + maxResultsGroupBox = new QGroupBox(tr("Results per page")); maxResultsGroupBox->setLayout(maxResultsGrid); descriptionGrid = new QGridLayout; @@ -295,84 +382,134 @@ void TabLog::createDock() searchDock->setWidget(searchDockContents); } +void TabLog::appendChatMessage(QTableWidget *table, + int &counter, + const QString &time, + const QString &senderName, + const QString &senderIp, + const QString &messageText, + const QString &targetId, + const QString &targetName, + const QString &channel) +{ + table->insertRow(counter); + table->setItem(counter, 0, new QTableWidgetItem(time)); + table->setItem(counter, 1, new QTableWidgetItem(senderName)); + table->setItem(counter, 2, new QTableWidgetItem(senderIp)); + table->setItem(counter, 3, new QTableWidgetItem(messageText)); + table->setItem(counter, 4, new QTableWidgetItem(targetId)); + table->setItem(counter, 5, new QTableWidgetItem(targetName)); + if (table->columnCount() > 6) { + table->setItem(counter, 6, new QTableWidgetItem(channel)); + } + ++counter; +} + void TabLog::viewLogHistory_processResponse(const Response &resp) { const Response_ViewLogHistory &response = resp.GetExtension(Response_ViewLogHistory::ext); + const bool isFirstPage = (currentOffset == 0); + isFetching = false; + getButton->setEnabled(true); + if (resp.response_code() != Response::RespOk) { QMessageBox::critical(static_cast(parent()), tr("Message History"), tr("Failed to collect message history information.")); return; } - if (response.log_message_size() == 0) { + if (isFirstPage && response.log_message_size() == 0) { QMessageBox::information(static_cast(parent()), tr("Message History"), tr("There are no messages for the selected filters.")); + hasMoreResults = false; return; } - int roomCounter = 0, gameCounter = 0, chatCounter = 0; - roomTable->setRowCount(roomCounter); - gameTable->setRowCount(gameCounter); - chatTable->setRowCount(chatCounter); + const QString thisPageStart = response.log_message_size() > 0 ? pageStartKey(response) : QString(); + + if (isFirstPage) { + // Rows come newest first, so the first one fixes the snapshot every later page is + // bounded by. Without it, each message logged between two page fetches pushes the + // window down by one and repeats a row the moderator already has. + snapshotBefore = QString::fromStdString(response.log_message(0).time()); + } else if (!response.has_has_more() && !thisPageStart.isEmpty() && thisPageStart == lastPageStartKey) { + // A server that predates `offset` ignores it and returns the same page again. It + // also predates `has_more`, so the full-page heuristic below can never prove + // exhaustion and every scroll would append the same rows forever. The identical + // first row is the proof; stop instead of looping. + hasMoreResults = false; + return; + } + lastPageStartKey = thisPageStart; for (int i = 0; i < response.log_message_size(); ++i) { ServerInfo_ChatMessage message = response.log_message(i); const QString targetType = QString::fromStdString(message.target_type()); if (targetType == "room" || targetType == "room_channel") { - roomTable->insertRow(roomCounter); - roomTable->setItem(roomCounter, 0, new QTableWidgetItem(QString::fromStdString(message.time()))); - roomTable->setItem(roomCounter, 1, new QTableWidgetItem(QString::fromStdString(message.sender_name()))); - roomTable->setItem(roomCounter, 2, new QTableWidgetItem(QString::fromStdString(message.sender_ip()))); - roomTable->setItem(roomCounter, 3, new QTableWidgetItem(QString::fromStdString(message.message()))); - roomTable->setItem(roomCounter, 4, new QTableWidgetItem(QString::fromStdString(message.target_id()))); - roomTable->setItem(roomCounter, 5, new QTableWidgetItem(QString::fromStdString(message.target_name()))); - roomTable->setItem(roomCounter, 6, new QTableWidgetItem(QString::fromStdString(message.channel()))); - ++roomCounter; + appendChatMessage(roomTable, roomCounter, QString::fromStdString(message.time()), + QString::fromStdString(message.sender_name()), + QString::fromStdString(message.sender_ip()), QString::fromStdString(message.message()), + QString::fromStdString(message.target_id()), + QString::fromStdString(message.target_name()), QString::fromStdString(message.channel())); } if (targetType == "game") { - gameTable->insertRow(gameCounter); - gameTable->setItem(gameCounter, 0, new QTableWidgetItem(QString::fromStdString(message.time()))); - gameTable->setItem(gameCounter, 1, new QTableWidgetItem(QString::fromStdString(message.sender_name()))); - gameTable->setItem(gameCounter, 2, new QTableWidgetItem(QString::fromStdString(message.sender_ip()))); - gameTable->setItem(gameCounter, 3, new QTableWidgetItem(QString::fromStdString(message.message()))); - gameTable->setItem(gameCounter, 4, new QTableWidgetItem(QString::fromStdString(message.target_id()))); - gameTable->setItem(gameCounter, 5, new QTableWidgetItem(QString::fromStdString(message.target_name()))); - ++gameCounter; + appendChatMessage(gameTable, gameCounter, QString::fromStdString(message.time()), + QString::fromStdString(message.sender_name()), + QString::fromStdString(message.sender_ip()), QString::fromStdString(message.message()), + QString::fromStdString(message.target_id()), + QString::fromStdString(message.target_name())); } if (targetType == "chat") { - chatTable->insertRow(chatCounter); - chatTable->setItem(chatCounter, 0, new QTableWidgetItem(QString::fromStdString(message.time()))); - chatTable->setItem(chatCounter, 1, new QTableWidgetItem(QString::fromStdString(message.sender_name()))); - chatTable->setItem(chatCounter, 2, new QTableWidgetItem(QString::fromStdString(message.sender_ip()))); - chatTable->setItem(chatCounter, 3, new QTableWidgetItem(QString::fromStdString(message.message()))); - chatTable->setItem(chatCounter, 4, new QTableWidgetItem(QString::fromStdString(message.target_id()))); - chatTable->setItem(chatCounter, 5, new QTableWidgetItem(QString::fromStdString(message.target_name()))); - ++chatCounter; + appendChatMessage(chatTable, chatCounter, QString::fromStdString(message.time()), + QString::fromStdString(message.sender_name()), + QString::fromStdString(message.sender_ip()), QString::fromStdString(message.message()), + QString::fromStdString(message.target_id()), + QString::fromStdString(message.target_name())); } } + currentOffset += response.log_message_size(); + if (response.has_has_more()) { + hasMoreResults = response.has_more(); + } else { + // Legacy server without has_more: a full page implies more may exist. + hasMoreResults = (response.log_message_size() >= pageSize); + } + if (roomCounter) { roomTable->show(); - roomTable->resizeColumnsToContents(); + if (isFirstPage) { + roomTable->resizeColumnsToContents(); + } } else { roomTable->hide(); } if (gameCounter) { - gameTable->resizeColumnsToContents(); + if (isFirstPage) { + gameTable->resizeColumnsToContents(); + } gameTable->show(); } else { gameTable->hide(); } if (chatCounter) { - chatTable->resizeColumnsToContents(); + if (isFirstPage) { + chatTable->resizeColumnsToContents(); + } chatTable->show(); } else { chatTable->hide(); } + + // The new rows may already fill less than a viewport (or the user is + // still parked at the bottom), so chain the next page without waiting + // for another scroll event. The prefetch stays invisible: rows simply + // keep appearing as you scroll. + checkFetchMore(); } void TabLog::restartLayout() diff --git a/cockatrice/src/interface/widgets/tabs/tab_logs.h b/cockatrice/src/interface/widgets/tabs/tab_logs.h index fc8ef1ea0..f2f5842c5 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_logs.h +++ b/cockatrice/src/interface/widgets/tabs/tab_logs.h @@ -10,6 +10,7 @@ #include "tab.h" #include +#include #include class AbstractClient; @@ -25,6 +26,7 @@ class QWidget; class QGridLayout; class QVBoxLayout; class QTableWidget; +class QTabWidget; class Response; class TabSupervisor; @@ -48,8 +50,37 @@ private: *buttonGroupBox; QVBoxLayout *mainLayout; QTableWidget *roomTable, *gameTable, *chatTable; + QTabWidget *tabManager = nullptr; + + //! The filters of the search in flight, captured when Get was clicked so that + //! later pages cannot pick up edits the moderator made after starting it. + Command_ViewLogHistory currentQuery; + bool hasCurrentQuery = false; + //! Newest `log_time` of the first page, sent back as the upper bound of every + //! later page so messages logged in the meantime do not shift the window. + QString snapshotBefore; + //! Identity of the previous page's first row, used to spot a server that ignores + //! `offset` and hands back the same page forever. + QString lastPageStartKey; + int currentOffset = 0; + int pageSize = 200; + int roomCounter = 0, gameCounter = 0, chatCounter = 0; + bool isFetching = false; + bool hasMoreResults = false; void createDock(); + [[nodiscard]] int dateRangeForSelection() const; + void fetchPage(int offset); + void appendChatMessage(QTableWidget *table, + int &counter, + const QString &time, + const QString &senderName, + const QString &senderIp, + const QString &messageText, + const QString &targetId, + const QString &targetName, + const QString &channel = QString()); + void checkFetchMore(); signals: private slots: 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..73d4da420 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp @@ -2,7 +2,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../deck_loader/deck_loader.h" -#include "../../deck_loader/loaded_deck.h" +#include "../../theme_icon.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 +28,14 @@ #include #include #include +#include #include #include #include #include #include #include +#include #include #include @@ -44,17 +46,15 @@ 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); headerLayout->addStretch(1); // Filter/toolbar row, matching the Visual Deck Storage: color identity filter - // first, the search bar stretching in the middle, and the quick settings - // cogwheel at the end. The card size slider lives inside the cogwheel popup. + // first, the search bar stretching in the middle, and the quick-settings button + // at the end. The card size slider lives inside its popup. emptyLabel = new QLabel(tr("This user has not published any decks."), this); emptyLabel->setAlignment(Qt::AlignCenter); emptyLabel->setVisible(false); @@ -69,7 +69,7 @@ TabPublicDecks::TabPublicDecks(TabSupervisor *_tabSupervisor, AbstractClient *_c colorIdentityFilter = new DeckPreviewColorIdentityFilterWidget(this); searchWidget = new VisualDeckStorageSearchWidget(this); refreshButton = new QToolButton(this); - refreshButton->setIcon(QPixmap("theme:icons/reload")); + refreshButton->setIcon(loadThemedIcon(QStringLiteral("icons/reload"))); refreshButton->setFixedSize(32, 32); quickSettingsWidget = new VisualDeckStorageQuickSettingsWidget(this); quickSettingsWidget->setPublicDecksMode(true); diff --git a/cockatrice/src/interface/widgets/tabs/tab_replays.cpp b/cockatrice/src/interface/widgets/tabs/tab_replays.cpp index 52bc4cd8f..bd2a69017 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_replays.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_replays.cpp @@ -1,7 +1,7 @@ #include "tab_replays.h" #include "../../../client/settings/cache_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../interface/widgets/server/remote/remote_replay_list_tree_widget.h" #include "libcockatrice/protocol/pb/commands.pb.h" #include "libcockatrice/protocol/pb/serverinfo_replay.pb.h" @@ -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 @@ -146,17 +145,17 @@ QGroupBox *TabReplays::createLeftLayout() // Left side actions aOpenLocalReplay = new QAction(this); - aOpenLocalReplay->setIcon(themePixmap(QStringLiteral("icons/view"))); + aOpenLocalReplay->setIcon(loadThemedIcon(QStringLiteral("icons/view"))); connect(aOpenLocalReplay, &QAction::triggered, this, &TabReplays::actOpenLocalReplay); connect(localDirView, &QTreeView::doubleClicked, this, &TabReplays::actOpenLocalReplay); aRenameLocal = new QAction(this); - aRenameLocal->setIcon(themePixmap(QStringLiteral("icons/rename"))); + aRenameLocal->setIcon(loadThemedIcon(QStringLiteral("icons/rename"))); connect(aRenameLocal, &QAction::triggered, this, &TabReplays::actRenameLocal); aNewLocalFolder = new QAction(this); aNewLocalFolder->setIcon(qApp->style()->standardIcon(QStyle::SP_FileDialogNewFolder)); connect(aNewLocalFolder, &QAction::triggered, this, &TabReplays::actNewLocalFolder); aDeleteLocalReplay = new QAction(this); - aDeleteLocalReplay->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); + aDeleteLocalReplay->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"))); connect(aDeleteLocalReplay, &QAction::triggered, this, &TabReplays::actDeleteLocalReplay); aOpenReplaysFolder = new QAction(this); @@ -208,24 +207,24 @@ QGroupBox *TabReplays::createRightLayout() // Right side actions aOpenRemoteReplay = new QAction(this); - aOpenRemoteReplay->setIcon(themePixmap(QStringLiteral("icons/view"))); + aOpenRemoteReplay->setIcon(loadThemedIcon(QStringLiteral("icons/view"))); connect(aOpenRemoteReplay, &QAction::triggered, this, &TabReplays::actOpenRemoteReplay); connect(serverDirView, &QTreeView::doubleClicked, this, &TabReplays::actOpenRemoteReplay); aDownload = new QAction(this); - aDownload->setIcon(themePixmap(QStringLiteral("icons/arrow_left_green"))); + aDownload->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_left"))); connect(aDownload, &QAction::triggered, this, &TabReplays::actDownload); aKeep = new QAction(this); - aKeep->setIcon(themePixmap(QStringLiteral("icons/lock"))); + aKeep->setIcon(loadThemedIcon(QStringLiteral("icons/lock"))); connect(aKeep, &QAction::triggered, this, &TabReplays::actKeepRemoteReplay); aDeleteRemoteReplay = new QAction(this); - aDeleteRemoteReplay->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); + aDeleteRemoteReplay->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"))); connect(aDeleteRemoteReplay, &QAction::triggered, this, &TabReplays::actDeleteRemoteReplay); aGetReplayCode = new QAction(this); - aGetReplayCode->setIcon(themePixmap(QStringLiteral("icons/share"))); + aGetReplayCode->setIcon(loadThemedIcon(QStringLiteral("icons/share"))); connect(aGetReplayCode, &QAction::triggered, this, &TabReplays::actGetReplayCode); aSubmitReplayCode = new QAction(this); - aSubmitReplayCode->setIcon(themePixmap(QStringLiteral("icons/search"))); + aSubmitReplayCode->setIcon(loadThemedIcon(QStringLiteral("icons/search"))); connect(aSubmitReplayCode, &QAction::triggered, this, &TabReplays::actSubmitReplayCode); // Add actions to toolbars @@ -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_room.cpp b/cockatrice/src/interface/widgets/tabs/tab_room.cpp index f63b77445..329732c64 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_room.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_room.cpp @@ -2,7 +2,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../interface/widgets/dialogs/dlg_settings.h" #include "../interface/widgets/server/chat_view/chat_view.h" #include "../interface/widgets/server/game_link.h" @@ -198,7 +198,7 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor, connect(aOpenChatSettings, &QAction::triggered, this, &TabRoom::actOpenChatSettings); auto *chatSettingsButton = new QToolButton; - chatSettingsButton->setIcon(themePixmap(QStringLiteral("icons/settings"))); + chatSettingsButton->setIcon(loadThemedIcon(QStringLiteral("icons/settings"))); chatSettingsButton->setMenu(chatSettingsMenu); chatSettingsButton->setPopupMode(QToolButton::InstantPopup); diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index 2352d7d7b..2f5b21280 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -2,15 +2,12 @@ #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" #include "../../intents/intent.h" #include "../../intents/intent_join_server_game.h" +#include "../../theme_icon.h" #include "../interface/pixel_map_generator.h" #include "../interface/widgets/server/game_link.h" #include "../interface/widgets/server/user/user_list_manager.h" @@ -51,8 +48,6 @@ #include #include #include -#include -#include #include #include #include @@ -65,6 +60,10 @@ #include #include #include +#include +#include +#include +#include #include #include #include @@ -79,6 +78,7 @@ #include #include #include +#include #include #include #include @@ -291,48 +291,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 +325,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; } @@ -1386,7 +1348,7 @@ void TabSupervisor::tabUserEvent(bool globalEvent) auto *tab = static_cast(sender()); if (tab != currentWidget()) { tab->setContentsChanged(true); - setTabIcon(indexOf(tab), themePixmap(QStringLiteral("icons/tab_changed"))); + setTabIcon(indexOf(tab), loadThemedIcon(QStringLiteral("icons/tab_changed"))); } if (globalEvent && SettingsCache::instance().userInterface().getNotificationsEnabled()) { QApplication::alert(this); 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..b09e6da1e 100644 --- a/cockatrice/src/interface/widgets/utility/sequence_edit.cpp +++ b/cockatrice/src/interface/widgets/utility/sequence_edit.cpp @@ -2,7 +2,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include #include @@ -15,6 +15,7 @@ #include #include #include +#include SequenceEdit::SequenceEdit(const QString &_shortcutName, QWidget *parent) : QWidget(parent) { @@ -23,12 +24,10 @@ SequenceEdit::SequenceEdit(const QString &_shortcutName, QWidget *parent) : QWid defaultButton = new QPushButton("", this); lineEdit->setMinimumWidth(70); - clearButton->setIcon(themePixmap(QStringLiteral("icons/clearsearch"))); - defaultButton->setIcon(themePixmap(QStringLiteral("icons/update"))); + clearButton->setIcon(loadThemedIcon(QStringLiteral("icons/clearsearch"))); + defaultButton->setIcon(loadThemedIcon(QStringLiteral("icons/reload"))); - 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/card_database_visual_view.cpp b/cockatrice/src/interface/widgets/visual_database_display/card_database_visual_view.cpp new file mode 100644 index 000000000..7b0d526f9 --- /dev/null +++ b/cockatrice/src/interface/widgets/visual_database_display/card_database_visual_view.cpp @@ -0,0 +1,203 @@ +#include "card_database_visual_view.h" + +#include "../../card_picture_loader/card_picture_loader.h" +#include "../cards/card_info_picture_widget.h" +#include "../cards/card_picture_delegate.h" +#include "../cards/card_size_widget.h" +#include "card_database_visual_display_model.h" +#include "libcockatrice/card/printing/exact_card.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +CardDatabaseVisualView::CardDatabaseVisualView(CardDatabaseVisualDisplayModel *_model, QWidget *parent) + : QListView(parent), model(_model) +{ + auto *cardDelegate = new CardPictureDelegate(model, this); + setItemDelegate(cardDelegate); + setModel(model); + + setViewMode(QListView::IconMode); + setFlow(QListView::LeftToRight); + setWrapping(true); + setResizeMode(QListView::Adjust); + setMovement(QListView::Static); + setUniformItemSizes(true); + setLayoutMode(QListView::SinglePass); + setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); + setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + setFrameShape(QFrame::NoFrame); + setWordWrap(false); + + // QListView hands the delegate's item height to the scroll bar as its single + // step under ScrollPerPixel, so one wheel notch would jump wheelScrollLines() + // whole card rows. Give the bar a text line instead; the wheel handler below + // scales that line to the grid. A positive step also stops Qt from taking the + // item height back on the next layout. + verticalScrollBar()->setSingleStep(qMax(1, fontMetrics().height())); + + // The grid is a read-only surface: clicks are actions on a card, not a + // selection, and they must not steal focus from the search field. + setSelectionMode(QAbstractItemView::NoSelection); + setEditTriggers(QAbstractItemView::NoEditTriggers); + setFocusPolicy(Qt::NoFocus); + + // Hover drives the card preview. + setMouseTracking(true); + viewport()->setMouseTracking(true); + + connect(&CardPictureLoader::getInstance(), &CardPictureLoader::pixmapLoaded, this, + [this] { viewport()->update(); }); + + // A new search result starts at the top, like the widget-per-card grid did. + connect(model, &QAbstractItemModel::modelReset, this, [this] { + verticalScrollBar()->setValue(0); + // The rows under a stationary cursor changed with the reset: re-resolve the + // hover so the preview follows the grid, as enterEvent did per card widget. + hoveredRow = -1; + updateHoveredRow(viewport()->mapFromGlobal(QCursor::pos())); + }); + + setCardSizeFactor(cardSizeFactor); +} + +void CardDatabaseVisualView::setCardSizeFactor(int factor) +{ + cardSizeFactor = qBound(CardSizeWidget::MIN_CARD_SIZE, factor, CardSizeWidget::MAX_CARD_SIZE); + + const QSize cardSize = CardInfoPictureWidget::sizeForScaleFactor(cardSizeFactor); + if (auto *cardDelegate = qobject_cast(itemDelegate())) { + cardDelegate->setCardSize(cardSize); + } + // The delegate's sizeHint does not emit sizeHintChanged, so the grid size is what + // makes a resize take effect. Keep the two in step. + setGridSize(cardSize + QSize(CardPictureDelegate::SPACING, CardPictureDelegate::SPACING)); +} + +void CardDatabaseVisualView::mousePressEvent(QMouseEvent *event) +{ + const QModelIndex index = indexAt(event->pos()); + if (index.isValid() && model != nullptr) { + const ExactCard card = model->cardAt(index.row()); + if (card) { + emit cardClicked(event, card); + event->accept(); + return; + } + } + + QListView::mousePressEvent(event); +} + +void CardDatabaseVisualView::mouseMoveEvent(QMouseEvent *event) +{ + QListView::mouseMoveEvent(event); + updateHoveredRow(event->pos()); +} + +bool CardDatabaseVisualView::viewportEvent(QEvent *event) +{ + // A per-card widget reported its hover from enterEvent, which also fires when the + // grid changes under a stationary cursor. A QListView only reports moves, so + // re-resolve the hover on enter and clear it on leave. + if (event->type() == QEvent::Enter) { + updateHoveredRow(viewport()->mapFromGlobal(QCursor::pos())); + } else if (event->type() == QEvent::Leave) { + hoveredRow = -1; + } + + return QListView::viewportEvent(event); +} + +void CardDatabaseVisualView::wheelEvent(QWheelEvent *event) +{ + // Ctrl+wheel is the card-size gesture and is filtered out before it reaches + // the view; any other modifier keeps QAbstractSlider's own page scrolling. + if (event->modifiers() != Qt::NoModifier) { + QListView::wheelEvent(event); + return; + } + + QScrollBar *bar = verticalScrollBar(); + if (!bar->isVisible()) { + event->ignore(); + return; + } + + // Touchpads and high-resolution wheels already report pixels: follow them. + if (!event->pixelDelta().isNull()) { + bar->setValue(bar->value() - event->pixelDelta().y()); + event->accept(); + return; + } + + if (event->angleDelta().y() == 0) { + QListView::wheelEvent(event); + return; + } + + // Accumulate wheel deltas smaller than a notch, like QAbstractSlider does, so + // a fine-grained wheel still moves. + wheelRemainder += static_cast(event->angleDelta().y()) / 120.0; + const int notches = static_cast(wheelRemainder); + if (notches == 0) { + event->accept(); + return; + } + wheelRemainder -= notches; + + const int pixels = -notches * wheelStep(); + bar->setValue(qBound(bar->minimum(), bar->value() + pixels, bar->maximum())); + event->accept(); +} + +int CardDatabaseVisualView::wheelStep() const +{ + // A card row is far too coarse for one wheel line, and a text line is too + // fine, so a line is an eighth of the grid cell. A notch then moves about a + // third of a card row at any card size, and the OS line count still applies. + const int lineHeight = qMax(1, gridSize().height() / 8); + return qMax(1, QApplication::wheelScrollLines()) * lineHeight; +} + +void CardDatabaseVisualView::updateHoveredRow(const QPoint &position) +{ + // indexAt() also answers for points outside the viewport, so a cursor parked + // below the grid (on the size slider, say) would resolve to the row just under + // the visible area. Only a point inside the viewport can hover a card. + if (model == nullptr || !viewport()->rect().contains(position)) { + return; + } + + const QModelIndex index = indexAt(position); + const int row = index.isValid() ? index.row() : -1; + if (row == hoveredRow) { + return; + } + + hoveredRow = row; + if (row >= 0) { + const ExactCard card = model->cardAt(row); + if (card) { + emit cardHovered(card); + } + } +} + +void CardDatabaseVisualView::leaveEvent(QEvent *event) +{ + hoveredRow = -1; + QListView::leaveEvent(event); +} diff --git a/cockatrice/src/interface/widgets/visual_database_display/card_database_visual_view.h b/cockatrice/src/interface/widgets/visual_database_display/card_database_visual_view.h new file mode 100644 index 000000000..5f004484d --- /dev/null +++ b/cockatrice/src/interface/widgets/visual_database_display/card_database_visual_view.h @@ -0,0 +1,67 @@ +/** + * @file card_database_visual_view.h + * @ingroup VisualCardDatabaseWidgets + * @brief Icon-mode view of the card database, one card picture per item. + */ + +#ifndef CARD_DATABASE_VISUAL_VIEW_H +#define CARD_DATABASE_VISUAL_VIEW_H + +#include +#include +#include + +class CardDatabaseVisualDisplayModel; +class ExactCard; +class QEvent; +class QMouseEvent; +class QPoint; +class QWheelEvent; + +/** + * @brief A wrapping grid of card pictures. + * + * The view virtualizes painting: it only asks the delegate for the items that are + * on screen, so the whole card database can sit in the model without a widget (or + * pixmap) per card. Clicks and hover are reported with the exact card under the + * cursor, which is what the deck editor needs to add, decrement or preview a card. + */ +class CardDatabaseVisualView : public QListView +{ + Q_OBJECT + +public: + explicit CardDatabaseVisualView(CardDatabaseVisualDisplayModel *_model, QWidget *parent = nullptr); + + /** + * @brief Sets the card size as a percentage of the base card width. + * @param factor Percentage in the range of the card size slider. + */ + void setCardSizeFactor(int factor); + +signals: + /** @brief A card was pressed. The event is valid for the duration of the emission. */ + void cardClicked(QMouseEvent *event, const ExactCard &card); + /** @brief The cursor moved onto a different card. */ + void cardHovered(const ExactCard &card); + +protected: + void mousePressEvent(QMouseEvent *event) override; + void mouseMoveEvent(QMouseEvent *event) override; + void leaveEvent(QEvent *event) override; + bool viewportEvent(QEvent *event) override; + void wheelEvent(QWheelEvent *event) override; + +private: + void updateHoveredRow(const QPoint &position); + /// Pixels one wheel notch moves, scaled to the grid and the OS line count. + [[nodiscard]] int wheelStep() const; + + CardDatabaseVisualDisplayModel *model; + /// Fraction of a line left over from a wheel event smaller than one notch. + qreal wheelRemainder = 0.0; + int cardSizeFactor = 100; + int hoveredRow = -1; +}; + +#endif // CARD_DATABASE_VISUAL_VIEW_H diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.cpp index cf815ace0..464d62e4a 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 38812b02a..184f1f96c 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; @@ -71,6 +72,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..ecb949b67 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp @@ -1,6 +1,6 @@ #include "visual_database_display_filter_toolbar_widget.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../deck_editor/card_database_view.h" #include "../quick_settings/settings_button_widget.h" #include "card_database_display_model.h" @@ -29,6 +29,7 @@ #include #include #include +#include #include class DeckListModel; @@ -44,13 +45,13 @@ VisualDatabaseDisplayFilterToolbarWidget::VisualDatabaseDisplayFilterToolbarWidg sortGroupBox = new QGroupBox(this); filterGroupBox = new QGroupBox(this); - auto scalePixmap = [](const QString &fileName) { return QIcon(QPixmap(fileName)).pixmap({20, 20}); }; + auto scalePixmap = [](const QString &prefix) { return loadThemedIcon(prefix).pixmap({20, 20}); }; sortByLabel = new QLabel(this); - sortByLabel->setPixmap(scalePixmap("theme:icons/sort_arrow_down")); + sortByLabel->setPixmap(scalePixmap("icons/sort_arrow_down")); filterByLabel = new QLabel(this); - filterByLabel->setPixmap(scalePixmap("theme:icons/filter")); + filterByLabel->setPixmap(scalePixmap("icons/filter")); sortColumnCombo = new QComboBox(this); sortColumnCombo->setSizeAdjustPolicy(QComboBox::SizeAdjustPolicy::AdjustToContents); @@ -88,36 +89,32 @@ VisualDatabaseDisplayFilterToolbarWidget::VisualDatabaseDisplayFilterToolbarWidg }); quickFilterSaveLoadWidget = new SettingsButtonWidget(this); - quickFilterSaveLoadWidget->setButtonIcon(themePixmap(QStringLiteral("icons/floppy_disk"))); + quickFilterSaveLoadWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/floppy_disk"))); quickFilterNameWidget = new SettingsButtonWidget(this); - quickFilterNameWidget->setButtonIcon(themePixmap(QStringLiteral("icons/pen_to_square"))); + quickFilterNameWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/pen_to_square"))); quickFilterMainTypeWidget = new SettingsButtonWidget(this); - quickFilterMainTypeWidget->setButtonIcon(themePixmap(QStringLiteral("icons/circle_half_stroke"))); + quickFilterMainTypeWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/circle_half_stroke"))); quickFilterSubTypeWidget = new SettingsButtonWidget(this); - quickFilterSubTypeWidget->setButtonIcon(themePixmap(QStringLiteral("icons/dragon"))); + quickFilterSubTypeWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/dragon"))); quickFilterSetWidget = new SettingsButtonWidget(this); - quickFilterSetWidget->setButtonIcon(themePixmap(QStringLiteral("icons/scroll"))); + quickFilterSetWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/scroll"))); quickFilterFormatLegalityWidget = new SettingsButtonWidget(this); - quickFilterFormatLegalityWidget->setButtonIcon(themePixmap(QStringLiteral("icons/scale_balanced"))); + quickFilterFormatLegalityWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/scales"))); retranslateUi(); } 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..af5c74969 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 @@ -4,7 +4,7 @@ #include "../../../filters/filter_tree_model.h" #include "../../../filters/syntax_help.h" #include "../../key_signals.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../cards/card_info_picture_widget.h" #include "../cards/card_info_picture_with_text_overlay_widget.h" #include "../cards/card_size_widget.h" @@ -13,32 +13,23 @@ #include "../utility/custom_line_edit.h" #include "card_database_display_model.h" #include "card_database_model.h" +#include "card_database_visual_display_model.h" +#include "card_database_visual_view.h" #include "deck_list_model.h" #include "libcockatrice/card/card_localization.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/printing/printing_info.h" #include "libcockatrice/filters/filter_card.h" #include "libcockatrice/filters/filter_tree.h" #include "visual_database_display_color_filter_widget.h" #include "visual_database_display_filter_toolbar_widget.h" -#include #include -#include #include #include #include -#include -#include -#include -#include -#include #include -#include -#include -#include #include #include #include @@ -48,13 +39,11 @@ #include #include #include -#include -#include #include #include #include #include -#include +#include class QMouseEvent; @@ -63,11 +52,6 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, DeckListModel *deckListModel) : QWidget(parent) { - debounceTimer = new QTimer(this); - debounceTimer->setSingleShot(true); // Ensure it only fires once after the timeout - - connect(debounceTimer, &QTimer::timeout, this, &VisualDatabaseDisplayWidget::onSearchModelChanged); - // Create display model databaseDisplayModel = new CardDatabaseDisplayModel(this); databaseDisplayModel->setObjectName("databaseDisplayModel"); @@ -85,24 +69,11 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardSearchLanguageChanged, this, applyCardSearchLanguage); - cards = new QList; - connect(databaseDisplayModel, &CardDatabaseDisplayModel::modelDirty, this, - &VisualDatabaseDisplayWidget::modelDirty); - // 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, - SettingsCache::instance().cardsDisplay().getVisualDatabaseDisplayCardSize()); - connect(cardSizeWidget, &CardSizeWidget::cardSizeSettingUpdated, &SettingsCache::instance().cardsDisplay(), - &CardsDisplaySettings::setVisualDatabaseDisplayCardSize); - cardSizeWidget->enableCtrlScrollResize(flowWidget); searchContainer = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAlwaysOff); @@ -110,14 +81,14 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, searchEdit->setObjectName("searchEdit"); searchEdit->setPlaceholderText(tr("Search by card name (or search expressions)")); searchEdit->setClearButtonEnabled(true); - searchEdit->addAction(loadColorAdjustedPixmap("theme:icons/search"), QLineEdit::LeadingPosition); - auto help = searchEdit->addAction(themePixmap(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); + searchEdit->addAction(loadThemedIcon(QStringLiteral("icons/search")), QLineEdit::LeadingPosition); + auto help = searchEdit->addAction(loadThemedIcon(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); connect(help, &QAction::triggered, this, [this] { createSearchSyntaxHelpWindow(searchEdit); }); 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); @@ -160,13 +131,32 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, connect(databaseView, &CardDatabaseView::relatedCardClicked, this, &VisualDatabaseDisplayWidget::onRelatedCardClicked); + // The visual grid is a real view on a flattened model: one row per displayed + // card picture, painted on demand. + visualDisplayModel = new CardDatabaseVisualDisplayModel(databaseDisplayModel, CardDatabaseManager::query(), this); + + cardView = new CardDatabaseVisualView(visualDisplayModel, this); + cardView->setObjectName("visualCardView"); + connect(cardView, &CardDatabaseVisualView::cardClicked, this, + &VisualDatabaseDisplayWidget::cardClickedDatabaseDisplay); + connect(cardView, &CardDatabaseVisualView::cardHovered, this, + &VisualDatabaseDisplayWidget::cardHoveredDatabaseDisplay); + + const int cardSize = SettingsCache::instance().cardsDisplay().getVisualDatabaseDisplayCardSize(); + cardSizeWidget = new CardSizeWidget(this, nullptr, cardSize); + connect(cardSizeWidget, &CardSizeWidget::cardSizeSettingUpdated, &SettingsCache::instance().cardsDisplay(), + &CardsDisplaySettings::setVisualDatabaseDisplayCardSize); + connect(cardSizeWidget->getSlider(), &QSlider::valueChanged, cardView, &CardDatabaseVisualView::setCardSizeFactor); + cardView->setCardSizeFactor(cardSize); + cardSizeWidget->enableCtrlScrollResize(cardView); + colorFilterWidget = new VisualDatabaseDisplayColorFilterWidget(this, filterModel); filterContainer = new VisualDatabaseDisplayFilterToolbarWidget(this, deckListModel); clearFilterWidget = new QToolButton(); clearFilterWidget->setFixedSize(32, 32); - clearFilterWidget->setIcon(themePixmap(QStringLiteral("icons/delete"))); + clearFilterWidget->setIcon(loadThemedIcon(QStringLiteral("icons/delete"))); connect(clearFilterWidget, &QToolButton::clicked, this, [this] { filterModel->blockSignals(true); filterModel->filterTree()->blockSignals(true); @@ -191,8 +181,6 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, databaseLoadIndicator->setVisible(false); } - QScrollBar *scrollBar = flowWidget->scrollArea->verticalScrollBar(); - connect(scrollBar, &QScrollBar::valueChanged, [this](int /*value*/) { loadCurrentPage(); }); retranslateUi(); } @@ -211,7 +199,7 @@ void VisualDatabaseDisplayWidget::initialize() mainLayout->addWidget(databaseView); - mainLayout->addWidget(flowWidget); + mainLayout->addWidget(cardView); mainLayout->addWidget(cardSizeWidget); @@ -242,8 +230,6 @@ void VisualDatabaseDisplayWidget::initializeFilters() databaseDisplayModel->setFilterTree(filterModel->filterTree()); - QTimer::singleShot(5000, this, [this] { loadCurrentPage(); }); - retranslateUi(); }); } @@ -263,6 +249,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,72 +260,41 @@ void VisualDatabaseDisplayWidget::highlightAllSearchEdit() searchEdit->setSelection(0, searchEdit->text().length()); } -void VisualDatabaseDisplayWidget::resizeEvent(QResizeEvent *event) +void VisualDatabaseDisplayWidget::updateDisplayModeButton() { - QWidget::resizeEvent(event); - loadCurrentPage(); + displayModeButton->setText(displayModeButton->isChecked() ? tr("Table") : tr("Visual")); } void VisualDatabaseDisplayWidget::onDisplayModeChanged(bool checked) { + updateDisplayModeButton(); if (checked) { // Table mode - displayModeButton->setText(tr("Table")); - flowWidget->setVisible(false); + cardView->setVisible(false); cardSizeWidget->setVisible(false); databaseView->setItemDelegate(new QStyledItemDelegate(databaseView)); databaseView->setVisible(true); } else { // Visual mode - displayModeButton->setText(tr("Visual")); - flowWidget->setVisible(true); + cardView->setVisible(true); cardSizeWidget->setVisible(true); databaseView->setVisible(false); - populateCards(); } } -void VisualDatabaseDisplayWidget::onClick(QMouseEvent *event, const ExactCard &card) -{ - emit cardClickedDatabaseDisplay(event, card); -} - -void VisualDatabaseDisplayWidget::onHover(const ExactCard &hoveredCard) -{ - emit cardHoveredDatabaseDisplay(hoveredCard); -} - -void VisualDatabaseDisplayWidget::addCardToDisplay(const ExactCard &cardToAdd) -{ - cards->append(cardToAdd); - auto *display = new CardInfoPictureWithTextOverlayWidget(flowWidget, false); - display->setScaleFactor(cardSizeWidget->getSlider()->value()); - display->setCard(cardToAdd); - flowWidget->addWidget(display); - connect(display, &CardInfoPictureWithTextOverlayWidget::cardClicked, this, &VisualDatabaseDisplayWidget::onClick); - connect(display, &CardInfoPictureWithTextOverlayWidget::hoveredOnCard, this, &VisualDatabaseDisplayWidget::onHover); - connect(cardSizeWidget->getSlider(), &QSlider::valueChanged, display, &CardInfoPictureWidget::setScaleFactor); -} - bool VisualDatabaseDisplayWidget::isVisualDisplayMode() const { return !displayModeButton->isChecked(); } +QStringList VisualDatabaseDisplayWidget::collectSetFilterTerms() const +{ + return filterModel->getFilterTermsOfType(CardFilter::AttrSet); +} + void VisualDatabaseDisplayWidget::onSearchModelChanged() { - if (flowWidget->isVisible()) { - // Clear the current page and prepare for new data - flowWidget->clearLayout(); // Clear existing cards - cards->clear(); // Clear the card list - // Reset scrollbar position to the top after loading new cards - QScrollBar *scrollBar = flowWidget->scrollArea->verticalScrollBar(); - scrollBar->setValue(0); // Reset scrollbar to top - - currentPage = 0; - loadCurrentPage(); - qCDebug(VisualDatabaseDisplayLog) << "Search model changed"; - } + visualDisplayModel->setSetFilterTerms(collectSetFilterTerms()); } void VisualDatabaseDisplayWidget::onSelectedCardChanged(const QString &cardName) @@ -361,112 +320,3 @@ void VisualDatabaseDisplayWidget::onRelatedCardClicked(const QString &relatedCar ExactCard exactCard = CardDatabaseManager::query()->guessCard({relatedCard}); emit cardInfoRequested(exactCard); } - -bool VisualDatabaseDisplayWidget::nearEndOfPage() const -{ - if (!flowWidget->isVisible()) { - return false; - } - - QScrollBar *scrollBar = flowWidget->scrollArea->verticalScrollBar(); - if (scrollBar->value() + scrollBar->pageStep() * 2 < scrollBar->maximum()) { - return false; // there is at least two pages of space to scroll remaining - } - - return true; -} - -void VisualDatabaseDisplayWidget::loadCurrentPage() -{ - // Ensure only the initial page is loaded - if (currentPage == 0) { - if (!initialLoadScheduled) { - initialLoadScheduled = true; - qCDebug(VisualDatabaseDisplayLog) << "Loading the first page"; - // Defer the first page so the tab switch stays responsive. The card - // grid builds one event loop turn later. This also applies to - // search-driven reloads, which reset currentPage back to 0. - QTimer::singleShot(0, this, [this] { - initialLoadScheduled = false; - populateCards(); - }); - } - } else if (nearEndOfPage()) { - // If not the first page, just load the next page and append to the flow widget - loadNextPage(); - } -} - -void VisualDatabaseDisplayWidget::populateCards() -{ - cards->clear(); - loadNextPage(); -} - -void VisualDatabaseDisplayWidget::loadNextPage() -{ - // Calculate the start and end indices for the next page - int rowCount = databaseDisplayModel->rowCount(); - int start = currentPage * cardsPerPage; - int end = qMin(start + cardsPerPage, rowCount); - - // Load more cards if we are at the end of the current list and can fetch more - if (end >= rowCount && databaseDisplayModel->canFetchMore(QModelIndex())) { - databaseDisplayModel->fetchMore(QModelIndex()); - } - - // Load the next page of cards and add them to the flow widget - flowWidget->setUpdatesEnabled(false); - loadPage(start, end); - flowWidget->setUpdatesEnabled(true); -} - -void VisualDatabaseDisplayWidget::loadPage(int start, int end) -{ - QList setFilters = filterModel->getFiltersOfType(CardFilter::AttrSet); - for (int row = start; row < end; ++row) { - qCDebug(VisualDatabaseDisplayLog) << "Adding " << row; - QModelIndex index = databaseDisplayModel->index(row, CardDatabaseModel::NameColumn); - QVariant name = databaseDisplayModel->data(index, Qt::DisplayRole); - qCDebug(VisualDatabaseDisplayLog) << name.toString(); - - if (CardInfoPtr info = CardDatabaseManager::query()->getCardInfo(name.toString())) { - if (!setFilters.empty()) { - SetToPrintingsMap setMap = info->getSets(); - for (const CardFilter *setFilter : setFilters) { - if (setMap.contains(setFilter->term())) { - for (PrintingInfo printing : setMap[setFilter->term()]) { - addCardToDisplay(ExactCard(info, printing)); - } - } - } - } else { - addCardToDisplay(CardDatabaseManager::query()->getPreferredCard(info)); - } - } else { - qCDebug(VisualDatabaseDisplayLog) << "Card not found in database!"; - } - } - currentPage++; -} - -void VisualDatabaseDisplayWidget::modelDirty() const -{ - debounceTimer->start(debounceTime); -} - -void VisualDatabaseDisplayWidget::sortCardList(const QStringList &properties, - Qt::SortOrder order = Qt::AscendingOrder) const -{ - CardInfoComparator comparator(properties, order); - std::sort(cards->begin(), cards->end(), [comparator](const ExactCard &a, const ExactCard &b) { - return comparator(a.getCardPtr(), b.getCardPtr()); - }); -} - -void VisualDatabaseDisplayWidget::databaseDataChanged(const QModelIndex &topLeft, const QModelIndex &bottomRight) -{ - (void)topLeft; - (void)bottomRight; - qCDebug(VisualDatabaseDisplayLog) << "Database Data changed"; -} 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..6148670a3 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 @@ -9,8 +9,6 @@ #include "libcockatrice/card/card_info.h" -#include -#include #include #include #include @@ -21,26 +19,21 @@ class CardDatabaseDisplayModel; class CardDatabaseModel; class CardDatabaseView; +class CardDatabaseVisualDisplayModel; +class CardDatabaseVisualView; class CardSizeWidget; -class DeckList; class DeckListModel; class ExactCard; class FilterTreeModel; class FlowWidget; -class OverlapControlWidget; class QLabel; -class QModelIndex; +class QMouseEvent; class QPushButton; -class QScrollArea; -class QTimer; class QToolButton; class QVBoxLayout; class SearchLineEdit; class VisualDatabaseDisplayColorFilterWidget; class VisualDatabaseDisplayFilterToolbarWidget; -template class QList; - -inline Q_LOGGING_CATEGORY(VisualDatabaseDisplayLog, "visual_database_display"); class VisualDatabaseDisplayWidget : public QWidget { @@ -52,14 +45,6 @@ public: DeckListModel *deckListModel = nullptr); void retranslateUi(); - void adjustCardsPerPage(); - void populateCards(); - void loadPage(int start, int end); - void loadNextPage(); - void loadCurrentPage(); - void sortCardList(const QStringList &properties, Qt::SortOrder order) const; - void setDeckList(const DeckList &new_deck_list_model); - /** * @brief Sets the callback used to create a custom zone from the add-to-zone menu. * The callback returns the name of the created zone, or an empty string if creation was cancelled. @@ -87,6 +72,13 @@ public: bool isVisualDisplayMode() const; public slots: + /** + * @brief Pushes the active set filters to the visual grid. + * + * The grid's rows follow the display model's filter and sort signals on their + * own; this only forwards the set filters, which decide which printings the + * grid shows. + */ void onSearchModelChanged(); signals: @@ -101,11 +93,6 @@ signals: protected slots: void initialize(); - void onClick(QMouseEvent *event, const ExactCard &card); - void onHover(const ExactCard &hoveredCard); - void addCardToDisplay(const ExactCard &cardToAdd); - void databaseDataChanged(const QModelIndex &topLeft, const QModelIndex &bottomRight); - void modelDirty() const; void onDisplayModeChanged(bool checked); void onSelectedCardChanged(const QString &cardName); @@ -125,30 +112,21 @@ private: QToolButton *clearFilterWidget; VisualDatabaseDisplayFilterToolbarWidget *filterContainer; CardDatabaseDisplayModel *databaseDisplayModel; + CardDatabaseVisualDisplayModel *visualDisplayModel; CardDatabaseView *databaseView; + CardDatabaseVisualView *cardView; std::function newZoneCreator; - QList *cards; QVBoxLayout *mainLayout; - QScrollArea *scrollArea; - FlowWidget *flowWidget; - QWidget *overlapCategories; - QVBoxLayout *overlapCategoriesLayout; - OverlapControlWidget *overlapControlWidget; CardSizeWidget *cardSizeWidget; - QTimer *debounceTimer; - int debounceTime = 300; // in Ms - int currentPage = 0; // Current page index - int cardsPerPage = 100; // Number of cards per page bool filtersInitialized = false; - bool initialLoadScheduled = false; void initializeFilters(); void highlightAllSearchEdit(); - bool nearEndOfPage() const; + void updateDisplayModeButton(); + [[nodiscard]] QStringList collectSetFilterTerms() const; protected: - void resizeEvent(QResizeEvent *event) override; void showEvent(QShowEvent *event) override; }; 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..f0c054c3a 100644 --- a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp @@ -1,6 +1,6 @@ #include "visual_deck_display_options_widget.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../deck_editor/deck_editor_deck_dock_widget.h" #include "../quick_settings/settings_button_widget.h" #include "../tabs/visual_deck_editor/tab_deck_editor_visual.h" @@ -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); @@ -66,7 +65,7 @@ VisualDeckDisplayOptionsWidget::VisualDeckDisplayOptionsWidget(QWidget *parent) sortByLabel = new QLabel(this); sortCriteriaButton = new SettingsButtonWidget(this); - sortCriteriaButton->setButtonIcon(themePixmap(QStringLiteral("icons/sort_arrow_down"))); + sortCriteriaButton->setButtonIcon(loadThemedIcon(QStringLiteral("icons/sort_arrow_down"))); sortLabel = new QLabel(sortCriteriaButton); sortLabel->setWordWrap(true); @@ -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(loadThemedIcon(QStringLiteral("icons/scroll"))); + break; + case DisplayType::Overlap: + displayTypeButton->setButtonText(tr("Toggle Layout: Overlap")); + displayTypeButton->setButtonIcon(loadThemedIcon(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..0e0b5e4b4 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 @@ -1,7 +1,7 @@ #include "visual_deck_editor_widget.h" #include "../../../client/settings/cache_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../cards/card_size_widget.h" #include "../cards/deck_card_zone_display_widget.h" #include "../general/layout_containers/flow_widget.h" @@ -42,6 +42,7 @@ #include #include #include +#include #include VisualDeckEditorWidget::VisualDeckEditorWidget(QWidget *parent, @@ -143,7 +144,7 @@ void VisualDeckEditorWidget::initializeSearchBarAndCompleter() // Search button functionality searchPushButton = new CompactPushButton(searchContainer); - searchPushButton->setButtonIcon(themePixmap(QStringLiteral("icons/search"))); + searchPushButton->setButtonIcon(loadThemedIcon(QStringLiteral("icons/search"))); connect(searchPushButton, &QPushButton::clicked, this, [this] { addCardFromSearch(); }); searchLayout->addWidget(searchPushButton); @@ -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..10a3bf33a 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp @@ -1,7 +1,7 @@ #include "visual_deck_storage_search_widget.h" #include "../../../filters/syntax_help.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include #include @@ -9,6 +9,7 @@ #include #include #include +#include /** * @brief Constructs a search bar for filtering decks in the Visual Deck Storage. @@ -20,16 +21,15 @@ */ 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); searchBar->setPlaceholderText(tr("Search by filename (or search expression)")); searchBar->setClearButtonEnabled(true); - searchBar->addAction(loadColorAdjustedPixmap("theme:icons/search"), QLineEdit::LeadingPosition); + searchBar->addAction(loadThemedIcon(QStringLiteral("icons/search")), QLineEdit::LeadingPosition); - auto help = searchBar->addAction(themePixmap(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); + auto help = searchBar->addAction(loadThemedIcon(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); connect(help, &QAction::triggered, this, [this] { createDeckSearchSyntaxHelpWindow(searchBar); }); layout->addWidget(searchBar); 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..bbf68adb9 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp @@ -1,7 +1,7 @@ #include "visual_deck_storage_widget.h" #include "../../../client/settings/cache_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "deck_preview/deck_preview_color_identity_filter_widget.h" #include "deck_preview/deck_preview_widget.h" #include "libcockatrice/card/database/card_database.h" @@ -33,6 +33,7 @@ #include #include #include +#include #include VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(parent) @@ -61,12 +62,12 @@ VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(pare searchWidget = new VisualDeckStorageSearchWidget(this); refreshButton = new QToolButton(this); - refreshButton->setIcon(themePixmap(QStringLiteral("icons/reload"))); + refreshButton->setIcon(loadThemedIcon(QStringLiteral("icons/reload"))); refreshButton->setFixedSize(32, 32); connect(refreshButton, &QPushButton::clicked, this, &VisualDeckStorageWidget::refreshIfPossible); shareButton = new QToolButton(this); - shareButton->setIcon(themePixmap(QStringLiteral("icons/share"))); + shareButton->setIcon(loadThemedIcon(QStringLiteral("icons/share"))); shareButton->setFixedSize(32, 32); shareButton->setVisible(false); connect(shareButton, &QPushButton::clicked, this, &VisualDeckStorageWidget::shareRequested); @@ -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..4e2e90556 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -50,6 +50,7 @@ #include "latency_tracker.h" #include "logger.h" #include "pixel_map_generator.h" +#include "theme_icon.h" #include "version_string.h" #include "widgets/dialogs/dlg_connect.h" #include "widgets/server/handle_public_servers.h" @@ -62,7 +63,6 @@ #include #include #include -#include #include #include #include @@ -112,6 +112,7 @@ #include #include #include +#include #include #include #include @@ -254,7 +255,11 @@ void MainWindow::actWatchReplay() void MainWindow::localGameEnded() { - delete localServer; + // Defer deletion so the LocalClients (already scheduled with deleteLater() in TabSupervisor::stop) + // and any queued commands are processed before the server and its interfaces are destroyed. + if (localServer) { + localServer->deleteLater(); + } localServer = nullptr; aConnect->setEnabled(true); @@ -341,7 +346,7 @@ void MainWindow::actViewLog() void MainWindow::actOpenSettingsFolder() { QString dir = SettingsCache::instance().getSettingsPath(); - QDesktopServices::openUrl(QUrl::fromLocalFile(dir)); + QtUtils::openPathInFileManager(dir); } void MainWindow::retranslateUi() @@ -356,10 +361,10 @@ void MainWindow::retranslateUi() aRegister->setText(tr("&Register to server...")); aForgotPassword->setText(tr("&Restore password...")); aSettings->setText(tr("&Settings...")); - aSettings->setIcon(themePixmap(QStringLiteral("icons/settings"))); + aSettings->setIcon(loadThemedIcon(QStringLiteral("icons/settings"))); settingsButton->setToolTip(tr("Settings")); settingsButton->setAccessibleName(tr("Settings")); - settingsButton->setIcon(themePixmap(QStringLiteral("icons/cogwheel"))); + settingsButton->setIcon(loadThemedIcon(QStringLiteral("icons/settings"))); aExit->setText(tr("&Exit")); #if defined(__APPLE__) /* For OSX */ @@ -923,11 +928,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 +959,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 +985,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 +1537,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 7b8648d69..829a8ac42 100644 --- a/cockatrice/src/main.cpp +++ b/cockatrice/src/main.cpp @@ -26,6 +26,7 @@ #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" @@ -431,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 diff --git a/format.sh b/format.sh index 9e3a6069b..fba76e93a 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/" \ @@ -32,6 +33,7 @@ cmakefile="CMakeLists.txt" cmakedir="cmake/.*\\.cmake" cmakeinclude=("cmake/gtest-CMakeLists.txt.in") scripts="*.sh" +scriptsrx="[^/]*\\.sh$" color="--" verbosity=0 sep="----------" @@ -253,7 +255,7 @@ if [[ $branch ]]; then if [[ $do_shell ]]; then shell_names=() for name in "${basenames[@]}"; do - filerx="(^|/)$scripts$" + filerx="(^|/)$scriptsrx" if [[ $name =~ $filerx ]]; then shell_names+=("$name") fi diff --git a/libcockatrice_card/CMakeLists.txt b/libcockatrice_card/CMakeLists.txt index b3583522c..d2e6ee83a 100644 --- a/libcockatrice_card/CMakeLists.txt +++ b/libcockatrice_card/CMakeLists.txt @@ -1,7 +1,3 @@ -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - set(HEADERS libcockatrice/card/card_info.h libcockatrice/card/card_info_comparator.h @@ -21,11 +17,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 bd33f18e7..90bfd3f44 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 356da0546..5452ef98b 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); @@ -524,6 +539,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 7c41daa3b..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) { @@ -149,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)}; } @@ -215,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; } } @@ -284,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); } @@ -297,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; @@ -315,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 @@ -339,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]++; } } @@ -351,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]++; @@ -377,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..3e7d522c2 100644 --- a/libcockatrice_deck_list/CMakeLists.txt +++ b/libcockatrice_deck_list/CMakeLists.txt @@ -1,34 +1,32 @@ -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - set(HEADERS libcockatrice/deck_list/tree/abstract_deck_list_card_node.h 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..070c04637 100644 --- a/libcockatrice_filters/CMakeLists.txt +++ b/libcockatrice_filters/CMakeLists.txt @@ -1,16 +1,10 @@ -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - set(HEADERS libcockatrice/filters/filter_card.h libcockatrice/filters/filter_string.h 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_card.h b/libcockatrice_filters/libcockatrice/filters/filter_card.h index e6a37ee40..dbeffd18f 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_card.h +++ b/libcockatrice_filters/libcockatrice/filters/filter_card.h @@ -54,7 +54,7 @@ private: enum Attr a; public: - CardFilter(QString &term, Type type, Attr attr) : trm(term), t(type), a(attr) + CardFilter(const QString &term, Type type, Attr attr) : trm(term), t(type), a(attr) { } diff --git a/libcockatrice_filters/libcockatrice/filters/filter_string.cpp b/libcockatrice_filters/libcockatrice/filters/filter_string.cpp index 0acc0eca0..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 @@ -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 742b44e97..f12f87181 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,42 @@ 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); } -bool FilterItem::acceptColor(const CardInfoPtr info) const +bool FilterItem::acceptColor(const CardInfoPtr &info) const { - QString converted_term = term.trimmed(); - - converted_term.replace("green", "g", Qt::CaseInsensitive); - converted_term.replace("grn", "g", Qt::CaseInsensitive); - converted_term.replace("blue", "u", Qt::CaseInsensitive); - converted_term.replace("blu", "u", Qt::CaseInsensitive); - converted_term.replace("black", "b", Qt::CaseInsensitive); - converted_term.replace("blk", "b", Qt::CaseInsensitive); - converted_term.replace("red", "r", Qt::CaseInsensitive); - converted_term.replace("white", "w", Qt::CaseInsensitive); - converted_term.replace("wht", "w", Qt::CaseInsensitive); - converted_term.replace("colorless", "c", Qt::CaseInsensitive); - converted_term.replace("colourless", "c", Qt::CaseInsensitive); - converted_term.replace("none", "c", Qt::CaseInsensitive); - - converted_term.replace(QString(" "), QString(""), Qt::CaseInsensitive); + const QString colors = info->getColors(); // Colorless card filter - if (converted_term.toLower() == "c" && info->getColors().length() < 1) { + if (convertedColorTerm.toLower() == "c" && colors.length() < 1) { return true; } @@ -256,16 +372,16 @@ bool FilterItem::acceptColor(const CardInfoPtr info) const * then we should match all of them to the card's colors */ int match_count = 0; - for (auto &it : converted_term) { - if (info->getColors().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::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); @@ -288,51 +404,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; @@ -344,39 +456,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; } @@ -386,49 +495,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; } } @@ -438,41 +509,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 { @@ -556,7 +611,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; @@ -589,7 +644,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 0defd466b..85a57304d 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,22 +238,22 @@ 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 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 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; }; @@ -251,7 +272,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 { @@ -290,7 +311,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..ce68c5ea6 --- /dev/null +++ b/libcockatrice_game/CMakeLists.txt @@ -0,0 +1,77 @@ +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..69ed13b0e 100644 --- a/libcockatrice_interfaces/CMakeLists.txt +++ b/libcockatrice_interfaces/CMakeLists.txt @@ -1,7 +1,3 @@ -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - set(HEADERS libcockatrice/interfaces/interface_cache_storage_settings_provider.h libcockatrice/interfaces/interface_card_database_path_provider.h @@ -24,9 +20,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/CMakeLists.txt b/libcockatrice_models/CMakeLists.txt index 7af35b2d5..5186eff13 100644 --- a/libcockatrice_models/CMakeLists.txt +++ b/libcockatrice_models/CMakeLists.txt @@ -1,7 +1,3 @@ -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - add_subdirectory(libcockatrice/models/database) add_subdirectory(libcockatrice/models/deck_list) diff --git a/libcockatrice_models/libcockatrice/models/database/CMakeLists.txt b/libcockatrice_models/libcockatrice/models/database/CMakeLists.txt index a73e36dbb..22a04cd32 100644 --- a/libcockatrice_models/libcockatrice/models/database/CMakeLists.txt +++ b/libcockatrice_models/libcockatrice/models/database/CMakeLists.txt @@ -1,10 +1,7 @@ -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - set(HEADERS card_database_model.h card_database_display_model.h + card_database_visual_display_model.h card/card_completer_proxy_model.h card/card_search_model.h card_set/card_sets_model.h @@ -12,13 +9,12 @@ 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_database_visual_display_model.cpp card/card_completer_proxy_model.cpp card/card_search_model.cpp card_set/card_sets_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..9ec0afd9c 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), cardModel(nullptr), + collator(NaturalSort::makeNaturalSortCollator()) { filterTree = nullptr; setFilterCaseSensitivity(Qt::CaseInsensitive); @@ -31,22 +33,37 @@ CardDatabaseDisplayModel::CardDatabaseDisplayModel(QObject *parent) invalidate(); emit modelDirty(); }); - - loadedRowCount = 0; } void CardDatabaseDisplayModel::setSourceModel(QAbstractItemModel *model) { + cardModel = qobject_cast(model); QSortFilterProxyModel::setSourceModel(model); connect(model, &QAbstractItemModel::rowsInserted, this, [this]() { dirty(); }); connect(model, &QAbstractItemModel::rowsRemoved, this, [this]() { dirty(); }); - connect(model, &QAbstractItemModel::modelReset, this, [this]() { - loadedRowCount = 0; - dirty(); - }); + connect(model, &QAbstractItemModel::modelReset, this, [this]() { dirty(); }); +} + +QVariant CardDatabaseDisplayModel::data(const QModelIndex &index, int role) const +{ + if (role == CardInfoRole) { + return QVariant::fromValue(cardInfoAt(index)); + } + + return QSortFilterProxyModel::data(index, role); +} + +CardInfoPtr CardDatabaseDisplayModel::cardInfoAt(const QModelIndex &index) const +{ + if (!index.isValid() || cardModel == nullptr) { + return {}; + } + + const QModelIndex sourceIndex = mapToSource(index); + return sourceIndex.isValid() ? cardModel->getCard(sourceIndex.row()) : CardInfoPtr(); } QMap CardDatabaseDisplayModel::characterTranslation = {{L'“', L'\"'}, @@ -54,41 +71,14 @@ QMap CardDatabaseDisplayModel::characterTranslation = {{L'“' {L'‘', L'\''}, {L'’', L'\''}}; -bool CardDatabaseDisplayModel::canFetchMore(const QModelIndex &index) const -{ - return loadedRowCount < sourceModel()->rowCount(index); -} - -void CardDatabaseDisplayModel::fetchMore(const QModelIndex &index) -{ - int remainder = sourceModel()->rowCount(index) - loadedRowCount; - int itemsToFetch = qMin(100, remainder); - - if (itemsToFetch == 0) { - return; - } - - const auto startIndex = qMin(rowCount(QModelIndex()), loadedRowCount); - beginInsertRows(QModelIndex(), startIndex, startIndex + itemsToFetch - 1); - - loadedRowCount += itemsToFetch; - endInsertRows(); -} - -int CardDatabaseDisplayModel::rowCount(const QModelIndex &parent) const -{ - return QSortFilterProxyModel::rowCount(parent); -} - 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 +91,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 +135,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 +151,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 +173,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 +181,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..3a9bbc6ab 100644 --- a/libcockatrice_models/libcockatrice/models/database/card_database_display_model.h +++ b/libcockatrice_models/libcockatrice/models/database/card_database_display_model.h @@ -10,15 +10,18 @@ #include "libcockatrice/card/card_info.h" +#include #include #include #include #include +#include #include #include #include #include +class CardDatabaseModel; class FilterTree; class QObject; template class QMap; @@ -34,13 +37,25 @@ public: ShowAll }; + enum Role + { + /** + * @brief The CardInfoPtr behind a proxy row. + * + * Saves views from looking the card up again through its name, and keeps + * them from depending on the source model's column layout. + */ + CardInfoRole = Qt::UserRole + 1 + }; + private: FilterBool isToken; QString cardName, cardText; QSet cardNameSet, cardTypes, cardColors; FilterTree *filterTree; FilterString *filterString; - int loadedRowCount; + /// The source model, kept typed so CardInfoRole does not cast per row. + CardDatabaseModel *cardModel; QTimer dirtyTimer; CardSearchLanguage searchLanguage; QString searchText; @@ -48,6 +63,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; @@ -80,15 +98,22 @@ public: dirtyTimer.start(20); } void clearFilterAll(); - [[nodiscard]] int rowCount(const QModelIndex &parent = QModelIndex()) const override; - [[nodiscard]] bool canFetchMore(const QModelIndex &parent) const override; - void fetchMore(const QModelIndex &parent) override; + [[nodiscard]] QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + + /** + * @brief Returns the card behind a proxy row, without the QVariant wrapper of CardInfoRole. + * + * The visual grid calls this for every row on each rebuild; CardInfoRole stays the + * generic way in for consumers that only see the model through data(). + */ + [[nodiscard]] CardInfoPtr cardInfoAt(const QModelIndex &index) const; + signals: void modelDirty(); 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_database_model.cpp b/libcockatrice_models/libcockatrice/models/database/card_database_model.cpp index 1ede554f8..a83b7c83d 100644 --- a/libcockatrice_models/libcockatrice/models/database/card_database_model.cpp +++ b/libcockatrice_models/libcockatrice/models/database/card_database_model.cpp @@ -27,14 +27,18 @@ CardDatabaseModel::CardDatabaseModel(CardDatabase *_db, bool _showOnlyCardsFromE CardDatabaseModel::~CardDatabaseModel() = default; -int CardDatabaseModel::rowCount(const QModelIndex & /*parent*/) const +int CardDatabaseModel::rowCount(const QModelIndex &parent) const { - return cardList.size(); + // A list model's card rows are leaves; returning the full card count for a + // valid parent makes every row claim all cards as children. + return parent.isValid() ? 0 : cardList.size(); } -int CardDatabaseModel::columnCount(const QModelIndex & /*parent*/) const +int CardDatabaseModel::columnCount(const QModelIndex &parent) const { - return CARDDBMODEL_COLUMNS; + // Card rows are leaves, so a valid parent holds no columns. Reporting the + // row width for a card index would claim child cells that do not exist. + return parent.isValid() ? 0 : CARDDBMODEL_COLUMNS; } QVariant CardDatabaseModel::data(const QModelIndex &index, int role) const diff --git a/libcockatrice_models/libcockatrice/models/database/card_database_visual_display_model.cpp b/libcockatrice_models/libcockatrice/models/database/card_database_visual_display_model.cpp new file mode 100644 index 000000000..f6a3a925c --- /dev/null +++ b/libcockatrice_models/libcockatrice/models/database/card_database_visual_display_model.cpp @@ -0,0 +1,123 @@ +#include "card_database_visual_display_model.h" + +#include "card_database_display_model.h" +#include "libcockatrice/card/database/card_database_querier.h" + +#include +#include +#include +#include + +/// Coalesces the bursts of proxy signals a filter, sort or database load produces. +static constexpr int REBUILD_COALESCE_MS = 30; + +CardDatabaseVisualDisplayModel::CardDatabaseVisualDisplayModel(CardDatabaseDisplayModel *_displayModel, + CardDatabaseQuerier *_querier, + QObject *parent) + : QAbstractListModel(parent), displayModel(_displayModel), querier(_querier) +{ + rebuildTimer.setSingleShot(true); + rebuildTimer.setInterval(REBUILD_COALESCE_MS); + connect(&rebuildTimer, &QTimer::timeout, this, &CardDatabaseVisualDisplayModel::rebuild); + + if (displayModel == nullptr) { + return; + } + + connect(displayModel, &QAbstractItemModel::modelReset, this, &CardDatabaseVisualDisplayModel::scheduleRebuild); + connect(displayModel, &QAbstractItemModel::layoutChanged, this, &CardDatabaseVisualDisplayModel::scheduleRebuild); + connect(displayModel, &QAbstractItemModel::rowsInserted, this, &CardDatabaseVisualDisplayModel::scheduleRebuild); + connect(displayModel, &QAbstractItemModel::rowsRemoved, this, &CardDatabaseVisualDisplayModel::scheduleRebuild); + connect(displayModel, &QAbstractItemModel::dataChanged, this, &CardDatabaseVisualDisplayModel::scheduleRebuild); +} + +void CardDatabaseVisualDisplayModel::setSetFilterTerms(const QStringList &terms) +{ + if (setFilterTerms == terms) { + return; + } + + setFilterTerms = terms; + refresh(); +} + +void CardDatabaseVisualDisplayModel::refresh() +{ + rebuildTimer.stop(); + rebuild(); +} + +void CardDatabaseVisualDisplayModel::scheduleRebuild() +{ + rebuildTimer.start(); +} + +void CardDatabaseVisualDisplayModel::rebuild() +{ + beginResetModel(); + entries.clear(); + + if (displayModel != nullptr) { + const int proxyRows = displayModel->rowCount(); + entries.reserve(proxyRows); + for (int row = 0; row < proxyRows; ++row) { + const CardInfoPtr info = displayModel->cardInfoAt(displayModel->index(row, 0)); + if (info) { + appendPrintingsFor(info); + } + } + } + + endResetModel(); +} + +void CardDatabaseVisualDisplayModel::appendPrintingsFor(const CardInfoPtr &info) +{ + if (setFilterTerms.isEmpty()) { + entries.append({info, PrintingInfo()}); + return; + } + + const auto &printingsBySet = info->getSets(); + for (const QString &term : setFilterTerms) { + const auto printings = printingsBySet.constFind(term); + if (printings == printingsBySet.constEnd()) { + continue; + } + for (const PrintingInfo &printing : printings.value()) { + entries.append({info, printing}); + } + } +} + +int CardDatabaseVisualDisplayModel::rowCount(const QModelIndex &parent) const +{ + return parent.isValid() ? 0 : static_cast(entries.size()); +} + +QVariant CardDatabaseVisualDisplayModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() >= entries.size()) { + return {}; + } + + if (role == Qt::DisplayRole || role == Qt::AccessibleTextRole) { + return cardAt(index.row()).getName(); + } + + return {}; +} + +ExactCard CardDatabaseVisualDisplayModel::cardAt(int row) const +{ + if (row < 0 || row >= entries.size()) { + return {}; + } + + const Entry &entry = entries.at(row); + if (entry.printing.isEmpty() && setFilterTerms.isEmpty()) { + return querier != nullptr ? querier->getPreferredCard(entry.info) : ExactCard(entry.info); + } + + return ExactCard(entry.info, entry.printing); +} diff --git a/libcockatrice_models/libcockatrice/models/database/card_database_visual_display_model.h b/libcockatrice_models/libcockatrice/models/database/card_database_visual_display_model.h new file mode 100644 index 000000000..1303b6464 --- /dev/null +++ b/libcockatrice_models/libcockatrice/models/database/card_database_visual_display_model.h @@ -0,0 +1,90 @@ +/** + * @file card_database_visual_display_model.h + * @ingroup CardDatabaseModels + * @brief A list model of the exact cards a visual card grid paints. + * + * The filtered and sorted CardDatabaseDisplayModel holds one row per card name. + * A visual grid shows one picture per *printing* instead, so this model flattens + * the proxy rows into ExactCards: the preferred printing per card normally, and + * every printing of the selected sets while set filters are active. + */ + +#ifndef COCKATRICE_CARD_DATABASE_VISUAL_DISPLAY_MODEL_H +#define COCKATRICE_CARD_DATABASE_VISUAL_DISPLAY_MODEL_H + +#include "libcockatrice/card/card_info.h" +#include "libcockatrice/card/printing/exact_card.h" +#include "libcockatrice/card/printing/printing_info.h" + +#include +#include +#include +#include +#include +#include +#include + +class CardDatabaseDisplayModel; +class CardDatabaseQuerier; + +class CardDatabaseVisualDisplayModel : public QAbstractListModel +{ + Q_OBJECT + +public: + /** + * @brief Flattens _displayModel into the cards a visual grid displays. + * @param _displayModel The filtered and sorted proxy model of the card database. + * @param _querier Resolves the preferred printing of a card. + * @param parent Parent object. + */ + explicit CardDatabaseVisualDisplayModel(CardDatabaseDisplayModel *_displayModel, + CardDatabaseQuerier *_querier, + QObject *parent = nullptr); + + /** + * @brief Sets the set shorthand names of the active set filters. + * + * With no terms every card is shown with its preferred printing. With terms + * every printing belonging to one of the named sets is shown instead, in term + * order. Rebuilds immediately when the terms change. + */ + void setSetFilterTerms(const QStringList &terms); + + /** + * @brief Rebuilds the row list from the proxy model right away. + * + * Proxy model changes normally rebuild on a short coalescing timer; call this + * when the caller needs the new rows before returning. + */ + void refresh(); + + [[nodiscard]] int rowCount(const QModelIndex &parent = QModelIndex()) const override; + [[nodiscard]] QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + + /** + * @brief Returns the card painted at @p row, with its preferred printing resolved. + * @param row Row in this model, not in the proxy model. + */ + [[nodiscard]] ExactCard cardAt(int row) const; + +private: + struct Entry + { + CardInfoPtr info; + /// Empty when the entry follows the preferred printing rather than a set filter. + PrintingInfo printing; + }; + + void scheduleRebuild(); + void rebuild(); + void appendPrintingsFor(const CardInfoPtr &info); + + QPointer displayModel; + CardDatabaseQuerier *querier; + QStringList setFilterTerms; + QList entries; + QTimer rebuildTimer; +}; + +#endif // COCKATRICE_CARD_DATABASE_VISUAL_DISPLAY_MODEL_H 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..ce5c4d54b 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt +++ b/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt @@ -1,13 +1,7 @@ -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -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_model.cpp b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp index 4b4088972..617324fe3 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp @@ -134,6 +134,13 @@ void DeckListModel::rebuildTree() int DeckListModel::rowCount(const QModelIndex &parent) const { + // Only column 0 carries children. Column 1 and later indexes are siblings + // used by views for the remaining cells of a row, and a model that hangs + // children off them breaks QAbstractItemModelTester and proxy models. + if (parent.isValid() && parent.column() != 0) { + return 0; + } + // debugIndexInfo("rowCount", parent); auto *node = getNode(parent); if (node) { @@ -282,7 +289,7 @@ QVariant DeckListModel::headerData(const int section, const Qt::Orientation orie QModelIndex DeckListModel::index(int row, int column, const QModelIndex &parent) const { // debugIndexInfo("index", parent); - if (!hasIndex(row, column, parent)) { + if ((parent.isValid() && parent.column() != 0) || !hasIndex(row, column, parent)) { return {}; } 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/CMakeLists.txt b/libcockatrice_network/CMakeLists.txt index 3069c0db4..925e4d445 100644 --- a/libcockatrice_network/CMakeLists.txt +++ b/libcockatrice_network/CMakeLists.txt @@ -1,7 +1,3 @@ -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - add_subdirectory(libcockatrice/network/client) add_subdirectory(libcockatrice/network/server) diff --git a/libcockatrice_network/libcockatrice/network/client/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/client/CMakeLists.txt index bcf62463c..cf8d9fc4d 100644 --- a/libcockatrice_network/libcockatrice/network/client/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/client/CMakeLists.txt @@ -1,7 +1,3 @@ -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - add_subdirectory(abstract) add_subdirectory(local) add_subdirectory(remote) diff --git a/libcockatrice_network/libcockatrice/network/client/abstract/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/client/abstract/CMakeLists.txt index 53ad524b4..33812e0bf 100644 --- a/libcockatrice_network/libcockatrice/network/client/abstract/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/client/abstract/CMakeLists.txt @@ -1,14 +1,8 @@ -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - 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) @@ -17,7 +11,7 @@ target_include_directories(libcockatrice_network_client_abstract PUBLIC .) target_link_libraries( libcockatrice_network_client_abstract PUBLIC ${COCKATRICE_QT_VERSION_NAME}::Network ${COCKATRICE_QT_VERSION_NAME}::WebSockets libcockatrice_protocol - libcockatrice_network_server_remote + libcockatrice_network_server_remote libcockatrice_utility ) target_precompile_headers(libcockatrice_network_client_abstract PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h") diff --git a/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.cpp b/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.cpp index 0c523d776..04e6beda0 100644 --- a/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.cpp +++ b/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.cpp @@ -10,6 +10,7 @@ #include "libcockatrice/protocol/pb/serverinfo_user.pb.h" #include "libcockatrice/protocol/pb/session_commands.pb.h" #include "libcockatrice/protocol/pb/session_event.pb.h" +#include "libcockatrice/utility/passwordhasher.h" #include #include @@ -80,6 +81,26 @@ AbstractClient::~AbstractClient() { } +Command_AccountPassword AbstractClient::makeAccountPasswordCommand(const QString &oldPassword, + const QString &newPassword) const +{ + Command_AccountPassword cmd; + cmd.set_old_password(oldPassword.toStdString()); + if (getServerSupportsChallengeResponse()) { + // A verifier lets the server upgrade the stored credential without ever seeing the + // password, and it is what a challenge-response server expects for a new password. + cmd.set_hashed_new_password(PasswordHasher::generatePasswordVerifier(newPassword).toStdString()); + } else if (getServerSupportsPasswordHash()) { + // The server hashes the password itself with its own salt; a salted hash is the + // strongest form it accepts, so the plaintext never goes on the wire. + const QString passwordSalt = PasswordHasher::generateRandomSalt(); + cmd.set_hashed_new_password(PasswordHasher::computeHash(newPassword, passwordSalt).toStdString()); + } else { + cmd.set_new_password(newPassword.toStdString()); + } + return cmd; +} + void AbstractClient::processProtocolItem(const ServerMessage &item) { switch (item.message_type()) { diff --git a/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.h b/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.h index 7efd67972..005587ccc 100644 --- a/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.h +++ b/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.h @@ -47,6 +47,7 @@ class Event_UserJoined; class Event_UserLeft; class Event_ServerMessage; class Event_ListRooms; +class Command_AccountPassword; class Event_GameJoined; class Event_UserMessage; class Event_NotifyUser; @@ -67,6 +68,9 @@ enum ClientStatus StatusSubmitForgotPasswordReset, StatusSubmitForgotPasswordChallenge, StatusGettingPasswordSalt, + //! The server accepted the credentials but keeps the session open until the account + //! password is replaced; the connection must survive for `Command_AccountPassword`. + StatusPasswordChangeRequired, }; class AbstractClient : public QObject @@ -171,6 +175,16 @@ public: { return serverSupportsChallengeResponse; } + + /** + * @brief Builds the account-password command in the form this server understands. + * + * Challenge-response servers take a scrypt verifier; servers that only advertise + * SupportsPasswordHash take a salted SHA-512 hash; anything older gets the plaintext, + * which is the only form it understands. Both password-change paths go through here so + * the two cannot drift apart and hand a plaintext password to a server that can hash it. + */ + Command_AccountPassword makeAccountPasswordCommand(const QString &oldPassword, const QString &newPassword) const; const QString &getUserName() const { return userName; diff --git a/libcockatrice_network/libcockatrice/network/client/local/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/client/local/CMakeLists.txt index 08c12b7f8..3eff7943a 100644 --- a/libcockatrice_network/libcockatrice/network/client/local/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/client/local/CMakeLists.txt @@ -1,14 +1,8 @@ -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - 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..b9a1566f8 100644 --- a/libcockatrice_network/libcockatrice/network/client/local/local_client.cpp +++ b/libcockatrice_network/libcockatrice/network/client/local/local_client.cpp @@ -33,20 +33,26 @@ LocalClient::LocalClient(LocalServerInterface *_lsi, sendCommand(prepareSessionCommand(joinCmd)); } -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); + } + + if (lsi.isNull()) { + qCWarning(LocalClientLog).noquote() + << userName << "dropping command, local server interface no longer exists:" << getSafeDebugString(cont); + return; + } 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/local/local_client.h b/libcockatrice_network/libcockatrice/network/client/local/local_client.h index 3ae570255..15b36d3db 100644 --- a/libcockatrice_network/libcockatrice/network/client/local/local_client.h +++ b/libcockatrice_network/libcockatrice/network/client/local/local_client.h @@ -10,6 +10,7 @@ #include "../abstract/abstract_client.h" #include +#include #include #include @@ -24,14 +25,15 @@ class LocalClient : public AbstractClient { Q_OBJECT private: - LocalServerInterface *lsi; + // Guarded pointer: the LocalServer (and its interfaces) may be destroyed while this client + // still has pending commands queued on the event loop. + QPointer lsi; public: LocalClient(LocalServerInterface *_lsi, const QString &_playerName, const QString &_clientId, QObject *parent = nullptr); - ~LocalClient() override; void sendCommandContainer(const CommandContainer &cont) override; private slots: diff --git a/libcockatrice_network/libcockatrice/network/client/remote/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/client/remote/CMakeLists.txt index c32ca0aa8..006c18d42 100644 --- a/libcockatrice_network/libcockatrice/network/client/remote/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/client/remote/CMakeLists.txt @@ -1,14 +1,8 @@ -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - 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..faacdc9f7 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(), @@ -84,6 +125,7 @@ RemoteClient::RemoteClient(QObject *parent, INetworkSettingsProvider *_networkSe &RemoteClient::doSubmitForgotPasswordResetToServer); connect(this, &RemoteClient::sigSubmitForgotPasswordChallengeToServer, this, &RemoteClient::doSubmitForgotPasswordChallengeToServer); + connect(this, &RemoteClient::sigChangePasswordToServer, this, &RemoteClient::doChangePasswordToServer); } RemoteClient::~RemoteClient() @@ -313,6 +355,29 @@ void RemoteClient::submitPasswordVerifierResponse(const Response &response) } } +void RemoteClient::changePasswordToServer(const QString &oldPassword, const QString &newPassword) +{ + emit sigChangePasswordToServer(oldPassword, newPassword); +} + +void RemoteClient::doChangePasswordToServer(const QString &oldPassword, const QString &newPassword) +{ + const Command_AccountPassword cmd = makeAccountPasswordCommand(oldPassword, newPassword); + + PendingCommand *pend = prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &RemoteClient::accountPasswordResponse); + sendCommand(pend); +} + +void RemoteClient::accountPasswordResponse(const Response &response) +{ + if (response.response_code() == Response::RespOk) { + emit sigPasswordChangeSuccess(); + } else { + emit sigPasswordChangeError(response.response_code()); + } +} + void RemoteClient::processConnectionClosedEvent(const Event_ConnectionClosed & /*event*/) { doDisconnectFromServer(); @@ -335,9 +400,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 +408,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 +419,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 +428,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 +436,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; @@ -439,6 +499,23 @@ void RemoteClient::loginResponse(const Response &response) emit notifyUserAboutUpdate(); } + } else if (response.response_code() == Response::RespPasswordChangeRequired) { + // The credentials were accepted: the server keeps this session open and serves + // nothing but ping and Command_AccountPassword until the password is replaced. + // Treating it as a failed login would disconnect before the dialog that collects + // the new password (queued to the UI thread behind this slot) can send anything: + // the command would sit in pendingCommands forever, because the ping timer that + // times it out stops with the connection. Keep the session and let the controller + // report the outcome through accountPasswordResponse(). + QList missingFeatures; + if (resp.missing_features_size() > 0) { + for (int i = 0; i < resp.missing_features_size(); ++i) { + missingFeatures << QString::fromStdString(resp.missing_features(i)); + } + } + setStatus(StatusPasswordChangeRequired); + emit loginError(response.response_code(), QString::fromStdString(resp.denied_reason_str()), + static_cast(resp.denied_end_time()), missingFeatures); } else if (response.response_code() != Response::RespNotConnected) { QList missingFeatures; if (resp.missing_features_size() > 0) { @@ -446,8 +523,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 +584,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 +602,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 +635,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 +648,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 +666,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 +925,7 @@ void RemoteClient::requestForgotPasswordResponse(const Response &response) emit sigPromptForForgotPasswordReset(); } } else { - emit sigForgotPasswordError(); + emit sigForgotPasswordError(response.response_code()); } doDisconnectFromServer(); @@ -857,7 +961,7 @@ void RemoteClient::submitForgotPasswordResetResponse(const Response &response) derivedVerifier.clear(); } } else { - emit sigForgotPasswordError(); + emit sigForgotPasswordError(response.response_code()); } doDisconnectFromServer(); @@ -892,7 +996,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..1cf40491c 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, @@ -71,6 +71,11 @@ signals: //! should only store it for the connection it is currently negotiating, so //! the hostname and user name are intentionally not part of the signal. void sigPasswordVerifierReady(const QString &verifier); + //! \brief Outcome of the password change the server demanded at login. + void sigPasswordChangeSuccess(); + void sigPasswordChangeError(int responseCode); + //! \brief Request a password change; queued to the client thread like the other requests. + void sigChangePasswordToServer(const QString &oldPassword, const QString &newPassword); private slots: void slotConnected(); void readData(); @@ -99,6 +104,8 @@ private slots: Command_Login generateCommandLogin(); void doSubmitPasswordVerifier(); void submitPasswordVerifierResponse(const Response &response); + void accountPasswordResponse(const Response &response); + void doChangePasswordToServer(const QString &oldPassword, const QString &newPassword); void doDisconnectFromServer(); void doActivateToServer(const QString &_token); void doRequestForgotPasswordToServer(const QString &hostname, unsigned int port, const QString &_userName); @@ -201,6 +208,10 @@ public: unsigned int port, const QString &_userName, const QString &_email); + //! \brief Change the password of the account that just logged in, following the + //! server's forced-password-change reply. Emits sigPasswordChangeSuccess or + //! sigPasswordChangeError when the server answers. + void changePasswordToServer(const QString &oldPassword, const QString &newPassword); }; #endif 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/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/server/CMakeLists.txt index cbb717ad8..b59d61572 100644 --- a/libcockatrice_network/libcockatrice/network/server/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/server/CMakeLists.txt @@ -1,7 +1,3 @@ -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - add_subdirectory(local) add_subdirectory(remote) diff --git a/libcockatrice_network/libcockatrice/network/server/local/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/server/local/CMakeLists.txt index 020753d7f..4055efa64 100644 --- a/libcockatrice_network/libcockatrice/network/server/local/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/server/local/CMakeLists.txt @@ -1,14 +1,8 @@ -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - 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..181218e1a 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt @@ -1,7 +1,3 @@ -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - set(HEADERS game/dump_zone_range.h game/server_abstract_participant.h @@ -12,6 +8,7 @@ set(HEADERS game/server_cardzone.h game/server_counter.h game/game_config.h + game/role_info.h game/server_deck_validation_strategy.h game/server_game.h game/server_game_lifecycle_strategy.h @@ -29,11 +26,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/game/role_info.h b/libcockatrice_network/libcockatrice/network/server/remote/game/role_info.h new file mode 100644 index 000000000..ef8692361 --- /dev/null +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/role_info.h @@ -0,0 +1,13 @@ +#ifndef COCKATRICE_ROLE_INFO_H +#define COCKATRICE_ROLE_INFO_H + +/** + * @brief Represents information about a participant's roles. + */ +struct RoleInfo +{ + bool isJudge = false; ///< Whether the participant joined as a judge + bool isSpectator = false; ///< Whether the participant joined as a spectator +}; + +#endif // COCKATRICE_ROLE_INFO_H diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.cpp index 5b8d901bb..ff8ea14f3 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.cpp @@ -61,10 +61,10 @@ Server_AbstractParticipant::Server_AbstractParticipant(Server_Game *_game, int _playerId, const ServerInfo_User &_userInfo, - bool _judge, + const RoleInfo &_roleInfo, Server_AbstractUserInterface *_userInterface) : ServerInfo_User_Container(_userInfo), game(_game), userInterface(_userInterface), pingTime(0), - playerId(_playerId), judge(_judge) + playerId(_playerId), roleInfo(_roleInfo) { } @@ -98,8 +98,8 @@ void Server_AbstractParticipant::getProperties(ServerInfo_PlayerProperties &resu if (withUserInfo) { copyUserInfo(*(result.mutable_user_info()), true); } - result.set_spectator(spectator); - result.set_judge(judge); + result.set_spectator(isSpectator()); + result.set_judge(isJudge()); result.set_ping_seconds(pingTime); getPlayerProperties(result); } @@ -176,7 +176,7 @@ Response::ResponseCode Server_AbstractParticipant::cmdUnconcede(const Command_Un Response::ResponseCode Server_AbstractParticipant::cmdJudge(const Command_Judge &cmd, ResponseContainer &rc, GameEventStorage &ges) { - if (!judge) { + if (!isJudge()) { return Response::RespFunctionNotAllowed; } @@ -204,7 +204,7 @@ Response::ResponseCode Server_AbstractParticipant::cmdReadyStart(const Command_R Response::ResponseCode Server_AbstractParticipant::cmdGameSay(const Command_GameSay &cmd, ResponseContainer & /*rc*/, GameEventStorage &ges) { - if (spectator) { + if (isSpectator()) { /* Spectators can only talk if: * (a) the game creator allows it * (b) the spectator is a moderator/administrator @@ -365,7 +365,7 @@ Response::ResponseCode Server_AbstractParticipant::cmdNextTurn(const Command_Nex return Response::RespGameNotStarted; } - if (!judge) { + if (!isJudge()) { return Response::RespFunctionNotAllowed; } @@ -381,7 +381,7 @@ Response::ResponseCode Server_AbstractParticipant::cmdSetActivePhase(const Comma return Response::RespGameNotStarted; } - if (!judge) { + if (!isJudge()) { return Response::RespFunctionNotAllowed; } @@ -415,8 +415,8 @@ Response::ResponseCode Server_AbstractParticipant::cmdReverseTurn(const Command_ ResponseContainer & /*rc*/, GameEventStorage &ges) { - if (!judge) { - if (spectator) { + if (!isJudge()) { + if (isSpectator()) { return Response::RespFunctionNotAllowed; } @@ -577,7 +577,7 @@ void Server_AbstractParticipant::setUserInterface(Server_AbstractUserInterface * void Server_AbstractParticipant::disconnectClient() { bool isRegistered = userInfo->user_level() & ServerInfo_User::IsRegistered; - if (!isRegistered || spectator) { + if (!isRegistered || isSpectator()) { game->removeParticipant(this, Event_Leave::USER_DISCONNECTED); } else { setUserInterface(nullptr); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.h index 195da9b7f..b6d4a8025 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.h @@ -2,6 +2,7 @@ #define ABSTRACT_PARTICIPANT_H #include "../serverinfo_user_container.h" +#include "role_info.h" #include "server_arrowtarget.h" #include @@ -61,8 +62,7 @@ protected: Server_AbstractUserInterface *userInterface; int pingTime; int playerId; - bool spectator; - bool judge; + RoleInfo roleInfo; virtual void getPlayerProperties(ServerInfo_PlayerProperties &result); mutable QMutex playerMutex; @@ -70,7 +70,7 @@ public: Server_AbstractParticipant(Server_Game *_game, int _playerId, const ServerInfo_User &_userInfo, - bool _judge, + const RoleInfo &_roleInfo, Server_AbstractUserInterface *_handler); ~Server_AbstractParticipant() override; virtual void prepareDestroy() @@ -91,11 +91,11 @@ public: } bool isSpectator() const { - return spectator; + return roleInfo.isSpectator; } bool isJudge() const { - return judge; + return roleInfo.isJudge; } Server_Game *getGame() const { diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.cpp index c03f1e966..b5a620798 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.cpp @@ -81,10 +81,9 @@ Server_AbstractPlayer::Server_AbstractPlayer(Server_Game *_game, const ServerInfo_User &_userInfo, bool _judge, Server_AbstractUserInterface *_userInterface) - : Server_AbstractParticipant(_game, _playerId, _userInfo, _judge, _userInterface), conceded(false), deck(nullptr), - sideboardLocked(true), readyStart(false), nextCardId(0) + : Server_AbstractParticipant(_game, _playerId, _userInfo, {.isJudge = _judge}, _userInterface), conceded(false), + deck(nullptr), sideboardLocked(true), readyStart(false), nextCardId(0) { - spectator = false; } Server_AbstractPlayer::~Server_AbstractPlayer() = default; @@ -295,7 +294,7 @@ Response::ResponseCode Server_AbstractPlayer::moveCard(GameEventStorage &ges, { // Disallow controller change to other zones than the table. if (((targetzone->getType() != ServerInfo_Zone::PublicZone) || !targetzone->hasCoords()) && - (startzone->getPlayer() != targetzone->getPlayer()) && !judge) { + (startzone->getPlayer() != targetzone->getPlayer()) && !isJudge()) { return Response::RespContextError; } @@ -808,7 +807,7 @@ Server_AbstractPlayer::cmdMoveCard(const Command_MoveCard &cmd, ResponseContaine return Response::RespNameNotFound; } - if ((startPlayer != this) && (!startZone->getPlayersWithWritePermission().contains(playerId)) && !judge) { + if ((startPlayer != this) && (!startZone->getPlayersWithWritePermission().contains(playerId)) && !isJudge()) { return Response::RespContextError; } @@ -821,7 +820,7 @@ Server_AbstractPlayer::cmdMoveCard(const Command_MoveCard &cmd, ResponseContaine return Response::RespNameNotFound; } - if ((startPlayer != this) && (targetPlayer != this) && !judge) { + if ((startPlayer != this) && (targetPlayer != this) && !isJudge()) { return Response::RespContextError; } @@ -1598,7 +1597,7 @@ Server_AbstractPlayer::cmdRevealCards(const Command_RevealCards &cmd, ResponseCo zone->addWritePermission(cmd.player_id()); } - if (judge) { + if (isJudge()) { ges.setOverwriteOwnership(true); } diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp index b1863c91e..49db999f6 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp @@ -41,6 +41,7 @@ #include "libcockatrice/protocol/pb/serverinfo_replay_match.pb.h" #include "libcockatrice/protocol/pb/serverinfo_user.pb.h" #include "libcockatrice/protocol/pb/session_event.pb.h" +#include "role_info.h" #include "server_abstract_player.h" #include "server_abstractuserinterface.h" #include "server_arrow.h" @@ -479,9 +480,8 @@ void Server_Game::stopGameIfFinished() Response::ResponseCode Server_Game::checkJoin(ServerInfo_User *user, const QString &_password, - bool spectator, - bool overrideRestrictions, - bool asJudge) + const RoleInfo &roleInfo, + bool overrideRestrictions) { Server_DatabaseInterface *databaseInterface = room->getServer()->getDatabaseInterface(); for (auto *participant : participants.values()) { @@ -490,11 +490,11 @@ Response::ResponseCode Server_Game::checkJoin(ServerInfo_User *user, } } - if (asJudge && !(user->user_level() & ServerInfo_User::IsJudge)) { + if (roleInfo.isJudge && !(user->user_level() & ServerInfo_User::IsJudge)) { return Response::RespUserLevelTooLow; } if (!(overrideRestrictions && (user->user_level() & (ServerInfo_User::IsModerator | ServerInfo_User::IsJudge)))) { - if ((_password != password) && !(spectator && !spectatorsNeedPassword)) { + if ((_password != password) && !(roleInfo.isSpectator && !spectatorsNeedPassword)) { return Response::RespWrongPassword; } if (!(user->user_level() & ServerInfo_User::IsRegistered) && onlyRegistered) { @@ -510,13 +510,13 @@ Response::ResponseCode Server_Game::checkJoin(ServerInfo_User *user, QString::fromStdString(user->name()))) { return Response::RespInIgnoreList; } - if (spectator) { + if (roleInfo.isSpectator) { if (!spectatorsAllowed) { return Response::RespSpectatorsNotAllowed; } } } - if (!spectator && (gameStarted || (getPlayerCount() >= getMaxPlayers()))) { + if (!roleInfo.isSpectator && (gameStarted || (getPlayerCount() >= getMaxPlayers()))) { return Response::RespGameFull; } @@ -537,19 +537,18 @@ bool Server_Game::containsUser(const QString &userName) const void Server_Game::addPlayer(Server_AbstractUserInterface *userInterface, ResponseContainer &rc, - bool spectator, - bool judge, + const RoleInfo &roleInfo, bool broadcastUpdate) { QMutexLocker locker(&gameMutex); Server_AbstractParticipant *newParticipant; - if (spectator) { + if (roleInfo.isSpectator) { newParticipant = new Server_Spectator(this, nextPlayerId++, userInterface->copyUserInfo(true, true, true), - judge, userInterface); + roleInfo.isJudge, userInterface); } else { - newParticipant = new Server_Player(this, nextPlayerId++, userInterface->copyUserInfo(true, true, true), judge, - userInterface); + newParticipant = new Server_Player(this, nextPlayerId++, userInterface->copyUserInfo(true, true, true), + roleInfo.isJudge, userInterface); } newParticipant->moveToThread(thread()); @@ -560,7 +559,7 @@ void Server_Game::addPlayer(Server_AbstractUserInterface *userInterface, const QString playerName = QString::fromStdString(newParticipant->getUserInfo()->name()); participants.insert(newParticipant->getPlayerId(), newParticipant); - if (spectator) { + if (roleInfo.isSpectator) { allSpectatorsEver.insert(playerName); } else { allPlayersEver.insert(playerName); @@ -579,7 +578,7 @@ void Server_Game::addPlayer(Server_AbstractUserInterface *userInterface, emit gameInfoChanged(gameInfo); } - if ((newParticipant->getUserInfo()->user_level() & ServerInfo_User::IsRegistered) && !spectator) { + if ((newParticipant->getUserInfo()->user_level() & ServerInfo_User::IsRegistered) && !roleInfo.isSpectator) { room->getServer()->addPersistentPlayer(playerName, room->getId(), gameId, newParticipant->getPlayerId()); } @@ -871,6 +870,13 @@ void Server_Game::createGameJoinedEvent(Server_AbstractParticipant *joiningParti } } +bool Server_Game::canSeePrivateEventsFor(const Server_AbstractParticipant *participant, int privatePlayerId) const +{ + return participant->getPlayerId() == privatePlayerId // same player + || participant->isJudge() // judges see everything + || (participant->isSpectator() && spectatorsSeeEverything); // spectators see everything +} + void Server_Game::sendGameEventContainer(GameEventContainer *cont, GameEventStorageItem::EventRecipients recipients, int privatePlayerId) @@ -879,8 +885,7 @@ void Server_Game::sendGameEventContainer(GameEventContainer *cont, cont->set_game_id(gameId); for (auto *participant : participants.values()) { - const bool playerPrivate = (participant->getPlayerId() == privatePlayerId) || participant->isJudge() || - (participant->isSpectator() && spectatorsSeeEverything); + const bool playerPrivate = canSeePrivateEventsFor(participant, privatePlayerId); if ((recipients.testFlag(GameEventStorageItem::SendToPrivate) && playerPrivate) || (recipients.testFlag(GameEventStorageItem::SendToOthers) && !playerPrivate)) { participant->sendGameEvent(*cont); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h index c7f8d0929..363c0ca94 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h @@ -62,6 +62,7 @@ class Message; } // namespace protobuf } // namespace google struct GameConfig; +struct RoleInfo; class Server_Game : public QObject { @@ -109,6 +110,7 @@ private: bool omniscient, bool withUserInfo); void storeGameInformation(); + bool canSeePrivateEventsFor(const Server_AbstractParticipant *participant, int privatePlayerId) const; signals: void sigStartGameIfReady(bool override); void gameInfoChanged(ServerInfo_Game gameInfo); @@ -188,12 +190,11 @@ public: return shareDecklistsOnLoad; } Response::ResponseCode - checkJoin(ServerInfo_User *user, const QString &_password, bool spectator, bool overrideRestrictions, bool asJudge); + checkJoin(ServerInfo_User *user, const QString &_password, const RoleInfo &roleInfo, bool overrideRestrictions); bool containsUser(const QString &userName) const; void addPlayer(Server_AbstractUserInterface *userInterface, ResponseContainer &rc, - bool spectator, - bool judge, + const RoleInfo &roleInfo, bool broadcastUpdate = true); void removeParticipant(Server_AbstractParticipant *participant, Event_Leave::LeaveReason reason); void removeArrowsRelatedToPlayer(GameEventStorage &ges, Server_AbstractPlayer *player); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.cpp index 949d04b4b..b40dae09b 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.cpp @@ -573,7 +573,7 @@ Server_Player::cmdNextTurn(const Command_NextTurn & /*cmd*/, ResponseContainer & return Response::RespGameNotStarted; } - if (conceded && !judge) { + if (conceded && !isJudge()) { return Response::RespContextError; } @@ -589,7 +589,7 @@ Response::ResponseCode Server_Player::cmdSetActivePhase(const Command_SetActiveP return Response::RespGameNotStarted; } - if (!judge) { + if (!isJudge()) { if (conceded) { return Response::RespContextError; } @@ -623,7 +623,7 @@ Response::ResponseCode Server_Player::cmdReverseTurn(const Command_ReverseTurn &cmd, ResponseContainer &rc, GameEventStorage &ges) { - if (!judge && conceded) { + if (!isJudge() && conceded) { return Response::RespContextError; } return Server_AbstractParticipant::cmdReverseTurn(cmd, rc, ges); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_spectator.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_spectator.cpp index d1c369949..164a4af5e 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_spectator.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_spectator.cpp @@ -11,7 +11,6 @@ Server_Spectator::Server_Spectator(Server_Game *_game, const ServerInfo_User &_userInfo, bool _judge, Server_AbstractUserInterface *_userInterface) - : Server_AbstractParticipant(_game, _playerId, _userInfo, _judge, _userInterface) + : Server_AbstractParticipant(_game, _playerId, _userInfo, {.isJudge = _judge, .isSpectator = true}, _userInterface) { - spectator = true; } diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server.cpp index 63989bbfa..8ea2cd993 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server.cpp @@ -131,6 +131,16 @@ AuthenticationResult Server::loginUser(Server_ProtocolHandler *session, return authState; } + if (authState == PasswordChangeRequired) { + // The credentials were right, but the account must choose a new password before it may + // use the server. Give the session an identity so it can complete that change, without + // registering a user session or announcing the user as logged in. + ServerInfo_User pendingData = databaseInterface->getUserData(name, true); + pendingData.set_address(session->getAddress().toStdString()); + session->setUserInfo(pendingData); + return authState; + } + ServerInfo_User data = databaseInterface->getUserData(name, true); data.set_address(session->getAddress().toStdString()); name = QString::fromStdString(data.name()); // Compensate for case indifference @@ -296,7 +306,9 @@ void Server::removeClient(Server_ProtocolHandler *client) QWriteLocker locker(&clientsLock); clients.removeAt(clientIndex); ServerInfo_User *data = client->getUserInfo(); - if (data) { + // A session that never completed login (for example one that still owes a password change) + // has no session id: it was never announced as joined, so it must not be announced as leaving. + if (data && data->has_session_id()) { Event_UserLeft event; event.set_name(data->name()); SessionEvent *se = Server_ProtocolHandler::prepareSessionEvent(event); @@ -311,12 +323,10 @@ void Server::removeClient(Server_ProtocolHandler *client) users.remove(QString::fromStdString(data->name())); qDebug() << "Server::removeClient: name=" << QString::fromStdString(data->name()); - if (data->has_session_id()) { - const qint64 sessionId = data->session_id(); - usersBySessionId.remove(sessionId); - emit endSession(sessionId); - qDebug() << "closed session id:" << sessionId; - } + const qint64 sessionId = data->session_id(); + usersBySessionId.remove(sessionId); + emit endSession(sessionId); + qDebug() << "closed session id:" << sessionId; } qDebug() << "Server::removeClient: removed" << (void *)client << ";" << clients.size() << "clients; " << users.size() << "users left"; @@ -330,7 +340,9 @@ QList Server::getOnlineModeratorList() const ServerInfo_User *data = client->getUserInfo(); //! \todo This line should be updated in the event there is any type of new user level created. - if (data && + // A session that still owes a forced password change has user info but no session id: + // it never joined, so it must not be reported as an online moderator either. + if (data && data->has_session_id() && (data->user_level() & ServerInfo_User::IsModerator || data->user_level() & ServerInfo_User::IsAdmin)) { results << QString::fromStdString(data->name()).simplified(); } 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..f9cadc9aa 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp @@ -64,8 +64,9 @@ Server_ProtocolHandler::Server_ProtocolHandler(Server *_server, Server_DatabaseInterface *_databaseInterface, QObject *parent) : QObject(parent), Server_AbstractUserInterface(_server), deleted(false), databaseInterface(_databaseInterface), - authState(NotLoggedIn), usingRealPassword(false), acceptsUserListChanges(false), acceptsRoomListChanges(false), - idleClientWarningSent(false), timeRunning(0), lastDataReceived(0), lastActionReceived(0) + authState(NotLoggedIn), usingRealPassword(false), passwordChangePending(false), acceptsUserListChanges(false), + acceptsRoomListChanges(false), idleClientWarningSent(false), timeRunning(0), lastDataReceived(0), + lastActionReceived(0) { connect(server, &Server::pingClockTimeout, this, &Server_ProtocolHandler::pingClockTimeout); @@ -208,11 +209,14 @@ 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); - if (authState == NotLoggedIn && !isPreAuthSessionCommand(commandType)) { + if (passwordChangePending && commandType != SessionCommand::PING && + commandType != SessionCommand::ACCOUNT_PASSWORD) { + resp = Response::RespFunctionNotAllowed; + } else if (authState == NotLoggedIn && !isPreAuthSessionCommand(commandType)) { resp = Response::RespLoginNeeded; } else { switch (commandType) { @@ -271,7 +275,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 +326,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 +343,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 +363,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 +414,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 +443,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 +472,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) { @@ -480,6 +493,19 @@ void Server_ProtocolHandler::processCommandContainer(const CommandContainer &con lastDataReceived = timeRunning; + // A session that still owes a forced password change may only reach the session family, + // where processSessionCommandContainer narrows it further to ping and the change itself. + // Every other family is rejected here, including a container that also carries a session + // command: the dispatch below prefers game/room over session, so testing only for the + // absence of a session command would let such a container through. + if (passwordChangePending && + (cont.game_command_size() || cont.room_command_size() || cont.moderator_command_size() || + cont.admin_command_size() || cont.developer_command_size())) { + sendResponseContainer(ResponseContainer(cont.has_cmd_id() ? cont.cmd_id() : -1), + Response::RespFunctionNotAllowed); + return; + } + ResponseContainer responseContainer(cont.has_cmd_id() ? cont.cmd_id() : -1); Response::ResponseCode finalResponseCode; @@ -687,6 +713,11 @@ Response::ResponseCode Server_ProtocolHandler::cmdLogin(const Command_Login &cmd server->recordFailedLogin(getAddress(), userName); return Response::RespAccountNotActivated; case PasswordChangeRequired: + // The credentials were right, but the account must choose a new password first. + // Keep the session authenticated just far enough for the client to do that: + // processCommandContainer refuses everything except the change itself. + authState = PasswordRight; + passwordChangePending = true; return Response::RespPasswordChangeRequired; default: server->clearFailedLogins(getAddress(), userName); @@ -1110,7 +1141,8 @@ Server_ProtocolHandler::cmdCreateGame(const Command_CreateGame &cmd, Server_Room auto *game = new Server_Game(config, room); - game->addPlayer(this, rc, asSpectator, asJudge, false); + RoleInfo roleInfo{.isJudge = asJudge, .isSpectator = asSpectator}; + game->addPlayer(this, rc, roleInfo, false); room->addGame(game); return 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..8ce110354 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h @@ -46,6 +46,12 @@ protected: Server_DatabaseInterface *databaseInterface; AuthenticationResult authState; bool usingRealPassword; + /** + * @brief True when the credentials were right but the account must choose a new password + * before it may use the server. Only ping, the password change and disconnecting + * are served while this is set. + */ + bool passwordChangePending; bool acceptsUserListChanges; bool acceptsRoomListChanges; bool idleClientWarningSent; @@ -55,6 +61,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_network/libcockatrice/network/server/remote/server_room.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server_room.cpp index 68b137cd4..78ef2b289 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_room.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_room.cpp @@ -1,5 +1,6 @@ #include "server_room.h" +#include "game/role_info.h" #include "game/server_game.h" #include "libcockatrice/protocol/pb/response.pb.h" #include "libcockatrice/protocol/pb/room_event.pb.h" @@ -372,11 +373,11 @@ Response::ResponseCode Server_Room::processJoinGameCommand(const Command_JoinGam QMutexLocker gameLocker(&game->gameMutex); - Response::ResponseCode result = - game->checkJoin(userInterface->getUserInfo(), QString::fromStdString(cmd.password()), cmd.spectator(), - cmd.override_restrictions(), cmd.join_as_judge()); + RoleInfo roleInfo{.isJudge = cmd.join_as_judge(), .isSpectator = cmd.spectator()}; + Response::ResponseCode result = game->checkJoin( + userInterface->getUserInfo(), QString::fromStdString(cmd.password()), roleInfo, cmd.override_restrictions()); if (result == Response::RespOk) { - game->addPlayer(userInterface, rc, cmd.spectator(), cmd.join_as_judge()); + game->addPlayer(userInterface, rc, roleInfo); } return result; 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..04cb31d98 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; @@ -85,14 +87,16 @@ message Command_ViewLogHistory { extend DeveloperCommand { optional Command_ViewLogHistory dev_ext = 1001; } - optional string user_name = 1; // user that created message - optional string ip_address = 2; // ip address of user that created message - optional string game_name = 3; // client id of user that created the message - optional string game_id = 4; // game number the message was sent to - optional string message = 5; // raw message that was sent - repeated string log_location = 6; // destination of message (ex: main room, game room, private chat) - required uint32 date_range = 7; // the length of time (in minutes) to look back for - optional uint32 maximum_results = 8; // the maximum number of query results + optional string user_name = 1; // user that created message + optional string ip_address = 2; // ip address of user that created message + optional string game_name = 3; // client id of user that created the message + optional string game_id = 4; // game number the message was sent to + optional string message = 5; // raw message that was sent + repeated string log_location = 6; // destination of message (ex: main room, game room, private chat) + required uint32 date_range = 7; // the length of time (in minutes) to look back for + optional uint32 maximum_results = 8; // the maximum number of query results (page size when paginating) + optional uint32 offset = 9 [default = 0]; // rows to skip for pagination, ORDER BY log_time DESC + optional string before_time = 10; // page those rows against this `log_time` snapshot (newest first page row) } message Command_GrantReplayAccess { @@ -179,3 +183,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_protocol/libcockatrice/protocol/pb/response_viewlog_history.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_viewlog_history.proto index 2ec0aff28..1041bca74 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/response_viewlog_history.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_viewlog_history.proto @@ -7,4 +7,5 @@ message Response_ViewLogHistory { optional Response_ViewLogHistory ext = 1015; } repeated ServerInfo_ChatMessage log_message = 1; + optional bool has_more = 2 [default = false]; // true when more rows exist past this page } diff --git a/libcockatrice_rng/CMakeLists.txt b/libcockatrice_rng/CMakeLists.txt index 6128e3f6e..77165122c 100644 --- a/libcockatrice_rng/CMakeLists.txt +++ b/libcockatrice_rng/CMakeLists.txt @@ -1,13 +1,7 @@ -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -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..a3b9a68db 100644 --- a/libcockatrice_settings/CMakeLists.txt +++ b/libcockatrice_settings/CMakeLists.txt @@ -1,7 +1,3 @@ -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - set(HEADERS libcockatrice/settings/appearance_settings.h libcockatrice/settings/cache_storage_settings.h @@ -31,11 +27,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/servers_settings.cpp b/libcockatrice_settings/libcockatrice/settings/servers_settings.cpp index 4595da2a2..38b87b97c 100644 --- a/libcockatrice_settings/libcockatrice/settings/servers_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/servers_settings.cpp @@ -5,16 +5,118 @@ #include #include #include -#include +#include +#include #include -#include +#include +#include #include -class QObject; +namespace +{ +constexpr auto SERVER_GROUP = "server"; +constexpr auto SERVER_DETAILS_GROUP = "server_details"; +constexpr auto SERVERS_ARRAY = "servers"; + +QString legacyKey(const char *field, int index) +{ + return QStringLiteral("%1/%2%3") + .arg(QString::fromLatin1(SERVER_DETAILS_GROUP), QString::fromLatin1(field)) + .arg(index); +} + +//! Expects \a settings to be positioned on the server group. +QList readServerArray(QSettings &settings) +{ + QList servers; + + settings.beginGroup(SERVER_DETAILS_GROUP); + const int size = settings.beginReadArray(SERVERS_ARRAY); + servers.reserve(size); + for (int i = 0; i < size; ++i) { + settings.setArrayIndex(i); + ServersSettings::ServerEntry entry; + entry.saveName = settings.value("saveName").toString(); + entry.host = settings.value("server").toString(); + entry.port = settings.value("port").toString(); + entry.username = settings.value("username").toString(); + entry.password = settings.value("password").toString(); + entry.savePassword = settings.value("savePassword").toBool(); + entry.site = settings.value("site").toString(); + servers.append(entry); + } + settings.endArray(); + settings.endGroup(); + + return servers; +} + +//! Expects \a settings to be positioned on the server group. +QList readLegacyServers(QSettings &settings, int lastIndex) +{ + QList servers; + + for (int i = 0; i <= lastIndex; ++i) { + ServersSettings::ServerEntry entry; + entry.saveName = settings.value(legacyKey("saveName", i)).toString(); + entry.host = settings.value(legacyKey("server", i)).toString(); + entry.port = settings.value(legacyKey("port", i)).toString(); + entry.username = settings.value(legacyKey("username", i)).toString(); + entry.password = settings.value(legacyKey("password", i)).toString(); + entry.savePassword = settings.value(legacyKey("savePassword", i)).toBool(); + entry.site = settings.value(legacyKey("site", i)).toString(); + + // Older builds could leave the unused index 0 behind. + if (!entry.saveName.isEmpty() || !entry.host.isEmpty()) { + servers.append(entry); + } + } + + return servers; +} + +void writeServerArray(QSettings &settings, const QList &servers) +{ + settings.beginGroup(SERVER_DETAILS_GROUP); + // beginWriteArray() never drops entries past the new size, so without this + // a removed profile would keep its slot - including its saved password - + // in servers.ini forever. The legacy keys live beside the array, not in it. + settings.remove(SERVERS_ARRAY); + settings.beginWriteArray(SERVERS_ARRAY, servers.size()); + for (int i = 0; i < servers.size(); ++i) { + settings.setArrayIndex(i); + const ServersSettings::ServerEntry &entry = servers.at(i); + settings.setValue("saveName", entry.saveName); + settings.setValue("server", entry.host); + settings.setValue("port", entry.port); + settings.setValue("username", entry.username); + settings.setValue("password", entry.password); + settings.setValue("savePassword", entry.savePassword); + settings.setValue("site", entry.site); + } + settings.endArray(); + settings.endGroup(); +} +} // namespace ServersSettings::ServersSettings(const QString &settingPath, QObject *parent) - : SettingsManager(settingPath + "servers.ini", "server", QString(), parent) + : SettingsManager(settingPath + "servers.ini", SERVER_GROUP, QString(), parent) { + migrateLegacyServers(); +} + +QList ServersSettings::getServers() const +{ + return loadServers(); +} + +std::optional ServersSettings::getServer(int index) const +{ + const QList servers = loadServers(); + if (index < 0 || index >= servers.size()) { + return std::nullopt; + } + return servers.at(index); } void ServersSettings::setPreviousHostLogin(int previous) @@ -45,16 +147,14 @@ void ServersSettings::setPrevioushostName(const QString &name) QString ServersSettings::getSaveName(QString defaultname) { - int index = getPrevioushostindex(getPrevioushostName()); - QVariant saveName = getValue(QString("saveName%1").arg(index), "server", "server_details"); - return saveName == QVariant() ? std::move(defaultname) : saveName.toString(); + const auto server = getServer(getPrevioushostindex(getPrevioushostName())); + return server ? server->saveName : std::move(defaultname); } QString ServersSettings::getSite(QString defaultSite) { - int index = getPrevioushostindex(getPrevioushostName()); - QVariant site = getValue(QString("site%1").arg(index), "server", "server_details"); - return site == QVariant() ? std::move(defaultSite) : site.toString(); + const auto server = getServer(getPrevioushostindex(getPrevioushostName())); + return server ? server->site : std::move(defaultSite); } QString ServersSettings::getPrevioushostName() const @@ -65,10 +165,9 @@ QString ServersSettings::getPrevioushostName() const int ServersSettings::getPrevioushostindex(const QString &saveName) const { - int size = getValue("totalServers", "server", "server_details").toInt(); - - for (int i = 0; i <= size; ++i) { - if (saveName == getValue(QString("saveName%1").arg(i), "server", "server_details").toString()) { + const QList servers = loadServers(); + for (int i = 0; i < servers.size(); ++i) { + if (servers.at(i).saveName == saveName) { return i; } } @@ -78,43 +177,38 @@ int ServersSettings::getPrevioushostindex(const QString &saveName) const QString ServersSettings::getHostname(QString defaultHost) const { - int index = getPrevioushostindex(getPrevioushostName()); - QVariant hostname = getValue(QString("server%1").arg(index), "server", "server_details"); - return hostname == QVariant() ? std::move(defaultHost) : hostname.toString(); + const auto server = getServer(getPrevioushostindex(getPrevioushostName())); + return server ? server->host : std::move(defaultHost); } QString ServersSettings::getPort(QString defaultPort) const { - int index = getPrevioushostindex(getPrevioushostName()); - QVariant port = getValue(QString("port%1").arg(index), "server", "server_details"); - qCDebug(ServersSettingsLog) << "getPort() index = " << index << " port.val = " << port.toString(); - return port == QVariant() ? std::move(defaultPort) : port.toString(); + const auto server = getServer(getPrevioushostindex(getPrevioushostName())); + qCDebug(ServersSettingsLog) << "getPort() found server = " << server.has_value(); + return server ? server->port : std::move(defaultPort); } QString ServersSettings::getPlayerName(QString defaultName) const { - int index = getPrevioushostindex(getPrevioushostName()); - QVariant name = getValue(QString("username%1").arg(index), "server", "server_details"); - qCDebug(ServersSettingsLog) << "getPlayerName() index = " << index << " name.val = " << name.toString(); - return name == QVariant() ? std::move(defaultName) : name.toString(); + const auto server = getServer(getPrevioushostindex(getPrevioushostName())); + qCDebug(ServersSettingsLog) << "getPlayerName() found server = " << server.has_value(); + return server ? server->username : std::move(defaultName); } QString ServersSettings::getPassword() { - int index = getPrevioushostindex(getPrevioushostName()); - - if (getSavePassword()) { - return getValue(QString("password%1").arg(index), "server", "server_details").toString(); + const auto server = getServer(getPrevioushostindex(getPrevioushostName())); + if (!server || !server->savePassword) { + return QString(); } - return QString(); + return server->password; } bool ServersSettings::getSavePassword() const { - int index = getPrevioushostindex(getPrevioushostName()); - bool save = getValue(QString("savePassword%1").arg(index), "server", "server_details").toBool(); - return save; + const auto server = getServer(getPrevioushostindex(getPrevioushostName())); + return server && server->savePassword; } void ServersSettings::setAutoConnect(int autoconnect) @@ -157,15 +251,19 @@ void ServersSettings::setFPPlayerName(QString playerName) void ServersSettings::setServerPassword(const QString &saveName, const QString &password) { - const int index = getPrevioushostindex(saveName); - if (index >= 0) { - setValue(password, QString("password%1").arg(index), "server", "server_details"); - } else { - // A credential write that silently no-ops is hard to diagnose from a - // bug report; surface a mismatched profile name instead. - qCWarning(ServersSettingsLog) << "setServerPassword() could not find profile:" << saveName - << "- password not saved"; + QList servers = loadServers(); + for (ServerEntry &server : servers) { + if (server.saveName == saveName) { + server.password = password; + storeServers(servers); + return; + } } + + // A credential write that silently no-ops is hard to diagnose from a bug + // report; surface a mismatched profile name instead. + qCWarning(ServersSettingsLog) << "setServerPassword() could not find profile:" << saveName + << "- password not saved"; } QString ServersSettings::getFPPlayerName(QString defaultName) const @@ -202,62 +300,29 @@ void ServersSettings::addNewServer(const QString &saveName, return; } - int index = getValue("totalServers", "server", "server_details").toInt() + 1; - - setValue(saveName, QString("saveName%1").arg(index), "server", "server_details"); - setValue(serv, QString("server%1").arg(index), "server", "server_details"); - setValue(port, QString("port%1").arg(index), "server", "server_details"); - setValue(username, QString("username%1").arg(index), "server", "server_details"); - setValue(savePassword, QString("savePassword%1").arg(index), "server", "server_details"); - setValue(index, "totalServers", "server", "server_details"); - setValue(password, QString("password%1").arg(index), "server", "server_details"); - setValue(site, QString("site%1").arg(index), "server", "server_details"); + QList servers = loadServers(); + ServerEntry entry; + entry.saveName = saveName; + entry.host = serv; + entry.port = port; + entry.username = username; + entry.password = password; + entry.savePassword = savePassword; + entry.site = site; + servers.append(entry); + storeServers(servers); } void ServersSettings::removeServer(QString servAddr) { - int size = getValue("totalServers", "server", "server_details").toInt(); - - bool found = false; - for (int i = 0; i <= size; ++i) { - if (!found) { - // find entry and overwrite it - if (servAddr == getValue(QString("server%1").arg(i), "server", "server_details").toString()) { - found = true; - } - } else { - // move all other entries after it one back, overwriting the previous one - int previous = i - 1; // we delete only one entry - setValue(getValue(QString("server%1").arg(i), "server", "server_details"), - QString("server%1").arg(previous), "server", "server_details"); - setValue(getValue(QString("port%1").arg(i), "server", "server_details"), QString("port%1").arg(previous), - "server", "server_details"); - setValue(getValue(QString("username%1").arg(i), "server", "server_details"), - QString("username%1").arg(previous), "server", "server_details"); - setValue(getValue(QString("savePassword%1").arg(i), "server", "server_details"), - QString("savePassword%1").arg(previous), "server", "server_details"); - setValue(getValue(QString("password%1").arg(i), "server", "server_details"), - QString("password%1").arg(previous), "server", "server_details"); - setValue(getValue(QString("saveName%1").arg(i), "server", "server_details"), - QString("saveName%1").arg(previous), "server", "server_details"); - setValue(getValue(QString("site%1").arg(i), "server", "server_details"), QString("site%1").arg(previous), - "server", "server_details"); + QList servers = loadServers(); + for (int i = 0; i < servers.size(); ++i) { + if (servers.at(i).host == servAddr) { + servers.removeAt(i); + storeServers(servers); + return; } } - - // if we have deleted an entry, adjust the total - if (found) { - setValue(size - 1, "totalServers", "server", "server_details"); - - // delete last value - deleteValue(QString("server%1").arg(size), "server", "server_details"); - deleteValue(QString("port%1").arg(size), "server", "server_details"); - deleteValue(QString("username%1").arg(size), "server", "server_details"); - deleteValue(QString("savePassword%1").arg(size), "server", "server_details"); - deleteValue(QString("password%1").arg(size), "server", "server_details"); - deleteValue(QString("saveName%1").arg(size), "server", "server_details"); - deleteValue(QString("site%1").arg(size), "server", "server_details"); - } } /** @@ -265,23 +330,23 @@ void ServersSettings::removeServer(QString servAddr) */ bool ServersSettings::updateExistingServerWithoutLoss(QString saveName, QString serv, QString port, QString site) { - int size = getValue("totalServers", "server", "server_details").toInt(); - - for (int i = 0; i <= size; ++i) { - if (serv == getValue(QString("server%1").arg(i), "server", "server_details").toString()) { - if (!port.isEmpty()) { - setValue(port, QString("port%1").arg(i), "server", "server_details"); - } - - if (!site.isEmpty()) { - setValue(site, QString("site%1").arg(i), "server", "server_details"); - } - - setValue(saveName, QString("saveName%1").arg(i), "server", "server_details"); - - return true; + QList servers = loadServers(); + for (ServerEntry &server : servers) { + if (server.host != serv) { + continue; } + + if (!port.isEmpty()) { + server.port = port; + } + if (!site.isEmpty()) { + server.site = site; + } + server.saveName = saveName; + storeServers(servers); + return true; } + return false; } @@ -293,17 +358,13 @@ bool ServersSettings::updateExistingServer(QString saveName, bool savePassword, QString site) { - int size = getValue("totalServers", "server", "server_details").toInt(); - - for (int i = 0; i <= size; ++i) { - if (serv == getValue(QString("server%1").arg(i), "server", "server_details").toString()) { - setValue(port, QString("port%1").arg(i), "server", "server_details"); - updateServerFields(i, saveName, username, password, savePassword, site); - - return true; - } + const int index = findServerIndex(serv, port); + if (index < 0) { + return false; } - return false; + + updateServerFields(index, saveName, username, password, savePassword, site); + return true; } void ServersSettings::updateServerFields(int index, @@ -313,33 +374,33 @@ void ServersSettings::updateServerFields(int index, bool savePassword, const QString &site) { + QList servers = loadServers(); + if (index < 0 || index >= servers.size()) { + return; + } + + ServerEntry &server = servers[index]; if (!username.isEmpty()) { - setValue(username, QString("username%1").arg(index), "server", "server_details"); + server.username = username; } - if (savePassword && !password.isEmpty()) { - setValue(password, QString("password%1").arg(index), "server", "server_details"); + server.password = password; } else { - setValue(QString(), QString("password%1").arg(index), "server", "server_details"); + server.password.clear(); } - if (!site.isEmpty()) { - setValue(site, QString("site%1").arg(index), "server", "server_details"); + server.site = site; } - - setValue(savePassword, QString("savePassword%1").arg(index), "server", "server_details"); - setValue(saveName, QString("saveName%1").arg(index), "server", "server_details"); + server.savePassword = savePassword; + server.saveName = saveName; + storeServers(servers); } int ServersSettings::findServerIndex(const QString &host, const QString &port) const { - int size = getValue("totalServers", "server", "server_details").toInt(); - - for (int i = 0; i <= size; ++i) { - QString storedHost = getValue(QString("server%1").arg(i), "server", "server_details").toString(); - QString storedPort = getValue(QString("port%1").arg(i), "server", "server_details").toString(); - - if (storedHost == host && storedPort == port) { + const QList servers = loadServers(); + for (int i = 0; i < servers.size(); ++i) { + if (servers.at(i).host == host && servers.at(i).port == port) { return i; } } @@ -349,12 +410,9 @@ int ServersSettings::findServerIndex(const QString &host, const QString &port) c int ServersSettings::findHostIndex(const QString &host) const { - int size = getValue("totalServers", "server", "server_details").toInt(); - - for (int i = 0; i <= size; ++i) { - QString storedHost = getValue(QString("server%1").arg(i), "server", "server_details").toString(); - - if (storedHost.compare(host, Qt::CaseInsensitive) == 0) { + const QList servers = loadServers(); + for (int i = 0; i < servers.size(); ++i) { + if (servers.at(i).host.compare(host, Qt::CaseInsensitive) == 0) { return i; } } @@ -364,29 +422,100 @@ int ServersSettings::findHostIndex(const QString &host) const bool ServersSettings::hasUsername(const QString &host, const QString &port) const { - int index = findServerIndex(host, port); - if (index < 0) { - return false; - } - - QString user = getValue(QString("username%1").arg(index), "server", "server_details").toString(); - return !user.isEmpty(); + const auto server = getServer(findServerIndex(host, port)); + return server && !server->username.isEmpty(); } bool ServersSettings::hasCredentials(const QString &host, const QString &port) const { - int index = findServerIndex(host, port); - if (index < 0) { - return false; - } - - bool save = getValue(QString("savePassword%1").arg(index), "server", "server_details").toBool(); - QString password = getValue(QString("password%1").arg(index), "server", "server_details").toString(); - - return save && !password.isEmpty(); + const auto server = getServer(findServerIndex(host, port)); + return server && server->savePassword && !server->password.isEmpty(); } bool ServersSettings::hasLoginData(const QString &host, const QString &port) const { return hasUsername(host, port) && hasCredentials(host, port); } + +QList ServersSettings::loadServers() const +{ + QSettings settings = getSettings(); + settings.beginGroup(SERVER_GROUP); + const QList servers = readServerArray(settings); + settings.endGroup(); + + return servers; +} + +void ServersSettings::storeServers(const QList &servers) +{ + batchWrite([&servers](QSettings &settings) { + settings.beginGroup(SERVER_GROUP); + writeServerArray(settings, servers); + settings.endGroup(); + }); +} + +void ServersSettings::migrateLegacyServers() +{ + { + QSettings settings = getSettings(); + settings.beginGroup(SERVER_GROUP); + // Only builds that predate the array format write this marker. Once it + // is gone there is nothing left to import and no reason to touch the + // file (a sync on every start is a needless open-and-write). + const bool hasLegacy = settings.contains(QStringLiteral("%1/totalServers").arg(SERVER_DETAILS_GROUP)); + settings.endGroup(); + + if (!hasLegacy) { + return; + } + } + + const QSettings::Status status = batchWrite([](QSettings &settings) { + settings.beginGroup(SERVER_GROUP); + + const int lastLegacyIndex = + settings.value(QStringLiteral("%1/totalServers").arg(SERVER_DETAILS_GROUP), -1).toInt(); + + QList servers = readServerArray(settings); + + // An older build can write the legacy keys again after the array + // migration, so the array is not automatically complete: append every + // legacy profile the array does not already hold instead of dropping it. + int imported = 0; + const QList legacyServers = readLegacyServers(settings, lastLegacyIndex); + for (const ServersSettings::ServerEntry &entry : legacyServers) { + const bool known = + std::any_of(servers.cbegin(), servers.cend(), [&entry](const ServersSettings::ServerEntry &existing) { + return existing.host == entry.host && existing.port == entry.port; + }); + if (!known) { + servers.append(entry); + ++imported; + } + } + + writeServerArray(settings, servers); + if (imported > 0) { + qCDebug(ServersSettingsLog) << "Migrated" << imported << "server profiles to the array format"; + } + + // The array (freshly written or already present) is authoritative now. + for (int i = 0; i <= lastLegacyIndex; ++i) { + settings.remove(legacyKey("saveName", i)); + settings.remove(legacyKey("server", i)); + settings.remove(legacyKey("port", i)); + settings.remove(legacyKey("username", i)); + settings.remove(legacyKey("password", i)); + settings.remove(legacyKey("savePassword", i)); + settings.remove(legacyKey("site", i)); + } + settings.remove(QStringLiteral("%1/totalServers").arg(SERVER_DETAILS_GROUP)); + settings.endGroup(); + }); + + if (status != QSettings::NoError) { + qCWarning(ServersSettingsLog) << "Failed to migrate server profiles:" << status; + } +} diff --git a/libcockatrice_settings/libcockatrice/settings/servers_settings.h b/libcockatrice_settings/libcockatrice/settings/servers_settings.h index 38bb02876..b403b1cea 100644 --- a/libcockatrice_settings/libcockatrice/settings/servers_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/servers_settings.h @@ -9,9 +9,11 @@ #include "settings_manager.h" +#include #include #include #include +#include #include class QObject; @@ -27,6 +29,22 @@ class ServersSettings : public SettingsManager friend class SettingsCache; public: + struct ServerEntry + { + QString saveName; + QString host; + QString port; + QString username; + QString password; + bool savePassword = false; + QString site; + }; + + explicit ServersSettings(const QString &settingPath, QObject *parent = nullptr); + + QList getServers() const; + std::optional getServer(int index) const; + int getPreviousHostLogin() const; int getPrevioushostindex(const QString &) const; QStringList getPreviousHostList() const; @@ -88,8 +106,11 @@ public: bool getClearDebugLogStatus(bool abDefaultValue) const; private: - explicit ServersSettings(const QString &settingPath, QObject *parent = nullptr); ServersSettings(const ServersSettings & /*other*/); + + QList loadServers() const; + void storeServers(const QList &servers); + void migrateLegacyServers(); }; #endif // SERVERSSETTINGS_H diff --git a/libcockatrice_settings/libcockatrice/settings/settings_manager.cpp b/libcockatrice_settings/libcockatrice/settings/settings_manager.cpp index f98778ca0..7cc1fd7be 100644 --- a/libcockatrice_settings/libcockatrice/settings/settings_manager.cpp +++ b/libcockatrice_settings/libcockatrice/settings/settings_manager.cpp @@ -193,13 +193,12 @@ QVariant SettingsManager::getValue(const QString &name, return value; } -void SettingsManager::batchWrite(std::function batchWriteFunction) +QSettings::Status SettingsManager::batchWrite(std::function batchWriteFunction) { auto settings = getSettings(); - settings.setAtomicSyncRequired(false); batchWriteFunction(settings); settings.sync(); // single flush - settings.setAtomicSyncRequired(true); + return settings.status(); } /** diff --git a/libcockatrice_settings/libcockatrice/settings/settings_manager.h b/libcockatrice_settings/libcockatrice/settings/settings_manager.h index 8d3110106..760d095e6 100644 --- a/libcockatrice_settings/libcockatrice/settings/settings_manager.h +++ b/libcockatrice_settings/libcockatrice/settings/settings_manager.h @@ -28,7 +28,9 @@ public: QVariant getValue(const QString &name, const QString &group, const QString &subGroup = QString()) const; QVariant getValue(const QString &name, const QString &group, const QString &subGroup, const QVariant &defaultValue) const; - void batchWrite(std::function batchWriteFunction); + //! \brief Runs \a batchWriteFunction on a temporary QSettings and syncs once. + //! \return the status of the underlying file after the final sync. + QSettings::Status batchWrite(std::function batchWriteFunction); void sync(); diff --git a/libcockatrice_utility/CMakeLists.txt b/libcockatrice_utility/CMakeLists.txt index 9b21a071f..98ad40e69 100644 --- a/libcockatrice_utility/CMakeLists.txt +++ b/libcockatrice_utility/CMakeLists.txt @@ -1,10 +1,6 @@ cmake_minimum_required(VERSION 3.16) project(Utility VERSION "${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}.${PROJECT_VERSION_PATCH}") -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTOUIC ON) -set(CMAKE_AUTORCC ON) - set(UTILITY_SOURCES libcockatrice/utility/cryptoutil.cpp libcockatrice/utility/expression.cpp @@ -12,6 +8,7 @@ set(UTILITY_SOURCES libcockatrice/utility/passwordhasher.cpp libcockatrice/utility/report_categories.cpp libcockatrice/utility/server_rate_limiter.cpp + libcockatrice/utility/svg_recolor.cpp libcockatrice/utility/translation_loader.cpp libcockatrice/utility/user_data_backup.cpp libcockatrice/utility/warning_categories.cpp @@ -21,12 +18,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 @@ -35,6 +35,7 @@ set(UTILITY_HEADERS libcockatrice/utility/zone_names.h libcockatrice/utility/days_years_between.h libcockatrice/utility/server_rate_limiter.h + libcockatrice/utility/svg_recolor.h libcockatrice/utility/translation_loader.h libcockatrice/utility/user_data_backup.h libcockatrice/utility/warning_categories.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/passwordhasher.cpp b/libcockatrice_utility/libcockatrice/utility/passwordhasher.cpp index a7106aadc..8e876d5bc 100644 --- a/libcockatrice_utility/libcockatrice/utility/passwordhasher.cpp +++ b/libcockatrice_utility/libcockatrice/utility/passwordhasher.cpp @@ -62,9 +62,11 @@ QByteArray PasswordHasher::deriveKey(const QString &password, const QByteArray & { QByteArray key(SCRYPT_VERIFIER_LENGTH, '\0'); const QByteArray passwordUtf8 = password.toUtf8(); - // EVP_PBE_scrypt aborts unless maxmem covers the required working memory, - // which is roughly 128 * n * r bytes (plus the small Salsa20/8 block array). - const auto maxmem = static_cast(128) * n * r + static_cast(128) * r * p + 4096; + // EVP_PBE_scrypt aborts unless maxmem covers the required working memory: V is 128 * n * + // r bytes, B is 128 * r * p, and XY is 256 * r. Under-budgeting XY makes OpenSSL fail + // for r >= 17, which costParamsAreSane still accepts. + const auto maxmem = + static_cast(128) * n * r + static_cast(128) * r * p + static_cast(256) * r + 4096; if (EVP_PBE_scrypt(passwordUtf8.constData(), passwordUtf8.size(), reinterpret_cast(salt.constData()), salt.size(), n, r, p, maxmem, reinterpret_cast(key.data()), key.size()) != 1) { 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/svg_recolor.cpp b/libcockatrice_utility/libcockatrice/utility/svg_recolor.cpp new file mode 100644 index 000000000..041e289fe --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/svg_recolor.cpp @@ -0,0 +1,44 @@ +#include "svg_recolor.h" + +#include + +namespace +{ +constexpr QByteArrayView SENTINEL = "currentColor"; + +/// Case-insensitive byte search for the sentinel, starting at @p from. +/// QByteArray::indexOf() only matches case-sensitively for QByteArrayView, and +/// its char overload cannot match a word. +qsizetype findSentinel(const QByteArray &bytes, qsizetype from) +{ + // QByteArrayView::sliced() is a view, so the scan does not allocate a copy + // at every byte position (QByteArray::sliced() would). + const QByteArrayView view(bytes); + for (qsizetype i = from; i + SENTINEL.size() <= view.size(); ++i) { + if (view.sliced(i, SENTINEL.size()).compare(SENTINEL, Qt::CaseInsensitive) == 0) { + return i; + } + } + return -1; +} +} // namespace + +QByteArray resolveSvgColorSentinel(const QByteArray &svg, const QByteArray &color) +{ + // The sentinel and the replacement are both ASCII, so stay in bytes: a + // QString round trip would decode the whole document (and mangle bytes that + // are not valid UTF-8) even on the no-op path. + qsizetype pos = findSentinel(svg, 0); + if (pos < 0) { + return svg; + } + + QByteArray resolved = svg; + while (pos >= 0) { + resolved.replace(pos, SENTINEL.size(), color); + // Skip past the inserted color: a caller's color never contains the + // sentinel, and this keeps the scan linear. + pos = findSentinel(resolved, pos + color.size()); + } + return resolved; +} diff --git a/libcockatrice_utility/libcockatrice/utility/svg_recolor.h b/libcockatrice_utility/libcockatrice/utility/svg_recolor.h new file mode 100644 index 000000000..56d609b87 --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/svg_recolor.h @@ -0,0 +1,31 @@ +/** + * @file svg_recolor.h + * @ingroup Core + */ + +#ifndef SVG_RECOLOR_H +#define SVG_RECOLOR_H + +#include + +/** + * @brief Resolves the `currentColor` sentinel in authored SVG data. + * + * Qt's SVG renderer has no CSS cascade or ambient color context, so the + * sentinel is not resolved by Qt. Callers pass the concrete color they want + * (for example a QPalette role serialized to #RRGGBB) and receive SVG bytes + * that are safe to hand to QSvgRenderer. + * + * Matching is a plain, case-insensitive substring search, not a CSS parse: an + * identifier that merely starts with the sentinel (for example + * `id="currentColorStop"`) is rewritten too. That is acceptable for the assets + * this project authors, and keeps the resolver dependency-free. + * + * @param svg SVG source bytes, expected to be UTF-8. Invalid UTF-8 is preserved + * byte-for-byte, since the resolver never decodes the document. + * @param color Concrete SVG color (for example "#ff00ff"), as ASCII bytes. + * @return The recolored SVG source; the input is left untouched. + */ +QByteArray resolveSvgColorSentinel(const QByteArray &svg, const QByteArray &color); + +#endif // SVG_RECOLOR_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..202b7c75f 100644 --- a/oracle/CMakeLists.txt +++ b/oracle/CMakeLists.txt @@ -30,6 +30,7 @@ set(oracle_SOURCES ../cockatrice/src/client/network/update/client/release_channel.cpp ../cockatrice/src/interface/pixel_map_generator.cpp ../cockatrice/src/interface/theme_config.cpp + ../cockatrice/src/interface/theme_icon.cpp ../cockatrice/src/interface/theme_manager.cpp ${VERSION_STRING_CPP} ) @@ -63,7 +64,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 +109,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 +284,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 +294,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 +320,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/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/servatrice.ini.example b/servatrice/servatrice.ini.example index 021f5157f..ac291fd7f 100644 --- a/servatrice/servatrice.ini.example +++ b/servatrice/servatrice.ini.example @@ -455,9 +455,11 @@ authentication_strictness=mixed ; the scrypt verifier, so old clients can still log in during the window below without ; the database holding a fast-crackable hash in the clear. ; Generate with: openssl rand -hex 32 -; If unset, migrated accounts have no downgrade window: old clients must use a password -; reset to get back in. Rotating the key invalidates existing backups (they become -; unusable immediately; expired ones are still purged). +; If unset there is no downgrade path at all: a migrated account keeps only a scrypt +; verifier, and an administrator's password reset stores a verifier too, so pre-challenge +; clients cannot log in with the temporary password either. Set the key if you still serve +; them. Rotating the key invalidates existing backups (they become unusable immediately; +; expired ones are still purged). legacy_backup_key= ; Days a legacy password backup may go unused before it is purged from the database. diff --git a/servatrice/src/isl_interface.cpp b/servatrice/src/isl_interface.cpp index 8733c9a40..1898cd5fa 100644 --- a/servatrice/src/isl_interface.cpp +++ b/servatrice/src/isl_interface.cpp @@ -58,17 +58,16 @@ void IslInterface::sharedCtor(const QSslCertificate &cert, const QSslKey &privat socket->setLocalCertificate(cert); socket->setPrivateKey(privateKey); - connect(socket, SIGNAL(readyRead()), this, SLOT(readClient()), Qt::QueuedConnection); - connect(socket, SIGNAL(error(QAbstractSocket::SocketError)), this, - SLOT(catchSocketError(QAbstractSocket::SocketError))); - connect(this, SIGNAL(outputBufferChanged()), this, SLOT(flushOutputBuffer()), Qt::QueuedConnection); + connect(socket, &QSslSocket::readyRead, this, &IslInterface::readClient, Qt::QueuedConnection); + connect(socket, &QAbstractSocket::errorOccurred, this, &IslInterface::catchSocketError); + connect(this, &IslInterface::outputBufferChanged, this, &IslInterface::flushOutputBuffer, Qt::QueuedConnection); } 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..afe028f32 100644 --- a/servatrice/src/servatrice.cpp +++ b/servatrice/src/servatrice.cpp @@ -100,8 +100,10 @@ Servatrice_GameServer::Servatrice_GameServer(Servatrice *_server, newThread->start(); server->watchWorkerThread(newThread); - QMetaObject::invokeMethod(newDatabaseInterface, "initDatabase", Qt::BlockingQueuedConnection, - Q_ARG(QSqlDatabase, _sqlDatabase)); + QMetaObject::invokeMethod( + newDatabaseInterface, + [newDatabaseInterface, _sqlDatabase] { newDatabaseInterface->initDatabase(_sqlDatabase); }, + Qt::BlockingQueuedConnection); connectionPools.append(newPool); } @@ -125,9 +127,10 @@ void Servatrice_GameServer::incomingConnection(qintptr socketDescriptor) auto ssi = new TcpServerSocketInterface(server, pool->getDatabaseInterface()); ssi->moveToThread(pool->thread()); pool->addClient(); - connect(ssi, SIGNAL(destroyed()), pool, SLOT(removeClient())); + connect(ssi, &QObject::destroyed, pool, &Servatrice_ConnectionPool::removeClient); - QMetaObject::invokeMethod(ssi, "initConnection", Qt::QueuedConnection, Q_ARG(int, socketDescriptor)); + QMetaObject::invokeMethod( + ssi, [ssi, socketDescriptor] { ssi->initConnection(socketDescriptor); }, Qt::QueuedConnection); } Servatrice_ConnectionPool *Servatrice_GameServer::findLeastUsedConnectionPool() @@ -168,13 +171,15 @@ Servatrice_WebsocketGameServer::Servatrice_WebsocketGameServer(Servatrice *_serv newThread->start(); server->watchWorkerThread(newThread); - QMetaObject::invokeMethod(newDatabaseInterface, "initDatabase", Qt::BlockingQueuedConnection, - Q_ARG(QSqlDatabase, _sqlDatabase)); + QMetaObject::invokeMethod( + newDatabaseInterface, + [newDatabaseInterface, _sqlDatabase] { newDatabaseInterface->initDatabase(_sqlDatabase); }, + Qt::BlockingQueuedConnection); connectionPools.append(newPool); - - connect(this, SIGNAL(newConnection()), this, SLOT(onNewConnection())); } + + connect(this, &QWebSocketServer::newConnection, this, &Servatrice_WebsocketGameServer::onNewConnection); } Servatrice_WebsocketGameServer::~Servatrice_WebsocketGameServer() @@ -199,9 +204,10 @@ void Servatrice_WebsocketGameServer::onNewConnection() */ // ssi->moveToThread(pool->thread()); pool->addClient(); - connect(ssi, SIGNAL(destroyed()), pool, SLOT(removeClient())); + connect(ssi, &QObject::destroyed, pool, &Servatrice_ConnectionPool::removeClient); - QMetaObject::invokeMethod(ssi, "initConnection", Qt::QueuedConnection, Q_ARG(void *, nextPendingConnection())); + void *socket = nextPendingConnection(); + QMetaObject::invokeMethod(ssi, [ssi, socket] { ssi->initConnection(socket); }, Qt::QueuedConnection); } Servatrice_ConnectionPool *Servatrice_WebsocketGameServer::findLeastUsedConnectionPool() @@ -224,14 +230,14 @@ Servatrice_ConnectionPool *Servatrice_WebsocketGameServer::findLeastUsedConnecti void Servatrice_IslServer::incomingConnection(qintptr socketDescriptor) { auto thread = new QThread; - connect(thread, SIGNAL(finished()), thread, SLOT(deleteLater())); + connect(thread, &QThread::finished, thread, &QObject::deleteLater); auto interface = new IslInterface(static_cast(socketDescriptor), cert, privateKey, server); interface->moveToThread(thread); - connect(interface, SIGNAL(destroyed()), thread, SLOT(quit())); + connect(interface, &QObject::destroyed, thread, &QThread::quit); thread->start(); - QMetaObject::invokeMethod(interface, "initServer", Qt::QueuedConnection); + QMetaObject::invokeMethod(interface, [interface] { interface->initServer(); }, Qt::QueuedConnection); } Servatrice::Servatrice(QObject *parent) @@ -308,7 +314,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 +386,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(); @@ -584,15 +591,16 @@ bool Servatrice::initServer() auto *thread = new QThread; thread->setObjectName("isl_" + QString::number(prop.id)); - connect(thread, SIGNAL(finished()), thread, SLOT(deleteLater())); + connect(thread, &QThread::finished, thread, &QObject::deleteLater); IslInterface *interface = new IslInterface(prop.id, prop.hostname, prop.address.toString(), prop.controlPort, prop.cert, cert, key, this); interface->moveToThread(thread); - connect(interface, SIGNAL(destroyed()), thread, SLOT(quit())); + connect(interface, &QObject::destroyed, thread, &QThread::quit); thread->start(); - QMetaObject::invokeMethod(interface, "initClient", Qt::BlockingQueuedConnection); + QMetaObject::invokeMethod( + interface, [interface] { interface->initClient(); }, Qt::BlockingQueuedConnection); } qDebug() << "Starting ISL server on port" << getISLNetworkPort(); @@ -600,27 +608,27 @@ 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; } pingClock = new QTimer(this); - connect(pingClock, SIGNAL(timeout()), this, SIGNAL(pingClockTimeout())); + connect(pingClock, &QTimer::timeout, this, &Servatrice::pingClockTimeout); pingClock->start(getClientKeepAlive() * 1000); statusUpdateClock = new QTimer(this); - connect(statusUpdateClock, SIGNAL(timeout()), this, SLOT(statusUpdate())); + connect(statusUpdateClock, &QTimer::timeout, this, &Servatrice::statusUpdate); if (getServerStatusUpdateTime() != 0) { qDebug() << "Starting status update clock, interval" << getServerStatusUpdateTime() << "ms"; statusUpdateClock->start(getServerStatusUpdateTime()); } deckShareCleanupClock = new QTimer(this); - connect(deckShareCleanupClock, SIGNAL(timeout()), this, SLOT(cleanupExpiredDeckShares())); + connect(deckShareCleanupClock, &QTimer::timeout, this, &Servatrice::cleanupExpiredDeckShares); const int deckShareCleanupInterval = getDeckShareCleanupInterval(); if (deckShareCleanupInterval > 0) { qDebug() << "Starting deck share cleanup clock, interval" << deckShareCleanupInterval << "ms"; @@ -641,7 +649,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 +665,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; } } @@ -1036,7 +1045,7 @@ void Servatrice::scheduleShutdown(const QString &reason, int minutes) nextShutdownMessageMinutes = shutdownMinutes; if (minutes > 0) { shutdownTimer = new QTimer; - connect(shutdownTimer, SIGNAL(timeout()), this, SLOT(shutdownTimeout())); + connect(shutdownTimer, &QTimer::timeout, this, &Servatrice::shutdownTimeout); shutdownTimer->start(60000); } shutdownStateMutex.unlock(); @@ -1099,25 +1108,19 @@ void Servatrice::addIslInterface(int _serverId, IslInterface *interface) { // Only call with islLock locked for writing islInterfaces.insert(_serverId, interface); - connect(interface, SIGNAL(externalUserJoined(ServerInfo_User)), this, SLOT(externalUserJoined(ServerInfo_User))); - connect(interface, SIGNAL(externalUserLeft(QString)), this, SLOT(externalUserLeft(QString))); - connect(interface, SIGNAL(externalRoomUserJoined(int, ServerInfo_User)), this, - SLOT(externalRoomUserJoined(int, ServerInfo_User))); - connect(interface, SIGNAL(externalRoomUserLeft(int, QString)), this, SLOT(externalRoomUserLeft(int, QString))); - connect(interface, SIGNAL(externalRoomSay(int, QString, QString, QString)), this, - SLOT(externalRoomSay(int, QString, QString, QString))); - connect(interface, SIGNAL(externalRoomRemoveMessages(int, QString, int)), this, - SLOT(externalRoomRemoveMessages(int, QString, int))); - connect(interface, SIGNAL(externalRoomGameListChanged(int, ServerInfo_Game)), this, - SLOT(externalRoomGameListChanged(int, ServerInfo_Game))); - connect(interface, SIGNAL(joinGameCommandReceived(Command_JoinGame, int, int, int, qint64)), this, - SLOT(externalJoinGameCommandReceived(Command_JoinGame, int, int, int, qint64))); - connect(interface, SIGNAL(gameCommandContainerReceived(CommandContainer, int, int, qint64)), this, - SLOT(externalGameCommandContainerReceived(CommandContainer, int, int, qint64))); - connect(interface, SIGNAL(responseReceived(Response, qint64)), this, - SLOT(externalResponseReceived(Response, qint64))); - connect(interface, SIGNAL(gameEventContainerReceived(GameEventContainer, qint64)), this, - SLOT(externalGameEventContainerReceived(GameEventContainer, qint64))); + connect(interface, &IslInterface::externalUserJoined, this, &Servatrice::externalUserJoined); + connect(interface, &IslInterface::externalUserLeft, this, &Servatrice::externalUserLeft); + connect(interface, &IslInterface::externalRoomUserJoined, this, &Servatrice::externalRoomUserJoined); + connect(interface, &IslInterface::externalRoomUserLeft, this, &Servatrice::externalRoomUserLeft); + connect(interface, &IslInterface::externalRoomSay, this, &Servatrice::externalRoomSay); + connect(interface, &IslInterface::externalRoomRemoveMessages, this, &Servatrice::externalRoomRemoveMessages); + connect(interface, &IslInterface::externalRoomGameListChanged, this, &Servatrice::externalRoomGameListChanged); + connect(interface, &IslInterface::joinGameCommandReceived, this, &Servatrice::externalJoinGameCommandReceived); + connect(interface, &IslInterface::gameCommandContainerReceived, this, + &Servatrice::externalGameCommandContainerReceived); + connect(interface, &IslInterface::responseReceived, this, &Servatrice::externalResponseReceived); + connect(interface, &IslInterface::gameEventContainerReceived, this, + &Servatrice::externalGameEventContainerReceived); } void Servatrice::removeIslInterface(int _serverId) diff --git a/servatrice/src/servatrice_database_interface.cpp b/servatrice/src/servatrice_database_interface.cpp index 04f6047fa..a4f184e38 100644 --- a/servatrice/src/servatrice_database_interface.cpp +++ b/servatrice/src/servatrice_database_interface.cpp @@ -33,6 +33,7 @@ #include #include #include +#include #include #include #include @@ -482,6 +483,10 @@ AuthenticationResult Servatrice_DatabaseInterface::checkUserPassword(Server_Prot const QByteArray expected = PasswordHasher::computeResponse(key, nonce); handler->clearAuthNonce(); if (PasswordHasher::constantTimeEquals(expected, response)) { + if (forceChange) { + qCDebug(DatabaseInterfaceLog) << "Login accepted but password change required"; + return PasswordChangeRequired; + } qCDebug(DatabaseInterfaceLog) << "Login accepted: challenge-response password right"; return PasswordRight; } @@ -755,6 +760,20 @@ QString Servatrice_DatabaseInterface::getUserPasswordData(const QString &user) return query->value(0).toString(); } +QString Servatrice_DatabaseInterface::sealLegacyBackup(const QString &storedName, const QString &credential) +{ + const QByteArray key = server->getLegacyBackupKey(); + if (key.isEmpty()) { + return {}; + } + const QByteArray aad = storedName.simplified().toUtf8(); + const QString sealed = CryptoUtil::encryptSecret(credential.toUtf8(), key, aad); + if (sealed.isEmpty() || sealed.length() > MAX_LEGACY_BACKUP_LENGTH) { + return {}; + } + return sealed; +} + bool Servatrice_DatabaseInterface::submitPasswordVerifier(const QString &user, const QString &passwordVerifier) { if (server->getAuthenticationMethod() != Servatrice::AuthenticationSql) { @@ -779,18 +798,13 @@ bool Servatrice_DatabaseInterface::submitPasswordVerifier(const QString &user, c QString sealedBackup; QDateTime backupUsedAt; - if (PasswordHasher::isLegacyFormat(currentCredential)) { - const QByteArray key = server->getLegacyBackupKey(); - if (!key.isEmpty()) { - const QByteArray aad = storedName.simplified().toUtf8(); - sealedBackup = CryptoUtil::encryptSecret(currentCredential.toUtf8(), key, aad); - if (sealedBackup.isEmpty() || sealedBackup.length() > MAX_LEGACY_BACKUP_LENGTH) { - qCWarning(DatabaseInterfaceLog) - << "Failed to seal legacy backup for user" << user << "- account migrates without a downgrade path"; - sealedBackup.clear(); - } else { - backupUsedAt = QDateTime::currentDateTimeUtc(); - } + if (PasswordHasher::isLegacyFormat(currentCredential) && !server->getLegacyBackupKey().isEmpty()) { + sealedBackup = sealLegacyBackup(storedName, currentCredential); + if (sealedBackup.isEmpty()) { + qCWarning(DatabaseInterfaceLog) + << "Failed to seal legacy backup for user" << user << "- account migrates without a downgrade path"; + } else { + backupUsedAt = QDateTime::currentDateTimeUtc(); } } @@ -1644,6 +1658,54 @@ bool Servatrice_DatabaseInterface::changeUserPassword(const QString &user, return passwordQuery->numRowsAffected() > 0; } +bool Servatrice_DatabaseInterface::changeUserPasswordWithLegacyBackup(const QString &user, + const QString &passwordVerifier, + const QString &legacyCredential) +{ + if (server->getAuthenticationMethod() != Servatrice::AuthenticationSql) { + return false; + } + + if (!checkSql()) { + return false; + } + + // Seal against the stored row name, not the caller-supplied one: the name column is + // case-insensitive, so a differently-cased name would fail the tag check on open. + QSqlQuery *nameQuery = prepareQuery("SELECT name FROM {prefix}_users WHERE name = :name"); + nameQuery->bindValue(":name", user); + if (!execSqlQuery(nameQuery) || !nameQuery->next()) { + return false; + } + const QString storedName = nameQuery->value(0).toString(); + + const QString sealedBackup = sealLegacyBackup(storedName, legacyCredential); + const QDateTime backupUsedAt = sealedBackup.isEmpty() ? QDateTime() : QDateTime::currentDateTimeUtc(); + if (sealedBackup.isEmpty()) { + // Without a backup a pre-challenge client has nothing to authenticate against: the + // row holds a verifier and the legacy hash it would send cannot match it. Say so, + // because the symptom - the temporary password appearing not to work - shows up on + // a different machine from the one that reset it. + qCWarning(DatabaseInterfaceLog) << "Stored a verifier without a legacy backup for user" << user + << "- pre-challenge clients cannot use this password" + << "- check security/legacy_backup_key"; + } + + QSqlQuery *query = prepareQuery("update {prefix}_users set password_sha512=:password, " + "password_legacy_backup=:backup, password_legacy_backup_used_at=:used_at, " + "passwordLastChangedDate = NOW() where name = :name"); + query->bindValue(":password", passwordVerifier); + query->bindValue(":backup", sealedBackup); + query->bindValue(":used_at", backupUsedAt.isValid() + ? QVariant(backupUsedAt.toString(QStringLiteral("yyyy-MM-dd HH:mm:ss"))) + : QVariant()); + query->bindValue(":name", storedName); + if (!execSqlQuery(query)) { + return false; + } + return query->numRowsAffected() > 0; +} + void Servatrice_DatabaseInterface::setForcePasswordChange(const QString &user, bool force) { if (!checkSql()) { @@ -2077,6 +2139,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, @@ -2086,8 +2250,14 @@ QList Servatrice_DatabaseInterface::getMessageLogHistory bool &game, bool &room, int &range, - int &maxresults) + int &maxresults, + int offset, + const QString &beforeTime, + bool *hasMore) { + if (hasMore != nullptr) { + *hasMore = false; + } QList results; ServerInfo_ChatMessage chatMessage; @@ -2096,8 +2266,31 @@ QList Servatrice_DatabaseInterface::getMessageLogHistory return results; } - if (user.isEmpty() && ipaddress.isEmpty() && gameid.isEmpty() && gamename.isEmpty()) { - // To ensure quick results and minimal lag, require an indexed field + // Page size guard: always bound the scan so a filter-less browse of room + // history cannot become a 50M-row fetch. Zero means "client did not set a + // size", so fall back to one smooth-scroll page. + const int pageSize = maxresults <= 0 ? 200 : qBound(1, maxresults, 1000); + maxresults = pageSize; + const int safeOffset = qMax(0, offset); + + const bool hasIndexedFilter = !user.isEmpty() || !ipaddress.isEmpty() || !gameid.isEmpty() || !gamename.isEmpty(); + const bool hasLocationFilter = chat || game || room; + if (!hasLocationFilter) { + // No target_type restriction would mix private chat into a room/game + // browse, so refuse rather than leak. + return results; + } + // A browse narrowed only by a message substring has no index to serve it, and `range` + // has no upper bound on the wire: the client offers 20 days, but a hand-built command + // can ask for any value and every LIKE candidate in it is a row the server examines. + // Clamping here keeps the guard independent of what the client happens to send. + constexpr int maxFilterlessRangeHours = 20 * 24; + if (!hasIndexedFilter && range > maxFilterlessRangeHours) { + range = maxFilterlessRangeHours; + } + if (!hasIndexedFilter && range <= 0) { + // Filter-less browse must stay time-bounded (idx_log_time) so the + // query stays an index range scan instead of a full table scan. return results; } @@ -2152,10 +2345,23 @@ QList Servatrice_DatabaseInterface::getMessageLogHistory queryString.append(" AND log_time >= DATE_SUB(now(), INTERVAL :range_time HOUR)"); } - if (maxresults) { - queryString.append(" LIMIT :limit_size"); + if (!beforeTime.isEmpty()) { + // Pages after the first run against the snapshot fixed by the first one, so rows + // logged while the moderator scrolls can no longer push the OFFSET window down and + // repeat a row already on screen. + queryString.append(" AND log_time <= :before_time"); } + // Newest-first keeps "going back from today" stable across pages and lets + // the client append older rows at the bottom for smooth infinite scroll. + queryString.append(" ORDER BY log_time DESC"); + + // Fetch one extra row when the caller wants a has_more cursor so a full + // page does not need a second empty round-trip to prove exhaustion. + const bool detectMore = (hasMore != nullptr); + const int fetchSize = detectMore ? pageSize + 1 : pageSize; + queryString.append(" LIMIT :limit_size OFFSET :offset_size"); + QSqlQuery *query = prepareQuery(queryString); if (!user.isEmpty()) { query->bindValue(":user_name", user); @@ -2175,9 +2381,11 @@ QList Servatrice_DatabaseInterface::getMessageLogHistory if (range) { query->bindValue(":range_time", range); } - if (maxresults) { - query->bindValue(":limit_size", maxresults); + if (!beforeTime.isEmpty()) { + query->bindValue(":before_time", beforeTime); } + query->bindValue(":limit_size", fetchSize); + query->bindValue(":offset_size", safeOffset); if (!execSqlQuery(query)) { qCWarning(DatabaseInterfaceLog) << "Failed to collect log history information: SQL Error"; @@ -2197,6 +2405,11 @@ QList Servatrice_DatabaseInterface::getMessageLogHistory results << chatMessage; } + if (detectMore && results.size() > pageSize) { + results.removeLast(); + *hasMore = true; + } + return results; } diff --git a/servatrice/src/servatrice_database_interface.h b/servatrice/src/servatrice_database_interface.h index 822accada..eb74063ac 100644 --- a/servatrice/src/servatrice_database_interface.h +++ b/servatrice/src/servatrice_database_interface.h @@ -62,6 +62,9 @@ private: bool checkUserIsIpBanned(const QString &ipAddress, QString &banReason, int &banSecondsRemaining); /** Must be called after checkSql and server is known to be in auth mode. */ bool checkUserIsNameBanned(QString const &userName, QString &banReason, int &banSecondsRemaining); + /** Seal a legacy credential for the account's downgrade backup; empty when the + * backup key is unset or the sealed value does not fit the column. */ + QString sealLegacyBackup(const QString &storedName, const QString &credential); bool isStrictModeEnabled(bool &ok) const; protected: @@ -192,6 +195,11 @@ public: const QString &targetName, const QString &channel = QString()) override; bool changeUserPassword(const QString &user, const QString &password, bool passwordNeedsHash) override; + /** Replace a credential with an already-derived verifier, keeping a sealed legacy hash of the + * same password as the downgrade backup for pre-challenge clients. */ + bool changeUserPasswordWithLegacyBackup(const QString &user, + const QString &passwordVerifier, + const QString &legacyCredential); bool changeUserPassword(const QString &user, const QString &oldPassword, bool oldPasswordNeedsHash, @@ -211,7 +219,10 @@ public: bool &game, bool &room, int &range, - int &maxresults); + int &maxresults, + int offset = 0, + const QString &beforeTime = QString(), + bool *hasMore = nullptr); QList getUserSessions(const QString &userName, int limit); QList getUserAlts(const QString &userName); QList getModeratorLastLogins(); @@ -231,6 +242,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..c38d790b1 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 @@ -197,7 +199,8 @@ AbstractServerSocketInterface::AbstractServerSocketInterface(Servatrice *_server // Never call flushOutputQueue directly from outputQueueChanged. In case of a socket error, // it could lead to this object being destroyed while another function is still on the call stack. -> mutex // deadlocks etc. - connect(this, SIGNAL(outputQueueChanged()), this, SLOT(flushOutputQueue()), Qt::QueuedConnection); + connect(this, &AbstractServerSocketInterface::outputQueueChanged, this, + &AbstractServerSocketInterface::flushOutputQueue, Qt::QueuedConnection); } bool AbstractServerSocketInterface::initSession() @@ -265,6 +268,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 +487,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; } @@ -1731,12 +1743,17 @@ Response::ResponseCode AbstractServerSocketInterface::cmdGetLogHistory(const Com int dateRange = cmd.date_range(); int maximumResults = cmd.maximum_results(); + const int offset = cmd.has_offset() ? static_cast(cmd.offset()) : 0; + const QString beforeTime = cmd.has_before_time() ? QString::fromStdString(cmd.before_time()) : QString(); Response_ViewLogHistory *re = new Response_ViewLogHistory; if (servatrice->getEnableLogQuery()) { - QListIterator messageIterator(sqlInterface->getMessageLogHistory( - userName, ipAddress, gameName, gameID, message, chatType, gameType, roomType, dateRange, maximumResults)); + bool hasMore = false; + QList history = + sqlInterface->getMessageLogHistory(userName, ipAddress, gameName, gameID, message, chatType, gameType, + roomType, dateRange, maximumResults, offset, beforeTime, &hasMore); + QListIterator messageIterator(history); while (messageIterator.hasNext()) { ServerInfo_ChatMessage chatMessage = messageIterator.next(); if (!allowPrivateChat) { @@ -1744,6 +1761,7 @@ Response::ResponseCode AbstractServerSocketInterface::cmdGetLogHistory(const Com } re->add_log_message()->CopyFrom(chatMessage); } + re->set_has_more(hasMore); } else { ServerInfo_ChatMessage chatMessage; @@ -1971,7 +1989,8 @@ Response::ResponseCode AbstractServerSocketInterface::cmdBanFromServer(const Com SessionEvent *se = userList[i]->prepareSessionEvent(event); userList[i]->sendProtocolItem(*se); delete se; - QMetaObject::invokeMethod(userList[i], "prepareDestroy", Qt::QueuedConnection); + QMetaObject::invokeMethod( + userList[i], [user = userList[i]] { user->prepareDestroy(); }, Qt::QueuedConnection); } } @@ -2630,7 +2649,15 @@ Response::ResponseCode AbstractServerSocketInterface::cmdResetUserPassword(const } const QString tempPassword = PasswordHasher::generateRandomSalt(); - if (!sqlInterface->changeUserPassword(userName, tempPassword, true)) { + // Store a scrypt verifier rather than a legacy salt+hash: a legacy row makes the next + // login look like an authentication downgrade, which modern clients refuse when they + // still hold a verifier for the account. Where security/legacy_backup_key is configured, + // keep a sealed legacy hash of the same temporary password as the downgrade backup so + // pre-challenge clients keep the path the reset used to provide; without the key they + // have none, which changeUserPasswordWithLegacyBackup warns about. + const QString verifier = PasswordHasher::generatePasswordVerifier(tempPassword); + const QString legacyCredential = PasswordHasher::computeHash(tempPassword, PasswordHasher::generateRandomSalt()); + if (!sqlInterface->changeUserPasswordWithLegacyBackup(userName, verifier, legacyCredential)) { return Response::RespInternalError; } sqlInterface->setForcePasswordChange(userName, true); @@ -2686,6 +2713,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()) { @@ -3707,6 +3795,9 @@ Response::ResponseCode AbstractServerSocketInterface::cmdAccountPassword(const C } databaseInterface->setForcePasswordChange(userName, false); + // The forced change is done, but this session never completed login (see + // Server_ProtocolHandler::cmdLogin). It stays restricted to ping and another password + // change; the client reconnects with the new password to actually enter the server. return Response::RespOk; } @@ -4108,15 +4199,15 @@ Response::ResponseCode AbstractServerSocketInterface::cmdUpdateServerMessage(const Command_UpdateServerMessage & /*cmd*/, ResponseContainer & /*rc*/) { - QMetaObject::invokeMethod(server, "updateLoginMessage"); + QMetaObject::invokeMethod(servatrice, [srv = servatrice] { srv->updateLoginMessage(); }); return Response::RespOk; } Response::ResponseCode AbstractServerSocketInterface::cmdShutdownServer(const Command_ShutdownServer &cmd, ResponseContainer & /*rc*/) { - QMetaObject::invokeMethod(server, "scheduleShutdown", Q_ARG(QString, textFromStdString(cmd.reason())), - Q_ARG(int, cmd.minutes())); + QMetaObject::invokeMethod(servatrice, [srv = servatrice, reason = textFromStdString(cmd.reason()), + minutes = cmd.minutes()] { srv->scheduleShutdown(reason, minutes); }); return Response::RespOk; } @@ -4125,7 +4216,9 @@ Response::ResponseCode AbstractServerSocketInterface::cmdReloadConfig(const Comm { logDebugMessage("Received admin command: reloading configuration"); settingsCache->sync(); - QMetaObject::invokeMethod(server, "setRequiredFeatures", Q_ARG(QString, server->getRequiredFeatures())); + QMetaObject::invokeMethod(servatrice, [srv = servatrice, features = servatrice->getRequiredFeatures()] { + srv->setRequiredFeatures(features); + }); return Response::RespOk; } @@ -4177,7 +4270,7 @@ bool AbstractServerSocketInterface::removeAdminFlagFromUser(const QString &userN delete se; } - QMetaObject::invokeMethod(user, "prepareDestroy", Qt::QueuedConnection); + QMetaObject::invokeMethod(user, [user] { user->prepareDestroy(); }, Qt::QueuedConnection); return true; } @@ -4352,10 +4445,10 @@ TcpServerSocketInterface::TcpServerSocketInterface(Servatrice *_server, { socket = new QTcpSocket(this); socket->setSocketOption(QAbstractSocket::LowDelayOption, 1); - connect(socket, SIGNAL(readyRead()), this, SLOT(readClient())); - connect(socket, SIGNAL(disconnected()), this, SLOT(catchSocketDisconnected())); - connect(socket, SIGNAL(errorOccurred(QAbstractSocket::SocketError)), this, - SLOT(catchSocketError(QAbstractSocket::SocketError))); + connect(socket, &QTcpSocket::readyRead, this, [this] { readClient(); }); + connect(socket, &QTcpSocket::disconnected, this, [this] { catchSocketDisconnected(); }); + connect(socket, &QTcpSocket::errorOccurred, this, + [this](QAbstractSocket::SocketError error) { catchSocketError(error); }); } TcpServerSocketInterface::~TcpServerSocketInterface() @@ -4431,13 +4524,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 +4543,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 +4580,7 @@ void TcpServerSocketInterface::readClient() << Qt::endl << "Message coming from:" << getAddress(); } - inputBuffer.remove(0, messageLength); + readOffset += messageLength; messageInProgress = false; if (ok) { @@ -4499,8 +4597,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() @@ -4568,11 +4670,17 @@ void WebsocketServerSocketInterface::initConnection(void *_socket) } } - connect(socket, SIGNAL(binaryMessageReceived(const QByteArray &)), this, - SLOT(binaryMessageReceived(const QByteArray &))); + connect(socket, &QWebSocket::binaryMessageReceived, this, &WebsocketServerSocketInterface::binaryMessageReceived); + // 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))); - connect(socket, SIGNAL(disconnected()), this, SLOT(catchSocketDisconnected())); +#endif + connect(socket, &QWebSocket::disconnected, this, [this] { catchSocketDisconnected(); }); // Add this object to the server's list of connections before it can receive socket events. // Otherwise, in case of a socket error, it could be removed from the list before it is added. 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/servatrice/src/smtpclient.cpp b/servatrice/src/smtpclient.cpp index 82824e496..bfdfae905 100644 --- a/servatrice/src/smtpclient.cpp +++ b/servatrice/src/smtpclient.cpp @@ -25,7 +25,7 @@ static const qint64 IDLE_TIMEOUT_MS = 60000; // 60 seconds // MAX_RETRY_DELAY cap is applied, preventing unbounded intermediate values. static const int MAX_BACKOFF_SHIFT = 10; -inline Q_LOGGING_CATEGORY(SmtpClientLog, "smtp_client") +inline Q_LOGGING_CATEGORY(SmtpClientLog, "smtp_client"); static QString pendingKey(const QString &userName, EmailType type, const QString &token) { @@ -55,24 +55,23 @@ SmtpClient::SmtpClient(QObject *parent) : QObject(parent) idleTimer = new QTimer(this); idleTimer->setSingleShot(true); - connect(idleTimer, SIGNAL(timeout()), this, SLOT(idleTimeout())); + connect(idleTimer, &QTimer::timeout, this, &SmtpClient::idleTimeout); - connect(smtp, SIGNAL(authenticated()), this, SLOT(authenticated())); - connect(smtp, SIGNAL(authenticationFailed(const QByteArray &)), this, - SLOT(authenticationFailed(const QByteArray &))); - connect(smtp, SIGNAL(connected()), this, SLOT(connected())); - connect(smtp, SIGNAL(connectionFailed(const QByteArray &)), this, SLOT(connectionFailed(const QByteArray &))); - connect(smtp, SIGNAL(disconnected()), this, SLOT(disconnected())); - connect(smtp, SIGNAL(encrypted()), this, SLOT(encrypted())); - connect(smtp, SIGNAL(encryptionFailed(const QByteArray &)), this, SLOT(encryptionFailed(const QByteArray &))); - connect(smtp, SIGNAL(finished()), this, SLOT(finished())); - connect(smtp, SIGNAL(mailFailed(int, int, const QByteArray &)), this, - SLOT(mailFailed(int, int, const QByteArray &))); - connect(smtp, SIGNAL(mailSent(int)), this, SLOT(mailSent(int))); - connect(smtp, SIGNAL(recipientRejected(int, const QString &, const QByteArray &)), this, - SLOT(recipientRejected(int, const QString &, const QByteArray &))); - connect(smtp, SIGNAL(senderRejected(int, const QString &, const QByteArray &)), this, - SLOT(senderRejected(int, const QString &, const QByteArray &))); + connect(smtp, &QxtSmtp::authenticated, this, &SmtpClient::authenticated); + connect(smtp, qOverload(&QxtSmtp::authenticationFailed), this, + &SmtpClient::authenticationFailed); + connect(smtp, &QxtSmtp::connected, this, &SmtpClient::connected); + connect(smtp, qOverload(&QxtSmtp::connectionFailed), this, &SmtpClient::connectionFailed); + connect(smtp, &QxtSmtp::disconnected, this, &SmtpClient::disconnected); + connect(smtp, &QxtSmtp::encrypted, this, &SmtpClient::encrypted); + connect(smtp, qOverload(&QxtSmtp::encryptionFailed), this, &SmtpClient::encryptionFailed); + connect(smtp, &QxtSmtp::finished, this, &SmtpClient::finished); + connect(smtp, qOverload(&QxtSmtp::mailFailed), this, &SmtpClient::mailFailed); + connect(smtp, &QxtSmtp::mailSent, this, &SmtpClient::mailSent); + connect(smtp, qOverload(&QxtSmtp::recipientRejected), this, + &SmtpClient::recipientRejected); + connect(smtp, qOverload(&QxtSmtp::senderRejected), this, + &SmtpClient::senderRejected); } SmtpClient::~SmtpClient() diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index aac4d4818..59a7376a3 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -177,7 +177,29 @@ cockatrice_add_test(NAME test_age_formatting SOURCES test_age_formatting.cpp LIB cockatrice_add_test(NAME user_data_backup_test SOURCES user_data_backup_test.cpp LIBS libcockatrice_utility) 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 svg_recolor_test SOURCES svg_recolor_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 +252,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 @@ -296,6 +329,40 @@ cockatrice_add_test( LABELS server ) +cockatrice_add_test( + NAME + server_forced_password_change_test + SOURCES + server_forced_password_change_test.cpp + LIBS + libcockatrice_network_server_remote + libcockatrice_rng + LABELS + server +) +cockatrice_add_test( + NAME + client_login_status_test + SOURCES + ${VERSION_STRING_CPP} + client_login_status_test.cpp + LIBS + libcockatrice_network_client_remote + LABELS + ci +) +# The local game runs one client and one server in the same process. +# Either of them can be destroyed first; the survivor has to stay usable. +cockatrice_add_test( + NAME + local_client_lifetime_test + SOURCES + local_client_lifetime_test.cpp + LIBS + libcockatrice_network + LABELS + ci +) cockatrice_add_test( NAME server_room_channels_test @@ -358,6 +425,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 +462,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 +491,22 @@ 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( @@ -443,11 +528,18 @@ add_subdirectory(card_zone_algorithms) add_subdirectory(carddatabase) add_subdirectory(deck_list_model) add_subdirectory(deck_list_zones) +add_subdirectory(icon_assets) add_subdirectory(loading_from_clipboard) +add_subdirectory(model_contract) 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/CMakeLists.txt b/tests/carddatabase/CMakeLists.txt index 22171a9e8..7d83640a1 100644 --- a/tests/carddatabase/CMakeLists.txt +++ b/tests/carddatabase/CMakeLists.txt @@ -35,6 +35,21 @@ if(WITH_ORACLE OR WITH_CLIENT) LIBS libcockatrice_filters ) + + # ------------------------ + # Visual Display Model Test + # ------------------------ + cockatrice_add_test( + NAME + visual_display_model_test + SOURCES + ${VERSION_STRING_CPP} + visual_display_model_test.cpp + mocks.cpp + LIBS + libcockatrice_card + libcockatrice_models + ) endif() # ------------------------ diff --git a/tests/carddatabase/carddatabase_test.cpp b/tests/carddatabase/carddatabase_test.cpp index 2b216d5b6..22cbe9074 100644 --- a/tests/carddatabase/carddatabase_test.cpp +++ b/tests/carddatabase/carddatabase_test.cpp @@ -17,15 +17,18 @@ #include #include #include +#include #include #include #include #include +#include #include #include #include #include #include +#include #include #include #include @@ -62,6 +65,43 @@ 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 @@ -87,6 +127,32 @@ TEST(CardDatabaseTest, FindPrintingWithIdOnNullCardInfoReturnsEmpty) 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; @@ -104,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); @@ -127,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"); } /** @@ -535,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/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/carddatabase/visual_display_model_test.cpp b/tests/carddatabase/visual_display_model_test.cpp new file mode 100644 index 000000000..5fa554bb3 --- /dev/null +++ b/tests/carddatabase/visual_display_model_test.cpp @@ -0,0 +1,188 @@ +/** + * @file visual_display_model_test.cpp + * @brief Tests for the flattened model behind the visual card database grid. + */ + +#include "card_database_display_model.h" +#include "card_database_model.h" +#include "card_database_visual_display_model.h" +#include "libcockatrice/card/card_info.h" +#include "libcockatrice/card/database/card_database.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include "libcockatrice/card/printing/exact_card.h" +#include "libcockatrice/card/printing/printing_info.h" +#include "libcockatrice/card/set/card_set.h" +#include "libcockatrice/interfaces/noop_card_preference_provider.h" +#include "libcockatrice/interfaces/noop_card_set_priority_controller.h" +#include "test_card_database_path_provider.h" + +#include "gtest/gtest.h" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +// GTest's main() creates no QCoreApplication, which QTimer needs for its event loop. +const QCoreApplication &application() +{ + static int argc = 1; + static char executable[] = "visual_display_model_test"; + static char *argv[] = {executable, nullptr}; + static const QCoreApplication app(argc, argv); + return app; +} + +/// Lets the rebuild timer fire, as it would in the running client. +void waitForRebuild() +{ + QEventLoop loop; + QTimer::singleShot(100, &loop, &QEventLoop::quit); + loop.exec(); +} + +class CardDatabaseVisualDisplayModelTest : public ::testing::Test +{ +protected: + void SetUp() override + { + (void)application(); + + db = std::make_unique(nullptr, new NoopCardPreferenceProvider(), + new TestCardDatabasePathProvider(), new NoopCardSetPriorityController()); + db->loadCardDatabases(); + + cardModel = std::make_unique(db.get(), false); + displayModel = std::make_unique(); + displayModel->setSourceModel(cardModel.get()); + visualModel = std::make_unique(displayModel.get(), db->query()); + visualModel->refresh(); + } + + /** + * @brief Builds a card with @p printingCount printings in each named set. + * @param printingCountPerSet Set short name to the number of printings in that set. + */ + [[nodiscard]] CardInfoPtr makeCard(const QString &name, const QMap &printingCountPerSet) + { + SetToPrintingsMap setsInfo; + for (auto it = printingCountPerSet.constBegin(); it != printingCountPerSet.constEnd(); ++it) { + CardSetPtr set = CardSet::newInstance(&setController, it.key(), it.key() + " test set", "expansion", + QDate(), CardSet::PriorityPrimary); + for (int printing = 0; printing < it.value(); ++printing) { + QHash properties; + properties["uuid"] = QString("%1-%2-%3").arg(it.key(), name).arg(printing); + setsInfo[it.key()].append(PrintingInfo(set, LazyPropertiesHash(properties))); + } + } + + CardInfo::UiAttributes attributes = {.tableRow = 1}; + return CardInfo::newInstance(name, "", false, {}, {}, {}, setsInfo, attributes); + } + + NoopCardSetPriorityController setController; + std::unique_ptr db; + std::unique_ptr cardModel; + std::unique_ptr displayModel; + std::unique_ptr visualModel; +}; + +TEST_F(CardDatabaseVisualDisplayModelTest, ExposesTheCardInfoBehindAProxyRow) +{ + ASSERT_GT(displayModel->rowCount(), 0); + + const QVariant info = displayModel->data(displayModel->index(0, CardDatabaseModel::NameColumn), + CardDatabaseDisplayModel::CardInfoRole); + ASSERT_TRUE(info.isValid()); + EXPECT_FALSE(info.value().isNull()); +} + +TEST_F(CardDatabaseVisualDisplayModelTest, FlattensOneRowPerProxyCardWithoutSetFilters) +{ + ASSERT_GT(displayModel->rowCount(), 0); + EXPECT_EQ(displayModel->rowCount(), visualModel->rowCount()); + + for (int row = 0; row < visualModel->rowCount(); ++row) { + const ExactCard card = visualModel->cardAt(row); + ASSERT_TRUE(card) << "row " << row; + EXPECT_EQ( + displayModel->data(displayModel->index(row, CardDatabaseModel::NameColumn), Qt::DisplayRole).toString(), + card.getName()) + << "row " << row; + } +} + +TEST_F(CardDatabaseVisualDisplayModelTest, ShowsOneRowPerCardWhenNoSetIsFiltered) +{ + const int rowsBefore = visualModel->rowCount(); + + db->addCard(makeCard("Multi", {{"TST", 2}, {"OTH", 1}})); + visualModel->refresh(); + + EXPECT_EQ(rowsBefore + 1, visualModel->rowCount()); + EXPECT_EQ("Multi", visualModel->cardAt(visualModel->rowCount() - 1).getName()); +} + +TEST_F(CardDatabaseVisualDisplayModelTest, ShowsEveryPrintingOfAFilteredSet) +{ + db->addCard(makeCard("Multi", {{"TST", 2}, {"OTH", 1}})); + visualModel->refresh(); + + visualModel->setSetFilterTerms({"TST"}); + + ASSERT_EQ(2, visualModel->rowCount()); + for (int row = 0; row < visualModel->rowCount(); ++row) { + const ExactCard card = visualModel->cardAt(row); + ASSERT_TRUE(card) << "row " << row; + EXPECT_EQ("Multi", card.getName()); + ASSERT_FALSE(card.getPrinting().isEmpty()) << "row " << row; + EXPECT_EQ("TST", card.getPrinting().getSet()->getShortName()) << "row " << row; + } + EXPECT_NE(visualModel->cardAt(0).getPrinting(), visualModel->cardAt(1).getPrinting()); +} + +TEST_F(CardDatabaseVisualDisplayModelTest, HidesCardsWithoutAPrintingInTheFilteredSets) +{ + visualModel->setSetFilterTerms({"NO SUCH SET"}); + + EXPECT_EQ(0, visualModel->rowCount()); +} + +TEST_F(CardDatabaseVisualDisplayModelTest, RestoresOneRowPerCardWhenTheSetTermsClear) +{ + db->addCard(makeCard("Multi", {{"TST", 2}, {"OTH", 1}})); + visualModel->refresh(); + const int rowsBefore = visualModel->rowCount(); + + visualModel->setSetFilterTerms({"TST"}); + ASSERT_EQ(2, visualModel->rowCount()); + + visualModel->setSetFilterTerms({}); + EXPECT_EQ(rowsBefore, visualModel->rowCount()); +} + +TEST_F(CardDatabaseVisualDisplayModelTest, RebuildsByItselfWhenTheProxyGainsARow) +{ + const int rowsBefore = visualModel->rowCount(); + + db->addCard(makeCard("Late", {{"TST", 1}})); + waitForRebuild(); + + EXPECT_EQ(rowsBefore + 1, visualModel->rowCount()); +} + +TEST_F(CardDatabaseVisualDisplayModelTest, ReportsTheCardNameForDisplayAndAccessibleText) +{ + ASSERT_GT(visualModel->rowCount(), 0); + const QString expected = visualModel->cardAt(0).getName(); + + EXPECT_EQ(expected, visualModel->data(visualModel->index(0), Qt::DisplayRole).toString()); + EXPECT_EQ(expected, visualModel->data(visualModel->index(0), Qt::AccessibleTextRole).toString()); + EXPECT_FALSE(visualModel->data(QModelIndex(), Qt::DisplayRole).isValid()); +} +} // namespace diff --git a/tests/client_login_status_test.cpp b/tests/client_login_status_test.cpp new file mode 100644 index 000000000..d8ea63550 --- /dev/null +++ b/tests/client_login_status_test.cpp @@ -0,0 +1,116 @@ +/** @file client_login_status_test.cpp + * @brief The login response state machine must keep the session a forced password change needs. + * @ingroup Tests + */ + +#include "abstract_client.h" +#include "libcockatrice/interfaces/interface_network_settings_provider.h" +#include "libcockatrice/protocol/pb/response.pb.h" +#include "libcockatrice/protocol/pb/response_login.pb.h" +#include "remote_client.h" + +#include +#include +#include +#include + +namespace +{ + +class StubNetworkSettingsProvider : public INetworkSettingsProvider +{ +public: + QString getClientID() override + { + return {}; + } + [[nodiscard]] int getTimeOut() const override + { + return 1000; + } + [[nodiscard]] int getKeepAlive() const override + { + return 60; + } + [[nodiscard]] bool getNotifyAboutUpdates() const override + { + return false; + } + void setKnownMissingFeatures(const QString &) override + { + } + QString getKnownMissingFeatures() override + { + return {}; + } +}; + +/** + * @brief Feeds a login response to RemoteClient without a socket. + * + * `loginResponse` is the slot the socket thread runs for the login command's answer, so + * invoking it directly exercises the status transition that decides whether `readData()` + * tears the connection down right after it. + */ +class ClientLoginStatusTest : public ::testing::Test +{ +protected: + void SetUp() override + { + client = new RemoteClient(nullptr, &provider); + } + + void TearDown() override + { + delete client; + client = nullptr; + } + + void deliverLoginResponse(Response::ResponseCode code) + { + Response response; + response.set_response_code(code); + response.MutableExtension(Response_Login::ext); + ASSERT_TRUE( + QMetaObject::invokeMethod(client, "loginResponse", Qt::DirectConnection, Q_ARG(Response, response))); + } + + StubNetworkSettingsProvider provider; + RemoteClient *client = nullptr; +}; + +/** + * @brief A required password change is not a failed login. + * + * The server keeps the session open and accepts only ping and Command_AccountPassword until + * the password is replaced. StatusDisconnecting would make readData() close the socket right + * after this slot returns - before the dialog that collects the new password (queued to the + * UI thread) can send anything - and the pending command would never be answered because the + * ping timer that times it out dies with the connection. StatusLoggedIn would start the + * normal post-login flow for a session that cannot serve it. + */ +TEST_F(ClientLoginStatusTest, PasswordChangeRequiredKeepsTheSessionOpen) +{ + deliverLoginResponse(Response::RespPasswordChangeRequired); + + EXPECT_EQ(client->getStatus(), StatusPasswordChangeRequired); + EXPECT_NE(client->getStatus(), StatusDisconnecting); + EXPECT_NE(client->getStatus(), StatusLoggedIn); +} + +/** @brief Every other login error still ends the connection, as it did before. */ +TEST_F(ClientLoginStatusTest, OtherLoginErrorsDisconnect) +{ + deliverLoginResponse(Response::RespWrongPassword); + + EXPECT_EQ(client->getStatus(), StatusDisconnecting); +} + +} // namespace + +int main(int argc, char **argv) +{ + QCoreApplication app(argc, 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..42c935c8f --- /dev/null +++ b/tests/game/CMakeLists.txt @@ -0,0 +1,18 @@ +cockatrice_add_test( + NAME + game_logic_test + SOURCES + game_test_main.cpp + card_state_test.cpp + card_zone_logic_test.cpp + game_event_handler_test.cpp + player_actions_test.cpp + replay_test.cpp + ${VERSION_STRING_CPP} + LIBS + libcockatrice_game + INCLUDE_DIRS + ${CMAKE_CURRENT_SOURCE_DIR} + DEFINITIONS + GAME_TEST_DATA_DIR="${CMAKE_CURRENT_SOURCE_DIR}/data" +) diff --git a/tests/game/card_state_test.cpp b/tests/game/card_state_test.cpp new file mode 100644 index 000000000..2f2c43bf4 --- /dev/null +++ b/tests/game/card_state_test.cpp @@ -0,0 +1,110 @@ +#include "gtest/gtest.h" +#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(CardStateTest, CountersAddUpdateAndClear) +{ + CardState card(nullptr, nullptr, cardRefFor(QStringLiteral("Cat")), 1); + int changes = 0; + QObject::connect(&card, &CardState::countersChanged, [&changes]() { ++changes; }); + + card.insertCounter(1, 2); + EXPECT_EQ(card.getCounters().value(1), 2); + + card.setCounter(1, 3); + EXPECT_EQ(card.getCounters().value(1), 3); + + card.setCounter(1, 0); + EXPECT_FALSE(card.getCounters().contains(1)); + + card.setCounter(2, 1); + card.clearCounters(); + EXPECT_TRUE(card.getCounters().isEmpty()); + + EXPECT_EQ(changes, 5); +} + +TEST(CardStateTest, TapFlipAttackingAndReset) +{ + CardState card(nullptr, nullptr, cardRefFor(QStringLiteral("Cat")), 1); + card.setTapped(true); + card.setFaceDown(true); + card.setAttacking(true); + card.setAnnotation(QStringLiteral("note")); + card.setPT(QStringLiteral("4/4")); + card.setDoesntUntap(true); + card.setDestroyOnZoneChange(true); + card.setColor(QStringLiteral("red")); + card.insertCounter(1, 2); + + card.resetState(true); + + EXPECT_FALSE(card.getTapped()); + EXPECT_FALSE(card.getAttacking()); + EXPECT_TRUE(card.getCounters().isEmpty()); + EXPECT_TRUE(card.getPT().isEmpty()); + EXPECT_FALSE(card.getDoesntUntap()); + EXPECT_EQ(card.getAnnotation(), QStringLiteral("note")); + // resetState() deliberately leaves these three alone. + EXPECT_TRUE(card.getFaceDown()); + EXPECT_EQ(card.getColor(), QStringLiteral("red")); + EXPECT_TRUE(card.getDestroyOnZoneChange()); + + card.setAnnotation(QStringLiteral("other")); + card.resetState(false); + EXPECT_TRUE(card.getAnnotation().isEmpty()); +} + +TEST(CardStateTest, ParsePT) +{ + const QVariantList plain = CardState::parsePT(QStringLiteral("3/3")); + ASSERT_EQ(plain.size(), 2); + EXPECT_EQ(plain.at(0).toString(), QStringLiteral("3")); + EXPECT_EQ(plain.at(1).toString(), QStringLiteral("3")); + + const QVariantList negative = CardState::parsePT(QStringLiteral("-1/2")); + ASSERT_EQ(negative.size(), 2); + EXPECT_EQ(negative.at(0).typeId(), QMetaType::Int); + EXPECT_EQ(negative.at(0).toInt(), -1); + EXPECT_EQ(negative.at(1).toString(), QStringLiteral("2")); + + const QVariantList star = CardState::parsePT(QStringLiteral("*")); + ASSERT_EQ(star.size(), 1); + EXPECT_EQ(star.at(0).toString(), QStringLiteral("*")); + + EXPECT_TRUE(CardState::parsePT(QString()).isEmpty()); +} diff --git a/tests/game/card_zone_logic_test.cpp b/tests/game/card_zone_logic_test.cpp new file mode 100644 index 000000000..7f0c39b47 --- /dev/null +++ b/tests/game/card_zone_logic_test.cpp @@ -0,0 +1,107 @@ +#include "gtest/gtest.h" +#include +#include +#include +#include +#include +#include + +namespace +{ + +CardRef cardRefFor(const QString &name) +{ + CardRef cardRef; + cardRef.name = name; + return cardRef; +} + +} // namespace + +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); + // The graphics side owns the view logic and registers it on the zone it + // shows; adding a card then goes through prepareAddCard and the request + // signal instead of the test emitting the signal itself. + ZoneViewZoneLogic view(nullptr, &zone, 1, true, false, false); + zone.getViews().append(&view); + + QSignalSpy requestSpy(&view, &CardZoneLogic::createCardRequested); + + CardState card(nullptr, nullptr, cardRefFor(QStringLiteral("Lightning Bolt")), 7); + zone.addCard(&card, true, 0, 0); + + ASSERT_EQ(requestSpy.count(), 1); + const QList arguments = requestSpy.takeFirst(); + const ServerInfo_Card cardInfo = arguments.at(0).value(); + EXPECT_EQ(cardInfo.id(), 7); + EXPECT_EQ(cardInfo.name(), "Lightning Bolt"); + EXPECT_TRUE(arguments.at(1).toBool()); + EXPECT_EQ(card.getZone(), &zone); + EXPECT_EQ(zone.getCards().size(), 1); +} + +TEST(CardZoneLogicTest, MoveBetweenZonesUpdatesCardZone) +{ + HandZoneLogic first(nullptr, QStringLiteral("hand"), false, false, true); + HandZoneLogic second(nullptr, QStringLiteral("grave"), false, false, true); + CardState card(nullptr, nullptr, cardRefFor(QStringLiteral("Cat")), 7); + + first.addCard(&card, false, 0); + ASSERT_EQ(first.getCards().size(), 1); + + CardState *taken = first.takeCard(0, 7); + ASSERT_EQ(taken, &card); + EXPECT_TRUE(first.getCards().isEmpty()); + + second.addCard(taken, false, 0); + EXPECT_EQ(second.getCards().size(), 1); + EXPECT_EQ(card.getZone(), &second); +} + +TEST(CardZoneLogicTest, GetCardFindsById) +{ + HandZoneLogic zone(nullptr, QStringLiteral("hand"), false, false, true); + CardState first(nullptr, nullptr, cardRefFor(QStringLiteral("Cat")), 7); + CardState second(nullptr, nullptr, cardRefFor(QStringLiteral("Dog")), 8); + + zone.addCard(&first, false, 0); + zone.addCard(&second, false, 0); + + EXPECT_EQ(zone.getCard(7), &first); + EXPECT_EQ(zone.getCard(8), &second); + EXPECT_EQ(zone.getCard(99), nullptr); +} + +TEST(CardZoneLogicTest, ReorganizeSignalFiresOnAdd) +{ + HandZoneLogic zone(nullptr, QStringLiteral("hand"), false, false, true); + CardState card(nullptr, nullptr, cardRefFor(QStringLiteral("Cat")), 7); + QSignalSpy reorganizeSpy(&zone, &CardZoneLogic::reorganizeCards); + + zone.addCard(&card, true, 0); + + EXPECT_EQ(reorganizeSpy.count(), 1); +} diff --git a/tests/game/data/cards.xml b/tests/game/data/cards.xml new file mode 100644 index 000000000..e315dbf48 --- /dev/null +++ b/tests/game/data/cards.xml @@ -0,0 +1,64 @@ + + + + + Cat + CAT + 0 + Meow! + + 111 + G + 2G + 2 + Creature — Cat + Creature + 3/3 + + + + Grizzly Bears + LEA + 0 + 1 + + + 112 + G + 1G + 2 + Creature — Bear + Creature + 2/2 + + + + Lightning Bolt + LEA + 3 + Lightning Bolt deals 3 damage to any target. + + 113 + R + R + 1 + Instant + Instant + + + + Forest + LEA + 2 + (Add G.) + + 114 + G + + 0 + Land + Land + + + + diff --git a/tests/game/data/spoilers.xml b/tests/game/data/spoilers.xml new file mode 100644 index 000000000..414ae10d4 --- /dev/null +++ b/tests/game/data/spoilers.xml @@ -0,0 +1,4 @@ + + + + diff --git a/tests/game/data/tokens.xml b/tests/game/data/tokens.xml new file mode 100644 index 000000000..30edbb24c --- /dev/null +++ b/tests/game/data/tokens.xml @@ -0,0 +1,21 @@ + + + + + Kitten + CAT + 0 + + 1 + + 211 + G + + + Token + Token + 1/1 + + + + diff --git a/tests/game/game_event_handler_test.cpp b/tests/game/game_event_handler_test.cpp new file mode 100644 index 000000000..2db6a1660 --- /dev/null +++ b/tests/game/game_event_handler_test.cpp @@ -0,0 +1,566 @@ +#include "game_test_fixture.h" + +#include "gtest/gtest.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + +/** + * Wraps one player-scoped event in the container the server would send and + * pushes it through the real dispatcher, so the test exercises the same + * GameEventHandler -> PlayerEventHandler path as a live client. + */ +template +void sendGameEvent(GameHarness &harness, + int playerId, + const EventType &message, + const GameEventContext *context = nullptr) +{ + GameEventContainer container; + container.set_game_id(42); + if (context != nullptr) { + container.mutable_context()->CopyFrom(*context); + } + + GameEvent *event = container.add_event_list(); + event->set_player_id(playerId); + event->MutableExtension(EventType::ext)->CopyFrom(message); + + harness.game()->getGameEventHandler()->processGameEventContainer(container, harness.client(), + EventProcessingOptions()); +} + +Event_SetCardAttr cardAttrEvent(const char *zoneName, int cardId, CardAttribute attribute, const char *value) +{ + Event_SetCardAttr event; + event.set_zone_name(zoneName); + event.set_card_id(cardId); + event.set_attribute(attribute); + event.set_attr_value(value); + return event; +} + +} // namespace + +TEST(GameEventHandlerTest, MoveCardMovesBetweenZones) +{ + GameHarness harness; + CardState *cat = harness.addCardToZone(ZoneNames::HAND, QStringLiteral("Cat"), 7); + ASSERT_NE(cat, nullptr); + + Event_MoveCard event; + event.set_card_id(7); + event.set_start_player_id(1); + event.set_start_zone(ZoneNames::HAND); + event.set_position(0); + event.set_target_player_id(1); + event.set_target_zone(ZoneNames::TABLE); + event.set_x(0); + event.set_y(TableZoneLogic::tableRowToGridY(0)); + event.set_new_card_id(7); + event.set_face_down(false); + + sendGameEvent(harness, 1, event); + + EXPECT_TRUE(harness.zone(ZoneNames::HAND)->getCards().isEmpty()); + CardZoneLogic *table = harness.zone(ZoneNames::TABLE); + ASSERT_EQ(table->getCards().size(), 1); + EXPECT_EQ(table->getCards().first(), cat); + EXPECT_EQ(cat->getZone(), table); + EXPECT_EQ(cat->getId(), 7); + EXPECT_EQ(cat->getCardRef().name, QStringLiteral("Cat")); + EXPECT_FALSE(cat->getFaceDown()); + EXPECT_EQ(cat->getPT(), QStringLiteral("3/3")); + EXPECT_EQ(cat->getGridPoint(), QPoint(0, TableZoneLogic::tableRowToGridY(0))); +} + +TEST(GameEventHandlerTest, MoveCardFaceDownKeepsTheCardHidden) +{ + GameHarness harness; + CardState *cat = harness.addCardToZone(ZoneNames::HAND, QStringLiteral("Cat"), 7); + ASSERT_NE(cat, nullptr); + + Event_MoveCard event; + event.set_card_id(7); + event.set_start_player_id(1); + event.set_start_zone(ZoneNames::HAND); + event.set_position(0); + event.set_target_player_id(1); + event.set_target_zone(ZoneNames::TABLE); + event.set_x(1); + event.set_y(TableZoneLogic::tableRowToGridY(2)); + event.set_new_card_id(7); + event.set_face_down(true); + + sendGameEvent(harness, 1, event); + + ASSERT_EQ(harness.zone(ZoneNames::TABLE)->getCards().size(), 1); + EXPECT_TRUE(cat->getFaceDown()); + // A face-down card keeps whatever P/T it had instead of picking up the + // printed one from the card database. + EXPECT_TRUE(cat->getPT().isEmpty()); +} + +TEST(GameEventHandlerTest, SetCardAttrUpdatesSingleCard) +{ + GameHarness harness; + CardState *cat = harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Cat"), 7); + ASSERT_NE(cat, nullptr); + + sendGameEvent(harness, 1, cardAttrEvent(ZoneNames::TABLE, 7, AttrTapped, "1")); + sendGameEvent(harness, 1, cardAttrEvent(ZoneNames::TABLE, 7, AttrAttacking, "1")); + sendGameEvent(harness, 1, cardAttrEvent(ZoneNames::TABLE, 7, AttrPT, "5/5")); + sendGameEvent(harness, 1, cardAttrEvent(ZoneNames::TABLE, 7, AttrAnnotation, "note")); + sendGameEvent(harness, 1, cardAttrEvent(ZoneNames::TABLE, 7, AttrColor, "red")); + sendGameEvent(harness, 1, cardAttrEvent(ZoneNames::TABLE, 7, AttrDoesntUntap, "1")); + sendGameEvent(harness, 1, cardAttrEvent(ZoneNames::TABLE, 7, AttrFaceDown, "1")); + + EXPECT_TRUE(cat->getTapped()); + EXPECT_TRUE(cat->getAttacking()); + EXPECT_EQ(cat->getPT(), QStringLiteral("5/5")); + EXPECT_EQ(cat->getAnnotation(), QStringLiteral("note")); + EXPECT_EQ(cat->getColor(), QStringLiteral("red")); + EXPECT_TRUE(cat->getDoesntUntap()); + EXPECT_TRUE(cat->getFaceDown()); +} + +TEST(GameEventHandlerTest, SetCardAttrWithoutCardIdTouchesEveryCardInZone) +{ + GameHarness harness; + CardState *cat = harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Cat"), 7); + CardState *bears = harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Grizzly Bears"), 9); + ASSERT_NE(cat, nullptr); + ASSERT_NE(bears, nullptr); + + Event_SetCardAttr event; + event.set_zone_name(ZoneNames::TABLE); + event.set_attribute(AttrAnnotation); + event.set_attr_value("marked"); + + sendGameEvent(harness, 1, event); + + EXPECT_EQ(cat->getAnnotation(), QStringLiteral("marked")); + EXPECT_EQ(bears->getAnnotation(), QStringLiteral("marked")); +} + +TEST(GameEventHandlerTest, UntapAllLeavesDoesntUntapCardsTapped) +{ + GameHarness harness; + CardState *cat = harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Cat"), 7); + CardState *bears = harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Grizzly Bears"), 9); + ASSERT_NE(cat, nullptr); + ASSERT_NE(bears, nullptr); + cat->setDoesntUntap(true); + + sendGameEvent(harness, 1, cardAttrEvent(ZoneNames::TABLE, 7, AttrTapped, "1")); + sendGameEvent(harness, 1, cardAttrEvent(ZoneNames::TABLE, 9, AttrTapped, "1")); + + Event_SetCardAttr untapAll; + untapAll.set_zone_name(ZoneNames::TABLE); + untapAll.set_attribute(AttrTapped); + untapAll.set_attr_value("0"); + + sendGameEvent(harness, 1, untapAll); + + EXPECT_TRUE(cat->getTapped()); + EXPECT_FALSE(bears->getTapped()); +} + +TEST(GameEventHandlerTest, SetCardCounterAddsAndRemoves) +{ + GameHarness harness; + CardState *cat = harness.addCardToZone(ZoneNames::HAND, QStringLiteral("Cat"), 7); + ASSERT_NE(cat, nullptr); + + Event_SetCardCounter add; + add.set_zone_name(ZoneNames::HAND); + add.set_card_id(7); + add.set_counter_id(1); + add.set_counter_value(2); + + sendGameEvent(harness, 1, add); + + EXPECT_EQ(cat->getCounters().value(1), 2); + + Event_SetCardCounter remove = add; + remove.set_counter_value(0); + + sendGameEvent(harness, 1, remove); + + EXPECT_FALSE(cat->getCounters().contains(1)); +} + +TEST(GameEventHandlerTest, AttachCardLinksBothEndsAndUnattachClearsThem) +{ + GameHarness harness; + CardState *host = harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Cat"), 7); + CardState *guest = harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Grizzly Bears"), 9); + ASSERT_NE(host, nullptr); + ASSERT_NE(guest, nullptr); + + Event_AttachCard attach; + attach.set_start_zone(ZoneNames::TABLE); + attach.set_card_id(9); + attach.set_target_player_id(1); + attach.set_target_zone(ZoneNames::TABLE); + attach.set_target_card_id(7); + + sendGameEvent(harness, 1, attach); + + EXPECT_EQ(guest->getAttachedTo(), host); + ASSERT_EQ(host->getAttachedCards().size(), 1); + EXPECT_EQ(host->getAttachedCards().first(), guest); + + Event_AttachCard detach; + detach.set_start_zone(ZoneNames::TABLE); + detach.set_card_id(9); + + sendGameEvent(harness, 1, detach); + + EXPECT_EQ(guest->getAttachedTo(), nullptr); + EXPECT_TRUE(host->getAttachedCards().isEmpty()); +} + +TEST(GameEventHandlerTest, ChangeZonePropertiesUpdatesReveal) +{ + GameHarness harness; + CardZoneLogic *deck = harness.zone(ZoneNames::DECK); + ASSERT_NE(deck, nullptr); + + Event_ChangeZoneProperties reveal; + reveal.set_zone_name(ZoneNames::DECK); + reveal.set_always_reveal_top_card(true); + + sendGameEvent(harness, 1, reveal); + EXPECT_TRUE(deck->getAlwaysRevealTopCard()); + + reveal.set_always_reveal_top_card(false); + sendGameEvent(harness, 1, reveal); + EXPECT_FALSE(deck->getAlwaysRevealTopCard()); +} + +TEST(GameEventHandlerTest, DrawCardsMovesNamedCardsFromDeckToHand) +{ + GameHarness harness; + ASSERT_NE(harness.addCardToZone(ZoneNames::DECK, QStringLiteral("Cat"), 7), nullptr); + + Event_DrawCards draw; + draw.set_number(1); + ServerInfo_Card *cardInfo = draw.add_cards(); + cardInfo->set_id(7); + cardInfo->set_name("Cat"); + + sendGameEvent(harness, 1, draw); + + EXPECT_TRUE(harness.zone(ZoneNames::DECK)->getCards().isEmpty()); + ASSERT_EQ(harness.zone(ZoneNames::HAND)->getCards().size(), 1); + CardState *drawn = harness.zone(ZoneNames::HAND)->getCards().first(); + EXPECT_EQ(drawn->getId(), 7); + EXPECT_EQ(drawn->getName(), QStringLiteral("Cat")); +} + +TEST(GameEventHandlerTest, FlipCardHidesAndRevealsTheCard) +{ + GameHarness harness; + CardState *cat = harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Cat"), 7); + ASSERT_NE(cat, nullptr); + + Event_FlipCard flip; + flip.set_zone_name(ZoneNames::TABLE); + flip.set_card_id(7); + flip.set_face_down(true); + + sendGameEvent(harness, 1, flip); + + EXPECT_TRUE(cat->getFaceDown()); + // Flipping face down does not touch the card reference: the client must not + // learn (or overwrite) the name of a card it is not allowed to see. + EXPECT_EQ(cat->getName(), QStringLiteral("Cat")); + + // Turning it face up carries the real printing, so the state picks it up. + flip.set_face_down(false); + flip.set_card_name("Grizzly Bears"); + + sendGameEvent(harness, 1, flip); + + EXPECT_FALSE(cat->getFaceDown()); + EXPECT_EQ(cat->getName(), QStringLiteral("Grizzly Bears")); +} + +TEST(GameEventHandlerTest, DestroyCardRemovesItAndDetachesAttachedCards) +{ + GameHarness harness; + CardState *host = harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Cat"), 7); + CardState *guest = harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Grizzly Bears"), 9); + ASSERT_NE(host, nullptr); + ASSERT_NE(guest, nullptr); + + guest->setAttachedTo(host); + ASSERT_EQ(guest->getAttachedTo(), host); + ASSERT_EQ(host->getAttachedCards().size(), 1); + + Event_DestroyCard destroy; + destroy.set_zone_name(ZoneNames::TABLE); + destroy.set_card_id(7); + + sendGameEvent(harness, 1, destroy); + + CardZoneLogic *table = harness.zone(ZoneNames::TABLE); + ASSERT_EQ(table->getCards().size(), 1); + EXPECT_EQ(table->getCards().first(), guest); + EXPECT_FALSE(table->getCards().contains(host)); + + // The attached card survives the host and is left un-attached, ready to be + // laid out again by the zone. + EXPECT_EQ(guest->getAttachedTo(), nullptr); + EXPECT_TRUE(host->getAttachedCards().isEmpty()); + EXPECT_EQ(guest->getZone(), table); +} + +TEST(GameEventHandlerTest, CreateTokenUsesDatabasePTUnlessFaceDown) +{ + GameHarness harness; + CardZoneLogic *table = harness.zone(ZoneNames::TABLE); + ASSERT_NE(table, nullptr); + + QList created; + QObject::connect(table, &CardZoneLogic::createCardRequested, table, + [&created](const ServerInfo_Card &cardInfo, bool) { created.append(cardInfo); }); + + Event_CreateToken token; + token.set_zone_name(ZoneNames::TABLE); + token.set_card_id(11); + token.set_card_name("Kitten"); + token.set_x(2); + token.set_y(1); + token.set_color("green"); + token.set_annotation("a kitten"); + token.set_destroy_on_zone_change(true); + + sendGameEvent(harness, 1, token); + + ASSERT_EQ(created.size(), 1); + // The event carries no P/T, so the token takes the printed one. + EXPECT_EQ(created.first().pt(), "1/1"); + EXPECT_EQ(created.first().name(), "Kitten"); + EXPECT_EQ(created.first().x(), 2); + EXPECT_EQ(created.first().y(), 1); + EXPECT_EQ(created.first().color(), "green"); + EXPECT_EQ(created.first().annotation(), "a kitten"); + EXPECT_TRUE(created.first().destroy_on_zone_change()); + EXPECT_FALSE(created.first().face_down()); + + // An explicit P/T always wins over the database. + Event_CreateToken explicitPt = token; + explicitPt.set_card_id(12); + explicitPt.set_pt("3/3"); + + sendGameEvent(harness, 1, explicitPt); + + ASSERT_EQ(created.size(), 2); + EXPECT_EQ(created.last().pt(), "3/3"); + + // A face-down token must not leak the printed P/T. + Event_CreateToken faceDown = token; + faceDown.set_card_id(13); + faceDown.set_face_down(true); + + sendGameEvent(harness, 1, faceDown); + + ASSERT_EQ(created.size(), 3); + EXPECT_TRUE(created.last().face_down()); + EXPECT_TRUE(created.last().pt().empty()); +} + +TEST(GameEventHandlerTest, ConcedeAndUnconcedeUpdatePlayerState) +{ + GameHarness harness; + ASSERT_NE(harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Cat"), 7), nullptr); + EXPECT_FALSE(harness.player()->getConceded()); + + Event_PlayerPropertiesChanged properties; + properties.mutable_player_properties()->set_player_id(1); + properties.mutable_player_properties()->set_conceded(true); + + GameEventContext concedeContext; + concedeContext.MutableExtension(Context_Concede::ext); + + sendGameEvent(harness, 1, properties, &concedeContext); + + EXPECT_TRUE(harness.player()->getConceded()); + // Conceding tears the board down for that player. + EXPECT_TRUE(harness.zone(ZoneNames::TABLE)->getCards().isEmpty()); + + Event_PlayerPropertiesChanged back = properties; + back.mutable_player_properties()->set_conceded(false); + + GameEventContext unconcedeContext; + unconcedeContext.MutableExtension(Context_Unconcede::ext); + + sendGameEvent(harness, 1, back, &unconcedeContext); + + EXPECT_FALSE(harness.player()->getConceded()); +} + +TEST(GameEventHandlerTest, SetActivePlayerAndPhaseUpdateGameState) +{ + GameHarness harness; + GameState *state = harness.game()->getGameState(); + ASSERT_NE(state, nullptr); + EXPECT_EQ(state->getActivePlayer(), -1); + EXPECT_EQ(state->getCurrentPhase(), -1); + + Event_SetActivePlayer setPlayer; + setPlayer.set_active_player_id(1); + + sendGameEvent(harness, 1, setPlayer); + + EXPECT_EQ(state->getActivePlayer(), 1); + + Event_SetActivePhase setPhase; + setPhase.set_phase(3); + + sendGameEvent(harness, 1, setPhase); + + EXPECT_EQ(state->getCurrentPhase(), 3); +} + +TEST(GameEventHandlerTest, CreateSetAndDeletePlayerCounter) +{ + GameHarness harness; + + ServerInfo_Counter counter; + counter.set_id(1); + counter.set_name("life"); + counter.set_radius(1); + counter.set_count(20); + + Event_CreateCounter create; + create.mutable_counter_info()->CopyFrom(counter); + + sendGameEvent(harness, 1, create); + + CounterState *state = harness.player()->getCounters().value(1); + ASSERT_NE(state, nullptr); + EXPECT_EQ(state->getName(), QStringLiteral("life")); + EXPECT_EQ(state->getValue(), 20); + EXPECT_EQ(harness.player()->getLifeCounter(), state); + + Event_SetCounter set; + set.set_counter_id(1); + set.set_value(15); + + sendGameEvent(harness, 1, set); + + EXPECT_EQ(state->getValue(), 15); + + // Updating a counter that does not exist is a no-op, not a crash. + Event_SetCounter unknown = set; + unknown.set_counter_id(99); + sendGameEvent(harness, 1, unknown); + + EXPECT_FALSE(harness.player()->getCounters().contains(99)); + + Event_DelCounter remove; + remove.set_counter_id(1); + + sendGameEvent(harness, 1, remove); + + EXPECT_FALSE(harness.player()->getCounters().contains(1)); + EXPECT_EQ(harness.player()->getLifeCounter(), nullptr); +} + +TEST(GameEventHandlerTest, LeaveRemovesThePlayer) +{ + GameHarness harness; + ASSERT_NE(harness.addCardToZone(ZoneNames::TABLE, QStringLiteral("Cat"), 7), nullptr); + ASSERT_NE(harness.game()->getPlayerManager()->getPlayer(1), nullptr); + + Event_Leave leave; + leave.set_reason(Event_Leave::USER_LEFT); + + sendGameEvent(harness, 1, leave); + + EXPECT_EQ(harness.game()->getPlayerManager()->getPlayer(1), nullptr); + EXPECT_TRUE(harness.game()->getPlayerManager()->getPlayers().isEmpty()); +} + +TEST(GameEventHandlerTest, KickedSignalsGameClosed) +{ + GameHarness harness; + + bool closed = false; + QObject::connect(harness.game()->getGameEventHandler(), &GameEventHandler::gameClosed, + [&closed] { closed = true; }); + + sendGameEvent(harness, 1, Event_Kicked()); + + EXPECT_TRUE(closed); +} + +TEST(GameEventHandlerTest, GameClosedMarksTheSessionClosed) +{ + GameHarness harness; + ASSERT_FALSE(harness.game()->getGameState()->isGameClosed()); + + Event_GameStateChanged started; + started.set_game_started(true); + sendGameEvent(harness, 1, started); + ASSERT_TRUE(harness.game()->getGameMetaInfo()->started()); + + sendGameEvent(harness, 1, Event_GameClosed()); + + EXPECT_TRUE(harness.game()->getGameState()->isGameClosed()); + EXPECT_FALSE(harness.game()->getGameMetaInfo()->started()); +} + +TEST(GameEventHandlerTest, GameHostChangedMovesTheHostRole) +{ + GameHarness harness; + + ServerInfo_User bob; + bob.set_name("Bob"); + bob.set_user_level(ServerInfo_User::IsUser); + ASSERT_NE(harness.game()->getPlayerManager()->addPlayer(2, bob), nullptr); + + sendGameEvent(harness, 2, Event_GameHostChanged()); + + EXPECT_EQ(harness.game()->getGameState()->getHostId(), 2); +} diff --git a/tests/game/game_test_fixture.h b/tests/game/game_test_fixture.h new file mode 100644 index 000000000..e4703c917 --- /dev/null +++ b/tests/game/game_test_fixture.h @@ -0,0 +1,129 @@ +/** + * @file game_test_fixture.h + * + * Fixtures for tests that need a complete game/player/zone graph without the + * client, a server or any graphics object. Commands are captured on a recording + * AbstractClient, which is the same seam the real client uses. + */ + +#ifndef COCKATRICE_TESTS_GAME_TEST_FIXTURE_H +#define COCKATRICE_TESTS_GAME_TEST_FIXTURE_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/** + * Captures every command the game logic sends, instead of writing it to a socket. + * + * AbstractClient::sendCommand uses a direct signal/slot connection in the same + * thread, so a command is in `sent` as soon as sendCommand returns. + */ +class RecordingClient : public AbstractClient +{ +public: + QList sent; + +protected: + void sendCommandContainer(const CommandContainer &cont) override + { + sent.append(cont); + } +}; + +/** + * Owns a local, single-client Game with one local player, plus the cards added + * to its zones. + */ +class GameHarness +{ +public: + explicit GameHarness(int playerId = 1) : playerId_(playerId), client_(std::make_unique()) + { + Event_GameJoined event; + event.set_host_id(playerId_); + event.set_player_id(playerId_); + event.mutable_game_info()->set_game_id(42); + event.mutable_game_info()->set_started(true); + + QList clients{client_.get()}; + QMap roomGameTypes; + game_ = std::make_unique(nullptr, true, clients, event, roomGameTypes); + + ServerInfo_User user; + user.set_name("Alice"); + user.set_user_level(ServerInfo_User::IsUser | ServerInfo_User::IsRegistered); + game_->getPlayerManager()->addPlayer(playerId_, user); + } + + ~GameHarness() + { + qDeleteAll(ownedCards_); + ownedCards_.clear(); + } + + GameHarness(const GameHarness &) = delete; + GameHarness &operator=(const GameHarness &) = delete; + + [[nodiscard]] Game *game() const + { + return game_.get(); + } + + [[nodiscard]] RecordingClient *client() const + { + return client_.get(); + } + + [[nodiscard]] PlayerLogic *player() const + { + return game_->getPlayerManager()->getPlayer(playerId_); + } + + [[nodiscard]] CardZoneLogic *zone(const QString &zoneName) const + { + return player()->getZones().value(zoneName); + } + + CardState *addCardToZone(const QString &zoneName, const QString &cardName, int cardId) + { + CardZoneLogic *targetZone = zone(zoneName); + if (!targetZone) { + return nullptr; + } + + auto *card = new CardState(nullptr, targetZone, CardRef{cardName, QString()}, cardId); + // The constructor only stores the ref; this is what fills in the card + // data (table row, P/T, ...) the actions read. + card->refreshCardInfo(); + targetZone->addCard(card, false, 0); + ownedCards_.append(card); + return card; + } + + [[nodiscard]] const CommandContainer &lastCommand() const + { + return client_->sent.last(); + } + +private: + int playerId_; + std::unique_ptr client_; + std::unique_ptr game_; + QList ownedCards_; +}; + +#endif // COCKATRICE_TESTS_GAME_TEST_FIXTURE_H diff --git a/tests/game/game_test_main.cpp b/tests/game/game_test_main.cpp new file mode 100644 index 000000000..47b9a6b8e --- /dev/null +++ b/tests/game/game_test_main.cpp @@ -0,0 +1,126 @@ +/** + * @file game_test_main.cpp + * + * Test entry point for the headless game-logic suite. The game library has no + * graphics dependency, so a QCoreApplication is enough; the only process-wide + * setup left is the card database, which the game logic still reaches through + * CardDatabaseManager and which therefore has to exist before the first zone is + * constructed. + */ + +#include "gtest/gtest.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + +class GameTestPathProvider : public ICardDatabasePathProvider +{ +public: + explicit GameTestPathProvider(QString baseDir) : baseDir_(std::move(baseDir)) + { + } + + QString getCardDatabasePath() const override + { + return baseDir_ + QStringLiteral("/cards.xml"); + } + + QString getCustomCardDatabasePath() const override + { + return baseDir_ + QStringLiteral("/customsets/"); + } + + QString getTokenDatabasePath() const override + { + return baseDir_ + QStringLiteral("/tokens.xml"); + } + + QString getSpoilerCardDatabasePath() const override + { + return baseDir_ + QStringLiteral("/spoilers.xml"); + } + +private: + QString baseDir_; +}; + +bool copyFixture(const QString &fileName, const QString &targetDir) +{ + const QString source = QString::fromUtf8(GAME_TEST_DATA_DIR) + QLatin1Char('/') + fileName; + const QString target = targetDir + QLatin1Char('/') + fileName; + if (QFile::copy(source, target)) { + return true; + } + + std::fprintf(stderr, "Failed to copy test fixture %s to %s\n", qPrintable(source), qPrintable(target)); + return false; +} + +} // namespace + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + + // gtest_discover_tests runs the binary with --gtest_list_tests while the + // build is still in progress. No test runs there, and building the card + // database would leave process-lifetime allocations that the sanitizer + // leg's default leak detection reports at exit, failing discovery. + if (::testing::GTEST_FLAG(list_tests)) { + return RUN_ALL_TESTS(); + } + + QStandardPaths::setTestModeEnabled(true); + + QTemporaryDir home; + if (!home.isValid()) { + std::fprintf(stderr, "Failed to create a temporary home directory\n"); + return EXIT_FAILURE; + } + qputenv("HOME", home.path().toLocal8Bit()); + + QCoreApplication app(argc, argv); + QLoggingCategory::setFilterRules(QStringLiteral("card_database.loading=false\n" + "card_database.loading.success_or_failure=false\n" + "settings_cache.*=false")); + + // Copy the fixtures so the database loader never writes its .cache file into + // the source tree, then point the singleton at the copy. + QTemporaryDir dataDir; + if (!dataDir.isValid() || !QDir().mkpath(dataDir.filePath(QStringLiteral("customsets")))) { + std::fprintf(stderr, "Failed to create a temporary card database directory\n"); + return EXIT_FAILURE; + } + if (!copyFixture(QStringLiteral("cards.xml"), dataDir.path()) || + !copyFixture(QStringLiteral("tokens.xml"), dataDir.path()) || + !copyFixture(QStringLiteral("spoilers.xml"), dataDir.path())) { + return EXIT_FAILURE; + } + + // Function-local static: constructed before the CardDatabase singleton below, + // so it is destroyed after it and the raw pointer stored in the manager stays + // valid for the database's lifetime. + static GameTestPathProvider pathProvider(dataDir.path()); + CardDatabaseManager::setCardDatabasePathProvider(&pathProvider); + + CardDatabase *database = CardDatabaseManager::getInstance(); + database->loadCardDatabases(); + if (database->getLoadStatus() != Ok) { + std::fprintf(stderr, "Failed to load the game test card database (status %d)\n", + static_cast(database->getLoadStatus())); + return EXIT_FAILURE; + } + + return RUN_ALL_TESTS(); +} diff --git a/tests/game/player_actions_test.cpp b/tests/game/player_actions_test.cpp new file mode 100644 index 000000000..11ae7338d --- /dev/null +++ b/tests/game/player_actions_test.cpp @@ -0,0 +1,119 @@ +#include "game_test_fixture.h" + +#include "gtest/gtest.h" +#include +#include +#include +#include +#include +#include + +TEST(PlayerActionsTest, PlayCreatureSendsMoveToTable) +{ + GameHarness harness; + CardState *cat = harness.addCardToZone(ZoneNames::HAND, QStringLiteral("Cat"), 7); + ASSERT_NE(cat, nullptr); + + harness.player()->getPlayerActions()->playCard(cat, false); + + ASSERT_FALSE(harness.client()->sent.isEmpty()); + const CommandContainer &container = harness.lastCommand(); + ASSERT_EQ(container.game_command_size(), 1); + const Command_MoveCard command = container.game_command(0).GetExtension(Command_MoveCard::ext); + + EXPECT_EQ(command.start_player_id(), 1); + EXPECT_EQ(command.start_zone(), std::string(ZoneNames::HAND)); + EXPECT_EQ(command.target_player_id(), 1); + EXPECT_EQ(command.target_zone(), std::string(ZoneNames::TABLE)); + EXPECT_EQ(command.x(), -1); + EXPECT_EQ(command.y(), TableZoneLogic::tableRowToGridY(0)); + + ASSERT_EQ(command.cards_to_move().card_size(), 1); + const CardToMove &card = command.cards_to_move().card(0); + EXPECT_EQ(card.card_id(), 7); + EXPECT_TRUE(card.has_face_down()); + EXPECT_FALSE(card.face_down()); + EXPECT_TRUE(card.has_pt()); + EXPECT_EQ(card.pt(), "3/3"); + EXPECT_TRUE(card.has_tapped()); + EXPECT_FALSE(card.tapped()); +} + +TEST(PlayerActionsTest, PlayInstantSendsMoveToStack) +{ + GameHarness harness; + CardState *bolt = harness.addCardToZone(ZoneNames::HAND, QStringLiteral("Lightning Bolt"), 8); + ASSERT_NE(bolt, nullptr); + + harness.player()->getPlayerActions()->playCard(bolt, false); + + ASSERT_FALSE(harness.client()->sent.isEmpty()); + const CommandContainer &container = harness.lastCommand(); + ASSERT_EQ(container.game_command_size(), 1); + const Command_MoveCard command = container.game_command(0).GetExtension(Command_MoveCard::ext); + + EXPECT_EQ(command.target_zone(), std::string(ZoneNames::STACK)); + EXPECT_EQ(command.x(), -1); + EXPECT_EQ(command.y(), 0); + + ASSERT_EQ(command.cards_to_move().card_size(), 1); + const CardToMove &card = command.cards_to_move().card(0); + EXPECT_EQ(card.card_id(), 8); + // The stack branch deliberately leaves the other attributes unset. + EXPECT_FALSE(card.has_face_down()); + EXPECT_FALSE(card.has_pt()); + EXPECT_FALSE(card.has_tapped()); +} + +TEST(PlayerActionsTest, PlayFaceDownCreatureUsesFaceDownRow) +{ + GameHarness harness; + CardState *cat = harness.addCardToZone(ZoneNames::HAND, QStringLiteral("Cat"), 7); + ASSERT_NE(cat, nullptr); + + harness.player()->getPlayerActions()->playCard(cat, true); + + ASSERT_FALSE(harness.client()->sent.isEmpty()); + const CommandContainer &container = harness.lastCommand(); + ASSERT_EQ(container.game_command_size(), 1); + const Command_MoveCard command = container.game_command(0).GetExtension(Command_MoveCard::ext); + + EXPECT_EQ(command.target_zone(), std::string(ZoneNames::TABLE)); + EXPECT_EQ(command.x(), -1); + EXPECT_EQ(command.y(), TableZoneLogic::tableRowToGridY(2)); + + ASSERT_EQ(command.cards_to_move().card_size(), 1); + const CardToMove &card = command.cards_to_move().card(0); + EXPECT_EQ(card.card_id(), 7); + EXPECT_TRUE(card.has_face_down()); + EXPECT_TRUE(card.face_down()); + EXPECT_FALSE(card.has_pt()); + EXPECT_TRUE(card.has_tapped()); + EXPECT_FALSE(card.tapped()); +} + +TEST(PlayerActionsTest, PlayCiptCreatureSendsTapped) +{ + GameHarness harness; + CardState *bears = harness.addCardToZone(ZoneNames::HAND, QStringLiteral("Grizzly Bears"), 9); + ASSERT_NE(bears, nullptr); + + harness.player()->getPlayerActions()->playCard(bears, false); + + ASSERT_FALSE(harness.client()->sent.isEmpty()); + const CommandContainer &container = harness.lastCommand(); + ASSERT_EQ(container.game_command_size(), 1); + const Command_MoveCard command = container.game_command(0).GetExtension(Command_MoveCard::ext); + + EXPECT_EQ(command.target_zone(), std::string(ZoneNames::TABLE)); + + ASSERT_EQ(command.cards_to_move().card_size(), 1); + const CardToMove &card = command.cards_to_move().card(0); + EXPECT_EQ(card.card_id(), 9); + EXPECT_TRUE(card.has_face_down()); + EXPECT_FALSE(card.face_down()); + EXPECT_TRUE(card.has_pt()); + EXPECT_EQ(card.pt(), "2/2"); + EXPECT_TRUE(card.has_tapped()); + EXPECT_TRUE(card.tapped()); +} diff --git a/tests/game/replay_test.cpp b/tests/game/replay_test.cpp new file mode 100644 index 000000000..218ffdf51 --- /dev/null +++ b/tests/game/replay_test.cpp @@ -0,0 +1,349 @@ +/** + * @file replay_test.cpp + * + * Replays a synthetic GameReplay through the real dispatcher. This is the only + * place that exercises PlayerLogic::processPlayerInfo / processCardAttachment + * end to end: the game state arrives as a GAME_STATE_CHANGED event and the + * graphics side is replaced by a small materializer that mirrors what + * CardZone::onCreateCardRequested does with the logic objects. + * + * The replay is built in code rather than from a .cor file: event application + * is deterministic (the client never shuffles on its own for a replay), and a + * checked-in binary fixture would be opaque about what it asserts. + */ + +#include "gtest/gtest.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + +ServerInfo_Card makeCard(int id, const QString &name, int x = -1, int y = -1) +{ + ServerInfo_Card card; + card.set_id(id); + card.set_name(name.toStdString()); + card.set_x(x); + card.set_y(y); + return card; +} + +ServerInfo_Zone makeZone(const char *name, + ServerInfo_Zone::ZoneType type, + bool withCoords, + const QList &cards, + int cardCount = 0) +{ + ServerInfo_Zone zone; + zone.set_name(name); + zone.set_type(type); + zone.set_with_coords(withCoords); + for (const auto &card : cards) { + zone.add_card_list()->CopyFrom(card); + } + if (cardCount > 0) { + zone.set_card_count(cardCount); + } + return zone; +} + +ServerInfo_Player makePlayer(int playerId, const QString &name, const QList &zones) +{ + ServerInfo_Player player; + ServerInfo_PlayerProperties *properties = player.mutable_properties(); + properties->set_player_id(playerId); + properties->mutable_user_info()->set_name(name.toStdString()); + properties->mutable_user_info()->set_user_level(ServerInfo_User::IsUser); + for (const auto &zone : zones) { + player.add_zone_list()->CopyFrom(zone); + } + return player; +} + +template GameEventContainer makeContainer(int playerId, const EventType &message) +{ + GameEventContainer container; + container.set_game_id(42); + + GameEvent *event = container.add_event_list(); + event->set_player_id(playerId); + event->MutableExtension(EventType::ext)->CopyFrom(message); + + return container; +} + +/** + * Stands in for the graphics side of a replay: when the logic asks for a card + * item, create the CardState and add it to the zone, copying the state fields + * the real CardItem::processCardInfo applies. + * + * The production view owns the visual item; the state itself is all a headless + * test needs, and this is the smallest thing that lets processPlayerInfo build + * a real zone. + */ +class HeadlessZoneView : public QObject +{ +public: + using QObject::QObject; + + ~HeadlessZoneView() override + { + qDeleteAll(cards_); + cards_.clear(); + } + + void attach(PlayerLogic *player) + { + for (CardZoneLogic *zone : player->getZones()) { + connect(zone, &CardZoneLogic::createCardRequested, this, + [this, zone](const ServerInfo_Card &cardInfo, bool reorganize) { + materialize(zone, cardInfo, reorganize); + }); + } + } + +private: + void materialize(CardZoneLogic *zone, const ServerInfo_Card &info, bool reorganize) + { + const CardRef cardRef{QString::fromStdString(info.name()), QString::fromStdString(info.provider_id())}; + auto *card = new CardState(nullptr, zone, cardRef, info.id()); + // The real CardItem constructor refreshes the card info before + // processCardInfo overwrites the state fields. + card->refreshCardInfo(); + + card->clearCounters(); + for (int i = 0; i < info.counter_list_size(); ++i) { + card->insertCounter(info.counter_list(i).id(), info.counter_list(i).value()); + } + card->setAttacking(info.attacking()); + card->setFaceDown(info.face_down()); + card->setPT(QString::fromStdString(info.pt())); + card->setAnnotation(QString::fromStdString(info.annotation())); + card->setColor(QString::fromStdString(info.color())); + card->setTapped(info.tapped()); + card->setDestroyOnZoneChange(info.destroy_on_zone_change()); + card->setDoesntUntap(info.doesnt_untap()); + + zone->addCard(card, reorganize, info.x(), info.y()); + cards_.append(card); + } + + QList cards_; +}; + +/** Owns a Replay built from a GameReplay and feeds its containers through it. */ +class ReplayHarness +{ +public: + explicit ReplayHarness(GameReplay replay) + : replayProto_(std::move(replay)), view_(std::make_unique()), + replay_(std::make_unique(nullptr, &replayProto_, false)) + { + QObject::connect(replay_->getPlayerManager(), &PlayerManager::playerAdded, view_.get(), + &HeadlessZoneView::attach); + } + + ReplayHarness(const ReplayHarness &) = delete; + ReplayHarness &operator=(const ReplayHarness &) = delete; + + void processAll() + { + for (const GameEventContainer &container : replayProto_.event_list()) { + replay_->getGameEventHandler()->processGameEventContainer(container, nullptr, EventProcessingOptions()); + } + } + + [[nodiscard]] Replay *replay() const + { + return replay_.get(); + } + + [[nodiscard]] PlayerLogic *player(int playerId) const + { + return replay_->getPlayerManager()->getPlayer(playerId); + } + +private: + GameReplay replayProto_; + std::unique_ptr view_; + std::unique_ptr replay_; +}; + +GameReplay makeReplay(const QList &containers) +{ + GameReplay replay; + replay.mutable_game_info()->set_game_id(42); + replay.mutable_game_info()->set_started(true); + for (const auto &container : containers) { + replay.add_event_list()->CopyFrom(container); + } + return replay; +} + +Event_GameStateChanged initialGameState() +{ + Event_GameStateChanged state; + state.set_game_started(true); + state.set_active_player_id(1); + state.set_active_phase(0); + return state; +} + +} // namespace + +TEST(ReplayTest, MaterializesGameStateZonesAndAttachments) +{ + Event_GameStateChanged state = initialGameState(); + + ServerInfo_Card cat = makeCard(7, QStringLiteral("Cat")); + // Cross-player attachment: Alice's card is attached to Bob's creature. + cat.set_attach_player_id(2); + cat.set_attach_zone(ZoneNames::TABLE); + cat.set_attach_card_id(9); + + const ServerInfo_Player alice = makePlayer(1, QStringLiteral("Alice"), + {makeZone(ZoneNames::HAND, ServerInfo_Zone::PrivateZone, false, {cat}), + makeZone(ZoneNames::TABLE, ServerInfo_Zone::PublicZone, true, + {makeCard(9, QStringLiteral("Grizzly Bears"), 2, 1)}), + makeZone(ZoneNames::DECK, ServerInfo_Zone::HiddenZone, false, {}, 2)}); + const ServerInfo_Player bob = makePlayer(2, QStringLiteral("Bob"), + {makeZone(ZoneNames::TABLE, ServerInfo_Zone::PublicZone, true, + {makeCard(9, QStringLiteral("Grizzly Bears"), 1, 0)})}); + + state.add_player_list()->CopyFrom(alice); + state.add_player_list()->CopyFrom(bob); + + ReplayHarness harness(makeReplay({makeContainer(-1, state)})); + harness.processAll(); + + PlayerLogic *aliceLogic = harness.player(1); + PlayerLogic *bobLogic = harness.player(2); + ASSERT_NE(aliceLogic, nullptr); + ASSERT_NE(bobLogic, nullptr); + + CardZoneLogic *hand = aliceLogic->getZones().value(ZoneNames::HAND); + CardZoneLogic *table = aliceLogic->getZones().value(ZoneNames::TABLE); + CardZoneLogic *deck = aliceLogic->getZones().value(ZoneNames::DECK); + ASSERT_NE(hand, nullptr); + ASSERT_NE(table, nullptr); + ASSERT_NE(deck, nullptr); + + ASSERT_EQ(hand->getCards().size(), 1); + CardState *catState = hand->getCards().first(); + EXPECT_EQ(catState->getId(), 7); + EXPECT_EQ(catState->getName(), QStringLiteral("Cat")); + + ASSERT_EQ(table->getCards().size(), 1); + CardState *host = table->getCards().first(); + EXPECT_EQ(host->getName(), QStringLiteral("Grizzly Bears")); + EXPECT_EQ(host->getGridPoint(), QPoint(2, 1)); + + // The hidden deck only carries a count, and the view materializes that many + // unknown cards. + EXPECT_EQ(deck->getCards().size(), 2); + for (const CardState *hidden : deck->getCards()) { + EXPECT_EQ(hidden->getId(), -1); + } + + // processCardAttachment links both sides after every player is materialized. + CardZoneLogic *bobTable = bobLogic->getZones().value(ZoneNames::TABLE); + ASSERT_NE(bobTable, nullptr); + ASSERT_EQ(bobTable->getCards().size(), 1); + CardState *bobHost = bobTable->getCards().first(); + EXPECT_EQ(catState->getAttachedTo(), bobHost); + ASSERT_EQ(bobHost->getAttachedCards().size(), 1); + EXPECT_EQ(bobHost->getAttachedCards().first(), catState); + // The identically numbered card on Alice's own table is a different card. + EXPECT_TRUE(host->getAttachedCards().isEmpty()); + + EXPECT_TRUE(harness.replay()->getGameState()->isGameStateKnown()); +} + +TEST(ReplayTest, AppliesMovesAttributesAndCountersInOrder) +{ + Event_GameStateChanged state = initialGameState(); + state.add_player_list()->CopyFrom(makePlayer( + 1, QStringLiteral("Alice"), + {makeZone(ZoneNames::HAND, ServerInfo_Zone::PrivateZone, false, {makeCard(7, QStringLiteral("Cat"))}), + makeZone(ZoneNames::TABLE, ServerInfo_Zone::PublicZone, true, + {makeCard(9, QStringLiteral("Grizzly Bears"), 0, TableZoneLogic::tableRowToGridY(0))}), + makeZone(ZoneNames::DECK, ServerInfo_Zone::HiddenZone, false, {}, 2)})); + + Event_MoveCard move; + move.set_card_id(7); + move.set_start_player_id(1); + move.set_start_zone(ZoneNames::HAND); + move.set_position(0); + move.set_target_player_id(1); + move.set_target_zone(ZoneNames::TABLE); + move.set_x(3); + move.set_y(TableZoneLogic::tableRowToGridY(1)); + move.set_new_card_id(7); + move.set_face_down(false); + + Event_SetCardAttr tap; + tap.set_zone_name(ZoneNames::TABLE); + tap.set_card_id(7); + tap.set_attribute(AttrTapped); + tap.set_attr_value("1"); + + Event_SetCardCounter counter; + counter.set_zone_name(ZoneNames::TABLE); + counter.set_card_id(7); + counter.set_counter_id(1); + counter.set_counter_value(2); + + ReplayHarness harness(makeReplay( + {makeContainer(-1, state), makeContainer(1, move), makeContainer(1, tap), makeContainer(1, counter)})); + harness.processAll(); + + PlayerLogic *alice = harness.player(1); + ASSERT_NE(alice, nullptr); + CardZoneLogic *hand = alice->getZones().value(ZoneNames::HAND); + CardZoneLogic *table = alice->getZones().value(ZoneNames::TABLE); + ASSERT_NE(hand, nullptr); + ASSERT_NE(table, nullptr); + + EXPECT_TRUE(hand->getCards().isEmpty()); + ASSERT_EQ(table->getCards().size(), 2); + + CardState *moved = table->getCard(7); + ASSERT_NE(moved, nullptr); + EXPECT_EQ(moved->getGridPoint(), QPoint(3, TableZoneLogic::tableRowToGridY(1))); + EXPECT_EQ(moved->getPT(), QStringLiteral("3/3")); + EXPECT_TRUE(moved->getTapped()); + EXPECT_EQ(moved->getCounters().value(1), 2); + + // The untouched creature keeps its own position and state. + CardState *bears = table->getCard(9); + ASSERT_NE(bears, nullptr); + EXPECT_EQ(bears->getGridPoint(), QPoint(0, TableZoneLogic::tableRowToGridY(0))); + EXPECT_FALSE(bears->getTapped()); +} 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/icon_assets/CMakeLists.txt b/tests/icon_assets/CMakeLists.txt new file mode 100644 index 000000000..da9776f8e --- /dev/null +++ b/tests/icon_assets/CMakeLists.txt @@ -0,0 +1,3 @@ +cockatrice_add_test( + NAME icon_assets_test SOURCES icon_assets_test.cpp DEFINITIONS COCKATRICE_SOURCE_DIR="${CMAKE_SOURCE_DIR}" +) diff --git a/tests/icon_assets/icon_assets_test.cpp b/tests/icon_assets/icon_assets_test.cpp new file mode 100644 index 000000000..f0b06d5cc --- /dev/null +++ b/tests/icon_assets/icon_assets_test.cpp @@ -0,0 +1,241 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +const QString ICONS_RELATIVE_PATH = QStringLiteral("cockatrice/resources/icons"); + +QString iconsDir() +{ + return QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)).absoluteFilePath(ICONS_RELATIVE_PATH); +} + +/** @brief True for content art that is allowed to carry full color. */ +bool isContentArt(const QString &relativePath) +{ + return relativePath.startsWith(QStringLiteral("mana/")); +} + +QString violation(const QString &rule, const QString &relativePath) +{ + return rule + QLatin1Char(' ') + relativePath; +} + +/** + * @brief Reads one SVG and returns true when the root carries a usable viewBox. + * + * Also reports malformed XML and editor-only metadata through the out params. + */ +bool inspectSvg(const QString &absolutePath, + bool &validXml, + bool &hasViewBox, + bool &hasEditorMetadata, + bool &hasPhysicalSize) +{ + validXml = false; + hasViewBox = false; + hasEditorMetadata = false; + hasPhysicalSize = false; + + QFile file(absolutePath); + if (!file.open(QIODevice::ReadOnly)) { + return false; + } + + QXmlStreamReader reader(&file); + bool sawRoot = false; + + while (!reader.atEnd()) { + reader.readNext(); + + if (reader.hasError()) { + return false; + } + + if (!reader.isStartElement()) { + continue; + } + + if (!sawRoot) { + sawRoot = true; + if (reader.name() != QStringLiteral("svg")) { + return false; + } + const QString viewBox = reader.attributes().value(QStringLiteral("viewBox")).toString().simplified(); + hasViewBox = viewBox == QStringLiteral("0 0 24 24"); + hasPhysicalSize = !reader.attributes().value(QStringLiteral("width")).isEmpty() || + !reader.attributes().value(QStringLiteral("height")).isEmpty(); + } + + if (reader.name() == QStringLiteral("metadata") || reader.name() == QStringLiteral("namedview")) { + hasEditorMetadata = true; + } + + for (const QXmlStreamAttribute &attribute : reader.attributes()) { + const QString prefix = attribute.prefix().toString(); + if (prefix == QStringLiteral("inkscape") || prefix == QStringLiteral("sodipodi") || + prefix == QStringLiteral("dc") || prefix == QStringLiteral("cc") || prefix == QStringLiteral("rdf")) { + hasEditorMetadata = true; + } + } + } + + validXml = sawRoot && !reader.hasError(); + return validXml; +} + +bool isAllowedPaintValue(const QString &value) +{ + const QString normalized = value.trimmed().toLower(); + return normalized.isEmpty() || normalized == QStringLiteral("none") || + normalized == QStringLiteral("currentcolor") || normalized == QStringLiteral("inherit") || + normalized == QStringLiteral("context-fill") || normalized == QStringLiteral("context-stroke") || + normalized.startsWith(QStringLiteral("url(")); +} + +QRegularExpression paintValuePattern() +{ + static const QRegularExpression pattern( + QStringLiteral(R"re((?:fill|stroke|stop-color|color)\s*[:=]\s*["']?\s*([^"'\s;>]+))re"), + QRegularExpression::CaseInsensitiveOption); + return pattern; +} + +bool hasHardcodedColor(const QString &absolutePath) +{ + QFile file(absolutePath); + if (!file.open(QIODevice::ReadOnly)) { + return false; + } + + const QString content = QString::fromUtf8(file.readAll()); + auto matches = paintValuePattern().globalMatch(content); + while (matches.hasNext()) { + const QString value = matches.next().captured(1).trimmed(); + if (isAllowedPaintValue(value)) { + continue; + } + const QString normalized = value.toLower(); + if (normalized.startsWith(QLatin1Char('#')) || normalized.contains(QStringLiteral("rgb(")) || + normalized.contains(QStringLiteral("hsl(")) || QColor(value).isValid()) { + return true; + } + } + return false; +} + +bool hasCurrentColorToken(const QString &absolutePath) +{ + QFile file(absolutePath); + if (!file.open(QIODevice::ReadOnly)) { + return false; + } + + const QString content = QString::fromUtf8(file.readAll()); + auto matches = paintValuePattern().globalMatch(content); + while (matches.hasNext()) { + if (matches.next().captured(1).trimmed().compare(QStringLiteral("currentColor"), Qt::CaseInsensitive) == 0) { + return true; + } + } + return false; +} + +bool isSchemeVariant(const QString &fileName) +{ + return fileName.contains(QStringLiteral("-dark."), Qt::CaseInsensitive) || + fileName.contains(QStringLiteral("-light."), Qt::CaseInsensitive); +} + +bool hasColorInFilename(const QString &fileName) +{ + static const QRegularExpression colorSuffix( + QStringLiteral(R"(_(green|blue|red|yellow|black|white|gray|grey)\.svg$)"), + QRegularExpression::CaseInsensitiveOption); + return colorSuffix.match(fileName).hasMatch(); +} + +/** @brief Collects every current violation as "rule relative/path.svg". */ +QSet collectViolations() +{ + QSet violations; + + QDirIterator it(iconsDir(), QDir::Files, QDirIterator::Subdirectories); + while (it.hasNext()) { + it.next(); + const QFileInfo info = it.fileInfo(); + const QString relativePath = QDir(iconsDir()).relativeFilePath(info.absoluteFilePath()); + if (isContentArt(relativePath)) { + continue; + } + if (info.suffix().compare(QStringLiteral("svg"), Qt::CaseInsensitive) != 0) { + violations.insert(violation(QStringLiteral("raster"), relativePath)); + continue; + } + + bool validXml = false; + bool hasViewBox = false; + bool hasEditorMetadata = false; + bool hasPhysicalSize = false; + inspectSvg(info.absoluteFilePath(), validXml, hasViewBox, hasEditorMetadata, hasPhysicalSize); + + if (!validXml) { + violations.insert(violation(QStringLiteral("xml"), relativePath)); + } + if (validXml && !hasViewBox) { + violations.insert(violation(QStringLiteral("viewbox"), relativePath)); + } + if (hasPhysicalSize) { + violations.insert(violation(QStringLiteral("physical-size"), relativePath)); + } + if (hasEditorMetadata) { + violations.insert(violation(QStringLiteral("editor-metadata"), relativePath)); + } + if (hasHardcodedColor(info.absoluteFilePath()) && !isSchemeVariant(info.fileName())) { + violations.insert(violation(QStringLiteral("hardcoded-color"), relativePath)); + } + if (!hasCurrentColorToken(info.absoluteFilePath()) && !isSchemeVariant(info.fileName())) { + violations.insert(violation(QStringLiteral("no-token"), relativePath)); + } + if (hasColorInFilename(info.fileName())) { + violations.insert(violation(QStringLiteral("color-filename"), relativePath)); + } + } + + return violations; +} + +QString formatSet(const QSet &entries) +{ + QStringList lines(entries.begin(), entries.end()); + lines.sort(); + return lines.join(QLatin1Char('\n')); +} + +} // namespace + +/** + * @brief Every UI glyph follows the palette-token rules. + * + * The legacy set was paid down icon by icon, so no baseline grandfathers + * violations: a new violation fails right here. Scheme variants + * (`*-dark.svg`/`*-light.svg`) and mana art are exempt inside collectViolations(). + */ +TEST(IconAssets, AssetsFollowTheRules) +{ + ASSERT_TRUE(QDir::isAbsolutePath(iconsDir())); + ASSERT_TRUE(QDir(iconsDir()).exists()) << "Missing icon directory: " << iconsDir().toStdString(); + + const QSet violations = collectViolations(); + EXPECT_TRUE(violations.isEmpty()) << "Icon rule violations. Fix the icon; do not add exceptions:\n" + << formatSet(violations).toStdString(); +} 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/local_client_lifetime_test.cpp b/tests/local_client_lifetime_test.cpp new file mode 100644 index 000000000..95a2b1380 --- /dev/null +++ b/tests/local_client_lifetime_test.cpp @@ -0,0 +1,149 @@ +/** + * @file local_client_lifetime_test.cpp + * @brief Tests that a LocalClient survives the death of the LocalServer it talks to. + * @ingroup Tests + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// The server library refers to this global; the real applications define it in main.cpp. +RNG_Abstract *rng = nullptr; + +namespace +{ + +// GTest's main() creates no QCoreApplication, which QObject::deleteLater() needs. +const QCoreApplication &application() +{ + static int argc = 1; + static char executable[] = "local_client_lifetime_test"; + static char *argv[] = {executable, nullptr}; + static const QCoreApplication app(argc, argv); + return app; +} + +/// Collects the warnings one logging category emits while this object is alive. +/// +/// The handler is process-global and Qt does not nest handlers, so only one capture can be +/// installed at a time; GTest running one case at a time is what makes that safe. +class CategoryWarningCapture +{ +public: + explicit CategoryWarningCapture(const char *_category) : category(_category) + { + previousHandler = qInstallMessageHandler(captureMessage); + s_active = this; + } + + ~CategoryWarningCapture() + { + qInstallMessageHandler(previousHandler); + s_active = nullptr; + } + + CategoryWarningCapture(const CategoryWarningCapture &) = delete; + CategoryWarningCapture &operator=(const CategoryWarningCapture &) = delete; + + const QStringList &warnings() const + { + return capturedWarnings; + } + +private: + static void captureMessage(QtMsgType type, const QMessageLogContext &context, const QString &message) + { + if (s_active != nullptr && type == QtWarningMsg && context.category != nullptr && + std::strcmp(s_active->category, context.category) == 0) { + s_active->capturedWarnings.append(message); + } + + // Forward everything else, so a failure elsewhere in the test is still visible. + if (previousHandler != nullptr) { + previousHandler(type, context, message); + return; + } + std::fprintf(stderr, "%s\n", qPrintable(qFormatLogMessage(type, context, message))); + } + + const char *category; + QStringList capturedWarnings; + + static CategoryWarningCapture *s_active; + static QtMessageHandler previousHandler; +}; + +CategoryWarningCapture *CategoryWarningCapture::s_active = nullptr; +QtMessageHandler CategoryWarningCapture::previousHandler = nullptr; + +class LocalClientLifetimeTest : public ::testing::Test +{ +protected: + void SetUp() override + { + (void)application(); + + server = std::make_unique(); + LocalServerInterface *lsi = server->newConnection(); + ASSERT_NE(lsi, nullptr); + // Building the client logs in and joins room 0 through that interface, exactly as + // MainWindow::startLocalGame() does. + client = std::make_unique(lsi, QStringLiteral("lifetime-test-player"), + QStringLiteral("lifetime-test-client-id")); + } + + void TearDown() override + { + // Either case may already have deleted one of these. + client.reset(); + server.reset(); + } + + std::unique_ptr server; + std::unique_ptr client; +}; + +TEST_F(LocalClientLifetimeTest, SendAfterServerDeletedIsDropped) +{ + server.reset(); // also destroys the LocalServerInterface the client was built on + + CategoryWarningCapture capture("local_client"); + // A default container stands in for a command still queued when the local game ended. + client->sendCommandContainer(CommandContainer()); + + ASSERT_EQ(capture.warnings().size(), 1); + EXPECT_TRUE(capture.warnings().first().contains(QStringLiteral("dropping command"))) + << capture.warnings().first().toStdString(); + + // The surviving client is intact: reading its state must not touch the deleted interface. + EXPECT_EQ(client->getUserName(), QStringLiteral("lifetime-test-player")); +} + +TEST_F(LocalClientLifetimeTest, DestroyingClientAfterServerIsSafe) +{ + server.reset(); + + CategoryWarningCapture capture("local_client"); + client.reset(); // must neither crash nor touch the destroyed interface + + // The destructor itself must have been silent. + EXPECT_TRUE(capture.warnings().isEmpty()); + + // A working capture proves the emptiness above is real rather than a broken handler. + qCWarning(LocalClientLog) << "local_client_lifetime_test capture self-check"; + EXPECT_EQ(capture.warnings().size(), 1); +} + +} // namespace diff --git a/tests/model_contract/CMakeLists.txt b/tests/model_contract/CMakeLists.txt new file mode 100644 index 000000000..3251c1443 --- /dev/null +++ b/tests/model_contract/CMakeLists.txt @@ -0,0 +1,17 @@ +cockatrice_add_test( + NAME + model_contract_test + SOURCES + ${VERSION_STRING_CPP} + model_contract_test.cpp + LIBS + libcockatrice_models + libcockatrice_card + libcockatrice_deck_list + libcockatrice_settings + INCLUDE_DIRS + ${CMAKE_CURRENT_SOURCE_DIR}/../support + ${CMAKE_CURRENT_SOURCE_DIR}/../carddatabase + DEFINITIONS + CARDDB_DATADIR="${CMAKE_SOURCE_DIR}/tests/carddatabase/data/" +) diff --git a/tests/model_contract/model_contract_test.cpp b/tests/model_contract/model_contract_test.cpp new file mode 100644 index 000000000..aefa26f3d --- /dev/null +++ b/tests/model_contract/model_contract_test.cpp @@ -0,0 +1,137 @@ +#include "libcockatrice/deck_list/deck_list.h" +#include "libcockatrice/deck_list/tree/inner_deck_list_node.h" +#include "libcockatrice/models/database/card_database_display_model.h" +#include "libcockatrice/models/database/card_database_model.h" +#include "libcockatrice/models/database/card_set/card_sets_model.h" +#include "libcockatrice/models/deck_list/deck_list_model.h" +#include "model_contract_checker.h" +#include "test_card_database_path_provider.h" + +#include "gtest/gtest.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +QSharedPointer makeDeck() +{ + return QSharedPointer(new DeckList()); +} + +void loadCardDatabase(CardDatabase &database) +{ + database.loadCardDatabases(); + ASSERT_EQ(database.getLoadStatus(), Ok); +} + +TEST(ModelContract, DeckListModelKeepsContractWhileZonesAndCardsChange) +{ + const QSharedPointer deck = makeDeck(); + DeckListModel model(nullptr, deck); + + test_support::ModelContractChecker checker(&model); + + ASSERT_NE(deck->getTree()->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + model.rebuildTree(); + + CardInfoPtr bolt = CardInfo::newInstance("Lightning Bolt"); + const QModelIndex added = model.addCard(ExactCard(bolt), DECK_ZONE_MAIN); + ASSERT_TRUE(added.isValid()); + + CardInfoPtr bears = CardInfo::newInstance("Grizzly Bears"); + bears->setProperty(Mtg::MainCardType, "Creature"); + const QModelIndex grouped = model.addCard(ExactCard(bears), "Removal"); + ASSERT_TRUE(grouped.isValid()); + + ASSERT_TRUE(model.removeRows(grouped.row(), 1, grouped.parent())); +} + +TEST(ModelContract, DeckListModelEmitsCardAddedSignal) +{ + const QSharedPointer deck = makeDeck(); + DeckListModel model(nullptr, deck); + QSignalSpy spy(&model, &DeckListModel::cardAddedAt); + + const QModelIndex added = model.addCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), DECK_ZONE_MAIN); + + ASSERT_TRUE(added.isValid()); + EXPECT_EQ(spy.count(), 1); +} + +TEST(ModelContract, CardDatabaseModelAndDisplayProxyKeepContract) +{ + CardDatabase database(nullptr, new NoopCardPreferenceProvider(), new TestCardDatabasePathProvider(), + new NoopCardSetPriorityController()); + ASSERT_NO_FATAL_FAILURE(loadCardDatabase(database)); + + CardDatabaseModel source(&database, false); + test_support::ModelContractChecker sourceChecker(&source); + + CardDatabaseDisplayModel display; + display.setSourceModel(&source); + test_support::ModelContractChecker displayChecker(&display); + + ASSERT_GT(source.rowCount(), 0); + EXPECT_EQ(display.rowCount(), source.rowCount()); + + // A card row is a leaf, so its index must not claim child columns. The + // tester does not check this, hence the explicit assertion. + const QModelIndex sourceCard = source.index(0, 0); + ASSERT_TRUE(sourceCard.isValid()); + EXPECT_EQ(source.columnCount(sourceCard), 0); + const QModelIndex displayCard = display.index(0, 0); + ASSERT_TRUE(displayCard.isValid()); + EXPECT_EQ(display.columnCount(displayCard), 0); + + QSignalSpy dirtySpy(&display, &CardDatabaseDisplayModel::modelDirty); + display.setCardName("Do"); + ASSERT_TRUE(dirtySpy.wait()); + EXPECT_GT(display.rowCount(), 0); + EXPECT_LT(display.rowCount(), source.rowCount()); +} + +TEST(ModelContract, SetsModelKeepsContract) +{ + CardDatabase database(nullptr, new NoopCardPreferenceProvider(), new TestCardDatabasePathProvider(), + new NoopCardSetPriorityController()); + ASSERT_NO_FATAL_FAILURE(loadCardDatabase(database)); + + SetsModel model(&database); + test_support::ModelContractChecker checker(&model); + + ASSERT_GT(model.rowCount(), 0); + ASSERT_GT(model.columnCount(), 0); +} + +TEST(ModelContract, SettingsSignalIsObservableWithQSignalSpy) +{ + QTemporaryDir tempDir; + ASSERT_TRUE(tempDir.isValid()); + DownloadSettings settings(tempDir.path() + "/", nullptr); + QSignalSpy spy(&settings, &DownloadSettings::downloadSpoilerStatusChanged); + + settings.setDownloadSpoilerStatus(true); + + ASSERT_EQ(spy.count(), 1); +} +} // namespace + +int main(int argc, char **argv) +{ + QCoreApplication app(argc, argv); + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/password_hash_test.cpp b/tests/password_hash_test.cpp index 99f2e1400..8e7eded72 100644 --- a/tests/password_hash_test.cpp +++ b/tests/password_hash_test.cpp @@ -52,6 +52,17 @@ TEST(PasswordHashTest, DeriveKeyMatchesKnownVector) ASSERT_EQ(derived.toHex(), expected.toHex()); } +TEST(PasswordHashTest, DeriveKeyCoversTheWholeAcceptedRangeOfR) +{ + // costParamsAreSane accepts r up to 32. The memory budget handed to OpenSSL has to cover + // scrypt's XY array (256 * r bytes) on top of V and B, or derivation fails for r >= 17. + for (const int r : {17, 32}) { + const QByteArray derived = PasswordHasher::deriveKey("password", QByteArray("NaCl"), 1024, r, 1); + ASSERT_FALSE(derived.isEmpty()) << "deriveKey failed for accepted r=" << r; + ASSERT_EQ(derived.size(), SCRYPT_VERIFIER_LENGTH); + } +} + TEST(PasswordHashTest, PasswordVerifierRoundTrip) { const QString stored = PasswordHasher::generatePasswordVerifier("hunter2"); 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_forced_password_change_test.cpp b/tests/server_forced_password_change_test.cpp new file mode 100644 index 000000000..9717b610b --- /dev/null +++ b/tests/server_forced_password_change_test.cpp @@ -0,0 +1,234 @@ +/** @file server_forced_password_change_test.cpp + * @brief Tests for the restricted session a forced password change leaves behind at login. + * @ingroup Tests + */ + +#include "libcockatrice/protocol/pb/response.pb.h" +#include "libcockatrice/protocol/pb/server_message.pb.h" +#include "server.h" +#include "server_database_interface.h" +#include "server_protocolhandler.h" + +#include +#include +#include +#include +#include +#include +#include + +RNG_Abstract *rng = nullptr; // referenced by the server_remote library + +namespace +{ + +class MockDatabaseInterface : public Server_DatabaseInterface +{ +public: + AuthenticationResult checkUserPassword(Server_ProtocolHandler *, + const QString &, + const QString &, + const QString &, + QString &, + int &, + bool) override + { + return PasswordChangeRequired; + } + ServerInfo_User getUserData(const QString &name, bool withId) override + { + ServerInfo_User user; + user.set_name(name.toStdString()); + user.set_user_level(ServerInfo_User::IsUser | ServerInfo_User::IsRegistered | ServerInfo_User::IsModerator); + if (withId) { + user.set_id(1); + } + return user; + } + int getNextReplayId() override + { + return 1; + } + int getNextGameId() override + { + return 1; + } + int getActiveUserCount(QString) override + { + return 0; + } +}; + +class FakeServer : public Server +{ +public: + FakeServer() + { + setDatabaseInterface(new MockDatabaseInterface()); + } +}; + +class TestHandler : public Server_ProtocolHandler +{ +public: + TestHandler(Server *_server, Server_DatabaseInterface *_db) : Server_ProtocolHandler(_server, _db) + { + } + + QString getAddress() const override + { + return {}; + } + QString getConnectionType() const override + { + return {}; + } + + Response::ResponseCode lastResponseCode = Response::RespNothing; + int lastExtendedSessionCommand = -1; + +protected: + Response::ResponseCode + processExtendedSessionCommand(int cmdType, const SessionCommand & /*cmd*/, ResponseContainer & /*rc*/) override + { + lastExtendedSessionCommand = cmdType; + return Response::RespOk; + } + + void transmitProtocolItem(const ServerMessage &item) override + { + if (item.message_type() == ServerMessage::RESPONSE) { + lastResponseCode = item.response().response_code(); + } + } +}; + +class ForcedPasswordChangeTest : public ::testing::Test +{ +protected: + FakeServer server; + TestHandler handler{&server, server.getDatabaseInterface()}; + + void login() + { + CommandContainer cont; + cont.set_cmd_id(1); + Command_Login cmd; + cmd.set_user_name("mod"); + cont.add_session_command()->MutableExtension(Command_Login::ext)->CopyFrom(cmd); + handler.processCommandContainer(cont); + } + + Response::ResponseCode sendPing() + { + CommandContainer cont; + cont.set_cmd_id(2); + cont.add_session_command()->MutableExtension(Command_Ping::ext); + handler.processCommandContainer(cont); + return handler.lastResponseCode; + } + + Response::ResponseCode sendSessionCommand() + { + CommandContainer cont; + cont.set_cmd_id(3); + cont.add_session_command()->MutableExtension(Command_ListRooms::ext); + handler.processCommandContainer(cont); + return handler.lastResponseCode; + } + + Response::ResponseCode sendRoomCommand() + { + CommandContainer cont; + cont.set_cmd_id(4); + cont.add_room_command()->MutableExtension(Command_RoomSay::ext)->set_message("hello"); + handler.processCommandContainer(cont); + return handler.lastResponseCode; + } + + Response::ResponseCode sendMixedSessionAndRoomCommand() + { + CommandContainer cont; + cont.set_cmd_id(5); + cont.add_session_command()->MutableExtension(Command_Ping::ext); + cont.add_room_command()->MutableExtension(Command_RoomSay::ext)->set_message("hello"); + handler.processCommandContainer(cont); + return handler.lastResponseCode; + } + + Response::ResponseCode sendAccountPassword() + { + CommandContainer cont; + cont.set_cmd_id(6); + Command_AccountPassword cmd; + cmd.set_old_password("temporary-password"); + cmd.set_new_password("replacement-password"); + cont.add_session_command()->MutableExtension(Command_AccountPassword::ext)->CopyFrom(cmd); + handler.processCommandContainer(cont); + return handler.lastResponseCode; + } +}; + +TEST_F(ForcedPasswordChangeTest, LoginReportsTheRequiredChange) +{ + login(); + + EXPECT_EQ(Response::RespPasswordChangeRequired, handler.lastResponseCode); + ASSERT_NE(nullptr, handler.getUserInfo()); + EXPECT_EQ("mod", QString::fromStdString(handler.getUserInfo()->name())); +} + +TEST_F(ForcedPasswordChangeTest, PendingSessionIsNotRegisteredAsAUser) +{ + login(); + // The session may change its password, but it must not take a user slot or appear in + // the session table before the client reconnects with the new password. + EXPECT_FALSE(handler.getUserInfo()->has_session_id()); + EXPECT_EQ(0, server.getUsersCount()); +} + +TEST_F(ForcedPasswordChangeTest, PendingModeratorIsNotReportedOnline) +{ + login(); + server.addClient(&handler); + + EXPECT_TRUE(server.getOnlineModeratorList().isEmpty()); +} + +TEST_F(ForcedPasswordChangeTest, OnlyPingAndPasswordChangeAreServed) +{ + login(); + + EXPECT_EQ(Response::RespOk, sendPing()); + EXPECT_EQ(Response::RespFunctionNotAllowed, sendSessionCommand()); + EXPECT_EQ(Response::RespFunctionNotAllowed, sendRoomCommand()); + // Dispatch prefers game/room over session, so a container carrying both must not slip past + // the restriction by carrying a session command. + EXPECT_EQ(Response::RespFunctionNotAllowed, sendMixedSessionAndRoomCommand()); +} + +TEST_F(ForcedPasswordChangeTest, AccountPasswordReachesTheCommand) +{ + login(); + // The forced change is useless if the gate refuses the command it needs. ACCOUNT_PASSWORD is + // served by the extended-command hook, so it must arrive there instead of being filtered. + EXPECT_EQ(Response::RespOk, sendAccountPassword()); + EXPECT_EQ(SessionCommand::ACCOUNT_PASSWORD, handler.lastExtendedSessionCommand); +} + +TEST_F(ForcedPasswordChangeTest, CompletedLoginIsStillReportedOnline) +{ + TestHandler loggedIn{&server, server.getDatabaseInterface()}; + ServerInfo_User user; + user.set_name("online-mod"); + user.set_user_level(ServerInfo_User::IsUser | ServerInfo_User::IsRegistered | ServerInfo_User::IsModerator); + user.set_session_id(7); + loggedIn.setUserInfo(user); + server.addClient(&loggedIn); + + const QList moderators = server.getOnlineModeratorList(); + ASSERT_EQ(1, moderators.size()); + EXPECT_EQ("online-mod", moderators.first()); +} + +} // namespace diff --git a/tests/server_game_join_test.cpp b/tests/server_game_join_test.cpp index 8a2ae8c45..93895ab5c 100644 --- a/tests/server_game_join_test.cpp +++ b/tests/server_game_join_test.cpp @@ -4,6 +4,7 @@ */ #include "game/game_config.h" +#include "game/role_info.h" #include "game/server_game.h" #include "libcockatrice/protocol/pb/response.pb.h" #include "server.h" @@ -119,13 +120,13 @@ TEST_F(GameJoinOverrideTest, StaffBypassPasswordRestriction) game = makeGame(true, false, false, true); // A plain user cannot override the password even with the override flag set. - EXPECT_EQ(game->checkJoin(&plainUser, "wrong", false, true, false), Response::RespWrongPassword); + EXPECT_EQ(game->checkJoin(&plainUser, "wrong", {}, true), Response::RespWrongPassword); // Moderators and judges may enter any game regardless of the password. - EXPECT_EQ(game->checkJoin(&moderator, "wrong", false, true, false), Response::RespOk); - EXPECT_EQ(game->checkJoin(&judge, "wrong", false, true, false), Response::RespOk); + EXPECT_EQ(game->checkJoin(&moderator, "wrong", {}, true), Response::RespOk); + EXPECT_EQ(game->checkJoin(&judge, "wrong", {}, true), Response::RespOk); // Without the override flag judges are still subject to the password. - EXPECT_EQ(game->checkJoin(&judge, "wrong", false, false, true), Response::RespWrongPassword); - EXPECT_EQ(game->checkJoin(&judge, "secret", false, false, true), Response::RespOk); + EXPECT_EQ(game->checkJoin(&judge, "wrong", {.isJudge = true}, false), Response::RespWrongPassword); + EXPECT_EQ(game->checkJoin(&judge, "secret", {.isJudge = true}, false), Response::RespOk); } TEST_F(GameJoinOverrideTest, StaffBypassRegisteredOnlyRestriction) @@ -133,9 +134,9 @@ TEST_F(GameJoinOverrideTest, StaffBypassRegisteredOnlyRestriction) game = makeGame(false, true, false, true); // Without the override flag the only-registered restriction still applies. - EXPECT_EQ(game->checkJoin(&unregisteredJudge, QString(), false, false, false), Response::RespUserLevelTooLow); + EXPECT_EQ(game->checkJoin(&unregisteredJudge, QString(), {}, false), Response::RespUserLevelTooLow); // An unregistered judge may enter when overriding restrictions. - EXPECT_EQ(game->checkJoin(&unregisteredJudge, QString(), false, true, false), Response::RespOk); + EXPECT_EQ(game->checkJoin(&unregisteredJudge, QString(), {}, true), Response::RespOk); } TEST_F(GameJoinOverrideTest, StaffBypassBuddiesOnlyRestriction) @@ -143,10 +144,10 @@ TEST_F(GameJoinOverrideTest, StaffBypassBuddiesOnlyRestriction) game = makeGame(false, false, true, true); // A plain user who is not on the creator's buddy list gets rejected. - EXPECT_EQ(game->checkJoin(&plainUser, QString(), false, true, false), Response::RespOnlyBuddies); + EXPECT_EQ(game->checkJoin(&plainUser, QString(), {}, true), Response::RespOnlyBuddies); // Moderators and judges bypass the buddies-only restriction. - EXPECT_EQ(game->checkJoin(&moderator, QString(), false, true, false), Response::RespOk); - EXPECT_EQ(game->checkJoin(&judge, QString(), false, true, false), Response::RespOk); + EXPECT_EQ(game->checkJoin(&moderator, QString(), {}, true), Response::RespOk); + EXPECT_EQ(game->checkJoin(&judge, QString(), {}, true), Response::RespOk); } TEST_F(GameJoinOverrideTest, StaffBypassSpectatorsNotAllowedRestriction) @@ -154,11 +155,11 @@ TEST_F(GameJoinOverrideTest, StaffBypassSpectatorsNotAllowedRestriction) game = makeGame(false, false, false, false); // A plain user cannot spectate when the game disallows spectators. - EXPECT_EQ(game->checkJoin(&plainUser, QString(), true, false, false), Response::RespSpectatorsNotAllowed); + EXPECT_EQ(game->checkJoin(&plainUser, QString(), {.isSpectator = true}, false), Response::RespSpectatorsNotAllowed); // Moderators and judges may spectate any game regardless of the password // and the spectator restriction. - EXPECT_EQ(game->checkJoin(&moderator, "wrong", true, true, false), Response::RespOk); - EXPECT_EQ(game->checkJoin(&judge, "wrong", true, true, false), Response::RespOk); + EXPECT_EQ(game->checkJoin(&moderator, "wrong", {.isSpectator = true}, true), Response::RespOk); + EXPECT_EQ(game->checkJoin(&judge, "wrong", {.isSpectator = true}, true), Response::RespOk); } TEST_F(GameJoinOverrideTest, JudgeOverrideDoesNotGrantJudgeJoinToPlainUser) @@ -166,8 +167,8 @@ TEST_F(GameJoinOverrideTest, JudgeOverrideDoesNotGrantJudgeJoinToPlainUser) game = makeGame(false, false, false, true); // joining with join_as_judge still requires the judge flag even when overriding. - EXPECT_EQ(game->checkJoin(&plainUser, QString(), false, true, true), Response::RespUserLevelTooLow); - EXPECT_EQ(game->checkJoin(&judge, QString(), false, true, true), Response::RespOk); + EXPECT_EQ(game->checkJoin(&plainUser, QString(), {.isJudge = true}, true), Response::RespUserLevelTooLow); + EXPECT_EQ(game->checkJoin(&judge, QString(), {.isJudge = true}, true), Response::RespOk); } } // namespace 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/CMakeLists.txt b/tests/settings/CMakeLists.txt index 98bed1f4b..9dbda9d6b 100644 --- a/tests/settings/CMakeLists.txt +++ b/tests/settings/CMakeLists.txt @@ -1,5 +1,7 @@ cockatrice_add_test(NAME settings_manager_test SOURCES settings_manager_test.cpp LIBS libcockatrice_settings) +cockatrice_add_test(NAME servers_settings_test SOURCES servers_settings_test.cpp LIBS libcockatrice_settings) + cockatrice_add_test( NAME settings_defaults_test diff --git a/tests/settings/servers_settings_test.cpp b/tests/settings/servers_settings_test.cpp new file mode 100644 index 000000000..5d5953999 --- /dev/null +++ b/tests/settings/servers_settings_test.cpp @@ -0,0 +1,228 @@ +#include "gtest/gtest.h" +#include +#include +#include +#include + +namespace +{ +class ServersSettingsTest : public ::testing::Test +{ +protected: + QTemporaryDir tempDir; + QString settingsPath; + + void SetUp() override + { + settingsPath = tempDir.path() + "/"; + } + + QString settingsFile() const + { + return settingsPath + "servers.ini"; + } + + void writeLegacyServer(int index, + const QString &saveName, + const QString &host, + const QString &port, + const QString &username, + const QString &password, + bool savePassword, + const QString &site) + { + QSettings settings(settingsFile(), QSettings::IniFormat); + const QString prefix = QStringLiteral("server/server_details/"); + settings.setValue(prefix + QStringLiteral("saveName%1").arg(index), saveName); + settings.setValue(prefix + QStringLiteral("server%1").arg(index), host); + settings.setValue(prefix + QStringLiteral("port%1").arg(index), port); + settings.setValue(prefix + QStringLiteral("username%1").arg(index), username); + settings.setValue(prefix + QStringLiteral("password%1").arg(index), password); + settings.setValue(prefix + QStringLiteral("savePassword%1").arg(index), savePassword); + settings.setValue(prefix + QStringLiteral("site%1").arg(index), site); + } +}; + +TEST_F(ServersSettingsTest, AddServerStoresAllFields) +{ + ServersSettings settings(settingsPath, nullptr); + settings.addNewServer("Alpha", "alpha.example", "4748", "alice", "secret", true, "https://alpha.example"); + + const auto servers = settings.getServers(); + ASSERT_EQ(servers.size(), 1); + EXPECT_EQ(servers.first().saveName, QString("Alpha")); + EXPECT_EQ(servers.first().host, QString("alpha.example")); + EXPECT_EQ(servers.first().port, QString("4748")); + EXPECT_EQ(servers.first().username, QString("alice")); + EXPECT_EQ(servers.first().password, QString("secret")); + EXPECT_TRUE(servers.first().savePassword); + EXPECT_EQ(servers.first().site, QString("https://alpha.example")); +} + +TEST_F(ServersSettingsTest, AddExistingHostAndPortUpdatesInsteadOfDuplicating) +{ + ServersSettings settings(settingsPath, nullptr); + settings.addNewServer("Alpha", "alpha.example", "4748", "alice", "secret", true, "site-a"); + settings.addNewServer("Renamed", "alpha.example", "4748", "bob", "new-secret", true, "site-b"); + + const auto servers = settings.getServers(); + ASSERT_EQ(servers.size(), 1); + EXPECT_EQ(servers.first().saveName, QString("Renamed")); + EXPECT_EQ(servers.first().username, QString("bob")); + EXPECT_EQ(servers.first().password, QString("new-secret")); + EXPECT_EQ(servers.first().site, QString("site-b")); +} + +TEST_F(ServersSettingsTest, RemoveServerRemovesOnlyMatchingHostAndKeepsOrder) +{ + ServersSettings settings(settingsPath, nullptr); + settings.addNewServer("Alpha", "alpha.example", "4748", "alice", "a", true, ""); + settings.addNewServer("Beta", "beta.example", "4748", "bob", "b", true, ""); + settings.addNewServer("Gamma", "gamma.example", "4748", "carol", "c", true, ""); + + settings.removeServer("beta.example"); + + const auto servers = settings.getServers(); + ASSERT_EQ(servers.size(), 2); + EXPECT_EQ(servers.at(0).saveName, QString("Alpha")); + EXPECT_EQ(servers.at(1).saveName, QString("Gamma")); +} + +TEST_F(ServersSettingsTest, RemovingLastServerDropsItsStaleArraySlot) +{ + ServersSettings settings(settingsPath, nullptr); + settings.addNewServer("Alpha", "alpha.example", "4748", "alice", "a", true, ""); + settings.addNewServer("Beta", "beta.example", "4748", "bob", "b", true, ""); + settings.addNewServer("Gamma", "gamma.example", "4748", "carol", "gamma-secret", true, ""); + + settings.removeServer("gamma.example"); + + QSettings raw(settingsFile(), QSettings::IniFormat); + EXPECT_FALSE(raw.contains("server/server_details/servers/3/password")); + EXPECT_FALSE(raw.contains("server/server_details/servers/3/saveName")); + ASSERT_EQ(settings.getServers().size(), 2); +} + +TEST_F(ServersSettingsTest, UpdateWithoutLossKeepsCredentials) +{ + ServersSettings settings(settingsPath, nullptr); + settings.addNewServer("Alpha", "alpha.example", "4748", "alice", "secret", true, ""); + + EXPECT_TRUE(settings.updateExistingServerWithoutLoss("Renamed", "alpha.example", "1234", "site")); + + const auto server = settings.getServer(settings.findServerIndex("alpha.example", "1234")); + ASSERT_TRUE(server.has_value()); + EXPECT_EQ(server->saveName, QString("Renamed")); + EXPECT_EQ(server->username, QString("alice")); + EXPECT_EQ(server->password, QString("secret")); + EXPECT_TRUE(server->savePassword); + EXPECT_EQ(server->site, QString("site")); +} + +TEST_F(ServersSettingsTest, SetServerPasswordUpdatesOnlyTheNamedProfile) +{ + ServersSettings settings(settingsPath, nullptr); + settings.addNewServer("Alpha", "alpha.example", "4748", "alice", "old", true, ""); + settings.addNewServer("Beta", "beta.example", "4748", "bob", "keep", true, ""); + + settings.setServerPassword("Beta", "new"); + + EXPECT_EQ(settings.getServer(0)->password, QString("old")); + EXPECT_EQ(settings.getServer(1)->password, QString("new")); +} + +TEST_F(ServersSettingsTest, LegacyProfilesAreMigrated) +{ + { + QSettings settings(settingsFile(), QSettings::IniFormat); + settings.setValue("server/server_details/totalServers", 2); + } + writeLegacyServer(1, "Alpha", "alpha.example", "4748", "alice", "secret", true, "site-a"); + writeLegacyServer(2, "Beta", "beta.example", "4748", "bob", "other", false, "site-b"); + + ServersSettings settings(settingsPath, nullptr); + + const auto servers = settings.getServers(); + ASSERT_EQ(servers.size(), 2); + EXPECT_EQ(servers.at(0).saveName, QString("Alpha")); + EXPECT_EQ(servers.at(0).host, QString("alpha.example")); + EXPECT_TRUE(servers.at(0).savePassword); + EXPECT_EQ(servers.at(1).saveName, QString("Beta")); + EXPECT_FALSE(servers.at(1).savePassword); + + QSettings raw(settingsFile(), QSettings::IniFormat); + EXPECT_FALSE(raw.contains("server/server_details/totalServers")); + EXPECT_FALSE(raw.contains("server/server_details/saveName1")); + EXPECT_FALSE(raw.contains("server/server_details/server1")); +} + +TEST_F(ServersSettingsTest, MigrationRunsOnce) +{ + writeLegacyServer(1, "Alpha", "alpha.example", "4748", "alice", "secret", true, ""); + { + QSettings settings(settingsFile(), QSettings::IniFormat); + settings.setValue("server/server_details/totalServers", 1); + } + + { + ServersSettings first(settingsPath, nullptr); + ASSERT_EQ(first.getServers().size(), 1); + } + + ServersSettings second(settingsPath, nullptr); + ASSERT_EQ(second.getServers().size(), 1); +} + +TEST_F(ServersSettingsTest, LegacyProfileAddedAfterMigrationIsMerged) +{ + { + ServersSettings first(settingsPath, nullptr); + first.addNewServer("Alpha", "alpha.example", "4748", "alice", "secret", true, ""); + } + + // An older build writes its own profile back in the legacy format after the + // array migration already happened. + writeLegacyServer(1, "Beta", "beta.example", "4748", "bob", "other", true, "site-b"); + // A duplicate of a profile the array already holds must not be added twice. + writeLegacyServer(2, "Alpha duplicate", "alpha.example", "4748", "eve", "dupe", true, ""); + { + QSettings settings(settingsFile(), QSettings::IniFormat); + settings.setValue("server/server_details/totalServers", 2); + } + + ServersSettings settings(settingsPath, nullptr); + + const auto servers = settings.getServers(); + ASSERT_EQ(servers.size(), 2); + EXPECT_EQ(servers.at(0).saveName, QString("Alpha")); + EXPECT_EQ(servers.at(0).password, QString("secret")); + EXPECT_EQ(servers.at(1).saveName, QString("Beta")); + EXPECT_EQ(servers.at(1).host, QString("beta.example")); + EXPECT_EQ(servers.at(1).username, QString("bob")); + EXPECT_EQ(servers.at(1).password, QString("other")); + EXPECT_TRUE(servers.at(1).savePassword); + EXPECT_EQ(servers.at(1).site, QString("site-b")); + + QSettings raw(settingsFile(), QSettings::IniFormat); + EXPECT_FALSE(raw.contains("server/server_details/totalServers")); + EXPECT_FALSE(raw.contains("server/server_details/saveName1")); + EXPECT_FALSE(raw.contains("server/server_details/server2")); + + // The merge is persisted, so a further start adds nothing. + ServersSettings again(settingsPath, nullptr); + ASSERT_EQ(again.getServers().size(), 2); +} + +TEST_F(ServersSettingsTest, OutOfRangeIndexReturnsNullopt) +{ + ServersSettings settings(settingsPath, nullptr); + EXPECT_FALSE(settings.getServer(-1).has_value()); + EXPECT_FALSE(settings.getServer(0).has_value()); +} +} // namespace + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/support/model_contract_checker.h b/tests/support/model_contract_checker.h new file mode 100644 index 000000000..9b493ff6e --- /dev/null +++ b/tests/support/model_contract_checker.h @@ -0,0 +1,35 @@ +#ifndef COCKATRICE_TEST_MODEL_CONTRACT_CHECKER_H +#define COCKATRICE_TEST_MODEL_CONTRACT_CHECKER_H + +#include +#include +#include + +namespace test_support +{ +/** + * @brief Keeps a QAbstractItemModelTester alive for the lifetime of a scope. + * + * The tester only observes the model while it exists, so construct this before + * the mutations a test wants to validate and keep it in scope until the end. + * Fatal reporting mode aborts on the first contract violation; a warning would + * let the test pass while printing a line nobody reads in CI. + */ +class ModelContractChecker +{ +public: + explicit ModelContractChecker(QAbstractItemModel *model) + : tester( + std::make_unique(model, QAbstractItemModelTester::FailureReportingMode::Fatal)) + { + } + + ModelContractChecker(const ModelContractChecker &) = delete; + ModelContractChecker &operator=(const ModelContractChecker &) = delete; + +private: + std::unique_ptr tester; +}; +} // namespace test_support + +#endif // COCKATRICE_TEST_MODEL_CONTRACT_CHECKER_H diff --git a/tests/svg_recolor_test.cpp b/tests/svg_recolor_test.cpp new file mode 100644 index 000000000..abe1e8d1a --- /dev/null +++ b/tests/svg_recolor_test.cpp @@ -0,0 +1,66 @@ +#include "libcockatrice/utility/svg_recolor.h" + +#include +#include + +namespace +{ + +TEST(SvgRecolor, ReplacesEverySentinel) +{ + const QByteArray svg = R"()"; + const QByteArray result = resolveSvgColorSentinel(svg, "#ff8800"); + + EXPECT_EQ(result.count("#ff8800"), 2); + EXPECT_FALSE(result.contains("currentColor")); +} + +TEST(SvgRecolor, ReplacesSentinelRegardlessOfCase) +{ + const QByteArray svg = R"()"; + + EXPECT_EQ(resolveSvgColorSentinel(svg, "#ff8800").count("#ff8800"), 2); +} + +TEST(SvgRecolor, LeavesTheRestOfTheDocumentIntact) +{ + const QByteArray svg = + R"()"; + const QByteArray result = resolveSvgColorSentinel(svg, "#123456"); + + EXPECT_TRUE(result.contains(R"(viewBox="0 0 24 24")")); + EXPECT_TRUE(result.contains(R"(d="M0 0h24v24")")); + EXPECT_TRUE(result.contains("an ordinary comment")); +} + +TEST(SvgRecolor, DoesNotModifyTheInput) +{ + const QByteArray svg = R"()"; + const QByteArray original = svg; + + resolveSvgColorSentinel(svg, "#000000"); + + EXPECT_EQ(svg, original); +} + +TEST(SvgRecolor, PreservesBytesThatAreNotValidUtf8) +{ + QByteArray svg = ""; + svg.append("\xff\xfe"); + svg.append(R"()"); + + const QByteArray result = resolveSvgColorSentinel(svg, "#ff8800"); + + EXPECT_TRUE(result.contains("\xff\xfe")); + EXPECT_FALSE(result.contains("currentColor")); + EXPECT_EQ(result.count("#ff8800"), 1); +} + +TEST(SvgRecolor, NoSentinelIsANoOp) +{ + const QByteArray svg = R"()"; + + EXPECT_EQ(resolveSvgColorSentinel(svg, "#ffffff"), svg); +} + +} // namespace diff --git a/tests/theme_migration/CMakeLists.txt b/tests/theme_migration/CMakeLists.txt index 93493ee5f..818c1bb16 100644 --- a/tests/theme_migration/CMakeLists.txt +++ b/tests/theme_migration/CMakeLists.txt @@ -5,12 +5,14 @@ cockatrice_add_test( ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_manager.cpp ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_config.cpp ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/pixel_map_generator.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_icon.cpp ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/cache_settings.cpp ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/card_counter_settings.cpp ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/shortcuts_settings.cpp ${CMAKE_SOURCE_DIR}/cockatrice/src/client/network/update/client/release_channel.cpp ${VERSION_STRING_CPP} theme_migration_test.cpp + theme_icon_render_test.cpp LIBS libcockatrice_settings libcockatrice_network @@ -18,4 +20,6 @@ cockatrice_add_test( libcockatrice_protocol INCLUDE_DIRS ${CMAKE_SOURCE_DIR}/cockatrice/src + DEFINITIONS + COCKATRICE_SOURCE_DIR="${CMAKE_SOURCE_DIR}" ) diff --git a/tests/theme_migration/theme_icon_render_test.cpp b/tests/theme_migration/theme_icon_render_test.cpp new file mode 100644 index 000000000..f12db5b16 --- /dev/null +++ b/tests/theme_migration/theme_icon_render_test.cpp @@ -0,0 +1,189 @@ +#include "interface/theme_icon.h" +#include "interface/theme_manager.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +/** + * @brief Creates the GUI application QPixmap needs, using the offscreen plugin. + * + * The theme-migration binary is headless; creating this lazily keeps its other + * tests free of a display dependency. + */ +void ensureGuiApplication() +{ + static int argc = 1; + static char name[] = "theme_icon_test"; + static char *argv[] = {name, nullptr}; + // Leaked on purpose: a function-local static QApplication is destroyed before Qt's + // own library statics at exit, and the network module's destructors then run + // against a torn-down QObject layer (SEGV under AddressSanitizer). + static QApplication *app = new QApplication(argc, argv); + Q_UNUSED(app); +} + +QString resourcesDir() +{ + return QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)).absoluteFilePath(QStringLiteral("cockatrice/resources")); +} + +int countOpaquePixels(const QPixmap &pixmap, const QColor &expected) +{ + const QImage image = pixmap.toImage(); + int count = 0; + for (int y = 0; y < image.height(); ++y) { + for (int x = 0; x < image.width(); ++x) { + const QColor pixel = image.pixelColor(x, y); + if (pixel.alpha() > 200 && pixel.red() == expected.red() && pixel.green() == expected.green() && + pixel.blue() == expected.blue()) { + ++count; + } + } + } + return count; +} + +QByteArray svgWith(const QByteArray &colorToken) +{ + return QByteArrayLiteral(R"()"); +} + +/** + * @brief Restores the global theme search paths and palette after each test. + * + * The icon engine reads both process-wide values, so a failing assertion must + * not leak a temporary theme directory or palette into the next test. + */ +class ThemeIconRenderTest : public ::testing::Test +{ +protected: + void SetUp() override + { + qputenv("QT_QPA_PLATFORM", "offscreen"); + ensureGuiApplication(); + savedSearchPaths = QDir::searchPaths(QStringLiteral("theme")); + savedPalette = qApp->palette(); + themeManager = new ThemeManager; + QDir::setSearchPaths(QStringLiteral("theme"), {resourcesDir()}); + } + + void TearDown() override + { + delete themeManager; + themeManager = nullptr; + QDir::setSearchPaths(QStringLiteral("theme"), savedSearchPaths); + qApp->setPalette(savedPalette); + } + + QStringList savedSearchPaths; ///< Search paths captured before the test. + QPalette savedPalette; ///< Application palette captured before the test. +}; + +/** @brief A non-square request must letterbox, not squash, a 24×24 glyph. */ +TEST_F(ThemeIconRenderTest, NonSquareRequestPreservesAspectRatio) +{ + const QIcon icon = loadThemedIcon(QStringLiteral("icons/player")); + ASSERT_FALSE(icon.isNull()); + + EXPECT_EQ(icon.actualSize(QSize(20, 15)), QSize(15, 15)); + + const QPixmap pixmap = icon.pixmap(QSize(20, 15)); + ASSERT_FALSE(pixmap.isNull()); + EXPECT_EQ(pixmap.deviceIndependentSize(), QSizeF(15, 15)); +} + +/** @brief The engine must follow palette flips, icon modes, and DPR. */ +TEST_F(ThemeIconRenderTest, ThemedIconFollowsPaletteModeAndDpr) +{ + QPalette palette = qApp->palette(); + palette.setColor(QPalette::WindowText, QColor(QStringLiteral("#123456"))); + qApp->setPalette(palette); + + const QIcon icon = loadThemedIcon(QStringLiteral("icons/search")); + ASSERT_FALSE(icon.isNull()); + + const QPixmap normal = icon.pixmap(QSize(24, 24), 1.0); + ASSERT_FALSE(normal.isNull()); + EXPECT_EQ(normal.size(), QSize(24, 24)); + EXPECT_DOUBLE_EQ(normal.devicePixelRatio(), 1.0); + EXPECT_GT(countOpaquePixels(normal, QColor(QStringLiteral("#123456"))), 0); + + const QPixmap hidpi = icon.pixmap(QSize(24, 24), 2.0); + ASSERT_FALSE(hidpi.isNull()); + EXPECT_DOUBLE_EQ(hidpi.devicePixelRatio(), 2.0); + EXPECT_EQ(hidpi.deviceIndependentSize(), QSizeF(24, 24)); + + palette.setColor(QPalette::WindowText, QColor(QStringLiteral("#abcdef"))); + qApp->setPalette(palette); + const QPixmap flipped = icon.pixmap(QSize(24, 24), 1.0); + EXPECT_GT(countOpaquePixels(flipped, QColor(QStringLiteral("#abcdef"))), 0); + + QPalette selectedPalette = qApp->palette(); + selectedPalette.setColor(QPalette::HighlightedText, QColor(QStringLiteral("#00ff00"))); + qApp->setPalette(selectedPalette); + const QPixmap selected = icon.pixmap(QSize(24, 24), 1.0, QIcon::Selected, QIcon::Off); + EXPECT_GT(countOpaquePixels(selected, QColor(QStringLiteral("#00ff00"))), 0); +} +} // namespace + +TEST_F(ThemeIconRenderTest, SentinelUsesPaletteColor) +{ + QPalette palette; + palette.setColor(QPalette::WindowText, QColor(Qt::white)); + qApp->setPalette(palette); + + const QPixmap pixmap = loadSentinelSvgPixmap(QStringLiteral("icons/search.svg")); + + ASSERT_FALSE(pixmap.isNull()); + EXPECT_EQ(pixmap.size(), QSize(24, 24)); + EXPECT_GT(countOpaquePixels(pixmap, Qt::white), 0); +} + +TEST_F(ThemeIconRenderTest, NonSentinelSvgReturnsNull) +{ + QTemporaryDir themeDir; + ASSERT_TRUE(themeDir.isValid()); + QFile file(QDir(themeDir.path()).absoluteFilePath(QStringLiteral("plain.svg"))); + ASSERT_TRUE(file.open(QIODevice::WriteOnly)); + file.write(svgWith("#000000")); + file.close(); + QDir::addSearchPath(QStringLiteral("theme"), themeDir.path()); + + EXPECT_TRUE(loadSentinelSvgPixmap(QStringLiteral("plain.svg")).isNull()); + EXPECT_TRUE(loadSentinelSvgPixmap(QStringLiteral("icons/search.png")).isNull()); +} + +TEST_F(ThemeIconRenderTest, LowercaseSentinelIsResolved) +{ + QTemporaryDir themeDir; + ASSERT_TRUE(themeDir.isValid()); + QFile file(QDir(themeDir.path()).absoluteFilePath(QStringLiteral("lower.svg"))); + ASSERT_TRUE(file.open(QIODevice::WriteOnly)); + file.write(svgWith("currentcolor")); + file.close(); + + QDir::addSearchPath(QStringLiteral("theme"), themeDir.path()); + + QPalette palette; + palette.setColor(QPalette::WindowText, QColor(QStringLiteral("#123456"))); + qApp->setPalette(palette); + + const QPixmap pixmap = loadSentinelSvgPixmap(QStringLiteral("lower.svg")); + + ASSERT_FALSE(pixmap.isNull()); + EXPECT_GT(countOpaquePixels(pixmap, QColor(QStringLiteral("#123456"))), 0); +} 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",