diff --git a/.ci/compile.sh b/.ci/compile.sh index d60683b6e..bf5a169af 100755 --- a/.ci/compile.sh +++ b/.ci/compile.sh @@ -474,6 +474,10 @@ if [[ $RUNNER_OS == Windows && ! $MAKE_NO_CLIENT ]]; then # 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" @@ -500,22 +504,149 @@ function Invoke-Probe([string] $guardDir) { 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 { - $locked = Invoke-Probe $dir + $self = Invoke-Probe $dir } finally { $handle.Dispose() } - if ($locked.Code -ne 1) { throw "expected exit 1 for a locked file, got $($locked.Code) : $($locked.Output)" } - if ($locked.Output -notmatch "pid\s+$PID\b") { throw "the lookup did not name this process: $($locked.Output)" } + 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)" } - Write-Host "holder lookup named pid $PID for a locked file and named nothing for an empty directory" + # 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 } diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index c97b48342..54068f5a0 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -584,11 +584,17 @@ jobs: # job. A scope nobody has written to yet therefore leaves the directory missing # and fails the job, which is exactly the state every scope starts in. Make the # directory up front so an empty scope is a cache miss instead of an error. + # always() covers the cache step itself failing: the action's post save still runs + # then, and it should report the real error instead of ENOENT. It cannot rescue a + # failure before the cache step, since a post step only runs for an action whose + # main step did. The fallback keeps this guard from failing when the cache step was + # skipped and left no path output. - name: "Create vcpkg cache directory" + if: always() shell: bash env: CACHE_DIR: '${{ steps.vcpkg-cache.outputs.path }}' - run: mkdir -p "$CACHE_DIR" + run: mkdir -p "${CACHE_DIR:-$GITHUB_WORKSPACE/.vcpkg-cache}" # Uses environment variables, see compile.sh for more details - name: "Build Cockatrice" diff --git a/CMakeLists.txt b/CMakeLists.txt index 41fe393e7..d9acabc5e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -250,6 +250,15 @@ message(STATUS "Update Translations: ${UPDATE_TRANSLATIONS}") include(FindQtRuntime) +# Reject APIs that were deprecated before the oldest Qt release we support +# (6.4). Qt 6.5 renamed the switch, so use whichever spelling the found +# version understands while still compiling everywhere. +if(Qt6_VERSION VERSION_GREATER_EQUAL 6.5) + add_compile_definitions(QT_DISABLE_DEPRECATED_UP_TO=0x060400) +else() + add_compile_definitions(QT_DISABLE_DEPRECATED_BEFORE=0x060400) +endif() + set(CMAKE_AUTOMOC TRUE) # Find other needed libraries @@ -420,6 +429,9 @@ if(WITH_ORACLE OR WITH_CLIENT) add_subdirectory(${CMAKE_SOURCE_DIR}/libcockatrice_models ${CMAKE_BINARY_DIR}/libcockatrice_models) add_subdirectory(${CMAKE_SOURCE_DIR}/libcockatrice_filters ${CMAKE_BINARY_DIR}/libcockatrice_filters) endif() +if(WITH_CLIENT) + add_subdirectory(${CMAKE_SOURCE_DIR}/libcockatrice_game ${CMAKE_BINARY_DIR}/libcockatrice_game) +endif() if(WITH_SERVER) add_subdirectory(servatrice) @@ -450,5 +462,5 @@ endif() if(Qt6_FOUND AND Qt6_VERSION_MINOR GREATER_EQUAL 3) # Qt6.3+ requires project finalization to support translations - qt6_finalize_project() + qt_finalize_project() endif() diff --git a/cmake/NSIS.template.in b/cmake/NSIS.template.in index ae156313d..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,14 +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 @@ -99,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 == "" @@ -111,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 @@ -153,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 @@ -219,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 "" @@ -260,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. @@ -295,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 @@ -325,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 @@ -349,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: @@ -364,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: @@ -372,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". @@ -673,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 @@ -899,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 @@ -1004,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 @@ -1048,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 @@ -1176,6 +1706,92 @@ Function CheckStaleRuntimeMask ${EndIf} FunctionEnd +; 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} + 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. ; @@ -1215,14 +1831,18 @@ FunctionEnd ; 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. ; -; The probe opens each survivor for DELETE access and then closes it again -; without deleting anything. The code is read by the plugin right after that call -; (?e), so a failure reports what Windows refused; clearing LastError first is -; what makes a zero code mean "this open worked", i.e. the purge-time failure was -; transient (an antivirus scan that has finished, an instance still shutting -; down) rather than a standing condition. The plugin parses the call's arguments -; between the clear and the call, so a non-zero code is reported as what Windows -; said rather than as proof of a cause - the wording below never claims one. +; 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 != "" @@ -1239,21 +1859,52 @@ Function LogStaleRuntimeDiagnostics ${Else} Pop $R2 ${If} $R2 = 0 - ; A valid handle: close it. The open proves the file is not held - ; open, but not that Delete would succeed: a read-only file opens - ; for DELETE and still fails DeleteFile until the attribute is - ; cleared, which the purge does not do. Report that standing - ; cause instead of calling the failure transient. - System::Call 'kernel32::CloseHandle(p R1)' - System::Call 'kernel32::GetFileAttributesW(w "$INSTDIR\$StaleFindName") i.R3' - ${If} $R3 = -1 - StrCpy $R0 "a delete-access open now succeeds, but its attributes could not be read" + ; 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 "a delete-access open now succeeds, but it is still read-only, which Delete does not clear" - ${Else} - StrCpy $R0 "a delete-access open now succeeds" + StrCpy $R0 "$R0; it is read-only again" ${EndIf} ${EndIf} ${Else} @@ -1271,45 +1922,35 @@ Function LogStaleRuntimeDiagnostics Call LogProcessesUsingInstallDir FunctionEnd -; Names the applications and services that still have the install directory's files -; 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. +; 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 a small executable built from tools/installer-holder-probe, extracted to -; $PLUGINSDIR only when the purge has already failed: the successful path pays nothing -; for it, and $PLUGINSDIR takes it away again when the installer exits. It is compiled by -; the same Windows build that compiles this installer, which is what lets it call the -; Restart Manager API directly. The same call made from this script would have to -; marshal RM_PROCESS_INFO through System::Call as a table of hand-computed offsets, and -; that table is precisely what cannot be checked without a Windows toolchain; in -; compiled code the layout belongs to the Windows SDK header. +; 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. ; -; Restart Manager is what makes the answer complete. Walking processes can only name one -; whose image lives here or that loaded one of these libraries, while Restart Manager -; names a process that holds a plain handle on a file - an antivirus scanner - and names -; services as well as user programs. Its blind spot is a handle taken in kernel mode, -; where there is no process to name; then the codes logged above are the whole story, and -; the probe says so by naming nothing rather than by failing. -; -; $INSTDIR travels through the same COCKATRICE_GUARD_DIR environment variable the -; running-instance guard uses, so a path with spaces or apostrophes never has to be -; embedded in a command line. +; $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 - InitPluginsDir - ; Embedded by the same relative path that resolves NSIS.definitions.nsh above (both - ; are built into the build root, which is three levels up from project.nsi). - File "/oname=$PLUGINSDIR\installer-holder-probe.exe" "..\..\..\installer-holder-probe.exe" - System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$INSTDIR")' - ; /TIMEOUT because this runs on the abort path, ahead of the message box: one call - ; into the Restart Manager cannot take the minute a walk over every process can, but - ; if that service stalls it must not leave the installer hanging with nothing on - ; screen. On a timeout nsExec reports it through the exit code like any other failure. - nsExec::ExecToStack /TIMEOUT=20000 '"$PLUGINSDIR\installer-holder-probe.exe"' - Pop $HolderExit - Pop $HolderOutput + ; 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 @@ -1317,149 +1958,99 @@ Function LogProcessesUsingInstallDir ${If} $HolderExit == "1" StrCpy $HolderFound 1 ${ElseIf} $HolderExit == "0" - StrCpy $HolderOutput "nothing is holding them any more" + StrCpy $HolderOutput "nothing is holding them and no payload program is running from the directory" ${Else} - StrCpy $HolderOutput "the holder probe could not run: $HolderOutput" + StrCpy $HolderOutput "the holder probe could not name what holds them: $HolderOutput" ${EndIf} !insertmacro CockLog "holders: $HolderOutput" 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 +; 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} - ; 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 - ${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 - ${EndIf} -FunctionEnd - -Function un.IsProcessLookupAvailable - nsExec::ExecToLog 'powershell -NoProfile -Command "exit 0"' - Pop $R0 - ${If} $R0 == "0" - StrCpy $R0 1 - ${Else} - StrCpy $R0 0 - ${EndIf} -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 -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 + ; 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} - - ${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 + ; 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 @@ -1467,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 @@ -1545,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} @@ -1562,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: @@ -1579,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} @@ -1588,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: @@ -1631,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 @@ -1654,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" @@ -1685,25 +2376,13 @@ ${EndIf} ; 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. -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 ${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" - ; RMDir /r reports a failure only through the error flag, and only as a - ; boolean; the surviving file names come from the listing below. - ${If} ${FileExists} "$INSTDIR\Plugins\*.*" - !insertmacro CockLog "purge: '$INSTDIR\Plugins' could not be removed completely" - ${EndIf} - Delete "$INSTDIR\*.dll" + Call RetryDllPurge + StrCpy $StaleRuntimeAfterPurge $R0 + StrCpy $DllPurgeRan 1 !insertmacro CockLogListing "after-purge" "*.dll" !insertmacro CockLogListing "after-plugins" "Plugins\*.*" ${EndIf} @@ -1718,28 +2397,77 @@ ${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." - ; Who is holding the payload is recorded before the abort, so the log that - ; reaches us answers the question the message can only guess at, and so the - ; message can name the processes to close whenever any were found. - Call LogStaleRuntimeDiagnostics - ; 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" + 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..ce7ad5712 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 @@ -345,6 +322,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 +428,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 +490,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 +511,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 +523,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 +535,7 @@ qt6_add_shaders( src/interface/widgets/onboarding/shaders/brand_plate.frag ) -qt6_add_resources( +qt_add_resources( cockatrice "onboarding_qml" PREFIX @@ -572,6 +551,7 @@ target_link_libraries( PUBLIC libcockatrice_card libcockatrice_deck_list libcockatrice_filters + libcockatrice_game libcockatrice_utility libcockatrice_network libcockatrice_models @@ -788,7 +768,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 +778,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 +804,4 @@ if(Qt6LinguistTools_FOUND) endforeach() endif() -qt6_finalize_target(cockatrice) +qt_finalize_target(cockatrice) 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 e23e91b46..020e53877 100644 --- a/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp +++ b/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp @@ -244,17 +244,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; } @@ -517,14 +527,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..c8f96a9a9 100644 --- a/cockatrice/src/client/network/connection_controller/remote_connection_controller.h +++ b/cockatrice/src/client/network/connection_controller/remote_connection_controller.h @@ -87,7 +87,7 @@ private slots: // Forgot-password flow void onForgotPasswordSuccess(); - void onForgotPasswordError(); + void onForgotPasswordError(int responseCode); void onPromptForgotPasswordReset(); void onPromptForgotPasswordChallenge(); 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/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_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_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_container.cpp b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp index 90b3bea11..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,12 +28,12 @@ #include #include #include +#include +#include #include -#include -#include -#include -#include -#include +#include +#include +#include #include #include #include @@ -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); } @@ -446,28 +429,17 @@ void DeckViewContainer::forceStart() 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); } /** @@ -479,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_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 8c82d6401..b1ca42ee1 100644 --- a/cockatrice/src/game_graphics/zones/view_zone_widget.cpp +++ b/cockatrice/src/game_graphics/zones/view_zone_widget.cpp @@ -1,13 +1,7 @@ #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 "../card_dimensions.h" #include "../z_values.h" @@ -45,7 +39,12 @@ #include #include #include -#include +#include +#include +#include +#include +#include +#include #include #include #include @@ -178,9 +177,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(); @@ -570,7 +570,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/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_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.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/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/dialogs/dlg_convert_deck_to_cod_format.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.cpp index 31841b9cd..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,6 +11,7 @@ #include #include #include +#include #include #include 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/general/home_widget.cpp b/cockatrice/src/interface/widgets/general/home_widget.cpp index 7555d1f45..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 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/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_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/user/user_context_menu.cpp b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp index 0b5523f67..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 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 2b885237b..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 @@ -51,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); @@ -96,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); @@ -384,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()); @@ -576,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/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 3abf3f9aa..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,6 +29,7 @@ #include #include #include +#include #include #include #include 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 741a1e85f..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,6 +27,7 @@ #include #include #include +#include #include #include #include diff --git a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp index 9db1952f1..3dfdc956b 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp @@ -1,9 +1,7 @@ #include "tab_deck_storage.h" #include "../../../client/settings/cache_settings.h" -#include "../../deck_loader/deck_file_format.h" #include "../../deck_loader/deck_loader.h" -#include "../../deck_loader/loaded_deck.h" #include "../../pixel_map_generator.h" #include "../cards/additional_info/deck_color_identity.h" #include "../deck_share/deck_share_utils.h" @@ -49,7 +47,9 @@ #include #include #include +#include #include +#include #include #include #include diff --git a/cockatrice/src/interface/widgets/tabs/tab_game.cpp b/cockatrice/src/interface/widgets/tabs/tab_game.cpp index 11813cdf4..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 @@ -97,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); @@ -141,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(); @@ -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); diff --git a/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp b/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp index 9c28abd0b..ce37ce440 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp @@ -2,7 +2,6 @@ #include "../../../client/settings/cache_settings.h" #include "../../deck_loader/deck_loader.h" -#include "../../deck_loader/loaded_deck.h" #include "../cards/card_size_widget.h" #include "../general/layout_containers/flow_widget.h" #include "../visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.h" @@ -28,6 +27,7 @@ #include #include #include +#include #include #include #include diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index c614fe7f6..3316232a4 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -2,10 +2,6 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" -#include "../../../game/abstract_game.h" -#include "../../../game/game_event_handler.h" -#include "../../../game/game_meta_info.h" -#include "../../deck_loader/loaded_deck.h" #include "../../intents/contexts/context_connect_to_server.h" #include "../../intents/contexts/context_join_game.h" #include "../../intents/contexts/context_join_room.h" @@ -63,6 +59,10 @@ #include #include #include +#include +#include +#include +#include #include #include #include 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 74c149b2d..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,7 +15,9 @@ #include #include #include +#include #include +#include #include #include #include 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 4e6e7cc05..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,7 +12,10 @@ #include #include #include +#include #include +#include +#include #include #include #include @@ -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_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/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_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/window_main.cpp b/cockatrice/src/interface/window_main.cpp index a3446e42b..f8ab5d3f6 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -923,11 +923,28 @@ void MainWindow::actShow() void MainWindow::closeEvent(QCloseEvent *event) { - // workaround Qt bug where closeEvent gets called twice - if (bClosingDown) { - return; + switch (shutdownState) { + case ShutdownState::Accepted: + // A duplicate close for a shutdown that already went through (Qt delivers the event + // twice on some platforms). Accepting it again keeps the quit that is already under + // way; ignoring it would cancel it. + event->accept(); + return; + case ShutdownState::Confirming: + // A second close request arrived while one of our own confirmations is up. Another + // program asking us to close - an installer replacing our files, for one - looks + // exactly like this, and accepting it would be destructive: close() hides this + // window, hides the dialog with it, and leaves the process running inside the nested + // loop that dialog opened. Nothing can reach that loop afterwards, so the process + // stays alive with no window and every later close request is accepted into the same + // dead end. Refuse this one and leave the question in front of the user; the shutdown + // that is already running finishes as soon as they answer it. + event->ignore(); + return; + case ShutdownState::Idle: + break; } - bClosingDown = true; + shutdownState = ShutdownState::Confirming; if (cardUpdateProcess && cardUpdateProcess->state() != QProcess::NotRunning) { const QString runningFor = cardUpdateStatusBar ? cardUpdateStatusBar->statusText() : QString(); @@ -939,19 +956,20 @@ void MainWindow::closeEvent(QCloseEvent *event) .arg(runningFor); 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(); } @@ -962,12 +980,36 @@ bool MainWindow::closeForUpdate() // closeEvent() is blocked on a user prompt). close() would hit the re-entrancy guard and return // true without any shutdown having happened, so report faithfully instead: the caller must keep // the process alive until the user resolves whatever is blocking the close. - if (bClosingDown) { + if (shutdownState != ShutdownState::Idle) { return false; } return close(); } +void MainWindow::requestInstallerClose() +{ + // The same shutdown the in-app updater asks for: close the window, flush settings and let + // the tabs answer their own prompts. Never a forced quit - ending a process that has not + // agreed is the installer's separate force-close answer, not this one. + if (closeForUpdate()) { + QTimer::singleShot(0, qApp, [] { QCoreApplication::exit(0); }); + return; + } + // A confirmation is already up, or a shutdown is in progress. Bring it forward: an + // installer prompt behind a hidden confirmation is the deadlock this request exists to + // break, and the user has to be able to answer it. showWindowIfHidden() also clears + // Qt::WindowMinimized, which raise() alone does not: a minimized window can keep the + // confirmation behind the installer. + showWindowIfHidden(); + if (QWidget *modal = QApplication::activeModalWidget()) { + modal->raise(); + modal->activateWindow(); + } + raise(); + activateWindow(); + QApplication::alert(this, 0); +} + void MainWindow::changeEvent(QEvent *event) { if (event->type() == QEvent::LanguageChange) { 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/libcockatrice_card/CMakeLists.txt b/libcockatrice_card/CMakeLists.txt index b3583522c..e2d5029ed 100644 --- a/libcockatrice_card/CMakeLists.txt +++ b/libcockatrice_card/CMakeLists.txt @@ -21,11 +21,9 @@ set(HEADERS libcockatrice/card/relation/card_relation.h ) -qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) - add_library( libcockatrice_card STATIC - ${MOC_SOURCES} + ${HEADERS} libcockatrice/card/card_info.cpp libcockatrice/card/card_info_comparator.cpp libcockatrice/card/card_localization.cpp diff --git a/libcockatrice_card/libcockatrice/card/card_info.cpp b/libcockatrice_card/libcockatrice/card/card_info.cpp index 388c9ab57..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, @@ -97,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) { @@ -126,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) { @@ -169,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); @@ -188,7 +206,7 @@ void CardInfo::combineLegalities(const QHash &props) properties.insert(it.key(), it.value()); } } - emit cardInfoChanged(smartThis); + notifyChanged(); } void CardInfo::refreshCachedSets() diff --git a/libcockatrice_card/libcockatrice/card/card_info.h b/libcockatrice_card/libcockatrice/card/card_info.h index eb1250a1c..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. @@ -162,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. } /** @@ -252,27 +253,13 @@ public: /** * @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 */ //@{ @@ -295,7 +282,7 @@ public: void setText(const QString &_text) { text = _text; - emit cardInfoChanged(smartThis); + notifyChanged(); } /** @@ -358,7 +345,7 @@ public: return; } localizedNames.insert(lang, _localizedName); - emit cardInfoChanged(smartThis); + notifyChanged(); } /** @@ -373,7 +360,7 @@ public: return; } localizedTexts.insert(lang, _localizedText); - emit cardInfoChanged(smartThis); + notifyChanged(); } /** @@ -552,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 946412606..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(); } @@ -280,6 +310,10 @@ void CardDatabase::swapInDatabaseData(CardDatabaseData data) 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/set/card_set.cpp b/libcockatrice_card/libcockatrice/card/set/card_set.cpp index e2b41a811..a0f0f4578 100644 --- a/libcockatrice_card/libcockatrice/card/set/card_set.cpp +++ b/libcockatrice_card/libcockatrice/card/set/card_set.cpp @@ -27,7 +27,6 @@ CardSetPtr CardSet::newInstance(ICardSetPriorityController *_priorityController, const Priority _priority) { CardSetPtr ptr(new CardSet(_priorityController, _shortName, _longName, _setType, _releaseDate, _priority)); - // ptr->setSmartPointer(ptr); return ptr; } diff --git a/libcockatrice_deck_list/CMakeLists.txt b/libcockatrice_deck_list/CMakeLists.txt index e61a5702c..ffd4c28a1 100644 --- a/libcockatrice_deck_list/CMakeLists.txt +++ b/libcockatrice_deck_list/CMakeLists.txt @@ -7,28 +7,30 @@ set(HEADERS libcockatrice/deck_list/tree/abstract_deck_list_node.h libcockatrice/deck_list/tree/deck_list_card_node.h libcockatrice/deck_list/tree/inner_deck_list_node.h + libcockatrice/deck_list/deck_file_format.h libcockatrice/deck_list/deck_list.h libcockatrice/deck_list/deck_list_history_manager.h libcockatrice/deck_list/deck_list_node_tree.h libcockatrice/deck_list/deck_list_memento.h libcockatrice/deck_list/deck_list_plain_text_parser.h + libcockatrice/deck_list/loaded_deck.h libcockatrice/deck_list/playmat_resolver.h libcockatrice/deck_list/sideboard_plan.h ) -qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) - add_library( libcockatrice_deck_list STATIC - ${MOC_SOURCES} + ${HEADERS} libcockatrice/deck_list/tree/abstract_deck_list_card_node.cpp libcockatrice/deck_list/tree/abstract_deck_list_node.cpp libcockatrice/deck_list/tree/deck_list_card_node.cpp libcockatrice/deck_list/tree/inner_deck_list_node.cpp + libcockatrice/deck_list/deck_file_format.cpp libcockatrice/deck_list/deck_list.cpp libcockatrice/deck_list/deck_list_history_manager.cpp libcockatrice/deck_list/deck_list_node_tree.cpp libcockatrice/deck_list/deck_list_plain_text_parser.cpp + libcockatrice/deck_list/loaded_deck.cpp libcockatrice/deck_list/playmat_resolver.cpp libcockatrice/deck_list/sideboard_plan.cpp ) diff --git a/cockatrice/src/interface/deck_loader/deck_file_format.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/deck_file_format.cpp similarity index 100% rename from cockatrice/src/interface/deck_loader/deck_file_format.cpp rename to libcockatrice_deck_list/libcockatrice/deck_list/deck_file_format.cpp diff --git a/cockatrice/src/interface/deck_loader/deck_file_format.h b/libcockatrice_deck_list/libcockatrice/deck_list/deck_file_format.h similarity index 100% rename from cockatrice/src/interface/deck_loader/deck_file_format.h rename to libcockatrice_deck_list/libcockatrice/deck_list/deck_file_format.h diff --git a/cockatrice/src/interface/deck_loader/loaded_deck.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/loaded_deck.cpp similarity index 100% rename from cockatrice/src/interface/deck_loader/loaded_deck.cpp rename to libcockatrice_deck_list/libcockatrice/deck_list/loaded_deck.cpp diff --git a/cockatrice/src/interface/deck_loader/loaded_deck.h b/libcockatrice_deck_list/libcockatrice/deck_list/loaded_deck.h similarity index 100% rename from cockatrice/src/interface/deck_loader/loaded_deck.h rename to libcockatrice_deck_list/libcockatrice/deck_list/loaded_deck.h diff --git a/libcockatrice_filters/CMakeLists.txt b/libcockatrice_filters/CMakeLists.txt index 2d45b8e5b..df2ead83f 100644 --- a/libcockatrice_filters/CMakeLists.txt +++ b/libcockatrice_filters/CMakeLists.txt @@ -6,11 +6,9 @@ set(HEADERS libcockatrice/filters/filter_card.h libcockatrice/filters/filter_str libcockatrice/filters/filter_tree.h ) -qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) - add_library( - libcockatrice_filters STATIC ${MOC_SOURCES} libcockatrice/filters/filter_card.cpp - libcockatrice/filters/filter_string.cpp libcockatrice/filters/filter_tree.cpp + libcockatrice_filters STATIC ${HEADERS} libcockatrice/filters/filter_card.cpp libcockatrice/filters/filter_string.cpp + libcockatrice/filters/filter_tree.cpp ) add_dependencies(libcockatrice_filters libcockatrice_card) diff --git a/libcockatrice_game/CMakeLists.txt b/libcockatrice_game/CMakeLists.txt new file mode 100644 index 000000000..e7d362ca2 --- /dev/null +++ b/libcockatrice_game/CMakeLists.txt @@ -0,0 +1,81 @@ +set(CMAKE_AUTOMOC ON) +set(CMAKE_AUTOUIC ON) +set(CMAKE_AUTORCC ON) + +set(HEADERS + libcockatrice/game/abstract_game.h + libcockatrice/game/board/arrow_data.h + libcockatrice/game/board/card_list.h + libcockatrice/game/board/card_state.h + libcockatrice/game/board/counter_state.h + libcockatrice/game/game.h + libcockatrice/game/game_event_handler.h + libcockatrice/game/game_meta_info.h + libcockatrice/game/game_state.h + libcockatrice/game/phase.h + libcockatrice/game/player/card_menu_action_type.h + libcockatrice/game/player/card_move_request.h + libcockatrice/game/player/event_processing_options.h + libcockatrice/game/player/move_top_cards_until_options.h + libcockatrice/game/player/player_actions.h + libcockatrice/game/player/player_event_handler.h + libcockatrice/game/player/player_info.h + libcockatrice/game/player/player_logic.h + libcockatrice/game/player/player_manager.h + libcockatrice/game/player/token_info.h + libcockatrice/game/replay.h + libcockatrice/game/translation.h + libcockatrice/game/zones/card_zone_algorithms.h + libcockatrice/game/zones/card_zone_logic.h + libcockatrice/game/zones/hand_zone_logic.h + libcockatrice/game/zones/pile_zone_logic.h + libcockatrice/game/zones/stack_zone_logic.h + libcockatrice/game/zones/table_zone_logic.h + libcockatrice/game/zones/view_zone_logic.h +) + +qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) + +add_library( + libcockatrice_game STATIC + ${MOC_SOURCES} + libcockatrice/game/abstract_game.cpp + libcockatrice/game/board/arrow_data.cpp + libcockatrice/game/board/card_list.cpp + libcockatrice/game/board/card_state.cpp + libcockatrice/game/board/counter_state.cpp + libcockatrice/game/game.cpp + libcockatrice/game/game_event_handler.cpp + libcockatrice/game/game_meta_info.cpp + libcockatrice/game/game_state.cpp + libcockatrice/game/phase.cpp + libcockatrice/game/player/player_actions.cpp + libcockatrice/game/player/player_event_handler.cpp + libcockatrice/game/player/player_info.cpp + libcockatrice/game/player/player_logic.cpp + libcockatrice/game/player/player_manager.cpp + libcockatrice/game/replay.cpp + libcockatrice/game/zones/card_zone_logic.cpp + libcockatrice/game/zones/hand_zone_logic.cpp + libcockatrice/game/zones/pile_zone_logic.cpp + libcockatrice/game/zones/stack_zone_logic.cpp + libcockatrice/game/zones/table_zone_logic.cpp + libcockatrice/game/zones/view_zone_logic.cpp +) + +target_include_directories(libcockatrice_game PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) + +target_link_libraries( + libcockatrice_game + PUBLIC libcockatrice_card + libcockatrice_deck_list + libcockatrice_filters + libcockatrice_interfaces + libcockatrice_network + libcockatrice_protocol + libcockatrice_settings + libcockatrice_utility + ${QT_CORE_MODULE} + ${COCKATRICE_QT_VERSION_NAME}::Concurrent + ${COCKATRICE_QT_VERSION_NAME}::Gui +) diff --git a/cockatrice/src/game/abstract_game.cpp b/libcockatrice_game/libcockatrice/game/abstract_game.cpp similarity index 88% rename from cockatrice/src/game/abstract_game.cpp rename to libcockatrice_game/libcockatrice/game/abstract_game.cpp index efc575eff..a93f44018 100644 --- a/cockatrice/src/game/abstract_game.cpp +++ b/libcockatrice_game/libcockatrice/game/abstract_game.cpp @@ -18,8 +18,6 @@ AbstractGame::AbstractGame(QObject *_parent) : QObject(_parent) { gameMetaInfo = new GameMetaInfo(this); gameEventHandler = new GameEventHandler(this); - - activeCard = nullptr; } bool AbstractGame::isHost() const @@ -48,12 +46,7 @@ void AbstractGame::loadReplay(const GameReplay *replay) gameMetaInfo->setSpectatorsOmniscient(true); } -void AbstractGame::setActiveCard(CardItem *card) -{ - activeCard = card; -} - -CardItem *AbstractGame::getCard(int playerId, const QString &zoneName, int cardId) const +CardState *AbstractGame::getCard(int playerId, const QString &zoneName, int cardId) const { PlayerLogic *player = playerManager->getPlayer(playerId); if (!player) { diff --git a/cockatrice/src/game/abstract_game.h b/libcockatrice_game/libcockatrice/game/abstract_game.h similarity index 82% rename from cockatrice/src/game/abstract_game.h rename to libcockatrice_game/libcockatrice/game/abstract_game.h index 326af8ac9..00a95a3b8 100644 --- a/cockatrice/src/game/abstract_game.h +++ b/libcockatrice_game/libcockatrice/game/abstract_game.h @@ -11,14 +11,14 @@ #include #include -class CardItem; class AbstractClient; +class CardState; class GameEventHandler; class GameMetaInfo; class GameReplay; class GameState; class PlayerManager; - +class CardState; class AbstractGame : public QObject { Q_OBJECT @@ -30,7 +30,6 @@ public: GameState *gameState; GameEventHandler *gameEventHandler; PlayerManager *playerManager; - CardItem *activeCard; GameMetaInfo *getGameMetaInfo() { @@ -58,13 +57,7 @@ public: void loadReplay(const GameReplay *replay); - CardItem *getCard(int playerId, const QString &zoneName, int cardId) const; - - void setActiveCard(CardItem *card); - CardItem *getActiveCard() const - { - return activeCard; - } + CardState *getCard(int playerId, const QString &zoneName, int cardId) const; }; #endif // COCKATRICE_ABSTRACT_GAME_H diff --git a/cockatrice/src/game/board/arrow_data.cpp b/libcockatrice_game/libcockatrice/game/board/arrow_data.cpp similarity index 100% rename from cockatrice/src/game/board/arrow_data.cpp rename to libcockatrice_game/libcockatrice/game/board/arrow_data.cpp diff --git a/cockatrice/src/game/board/arrow_data.h b/libcockatrice_game/libcockatrice/game/board/arrow_data.h similarity index 100% rename from cockatrice/src/game/board/arrow_data.h rename to libcockatrice_game/libcockatrice/game/board/arrow_data.h diff --git a/cockatrice/src/game/board/card_list.cpp b/libcockatrice_game/libcockatrice/game/board/card_list.cpp similarity index 70% rename from cockatrice/src/game/board/card_list.cpp rename to libcockatrice_game/libcockatrice/game/board/card_list.cpp index 94bae67cd..361c51502 100644 --- a/cockatrice/src/game/board/card_list.cpp +++ b/libcockatrice_game/libcockatrice/game/board/card_list.cpp @@ -1,7 +1,6 @@ #include "card_list.h" -#include "../../game_graphics/board/card_item.h" -#include "libcockatrice/card/printing/exact_card.h" +#include "card_state.h" #include #include @@ -11,26 +10,26 @@ #include #include -CardList::CardList(bool _contentsKnown) : QList(), contentsKnown(_contentsKnown) +CardList::CardList(bool _contentsKnown) : QList(), contentsKnown(_contentsKnown) { } /** - * @brief Finds the CardItem with the given id in the list. + * @brief Finds the CardState with the given id in the list. * If contentsKnown is false, then this just returns the first element of the list. * * @param cardId The id of the card to find. * - * @returns A pointer to the CardItem, or a nullptr if not found. + * @returns A pointer to the CardState, or a nullptr if not found. */ -CardItem *CardList::findCard(const int cardId) const +CardState *CardList::findCard(const int cardId) const { if (!contentsKnown && !empty()) { return at(0); } else { - for (auto *cardItem : *this) { - if (cardItem->getId() == cardId) { - return cardItem; + for (auto *cardState : *this) { + if (cardState->getId() == cardId) { + return cardState; } } } @@ -38,7 +37,7 @@ CardItem *CardList::findCard(const int cardId) const } /** - * @brief sorts the list by using string comparison on properties extracted from the CardItem + * @brief sorts the list by using string comparison on properties extracted from the CardState * The cards are compared using each property in order. * If two cards have the same value for a property, then the next property in the list is used. * @@ -51,7 +50,7 @@ void CardList::sortBy(const QList &option) return; } - auto comparator = [&option](CardItem *a, CardItem *b) { + auto comparator = [&option](CardState *a, CardState *b) { for (auto prop : option) { auto extractor = getExtractorFor(prop); QString t1 = extractor(a); @@ -116,26 +115,26 @@ static QString getColorSortString(const CardInfo &c, bool appendAtEnd) } /** - * @brief returns the function that extracts the given property from the CardItem. + * @brief returns the function that extracts the given property from the CardState. */ -std::function CardList::getExtractorFor(SortOption option) +std::function CardList::getExtractorFor(SortOption option) { switch (option) { case NoSort: - return [](CardItem *) { return ""; }; + return [](CardState *) { return ""; }; case SortByMainType: - return [](CardItem *c) { return c->getCardInfo().getMainCardType(); }; + return [](CardState *c) { return c->getCardInfo().getMainCardType(); }; case SortByManaValue: // getCmc returns the int as a string. We pad with 0's so that string comp also works on it - return [](CardItem *c) { return c->getCard() ? c->getCardInfo().getCmc().rightJustified(4, '0') : ""; }; + return [](CardState *c) { return c->getCard() ? c->getCardInfo().getCmc().rightJustified(4, '0') : ""; }; case SortByColorGrouping: - return [](CardItem *c) { return c->getCard() ? getColorSortString(c->getCardInfo(), false) : ""; }; + return [](CardState *c) { return c->getCard() ? getColorSortString(c->getCardInfo(), false) : ""; }; case SortByName: - return [](CardItem *c) { return c->getName(); }; + return [](CardState *c) { return c->getName(); }; case SortByType: - return [](CardItem *c) { return c->getCardInfo().getCardType(); }; + return [](CardState *c) { return c->getCardInfo().getCardType(); }; case SortByManaCost: - return [](CardItem *c) { + return [](CardState *c) { if (!c->getCard()) { return QString(); } @@ -147,18 +146,18 @@ std::function CardList::getExtractorFor(SortOption option) return QString("%1%2").arg(info.getCmc(), 4, QChar('0')).arg(info.getManaCost()); }; case SortByColors: - return [](CardItem *c) { return c->getCard() ? getColorSortString(c->getCardInfo(), true) : ""; }; + return [](CardState *c) { return c->getCard() ? getColorSortString(c->getCardInfo(), true) : ""; }; case SortByPt: // do the same padding trick as above return - [](CardItem *c) { return c->getCard() ? c->getCardInfo().getPowTough().rightJustified(10, '0') : ""; }; + [](CardState *c) { return c->getCard() ? c->getCardInfo().getPowTough().rightJustified(10, '0') : ""; }; case SortBySet: - return [](CardItem *c) { return c->getCardInfo().getSetsNames(); }; + return [](CardState *c) { return c->getCardInfo().getSetsNames(); }; case SortByPrinting: - return [](CardItem *c) { return c->getProviderId(); }; + return [](CardState *c) { return c->getProviderId(); }; } // this line should never be reached qCWarning(CardListLog) << "cardlist.cpp: Could not find extractor for SortOption" << option; - return [](CardItem *) { return ""; }; + return [](CardState *) { return ""; }; } \ No newline at end of file diff --git a/cockatrice/src/game/board/card_list.h b/libcockatrice_game/libcockatrice/game/board/card_list.h similarity index 84% rename from cockatrice/src/game/board/card_list.h rename to libcockatrice_game/libcockatrice/game/board/card_list.h index b5427df0d..b3037d0f6 100644 --- a/cockatrice/src/game/board/card_list.h +++ b/libcockatrice_game/libcockatrice/game/board/card_list.h @@ -14,9 +14,9 @@ inline Q_LOGGING_CATEGORY(CardListLog, "card_list"); -class CardItem; +class CardState; -class CardList : public QList +class CardList : public QList { protected: bool contentsKnown; @@ -43,7 +43,7 @@ public: SortByPrinting }; explicit CardList(bool _contentsKnown); - CardItem *findCard(const int cardId) const; + CardState *findCard(const int cardId) const; bool getContentsKnown() const { return contentsKnown; @@ -51,7 +51,7 @@ public: void sortBy(const QList &options); - static std::function getExtractorFor(SortOption option); + static std::function getExtractorFor(SortOption option); }; #endif diff --git a/libcockatrice_game/libcockatrice/game/board/card_state.cpp b/libcockatrice_game/libcockatrice/game/board/card_state.cpp new file mode 100644 index 000000000..7e8c079a4 --- /dev/null +++ b/libcockatrice_game/libcockatrice/game/board/card_state.cpp @@ -0,0 +1,240 @@ +#include "card_state.h" + +#include "../zones/card_zone_logic.h" + +#include +#include +#include +#include + +void CardState::resetState(bool keepAnnotations) +{ + attacking = false; + counters.clear(); + pt.clear(); + if (!keepAnnotations) { + annotation.clear(); + } + attachedTo = nullptr; + attachedCards.clear(); + setTapped(false, false); + setDoesntUntap(false); + emit stateReset(); +} + +void CardState::setColor(const QString &_color) +{ + color = _color; + emit colorChanged(); +} + +void CardState::deleteView() +{ + emit viewDeleteRequested(); +} + +void CardState::setCardRef(const CardRef &_cardRef) +{ + if (cardRef == _cardRef) { + return; + } + + const CardRef oldCardRef = cardRef; + if (exactCard) { + disconnect(exactCard.getCardPtr().data(), nullptr, this, nullptr); + } + cardRef = _cardRef; + + emit cardRefChanged(oldCardRef, cardRef); + refreshCardInfo(); +} + +void CardState::refreshCardInfo() +{ + exactCard = CardDatabaseManager::query()->getCard(cardRef); + + if (!exactCard && !cardRef.name.isEmpty()) { + CardInfo::UiAttributes attributes = {.tableRow = -1}; + auto info = CardInfo::newInstance(cardRef.name, "", true, {}, {}, {}, {}, attributes); + exactCard = ExactCard(info); + } + if (exactCard) { + connect(exactCard.getCardPtr().data(), &CardInfo::pixmapUpdated, this, &CardState::cardPixmapUpdated); + } + + emit cardInfoChanged(); +} + +void CardState::setTapped(bool _tapped, bool canAnimate) +{ + if (tapped == _tapped) { + return; + } + + tapped = _tapped; + emit tappedChanged(tapped, canAnimate); + emit stateChanged(); +} + +void CardState::setFaceDown(bool _facedown) +{ + facedown = _facedown; + emit facedownChanged(facedown); + emit stateChanged(); +} + +QVariantList CardState::parsePT(const QString &pt) +{ + QVariantList ptList = QVariantList(); + if (!pt.isEmpty()) { + int sep = pt.indexOf('/'); + if (sep == 0) { + ptList.append(QVariant(pt.mid(1))); // cut off starting '/' and take full string + } else { + int start = 0; + for (;;) { + QString item = pt.mid(start, sep - start); + if (item.isEmpty()) { + ptList.append(QVariant(QString())); + } else if (item[0] == '+') { + ptList.append(QVariant(item.mid(1).toInt())); // add as int + } else if (item[0] == '-') { + ptList.append(QVariant(item.toInt())); // add as int + } else { + ptList.append(QVariant(item)); // add as qstring + } + if (sep == -1) { + break; + } + start = sep + 1; + sep = pt.indexOf('/', start); + } + } + } + return ptList; +} + +void CardState::setZone(CardZoneLogic *_zone) +{ + if (zone == _zone) { + return; + } + + zone = _zone; + emit zoneChanged(this, zone); + emit stateChanged(); +} + +void CardState::setAttacking(bool _attacking) +{ + if (attacking == _attacking) { + return; + } + attacking = _attacking; + emit attackingChanged(_attacking); + emit stateChanged(); +} + +void CardState::insertCounter(int id, int value) +{ + counters.insert(id, value); + + emit countersChanged(counters); + emit stateChanged(); +} + +void CardState::setCounter(int id, int value) +{ + if (value) { + counters[id] = value; + } else { + counters.remove(id); + } + + emit countersChanged(counters); + emit stateChanged(); +} + +void CardState::clearCounters() +{ + counters.clear(); + emit countersChanged(counters); + emit stateChanged(); +} + +void CardState::setAnnotation(const QString &_annotation) +{ + if (annotation == _annotation) { + return; + } + annotation = _annotation; + emit annotationChanged(annotation); + emit stateChanged(); +} + +void CardState::setPT(const QString &_pt) +{ + if (pt == _pt) { + return; + } + pt = _pt; + emit ptChanged(pt); + emit stateChanged(); +} + +void CardState::setDoesntUntap(bool _doesntUntap) +{ + if (doesntUntap == _doesntUntap) { + return; + } + doesntUntap = _doesntUntap; + emit doesntUntapChanged(_doesntUntap); + emit stateChanged(); +} + +void CardState::setDestroyOnZoneChange(bool _destroyOnZoneChange) +{ + if (destroyOnZoneChange == _destroyOnZoneChange) { + return; + } + + destroyOnZoneChange = _destroyOnZoneChange; + emit destroyOnZoneChangeChanged(_destroyOnZoneChange); + emit stateChanged(); +} + +void CardState::setAttachedTo(CardState *_attachedTo) +{ + if (attachedTo == _attachedTo) { + return; + } + + if (attachedTo != nullptr) { + attachedTo->removeAttachedCard(this); + } + + setGridPoint(QPoint(-1, getGridPoint().y())); + attachedTo = _attachedTo; + emit attachedToChanged(_attachedTo); + emit stateChanged(); + + if (attachedTo != nullptr) { + // If the zone is being torn down, it might already be null by the time a card tries to un-attach all its + // attached cards. The graphics item that owns this state handles the teardown in that case. + CardZoneLogic *parentZone = attachedTo->getZone(); + if (parentZone == nullptr) { + return; + } + emit parentZone->cardAdded(this); + attachedTo->addAttachedCard(this); + if (getZone() != parentZone) { + parentZone->reorganizeCards(); + } + } else if (getZone() != nullptr) { + emit getZone() -> cardAdded(this); + } + + if (getZone() != nullptr) { + getZone()->reorganizeCards(); + } +} diff --git a/libcockatrice_game/libcockatrice/game/board/card_state.h b/libcockatrice_game/libcockatrice/game/board/card_state.h new file mode 100644 index 000000000..fa1e81393 --- /dev/null +++ b/libcockatrice_game/libcockatrice/game/board/card_state.h @@ -0,0 +1,243 @@ +#ifndef COCKATRICE_CARD_STATE_H +#define COCKATRICE_CARD_STATE_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class CardZoneLogic; +class CardState : public QObject +{ + Q_OBJECT + +private: + int id = -1; + CardRef cardRef; + ExactCard exactCard; + bool tapped = false; + bool facedown = false; + QPoint gridPoint; + + bool attacking = false; + QMap counters; + QString annotation; + QString pt; + bool doesntUntap = false; + bool destroyOnZoneChange = false; + QString color; + + CardState *attachedTo = nullptr; + QList attachedCards; + CardZoneLogic *zone = nullptr; + +signals: + void stateChanged(); + + void cardRefChanged(const CardRef &oldCardRef, const CardRef &newCardRef); + void cardInfoChanged(); + void cardPixmapUpdated(); + void tappedChanged(bool newTapped, bool canAnimate); + void facedownChanged(bool newFaceDown); + void stateReset(); + void colorChanged(); + + void attackingChanged(bool newValue); + void countersChanged(const QMap &newCounters); + void annotationChanged(const QString &newAnnotation); + void ptChanged(const QString &newPt); + void doesntUntapChanged(bool newValue); + void destroyOnZoneChangeChanged(bool newValue); + void attachedToChanged(CardState *newAttachedTo); + void zoneChanged(CardState *changedCard, CardZoneLogic *newZone); + + void viewDeleteRequested(); + +public: + explicit CardState(QObject *parent, CardZoneLogic *_zone = nullptr, const CardRef &_cardRef = {}, int _id = -1) + : QObject(parent), id(_id), cardRef(_cardRef), zone(_zone) + { + } + + void resetState(bool keepAnnotations); + + /** + * @brief Asks the card view owning this state to tear itself down. + * + * The state never holds a card item directly, so it cannot delete one. It asks instead and lets the view run + * its own teardown, which detaches attachments, drops it from the active card slot and unregisters its + * animations before the graphics object goes away. + */ + void deleteView(); + + int getId() const + { + return id; + } + + void setId(int _id) + { + id = _id; + } + + const CardRef &getCardRef() const + { + return cardRef; + } + + void setCardRef(const CardRef &_cardRef); + + QString getName() const + { + return cardRef.name; + } + + QString getProviderId() const + { + return cardRef.providerId; + } + + ExactCard getCard() const + { + return exactCard; + } + + const CardInfo &getCardInfo() const + { + return exactCard.getInfo(); + } + + void refreshCardInfo(); + + bool getTapped() const + { + return tapped; + } + + void setTapped(bool _tapped, bool canAnimate = false); + + bool getFaceDown() const + { + return facedown; + } + + void setFaceDown(bool _facedown); + + const QPoint &getGridPoint() const + { + return gridPoint; + } + + void setGridPoint(const QPoint &_gridPoint) + { + gridPoint = _gridPoint; + } + + CardZoneLogic *getZone() const + { + return zone; + } + + void setZone(CardZoneLogic *_zone); + + bool getAttacking() const + { + return attacking; + } + void setAttacking(bool _attacking); + + const QMap &getCounters() const + { + return counters; + } + + void insertCounter(int id, int value); + + void setCounter(int id, int value); + + void clearCounters(); + + QString getAnnotation() const + { + return annotation; + } + + void setAnnotation(const QString &_annotation); + + QString getPT() const + { + return pt; + } + + void setPT(const QString &_pt); + + /** + * @brief Parses a string representing a p/t in order to extract the values from it. + * + * If the string contains '/', the string will be split at the '/' and each side will be parsed separately, + * which means the result list will have two elements. + * + * If '/' is not found, then the entire string is parsed together, which means the result list will + * have a single element. + * + * If either side of the split is empty, there will also only be a single element in the result list. + * + * This function will attempt to parse each substring as an int first, handling plus and minus prefixes. + * If successful, it will put the parsed value into the QVariant as an int. + * If failed, it will just put the substring into the QVariant as a QString. + * + * @param pt The p/t string + * @return A QVariantList that can contain one or two elements, where each QVariant can be either int or QString + */ + static QVariantList parsePT(const QString &pt); + + [[nodiscard]] QString getColor() const + { + return color; + } + + void setColor(const QString &_color); + + bool getDoesntUntap() const + { + return doesntUntap; + } + + void setDoesntUntap(bool _doesntUntap); + + bool getDestroyOnZoneChange() const + { + return destroyOnZoneChange; + } + + void setDestroyOnZoneChange(bool _destroyOnZoneChange); + + CardState *getAttachedTo() const + { + return attachedTo; + } + + void setAttachedTo(CardState *_attachedTo); + + void addAttachedCard(CardState *card) + { + attachedCards.append(card); + } + + void removeAttachedCard(CardState *card) + { + attachedCards.removeOne(card); + } + + const QList &getAttachedCards() const + { + return attachedCards; + } +}; + +#endif // COCKATRICE_CARD_STATE_H diff --git a/cockatrice/src/game/board/counter_state.cpp b/libcockatrice_game/libcockatrice/game/board/counter_state.cpp similarity index 100% rename from cockatrice/src/game/board/counter_state.cpp rename to libcockatrice_game/libcockatrice/game/board/counter_state.cpp diff --git a/cockatrice/src/game/board/counter_state.h b/libcockatrice_game/libcockatrice/game/board/counter_state.h similarity index 100% rename from cockatrice/src/game/board/counter_state.h rename to libcockatrice_game/libcockatrice/game/board/counter_state.h diff --git a/cockatrice/src/game/game.cpp b/libcockatrice_game/libcockatrice/game/game.cpp similarity index 75% rename from cockatrice/src/game/game.cpp rename to libcockatrice_game/libcockatrice/game/game.cpp index 7343d052a..a1a4a31a8 100644 --- a/cockatrice/src/game/game.cpp +++ b/libcockatrice_game/libcockatrice/game/game.cpp @@ -16,13 +16,16 @@ Game::Game(QObject *_parent, bool isLocalGame, QList &_clients, const Event_GameJoined &event, - const QMap &_roomGameTypes) + const QMap &_roomGameTypes, + IInterfaceSettingsProvider *interfaceSettings, + ICardPreferenceProvider *cardPreferences) : AbstractGame(_parent) { gameMetaInfo->setFromProto(event.game_info()); gameMetaInfo->setRoomGameTypes(_roomGameTypes); gameState = new GameState(this, 0, event.host_id(), isLocalGame, _clients, false, event.resuming(), -1, false); connect(gameMetaInfo, &GameMetaInfo::startedChanged, gameState, &GameState::onStartedChanged); - playerManager = new PlayerManager(this, event.player_id(), event.judge(), event.spectator()); + playerManager = new PlayerManager(this, event.player_id(), event.judge(), event.spectator(), interfaceSettings, + cardPreferences); gameMetaInfo->setStarted(false); } diff --git a/cockatrice/src/game/game.h b/libcockatrice_game/libcockatrice/game/game.h similarity index 69% rename from cockatrice/src/game/game.h rename to libcockatrice_game/libcockatrice/game/game.h index 6a309cfb2..f80b75ea5 100644 --- a/cockatrice/src/game/game.h +++ b/libcockatrice_game/libcockatrice/game/game.h @@ -18,6 +18,9 @@ class QString; template class QMap; template class QList; +class ICardPreferenceProvider; +class IInterfaceSettingsProvider; + class Game : public AbstractGame { Q_OBJECT @@ -27,7 +30,9 @@ public: bool isLocalGame, QList &_clients, const Event_GameJoined &event, - const QMap &_roomGameTypes); + const QMap &_roomGameTypes, + IInterfaceSettingsProvider *interfaceSettings = nullptr, + ICardPreferenceProvider *cardPreferences = nullptr); }; #endif // COCKATRICE_GAME_H diff --git a/cockatrice/src/game/game_event_handler.cpp b/libcockatrice_game/libcockatrice/game/game_event_handler.cpp similarity index 89% rename from cockatrice/src/game/game_event_handler.cpp rename to libcockatrice_game/libcockatrice/game/game_event_handler.cpp index 7b08af47c..856475dfa 100644 --- a/cockatrice/src/game/game_event_handler.cpp +++ b/libcockatrice_game/libcockatrice/game/game_event_handler.cpp @@ -28,12 +28,20 @@ #include #include #include +#include #include +#include #include #include #include +#include #include #include +#include +#include +#include +#include +#include #include #include #include @@ -48,6 +56,7 @@ #include #include #include +#include #include #include @@ -223,6 +232,89 @@ void GameEventHandler::handleActivePhaseChanged(int phase) sendGameCommand(cmd); } +void GameEventHandler::handleUntapAll() +{ + Command_SetCardAttr cmd; + cmd.set_zone(ZoneNames::TABLE); + cmd.set_attribute(AttrTapped); + cmd.set_attr_value("0"); + + sendGameCommand(cmd); +} + +void GameEventHandler::handleDrawCard() +{ + Command_DrawCards cmd; + cmd.set_number(1); + + sendGameCommand(cmd); +} + +PendingCommand *GameEventHandler::prepareDeckSelect(const QString &deckList) +{ + Command_DeckSelect cmd; + cmd.set_deck(deckList.toStdString()); + + return prepareGameCommand(cmd); +} + +PendingCommand *GameEventHandler::prepareDeckSelectId(int deckId) +{ + Command_DeckSelect cmd; + cmd.set_deck_id(deckId); + + return prepareGameCommand(cmd); +} + +PendingCommand *GameEventHandler::prepareSetPlaymat(const PlaymatInfo &playmat) +{ + Command_SetPlaymat cmd; + auto *pp = cmd.mutable_playmat_params(); + pp->set_card_name(playmat.card.name.toStdString()); + pp->set_card_provider_id(playmat.card.providerId.toStdString()); + pp->set_margin_pct_l(playmat.params.marginPctL); + pp->set_margin_pct_r(playmat.params.marginPctR); + pp->set_vertical_offset(playmat.params.verticalOffset); + pp->set_zoom(playmat.params.zoom); + + return prepareGameCommand(cmd); +} + +void GameEventHandler::sendReadyStart(bool ready, int playerId) +{ + Command_ReadyStart cmd; + cmd.set_ready(ready); + + sendGameCommand(cmd, playerId); +} + +void GameEventHandler::sendForceStart(int playerId) +{ + Command_ReadyStart cmd; + cmd.set_force_start(true); + cmd.set_ready(true); + + sendGameCommand(cmd, playerId); +} + +void GameEventHandler::sendSetSideboardLock(bool locked, int playerId) +{ + Command_SetSideboardLock cmd; + cmd.set_locked(locked); + + sendGameCommand(cmd, playerId); +} + +void GameEventHandler::sendSetSideboardPlan(const QList &plan, int playerId) +{ + Command_SetSideboardPlan cmd; + for (const auto &zone : plan) { + cmd.add_move_list()->CopyFrom(zone); + } + + sendGameCommand(cmd, playerId); +} + void GameEventHandler::handleGameLeft() { sendGameCommand(Command_LeaveGame()); diff --git a/cockatrice/src/game/game_event_handler.h b/libcockatrice_game/libcockatrice/game/game_event_handler.h similarity index 83% rename from cockatrice/src/game/game_event_handler.h rename to libcockatrice_game/libcockatrice/game/game_event_handler.h index 30021d295..f7b11780b 100644 --- a/cockatrice/src/game/game_event_handler.h +++ b/libcockatrice_game/libcockatrice/game/game_event_handler.h @@ -29,13 +29,14 @@ // GameEventContext, ServerInfo_Player and ServerInfo_User are passed by value // to signals and slots, so the moc-generated code needs the complete types. #include +#include #include #include +#include class AbstractClient; class AbstractGame; class GameEventContainer; -class GameEventContext; class PendingCommand; class PlayerLogic; class Response; @@ -127,6 +128,12 @@ public: */ void handleActivePhaseChanged(int phase); + /** @brief Request untapping every permanent on the table. */ + void handleUntapAll(); + + /** @brief Request drawing a single card. */ + void handleDrawCard(); + /** @brief Leave the current game session. */ void handleGameLeft(); @@ -145,6 +152,70 @@ public: void handleArrowDeletion(int creatorId, int arrowId); void handleArrowDeletionFinished(const Response &response, int creatorId, int arrowId); + /** + * @brief Prepare a deck selection from its native deck list text. + * + * The returned command is not sent yet, so the caller can connect to + * PendingCommand::finished before handing it to sendGameCommand(). + * + * @param deckList Serialized deck list. + * @return The pending command awaiting the deck download response. + */ + PendingCommand *prepareDeckSelect(const QString &deckList); + + /** + * @brief Prepare a deck selection for a deck stored on the server. + * + * The returned command is not sent yet, so the caller can connect to + * PendingCommand::finished before handing it to sendGameCommand(). + * + * @param deckId Server-side deck identifier. + * @return The pending command awaiting the deck download response. + */ + PendingCommand *prepareDeckSelectId(int deckId); + + /** + * @brief Build the playmat command for a player without sending it. + * + * The returned command is not sent yet, so the caller can connect to + * PendingCommand::finished before handing it to sendGameCommand(). + * + * @param playmat Resolved playmat card and positioning parameters. + * @return The pending command awaiting the playmat response. + */ + PendingCommand *prepareSetPlaymat(const PlaymatInfo &playmat); + + /** + * @brief Set a player's ready state. + * + * @param ready Whether the player is ready. + * @param playerId Player the state applies to. + */ + void sendReadyStart(bool ready, int playerId); + + /** + * @brief Force-start the game, kicking every player who is not ready. + * + * @param playerId Player issuing the force start. + */ + void sendForceStart(int playerId); + + /** + * @brief Lock or unlock a player's sideboard. + * + * @param locked Whether the sideboard is locked. + * @param playerId Player the lock applies to. + */ + void sendSetSideboardLock(bool locked, int playerId); + + /** + * @brief Send a player's sideboard plan. + * + * @param plan Cards to move between zones when leaving sideboarding. + * @param playerId Player the plan belongs to. + */ + void sendSetSideboardPlan(const QList &plan, int playerId); + /** @} */ /** @name Incoming event processing diff --git a/cockatrice/src/game/game_meta_info.cpp b/libcockatrice_game/libcockatrice/game/game_meta_info.cpp similarity index 100% rename from cockatrice/src/game/game_meta_info.cpp rename to libcockatrice_game/libcockatrice/game/game_meta_info.cpp diff --git a/cockatrice/src/game/game_meta_info.h b/libcockatrice_game/libcockatrice/game/game_meta_info.h similarity index 100% rename from cockatrice/src/game/game_meta_info.h rename to libcockatrice_game/libcockatrice/game/game_meta_info.h diff --git a/cockatrice/src/game/game_state.cpp b/libcockatrice_game/libcockatrice/game/game_state.cpp similarity index 100% rename from cockatrice/src/game/game_state.cpp rename to libcockatrice_game/libcockatrice/game/game_state.cpp diff --git a/cockatrice/src/game/game_state.h b/libcockatrice_game/libcockatrice/game/game_state.h similarity index 100% rename from cockatrice/src/game/game_state.h rename to libcockatrice_game/libcockatrice/game/game_state.h diff --git a/cockatrice/src/game/phase.cpp b/libcockatrice_game/libcockatrice/game/phase.cpp similarity index 100% rename from cockatrice/src/game/phase.cpp rename to libcockatrice_game/libcockatrice/game/phase.cpp diff --git a/cockatrice/src/game/phase.h b/libcockatrice_game/libcockatrice/game/phase.h similarity index 100% rename from cockatrice/src/game/phase.h rename to libcockatrice_game/libcockatrice/game/phase.h diff --git a/cockatrice/src/game_graphics/player/card_menu_action_type.h b/libcockatrice_game/libcockatrice/game/player/card_menu_action_type.h similarity index 95% rename from cockatrice/src/game_graphics/player/card_menu_action_type.h rename to libcockatrice_game/libcockatrice/game/player/card_menu_action_type.h index 4cae22716..e5c4cd84e 100644 --- a/cockatrice/src/game_graphics/player/card_menu_action_type.h +++ b/libcockatrice_game/libcockatrice/game/player/card_menu_action_type.h @@ -1,6 +1,6 @@ /** * @file card_menu_action_type.h - * @ingroup GameMenusPlayers + * @ingroup GameLogicPlayers */ //! \todo Document this file. diff --git a/libcockatrice_game/libcockatrice/game/player/card_move_request.h b/libcockatrice_game/libcockatrice/game/player/card_move_request.h new file mode 100644 index 000000000..bed46ab60 --- /dev/null +++ b/libcockatrice_game/libcockatrice/game/player/card_move_request.h @@ -0,0 +1,22 @@ +/** + * @file card_move_request.h + * @ingroup GameLogicActions + */ +//! \todo Document this file. + +#ifndef COCKATRICE_CARD_MOVE_REQUEST_H +#define COCKATRICE_CARD_MOVE_REQUEST_H + +#include + +/** + * @brief Describes one card a view wants moved, in logic terms rather than wire terms. + */ +struct CardMoveRequest +{ + int cardId = -1; + bool faceDown = false; + QString pt = {}; +}; + +#endif // COCKATRICE_CARD_MOVE_REQUEST_H diff --git a/cockatrice/src/game/player/event_processing_options.h b/libcockatrice_game/libcockatrice/game/player/event_processing_options.h similarity index 100% rename from cockatrice/src/game/player/event_processing_options.h rename to libcockatrice_game/libcockatrice/game/player/event_processing_options.h diff --git a/libcockatrice_game/libcockatrice/game/player/move_top_cards_until_options.h b/libcockatrice_game/libcockatrice/game/player/move_top_cards_until_options.h new file mode 100644 index 000000000..da8028b0f --- /dev/null +++ b/libcockatrice_game/libcockatrice/game/player/move_top_cards_until_options.h @@ -0,0 +1,19 @@ +/** + * @file move_top_cards_until_options.h + * @ingroup GameLogicPlayers + */ +//! \todo Document this file. + +#ifndef COCKATRICE_MOVE_TOP_CARDS_UNTIL_OPTIONS_H +#define COCKATRICE_MOVE_TOP_CARDS_UNTIL_OPTIONS_H + +#include + +struct MoveTopCardsUntilOptions +{ + QStringList exprs = {}; + int numberOfHits = 1; + bool autoPlay = false; +}; + +#endif // COCKATRICE_MOVE_TOP_CARDS_UNTIL_OPTIONS_H diff --git a/cockatrice/src/game/player/player_actions.cpp b/libcockatrice_game/libcockatrice/game/player/player_actions.cpp similarity index 84% rename from cockatrice/src/game/player/player_actions.cpp rename to libcockatrice_game/libcockatrice/game/player/player_actions.cpp index 35682ede8..90823fec6 100644 --- a/cockatrice/src/game/player/player_actions.cpp +++ b/libcockatrice_game/libcockatrice/game/player/player_actions.cpp @@ -1,13 +1,8 @@ #include "player_actions.h" -#include "../../client/settings/cache_settings.h" -#include "../../game_graphics/board/card_item.h" -#include "../../game_graphics/dialogs/dlg_create_token.h" -#include "../../game_graphics/dialogs/dlg_move_top_cards_until.h" -#include "../../game_graphics/player/card_menu_action_type.h" -#include "../../game_graphics/zones/table_zone.h" -#include "../../interface/deck_loader/loaded_deck.h" #include "../abstract_game.h" +#include "../board/arrow_data.h" +#include "../board/card_state.h" #include "../board/counter_state.h" #include "../game_event_handler.h" #include "../zones/card_zone_logic.h" @@ -31,8 +26,6 @@ #include #include #include -#include -#include #include #include #include @@ -51,10 +44,15 @@ #include #include #include +#include +#include +#include #include #include +#include #include #include +#include #include #include #include @@ -64,11 +62,12 @@ #include #include #include +#include #include #include -#include -#include +#include #include +#include #include #include #include @@ -80,8 +79,11 @@ // milliseconds in between triggers of the move top cards until action static constexpr int MOVE_TOP_CARD_UNTIL_INTERVAL = 100; -PlayerActions::PlayerActions(PlayerLogic *_player) - : QObject(_player), player(_player), lastTokenTableRow(0), movingCardsUntil(false) +PlayerActions::PlayerActions(PlayerLogic *_player, + IInterfaceSettingsProvider *_interfaceSettings, + ICardPreferenceProvider *_cardPreferences) + : QObject(_player), player(_player), interfaceSettings(_interfaceSettings), cardPreferences(_cardPreferences), + lastTokenTableRow(0), movingCardsUntil(false) { connect(this, &PlayerActions::requestZoneViewToggle, player, &PlayerLogic::onRequestZoneViewToggle); @@ -91,7 +93,27 @@ PlayerActions::PlayerActions(PlayerLogic *_player) connect(moveTopCardTimer, &QTimer::timeout, [this]() { actMoveTopCardToPlay(); }); } -void PlayerActions::playCard(CardItem *card, bool faceDown) +bool PlayerActions::getPlayToStack() const +{ + return interfaceSettings ? interfaceSettings->getPlayToStack() : true; +} + +int PlayerActions::getStartingHandSize() const +{ + return interfaceSettings ? interfaceSettings->getStartingHandSize() : 7; +} + +bool PlayerActions::getAnnotateTokens() const +{ + return interfaceSettings ? interfaceSettings->getAnnotateTokens() : false; +} + +QString PlayerActions::getCardPreferenceOverride(const QString &cardName) const +{ + return cardPreferences ? cardPreferences->getCardPreferenceOverride(cardName) : QString(); +} + +void PlayerActions::playCard(CardState *card, bool faceDown) { if (card == nullptr) { return; @@ -112,7 +134,7 @@ void PlayerActions::playCard(CardItem *card, bool faceDown) const CardInfo &info = exactCard.getInfo(); int tableRow = info.getUiAttributes().tableRow; - bool playToStack = SettingsCache::instance().userInterface().getPlayToStack(); + bool playToStack = getPlayToStack(); QString currentZone = card->getZone()->getName(); if (!faceDown && currentZone == ZoneNames::STACK && tableRow == 3) { cmd.set_target_zone(ZoneNames::GRAVE); @@ -125,7 +147,7 @@ void PlayerActions::playCard(CardItem *card, bool faceDown) cmd.set_y(0); } else { tableRow = faceDown ? 2 : info.getUiAttributes().tableRow; - QPoint gridPoint = QPoint(-1, TableZone::tableRowToGridY(tableRow)); + QPoint gridPoint = QPoint(-1, TableZoneLogic::tableRowToGridY(tableRow)); cardToMove->set_face_down(faceDown); if (!faceDown) { cardToMove->set_pt(info.getPowTough().toStdString()); @@ -144,7 +166,7 @@ void PlayerActions::playCard(CardItem *card, bool faceDown) * Like {@link PlayerActions::playCard}, but forces the card to be played to the table zone. * Cards with tablerow 3 (the stack) will be played to tablerow 1 (the noncreatures row). */ -void PlayerActions::playCardToTable(const CardItem *card, bool faceDown) +void PlayerActions::playCardToTable(const CardState *card, bool faceDown) { if (card == nullptr) { return; @@ -165,7 +187,7 @@ void PlayerActions::playCardToTable(const CardItem *card, bool faceDown) const CardInfo &info = exactCard.getInfo(); int tableRow = faceDown ? 2 : info.getUiAttributes().tableRow; - QPoint gridPoint = QPoint(-1, TableZone::tableRowToGridY(tableRow)); + QPoint gridPoint = QPoint(-1, TableZoneLogic::tableRowToGridY(tableRow)); cardToMove->set_face_down(faceDown); if (!faceDown) { cardToMove->set_pt(info.getPowTough().toStdString()); @@ -177,6 +199,60 @@ void PlayerActions::playCardToTable(const CardItem *card, bool faceDown) sendGameCommand(cmd); } +void PlayerActions::attachCards(CardState *startCard, const CardState *targetCard) +{ + if (targetCard->getAttachedTo() || targetCard->getZone()->getName() != ZoneNames::TABLE) { + return; + } + + // move card onto table first if attaching from some other zone + if (startCard->getZone()->getName() != ZoneNames::TABLE) { + playCardToTable(startCard, false); + } + + Command_AttachCard cmd; + cmd.set_start_zone(ZoneNames::TABLE); + cmd.set_card_id(startCard->getId()); + cmd.set_target_player_id(targetCard->getZone()->getPlayer()->getPlayerInfo()->getId()); + cmd.set_target_zone(targetCard->getZone()->getName().toStdString()); + cmd.set_target_card_id(targetCard->getId()); + sendGameCommand(cmd); +} + +void PlayerActions::createArrow(CardState *startCard, const ArrowData &arrow, int deleteInPhase) +{ + Command_CreateArrow cmd; + cmd.mutable_arrow_color()->CopyFrom(convertQColorToColor(arrow.color)); + cmd.set_start_player_id(arrow.startPlayerId); + cmd.set_start_zone(arrow.startZone.toStdString()); + cmd.set_start_card_id(arrow.startCardId); + cmd.set_target_player_id(arrow.targetPlayerId); + + if (!arrow.isPlayerTargeted()) { + cmd.set_target_zone(arrow.targetZone.toStdString()); + cmd.set_target_card_id(arrow.targetCardId); + } + + // if the card is in hand then it has just been moved to the stack or table as part of drawing the arrow + if (arrow.startZone == ZoneNames::HAND) { + CardInfoPtr ci = startCard->getCard().getCardPtr(); + bool playToStack = getPlayToStack(); + if (ci && ((!playToStack && ci->getUiAttributes().tableRow == 3) || + (playToStack && ci->getUiAttributes().tableRow != 0 && + startCard->getZone()->getName() != ZoneNames::STACK))) { + cmd.set_start_zone(ZoneNames::STACK); + } else { + cmd.set_start_zone(playToStack ? ZoneNames::STACK : ZoneNames::TABLE); + } + } + + if (deleteInPhase != 0) { + cmd.set_delete_in_phase(deleteInPhase); + } + + sendGameCommand(cmd); +} + void PlayerActions::actViewLibrary() { emit requestZoneViewToggle(ZoneNames::DECK, -1); @@ -355,7 +431,7 @@ void PlayerActions::actDrawCard() void PlayerActions::actRequestMulliganDialog() { - int startSize = SettingsCache::instance().userInterface().getStartingHandSize(); + int startSize = getStartingHandSize(); int handSize = player->getHandZone()->getCards().size(); int deckSize = player->getDeckZone()->getCards().size() + handSize; @@ -371,7 +447,7 @@ void PlayerActions::actMulligan(int number) } doMulligan(number); - SettingsCache::instance().userInterface().setStartingHandSize(number); + emit startingHandSizeChosen(number); } void PlayerActions::actMulliganSameSize() @@ -549,7 +625,7 @@ void PlayerActions::moveTopCardsUntil(const QString &expr, MoveTopCardsUntilOpti } } -void PlayerActions::moveOneCardUntil(CardItem *card) +void PlayerActions::moveOneCardUntil(CardState *card) { moveTopCardTimer->stop(); @@ -741,61 +817,6 @@ void PlayerActions::actMoveBottomCardToTop() sendGameCommand(cmd); } -/** - * Selects all cards in the given zone. - * - * @param zone The zone to select from - * @param filter A predicate to filter which cards are selected. Defaults to always returning true. - */ -static void selectCardsInZone( - const CardZoneLogic *zone, - std::function filter = [](const CardItem *) { return true; }) -{ - if (!zone) { - return; - } - - for (auto &cardItem : zone->getCards()) { - if (cardItem && filter(cardItem)) { - cardItem->setSelected(true); - } - } -} - -void PlayerActions::actSelectAll() -{ - const CardItem *card = player->getGame()->getActiveCard(); - if (!card) { - return; - } - - selectCardsInZone(card->getZone()); -} - -void PlayerActions::actSelectRow() -{ - const CardItem *card = player->getGame()->getActiveCard(); - if (!card) { - return; - } - - auto isSameRow = [card](const CardItem *cardItem) { - return qAbs(card->scenePos().y() - cardItem->scenePos().y()) < 50; - }; - selectCardsInZone(card->getZone(), isSameRow); -} - -void PlayerActions::actSelectColumn() -{ - const CardItem *card = player->getGame()->getActiveCard(); - if (!card) { - return; - } - - auto isSameColumn = [card](const CardItem *cardItem) { return cardItem->x() == card->x(); }; - selectCardsInZone(card->getZone(), isSameColumn); -} - void PlayerActions::actDrawBottomCard() { if (player->getDeckZone()->getCards().empty()) { @@ -929,7 +950,7 @@ void PlayerActions::actCreateToken(TokenInfo tokenToCreate) if (correctedCard) { lastTokenInfo.name = correctedCard.getName(); int tableRow = lastTokenInfo.faceDown ? 2 : correctedCard.getInfo().getUiAttributes().tableRow; - lastTokenTableRow = TableZone::tableRowToGridY(tableRow); + lastTokenTableRow = TableZoneLogic::tableRowToGridY(tableRow); if (lastTokenInfo.pt.isEmpty()) { lastTokenInfo.pt = correctedCard.getInfo().getPowTough(); } @@ -975,15 +996,14 @@ void PlayerActions::setLastTokenInfo(CardInfoPtr cardInfo) return; } - lastTokenInfo = { - .name = cardInfo->getName(), - .color = cardInfo->getColors().isEmpty() ? QString() : cardInfo->getColors().left(1).toLower(), - .pt = cardInfo->getPowTough(), - .annotation = SettingsCache::instance().userInterface().getAnnotateTokens() ? cardInfo->getText() : "", - .destroy = true, - .providerId = SettingsCache::instance().cardOverrides().getCardPreferenceOverride(cardInfo->getName())}; + lastTokenInfo = {.name = cardInfo->getName(), + .color = cardInfo->getColors().isEmpty() ? QString() : cardInfo->getColors().left(1).toLower(), + .pt = cardInfo->getPowTough(), + .annotation = getAnnotateTokens() ? cardInfo->getText() : "", + .destroy = true, + .providerId = getCardPreferenceOverride(cardInfo->getName())}; - lastTokenTableRow = TableZone::tableRowToGridY(cardInfo->getUiAttributes().tableRow); + lastTokenTableRow = TableZoneLogic::tableRowToGridY(cardInfo->getUiAttributes().tableRow); emit requestEnableAndSetCreateAnotherTokenAction(lastTokenInfo.name); } @@ -1001,25 +1021,20 @@ void PlayerActions::actCreatePredefinedToken() actCreateAnotherToken(); } -void PlayerActions::actCreateRelatedCard() +void PlayerActions::actCreateRelatedCard(const CardState *sourceCard, int index) { - const CardItem *sourceCard = player->getGame()->getActiveCard(); - if (!sourceCard) { return; } - auto *action = static_cast(sender()); - // If there is a better way of passing a CardRelation through a QAction, please add it here. auto relatedCards = sourceCard->getCardInfo().getAllRelatedCards(); - CardRelation *cardRelation = relatedCards.at(action->data().toInt()); + CardRelation *cardRelation = relatedCards.at(index); actRequestCreateRelatedFromRelationDialog(sourceCard, cardRelation); } -void PlayerActions::actCreateAllRelatedCards() +void PlayerActions::actCreateAllRelatedCards(const CardState *sourceCard) { - const CardItem *sourceCard = player->getGame()->getActiveCard(); if (!sourceCard) { return; } @@ -1105,13 +1120,13 @@ void PlayerActions::actCreateAllRelatedCards() } } -void PlayerActions::actRequestCreateRelatedFromRelationDialog(const CardItem *sourceCard, +void PlayerActions::actRequestCreateRelatedFromRelationDialog(const CardState *sourceCard, const CardRelation *cardRelation) { emit requestCreateRelatedFromRelationDialog(sourceCard, cardRelation); } -bool PlayerActions::createRelatedFromRelation(const CardItem *sourceCard, +bool PlayerActions::createRelatedFromRelation(const CardState *sourceCard, const CardRelation *cardRelation, int variableCount) { @@ -1163,7 +1178,7 @@ bool PlayerActions::createRelatedFromRelation(const CardItem *sourceCard, return true; } -void PlayerActions::onRelatedCardCreated(const CardItem *sourceCard, const CardRelation *cardRelation) +void PlayerActions::onRelatedCardCreated(const CardState *sourceCard, const CardRelation *cardRelation) { if (sourceCard == nullptr || cardRelation == nullptr) { return; @@ -1183,7 +1198,7 @@ void PlayerActions::onRelatedCardCreated(const CardItem *sourceCard, const CardR setLastToken(relatedCard.getCardPtr()); } -void PlayerActions::createCard(const CardItem *sourceCard, +void PlayerActions::createCard(const CardState *sourceCard, const QString &dbCardName, CardRelationType attachType, bool persistent, @@ -1195,7 +1210,7 @@ void PlayerActions::createCard(const CardItem *sourceCard, return; } - QPoint gridPoint = QPoint(-1, TableZone::tableRowToGridY(cardInfo->getUiAttributes().tableRow)); + QPoint gridPoint = QPoint(-1, TableZoneLogic::tableRowToGridY(cardInfo->getUiAttributes().tableRow)); // create the token for the related card Command_CreateToken cmd; @@ -1214,7 +1229,7 @@ void PlayerActions::createCard(const CardItem *sourceCard, } cmd.set_pt(cardInfo->getPowTough().toStdString()); - if (SettingsCache::instance().userInterface().getAnnotateTokens()) { + if (getAnnotateTokens()) { cmd.set_annotation(cardInfo->getText().toStdString()); } else { cmd.set_annotation(""); @@ -1269,7 +1284,7 @@ void PlayerActions::actRequestMoveCardXCardsFromTopDialog() emit requestMoveCardXCardsFromTopDialog(defaultNumberTopCardsToPlaceBelow, deckSize); } -void PlayerActions::actMoveCardXCardsFromTop(QList selectedCards, int number) +void PlayerActions::actMoveCardXCardsFromTop(QList selectedCards, int number) { defaultNumberTopCardsToPlaceBelow = number; @@ -1303,14 +1318,14 @@ void PlayerActions::actMoveCardXCardsFromTop(QList selectedCards, in } } -void PlayerActions::actIncPT(QList selectedCards, int deltaP, int deltaT) +void PlayerActions::actIncPT(QList selectedCards, int deltaP, int deltaT) { int playerid = player->getPlayerInfo()->getId(); QList commandList; for (auto card : selectedCards) { QString pt = card->getPT(); - const auto ptList = CardItem::parsePT(pt); + const auto ptList = CardState::parsePT(pt); QString newpt; if (ptList.isEmpty()) { newpt = QString::number(deltaP) + (deltaT ? "/" + QString::number(deltaT) : ""); @@ -1336,7 +1351,7 @@ void PlayerActions::actIncPT(QList selectedCards, int deltaP, int de player->getGame()->getGameEventHandler()->sendGameCommand(prepareGameCommand(commandList), playerid); } -void PlayerActions::actResetPT(QList selectedCards) +void PlayerActions::actResetPT(QList selectedCards) { int playerid = player->getPlayerInfo()->getId(); QList commandList; @@ -1369,7 +1384,7 @@ void PlayerActions::actResetPT(QList selectedCards) } } -void PlayerActions::actRequestSetPTDialog(QList selectedCards) +void PlayerActions::actRequestSetPTDialog(QList selectedCards) { QString oldPT; @@ -1382,11 +1397,11 @@ void PlayerActions::actRequestSetPTDialog(QList selectedCards) emit requestSetPTDialog(oldPT); } -void PlayerActions::actSetPT(QList selectedCards, const QString &pt) +void PlayerActions::actSetPT(QList selectedCards, const QString &pt) { int playerid = player->getPlayerInfo()->getId(); - const auto ptList = CardItem::parsePT(pt); + const auto ptList = CardState::parsePT(pt); bool empty = ptList.isEmpty(); QList commandList; @@ -1394,7 +1409,7 @@ void PlayerActions::actSetPT(QList selectedCards, const QString &pt) auto *cmd = new Command_SetCardAttr; QString newpt = QString(); if (!empty) { - const auto oldpt = CardItem::parsePT(card->getPT()); + const auto oldpt = CardState::parsePT(card->getPT()); int ptIter = 0; for (const auto &_item : ptList) { if (_item.typeId() == QMetaType::Type::Int) { @@ -1422,55 +1437,65 @@ void PlayerActions::actSetPT(QList selectedCards, const QString &pt) player->getGame()->getGameEventHandler()->sendGameCommand(prepareGameCommand(commandList), playerid); } -void PlayerActions::actDrawArrow() -{ - auto *card = player->getGame()->getActiveCard(); - if (card) { - card->drawArrow(Qt::red); - } -} - -void PlayerActions::actIncP(QList selectedCards) +void PlayerActions::actIncP(QList selectedCards) { actIncPT(selectedCards, 1, 0); } -void PlayerActions::actDecP(QList selectedCards) +void PlayerActions::actDecP(QList selectedCards) { actIncPT(selectedCards, -1, 0); } -void PlayerActions::actIncT(QList selectedCards) +void PlayerActions::actIncT(QList selectedCards) { actIncPT(selectedCards, 0, 1); } -void PlayerActions::actDecT(QList selectedCards) +void PlayerActions::actDecT(QList selectedCards) { actIncPT(selectedCards, 0, -1); } -void PlayerActions::actIncPT(QList selectedCards) +void PlayerActions::actIncPT(QList selectedCards) { actIncPT(selectedCards, 1, 1); } -void PlayerActions::actDecPT(QList selectedCards) +void PlayerActions::actDecPT(QList selectedCards) { actIncPT(selectedCards, -1, -1); } -void PlayerActions::actFlowP(QList selectedCards) +void PlayerActions::actFlowP(QList selectedCards) { actIncPT(selectedCards, 1, -1); } -void PlayerActions::actFlowT(QList selectedCards) +void PlayerActions::actFlowT(QList selectedCards) { actIncPT(selectedCards, -1, 1); } -void PlayerActions::actReduceLifeByPower(QList selectedCards) +void PlayerActions::actIncrementCounter(int counterId, int delta) +{ + Command_IncCounter cmd; + cmd.set_counter_id(counterId); + cmd.set_delta(delta); + + sendGameCommand(cmd); +} + +void PlayerActions::actSetCounter(int counterId, int value) +{ + Command_SetCounter cmd; + cmd.set_counter_id(counterId); + cmd.set_value(value); + + sendGameCommand(cmd); +} + +void PlayerActions::actReduceLifeByPower(QList selectedCards) { // find life counter auto lifeCounter = player->getLifeCounter(); @@ -1481,7 +1506,7 @@ void PlayerActions::actReduceLifeByPower(QList selectedCards) // calculate total power; int total = 0; for (auto card : selectedCards) { - QVariantList parsed = CardItem::parsePT(card->getPT()); + QVariantList parsed = CardState::parsePT(card->getPT()); if (!parsed.isEmpty()) { int power = parsed.first().toInt(); // toInt will default to 0 if it's not an int total += qMax(power, 0); @@ -1495,17 +1520,7 @@ void PlayerActions::actReduceLifeByPower(QList selectedCards) sendGameCommand(prepareGameCommand(cmd)); } -void AnnotationDialog::keyPressEvent(QKeyEvent *event) -{ - if (event->key() == Qt::Key_Return && event->modifiers() & Qt::ControlModifier) { - event->accept(); - accept(); - return; - } - QInputDialog::keyPressEvent(event); -} - -void PlayerActions::actRequestSetAnnotationDialog(QList selectedCards) +void PlayerActions::actRequestSetAnnotationDialog(QList selectedCards) { QString oldAnnotation; for (auto card : selectedCards) { @@ -1517,7 +1532,7 @@ void PlayerActions::actRequestSetAnnotationDialog(QList selectedCard emit requestSetAnnotationDialog(oldAnnotation); } -void PlayerActions::actSetAnnotation(QList selectedCards, const QString &annotation) +void PlayerActions::actSetAnnotation(QList selectedCards, const QString &annotation) { QList commandList; for (auto card : selectedCards) { @@ -1531,17 +1546,7 @@ void PlayerActions::actSetAnnotation(QList selectedCards, const QStr sendGameCommand(prepareGameCommand(commandList)); } -void PlayerActions::actAttach() -{ - auto *card = player->getGame()->getActiveCard(); - if (!card) { - return; - } - - card->drawAttachArrow(); -} - -void PlayerActions::actUnattach(QList selectedCards) +void PlayerActions::actUnattach(QList selectedCards) { QList commandList; for (auto card : selectedCards) { @@ -1557,17 +1562,17 @@ void PlayerActions::actUnattach(QList selectedCards) sendGameCommand(prepareGameCommand(commandList)); } -void PlayerActions::actAddCardCounter(QList selectedCards, int counterId) +void PlayerActions::actAddCardCounter(QList selectedCards, int counterId) { offsetCardCounter(selectedCards, counterId, 1); } -void PlayerActions::actRemoveCardCounter(QList selectedCards, int counterId) +void PlayerActions::actRemoveCardCounter(QList selectedCards, int counterId) { offsetCardCounter(selectedCards, counterId, -1); } -void PlayerActions::offsetCardCounter(QList selectedCards, int counterId, int offset) +void PlayerActions::offsetCardCounter(QList selectedCards, int counterId, int offset) { QList commandList; for (auto card : selectedCards) { @@ -1593,7 +1598,7 @@ void PlayerActions::offsetCardCounter(QList selectedCards, int count sendGameCommand(prepareGameCommand(commandList)); } -void PlayerActions::actRequestSetCardCounterDialog(QList selectedCards, int counterId) +void PlayerActions::actRequestSetCardCounterDialog(QList selectedCards, int counterId) { // If a single card is selected, we show the old value in the dialog. Otherwise, we show "x" QString oldValueForDlg = "x"; @@ -1605,7 +1610,7 @@ void PlayerActions::actRequestSetCardCounterDialog(QList selectedCar emit requestSetCardCounterDialog(counterId, oldValueForDlg); } -void PlayerActions::actSetCardCounter(QList selectedCards, int counterId, const QString &counterValue) +void PlayerActions::actSetCardCounter(QList selectedCards, int counterId, const QString &counterValue) { QList commandList; for (auto card : selectedCards) { @@ -1626,11 +1631,13 @@ void PlayerActions::actSetCardCounter(QList selectedCards, int count sendGameCommand(prepareGameCommand(commandList)); } -void PlayerActions::actIncrementAllCardCounters(QList cardsToUpdate) +void PlayerActions::actIncrementAllCardCounters(QList cardsToUpdate) { if (cardsToUpdate.isEmpty()) { // If no cards selected, update all cards on table - cardsToUpdate = static_cast>(player->getTableZone()->getCards()); + for (auto *state : player->getTableZone()->getCards()) { + cardsToUpdate.append(state); + } } QList commandList; @@ -1673,7 +1680,7 @@ static bool isUnwritableRevealZone(CardZoneLogic *zone) return false; } -void PlayerActions::playSelectedCards(QList selectedCards, const bool faceDown) +void PlayerActions::playSelectedCards(QList selectedCards, const bool faceDown) { // CardIds will get shuffled downwards when cards leave the deck. // We need to iterate through the cards in reverse order so cardIds don't get changed out from under us as we play @@ -1688,27 +1695,26 @@ void PlayerActions::playSelectedCards(QList selectedCards, const boo } } -void PlayerActions::actPlay(QList selectedCards) +void PlayerActions::actPlay(QList selectedCards) { playSelectedCards(selectedCards, false); } -void PlayerActions::actPlayFacedown(QList selectedCards) +void PlayerActions::actPlayFacedown(QList selectedCards) { playSelectedCards(selectedCards, true); } -void PlayerActions::actHide(QList selectedCards) +void PlayerActions::actHide(QList selectedCards) { - for (const auto &item : selectedCards) { - auto *card = static_cast(item); - if (card && isUnwritableRevealZone(card->getZone())) { + for (const auto &card : selectedCards) { + if (isUnwritableRevealZone(card->getZone())) { card->getZone()->removeCard(card); } } } -void PlayerActions::actReveal(QList selectedCards, QAction *action) +void PlayerActions::actReveal(QList selectedCards, QAction *action) { const int otherPlayerId = action->data().toInt(); @@ -1799,9 +1805,9 @@ void PlayerActions::actRevealRandomGraveyardCard(int revealToPlayerId) sendGameCommand(cmd); } -void PlayerActions::cardMenuAction(QList selectedCards, CardMenuActionType type) +void PlayerActions::cardMenuAction(QList selectedCards, CardMenuActionType type) { - QList cardList = selectedCards; + QList cardList = selectedCards; QList commandList; if (type <= cmClone) { @@ -1991,7 +1997,7 @@ void PlayerActions::cardMenuAction(QList selectedCards, CardMenuActi ctm->set_tapped(info.getUiAttributes().cipt); } - cmd->set_y(TableZone::tableRowToGridY(tableRow)); + cmd->set_y(TableZoneLogic::tableRowToGridY(tableRow)); commandList.append(cmd); } break; @@ -2040,6 +2046,76 @@ PendingCommand *PlayerActions::prepareGameCommand(const QList &cards) +{ + if (!startZone || !targetZone) { + return; + } + + Command_MoveCard cmd; + cmd.set_start_player_id(startZone->getPlayer()->getPlayerInfo()->getId()); + cmd.set_start_zone(startZone->getName().toStdString()); + cmd.set_target_player_id(targetZone->getPlayer()->getPlayerInfo()->getId()); + cmd.set_target_zone(targetZone->getName().toStdString()); + cmd.set_x(x); + cmd.set_y(y); + + if (auto *viewLogic = qobject_cast(targetZone)) { + cmd.set_is_reversed(viewLogic->getIsReversed()); + } + + for (const CardMoveRequest &card : cards) { + auto *cardToMove = cmd.mutable_cards_to_move()->add_card(); + cardToMove->set_card_id(card.cardId); + if (card.faceDown) { + cardToMove->set_face_down(true); + } + if (!card.pt.isEmpty()) { + cardToMove->set_pt(card.pt.toStdString()); + } + } + + sendGameCommand(cmd); +} + +PendingCommand *PlayerActions::prepareZoneDump(CardZoneLogic *zone, int numberCards) +{ + if (!zone) { + return nullptr; + } + + Command_DumpZone cmd; + cmd.set_player_id(zone->getPlayer()->getPlayerInfo()->getId()); + cmd.set_zone_name(zone->getName().toStdString()); + cmd.set_number_cards(numberCards); + if (auto *viewLogic = qobject_cast(zone)) { + cmd.set_is_reversed(viewLogic->getIsReversed()); + } + + return prepareGameCommand(cmd); +} + +void PlayerActions::actToggleTapped(const QString &zoneName, const QList &cards, bool tapped) +{ + QList cmdList; + for (CardState *card : cards) { + if (card->getTapped() != tapped) { + auto *cmd = new Command_SetCardAttr; + cmd->set_zone(zoneName.toStdString()); + cmd->set_card_id(card->getId()); + cmd->set_attribute(AttrTapped); + cmd->set_attr_value(tapped ? "1" : "0"); + cmdList.append(cmd); + } + } + + sendGameCommand(prepareGameCommand(cmdList)); +} + void PlayerActions::sendGameCommand(const google::protobuf::Message &command) { if (player->getPlayerInfo()->getJudge() && !player->getPlayerInfo()->getLocal()) { diff --git a/cockatrice/src/game/player/player_actions.h b/libcockatrice_game/libcockatrice/game/player/player_actions.h similarity index 54% rename from cockatrice/src/game/player/player_actions.h rename to libcockatrice_game/libcockatrice/game/player/player_actions.h index 8fff81635..7ed52b9a1 100644 --- a/cockatrice/src/game/player/player_actions.h +++ b/libcockatrice_game/libcockatrice/game/player/player_actions.h @@ -8,12 +8,13 @@ #ifndef COCKATRICE_PLAYER_ACTIONS_H #define COCKATRICE_PLAYER_ACTIONS_H -#include "../../game_graphics/board/card_item.h" // IWYU pragma: keep - moc instantiates the metatype for QList, which Qt 6.4 needs complete -#include "../../game_graphics/dialogs/dlg_create_token.h" -#include "../../game_graphics/dialogs/dlg_move_top_cards_until.h" -#include "../../game_graphics/player/card_menu_action_type.h" #include "../board/card_list.h" +#include "../board/card_state.h" +#include "card_menu_action_type.h" +#include "card_move_request.h" #include "libcockatrice/card/card_info.h" +#include "move_top_cards_until_options.h" +#include "token_info.h" #include #include @@ -22,7 +23,6 @@ #include #include -class CardItem; class CardRelation; class QAction; class QTimer; @@ -36,10 +36,16 @@ class Message; } } // namespace google +class CardState; +class CardZoneLogic; class Command_MoveCard; +class GameEventContext; +class ICardPreferenceProvider; +class IInterfaceSettingsProvider; class PendingCommand; class PlayerLogic; - +class QAction; +struct ArrowData; class PlayerActions : public QObject { Q_OBJECT @@ -50,7 +56,9 @@ public: RANDOM_CARD_FROM_ZONE = -2 }; - explicit PlayerActions(PlayerLogic *player); + explicit PlayerActions(PlayerLogic *player, + IInterfaceSettingsProvider *interfaceSettings = nullptr, + ICardPreferenceProvider *cardPreferences = nullptr); void sendGameCommand(PendingCommand *pend); void sendGameCommand(const google::protobuf::Message &command); @@ -58,7 +66,52 @@ public: PendingCommand *prepareGameCommand(const ::google::protobuf::Message &cmd); PendingCommand *prepareGameCommand(const QList &cmdList); - void moveOneCardUntil(CardItem *card); + /** + * @brief Moves the requested cards between zones on behalf of a view. + * @param startZone Zone the cards are dragged out of. + * @param targetZone Zone the cards are dropped onto. + * @param x Horizontal target index within the zone. + * @param y Vertical target index within the zone. + * @param cards The cards to move, as chosen by the view. + */ + void + moveCards(CardZoneLogic *startZone, CardZoneLogic *targetZone, int x, int y, const QList &cards); + + /** + * @brief Prepares a dump of the given zone, for the caller to connect to and send. + * @param zone Zone to dump. + * @param numberCards How many cards to dump, -1 for all. + */ + PendingCommand *prepareZoneDump(CardZoneLogic *zone, int numberCards); + + /** + * @brief Sets the tapped state of the cards that do not already have it. + * @param zoneName Zone the cards belong to. + * @param cards Cards the view wants toggled. + * @param tapped Desired tapped state. + */ + void actToggleTapped(const QString &zoneName, const QList &cards, bool tapped); + + /** + * @brief Attaches one card to another on the table on behalf of a view. + * @param startCard Card being attached. + * @param targetCard Card being attached to. + */ + void attachCards(CardState *startCard, const CardState *targetCard); + + /** + * @brief Draws an arrow from a card to another card or a player on behalf of a view. + * + * The view resolves the endpoints before playing the card out of hand, so the + * arrow still records the zone the card started in. + * + * @param startCard Card the arrow starts from. + * @param arrow Arrow endpoints and color, as resolved by the view. + * @param deleteInPhase Phase the arrow expires in, 0 to keep it until removed. + */ + void createArrow(CardState *startCard, const ArrowData &arrow, int deleteInPhase); + + void moveOneCardUntil(CardState *card); void stopMoveTopCardsUntil(); [[nodiscard]] bool isMovingCardsUntil() const @@ -87,7 +140,7 @@ signals: void requestDrawBottomCardsDialog(int defaultNumberBottomCards, int maxCards); void requestRollDieDialog(); void requestCreateTokenDialog(const QStringList &predefinedTokens); - void requestCreateRelatedFromRelationDialog(const CardItem *sourceCard, const CardRelation *cardRelation); + void requestCreateRelatedFromRelationDialog(const CardState *sourceCard, const CardRelation *cardRelation); void requestMoveCardXCardsFromTopDialog(int defaultNumberTopCardsToPlaceBelow, int deckSize); void requestSetPTDialog(const QString &oldPT); void requestSetAnnotationDialog(const QString &oldAnnotation); @@ -96,12 +149,18 @@ signals: void requestSortHand(const QList &options); void requestEnableAndSetCreateAnotherTokenAction(const QString &lastTokenName); void requestSetLastToken(CardInfoPtr lastToken); + /** + * @brief The player chose a mulligan size to remember as the starting hand size. + * + * Game logic must not write client settings itself, so persistence is left to the application. + */ + void startingHandSizeChosen(int number); public slots: void setLastToken(CardInfoPtr cardInfo); void setLastTokenInfo(CardInfoPtr cardInfo); - void playCard(CardItem *c, bool faceDown); - void playCardToTable(const CardItem *c, bool faceDown); + void playCard(CardState *c, bool faceDown); + void playCardToTable(const CardState *c, bool faceDown); void actUntapAll(); void actRequestRollDieDialog(); @@ -110,9 +169,9 @@ public slots: void actRequestCreateTokenDialog(const QStringList &predefinedTokens); void actCreateToken(TokenInfo tokenToCreate); void actCreateAnotherToken(); - void actRequestCreateRelatedFromRelationDialog(const CardItem *sourceCard, const CardRelation *cardRelation); - bool createRelatedFromRelation(const CardItem *sourceCard, const CardRelation *cardRelation, int variableCount); - void onRelatedCardCreated(const CardItem *sourceCard, const CardRelation *cardRelation); + void actRequestCreateRelatedFromRelationDialog(const CardState *sourceCard, const CardRelation *cardRelation); + bool createRelatedFromRelation(const CardState *sourceCard, const CardRelation *cardRelation, int variableCount); + void onRelatedCardCreated(const CardState *sourceCard, const CardRelation *cardRelation); void setLastRelatedCreationSucceeded(bool succeeded) { lastRelatedCreationSucceeded = succeeded; @@ -132,9 +191,9 @@ public slots: void actMulliganMinusOne(); void doMulligan(int number); - void actPlay(QList selectedCards); - void actPlayFacedown(QList selectedCards); - void actHide(QList selectedCards); + void actPlay(QList selectedCards); + void actPlayFacedown(QList selectedCards); + void actHide(QList selectedCards); void actMoveTopCardToPlay(); void actMoveTopCardToPlayFaceDown(); @@ -164,10 +223,6 @@ public slots: void actRequestMoveBottomCardsToDialog(const QString &targetZone, const QString &zoneDisplayName, bool faceDown); void moveBottomCardsTo(int number, const QString &targetZone, bool faceDown); - void actSelectAll(); - void actSelectRow(); - void actSelectColumn(); - void actViewLibrary(); void actViewHand(); void actRequestViewTopCardsDialog(); @@ -187,47 +242,55 @@ public slots: void actOpenDeckInDeckEditor(); void actCreatePredefinedToken(); - void actCreateRelatedCard(); - void actCreateAllRelatedCards(); + void actCreateRelatedCard(const CardState *sourceCard, int index); + void actCreateAllRelatedCards(const CardState *sourceCard); void actRequestMoveCardXCardsFromTopDialog(); - void actMoveCardXCardsFromTop(QList selectedCards, int number); - void actRemoveCardCounter(QList selectedCards, int counterId); - void actAddCardCounter(QList selectedCards, int counterId); - void actRequestSetCardCounterDialog(QList selectedCards, int counterId); - void actSetCardCounter(QList selectedCards, int counterId, const QString &counterValue); - void actIncrementAllCardCounters(QList cardsToUpdate); - void actAttach(); - void actUnattach(QList selectedCards); - void actDrawArrow(); - void actIncPT(QList selectedCards, int deltaP, int deltaT); - void actResetPT(QList selectedCards); - void actRequestSetPTDialog(QList selectedCards); - void actSetPT(QList selectedCards, const QString &pt); - void actIncP(QList selectedCards); - void actDecP(QList selectedCards); - void actIncT(QList selectedCards); - void actDecT(QList selectedCards); - void actIncPT(QList selectedCards); - void actDecPT(QList selectedCards); - void actFlowP(QList selectedCards); - void actFlowT(QList selectedCards); + void actMoveCardXCardsFromTop(QList selectedCards, int number); + void actRemoveCardCounter(QList selectedCards, int counterId); + void actAddCardCounter(QList selectedCards, int counterId); + void actRequestSetCardCounterDialog(QList selectedCards, int counterId); + void actSetCardCounter(QList selectedCards, int counterId, const QString &counterValue); + void actIncrementAllCardCounters(QList cardsToUpdate); + void actUnattach(QList selectedCards); + void actIncPT(QList selectedCards, int deltaP, int deltaT); + void actResetPT(QList selectedCards); + void actRequestSetPTDialog(QList selectedCards); + void actSetPT(QList selectedCards, const QString &pt); + void actIncP(QList selectedCards); + void actDecP(QList selectedCards); + void actIncT(QList selectedCards); + void actDecT(QList selectedCards); + void actIncPT(QList selectedCards); + void actDecPT(QList selectedCards); + void actFlowP(QList selectedCards); + void actFlowT(QList selectedCards); - void actReduceLifeByPower(QList selectedCards); + /** @brief Adjusts a player counter by a signed delta. */ + void actIncrementCounter(int counterId, int delta); - void actRequestSetAnnotationDialog(QList selectedCards); - void actSetAnnotation(QList selectedCards, const QString &annotation); - void actReveal(QList selectedCards, QAction *action); + /** @brief Sets a player counter to an absolute value. */ + void actSetCounter(int counterId, int value); + + void actReduceLifeByPower(QList selectedCards); + + void actRequestSetAnnotationDialog(QList selectedCards); + void actSetAnnotation(QList selectedCards, const QString &annotation); + void actReveal(QList selectedCards, QAction *action); void actRevealHand(int revealToPlayerId); void actRevealRandomHandCard(int revealToPlayerId); void actRevealLibrary(int revealToPlayerId); void actSortHand(); - void cardMenuAction(QList selectedCards, CardMenuActionType type); + void cardMenuAction(QList selectedCards, CardMenuActionType type); private: PlayerLogic *player; + // Borrowed settings providers (owned by the application). Null outside the client, in which case the compiled-in + // defaults below apply. + IInterfaceSettingsProvider *interfaceSettings; + ICardPreferenceProvider *cardPreferences; int defaultNumberTopCards = 1; int defaultNumberTopCardsToPlaceBelow = 1; @@ -245,18 +308,23 @@ private: bool lastRelatedCreationSucceeded = false; - void createCard(const CardItem *sourceCard, + [[nodiscard]] bool getPlayToStack() const; + [[nodiscard]] int getStartingHandSize() const; + [[nodiscard]] bool getAnnotateTokens() const; + [[nodiscard]] QString getCardPreferenceOverride(const QString &cardName) const; + + void createCard(const CardState *sourceCard, const QString &dbCardName, CardRelationType attach = CardRelationType::DoesNotAttach, bool persistent = false, bool faceDown = false); - void playSelectedCards(QList selectedCards, bool faceDown = false); + void playSelectedCards(QList selectedCards, bool faceDown = false); void cmdSetTopCard(Command_MoveCard &cmd); void cmdSetBottomCard(Command_MoveCard &cmd); - void offsetCardCounter(QList selectedCards, int counterId, int offset); + void offsetCardCounter(QList selectedCards, int counterId, int offset); }; #endif // COCKATRICE_PLAYER_ACTIONS_H diff --git a/cockatrice/src/game/player/player_event_handler.cpp b/libcockatrice_game/libcockatrice/game/player/player_event_handler.cpp similarity index 88% rename from cockatrice/src/game/player/player_event_handler.cpp rename to libcockatrice_game/libcockatrice/game/player/player_event_handler.cpp index 18f6573df..6588547da 100644 --- a/cockatrice/src/game/player/player_event_handler.cpp +++ b/libcockatrice_game/libcockatrice/game/player/player_event_handler.cpp @@ -1,14 +1,14 @@ #include "player_event_handler.h" -#include "../../game_graphics/board/card_item.h" -#include "../../game_graphics/zones/view_zone.h" #include "../abstract_game.h" #include "../board/arrow_data.h" #include "../board/card_list.h" +#include "../board/card_state.h" #include "../board/counter_state.h" #include "../zones/card_zone_logic.h" #include "../zones/hand_zone_logic.h" #include "../zones/pile_zone_logic.h" +#include "../zones/view_zone_logic.h" #include "libcockatrice/card/card_info.h" #include "libcockatrice/card/printing/exact_card.h" #include "libcockatrice/protocol/pb/card_attributes.pb.h" @@ -27,6 +27,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -49,6 +52,7 @@ #include #include #include +#include #include #include #include @@ -76,12 +80,12 @@ void PlayerEventHandler::eventShuffle(const Event_Shuffle &event) } // close all views that contain shuffled cards - for (ZoneViewZone *view : zone->getViews()) { - if (view != nullptr) { - int length = view->getLogic()->getCards().length(); + for (auto *viewLogic : zone->getViews()) { + if (viewLogic != nullptr) { + int length = viewLogic->getCards().length(); // we want to close empty views as well if (length == 0 || length > absStart) { // note this assumes views always start at the top of the library - view->close(); + viewLogic->requestClose(); } } else { qWarning() << zone->getName() << "of" << player->getPlayerInfo()->getName() << "holds empty zoneview!"; @@ -155,23 +159,29 @@ void PlayerEventHandler::eventCreateToken(const Event_CreateToken &event) } CardRef cardRef = {QString::fromStdString(event.card_name()), QString::fromStdString(event.card_provider_id())}; - CardItem *card = new CardItem(player, nullptr, cardRef, event.card_id()); // use db PT if not provided in event and not face-down - if (!QString::fromStdString(event.pt()).isEmpty()) { - card->setPT(QString::fromStdString(event.pt())); - } else if (!event.face_down()) { - ExactCard dbCard = card->getCard(); + QString pt = QString::fromStdString(event.pt()); + if (pt.isEmpty() && !event.face_down()) { + ExactCard dbCard = CardDatabaseManager::query()->getCard(cardRef); if (dbCard) { - card->setPT(dbCard.getInfo().getPowTough()); + pt = dbCard.getInfo().getPowTough(); } } - card->setColor(QString::fromStdString(event.color())); - card->setAnnotation(QString::fromStdString(event.annotation())); - card->setDestroyOnZoneChange(event.destroy_on_zone_change()); - card->setFaceDown(event.face_down()); - emit logCreateToken(player, card->getName(), card->getPT(), card->getFaceDown()); - zone->addCard(card, true, event.x(), event.y()); + emit logCreateToken(player, cardRef.name, pt, event.face_down()); + + ServerInfo_Card cardInfo; + cardInfo.set_id(event.card_id()); + cardInfo.set_name(event.card_name()); + cardInfo.set_provider_id(event.card_provider_id()); + cardInfo.set_pt(pt.toStdString()); + cardInfo.set_color(event.color()); + cardInfo.set_annotation(event.annotation()); + cardInfo.set_destroy_on_zone_change(event.destroy_on_zone_change()); + cardInfo.set_face_down(event.face_down()); + cardInfo.set_x(event.x()); + cardInfo.set_y(event.y()); + emit zone->createCardRequested(cardInfo, true); } void PlayerEventHandler::eventSetCardAttr(const Event_SetCardAttr &event, @@ -193,7 +203,7 @@ void PlayerEventHandler::eventSetCardAttr(const Event_SetCardAttr &event, emit logSetTapped(player, nullptr, event.attr_value() == "1"); } } else { - CardItem *card = zone->getCard(event.card_id()); + CardState *card = zone->getCard(event.card_id()); if (!card) { qWarning() << "PlayerEventHandler::eventSetCardAttr: card id=" << event.card_id() << "not found"; return; @@ -203,7 +213,7 @@ void PlayerEventHandler::eventSetCardAttr(const Event_SetCardAttr &event, } void PlayerEventHandler::setCardAttrHelper(const GameEventContext &context, - CardItem *card, + CardState *card, CardAttribute attribute, const QString &avalue, bool allCards, @@ -264,7 +274,7 @@ void PlayerEventHandler::eventSetCardCounter(const Event_SetCardCounter &event) return; } - CardItem *card = zone->getCard(event.card_id()); + CardState *card = zone->getCard(event.card_id()); if (!card) { return; } @@ -341,13 +351,13 @@ void PlayerEventHandler::eventMoveCard(const Event_MoveCard &event, const GameEv if (x == -1) { x = 0; } - CardItem *card = startZone->takeCard(position, event.card_id(), startZone != targetZone); + CardState *card = startZone->takeCard(position, event.card_id(), startZone != targetZone); if (card == nullptr) { return; } - if (startZone != targetZone) { - card->deleteCardInfoPopup(); - } + const bool zoneChanged = startZone != targetZone; + const bool ownerChanged = zoneChanged && startZone->getPlayer() != targetZone->getPlayer(); + emit cardViewRefreshRequested(card, zoneChanged, ownerChanged ? targetZone->getPlayer() : nullptr); if (event.has_card_name()) { QString name = QString::fromStdString(event.card_name()); QString providerId = @@ -356,27 +366,19 @@ void PlayerEventHandler::eventMoveCard(const Event_MoveCard &event, const GameEv } if (card->getAttachedTo() && (startZone != targetZone)) { - CardItem *parentCard = card->getAttachedTo(); + CardState *parentCard = card->getAttachedTo(); + // CardState::setAttachedTo removes the card from the old parent's attachedCards list and emits the zone events. card->setAttachedTo(nullptr); parentCard->getZone()->reorganizeCards(); } - card->deleteDragItem(); - card->setId(event.new_card_id()); card->setFaceDown(event.face_down()); if (startZone != targetZone) { - card->setBeingPointedAt(false); - card->setHovered(false); - - const QList &attachedCards = card->getAttachedCards(); + const QList &attachedCards = card->getAttachedCards(); for (auto attachedCard : attachedCards) { emit targetZone->cardAdded(attachedCard); } - - if (startZone->getPlayer() != targetZone->getPlayer()) { - card->setOwner(targetZone->getPlayer()); - } } // The log event has to be sent before the card is added to the target zone @@ -404,7 +406,7 @@ void PlayerEventHandler::eventFlipCard(const Event_FlipCard &event) if (!zone) { return; } - CardItem *card = zone->getCard(event.card_id()); + CardState *card = zone->getCard(event.card_id()); if (!card) { return; } @@ -427,20 +429,28 @@ void PlayerEventHandler::eventDestroyCard(const Event_DestroyCard &event) return; } - CardItem *card = zone->getCard(event.card_id()); + CardState *card = zone->getCard(event.card_id()); if (!card) { return; } - QList attachedCards = card->getAttachedCards(); + QList attachedCards = card->getAttachedCards(); // This list is always empty except for buggy server implementations. for (auto &attachedCard : attachedCards) { + card->removeAttachedCard(attachedCard); + attachedCard->setGridPoint(QPoint(-1, attachedCard->getGridPoint().y())); attachedCard->setAttachedTo(nullptr); + if (attachedCard->getZone() == nullptr) { + attachedCard->deleteView(); + } else { + emit attachedCard->getZone()->cardAdded(attachedCard); + attachedCard->getZone()->reorganizeCards(); + } } emit logDestroyCard(player, card->getName()); zone->takeCard(-1, event.card_id(), true); - card->deleteLater(); + card->deleteView(); } void PlayerEventHandler::eventAttachCard(const Event_AttachCard &event) @@ -448,7 +458,7 @@ void PlayerEventHandler::eventAttachCard(const Event_AttachCard &event) const QMap &playerList = player->getGame()->getPlayerManager()->getPlayers(); PlayerLogic *targetPlayer = nullptr; CardZoneLogic *targetZone = nullptr; - CardItem *targetCard = nullptr; + CardState *targetCard = nullptr; if (event.has_target_player_id()) { targetPlayer = playerList.value(event.target_player_id(), 0); if (targetPlayer) { @@ -464,12 +474,12 @@ void PlayerEventHandler::eventAttachCard(const Event_AttachCard &event) return; } - CardItem *startCard = startZone->getCard(event.card_id()); + CardState *startCard = startZone->getCard(event.card_id()); if (!startCard) { return; } - CardItem *oldParent = startCard->getAttachedTo(); + CardState *oldParent = startCard->getAttachedTo(); startCard->setAttachedTo(targetCard); @@ -498,7 +508,7 @@ void PlayerEventHandler::eventDrawCards(const Event_DrawCards &event) if (listSize) { for (int i = 0; i < listSize; ++i) { const ServerInfo_Card &cardInfo = event.cards(i); - CardItem *card = _deck->takeCard(0, cardInfo.id()); + CardState *card = _deck->takeCard(0, cardInfo.id()); QString cardName = QString::fromStdString(cardInfo.name()); QString providerId = QString::fromStdString(cardInfo.provider_id()); card->setCardRef({cardName, providerId}); @@ -546,7 +556,7 @@ void PlayerEventHandler::eventRevealCards(const Event_RevealCards &event, EventP for (const auto &card : cardList) { QString cardName = QString::fromStdString(card->name()); QString providerId = QString::fromStdString(card->provider_id()); - CardItem *cardItem = zone->getCard(card->id()); + CardState *cardItem = zone->getCard(card->id()); if (!cardItem) { continue; } diff --git a/cockatrice/src/game/player/player_event_handler.h b/libcockatrice_game/libcockatrice/game/player/player_event_handler.h similarity index 89% rename from cockatrice/src/game/player/player_event_handler.h rename to libcockatrice_game/libcockatrice/game/player/player_event_handler.h index 88254119b..d253deef0 100644 --- a/cockatrice/src/game/player/player_event_handler.h +++ b/libcockatrice_game/libcockatrice/game/player/player_event_handler.h @@ -27,7 +27,7 @@ #include #include -class CardItem; +class CardState; class CardZoneLogic; class PlayerLogic; class Event_AttachCard; @@ -61,7 +61,7 @@ template class QList; * Design notes: * - All event handlers assume events are authoritative and already validated * by the server. - * - Most handlers mutate both logical state (CardItem, CardZoneLogic, counters) + * - Most handlers mutate both logical state (CardState, CardZoneLogic, counters) * and visual/UI state (views, arrows, menus). * - Logging signals are emitted *after* or *during* state mutation, depending * on whether later mutations would invalidate log data. @@ -238,7 +238,7 @@ signals: void logUndoDraw(PlayerLogic *player, QString cardName); void logUndoDrawFailed(PlayerLogic *player); void logMoveCard(PlayerLogic *player, - CardItem *card, + CardState *card, CardZoneLogic *startZone, int oldX, CardZoneLogic *targetZone, @@ -248,11 +248,11 @@ signals: void logAttachCard(PlayerLogic *player, QString cardName, PlayerLogic *targetPlayer, QString targetCardName); void logUnattachCard(PlayerLogic *player, QString cardName); void logSetCardCounter(PlayerLogic *player, QString cardName, int counterId, int value, int oldValue); - void logSetTapped(PlayerLogic *player, CardItem *card, bool tapped); + void logSetTapped(PlayerLogic *player, CardState *card, bool tapped); void logSetCounter(PlayerLogic *player, QString counterName, int value, int oldValue); - void logSetDoesntUntap(PlayerLogic *player, CardItem *card, bool doesntUntap); - void logSetPT(PlayerLogic *player, CardItem *card, QString newPT); - void logSetAnnotation(PlayerLogic *player, CardItem *card, QString newAnnotation); + void logSetDoesntUntap(PlayerLogic *player, CardState *card, bool doesntUntap); + void logSetPT(PlayerLogic *player, CardState *card, QString newPT); + void logSetAnnotation(PlayerLogic *player, CardState *card, QString newAnnotation); void logDumpZone(PlayerLogic *player, CardZoneLogic *zone, int numberCards, bool isReversed = false); void logRevealCards(PlayerLogic *player, CardZoneLogic *zone, @@ -266,15 +266,23 @@ signals: void logAlwaysLookAtTopCard(PlayerLogic *player, CardZoneLogic *zone, bool reveal); /** @} */ - void cardZoneChanged(CardItem *card, bool sameZone); - void requestCardMenuUpdate(const CardItem *card); + void cardZoneChanged(CardState *card, bool sameZone); + void requestCardMenuUpdate(CardState *card); + /** + * @brief A card was just moved by the server and its view needs refreshing. + * + * @param card The card that moved. + * @param zoneChanged Whether the card crossed a zone boundary. + * @param newOwner The player the card now belongs to, or nullptr to leave the owner alone. + */ + void cardViewRefreshRequested(CardState *card, bool zoneChanged, PlayerLogic *newOwner); private: /** Owning player instance. */ PlayerLogic *player; void setCardAttrHelper(const GameEventContext &context, - CardItem *card, + CardState *card, CardAttribute attribute, const QString &avalue, bool allCards, diff --git a/cockatrice/src/game/player/player_info.cpp b/libcockatrice_game/libcockatrice/game/player/player_info.cpp similarity index 100% rename from cockatrice/src/game/player/player_info.cpp rename to libcockatrice_game/libcockatrice/game/player/player_info.cpp diff --git a/cockatrice/src/game/player/player_info.h b/libcockatrice_game/libcockatrice/game/player/player_info.h similarity index 100% rename from cockatrice/src/game/player/player_info.h rename to libcockatrice_game/libcockatrice/game/player/player_info.h diff --git a/cockatrice/src/game/player/player_logic.cpp b/libcockatrice_game/libcockatrice/game/player/player_logic.cpp similarity index 89% rename from cockatrice/src/game/player/player_logic.cpp rename to libcockatrice_game/libcockatrice/game/player/player_logic.cpp index b545b7088..96bd07164 100644 --- a/cockatrice/src/game/player/player_logic.cpp +++ b/libcockatrice_game/libcockatrice/game/player/player_logic.cpp @@ -1,8 +1,9 @@ #include "player_logic.h" -#include "../../game_graphics/board/card_item.h" #include "../abstract_game.h" #include "../board/arrow_data.h" +#include "../board/card_list.h" +#include "../board/card_state.h" #include "../board/counter_state.h" #include "../game_meta_info.h" #include "../zones/hand_zone_logic.h" @@ -33,10 +34,17 @@ #include #include -PlayerLogic::PlayerLogic(const ServerInfo_User &info, int _id, bool _local, bool _judge, AbstractGame *_parent) +PlayerLogic::PlayerLogic(const ServerInfo_User &info, + int _id, + bool _local, + bool _judge, + AbstractGame *_parent, + IInterfaceSettingsProvider *interfaceSettings, + ICardPreferenceProvider *cardPreferences) : QObject(_parent), game(_parent), playerInfo(new PlayerInfo(info, _id, _local, _judge)), - playerEventHandler(new PlayerEventHandler(this)), playerActions(new PlayerActions(this)), active(false), - conceded(false), zoneId(0), dialogSemaphore(false) + playerEventHandler(new PlayerEventHandler(this)), + playerActions(new PlayerActions(this, interfaceSettings, cardPreferences)), active(false), conceded(false), + zoneId(0), dialogSemaphore(false) { initializeZones(); } @@ -172,22 +180,20 @@ void PlayerLogic::processPlayerInfo(const ServerInfo_Player &info) const int cardListSize = zoneInfo.card_list_size(); if (!cardListSize) { for (int j = 0; j < zoneInfo.card_count(); ++j) { - zone->addCard(new CardItem(this), false, -1); + emit zone->createCardRequested(ServerInfo_Card(), false); } } else { for (int j = 0; j < cardListSize; ++j) { - const ServerInfo_Card &cardInfo = zoneInfo.card_list(j); - auto *card = new CardItem(this); - card->processCardInfo(cardInfo); + ServerInfo_Card cardInfo = zoneInfo.card_list(j); // Zones without coordinates (hand, piles, stack) preserve the order // they arrive in on the server in the positions of their cards list. // The x coordinate of such cards is always 0, so inserting at it // would reverse the list on reconnect. Append instead. - if (zoneInfo.with_coords()) { - zone->addCard(card, false, cardInfo.x(), cardInfo.y()); - } else { - zone->addCard(card, false, -1); + if (!zoneInfo.with_coords()) { + cardInfo.set_x(-1); + cardInfo.set_y(-1); } + emit zone->createCardRequested(cardInfo, false); } } if (zoneInfo.has_always_reveal_top_card()) { @@ -219,8 +225,8 @@ void PlayerLogic::processCardAttachment(const ServerInfo_Player &info) for (int j = 0; j < cardListSize; ++j) { const ServerInfo_Card &cardInfo = zoneInfo.card_list(j); if (cardInfo.has_attach_player_id()) { - CardItem *startCard = zone->getCard(cardInfo.id()); - CardItem *targetCard = + CardState *startCard = zone->getCard(cardInfo.id()); + CardState *targetCard = game->getCard(cardInfo.attach_player_id(), QString::fromStdString(cardInfo.attach_zone()), cardInfo.attach_card_id()); if (!targetCard) { @@ -239,19 +245,19 @@ void PlayerLogic::processCardAttachment(const ServerInfo_Player &info) } } -void PlayerLogic::addCard(CardItem *card) +void PlayerLogic::addCard(AbstractCardItem *card) { emit newCardAdded(card); } -void PlayerLogic::deleteCard(CardItem *card) +void PlayerLogic::deleteCard(CardState *card) { if (card == nullptr) { return; } else if (dialogSemaphore) { cardsToDelete.append(card); } else { - card->deleteLater(); + card->deleteView(); } } @@ -332,7 +338,7 @@ bool PlayerLogic::clearCardsToDelete() for (auto &i : cardsToDelete) { if (i != nullptr) { - i->deleteLater(); + i->deleteView(); } } cardsToDelete.clear(); diff --git a/cockatrice/src/game/player/player_logic.h b/libcockatrice_game/libcockatrice/game/player/player_logic.h similarity index 88% rename from cockatrice/src/game/player/player_logic.h rename to libcockatrice_game/libcockatrice/game/player/player_logic.h index 59da7cc32..d6c4f5187 100644 --- a/cockatrice/src/game/player/player_logic.h +++ b/libcockatrice_game/libcockatrice/game/player/player_logic.h @@ -7,6 +7,8 @@ #ifndef PLAYER_H #define PLAYER_H +#include "../board/arrow_data.h" +#include "../board/counter_state.h" #include "../zones/card_zone_logic.h" #include "../zones/hand_zone_logic.h" #include "../zones/pile_zone_logic.h" @@ -17,12 +19,16 @@ #include "libcockatrice/utility/playmat_params.h" #include -#include #include #include #include #include #include +#include +#include +#include +#include +#include #include #include @@ -31,7 +37,6 @@ // signatures, which the moc-generated code needs to be complete. #include -class CardItem; class CounterState; class QWidget; class ServerInfo_PlayerProperties; @@ -42,6 +47,8 @@ template class QSharedPointer; inline Q_LOGGING_CATEGORY(PlayerLog, "player"); class AbstractCardItem; class AbstractGame; +class ICardPreferenceProvider; +class IInterfaceSettingsProvider; class PlayerInfo; class PlayerEventHandler; class PlayerActions; @@ -67,7 +74,7 @@ signals: /** @brief Emitted when the remote playmat (card/params) is updated from player properties. */ void playmatChanged(); void newCardAdded(AbstractCardItem *card); - void requestCardMenuUpdate(const CardItem *card); + void requestCardMenuUpdate(CardState *card); void counterAdded(CounterState *state); void counterRemoved(int counterId); void rearrangeCounters(); @@ -87,7 +94,13 @@ public slots: void onRequestZoneViewToggle(const QString &zoneName, int numberCards, bool isReversed); public: - PlayerLogic(const ServerInfo_User &info, int _id, bool _local, bool _judge, AbstractGame *_parent); + PlayerLogic(const ServerInfo_User &info, + int _id, + bool _local, + bool _judge, + AbstractGame *_parent, + IInterfaceSettingsProvider *interfaceSettings = nullptr, + ICardPreferenceProvider *cardPreferences = nullptr); ~PlayerLogic() override; void initializeZones(); @@ -97,8 +110,8 @@ public: void processPlayerInfo(const ServerInfo_Player &info); void processCardAttachment(const ServerInfo_Player &info); - void addCard(CardItem *c); - void deleteCard(CardItem *c); + void addCard(AbstractCardItem *c); + void deleteCard(CardState *c); bool clearCardsToDelete(); @@ -250,7 +263,7 @@ private: QMap counters; bool dialogSemaphore; - QList cardsToDelete; + QList cardsToDelete; // Playmat from player properties (for opponent display) CardRef remotePlaymatCard; @@ -258,15 +271,4 @@ private: bool hasRemotePlaymat = false; }; -class AnnotationDialog : public QInputDialog -{ - Q_OBJECT - void keyPressEvent(QKeyEvent *e) override; - -public: - explicit AnnotationDialog(QWidget *parent = nullptr) : QInputDialog(parent) - { - } -}; - #endif diff --git a/cockatrice/src/game/player/player_manager.cpp b/libcockatrice_game/libcockatrice/game/player/player_manager.cpp similarity index 86% rename from cockatrice/src/game/player/player_manager.cpp rename to libcockatrice_game/libcockatrice/game/player/player_manager.cpp index 4730805ad..284d21f68 100644 --- a/cockatrice/src/game/player/player_manager.cpp +++ b/libcockatrice_game/libcockatrice/game/player/player_manager.cpp @@ -12,9 +12,12 @@ PlayerManager::PlayerManager(AbstractGame *_game, int _localPlayerId, bool _localPlayerIsJudge, - bool localPlayerIsSpectator) + bool localPlayerIsSpectator, + IInterfaceSettingsProvider *_interfaceSettings, + ICardPreferenceProvider *_cardPreferences) : QObject(_game), game(_game), players(QMap()), localPlayerId(_localPlayerId), - localPlayerIsJudge(_localPlayerIsJudge), localPlayerIsSpectator(localPlayerIsSpectator) + localPlayerIsJudge(_localPlayerIsJudge), localPlayerIsSpectator(localPlayerIsSpectator), + interfaceSettings(_interfaceSettings), cardPreferences(_cardPreferences) { } @@ -52,7 +55,7 @@ bool PlayerManager::isLocalPlayer(int playerId) PlayerLogic *PlayerManager::addPlayer(int playerId, const ServerInfo_User &info) { auto *newPlayer = new PlayerLogic(info, playerId, isLocalPlayer(playerId) || game->getGameState()->getIsLocalGame(), - isJudge(), getGame()); + isJudge(), getGame(), interfaceSettings, cardPreferences); connect(newPlayer, &PlayerLogic::concededChanged, this, &PlayerManager::onPlayerConceded); players.insert(playerId, newPlayer); emit playerAdded(newPlayer); diff --git a/cockatrice/src/game/player/player_manager.h b/libcockatrice_game/libcockatrice/game/player/player_manager.h similarity index 84% rename from cockatrice/src/game/player/player_manager.h rename to libcockatrice_game/libcockatrice/game/player/player_manager.h index 7a9f7645b..c95bbbb96 100644 --- a/cockatrice/src/game/player/player_manager.h +++ b/libcockatrice_game/libcockatrice/game/player/player_manager.h @@ -16,6 +16,8 @@ #include class AbstractGame; +class ICardPreferenceProvider; +class IInterfaceSettingsProvider; class PlayerLogic; class PlayerManager : public QObject @@ -23,7 +25,12 @@ class PlayerManager : public QObject Q_OBJECT public: - PlayerManager(AbstractGame *_game, int _localPlayerId, bool _localPlayerIsJudge, bool localPlayerIsSpectator); + PlayerManager(AbstractGame *_game, + int _localPlayerId, + bool _localPlayerIsJudge, + bool localPlayerIsSpectator, + IInterfaceSettingsProvider *interfaceSettings = nullptr, + ICardPreferenceProvider *cardPreferences = nullptr); AbstractGame *game; QMap players; @@ -31,6 +38,9 @@ public: bool localPlayerIsJudge; bool localPlayerIsSpectator; QMap spectators; + // Borrowed settings providers forwarded to each PlayerLogic (owned by the application). + IInterfaceSettingsProvider *interfaceSettings; + ICardPreferenceProvider *cardPreferences; [[nodiscard]] bool isSpectator() const { diff --git a/libcockatrice_game/libcockatrice/game/player/token_info.h b/libcockatrice_game/libcockatrice/game/player/token_info.h new file mode 100644 index 000000000..99869f33e --- /dev/null +++ b/libcockatrice_game/libcockatrice/game/player/token_info.h @@ -0,0 +1,23 @@ +/** + * @file token_info.h + * @ingroup GameLogicPlayers + */ +//! \todo Document this file. + +#ifndef COCKATRICE_TOKEN_INFO_H +#define COCKATRICE_TOKEN_INFO_H + +#include + +struct TokenInfo +{ + QString name; + QString color; + QString pt; + QString annotation; + bool destroy = true; + bool faceDown = false; + QString providerId; +}; + +#endif // COCKATRICE_TOKEN_INFO_H diff --git a/cockatrice/src/game/replay.cpp b/libcockatrice_game/libcockatrice/game/replay.cpp similarity index 51% rename from cockatrice/src/game/replay.cpp rename to libcockatrice_game/libcockatrice/game/replay.cpp index 4ab4a6321..4d41a12ea 100644 --- a/cockatrice/src/game/replay.cpp +++ b/libcockatrice_game/libcockatrice/game/replay.cpp @@ -9,10 +9,15 @@ class GameReplay; class QObject; -Replay::Replay(QObject *_parent, const GameReplay *_replay, bool isLocalGame) : AbstractGame(_parent) +Replay::Replay(QObject *_parent, + const GameReplay *_replay, + bool isLocalGame, + IInterfaceSettingsProvider *interfaceSettings, + ICardPreferenceProvider *cardPreferences) + : AbstractGame(_parent) { gameState = new GameState(this, 0, -1, isLocalGame, {}, false, false, -1, false); connect(gameMetaInfo, &GameMetaInfo::startedChanged, gameState, &GameState::onStartedChanged); - playerManager = new PlayerManager(this, -1, false, true); + playerManager = new PlayerManager(this, -1, false, true, interfaceSettings, cardPreferences); loadReplay(_replay); } diff --git a/libcockatrice_game/libcockatrice/game/replay.h b/libcockatrice_game/libcockatrice/game/replay.h new file mode 100644 index 000000000..f8f82a70d --- /dev/null +++ b/libcockatrice_game/libcockatrice/game/replay.h @@ -0,0 +1,32 @@ +/** + * @file replay.h + * @ingroup GameLogic + * @ingroup Replay + */ +//! \todo Document this file. + +#ifndef COCKATRICE_REPLAY_H +#define COCKATRICE_REPLAY_H + +#include "abstract_game.h" + +#include + +class GameReplay; +class QObject; +class ICardPreferenceProvider; +class IInterfaceSettingsProvider; + +class Replay : public AbstractGame +{ + Q_OBJECT + +public: + explicit Replay(QObject *_parent, + const GameReplay *_replay, + bool isLocalGame, + IInterfaceSettingsProvider *interfaceSettings = nullptr, + ICardPreferenceProvider *cardPreferences = nullptr); +}; + +#endif // COCKATRICE_REPLAY_H diff --git a/cockatrice/src/client/translation.h b/libcockatrice_game/libcockatrice/game/translation.h similarity index 100% rename from cockatrice/src/client/translation.h rename to libcockatrice_game/libcockatrice/game/translation.h diff --git a/cockatrice/src/game/zones/card_zone_algorithms.h b/libcockatrice_game/libcockatrice/game/zones/card_zone_algorithms.h similarity index 81% rename from cockatrice/src/game/zones/card_zone_algorithms.h rename to libcockatrice_game/libcockatrice/game/zones/card_zone_algorithms.h index 118a7d29d..35e96b86c 100644 --- a/cockatrice/src/game/zones/card_zone_algorithms.h +++ b/libcockatrice_game/libcockatrice/game/zones/card_zone_algorithms.h @@ -5,9 +5,9 @@ namespace CardZoneAlgorithms { /** - * Shared insertion logic for zones where cards become visible on add and follow - * the standard pattern: clamp index, insert, clear identity if contents unknown, - * reset state, show card. + * Shared insertion logic for zones where cards follow the standard pattern: + * clamp index, insert, clear identity if contents unknown, reset state. + * Visibility and parenting are handled by the graphics zone's onCardAdded(). * * Zones with different post-add behavior (signal connections, positional resets, * hidden cards, or coordinate-based placement) should NOT use this — implement @@ -18,8 +18,7 @@ namespace CardZoneAlgorithms * * @tparam CardList Must provide: size() -> int, insert(int, CardType*), * getContentsKnown() -> bool - * @tparam CardType Must provide: setId(int), setCardRef(CardRefType), - * resetState(bool), setVisible(bool) + * @tparam CardType Must provide: setId(int), setCardRef(CardRefType), resetState(bool) * @param keepAnnotations Forwarded to card->resetState(). Stack-like zones preserve * annotations across zone transitions; hand-like zones clear them. */ @@ -37,7 +36,6 @@ void addCardToList(CardList &cards, CardType *card, int x, bool keepAnnotations) } card->resetState(keepAnnotations); - card->setVisible(true); } } // namespace CardZoneAlgorithms diff --git a/cockatrice/src/game/zones/card_zone_logic.cpp b/libcockatrice_game/libcockatrice/game/zones/card_zone_logic.cpp similarity index 79% rename from cockatrice/src/game/zones/card_zone_logic.cpp rename to libcockatrice_game/libcockatrice/game/zones/card_zone_logic.cpp index d997ab9ad..a160dcc1d 100644 --- a/cockatrice/src/game/zones/card_zone_logic.cpp +++ b/libcockatrice_game/libcockatrice/game/zones/card_zone_logic.cpp @@ -1,13 +1,11 @@ #include "card_zone_logic.h" -#include "../../client/translation.h" -#include "../../game_graphics/board/card_item.h" -#include "../../game_graphics/zones/card_zone.h" -#include "../../game_graphics/zones/view_zone.h" #include "../board/card_list.h" +#include "../board/card_state.h" #include "../player/player_actions.h" #include "../player/player_info.h" #include "../player/player_logic.h" +#include "../translation.h" #include "libcockatrice/card/database/card_database.h" #include "view_zone_logic.h" @@ -44,19 +42,24 @@ CardZoneLogic::CardZoneLogic(PlayerLogic *_player, &CardZoneLogic::refreshCardInfos); } -void CardZoneLogic::addCard(CardItem *card, const bool reorganize, const int x, const int y) +void CardZoneLogic::addCard(CardState *card, const bool reorganize, const int x, const int y) { if (!card) { - qCWarning(CardZoneLog) << "CardZoneLogic::addCard() card is null; this shouldn't normally happen"; + qCWarning(CardZoneLogicLog) << "CardZoneLogic::addCard() card is null; this shouldn't normally happen"; return; } - for (auto *view : views) { - if (qobject_cast(view->getLogic())->prepareAddCard(x)) { - auto copy = new CardItem(player, nullptr, card->getCardRef(), card->getId()); - copy->setFaceDown(card->getFaceDown()); - - view->getLogic()->addCard(copy, reorganize, x, y); + for (auto *viewLogic : views) { + if (viewLogic->prepareAddCard(x)) { + ServerInfo_Card cardInfo; + const CardRef &cardRef = card->getCardRef(); + cardInfo.set_name(cardRef.name.toStdString()); + cardInfo.set_provider_id(cardRef.providerId.toStdString()); + cardInfo.set_id(card->getId()); + cardInfo.set_face_down(card->getFaceDown()); + cardInfo.set_x(x); + cardInfo.set_y(y); + emit viewLogic->createCardRequested(cardInfo, reorganize); } } @@ -71,7 +74,7 @@ void CardZoneLogic::addCard(CardItem *card, const bool reorganize, const int x, emit cardCountChanged(); } -CardItem *CardZoneLogic::takeCard(int position, int cardId, bool toNewZone) +CardState *CardZoneLogic::takeCard(int position, int cardId, bool toNewZone) { if (position == -1) { // position == -1 means either that the zone is indexed by card id @@ -90,11 +93,11 @@ CardItem *CardZoneLogic::takeCard(int position, int cardId, bool toNewZone) return nullptr; } - for (auto *view : views) { - qobject_cast(view->getLogic())->removeCard(position, toNewZone); + for (auto *viewLogic : views) { + viewLogic->removeCard(position, toNewZone); } - CardItem *c = cards.takeAt(position); + CardState *c = cards.takeAt(position); c->setId(cardId); @@ -103,11 +106,11 @@ CardItem *CardZoneLogic::takeCard(int position, int cardId, bool toNewZone) return c; } -CardItem *CardZoneLogic::getCard(int cardId) +CardState *CardZoneLogic::getCard(int cardId) { - CardItem *c = cards.findCard(cardId); + CardState *c = cards.findCard(cardId); if (!c) { - qCWarning(CardZoneLog) << "CardZoneLogic::getCard: card id=" << cardId << "not found"; + qCWarning(CardZoneLogicLog) << "CardZoneLogic::getCard: card id=" << cardId << "not found"; return nullptr; } // If the card's id is -1, this zone is invisible, @@ -119,10 +122,10 @@ CardItem *CardZoneLogic::getCard(int cardId) return c; } -void CardZoneLogic::removeCard(CardItem *card) +void CardZoneLogic::removeCard(CardState *card) { if (!card) { - qCWarning(CardZoneLog) << "CardZoneLogic::removeCard: card is null, this shouldn't normally happen"; + qCWarning(CardZoneLogicLog) << "CardZoneLogic::removeCard: card is null, this shouldn't normally happen"; return; } @@ -135,8 +138,8 @@ void CardZoneLogic::removeCard(CardItem *card) void CardZoneLogic::refreshCardInfos() { - for (const auto &cardItem : cards) { - cardItem->refreshCardInfo(); + for (const auto &cardState : cards) { + cardState->refreshCardInfo(); } } @@ -168,17 +171,17 @@ void CardZoneLogic::clearContents() const CardList toClear = cards; // Detach and notify attached cards and zones *before* deleting anything. - for (CardItem *card : toClear) { + for (CardState *card : toClear) { // If an incorrectly implemented server doesn't return attached cards to whom they belong before dropping a // player, we have to return them to avoid a crash. - const QList &attachedCards = card->getAttachedCards(); - for (CardItem *attachedCard : attachedCards) { + const QList &attachedCards = card->getAttachedCards(); + for (CardState *attachedCard : attachedCards) { emit attachedCard->getZone()->cardAdded(attachedCard); } } // Now request deletions after all manipulations are done. - for (CardItem *card : toClear) { + for (CardState *card : toClear) { player->deleteCard(card); } diff --git a/cockatrice/src/game/zones/card_zone_logic.h b/libcockatrice_game/libcockatrice/game/zones/card_zone_logic.h similarity index 73% rename from cockatrice/src/game/zones/card_zone_logic.h rename to libcockatrice_game/libcockatrice/game/zones/card_zone_logic.h index 2e232b258..d3cd9121f 100644 --- a/cockatrice/src/game/zones/card_zone_logic.h +++ b/libcockatrice_game/libcockatrice/game/zones/card_zone_logic.h @@ -7,28 +7,30 @@ #ifndef COCKATRICE_CARD_ZONE_LOGIC_H #define COCKATRICE_CARD_ZONE_LOGIC_H -#include "../../client/translation.h" #include "../board/card_list.h" +#include "../translation.h" #include #include #include #include +#include #include -class CardItem; - inline Q_LOGGING_CATEGORY(CardZoneLogicLog, "card_zone_logic"); class PlayerLogic; -class ZoneViewZone; +class ZoneViewZoneLogic; +class CardDragItem; class CardZoneLogic : public QObject { Q_OBJECT signals: - void cardAdded(CardItem *addedCard); + void cardAdded(CardState *addedCard); + /** @brief Asks the graphics side to build the card item for @p cardInfo and add it to this zone. */ + void createCardRequested(const ServerInfo_Card &cardInfo, bool reorganize); void cardCountChanged(); void reorganizeCards(); void updateGraphics(); @@ -43,16 +45,17 @@ public: bool _contentsKnown, QObject *parent = nullptr); - void addCard(CardItem *card, bool reorganize, int x, int y = -1); + void addCard(CardState *card, bool reorganize, int x, int y = -1); // getCard() finds a card by id. - CardItem *getCard(int cardId); - void removeCard(CardItem *card); + CardState *getCard(int cardId); + void removeCard(CardState *card); // takeCard() finds a card by position and removes it from the zone and from all of its views. - virtual CardItem *takeCard(int position, int cardId, bool canResize = true); + virtual CardState *takeCard(int position, int cardId, bool canResize = true); - void rawInsertCard(CardItem *card, int index) + void rawInsertCard(CardState *card, int index) { cards.insert(index, card); + emit cardCountChanged(); } [[nodiscard]] const CardList &getCards() const @@ -63,6 +66,7 @@ public: void sortCards(const QList &options) { cards.sortBy(options); + emit cardCountChanged(); } [[nodiscard]] QString getName() const { @@ -77,7 +81,7 @@ public: { return cards.getContentsKnown(); } - QList &getViews() + QList &getViews() { return views; } @@ -109,12 +113,12 @@ protected: PlayerLogic *player; QString name; CardList cards; - QList views; + QList views; bool hasCardAttr; bool isShufflable; bool alwaysRevealTopCard; - virtual void addCardImpl(CardItem *card, int x, int y) = 0; + virtual void addCardImpl(CardState *card, int x, int y) = 0; }; #endif // COCKATRICE_CARD_ZONE_LOGIC_H diff --git a/cockatrice/src/game/zones/hand_zone_logic.cpp b/libcockatrice_game/libcockatrice/game/zones/hand_zone_logic.cpp similarity index 82% rename from cockatrice/src/game/zones/hand_zone_logic.cpp rename to libcockatrice_game/libcockatrice/game/zones/hand_zone_logic.cpp index cea17d031..1d3f54929 100644 --- a/cockatrice/src/game/zones/hand_zone_logic.cpp +++ b/libcockatrice_game/libcockatrice/game/zones/hand_zone_logic.cpp @@ -1,6 +1,6 @@ #include "hand_zone_logic.h" -#include "../../game_graphics/board/card_item.h" +#include "../board/card_state.h" #include "card_zone_algorithms.h" class PlayerLogic; @@ -16,7 +16,7 @@ HandZoneLogic::HandZoneLogic(PlayerLogic *_player, { } -void HandZoneLogic::addCardImpl(CardItem *card, int x, int /*y*/) +void HandZoneLogic::addCardImpl(CardState *card, int x, int /*y*/) { CardZoneAlgorithms::addCardToList(cards, card, x, false); } diff --git a/cockatrice/src/game/zones/hand_zone_logic.h b/libcockatrice_game/libcockatrice/game/zones/hand_zone_logic.h similarity index 91% rename from cockatrice/src/game/zones/hand_zone_logic.h rename to libcockatrice_game/libcockatrice/game/zones/hand_zone_logic.h index c7d63bc0d..f8756eafd 100644 --- a/cockatrice/src/game/zones/hand_zone_logic.h +++ b/libcockatrice_game/libcockatrice/game/zones/hand_zone_logic.h @@ -26,7 +26,7 @@ public: QObject *parent = nullptr); protected: - void addCardImpl(CardItem *card, int x, int y) override; + void addCardImpl(CardState *card, int x, int y) override; }; #endif // COCKATRICE_HAND_ZONE_LOGIC_H diff --git a/cockatrice/src/game/zones/pile_zone_logic.cpp b/libcockatrice_game/libcockatrice/game/zones/pile_zone_logic.cpp similarity index 70% rename from cockatrice/src/game/zones/pile_zone_logic.cpp rename to libcockatrice_game/libcockatrice/game/zones/pile_zone_logic.cpp index 40af25cc5..e33d8fe79 100644 --- a/cockatrice/src/game/zones/pile_zone_logic.cpp +++ b/libcockatrice_game/libcockatrice/game/zones/pile_zone_logic.cpp @@ -1,10 +1,6 @@ #include "pile_zone_logic.h" -#include "../../game_graphics/board/card_item.h" -#include "../board/card_list.h" - -class PlayerLogic; -class QObject; +#include "../board/card_state.h" PileZoneLogic::PileZoneLogic(PlayerLogic *_player, const QString &_name, @@ -16,15 +12,14 @@ PileZoneLogic::PileZoneLogic(PlayerLogic *_player, { } -void PileZoneLogic::addCardImpl(CardItem *card, int x, int /*y*/) +void PileZoneLogic::addCardImpl(CardState *card, int x, int /*y*/) { - connect(card, &CardItem::sigPixmapUpdated, this, &PileZoneLogic::callUpdate); + connect(card, &CardState::cardPixmapUpdated, this, &PileZoneLogic::callUpdate); // if x is negative set it to add at end if (x < 0 || x >= cards.size()) { x = cards.size(); } cards.insert(x, card); - card->setPos(0, 0); if (!contentsKnown()) { card->setCardRef({}); card->setId(-1); @@ -33,6 +28,5 @@ void PileZoneLogic::addCardImpl(CardItem *card, int x, int /*y*/) cards.at(x + 1)->setCardRef({}); } } - card->setVisible(false); - card->resetState(); + card->resetState(false); } \ No newline at end of file diff --git a/cockatrice/src/game/zones/pile_zone_logic.h b/libcockatrice_game/libcockatrice/game/zones/pile_zone_logic.h similarity index 91% rename from cockatrice/src/game/zones/pile_zone_logic.h rename to libcockatrice_game/libcockatrice/game/zones/pile_zone_logic.h index ed6841b75..662b0377f 100644 --- a/cockatrice/src/game/zones/pile_zone_logic.h +++ b/libcockatrice_game/libcockatrice/game/zones/pile_zone_logic.h @@ -31,7 +31,7 @@ public: QObject *parent = nullptr); protected: - void addCardImpl(CardItem *card, int x, int y) override; + void addCardImpl(CardState *card, int x, int y) override; }; #endif // COCKATRICE_PILE_ZONE_LOGIC_H diff --git a/cockatrice/src/game/zones/stack_zone_logic.cpp b/libcockatrice_game/libcockatrice/game/zones/stack_zone_logic.cpp similarity index 83% rename from cockatrice/src/game/zones/stack_zone_logic.cpp rename to libcockatrice_game/libcockatrice/game/zones/stack_zone_logic.cpp index 454274971..ccf0d32f3 100644 --- a/cockatrice/src/game/zones/stack_zone_logic.cpp +++ b/libcockatrice_game/libcockatrice/game/zones/stack_zone_logic.cpp @@ -1,6 +1,6 @@ #include "stack_zone_logic.h" -#include "../../game_graphics/board/card_item.h" +#include "../board/card_state.h" #include "card_zone_algorithms.h" class PlayerLogic; @@ -16,7 +16,7 @@ StackZoneLogic::StackZoneLogic(PlayerLogic *_player, { } -void StackZoneLogic::addCardImpl(CardItem *card, int x, int /*y*/) +void StackZoneLogic::addCardImpl(CardState *card, int x, int /*y*/) { CardZoneAlgorithms::addCardToList(cards, card, x, true); } diff --git a/cockatrice/src/game/zones/stack_zone_logic.h b/libcockatrice_game/libcockatrice/game/zones/stack_zone_logic.h similarity index 91% rename from cockatrice/src/game/zones/stack_zone_logic.h rename to libcockatrice_game/libcockatrice/game/zones/stack_zone_logic.h index 657d44ecf..ac6c4617c 100644 --- a/cockatrice/src/game/zones/stack_zone_logic.h +++ b/libcockatrice_game/libcockatrice/game/zones/stack_zone_logic.h @@ -26,7 +26,7 @@ public: QObject *parent = nullptr); protected: - void addCardImpl(CardItem *card, int x, int y) override; + void addCardImpl(CardState *card, int x, int y) override; }; #endif // COCKATRICE_STACK_ZONE_LOGIC_H diff --git a/cockatrice/src/game/zones/table_zone_logic.cpp b/libcockatrice_game/libcockatrice/game/zones/table_zone_logic.cpp similarity index 60% rename from cockatrice/src/game/zones/table_zone_logic.cpp rename to libcockatrice_game/libcockatrice/game/zones/table_zone_logic.cpp index 07e4a759b..834794268 100644 --- a/cockatrice/src/game/zones/table_zone_logic.cpp +++ b/libcockatrice_game/libcockatrice/game/zones/table_zone_logic.cpp @@ -1,14 +1,9 @@ #include "table_zone_logic.h" -#include "../../game_graphics/board/card_item.h" -#include "../board/card_list.h" -#include "libcockatrice/card/card_info.h" +#include "../board/card_state.h" #include -class PlayerLogic; -class QObject; - TableZoneLogic::TableZoneLogic(PlayerLogic *_player, const QString &_name, bool _hasCardAttr, @@ -19,7 +14,7 @@ TableZoneLogic::TableZoneLogic(PlayerLogic *_player, { } -void TableZoneLogic::addCardImpl(CardItem *card, int _x, int _y) +void TableZoneLogic::addCardImpl(CardState *card, int _x, int _y) { cards.append(card); if (!card->getFaceDown() && card->getPT().isEmpty()) { @@ -29,15 +24,33 @@ void TableZoneLogic::addCardImpl(CardItem *card, int _x, int _y) card->setDoesntUntap(true); } card->setGridPoint(QPoint(_x, _y)); - card->setVisible(true); } -CardItem *TableZoneLogic::takeCard(int position, int cardId, bool toNewZone) +CardState *TableZoneLogic::takeCard(int position, int cardId, bool toNewZone) { - CardItem *result = CardZoneLogic::takeCard(position, cardId); + CardState *result = CardZoneLogic::takeCard(position, cardId); if (toNewZone) { emit contentSizeChanged(); } return result; -} \ No newline at end of file +} + +int TableZoneLogic::clampValidTableRow(const int row) +{ + if (row < 0) { + return 0; + } + if (row >= ROW_COUNT) { + return ROW_COUNT - 1; + } + return row; +} + +int TableZoneLogic::tableRowToGridY(int tableRow) +{ + if (tableRow > 2) { + tableRow = 1; + } + return clampValidTableRow(2 - tableRow); +} diff --git a/cockatrice/src/game/zones/table_zone_logic.h b/libcockatrice_game/libcockatrice/game/zones/table_zone_logic.h similarity index 57% rename from cockatrice/src/game/zones/table_zone_logic.h rename to libcockatrice_game/libcockatrice/game/zones/table_zone_logic.h index 8078bfd05..c828d8b91 100644 --- a/cockatrice/src/game/zones/table_zone_logic.h +++ b/libcockatrice_game/libcockatrice/game/zones/table_zone_logic.h @@ -29,8 +29,23 @@ public: bool _contentsKnown, QObject *parent = nullptr); + /** @brief Number of rows in the table zone grid (0=creatures, 1=noncreatures, 2=lands). */ + static const int ROW_COUNT = 3; + + /** + * Clamps a grid row index into [0, ROW_COUNT - 1]. + */ + static int clampValidTableRow(const int row); + + /** + * Converts a card's logical table row (0=creatures, 1=noncreatures, 2=lands) + * to the corresponding grid Y coordinate. Cards with tableRow > 2 (e.g., + * instants/sorceries) default to the noncreatures row. + */ + static int tableRowToGridY(int tableRow); + protected: - void addCardImpl(CardItem *card, int x, int y) override; + void addCardImpl(CardState *card, int x, int y) override; /** * @brief Removes a card from view. @@ -38,9 +53,9 @@ protected: * @param position card position * @param cardId id of card to take * @param toNewZone Whether the destination of the card is not the same as the starting zone. Defaults to true - * @return CardItem that has been removed + * @return CardState that has been removed */ - CardItem *takeCard(int position, int cardId, bool toNewZone = true) override; + CardState *takeCard(int position, int cardId, bool toNewZone = true) override; }; #endif // COCKATRICE_TABLE_ZONE_LOGIC_H diff --git a/cockatrice/src/game/zones/view_zone_logic.cpp b/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.cpp similarity index 81% rename from cockatrice/src/game/zones/view_zone_logic.cpp rename to libcockatrice_game/libcockatrice/game/zones/view_zone_logic.cpp index eb3b7715f..db1fa32c3 100644 --- a/cockatrice/src/game/zones/view_zone_logic.cpp +++ b/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.cpp @@ -1,10 +1,11 @@ #include "view_zone_logic.h" -#include "../../client/settings/cache_settings.h" -#include "../../game_graphics/board/card_item.h" -#include "../board/card_list.h" +#include "../board/card_state.h" +#include "../player/player_actions.h" +#include "../player/player_logic.h" #include +#include #include class PlayerLogic; @@ -22,13 +23,19 @@ ZoneViewZoneLogic::ZoneViewZoneLogic(PlayerLogic *_player, bool _revealZone, bool _writeableRevealZone, bool _isReversed, + IInterfaceSettingsProvider *_interfaceSettings, QObject *parent) : CardZoneLogic(_player, _origZone->getName(), false, false, true, parent), origZone(_origZone), numberCards(_numberCards), revealZone(_revealZone), writeableRevealZone(_writeableRevealZone), - isReversed(_isReversed) + isReversed(_isReversed), interfaceSettings(_interfaceSettings) { } +bool ZoneViewZoneLogic::getCloseEmptyCardView() const +{ + return interfaceSettings ? interfaceSettings->getCloseEmptyCardView() : true; +} + /** * Checks if inserting a card at the given position requires an actual new card to be created and added to the view. * Also does any cardId updates that would be required if a card is inserted in that position. @@ -36,7 +43,7 @@ ZoneViewZoneLogic::ZoneViewZoneLogic(PlayerLogic *_player, * Note that this method can end up modifying the cardIds despite returning false. * (for example, if the card is inserted into a hidden portion of the deck while the view is reversed) * - * Make sure to call this method once before calling addCard(), so that you skip creating a new CardItem and calling + * Make sure to call this method once before calling addCard(), so that you skip creating the card and calling * addCard() if it's not required. * * @param x The position to insert the card at. @@ -64,7 +71,7 @@ bool ZoneViewZoneLogic::prepareAddCard(int x) // autoclose check is done both here and in removeCard - if (cards.isEmpty() && !doInsert && SettingsCache::instance().userInterface().getCloseEmptyCardView()) { + if (cards.isEmpty() && !doInsert && getCloseEmptyCardView()) { emit closeView(); } @@ -75,7 +82,7 @@ bool ZoneViewZoneLogic::prepareAddCard(int x) * Make sure prepareAddCard() was called before calling addCard(). * This method assumes we already checked that the card is being inserted into the visible portion */ -void ZoneViewZoneLogic::addCardImpl(CardItem *card, int x, int /*y*/) +void ZoneViewZoneLogic::addCardImpl(CardState *card, int x, int /*y*/) { if (!isReversed) { // if x is negative set it to add at end @@ -144,14 +151,14 @@ void ZoneViewZoneLogic::removeCard(int position, bool toNewZone) return; } - CardItem *card = cards.takeAt(position); - card->deleteLater(); + CardState *card = cards.takeAt(position); + card->deleteView(); // The toNewZone check is to prevent the view from auto-closing if the view contains only a single card and that // card gets dragged within the view. // Another autoclose check is done in prepareAddCard so that the view autocloses if the last card was moved to an // unrevealed portion of the same zone. - if (cards.isEmpty() && SettingsCache::instance().userInterface().getCloseEmptyCardView() && toNewZone) { + if (cards.isEmpty() && getCloseEmptyCardView() && toNewZone) { emit closeView(); return; } @@ -160,12 +167,18 @@ void ZoneViewZoneLogic::removeCard(int position, bool toNewZone) reorganizeCards(); } +void ZoneViewZoneLogic::requestClose() +{ + emit closeView(); +} + void ZoneViewZoneLogic::clearCards() { for (auto card : cards) { - card->deleteLater(); + card->deleteView(); } cards.clear(); + emit cardCountChanged(); } void ZoneViewZoneLogic::setWriteableRevealZone(bool _writeableRevealZone) @@ -177,4 +190,16 @@ void ZoneViewZoneLogic::setWriteableRevealZone(bool _writeableRevealZone) emit removeFromViews(); } writeableRevealZone = _writeableRevealZone; -} \ No newline at end of file +} + +PendingCommand *ZoneViewZoneLogic::prepareZoneDump() +{ + return getPlayer()->getPlayerActions()->prepareZoneDump(this, numberCards); +} + +void ZoneViewZoneLogic::sendZoneDump(PendingCommand *pendingCommand) +{ + if (pendingCommand != nullptr) { + getPlayer()->getPlayerActions()->sendGameCommand(pendingCommand); + } +} diff --git a/cockatrice/src/game/zones/view_zone_logic.h b/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.h similarity index 55% rename from cockatrice/src/game/zones/view_zone_logic.h rename to libcockatrice_game/libcockatrice/game/zones/view_zone_logic.h index 6fae301e4..a29e543a3 100644 --- a/cockatrice/src/game/zones/view_zone_logic.h +++ b/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.h @@ -12,6 +12,8 @@ class PlayerLogic; class QObject; +class PendingCommand; +class IInterfaceSettingsProvider; class ZoneViewZoneLogic : public CardZoneLogic { @@ -26,6 +28,8 @@ private: int numberCards; bool revealZone, writeableRevealZone; bool isReversed; + // Borrowed settings provider (owned by the application). Null outside the client, in which case views stay open. + IInterfaceSettingsProvider *interfaceSettings; public: enum CardAction @@ -41,15 +45,25 @@ public: bool _revealZone, bool _writeableRevealZone, bool _isReversed, + IInterfaceSettingsProvider *interfaceSettings = nullptr, QObject *parent = nullptr); bool prepareAddCard(int x); void removeCard(int position, bool toNewZone); void updateCardIds(CardAction action); + /** + * @brief Asks the graphics view showing this zone to close itself. + * + * Logic code has no handle on the view, so it goes through the logic object the view was built from. + */ + void requestClose(); + /** @brief Removes all cards from the view, without touching anything else. Used in replay rewind */ void clearCards(); + [[nodiscard]] bool getCloseEmptyCardView() const; + int getNumberCards() const { return numberCards; @@ -73,8 +87,25 @@ public: return origZone; } + /** + * @brief Builds the zone-dump command for this view without sending it. + * @return The pending command, or nullptr when there is no zone to dump. + * + * Keeps command construction in game/ so graphics views don't reach + * through the player into PlayerActions to set it up. The response can + * arrive synchronously (local games), so callers must connect to + * PendingCommand::finished before calling sendZoneDump(). + */ + PendingCommand *prepareZoneDump(); + + /** + * @brief Sends a command returned by prepareZoneDump(). + * @param pendingCommand The prepared command; nullptr is ignored. + */ + void sendZoneDump(PendingCommand *pendingCommand); + protected: - void addCardImpl(CardItem *card, int x, int y) override; + void addCardImpl(CardState *card, int x, int y) override; }; #endif // COCKATRICE_VIEW_ZONE_LOGIC_H diff --git a/libcockatrice_interfaces/CMakeLists.txt b/libcockatrice_interfaces/CMakeLists.txt index 3b17569b1..16ea686b5 100644 --- a/libcockatrice_interfaces/CMakeLists.txt +++ b/libcockatrice_interfaces/CMakeLists.txt @@ -24,9 +24,7 @@ set(HEADERS libcockatrice/interfaces/noop_card_set_priority_controller.h ) -qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) - -add_library(libcockatrice_interfaces STATIC ${MOC_SOURCES}) +add_library(libcockatrice_interfaces STATIC ${HEADERS}) target_include_directories(libcockatrice_interfaces PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) diff --git a/libcockatrice_models/libcockatrice/models/database/CMakeLists.txt b/libcockatrice_models/libcockatrice/models/database/CMakeLists.txt index a73e36dbb..2471adacf 100644 --- a/libcockatrice_models/libcockatrice/models/database/CMakeLists.txt +++ b/libcockatrice_models/libcockatrice/models/database/CMakeLists.txt @@ -12,11 +12,9 @@ set(HEADERS token/token_edit_model.h ) -qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) - add_library( libcockatrice_models_database STATIC - ${MOC_SOURCES} + ${HEADERS} card_database_model.cpp card_database_display_model.cpp card/card_completer_proxy_model.cpp diff --git a/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt b/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt index 324bed102..b46275437 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt +++ b/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt @@ -4,10 +4,8 @@ set(CMAKE_AUTORCC ON) set(HEADERS deck_list_model.h deck_list_sort_filter_proxy_model.h) -qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) - add_library( - libcockatrice_models_deck_list STATIC ${MOC_SOURCES} deck_list_model.cpp deck_list_model_custom_zones.cpp + libcockatrice_models_deck_list STATIC ${HEADERS} deck_list_model.cpp deck_list_model_custom_zones.cpp deck_list_sort_filter_proxy_model.cpp ) diff --git a/libcockatrice_network/libcockatrice/network/client/abstract/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/client/abstract/CMakeLists.txt index 53ad524b4..880cef5a1 100644 --- a/libcockatrice_network/libcockatrice/network/client/abstract/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/client/abstract/CMakeLists.txt @@ -6,9 +6,7 @@ set(HEADERS abstract_client.h latency_tracker.h) set(SOURCES abstract_client.cpp latency_tracker.cpp) -qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) - -add_library(libcockatrice_network_client_abstract STATIC ${MOC_SOURCES} ${SOURCES}) +add_library(libcockatrice_network_client_abstract STATIC ${HEADERS} ${SOURCES}) add_dependencies(libcockatrice_network_client_abstract libcockatrice_protocol libcockatrice_network_server_remote) diff --git a/libcockatrice_network/libcockatrice/network/client/local/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/client/local/CMakeLists.txt index 08c12b7f8..4d5676fc0 100644 --- a/libcockatrice_network/libcockatrice/network/client/local/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/client/local/CMakeLists.txt @@ -6,9 +6,7 @@ set(HEADERS local_client.h) set(SOURCES local_client.cpp) -qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) - -add_library(libcockatrice_network_client_local STATIC ${MOC_SOURCES} ${SOURCES}) +add_library(libcockatrice_network_client_local STATIC ${HEADERS} ${SOURCES}) add_dependencies(libcockatrice_network_client_local libcockatrice_network_client_abstract) diff --git a/libcockatrice_network/libcockatrice/network/client/remote/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/client/remote/CMakeLists.txt index c32ca0aa8..ecc822b7c 100644 --- a/libcockatrice_network/libcockatrice/network/client/remote/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/client/remote/CMakeLists.txt @@ -6,9 +6,7 @@ set(HEADERS remote_client.h) set(SOURCES remote_client.cpp) -qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) - -add_library(libcockatrice_network_client_remote STATIC ${MOC_SOURCES} ${SOURCES}) +add_library(libcockatrice_network_client_remote STATIC ${HEADERS} ${SOURCES}) add_dependencies(libcockatrice_network_client_remote libcockatrice_network_client_abstract libcockatrice_protocol) diff --git a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp index e02f5d3d8..7eab58668 100644 --- a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp +++ b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp @@ -40,6 +40,47 @@ class QObject; static const unsigned int protocolVersion = 14; +// Explanations for logins that do not complete but carry no missing-feature list: the +// ones RemoteClient stops itself, plus a strict server asking for a password. They all +// travel with RespClientUpdateRequired and are shown by the same dialog, so their wording +// is kept together here and adjusted as a set. The context has to stay "RemoteClient" for +// the tr() calls at the use sites to find the translations. +namespace +{ +// The server warns that this account still uses the legacy password hash while this client +// holds a scrypt verifier. Sending the verifier would downgrade the proof, so the client +// stops; the login only succeeds after the saved verifier is gone and the user types the +// password, which also upgrades the account. +constexpr const char *const LOGIN_ABORT_AUTH_DOWNGRADE = QT_TRANSLATE_NOOP( + "RemoteClient", + "The server still uses an old password format for this account, so the saved login on this computer cannot be " + "used. Untick 'Save password' in the Connect window and connect; when it opens again, type your password."); +// The account is still on the legacy hash and only the plaintext password can produce it. +constexpr const char *const LOGIN_ABORT_PASSWORD_REQUIRED = + QT_TRANSLATE_NOOP("RemoteClient", + "This account still uses an old password format. Type your password in the Connect window and " + "log in; the account is upgraded automatically."); +// Rejected by PasswordHasher::costParamsAreSane, either for being weak or for being too +// expensive for this client. Nothing on the client side can fix it. +constexpr const char *const LOGIN_ABORT_UNSAFE_SCRYPT_PARAMS = + QT_TRANSLATE_NOOP("RemoteClient", + "The server asked for password settings that Cockatrice will not accept because they are too " + "weak or too demanding. The login was stopped; only the server's staff can change this."); +// Scrypt derivation failed locally: a device resource problem, or server parameters this +// client cannot satisfy. +constexpr const char *const LOGIN_ABORT_VERIFIER_NOT_DERIVED = + QT_TRANSLATE_NOOP("RemoteClient", + "Cockatrice could not prepare your password for this login. Try again; if it keeps happening, " + "the server's password settings may be too demanding for this version of Cockatrice."); +constexpr const char *const LOGIN_ABORT_STORED_VERIFIER_UNUSABLE = + QT_TRANSLATE_NOOP("RemoteClient", + "The saved login for this account cannot be used. Type your password in the Connect window; if " + "'Save password' is ticked, a new saved login is created after you log in."); +constexpr const char *const LOGIN_ABORT_PASSWORD_MISSING = + QT_TRANSLATE_NOOP("RemoteClient", + "This server requires your password to log in. Type it in the Connect window and try again."); +} // namespace + RemoteClient::RemoteClient(QObject *parent, INetworkSettingsProvider *_networkSettingsProvider) : AbstractClient(parent), networkSettingsProvider(_networkSettingsProvider), timeRunning(0), lastDataReceived(0), messageInProgress(false), handshakeStarted(false), usingWebSocket(false), messageLength(0), hashedPassword(), @@ -335,9 +376,7 @@ void RemoteClient::passwordSaltResponse(const Response &response) // 1000-round legacy hash is a downgrade of the KDF — refuse it rather // than hand an attacker a weakly-keyed HMAC they can attack offline. if (resp.needs_migration() && PasswordHasher::parsePasswordVerifier(storedVerifier).isValid) { - emit loginError(Response::RespClientUpdateRequired, - QStringLiteral("Server asked to downgrade authentication for a migrated account."), 0, - {}); + emit loginError(Response::RespClientUpdateRequired, tr(LOGIN_ABORT_AUTH_DOWNGRADE), 0, {}); return; } QByteArray key; @@ -345,8 +384,7 @@ void RemoteClient::passwordSaltResponse(const Response &response) // The account still uses the legacy format; the legacy full hash // is only derivable from the plaintext password. if (password.isEmpty()) { - emit loginError(Response::RespClientUpdateRequired, - QStringLiteral("This account must be logged in with its password once."), 0, {}); + emit loginError(Response::RespClientUpdateRequired, tr(LOGIN_ABORT_PASSWORD_REQUIRED), 0, {}); return; } key = PasswordHasher::computeHash(password, passwordSalt).toUtf8(); @@ -357,8 +395,7 @@ void RemoteClient::passwordSaltResponse(const Response &response) const int r = resp.has_r() ? resp.r() : SCRYPT_R; const int p = resp.has_p() ? resp.p() : SCRYPT_P; if (!PasswordHasher::costParamsAreSane(n, r, p)) { - emit loginError(Response::RespClientUpdateRequired, - QStringLiteral("The server requested unreasonable scrypt cost parameters."), 0, {}); + emit loginError(Response::RespClientUpdateRequired, tr(LOGIN_ABORT_UNSAFE_SCRYPT_PARAMS), 0, {}); return; } // password_salt may carry a legacy backup salt for pre-challenge clients; @@ -367,8 +404,7 @@ void RemoteClient::passwordSaltResponse(const Response &response) resp.has_scrypt_salt() ? QString::fromStdString(resp.scrypt_salt()) : passwordSalt; key = PasswordHasher::deriveKey(password, QByteArray::fromBase64(scryptSalt.toUtf8()), n, r, p); if (key.isEmpty()) { - emit loginError(Response::RespClientUpdateRequired, QStringLiteral("Unable to derive verifier."), 0, - {}); + emit loginError(Response::RespClientUpdateRequired, tr(LOGIN_ABORT_VERIFIER_NOT_DERIVED), 0, {}); return; } derivedVerifier = @@ -376,8 +412,8 @@ void RemoteClient::passwordSaltResponse(const Response &response) } else if (!storedVerifier.isEmpty()) { const PasswordVerifier verifier = PasswordHasher::parsePasswordVerifier(storedVerifier); if (!verifier.isValid) { - emit loginError(Response::RespClientUpdateRequired, QStringLiteral("Stored verifier is invalid."), - 0, {}); + emit loginError(Response::RespClientUpdateRequired, tr(LOGIN_ABORT_STORED_VERIFIER_UNUSABLE), 0, + {}); return; } key = verifier.verifier; @@ -446,8 +482,19 @@ void RemoteClient::loginResponse(const Response &response) missingFeatures << QString::fromStdString(resp.missing_features(i)); } } - emit loginError(response.response_code(), QString::fromStdString(resp.denied_reason_str()), - static_cast(resp.denied_end_time()), missingFeatures); + + QString reasonStr = QString::fromStdString(resp.denied_reason_str()); + // A strict server reports a login that carried no credential as a missing feature, + // because challenge-response needs one. No client update can supply a password, so + // report it like the other local aborts instead of advising an update. + if (response.response_code() == Response::RespClientUpdateRequired && missingFeatures.size() == 1 && + missingFeatures.first() == "challenge_response_auth" && password.isEmpty() && storedVerifier.isEmpty()) { + missingFeatures.clear(); + reasonStr = tr(LOGIN_ABORT_PASSWORD_MISSING); + } + + emit loginError(response.response_code(), reasonStr, static_cast(resp.denied_end_time()), + missingFeatures); setStatus(StatusDisconnecting); } } @@ -837,7 +884,7 @@ void RemoteClient::requestForgotPasswordResponse(const Response &response) emit sigPromptForForgotPasswordReset(); } } else { - emit sigForgotPasswordError(); + emit sigForgotPasswordError(response.response_code()); } doDisconnectFromServer(); @@ -873,7 +920,7 @@ void RemoteClient::submitForgotPasswordResetResponse(const Response &response) derivedVerifier.clear(); } } else { - emit sigForgotPasswordError(); + emit sigForgotPasswordError(response.response_code()); } doDisconnectFromServer(); @@ -908,7 +955,7 @@ void RemoteClient::submitForgotPasswordChallengeResponse(const Response &respons if (response.response_code() == Response::RespOk) { emit sigPromptForForgotPasswordReset(); } else { - emit sigForgotPasswordError(); + emit sigForgotPasswordError(response.response_code()); } doDisconnectFromServer(); diff --git a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.h b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.h index dd91c4c6d..d0c0a18ce 100644 --- a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.h +++ b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.h @@ -54,7 +54,7 @@ signals: void notifyUserAboutUpdate(); void sigRequestForgotPasswordToServer(const QString &hostname, unsigned int port, const QString &_userName); void sigForgotPasswordSuccess(); - void sigForgotPasswordError(); + void sigForgotPasswordError(int responseCode); void sigPromptForForgotPasswordReset(); void sigSubmitForgotPasswordResetToServer(const QString &hostname, unsigned int port, diff --git a/libcockatrice_network/libcockatrice/network/server/local/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/server/local/CMakeLists.txt index 020753d7f..c5f8ee432 100644 --- a/libcockatrice_network/libcockatrice/network/server/local/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/server/local/CMakeLists.txt @@ -6,9 +6,7 @@ set(HEADERS local_server.h local_server_interface.h) set(SOURCES local_server.cpp local_server_interface.cpp) -qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) - -add_library(libcockatrice_network_server_local STATIC ${MOC_SOURCES} ${SOURCES}) +add_library(libcockatrice_network_server_local STATIC ${HEADERS} ${SOURCES}) add_dependencies(libcockatrice_network_server_local libcockatrice_protocol) diff --git a/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt index 2127487d2..26c2be69a 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt @@ -29,11 +29,9 @@ set(HEADERS serverinfo_user_container.h ) -qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) - add_library( libcockatrice_network_server_remote STATIC - ${MOC_SOURCES} + ${HEADERS} game/dump_zone_range.cpp game/server_abstract_participant.cpp game/server_abstract_player.cpp diff --git a/libcockatrice_rng/CMakeLists.txt b/libcockatrice_rng/CMakeLists.txt index 6128e3f6e..c5b19ae85 100644 --- a/libcockatrice_rng/CMakeLists.txt +++ b/libcockatrice_rng/CMakeLists.txt @@ -4,10 +4,8 @@ set(CMAKE_AUTORCC ON) set(HEADERS libcockatrice/rng/rng_abstract.h libcockatrice/rng/rng_sfmt.h libcockatrice/rng/sfmt/SFMT.h) -qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) - add_library( - libcockatrice_rng STATIC ${MOC_SOURCES} libcockatrice/rng/rng_abstract.cpp libcockatrice/rng/rng_sfmt.cpp + libcockatrice_rng STATIC ${HEADERS} libcockatrice/rng/rng_abstract.cpp libcockatrice/rng/rng_sfmt.cpp libcockatrice/rng/sfmt/SFMT.c ) diff --git a/libcockatrice_rng/libcockatrice/rng/sfmt/SFMT.h b/libcockatrice_rng/libcockatrice/rng/sfmt/SFMT.h index 34d9e746f..df114cf9b 100644 --- a/libcockatrice_rng/libcockatrice/rng/sfmt/SFMT.h +++ b/libcockatrice_rng/libcockatrice/rng/sfmt/SFMT.h @@ -113,6 +113,14 @@ union W128_T { /** 128-bit data type */ typedef union W128_T w128_t; +#if defined(__AVX2__) +/* The AVX2 state below names __m256i (and __m512i under AVX-512VL). The SIMD header + above is only pulled in for HAVE_SSE2, so a build that has __AVX2__ from the compiler + (e.g. -march=native) but not HAVE_SSE2 from the build system would otherwise not have + those types declared. */ + #include +#endif + /** * SFMT internal state */ diff --git a/libcockatrice_settings/CMakeLists.txt b/libcockatrice_settings/CMakeLists.txt index 5a2fa42a9..d23fb3350 100644 --- a/libcockatrice_settings/CMakeLists.txt +++ b/libcockatrice_settings/CMakeLists.txt @@ -31,11 +31,9 @@ set(HEADERS libcockatrice/settings/visual_deck_storage_settings.h ) -qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) - add_library( libcockatrice_settings STATIC - ${MOC_SOURCES} + ${HEADERS} libcockatrice/settings/appearance_settings.cpp libcockatrice/settings/cache_storage_settings.cpp libcockatrice/settings/card_database_settings.cpp diff --git a/libcockatrice_utility/CMakeLists.txt b/libcockatrice_utility/CMakeLists.txt index 748f6b90f..55f1daf24 100644 --- a/libcockatrice_utility/CMakeLists.txt +++ b/libcockatrice_utility/CMakeLists.txt @@ -23,6 +23,7 @@ set(UTILITY_HEADERS 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 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/translation_loader.cpp b/libcockatrice_utility/libcockatrice/utility/translation_loader.cpp index 41c6107b3..e5761fcaf 100644 --- a/libcockatrice_utility/libcockatrice/utility/translation_loader.cpp +++ b/libcockatrice_utility/libcockatrice/utility/translation_loader.cpp @@ -5,8 +5,10 @@ #include #include #include +#include #include #include +#include #include #include #include @@ -100,6 +102,60 @@ QList TranslationLoader::availableLanguages(const QString &_prefix, con return sortedLanguages; } +QString TranslationLoader::languageDisplayName(const QString &_lang) +{ + if (_lang.isEmpty()) { + return QString(); + } + + // The native name first, so a speaker finds their own language, then the English one, so everyone + // else does. A language shipped with a .qm file belongs in here: the names used to come from the + // translation files, where a missing translation left the selector blank. + static const QHash displayNames = { + {QStringLiteral("ca"), QStringLiteral("Català (Catalan)")}, + {QStringLiteral("cs"), QStringLiteral("Česky (Czech)")}, + {QStringLiteral("de"), QStringLiteral("Deutsch (German)")}, + {QStringLiteral("el"), QStringLiteral("Ελληνικά (Greek)")}, + {QStringLiteral("en@pirate"), QStringLiteral("English, arr! (Pirate English)")}, + {QStringLiteral("en_US"), QStringLiteral("English (United States)")}, + {QStringLiteral("es"), QStringLiteral("Español (Spanish)")}, + {QStringLiteral("et"), QStringLiteral("Eesti Keel (Estonian)")}, + {QStringLiteral("fi"), QStringLiteral("Suomi (Finnish)")}, + {QStringLiteral("fr"), QStringLiteral("Français (French)")}, + {QStringLiteral("hu"), QStringLiteral("Magyar (Hungarian)")}, + {QStringLiteral("it"), QStringLiteral("Italiano (Italian)")}, + {QStringLiteral("ja"), QStringLiteral("日本語 (Japanese)")}, + {QStringLiteral("ko"), QStringLiteral("한국어 (Korean)")}, + {QStringLiteral("nb"), QStringLiteral("Norsk Bokmål (Norwegian Bokmål)")}, + {QStringLiteral("nl"), QStringLiteral("Nederlands (Dutch)")}, + {QStringLiteral("pl"), QStringLiteral("Polski (Polish)")}, + {QStringLiteral("pt"), QStringLiteral("Português (Portuguese)")}, + {QStringLiteral("pt_BR"), QStringLiteral("Português do Brasil (Brazilian Portuguese)")}, + {QStringLiteral("ru"), QStringLiteral("Русский (Russian)")}, + {QStringLiteral("sr"), QStringLiteral("Srpski (Serbian)")}, + {QStringLiteral("sv"), QStringLiteral("Svenska (Swedish)")}, + {QStringLiteral("tr"), QStringLiteral("Türkçe (Turkish)")}, + {QStringLiteral("yue"), QStringLiteral("粵語 (Cantonese)")}, + {QStringLiteral("zh-Hans"), QStringLiteral("简体中文 (Chinese Simplified)")}, + {QStringLiteral("zh-Hant"), QStringLiteral("繁體中文 (Chinese Traditional)")}, + }; + + const QString displayName = displayNames.value(_lang); + if (!displayName.isEmpty()) { + return displayName; + } + + // A language added after this table still gets a label instead of the blank one this function exists + // to prevent. Qt knows no name for an unknown code and answers with an empty string. + const QString nativeName = QLocale(_lang).nativeLanguageName(); + return nativeName.isEmpty() ? _lang : nativeName; +} + +QString TranslationLoader::defaultLanguageCode() +{ + return QStringLiteral("en_US"); +} + TranslationLoadResult TranslationLoader::loadFrom(QTranslator &_translator, const QString &_nameHint, const QList &_directories) { diff --git a/libcockatrice_utility/libcockatrice/utility/translation_loader.h b/libcockatrice_utility/libcockatrice/utility/translation_loader.h index 645489696..53f419cf2 100644 --- a/libcockatrice_utility/libcockatrice/utility/translation_loader.h +++ b/libcockatrice_utility/libcockatrice/utility/translation_loader.h @@ -74,6 +74,26 @@ TranslationLoadResult loadQt(QTranslator &_translator, const QString &_lang); */ QList availableLanguages(const QString &_prefix, const QList &_directories); +/** + * @brief A human-readable name for a language, e.g. "Deutsch (German)". + * + * The names are data, not translations: a language selector has to be readable before any translation + * is loaded, and its labels must not depend on the translation of the language they name. + * + * @param _lang The language code, e.g. "de" or "pt_BR". + * @return The curated name; for a code it does not know, the native language name; the code itself when + * Qt has no name for it either; an empty string when _lang is empty. + */ +QString languageDisplayName(const QString &_lang); + +/** + * @brief The language a client runs with when none is configured, for preselecting a selector entry. + * + * English is the untranslated default and ships no translation file of its own, so a selector has nothing + * of its own to point at and uses the entry that carries an English translation instead. + */ +QString defaultLanguageCode(); + /** * @brief Loads the <_prefix>_<_lang>.qm translation file from _directories into _translator. * diff --git a/oracle/CMakeLists.txt b/oracle/CMakeLists.txt index 0df345812..587463572 100644 --- a/oracle/CMakeLists.txt +++ b/oracle/CMakeLists.txt @@ -63,7 +63,7 @@ set(oracle_RESOURCES oracle.qrc) # ------------------------ # Qt resources # ------------------------ -qt6_add_resources(oracle_RESOURCES_RCC ${oracle_RESOURCES}) +qt_add_resources(oracle_RESOURCES_RCC ${oracle_RESOURCES}) # ------------------------ # Include directories @@ -108,7 +108,7 @@ set(ORACLE_UNIX_QM_INSTALL_DIR "share/oracle/translations") set(ORACLE_WIN32_QM_INSTALL_DIR "translations") # Qt6 Translations are linked after the executable is created in manual mode -qt6_add_executable( +qt_add_executable( oracle WIN32 MACOSX_BUNDLE @@ -283,7 +283,7 @@ endif() if(Qt6LinguistTools_FOUND) #Qt6 Translations happen after the executable is built up if(UPDATE_TRANSLATIONS) - qt6_add_translations( + qt_add_translations( oracle TS_FILES ${oracle_TS} @@ -293,7 +293,7 @@ if(Qt6LinguistTools_FOUND) oracle_QM ) else() - qt6_add_translations(oracle TS_FILES ${oracle_TS} QM_FILES_OUTPUT_VARIABLE oracle_QM) + qt_add_translations(oracle TS_FILES ${oracle_TS} QM_FILES_OUTPUT_VARIABLE oracle_QM) endif() if(UNIX) @@ -319,4 +319,4 @@ if(Qt6LinguistTools_FOUND) endforeach() endif() -qt6_finalize_target(oracle) +qt_finalize_target(oracle) diff --git a/oracle/src/oraclewizard.cpp b/oracle/src/oraclewizard.cpp index 47afecc06..c33d821a5 100644 --- a/oracle/src/oraclewizard.cpp +++ b/oracle/src/oraclewizard.cpp @@ -32,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 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/src/isl_interface.cpp b/servatrice/src/isl_interface.cpp index 7121bfa48..8635808ac 100644 --- a/servatrice/src/isl_interface.cpp +++ b/servatrice/src/isl_interface.cpp @@ -59,8 +59,7 @@ void IslInterface::sharedCtor(const QSslCertificate &cert, const QSslKey &privat socket->setPrivateKey(privateKey); connect(socket, SIGNAL(readyRead()), this, SLOT(readClient()), Qt::QueuedConnection); - connect(socket, SIGNAL(error(QAbstractSocket::SocketError)), this, - SLOT(catchSocketError(QAbstractSocket::SocketError))); + connect(socket, &QAbstractSocket::errorOccurred, this, &IslInterface::catchSocketError); connect(this, SIGNAL(outputBufferChanged()), this, SLOT(flushOutputBuffer()), Qt::QueuedConnection); } @@ -68,7 +67,7 @@ IslInterface::IslInterface(int _socketDescriptor, const QSslCertificate &cert, const QSslKey &privateKey, Servatrice *_server) - : QObject(), socketDescriptor(_socketDescriptor), server(_server), messageInProgress(false) + : QObject(), serverId(-1), socketDescriptor(_socketDescriptor), server(_server), messageInProgress(false) { sharedCtor(cert, privateKey); } diff --git a/servatrice/src/main.cpp b/servatrice/src/main.cpp index eb019a2b7..4d513d026 100644 --- a/servatrice/src/main.cpp +++ b/servatrice/src/main.cpp @@ -41,6 +41,7 @@ #include #include #include +#include #include #include #include @@ -162,14 +163,15 @@ int main(int argc, char *argv[]) configPath = SettingsCache::guessConfigurationPath(); } else if (!QFile::exists(configPath)) { qCritical() << "Could not find configuration file at" << configPath; - return 1; + return EXIT_FAILURE; } qWarning() << "Using configuration file: " << configPath; settingsCache = new SettingsCache(configPath); loggerThread = new QThread; loggerThread->setObjectName("logger"); - logger = new ServerLogger(logToConsole); + logger = new ServerLogger(logToConsole, settingsCache->value("server/writelog", 1).toBool(), + settingsCache->value("server/logfilters").toString()); logger->moveToThread(loggerThread); loggerThread->start(); @@ -214,8 +216,16 @@ int main(int argc, char *argv[]) std::cerr << "Server quit." << std::endl; std::cerr << "-------------------------" << std::endl; + } else { + // initServer() logged the reason; a failed startup must not look like a clean exit. + std::cerr << "Server initialization failed, exiting." << std::endl; + retval = EXIT_FAILURE; } + // The handler reads the logger and settingsCache globals torn down below; leaving it + // installed lets Qt's own shutdown warnings dereference freed memory. + qInstallMessageHandler(nullptr); + delete smtpClient; delete rng; delete signalhandler; diff --git a/servatrice/src/servatrice.cpp b/servatrice/src/servatrice.cpp index b6eaf11f4..49d8e1e4c 100644 --- a/servatrice/src/servatrice.cpp +++ b/servatrice/src/servatrice.cpp @@ -308,7 +308,8 @@ bool Servatrice::initServer() authenticationMethod = AuthenticationPassword; } else { if (getRegOnlyServerEnabled()) { - qDebug() << "Registration only server enabled but no authentication method defined: Error."; + qCCritical(ServatriceLog) << "Registration only server enabled but no authentication method defined; " + "refusing to start."; return false; } qDebug() << "Authenticating method: none"; @@ -379,7 +380,7 @@ bool Servatrice::initServer() bool dbOpened = servatriceDatabaseInterface->initDatabase( "QMYSQL", getDBHostNameString(), getDBDatabaseNameString(), getDBUserNameString(), getDBPasswordString()); if (!dbOpened) { - qDebug() << "Failed to open database"; + qCCritical(ServatriceLog) << "Failed to open the database; refusing to start."; return false; } updateServerList(); @@ -600,11 +601,11 @@ bool Servatrice::initServer() if (islServer->listen(QHostAddress::Any, static_cast(getISLNetworkPort()))) { qDebug() << "ISL server listening."; } else { - throw QString("islServer->listen()"); + throw islServer->errorString(); } } } catch (QString &error) { - qDebug() << "ERROR --" << error; + qCCritical(ServatriceLog) << "Failed to start the ISL server:" << error; return false; } @@ -641,7 +642,7 @@ bool Servatrice::initServer() if (gameServer->listen(tcpHost, static_cast(getServerTCPPort()))) { qDebug() << "Server listening."; } else { - qDebug() << "gameServer->listen(): Error:" << gameServer->errorString(); + qCCritical(ServatriceLog) << "Failed to listen for TCP clients:" << gameServer->errorString(); return false; } } @@ -657,7 +658,8 @@ bool Servatrice::initServer() if (websocketGameServer->listen(webSocketHost, static_cast(getServerWebSocketPort()))) { qDebug() << "Websocket server listening."; } else { - qDebug() << "websocketGameServer->listen(): Error:" << websocketGameServer->errorString(); + qCCritical(ServatriceLog) << "Failed to listen for websocket clients:" + << websocketGameServer->errorString(); return false; } } diff --git a/servatrice/src/server_logger.cpp b/servatrice/src/server_logger.cpp index 1c83299b8..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,60 +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 { - return logFile != nullptr && settingsCache->value("server/writelog", 1).toBool(); + // 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() @@ -107,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() @@ -141,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 e0be4b712..40df9fd62 100644 --- a/servatrice/src/server_logger.h +++ b/servatrice/src/server_logger.h @@ -14,7 +14,7 @@ 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(); /** @@ -27,6 +27,7 @@ public: 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: @@ -36,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 2cb4df8ba..78593f6dc 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -4651,8 +4651,15 @@ void WebsocketServerSocketInterface::initConnection(void *_socket) connect(socket, SIGNAL(binaryMessageReceived(const QByteArray &)), this, SLOT(binaryMessageReceived(const QByteArray &))); + // QWebSocket::errorOccurred was added in Qt 6.5 (error() is deprecated from 6.5 on), + // so a typed connect cannot compile against the 6.4 floor. +#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0) + connect(socket, &QWebSocket::errorOccurred, this, + [this](QAbstractSocket::SocketError error) { catchSocketError(error); }); +#else connect(socket, SIGNAL(error(QAbstractSocket::SocketError)), this, SLOT(catchSocketError(QAbstractSocket::SocketError))); +#endif connect(socket, SIGNAL(disconnected()), this, SLOT(catchSocketDisconnected())); // Add this object to the server's list of connections before it can receive socket events. diff --git a/servatrice/src/signalhandler.cpp b/servatrice/src/signalhandler.cpp index 32cd5b206..b8a7e3538 100644 --- a/servatrice/src/signalhandler.cpp +++ b/servatrice/src/signalhandler.cpp @@ -73,6 +73,8 @@ void SignalHandler::internalSigHupHandler() logger->rotateLogs(); settingsCache->sync(); + logger->reloadConfig(settingsCache->value("server/writelog", 1).toBool(), + settingsCache->value("server/logfilters").toString()); snHup->setEnabled(true); } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index e2c3252ee..c8d3fbd59 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -178,8 +178,9 @@ cockatrice_add_test(NAME user_data_backup_test SOURCES user_data_backup_test.cpp cockatrice_add_test(NAME password_hash_test SOURCES password_hash_test.cpp LIBS libcockatrice_utility) cockatrice_add_test(NAME report_categories_test SOURCES report_categories_test.cpp LIBS libcockatrice_utility) cockatrice_add_test(NAME warning_categories_test SOURCES warning_categories_test.cpp LIBS libcockatrice_utility) -# The installer's holder probe is Windows-only, but the wording it produces is plain C++ -# and is what a stuck user has to act on, so it is tested everywhere. +# 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 @@ -189,6 +190,15 @@ cockatrice_add_test( 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 @@ -241,6 +251,17 @@ cockatrice_add_test( LABELS server ) +cockatrice_add_test( + NAME + server_logger_test + SOURCES + ${CMAKE_SOURCE_DIR}/servatrice/src/server_logger.cpp + server_logger_test.cpp + INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/servatrice/src + LABELS + server +) cockatrice_add_test( NAME server_card_counter_test @@ -406,6 +427,7 @@ if(TARGET Qt6::lrelease) libcockatrice_utility DEFINITIONS TRANSLATION_LOADER_TEST_DIR="${CMAKE_CURRENT_BINARY_DIR}" + COCKATRICE_TRANSLATIONS_DIR="${CMAKE_SOURCE_DIR}/cockatrice/translations" ) add_dependencies(translation_loader_test translation_loader_test_qm) @@ -434,6 +456,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( @@ -460,6 +498,11 @@ add_subdirectory(movecard_tests) add_subdirectory(oracle) add_subdirectory(settings) +# libcockatrice_game only exists when the client is built. +if(WITH_CLIENT) + add_subdirectory(game) +endif() + # theme_migration_test compiles client sources, whose headers are only reachable from a client build. if(WITH_CLIENT) add_subdirectory(theme_migration) diff --git a/tests/card_zone_algorithms/CMakeLists.txt b/tests/card_zone_algorithms/CMakeLists.txt index 945f8a0ed..2418092de 100644 --- a/tests/card_zone_algorithms/CMakeLists.txt +++ b/tests/card_zone_algorithms/CMakeLists.txt @@ -1,4 +1,4 @@ cockatrice_add_test( NAME card_zone_algorithms_test SOURCES card_zone_algorithms_test.cpp INCLUDE_DIRS - ${CMAKE_SOURCE_DIR}/cockatrice/src/game/zones + ${CMAKE_SOURCE_DIR}/libcockatrice_game/libcockatrice/game/zones ) diff --git a/tests/card_zone_algorithms/card_zone_algorithms_test.cpp b/tests/card_zone_algorithms/card_zone_algorithms_test.cpp index 4f5282235..3d4baa430 100644 --- a/tests/card_zone_algorithms/card_zone_algorithms_test.cpp +++ b/tests/card_zone_algorithms/card_zone_algorithms_test.cpp @@ -16,7 +16,6 @@ struct MockCard bool cardRefWasCalled = false; bool resetStateCalled = false; bool resetStateKeepAnnotations = false; - bool visibleSet = false; void setId(int id) { @@ -35,11 +34,6 @@ struct MockCard resetStateCalled = true; resetStateKeepAnnotations = keepAnnotations; } - - void setVisible(bool visible) - { - visibleSet = visible; - } }; class MockCardList @@ -109,7 +103,6 @@ TEST_F(AddCardAlgorithmTest, ContentsKnownPreservesIdentity) EXPECT_FALSE(card.idWasCalled); EXPECT_FALSE(card.cardRefWasCalled); - EXPECT_TRUE(card.visibleSet); } TEST_F(AddCardAlgorithmTest, ContentsUnknownClearsIdentity) diff --git a/tests/carddatabase/carddatabase_test.cpp b/tests/carddatabase/carddatabase_test.cpp index eb522434d..22cbe9074 100644 --- a/tests/carddatabase/carddatabase_test.cpp +++ b/tests/carddatabase/carddatabase_test.cpp @@ -17,10 +17,12 @@ #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -608,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/game/CMakeLists.txt b/tests/game/CMakeLists.txt new file mode 100644 index 000000000..4a7492c71 --- /dev/null +++ b/tests/game/CMakeLists.txt @@ -0,0 +1,28 @@ +add_executable( + game_smoke_test + ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/cache_settings.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/card_counter_settings.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/shortcuts_settings.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/client/network/update/client/release_channel.cpp + ${VERSION_STRING_CPP} + game_smoke_test.cpp +) + +# SettingsCache is still reached from the game logic, but it lives in the application sources +# rather than in libcockatrice_settings, so the client settings sources are compiled in here +# exactly like loader_local_matching_test and theme_migration_test do. +target_include_directories(game_smoke_test PRIVATE ${CMAKE_SOURCE_DIR}/cockatrice/src) + +target_link_libraries( + game_smoke_test + PRIVATE libcockatrice_game + PRIVATE Threads::Threads + PRIVATE ${GTEST_BOTH_LIBRARIES} + PRIVATE ${TEST_QT_MODULES} +) + +add_test(NAME game_smoke_test COMMAND game_smoke_test) + +if(NOT GTEST_FOUND) + add_dependencies(game_smoke_test gtest) +endif() diff --git a/tests/game/game_smoke_test.cpp b/tests/game/game_smoke_test.cpp new file mode 100644 index 000000000..356d544bf --- /dev/null +++ b/tests/game/game_smoke_test.cpp @@ -0,0 +1,106 @@ +/** + * @file game_smoke_test.cpp + * + * Links the game logic library into a target that contains no graphics code at all, and drives + * the two seams the graphics layer used to own: the attachment graph on CardState and the + * card-creation request a zone makes so the graphics side can build the matching card item. + */ + +#include "gtest/gtest.h" +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + +CardRef cardRefFor(const QString &name) +{ + CardRef cardRef; + cardRef.name = name; + return cardRef; +} + +} // namespace + +TEST(CardStateTest, AttachmentGraphKeepsBothEndsConsistent) +{ + CardState host(nullptr, nullptr, cardRefFor(QStringLiteral("Host")), 1); + CardState guest(nullptr, nullptr, cardRefFor(QStringLiteral("Guest")), 2); + + guest.setAttachedTo(&host); + host.addAttachedCard(&guest); + + EXPECT_EQ(guest.getAttachedTo(), &host); + ASSERT_EQ(host.getAttachedCards().size(), 1); + EXPECT_EQ(host.getAttachedCards().first(), &guest); + + host.removeAttachedCard(&guest); + guest.setAttachedTo(nullptr); + + EXPECT_TRUE(host.getAttachedCards().isEmpty()); + EXPECT_EQ(guest.getAttachedTo(), nullptr); +} + +TEST(CardZoneLogicTest, AddsAndTakesCardsWithoutAGraphicsSide) +{ + HandZoneLogic zone(nullptr, QStringLiteral("hand"), false, false, true); + CardState card(nullptr, nullptr, cardRefFor(QStringLiteral("Lightning Bolt")), 7); + + QSignalSpy cardAddedSpy(&zone, &CardZoneLogic::cardAdded); + QSignalSpy cardCountSpy(&zone, &CardZoneLogic::cardCountChanged); + + zone.addCard(&card, false, 0); + + ASSERT_EQ(zone.getCards().size(), 1); + EXPECT_EQ(zone.getCards().first(), &card); + EXPECT_EQ(card.getZone(), &zone); + EXPECT_EQ(cardAddedSpy.count(), 1); + EXPECT_GE(cardCountSpy.count(), 1); + + EXPECT_EQ(zone.takeCard(0, 7), &card); + EXPECT_TRUE(zone.getCards().isEmpty()); + EXPECT_EQ(card.getId(), 7); +} + +TEST(CardZoneLogicTest, AsksTheGraphicsSideToCreateCards) +{ + HandZoneLogic zone(nullptr, QStringLiteral("library"), false, true, true); + QSignalSpy requestSpy(&zone, &CardZoneLogic::createCardRequested); + + ServerInfo_Card cardInfo; + cardInfo.set_name("Lightning Bolt"); + cardInfo.set_id(7); + cardInfo.set_x(0); + cardInfo.set_y(1); + + zone.createCardRequested(cardInfo, true); + + ASSERT_EQ(requestSpy.count(), 1); + const QList arguments = requestSpy.takeFirst(); + EXPECT_EQ(arguments.at(0).value().id(), 7); + EXPECT_TRUE(arguments.at(1).toBool()); +} + +int main(int argc, char **argv) +{ + // Keep SettingsCache reads and writes away from the real user profile: the game logic still + // reaches for a handful of settings, and the client settings sources linked into this test + // would otherwise touch the developer's own configuration. + QStandardPaths::setTestModeEnabled(true); + + QTemporaryDir home; + if (home.isValid()) { + qputenv("HOME", home.path().toLocal8Bit()); + } + + QCoreApplication app(argc, argv); + QLoggingCategory::setFilterRules("settings_cache.*=false"); + + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/installer_close_server_test.cpp b/tests/installer_close_server_test.cpp new file mode 100644 index 000000000..158d0ce47 --- /dev/null +++ b/tests/installer_close_server_test.cpp @@ -0,0 +1,101 @@ +#include "installer_close_name.h" +#include "installer_close_server.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +constexpr int HANDSHAKE_TIMEOUT_MS = 5000; + +/// The bytes both ends exchange, from the header they both include. The separate +/// WireCommandIsThePinnedBytes test is what catches an accidental change to the literal. +const QByteArray CLOSE_COMMAND = QByteArray::fromRawData(INSTALLER_CLOSE_COMMAND, sizeof(INSTALLER_CLOSE_COMMAND) - 1); + +/// Scopes the socket to this test process, so a Cockatrice the developer happens to be +/// running cannot claim the name and the test cannot close it. Kept short on purpose: +/// QLocalServer on Unix puts the socket file under QDir::tempPath(), and macOS caps that +/// path at 104 bytes (sun_path). A longer name passes on Linux (/tmp) and fails to listen +/// on the macOS CI runners, whose temp directory is already ~48 characters. +QByteArray testUserName() +{ + return QByteArrayLiteral("t") + QByteArray::number(QCoreApplication::applicationPid()); +} +} // namespace + +// The probe builds the name from the process id and the account name it reads from the +// environment; the client builds it with scopedLocalServerName(). They have to produce the +// same string or the installer can never reach the instance it is trying to close. +TEST(InstallerCloseServerTest, SocketNameMatchesTheProbeForTheSameProcessAndUser) +{ + constexpr unsigned long PROCESS_ID = 4242; + const QString userName = QStringLiteral("installer-test"); + const QString expected = QStringLiteral("CockatriceInstallerClose-4242-installer-test"); + + EXPECT_EQ(expected, InstallerCloseServer::socketNameForUser(PROCESS_ID, userName)); + EXPECT_EQ(expected.toStdWString(), installerCloseSocketName(PROCESS_ID, userName.toStdWString())); +} + +TEST(InstallerCloseServerTest, SocketNameScopesTheProcessIdByTheCurrentUser) +{ + qputenv("USER", testUserName()); + + EXPECT_EQ(QStringLiteral("CockatriceInstallerClose-4242-") + QString::fromLatin1(testUserName()), + InstallerCloseServer::socketName(4242)); +} + +// The line is a wire format shared with the Windows-only probe, which cannot be compiled +// into this test. Pinning the exact bytes here makes a change to the shared header a +// deliberate protocol change rather than a typo that only shows up in the installer. +TEST(InstallerCloseServerTest, WireCommandIsThePinnedBytes) +{ + EXPECT_EQ(QByteArrayLiteral("COCKATRICE_INSTALLER_CLOSE\n"), CLOSE_COMMAND); + EXPECT_EQ(27, CLOSE_COMMAND.size()); +} + +// The installer only writes the command; the listening instance is what turns it into a +// close. Anything but that exact line must not be treated as a request. +TEST(InstallerCloseServerTest, CloseRequestIsEmittedForTheProbeCommand) +{ + qputenv("USER", testUserName()); + + InstallerCloseServer server; + ASSERT_TRUE(server.listen()) << "the installer close socket could not be started"; + + int closeRequests = 0; + QObject::connect(&server, &InstallerCloseServer::closeRequested, &server, [&closeRequests]() { ++closeRequests; }); + + QLocalSocket socket; + socket.connectToServer(InstallerCloseServer::socketName(QCoreApplication::applicationPid())); + ASSERT_TRUE(socket.waitForConnected(HANDSHAKE_TIMEOUT_MS)) << "could not reach the installer close socket"; + ASSERT_EQ(CLOSE_COMMAND.size(), socket.write(CLOSE_COMMAND)) << "the close request was not accepted"; + + // No waitForBytesWritten(): on Windows QLocalSocket completes the write through the + // event dispatcher, and this test runs the server in the same thread, so a blocking + // wait would hold the loop the server needs to accept and read. The loop below is the + // real handshake: it flushes the write and delivers the server's readyRead. + QEventLoop loop; + QTimer timeout; + timeout.setSingleShot(true); + QObject::connect(&timeout, &QTimer::timeout, &loop, &QEventLoop::quit); + QObject::connect(&server, &InstallerCloseServer::closeRequested, &loop, &QEventLoop::quit); + timeout.start(HANDSHAKE_TIMEOUT_MS); + loop.exec(); + + EXPECT_EQ(1, closeRequests) << "the probe's line must reach the close handler exactly once"; +} + +int main(int argc, char **argv) +{ + QCoreApplication app(argc, argv); + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/payload_paths_test.cpp b/tests/payload_paths_test.cpp new file mode 100644 index 000000000..c4576d751 --- /dev/null +++ b/tests/payload_paths_test.cpp @@ -0,0 +1,57 @@ +#include "payload_paths.h" + +#include +#include + +namespace +{ +/// The directory part of a path spelled with the same case as the install directory, which +/// is the comparison the probe makes once both sides are folded - see isSameDirectory in +/// restart_manager_holders.cpp. Kept as a plain string here so the shape of a path can be +/// tested without Windows. +bool runsFrom(const std::wstring &imagePath, const std::wstring &installDir) +{ + return parentDirectoryOf(imagePath) == installDir; +} +} // namespace + +TEST(PayloadPaths, SplitsAnImagePathIntoItsDirectoryAndName) +{ + EXPECT_EQ(fileNameOf(L"D:\\Games\\Cockatrice\\cockatrice.exe"), std::wstring(L"cockatrice.exe")); + EXPECT_EQ(parentDirectoryOf(L"D:\\Games\\Cockatrice\\cockatrice.exe"), std::wstring(L"D:\\Games\\Cockatrice")); + EXPECT_EQ(fileNameOf(L"cockatrice.exe"), std::wstring(L"cockatrice.exe")); + EXPECT_EQ(parentDirectoryOf(L"cockatrice.exe"), std::wstring()); + EXPECT_EQ(parentDirectoryOf(L"D:/Games/Cockatrice/cockatrice.exe"), std::wstring(L"D:/Games/Cockatrice")); +} + +TEST(PayloadPaths, OnlyAnImageInTheInstallDirectoryRunsFromIt) +{ + const std::wstring installDir = L"D:\\Games\\Cockatrice"; + EXPECT_TRUE(runsFrom(L"D:\\Games\\Cockatrice\\cockatrice.exe", installDir)); + // A folder the user keeps other applications in is not the installation, and neither is + // a sibling whose name merely begins with the same letters. + EXPECT_FALSE(runsFrom(L"D:\\Games\\Cockatrice\\old\\cockatrice.exe", installDir)); + EXPECT_FALSE(runsFrom(L"D:\\Games\\Cockatrice-old\\cockatrice.exe", installDir)); + EXPECT_FALSE(runsFrom(L"D:\\Games\\cockatrice.exe", installDir)); +} + +TEST(PayloadPaths, KnowsTheProgramsThePayloadInstalls) +{ + EXPECT_TRUE(isPayloadProgram(L"cockatrice.exe")); + EXPECT_TRUE(isPayloadProgram(L"oracle.exe")); + EXPECT_TRUE(isPayloadProgram(L"servatrice.exe")); + EXPECT_TRUE(isPayloadProgram(L"COCKATRICE.EXE")); + EXPECT_TRUE(isPayloadProgram(L"Servatrice.Exe")); +} + +TEST(PayloadPaths, LeavesEveryOtherProgramAlone) +{ + // A program the user keeps in the install directory, the uninstaller, a service host and + // a name that only looks like a payload program are all not instances of the payload. + EXPECT_FALSE(isPayloadProgram(L"uninstall.exe")); + EXPECT_FALSE(isPayloadProgram(L"tool.exe")); + EXPECT_FALSE(isPayloadProgram(L"svchost.exe")); + EXPECT_FALSE(isPayloadProgram(L"cockatrice2.exe")); + EXPECT_FALSE(isPayloadProgram(L"mycockatrice.exe")); + EXPECT_FALSE(isPayloadProgram(L"")); +} diff --git a/tests/server_card_counter_test.cpp b/tests/server_card_counter_test.cpp index 6308d80b9..6b884864d 100644 --- a/tests/server_card_counter_test.cpp +++ b/tests/server_card_counter_test.cpp @@ -8,10 +8,14 @@ #include #include #include +#include #include #include #include +// Referenced by other translation units in the server_remote library. +RNG_Abstract *rng = nullptr; + TEST(ServerCardCounter, IncrementNewCounter) { Server_Card card(CardRef{"TestCard", ""}, 1, 0, 0); diff --git a/tests/server_logger_test.cpp b/tests/server_logger_test.cpp new file mode 100644 index 000000000..3c2d047cc --- /dev/null +++ b/tests/server_logger_test.cpp @@ -0,0 +1,201 @@ +#include "server_logger.h" + +#include "gtest/gtest.h" +#include +#include +#include +#include +#include +#include + +namespace +{ + +/** + * @brief Redirects std::cout for the lifetime of the object so the logger's console + * sink can be asserted on. + */ +class CoutCapture +{ +public: + CoutCapture() : previousBuffer(std::cout.rdbuf(captured.rdbuf())) + { + } + ~CoutCapture() + { + std::cout.rdbuf(previousBuffer); + } + + QString text() const + { + return QString::fromStdString(captured.str()); + } + +private: + std::ostringstream captured; + std::streambuf *previousBuffer; +}; + +// The console sink is written by the logger thread; drive it directly here, as the event +// loop would in the server. +void flushLogger(ServerLogger &logger) +{ + QMetaObject::invokeMethod(&logger, "flushBuffer", Qt::DirectConnection); +} + +// Regression: --log-to-console used to be gated behind a successfully opened logfile, so an +// empty server/logfile silenced every diagnostic, including critical startup errors. +TEST(ServerLoggerTest, ConsoleSinkDoesNotDependOnLogFile) +{ + ServerLogger logger(true, true, QString()); + logger.startLog(QString()); + + CoutCapture capture; + logger.logMessage("could not open the database"); + flushLogger(logger); + + EXPECT_TRUE(capture.text().contains("could not open the database")) << capture.text().toStdString(); +} + +// writelog only describes the logfile; disabling it must not silence the console. +TEST(ServerLoggerTest, ConsoleSinkSurvivesDisabledFileLog) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + + ServerLogger logger(true, false, QString()); + logger.startLog(dir.filePath("server.log")); + + CoutCapture capture; + logger.logMessage("startup failed"); + flushLogger(logger); + + EXPECT_TRUE(capture.text().contains("startup failed")) << capture.text().toStdString(); +} + +// logfilters selects what goes into the logfile; the console is not filtered. +TEST(ServerLoggerTest, ConsoleSinkIgnoresLogFilters) +{ + ServerLogger logger(true, true, "database"); + logger.startLog(QString()); + + CoutCapture capture; + logger.logMessage("unrelated message"); + flushLogger(logger); + + EXPECT_TRUE(capture.text().contains("unrelated message")) << capture.text().toStdString(); +} + +TEST(ServerLoggerTest, StaysQuietWhenConsoleIsDisabled) +{ + ServerLogger logger(false, false, QString()); + logger.startLog(QString()); + + CoutCapture capture; + logger.logMessage("not for the console"); + flushLogger(logger); + + EXPECT_TRUE(capture.text().isEmpty()) << capture.text().toStdString(); +} + +TEST(ServerLoggerTest, IsLoggingEnabledMirrorsTheSinks) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + + // The console is a sink of its own: messages must not be discarded for want of a logfile. + { + ServerLogger logger(true, false, QString()); + EXPECT_TRUE(logger.isLoggingEnabled()); + } + + // The logfile sink needs an open file and writelog; reloadConfig() carries the SIGHUP change. + { + ServerLogger logger(false, true, QString()); + EXPECT_FALSE(logger.isLoggingEnabled()) << "no logfile is open yet"; + logger.startLog(dir.filePath("enabled.log")); + EXPECT_TRUE(logger.isLoggingEnabled()); + logger.reloadConfig(false, QString()); + EXPECT_FALSE(logger.isLoggingEnabled()) << "writelog=false leaves no sink enabled"; + } +} + +TEST(ServerLoggerTest, WritesMatchingFilteredMessagesToTheLogFile) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString logPath = dir.filePath("server.log"); + + { + ServerLogger logger(false, true, "database"); + logger.startLog(logPath); + logger.logMessage("database is unavailable"); + logger.logMessage("unrelated message"); + } + + QFile logFile(logPath); + ASSERT_TRUE(logFile.open(QIODevice::ReadOnly)); + const QString contents = QString::fromUtf8(logFile.readAll()); + EXPECT_TRUE(contents.contains("database is unavailable")) << contents.toStdString(); + EXPECT_FALSE(contents.contains("unrelated message")) << contents.toStdString(); +} + +// SIGHUP reloads the configuration; the logger must pick up the new filter. +TEST(ServerLoggerTest, ReloadConfigAppliesANewFilter) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString logPath = dir.filePath("server.log"); + + { + ServerLogger logger(false, true, "database"); + logger.startLog(logPath); + logger.logMessage("filtered out"); + logger.reloadConfig(true, QString()); + logger.logMessage("kept after reload"); + } + + QFile logFile(logPath); + ASSERT_TRUE(logFile.open(QIODevice::ReadOnly)); + const QString contents = QString::fromUtf8(logFile.readAll()); + EXPECT_FALSE(contents.contains("filtered out")) << contents.toStdString(); + EXPECT_TRUE(contents.contains("kept after reload")) << contents.toStdString(); +} + +TEST(ServerLoggerTest, DisabledFileLogLeavesTheLogFileEmpty) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString logPath = dir.filePath("server.log"); + + { + ServerLogger logger(false, false, QString()); + logger.startLog(logPath); + logger.logMessage("not for the logfile"); + } + + QFile logFile(logPath); + ASSERT_TRUE(logFile.open(QIODevice::ReadOnly)); + EXPECT_TRUE(logFile.readAll().isEmpty()); +} + +// A filter list consisting only of separators activates filtering with no entries; the old +// trimmed-empty check suppressed the logfile in that case and this preserves it. +TEST(ServerLoggerTest, DegenerateFilterSuppressesTheLogFile) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString logPath = dir.filePath("server.log"); + + { + ServerLogger logger(false, true, ","); + logger.startLog(logPath); + logger.logMessage("anything"); + } + + QFile logFile(logPath); + ASSERT_TRUE(logFile.open(QIODevice::ReadOnly)); + EXPECT_TRUE(logFile.readAll().isEmpty()); +} + +} // namespace diff --git a/tests/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 index 3098669cc..99e7d5045 100644 --- a/tools/installer-holder-probe/CMakeLists.txt +++ b/tools/installer-holder-probe/CMakeLists.txt @@ -1,13 +1,28 @@ -# Windows-only. The installer runs this on the abort path of a failed purge to name the -# applications and services holding the install directory open (Restart Manager); see -# the LogProcessesUsingInstallDir function in cmake/NSIS.template.in. It ships inside -# the installer 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 holder_line.cpp main.cpp restart_manager_holders.cpp) +# 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( 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.h b/tools/installer-holder-probe/holder_line.h index 7fcc92375..675d79fc6 100644 --- a/tools/installer-holder-probe/holder_line.h +++ b/tools/installer-holder-probe/holder_line.h @@ -26,12 +26,15 @@ 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: the abort dialog puts it after a fixed paragraph and - * before the path of the installer log, inside an NSIS string of 1024 characters. Windows - * caps one application or service name well below this budget, so it bounds the list - * without ever cutting a single holder short. + * 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 = 400; +inline constexpr std::size_t HOLDER_LINE_CHAR_MAX = 320; /** * @brief Renders one holder the way the user has to recognise it, e.g. 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 index 3b5bb5d22..31901a0ec 100644 --- a/tools/installer-holder-probe/main.cpp +++ b/tools/installer-holder-probe/main.cpp @@ -1,6 +1,11 @@ +#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 @@ -12,16 +17,88 @@ namespace /// 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 @@ -38,11 +115,29 @@ void writeLine(const std::wstring &text) WriteFile(GetStdHandle(STD_OUTPUT_HANDLE), narrow.data(), static_cast(narrow.size()), &written, nullptr); } -std::wstring installDirFromEnvironment() +/// 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(INSTALL_DIR_VARIABLE, buffer.data(), size); + const DWORD length = GetEnvironmentVariableW(name, buffer.data(), size); if (length == 0) { return {}; } @@ -52,9 +147,71 @@ std::wstring installDirFromEnvironment() } 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 main(int argumentCount, char **arguments) { const std::wstring installDir = installDirFromEnvironment(); if (installDir.empty()) { @@ -62,17 +219,82 @@ int main() return EXIT_NOT_CONFIGURED; } - std::vector holders; - const unsigned long error = findProcessesUsingFiles(collectProbeFiles(installDir), holders); - if (error != ERROR_SUCCESS) { - writeLine(L"the Restart Manager lookup failed with win32 error " + std::to_wstring(error)); - return EXIT_PROBE_FAILED; + 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()) { - // No output: the installer words the "nothing is holding them" case, and it - // owns the message either way. + // 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; } - writeLine(formatHolders(holders)); - return EXIT_HOLDERS_FOUND; + 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 index 39f6a0049..21ddd6988 100644 --- a/tools/installer-holder-probe/restart_manager_holders.cpp +++ b/tools/installer-holder-probe/restart_manager_holders.cpp @@ -5,10 +5,12 @@ // here is a requirement; Regroup would sort them the other way round. #include #include +#include // clang-format on #include #include +#include namespace { @@ -20,6 +22,11 @@ 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) { @@ -70,10 +77,30 @@ std::wstring imagePathOf(unsigned long processId) return imagePath; } -std::wstring fileNameOf(const std::wstring &path) +/// @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) { - const std::size_t separator = path.find_last_of(L"\\/"); - return separator == std::wstring::npos ? path : path.substr(separator + 1); + 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. @@ -96,13 +123,44 @@ Holder holderOf(const RM_PROCESS_INFO &info) } } // namespace -std::vector collectProbeFiles(const std::wstring &installDir) +std::vector collectPayloadFiles(const std::wstring &installDir) { std::vector files; - // depth 0, maxDepth 0: the install root's files and nothing below it. Its data - // directory holds downloaded pictures, which the purge does not touch and which - // would turn this into a walk over thousands of files. - collectFilesOf(installDir, 0, 0, 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); } @@ -158,3 +216,90 @@ unsigned long findProcessesUsingFiles(const std::vector &files, st 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 index d69fc0259..e966b70b8 100644 --- a/tools/installer-holder-probe/restart_manager_holders.h +++ b/tools/installer-holder-probe/restart_manager_holders.h @@ -2,22 +2,25 @@ #define RESTART_MANAGER_HOLDERS_H #include "holder_line.h" +#include "payload_paths.h" #include #include /** - * @brief Lists the files a failed purge can be holding up, as full paths. + * @brief Lists the payload files, as full paths. * - * Everything directly in @p installDir plus everything below its Plugins directory: - * those are the two things the purge removes, so they are the two sets of files a - * holder can be holding. Restart Manager refuses a directory with ERROR_ACCESS_DENIED, - * which is why the probe registers files and this has to name them first. + * 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. * - * Missing or unreadable directories are skipped rather than reported, and the list is - * capped: this runs on the abort path and must not walk a tree of downloaded pictures. + * 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 collectProbeFiles(const std::wstring &installDir); +std::vector collectPayloadFiles(const std::wstring &installDir); /** * @brief Names the applications and services that have one of @p files open or mapped. @@ -32,4 +35,54 @@ std::vector collectProbeFiles(const std::wstring &installDir); */ 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 3476cf9d2..024293d34 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",